#
# Copyright 2021-2023 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License version 3 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""Executor module."""
from __future__ import annotations
import contextlib
import hashlib
import logging
import re
from abc import ABC, abstractmethod
from os import PathLike
from typing import (
IO,
TYPE_CHECKING,
Any,
Literal,
TypeAlias,
overload,
)
from typing_extensions import Buffer
import craft_providers.util.temp_paths
if TYPE_CHECKING:
import io
import pathlib
import subprocess
from collections.abc import Callable, Collection, Generator, Iterable
from craft_providers.errors import ProviderError
logger = logging.getLogger(__name__)
MAX_INSTANCE_NAME_LENGTH = 63
StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes]
[docs]
class Executor(ABC):
"""Interfaces to execute commands and move data in/out of an environment."""
[docs]
@abstractmethod
def execute_popen(
self,
command: list[str],
*,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
timeout: float | None = None,
**kwargs: Any,
) -> subprocess.Popen[str] | subprocess.Popen[bytes]:
"""Execute a command in instance, using subprocess.Popen().
The process' environment will inherit the execution environment's
default environment (PATH, etc.), but can be additionally configured via
env parameter.
:param command: Command to execute.
:param cwd: Working directory for the process inside the instance.
:param env: Additional environment to set for process.
:param timeout: Timeout (in seconds) for the command.
:param kwargs: Additional keyword arguments to pass.
:returns: Popen instance.
"""
# This is modified from typeshed. The actual implementation doesn't have all this,
# but that's just because it passes it through to subprocess.run.
# https://github.com/python/typeshed/blob/cb2c371676f8f4a6a85b0d65c672dae308f51ca6/stdlib/subprocess.pyi#L298
@overload
def execute_run(
self,
command: list[str],
*,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: None | int | IO[Any] = None,
stdout: None | int | IO[Any] = None,
stderr: None | int | IO[Any] = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
universal_newlines: bool | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
capture_output: bool = False,
check: bool = False,
encoding: str | None = None,
errors: str | None = None,
input: str | None = None,
text: Literal[True],
timeout: float | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
**kwargs: Any,
) -> subprocess.CompletedProcess[str]: ...
@overload
def execute_run(
self,
command: list[str],
*,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: None | int | IO[Any] = None,
stdout: None | int | IO[Any] = None,
stderr: None | int | IO[Any] = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
universal_newlines: bool | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
capture_output: bool = False,
check: bool = False,
encoding: str,
errors: str | None = None,
input: str | None = None,
text: bool | None = None,
timeout: float | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
**kwargs: Any,
) -> subprocess.CompletedProcess[str]: ...
@overload
def execute_run(
self,
command: list[str],
*,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: None | int | IO[Any] = None,
stdout: None | int | IO[Any] = None,
stderr: None | int | IO[Any] = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
universal_newlines: bool | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
capture_output: bool = False,
check: bool = False,
encoding: str | None = None,
errors: str,
input: str | None = None,
text: bool | None = None,
timeout: float | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
**kwargs: Any,
) -> subprocess.CompletedProcess[str]: ...
@overload
def execute_run(
self,
command: list[str],
*,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: None | int | IO[Any] = None,
stdout: None | int | IO[Any] = None,
stderr: None | int | IO[Any] = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
universal_newlines: Literal[True],
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
capture_output: bool = False,
check: bool = False,
encoding: str | None = None,
errors: str | None = None,
input: str | None = None,
text: bool | None = None,
timeout: float | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
**kwargs: Any,
) -> subprocess.CompletedProcess[str]: ...
@overload
def execute_run(
self,
command: list[str],
*,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: None | int | IO[Any] = None,
stdout: None | int | IO[Any] = None,
stderr: None | int | IO[Any] = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
universal_newlines: Literal[False] | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
capture_output: bool = False,
check: bool = False,
encoding: None = None,
errors: None = None,
input: Buffer | None = None,
text: Literal[False] | None = None,
timeout: float | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
**kwargs: Any,
) -> subprocess.CompletedProcess[bytes]: ...
@overload
def execute_run(
self,
command: list[str],
*,
bufsize: int = -1,
executable: StrOrBytesPath | None = None,
stdin: None | int | IO[Any] = None,
stdout: None | int | IO[Any] = None,
stderr: None | int | IO[Any] = None,
preexec_fn: Callable[[], Any] | None = None,
close_fds: bool = True,
shell: bool = False,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
universal_newlines: bool | None = None,
creationflags: int = 0,
restore_signals: bool = True,
start_new_session: bool = False,
pass_fds: Collection[int] = (),
capture_output: bool = False,
check: bool = False,
encoding: str | None = None,
errors: str | None = None,
input: Buffer | str | None = None,
text: bool | None = None,
timeout: float | None = None,
user: str | int | None = None,
group: str | int | None = None,
extra_groups: Iterable[str | int] | None = None,
umask: int = -1,
pipesize: int = -1,
**kwargs: Any,
) -> subprocess.CompletedProcess[Any]: ...
[docs]
@abstractmethod
def execute_run(
self,
command: list[str],
*,
cwd: pathlib.PurePath | None = None,
env: dict[str, str | None] | None = None,
timeout: float | None = None,
text: bool | None = None,
**kwargs: Any,
) -> subprocess.CompletedProcess[Any]:
"""Execute a command using subprocess.run().
The process' environment will inherit the execution environment's
default environment (PATH, etc.), but can be additionally configured via
env parameter.
:param command: Command to execute.
:param cwd: Working directory for the process inside the instance.
:param env: Additional environment to set for process.
:param timeout: Timeout (in seconds) for the command.
:param check: Raise an exception if the command fails.
:param kwargs: Keyword args to pass to subprocess.run().
:returns: Completed process.
:raises subprocess.CalledProcessError: if command fails and check is True.
"""
[docs]
@abstractmethod
def pull_file(self, *, source: pathlib.PurePath, destination: pathlib.Path) -> None:
"""Copy a file from the environment to host.
:param source: Environment file to copy.
:param destination: Host file path to copy to. Parent directory
(destination.parent) must exist.
:raises FileNotFoundError: If source file or destination's parent
directory does not exist.
:raises ProviderError: On error copying file.
"""
@overload
@contextlib.contextmanager
def temporarily_pull_file(
self, *, source: pathlib.PurePath, missing_ok: Literal[False] = False
) -> Generator[pathlib.Path, None, None]: ...
@overload
@contextlib.contextmanager
def temporarily_pull_file(
self, *, source: pathlib.PurePath, missing_ok: Literal[True]
) -> Generator[pathlib.Path | None, None, None]: ...
[docs]
@contextlib.contextmanager
def temporarily_pull_file(
self, *, source: pathlib.PurePath, missing_ok: bool = False
) -> Generator[pathlib.Path | None, None, None]:
"""Copy a file from the environment to a temporary file in the host.
This is mainly a layer above `pull_file` that pulls the file into a
temporary path which is cleaned later.
Works as a context manager, provides the file path in the host as target.
The temporary file is stored in the home directory where Multipass has access.
:param source: Environment file to copy.
:param missing_ok: Do not raise an error if the file does not exist in the
environment; in this case the target will be None.
:raises FileNotFoundError: If source file or destination's parent
directory does not exist (and `missing_ok` is False).
:raises ProviderError: On error copying file content.
"""
with craft_providers.util.temp_paths.home_temporary_file() as tmp_file:
try:
self.pull_file(source=source, destination=tmp_file)
except FileNotFoundError:
if missing_ok:
yield None
else:
raise
else:
yield tmp_file
[docs]
@contextlib.contextmanager
def edit_file(
self,
*,
source: pathlib.PurePath,
pull_file: bool = True,
missing_ok: bool = False,
) -> Generator[pathlib.Path, None, None]:
"""Edit a file from the environment for modification via context manager.
A file is pulled from an environment for editing via a context manager. Upon
exiting, the file is pushed back to the environment. If the environment file
does not exist, a new file will be created.
:param source: Environment file to copy.
:param pull_file: If true, pull the file from the environment. If false,
edit a new file.
:param missing_ok: Create a new file if the file doesn't exist. Has no
effect if 'pull_file' is false.
:raises FileNotFoundError: If source file or destination's parent
directory does not exist (and `missing_ok` is False).
:raises ProviderError: On error copying file content.
"""
with craft_providers.util.temp_paths.home_temporary_file() as tmp_file:
tmp_file.touch() # ensure the file exists
if pull_file:
try:
self.pull_file(source=source, destination=tmp_file)
except FileNotFoundError:
if not missing_ok:
raise
try:
yield tmp_file
finally:
self.push_file(source=tmp_file, destination=source)
[docs]
@abstractmethod
def push_file(self, *, source: pathlib.Path, destination: pathlib.PurePath) -> None:
"""Copy a file from the host into the environment.
The destination file is overwritten if it exists.
:param source: Host file to copy.
:param destination: Target environment file path to copy to. Parent
directory (destination.parent) must exist.
:raises FileNotFoundError: If source file or destination's parent
directory does not exist.
:raises ProviderError: On error copying file.
"""
[docs]
@abstractmethod
def push_file_io(
self,
*,
destination: pathlib.PurePath,
content: io.BytesIO,
file_mode: str,
group: str = "root",
user: str = "root",
) -> None:
"""Create or replace a file with specified content and file mode.
:param destination: Path to file.
:param content: Contents of file.
:param file_mode: File mode string (e.g. '0644').
:param group: File owner group.
:param user: File owner user.
"""
[docs]
@abstractmethod
def delete(self) -> None:
"""Delete instance."""
[docs]
@abstractmethod
def exists(self) -> bool:
"""Check if instance exists.
:returns: True if instance exists.
"""
[docs]
@abstractmethod
def mount(self, *, host_source: pathlib.Path, target: pathlib.PurePath) -> None:
"""Mount host source directory to target mount point."""
[docs]
@abstractmethod
def is_running(self) -> bool:
"""Check if instance is running.
:returns: True if instance is running.
"""
def get_instance_name(name: str, error_class: type[ProviderError]) -> str:
"""Get an instance-friendly name from a name.
LXD and Multipass instance names have the same naming convention
as Linux hostnames.
Naming convention:
- between 1 and 63 characters long
- made up exclusively of letters, numbers, and hyphens from the ASCII table
- not begin with a digit or a hyphen
- not end with a hyphen
To create an instance name, invalid characters are removed, the name is
truncated to 40 characters, then a hash is appended:
<truncated-name>-<hash-of-name>
└ 1 - 40 ┘1└ 20 ┘
:param name: the name to convert
:param error_class: the exception class to raise if name is invalid
:raises error_class: if name contains no alphanumeric characters
:returns: the instance name
"""
# remove anything that is not an alphanumeric character or hyphen
name_with_valid_chars = re.sub(r"[^a-zA-Z0-9-]", "", name, flags=re.ASCII)
if not name_with_valid_chars:
raise error_class(
brief=f"failed to create an instance with name {name!r}.",
details="name must contain at least one alphanumeric character",
)
# trim digits and hyphens from the beginning and hyphens from the end
trimmed_name = re.compile(r"^[0-9-]*(?P<valid_name>.*?)[-]*$").search(
name_with_valid_chars
)
if not trimmed_name or not trimmed_name.group("valid_name"):
raise error_class(
brief=f"failed to create an instance with name {name!r}.",
details="name must contain at least one alphanumeric character",
)
valid_name = trimmed_name.group("valid_name")
# if the original name satisfies the naming convention, then use the original name
if name == valid_name and len(name) <= MAX_INSTANCE_NAME_LENGTH:
instance_name = name
# else, continue converting the name
else:
# truncate to 40 characters
truncated_name = valid_name[:40]
# hash the entire name, not the truncated name
hashed_name = hashlib.sha1(name.encode()).hexdigest()[:20] # noqa: S324, security of this does not matter
instance_name = f"{truncated_name}-{hashed_name}"
logger.debug("Converted name %r to instance name %r", name, instance_name)
return instance_name