#
# Copyright 2021-2022 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.
#
"""LXD Instance Provider."""
import logging
from datetime import datetime, timedelta
from typing import Optional
from craft_providers import Base, ProviderError, bases
from .errors import LXDError
from .lxc import LXC
from .lxd_instance import LXDInstance
from .project import create_with_default_profile
logger = logging.getLogger(__name__)
def _create_instance(
*,
instance: LXDInstance,
base_instance: Optional[LXDInstance],
base_configuration: Base,
image_name: str,
image_remote: str,
ephemeral: bool,
map_user_uid: bool,
uid: Optional[int],
project: str,
remote: str,
lxc: LXC,
) -> None:
"""Launch and setup an instance from an image.
If a base instance is passed, copy the instance to the base instance.
Preconditions: The LXD project exists and the instance and base instance (if
defined) do not exist.
:param instance: LXD instance to launch and setup
:param base_instance: LXD instance to be created as a copy of the instance. If the
base_instance is None, a base instance will not be created.
:param base_configuration: Base configuration to apply to instance.
:param image_name: LXD image to use, e.g. "20.04".
:param image_remote: LXD image to use, e.g. "ubuntu".
:param ephemeral: After the instance is stopped, delete it.
:param map_user_uid: Map host uid/gid to instance's root uid/gid.
:param uid: The uid to be mapped, if ``map_user_id`` is enabled.
:param project: LXD project to create instance in.
:param remote: LXD remote to create instance on.
:param lxc: LXC client.
"""
logger.warning(
"Creating new instance from image %r from remote %r.", image_name, image_remote
)
instance.launch(
image=image_name,
image_remote=image_remote,
ephemeral=ephemeral,
map_user_uid=map_user_uid,
uid=uid,
)
base_configuration.setup(executor=instance)
if base_instance is not None:
logger.warning(
"Creating new base instance %r from instance.", base_instance.instance_name
)
# stop the instance before copying, to ensure it is in a "good" state
instance.stop()
lxc.copy(
source_remote=remote,
source_instance_name=instance.instance_name,
destination_remote=remote,
destination_instance_name=base_instance.instance_name,
project=project,
)
# now restart and wait for the instance to be ready
instance.start()
base_configuration.wait_until_ready(executor=instance)
def _ensure_project_exists(
*, create: bool, project: str, remote: str, lxc: LXC
) -> None:
"""Check if project exists and create it if it does not exist.
:param create: Create project if not found.
:param project: LXD project name to create.
:param remote: LXD remote to create project on.
:param lxc: LXC client.
:raises LXDError: on error.
"""
projects = lxc.project_list(remote)
if project in projects:
return
if create:
create_with_default_profile(project=project, remote=remote, lxc=lxc)
else:
raise LXDError(
brief=f"LXD project {project!r} not found on remote {remote!r}.",
details=f"Available projects: {projects!r}",
)
def _formulate_base_instance_name(
*, image_name: str, image_remote: str, compatibility_tag: str
) -> str:
"""Compute the base instance name.
:param image_remote: Name of source image's remote (e.g. ubuntu).
:param image_name: Name of source image (e.g. 20.04). The image name should include
the architecture to ensure uniqueness amongst multiple architectures built on the
same platform.
:param compatibility_tag: Compatibility tag of base configuration applied to the
base instance.
:returns: Name of (compatible) base instance.
"""
return "-".join(["base-instance", compatibility_tag, image_remote, image_name])
def _is_valid(*, instance_name: str, project: str, remote: str, lxc: LXC) -> bool:
"""Check if an instance is valid.
Instances are valid for 3 months (90 days). After 3 months, they are considered
expired and invalid.
If errors occur during the validity check, the instance is assumed to be invalid.
:param instance: Name of instance to check the validity of.
:param project: LXD project name to create.
:param remote: LXD remote to create project on.
:param lxc: LXC client.
:returns: True if the instance is valid. False otherwise.
"""
logger.debug("Checking validity of instance %r.", instance_name)
# capture instance info
try:
info = lxc.info(instance_name=instance_name, project=project, remote=remote)
except LXDError as raised:
# if the base instance info can't be retrieved, consider it invalid
logger.warning("Could not get instance info with error: %s", raised)
return False
creation_date_raw = info.get("Created")
# if the base instance does not have a creation date, consider it invalid
if not creation_date_raw:
logger.warning("Instance does not have a 'Created' date.")
return False
# parse datetime
try:
creation_date = datetime.strptime(creation_date_raw, "%Y/%m/%d %H:%M %Z")
except ValueError as raised:
# if the date can't be parsed, consider it invalid
logger.warning(
"Could not parse instance's 'Created' date with error: %r", raised
)
return False
expiration_date = datetime.now() - timedelta(days=90)
if creation_date < expiration_date:
logger.warning(
"Instance is expired (Instance creation date: %s, expiration date: %s).",
creation_date,
expiration_date,
)
return False
logger.debug("Instance is valid.")
return True
def _launch_existing_instance(
*,
instance: LXDInstance,
auto_clean: bool,
base_configuration: Base,
ephemeral: bool,
) -> bool:
"""Start and warmup an existing instance.
:param instance: LXD instance to launch
:param auto_clean: If true, clean incompatible instances.
:param base_configuration: Base configuration to apply to the instance.
:param ephemeral: If the instance is ephemeral, it will not be launched.
Instead, the instance will be deleted and the function will return false.
:returns: True if the instance was started and warmed up. False otherwise.
:raises BaseCompatibilityError: If the instance is incompatible.
"""
# TODO: auto clean instance if map_user_uid is mismatched
if ephemeral:
logger.warning("Instance exists and is ephemeral. Cleaning instance.")
instance.delete()
return False
if instance.is_running():
logger.warning("Instance exists and is running.")
else:
logger.warning("Instance exists and is not running. Starting instance.")
instance.start()
try:
base_configuration.warmup(executor=instance)
return True
except bases.BaseCompatibilityError as error:
# delete the instance and continue on so a new instance can be created
if auto_clean:
logger.warning(
"Cleaning incompatible instance %r (reason: %s).",
instance.instance_name,
error.reason,
)
instance.delete()
return False
raise
# pylint: disable-next=too-many-locals
[docs]def launch(
name: str,
*,
base_configuration: Base,
image_name: str,
image_remote: str,
auto_clean: bool = False,
auto_create_project: bool = False,
ephemeral: bool = False,
map_user_uid: bool = False,
uid: Optional[int] = None,
use_snapshots: Optional[bool] = None,
use_base_instance: Optional[bool] = False,
project: str = "default",
remote: str = "local",
lxc: LXC = LXC(),
) -> LXDInstance:
"""Create, start, and configure an instance.
On the first run of an application, an instance will be launched from an image
(i.e. an image from https://cloud-images.ubuntu.com). The instance is setup
according to the Base configuration passed to this function.
After setup, a copy of this instance is saved (or cached) as a 'base instance'.
This is done to reduce setup time on subsequent runs. When the application requests
a new instance on a subsequent run, the base instance will be copied to create the
new instance. This instance is run through a small subset of the setup, which is
referred to as 'warmup'.
To keep build environments clean, consistent, and up-to-date, any base instance
older than 3 months (90 days) is deleted and recreated.
:param name: Name of instance.
:param base_configuration: Base configuration to apply to the instance.
:param image_name: LXD image to use, e.g. "20.04".
:param image_remote: LXD image to use, e.g. "ubuntu".
:param auto_clean: If true and the existing instance is incompatible, then the
instance will be deleted and rebuilt. If false and the existing instance is
incompatible, then a BaseCompatibilityError is raised.
:param auto_create_project: Automatically create LXD project, if needed.
:param ephemeral: After the instance is stopped, delete it. Non-ephemeral instances
cannot be converted to ephemeral instances, so if the instance already exists, it
will be deleted, then recreated as an ephemeral instance.
:param map_user_uid: Map host uid/gid to instance's root uid/gid.
:param uid: The uid to be mapped, if ``map_user_id`` is enabled.
:param use_base_instance: Use the base instance mechanisms to reduce setup time.
:param use_snapshots: Deprecated parameter replaced by `use_base_instance`.
:param project: LXD project to create instance in.
:param remote: LXD remote to create instance on.
:param lxc: LXC client.
:returns: LXD instance.
:raises BaseConfigurationError: on unexpected error configuration base.
:raises BaseCompatibilityError: if instance is incompatible with the base.
:raises LXDError: on unexpected LXD error.
:raises ProviderError: if name of instance collides with base instance name.
"""
if use_snapshots:
logger.warning(
"Deprecated: Parameter 'use_snapshots' is deprecated. "
"Use parameter 'use_base_instance' instead."
)
use_base_instance = use_snapshots
_ensure_project_exists(
create=auto_create_project, project=project, remote=remote, lxc=lxc
)
instance = LXDInstance(
name=name,
project=project,
remote=remote,
default_command_environment=base_configuration.get_command_environment(),
)
logger.warning(
"Checking for instance %r in project %r in remote %r",
instance.instance_name,
project,
remote,
)
if instance.exists():
# if the existing instance could not be launched, then continue on so a new
# instance can be created (this can occur when `auto_clean` triggers the
# instance to be deleted or if the instance is supposed to be ephemeral)
if _launch_existing_instance(
instance=instance,
auto_clean=auto_clean,
base_configuration=base_configuration,
ephemeral=ephemeral,
):
return instance
logger.warning("Instance %r does not exist.", instance.instance_name)
if not use_base_instance:
logger.warning("Using base instances is disabled.")
_create_instance(
instance=instance,
base_instance=None,
base_configuration=base_configuration,
image_name=image_name,
image_remote=image_remote,
ephemeral=ephemeral,
map_user_uid=map_user_uid,
uid=uid,
project=project,
remote=remote,
lxc=lxc,
)
return instance
base_instance_name = _formulate_base_instance_name(
image_name=image_name,
image_remote=image_remote,
compatibility_tag=base_configuration.compatibility_tag,
)
base_instance = LXDInstance(
name=base_instance_name,
project=project,
remote=remote,
default_command_environment=base_configuration.get_command_environment(),
)
logger.warning(
"Checking for base instance %r in project %r in remote %r",
base_instance.instance_name,
project,
remote,
)
# an application could formulate an instance name that matches the base instance's
# name, which would break calls to `lxc.copy()`
if instance.instance_name == base_instance.instance_name:
raise ProviderError(
brief="instance name cannot match the base instance name: "
f"{instance.instance_name!r}",
resolution="change name of instance",
)
# the base instance does not exist, so create a new instance and base instance
if not base_instance.exists():
logger.warning("Base instance %r does not exist.", base_instance.instance_name)
_create_instance(
instance=instance,
base_instance=base_instance,
base_configuration=base_configuration,
image_name=image_name,
image_remote=image_remote,
ephemeral=ephemeral,
map_user_uid=map_user_uid,
uid=uid,
project=project,
remote=remote,
lxc=lxc,
)
return instance
# the base instance exists but is not valid, so delete it then create a new
# instance and base instance
if not _is_valid(
instance_name=base_instance.instance_name,
project=project,
remote=remote,
lxc=lxc,
):
logger.warning(
"Base instance %r is not valid. Deleting base instance.",
base_instance.instance_name,
)
base_instance.delete()
_create_instance(
instance=instance,
base_instance=base_instance,
base_configuration=base_configuration,
image_name=image_name,
image_remote=image_remote,
ephemeral=ephemeral,
map_user_uid=map_user_uid,
uid=uid,
project=project,
remote=remote,
lxc=lxc,
)
return instance
# at this point, there is a valid base instance to be copied to a new instance
logger.warning(
"Creating instance from base instance %r", base_instance.instance_name
)
# the base instance is not expected to be running but check for safety
if base_instance.is_running():
logger.warning("Stopping base instance.")
lxc.copy(
source_remote=remote,
source_instance_name=base_instance.instance_name,
destination_remote=remote,
destination_instance_name=instance.instance_name,
project=project,
)
# the newly copied instance should not be running, but check anyways
if instance.is_running():
logger.warning("Instance is already running.")
else:
logger.warning("Starting instance.")
instance.start()
base_configuration.warmup(executor=instance)
return instance