Source code for craft_providers.lxd.launcher

#
# 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