diff --git a/Makefile b/Makefile
index 7892a2f33..9fd8c6c8d 100644
--- a/Makefile
+++ b/Makefile
@@ -117,7 +117,7 @@ test-pydocstyle:
.PHONY: test-pylint
test-pylint:
pylint rockcraft
- pylint tests --disable=invalid-name,missing-module-docstring,missing-function-docstring,redefined-outer-name,too-many-arguments,too-many-public-methods,no-member
+ pylint tests --disable=invalid-name,missing-module-docstring,missing-function-docstring,redefined-outer-name,too-many-arguments,too-many-public-methods,no-member,import-outside-toplevel
.PHONY: test-pyright
test-pyright:
diff --git a/docs/how-to/code/convert-to-pebble-layer/task.yaml b/docs/how-to/code/convert-to-pebble-layer/task.yaml
index 42ced9b61..ee2ba0a1f 100644
--- a/docs/how-to/code/convert-to-pebble-layer/task.yaml
+++ b/docs/how-to/code/convert-to-pebble-layer/task.yaml
@@ -9,7 +9,7 @@ summary: test the "How to convert a popular entrypoint to a Pebble layer" guide
execute: |
# [docs:pack]
- rockcraft
+ rockcraft pack
# [docs:pack-end]
# [docs:skopeo]
diff --git a/docs/how-to/code/install-slice/task.yaml b/docs/how-to/code/install-slice/task.yaml
index 43d1fc8a7..e729d9cb4 100644
--- a/docs/how-to/code/install-slice/task.yaml
+++ b/docs/how-to/code/install-slice/task.yaml
@@ -39,7 +39,7 @@ execute: |
mv rockcraft.yaml.backup rockcraft.yaml
# [docs:pack]
- rockcraft
+ rockcraft pack
# [docs:pack-end]
# [docs:skopeo-copy]
diff --git a/docs/tutorials/code/chisel/task.yaml b/docs/tutorials/code/chisel/task.yaml
index 393443237..a8c0d13bd 100644
--- a/docs/tutorials/code/chisel/task.yaml
+++ b/docs/tutorials/code/chisel/task.yaml
@@ -13,7 +13,7 @@ execute: |
# [docs:install-rockcraft-end]
# [docs:build-rock]
- rockcraft
+ rockcraft pack
# [docs:build-rock-end]
test -f chisel-openssl_0.0.1_amd64.rock
diff --git a/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml b/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml
index 1419e5f24..5999a015c 100644
--- a/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml
+++ b/docs/tutorials/code/migrate-to-chiselled-rock/task.yaml
@@ -33,7 +33,7 @@ execute: |
# [docs:run-docker-image-end]
# [docs:build-rock]
- rockcraft --verbosity debug
+ rockcraft pack --verbosity debug
# [docs:build-rock-end]
test -f dotnet-runtime_chiselled_amd64.rock
diff --git a/docs/tutorials/code/pyfiglet/task.yaml b/docs/tutorials/code/pyfiglet/task.yaml
index ba5c4c2b3..4d592bc43 100644
--- a/docs/tutorials/code/pyfiglet/task.yaml
+++ b/docs/tutorials/code/pyfiglet/task.yaml
@@ -15,7 +15,7 @@ execute: |
cp ../rockcraft.yaml .
# [docs:build-rock]
- rockcraft
+ rockcraft pack
# [docs:build-rock-end]
# [docs:skopeo-copy]
diff --git a/rockcraft/__init__.py b/rockcraft/__init__.py
index 0bc225918..f9057c75f 100644
--- a/rockcraft/__init__.py
+++ b/rockcraft/__init__.py
@@ -16,4 +16,9 @@
"""The craft tool to create ROCKs."""
+from craft_parts import Features
+
__version__ = "0.0.1.dev1"
+
+
+Features(enable_overlay=True)
diff --git a/rockcraft/__main__.py b/rockcraft/__main__.py
index 26e9e00ee..e232a932c 100644
--- a/rockcraft/__main__.py
+++ b/rockcraft/__main__.py
@@ -16,8 +16,9 @@
# along with this program. If not, see .
"""Main module."""
+import sys
from .cli import run
if __name__ == "__main__":
- run()
+ sys.exit(run())
diff --git a/rockcraft/application.py b/rockcraft/application.py
index 49f59517b..7871bb93f 100644
--- a/rockcraft/application.py
+++ b/rockcraft/application.py
@@ -19,16 +19,10 @@
from __future__ import annotations
import functools
-import os
import pathlib
-import signal
-import sys
import craft_cli
from craft_application import Application, AppMetadata, util
-from craft_application.application import _Dispatcher
-from craft_application.commands import get_lifecycle_command_group
-from craft_cli import CommandGroup
from overrides import override
from rockcraft import models
@@ -42,27 +36,9 @@
)
-class FallbackRunError(RuntimeError):
- """Run should be handled by the legacy code."""
-
-
class Rockcraft(Application):
"""Rockcraft application definition."""
- @property
- @override
- def command_groups(self) -> list[craft_cli.CommandGroup]:
- """Filter supported command from CraftApplication."""
- all_lifecycle_commands = get_lifecycle_command_group().commands
- migrated_commands = {"pack"}
-
- return [
- CommandGroup(
- "Lifecycle",
- [c for c in all_lifecycle_commands if c.name in migrated_commands],
- )
- ]
-
@functools.cached_property
def project(self) -> models.Project:
"""Get this application's Project metadata."""
@@ -70,75 +46,6 @@ def project(self) -> models.Project:
craft_cli.emit.debug(f"Loading project file '{project_file!s}'")
return models.Project.from_yaml_file(project_file, work_dir=self._work_dir)
- @override
- def _get_dispatcher(self) -> _Dispatcher:
- """Overridden to raise a FallbackRunError() for unhandled commands.
-
- Should be removed after we finish migrating all commands.
- """
- craft_cli.emit.init(
- mode=craft_cli.EmitterMode.BRIEF,
- appname=self.app.name,
- greeting=f"Starting {self.app.name}",
- log_filepath=self.log_path,
- streaming_brief=True,
- )
-
- dispatcher = _Dispatcher(
- self.app.name,
- self.command_groups,
- summary=str(self.app.summary),
- extra_global_args=self._global_arguments,
- )
-
- try:
- craft_cli.emit.trace("pre-parsing arguments...")
- # Workaround for the fact that craft_cli requires a command.
- # https://github.com/canonical/craft-cli/issues/141
- if "--version" in sys.argv or "-V" in sys.argv:
- try:
- global_args = dispatcher.pre_parse_args(["pull", *sys.argv[1:]])
- except craft_cli.ArgumentParsingError:
- global_args = dispatcher.pre_parse_args(sys.argv[1:])
- else:
- global_args = dispatcher.pre_parse_args(sys.argv[1:])
-
- if global_args.get("version"):
- craft_cli.emit.ended_ok()
- print(f"{self.app.name} {self.app.version}")
- sys.exit(0)
- # Try loading the command to shake out possible ArgumentParsingErrors
- # from commands with options that we can't handle yet (like
- # --destructive-mode)
- dispatcher.load_command(
- {
- "app": self.app,
- "services": self.services,
- }
- )
- except (craft_cli.ProvideHelpException, craft_cli.ArgumentParsingError) as err:
- # Difference from base's behavior: fallback to the legacy run
- # if we get an unknown command, or an invalid option, or a request
- # for help.
- raise FallbackRunError() from err
- except KeyboardInterrupt as err:
- self._emit_error(craft_cli.CraftError("Interrupted."), cause=err)
- sys.exit(128 + signal.SIGINT)
- except Exception as err: # pylint: disable=broad-except
- self._emit_error(
- craft_cli.CraftError(
- f"Internal error while loading {self.app.name}: {err!r}"
- )
- )
- if os.getenv("CRAFT_DEBUG") == "1":
- raise
- sys.exit(70) # EX_SOFTWARE from sysexits.h
-
- craft_cli.emit.trace("Preparing application...")
- self.configure(global_args)
-
- return dispatcher
-
@override
def _configure_services(self, platform: str | None, build_for: str | None) -> None:
if build_for is None:
diff --git a/rockcraft/cli.py b/rockcraft/cli.py
index 8f99ce760..d8976d058 100644
--- a/rockcraft/cli.py
+++ b/rockcraft/cli.py
@@ -17,63 +17,15 @@
"""Command-line application entry point."""
import logging
-import sys
-from typing import Optional
-import craft_cli
-from craft_cli import ArgumentParsingError, ProvideHelpException, emit
-from craft_parts import PartsError
-from craft_providers import ProviderError
-
-from rockcraft import __version__, errors, plugins
+from rockcraft import plugins
from . import commands
from .services import RockcraftServiceFactory
-from .utils import is_managed_mode
-
-COMMAND_GROUPS = [
- craft_cli.CommandGroup(
- "Lifecycle",
- [
- commands.CleanCommand,
- commands.PullCommand,
- commands.OverlayCommand,
- commands.BuildCommand,
- commands.StageCommand,
- commands.PrimeCommand,
- commands.PackCommand,
- ],
- ),
- craft_cli.CommandGroup(
- "Extensions",
- [
- commands.ExtensionsCommand,
- commands.ListExtensionsCommand,
- commands.ExpandExtensionsCommand,
- ],
- ),
- craft_cli.CommandGroup(
- "Other",
- [
- commands.InitCommand,
- ],
- ),
-]
-
-GLOBAL_ARGS = [
- craft_cli.GlobalArgument(
- "version", "flag", "-V", "--version", "Show the application version and exit"
- )
-]
-def run() -> None:
+def run() -> int:
"""Command-line interface entrypoint."""
- # pylint: disable=import-outside-toplevel
- # Import these here so that the script that generates the docs for the
- # commands doesn't need to know *too much* of the application.
- from .application import APP_METADATA, FallbackRunError, Rockcraft
-
# Register our own plugins
plugins.register()
@@ -82,6 +34,17 @@ def run() -> None:
logger = logging.getLogger(lib_name)
logger.setLevel(logging.DEBUG)
+ app = _create_app()
+
+ return app.run()
+
+
+def _create_app():
+ # pylint: disable=import-outside-toplevel
+ # Import these here so that the script that generates the docs for the
+ # commands doesn't need to know *too much* of the application.
+ from .application import APP_METADATA, Rockcraft
+
services = RockcraftServiceFactory(
# type: ignore # type: ignore[call-arg]
app=APP_METADATA,
@@ -89,64 +52,19 @@ def run() -> None:
app = Rockcraft(app=APP_METADATA, services=services)
- try:
- app.run()
- except FallbackRunError:
- legacy_run()
-
-
-def _emit_error(error: craft_cli.CraftError, cause: Optional[Exception] = None) -> None:
- """Emit the error in a centralized way so we can alter it consistently."""
- # set the cause, if any
- if cause is not None:
- error.__cause__ = cause
-
- # if running inside a managed instance, do not report the internal logpath
- if is_managed_mode():
- error.logpath_report = False
-
- # finally, emit
- emit.error(error)
-
-
-def legacy_run() -> None:
- """Run the CLI."""
- dispatcher = craft_cli.Dispatcher(
- "rockcraft",
- COMMAND_GROUPS,
- summary="A tool to create OCI images",
- extra_global_args=GLOBAL_ARGS,
- default_command=commands.PackCommand,
+ app.add_command_group(
+ "Other",
+ [
+ commands.InitCommand,
+ ],
+ )
+ app.add_command_group(
+ "Extensions",
+ [
+ commands.ExtensionsCommand,
+ commands.ListExtensionsCommand,
+ commands.ExpandExtensionsCommand,
+ ],
)
- try:
- global_args = dispatcher.pre_parse_args(sys.argv[1:])
- if global_args.get("version"):
- emit.message(f"rockcraft {__version__}")
- else:
- dispatcher.load_command(None)
- dispatcher.run()
- emit.ended_ok()
- except ProvideHelpException as err:
- print(err, file=sys.stderr) # to stderr, as argparse normally does
- emit.ended_ok()
- except ArgumentParsingError as err:
- print(err, file=sys.stderr) # to stderr, as argparse normally does
- emit.ended_ok()
- sys.exit(1)
- except errors.RockcraftError as err:
- _emit_error(err)
- sys.exit(1)
- except PartsError as err:
- _emit_error(
- craft_cli.CraftError(
- err.brief, details=err.details, resolution=err.resolution
- )
- )
- sys.exit(1)
- except ProviderError as err:
- _emit_error(craft_cli.CraftError(f"craft-providers error: {err}"))
- sys.exit(1)
- except Exception as err: # pylint: disable=broad-except
- _emit_error(craft_cli.CraftError(f"rockcraft internal error: {err!r}"))
- sys.exit(1)
+ return app
diff --git a/rockcraft/commands/__init__.py b/rockcraft/commands/__init__.py
index 9a028ed38..6f85a155c 100644
--- a/rockcraft/commands/__init__.py
+++ b/rockcraft/commands/__init__.py
@@ -22,25 +22,9 @@
ListExtensionsCommand,
)
from .init import InitCommand
-from .lifecycle import (
- BuildCommand,
- CleanCommand,
- OverlayCommand,
- PackCommand,
- PrimeCommand,
- PullCommand,
- StageCommand,
-)
__all__ = [
"InitCommand",
- "CleanCommand",
- "PullCommand",
- "OverlayCommand",
- "BuildCommand",
- "StageCommand",
- "PrimeCommand",
- "PackCommand",
"ExpandExtensionsCommand",
"ExtensionsCommand",
"ListExtensionsCommand",
diff --git a/rockcraft/commands/extensions.py b/rockcraft/commands/extensions.py
index 19b2b1e23..c2c93142c 100644
--- a/rockcraft/commands/extensions.py
+++ b/rockcraft/commands/extensions.py
@@ -23,7 +23,8 @@
from typing import Dict, List
import tabulate
-from craft_cli import BaseCommand, emit
+from craft_application.commands import AppCommand
+from craft_cli import emit
from overrides import overrides
from pydantic import BaseModel
@@ -45,7 +46,7 @@ def marshal(self) -> Dict[str, str]:
}
-class ListExtensionsCommand(BaseCommand, abc.ABC):
+class ListExtensionsCommand(AppCommand, abc.ABC):
"""List available extensions for all supported bases."""
name = "list-extensions"
@@ -82,7 +83,7 @@ class ExtensionsCommand(ListExtensionsCommand, abc.ABC):
hidden = True
-class ExpandExtensionsCommand(BaseCommand, abc.ABC):
+class ExpandExtensionsCommand(AppCommand, abc.ABC):
"""Expand the extensions in the snapcraft.yaml file."""
name = "expand-extensions"
diff --git a/rockcraft/commands/init.py b/rockcraft/commands/init.py
index b11719857..9b158549a 100644
--- a/rockcraft/commands/init.py
+++ b/rockcraft/commands/init.py
@@ -19,7 +19,8 @@
from pathlib import Path
from typing import TYPE_CHECKING
-from craft_cli import BaseCommand, emit
+from craft_application.commands import AppCommand
+from craft_cli import emit
from overrides import overrides
from rockcraft import errors
@@ -48,7 +49,7 @@ def init(rockcraft_yaml_content: str) -> None:
emit.progress(f"Created {rockcraft_yaml_path}.")
-class InitCommand(BaseCommand):
+class InitCommand(AppCommand):
"""Initialize a rockcraft project."""
name = "init"
diff --git a/rockcraft/commands/lifecycle.py b/rockcraft/commands/lifecycle.py
deleted file mode 100644
index 953617b50..000000000
--- a/rockcraft/commands/lifecycle.py
+++ /dev/null
@@ -1,176 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2022 Canonical Ltd.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-"""Rockcraft lifecycle commands."""
-
-import abc
-import textwrap
-from typing import TYPE_CHECKING
-
-from craft_cli import BaseCommand, emit
-from overrides import overrides
-
-from rockcraft import lifecycle
-
-if TYPE_CHECKING:
- import argparse
-
-
-class _LifecycleCommand(BaseCommand, abc.ABC):
- """Lifecycle-related commands."""
-
- @overrides
- def run(self, parsed_args: "argparse.Namespace") -> None:
- """Run the command."""
- if not self.name:
- raise RuntimeError("command name not specified")
-
- emit.trace(f"lifecycle command: {self.name!r}, arguments: {parsed_args!r}")
- lifecycle.run(self.name, parsed_args)
-
- def fill_parser(self, parser: "argparse.ArgumentParser") -> None:
- super().fill_parser(parser) # type: ignore
- parser.add_argument(
- "--debug",
- action="store_true",
- help="Shell into the environment if the build fails",
- )
- parser.add_argument(
- "--destructive-mode",
- action="store_true",
- help="Build in the current host",
- )
-
-
-class _LifecycleStepCommand(_LifecycleCommand):
- """Lifecycle step commands."""
-
- @overrides
- def fill_parser(self, parser: "argparse.ArgumentParser") -> None:
- super().fill_parser(parser) # type: ignore
- parser.add_argument(
- "parts",
- metavar="part-name",
- type=str,
- nargs="*",
- help="Optional list of parts to process",
- )
-
- group = parser.add_mutually_exclusive_group()
- group.add_argument(
- "--shell",
- action="store_true",
- help="Shell into the environment in lieu of the step to run.",
- )
- group.add_argument(
- "--shell-after",
- action="store_true",
- help="Shell into the environment after the step has run.",
- )
-
-
-class CleanCommand(_LifecycleStepCommand):
- """Command to remove part assets."""
-
- name = "clean"
- help_msg = "Remove a part's assets"
- overview = textwrap.dedent(
- """
- Clean up artifacts belonging to parts. If no parts are specified,
- remove the ROCK packing environment.
- """
- )
-
-
-class PullCommand(_LifecycleStepCommand):
- """Command to pull parts."""
-
- name = "pull"
- help_msg = "Download or retrieve artifacts defined for a part"
- overview = textwrap.dedent(
- """
- Download or retrieve artifacts defined for a part. If part names
- are specified only those parts will be pulled, otherwise all parts
- will be pulled.
- """
- )
-
-
-class OverlayCommand(_LifecycleStepCommand):
- """Command to overlay parts."""
-
- name = "overlay"
- help_msg = "Create part layers over the base filesystem."
- overview = textwrap.dedent(
- """
- Execute operations defined for each part on a layer over the base
- filesystem, potentially modifying its contents.
- """
- )
-
-
-class BuildCommand(_LifecycleStepCommand):
- """Command to build parts."""
-
- name = "build"
- help_msg = "Build artifacts defined for a part"
- overview = textwrap.dedent(
- """
- Build artifacts defined for a part. If part names are specified only
- those parts will be built, otherwise all parts will be built.
- """
- )
-
-
-class StageCommand(_LifecycleStepCommand):
- """Command to stage parts."""
-
- name = "stage"
- help_msg = "Stage built artifacts into a common staging area"
- overview = textwrap.dedent(
- """
- Stage built artifacts into a common staging area. If part names are
- specified only those parts will be staged. The default is to stage
- all parts.
- """
- )
-
-
-class PrimeCommand(_LifecycleStepCommand):
- """Command to prime parts."""
-
- name = "prime"
- help_msg = "Prime artifacts defined for a part"
- overview = textwrap.dedent(
- """
- Prepare the final payload to be packed as a ROCK, performing additional
- processing and adding metadata files. If part names are specified only
- those parts will be primed. The default is to prime all parts.
- """
- )
-
-
-class PackCommand(_LifecycleCommand):
- """Command to pack the final artifact."""
-
- name = "pack"
- help_msg = "Create the ROCK"
- overview = textwrap.dedent(
- """
- Process parts and create the ROCK as an OCI archive file containing
- the project payload with the provided metadata.
- """
- )
diff --git a/rockcraft/environment.py b/rockcraft/environment.py
new file mode 100644
index 000000000..da6c26061
--- /dev/null
+++ b/rockcraft/environment.py
@@ -0,0 +1,57 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Utilities to expand variables in project environments."""
+
+from __future__ import annotations
+
+import pathlib
+from typing import Any
+
+import craft_parts
+
+
+def expand_environment(
+ project_yaml: dict[str, Any],
+ *,
+ project_vars: dict[str, Any],
+ work_dir: pathlib.Path,
+) -> None:
+ """Expand global variables in the provided dictionary values.
+
+ :param project_yaml: A dictionary containing the rockcraft.yaml's contents.
+ :param project_var: A dictionary with the project-specific variables.
+ :param work_dir: The working directory.
+ """
+ info = craft_parts.ProjectInfo(
+ application_name="rockcraft", # not used in environment expansion
+ cache_dir=pathlib.Path(), # not used in environment expansion
+ project_name=project_yaml.get("name", ""),
+ project_dirs=craft_parts.ProjectDirs(work_dir=work_dir),
+ project_vars=project_vars,
+ )
+ _set_global_environment(info)
+
+ craft_parts.expand_environment(project_yaml, info=info)
+
+
+def _set_global_environment(info: craft_parts.ProjectInfo) -> None:
+ """Set global environment variables."""
+ info.global_environment.update(
+ {
+ "CRAFT_PROJECT_VERSION": info.get_project_var("version", raw_read=True),
+ }
+ )
diff --git a/rockcraft/lifecycle.py b/rockcraft/lifecycle.py
deleted file mode 100644
index 46626db9c..000000000
--- a/rockcraft/lifecycle.py
+++ /dev/null
@@ -1,347 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2021-2022 Canonical Ltd.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-
-"""Lifecycle integration."""
-
-import datetime
-import subprocess
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Dict
-
-from craft_cli import emit
-from craft_parts import ProjectDirs, ProjectInfo, expand_environment
-from craft_providers import ProviderError
-
-from . import oci, providers, utils
-from .models.project import Project, load_project
-from .parts import PartsLifecycle
-from .usernames import SUPPORTED_GLOBAL_USERNAMES
-
-if TYPE_CHECKING:
- import argparse
-
-
-def run(command_name: str, parsed_args: "argparse.Namespace") -> None:
- """Run the parts lifecycle."""
- # pylint: disable=too-many-locals
- emit.trace(f"command: {command_name}, arguments: {parsed_args}")
-
- project_yaml = load_project(Path("rockcraft.yaml"))
- destructive_mode = getattr(parsed_args, "destructive_mode", False)
-
- part_names = getattr(parsed_args, "parts", None)
- managed_mode = utils.is_managed_mode()
-
- if not managed_mode and not destructive_mode:
- if command_name == "clean" and not part_names:
- clean_provider(
- project_name=project_yaml["name"], project_path=Path().absolute()
- )
- else:
- run_in_provider(Project.unmarshal(project_yaml), command_name, parsed_args)
- return
-
- if managed_mode:
- work_dir = utils.get_managed_environment_home_path()
- else:
- work_dir = Path().absolute()
-
- project_vars = {"version": project_yaml["version"]}
- # Expand the environment so that the global variables can be interpolated
- _expand_environment(
- project_yaml,
- project_vars=project_vars,
- work_dir=work_dir,
- )
-
- # pylint: disable=no-member
- project = Project.unmarshal(project_yaml)
-
- image_dir = work_dir / "images"
- bundle_dir = work_dir / "bundles"
-
- if project.package_repositories is None:
- package_repositories = []
- else:
- package_repositories = project.package_repositories
-
- # Obtain base image and extract it to use as our overlay base
- # TODO: check if image was already downloaded, etc.
- emit.progress(f"Retrieving base {project.base}")
-
- for platform_entry, platform in project.platforms.items():
- build_for = (
- platform["build_for"][0] if platform.get("build_for") else platform_entry
- )
- build_for_variant = platform.get("build_for_variant")
-
- if project.base == "bare":
- base_image, source_image = oci.Image.new_oci_image(
- f"{project.base}:latest",
- image_dir=image_dir,
- arch=build_for,
- variant=build_for_variant,
- )
- else:
- base_image, source_image = oci.Image.from_docker_registry(
- project.base,
- image_dir=image_dir,
- arch=build_for,
- variant=build_for_variant,
- )
- emit.progress(f"Retrieved base {project.base} for {build_for}", permanent=True)
-
- emit.progress(f"Extracting {base_image.image_name}")
- rootfs = base_image.extract_to(bundle_dir)
- emit.progress(f"Extracted {base_image.image_name}", permanent=True)
-
- # TODO: check if destination image already exists, etc.
- project_base_image = base_image.copy_to(
- f"{project.name}:rockcraft-base", image_dir=image_dir
- )
-
- base_digest = project_base_image.digest(source_image)
- step_name = "prime" if command_name == "pack" else command_name
-
- lifecycle = PartsLifecycle(
- project.parts,
- project_name=project.name,
- project_vars=project_vars,
- work_dir=work_dir,
- part_names=part_names,
- base_layer_dir=rootfs,
- base_layer_hash=base_digest,
- base=project.base,
- package_repositories=package_repositories,
- )
-
- if command_name == "clean":
- lifecycle.clean()
- return
-
- lifecycle.run(
- step_name,
- shell=getattr(parsed_args, "shell", False),
- shell_after=getattr(parsed_args, "shell_after", False),
- debug=getattr(parsed_args, "debug", False),
- )
-
- if command_name == "pack":
- _pack(
- prime_dir=lifecycle.prime_dir,
- project=project,
- project_base_image=project_base_image,
- base_digest=base_digest,
- rock_suffix=platform_entry,
- build_for=build_for,
- base_layer_dir=rootfs,
- )
-
-
-def _pack(
- *,
- prime_dir: Path,
- project: Project,
- project_base_image: oci.Image,
- base_digest: bytes,
- rock_suffix: str,
- build_for: str,
- base_layer_dir: Path,
-) -> str:
- """Create the rock image for a given architecture.
-
- :param lifecycle:
- The lifecycle object containing the primed payload for the rock.
- :param project_base_image:
- The Image for the base over which the payload was primed.
- :param base_digest:
- The digest of the base image, to add to the new image's metadata.
- :param rock_suffix:
- The suffix to append to the image's filename, after the name and version.
- :param build_for:
- The architecture of the built rock, to add as metadata.
- :param base_layer_dir:
- The directory where the rock's base image was extracted.
- """
- emit.progress("Creating new layer")
- new_image = project_base_image.add_layer(
- tag=project.version,
- new_layer_dir=prime_dir,
- base_layer_dir=base_layer_dir,
- )
- emit.progress("Created new layer")
-
- if project.run_user:
- emit.progress(f"Creating new user {project.run_user}")
- new_image.add_user(
- prime_dir=prime_dir,
- base_layer_dir=base_layer_dir,
- tag=project.version,
- username=project.run_user,
- uid=SUPPORTED_GLOBAL_USERNAMES[project.run_user]["uid"],
- )
-
- emit.progress(f"Setting the default OCI user to be {project.run_user}")
- new_image.set_default_user(project.run_user)
-
- emit.progress("Adding Pebble entrypoint")
-
- new_image.set_entrypoint()
-
- services = project.dict(exclude_none=True, by_alias=True).get("services", {})
-
- checks = project.dict(exclude_none=True, by_alias=True).get("checks", {})
-
- if services or checks:
- new_image.set_pebble_layer(
- services=services,
- checks=checks,
- name=project.name,
- tag=project.version,
- summary=project.summary,
- description=project.description,
- base_layer_dir=base_layer_dir,
- )
-
- if project.environment:
- new_image.set_environment(project.environment)
-
- # Set annotations and metadata, both dynamic and the ones based on user-provided properties
- # Also include the "created" timestamp, just before packing the image
- emit.progress("Adding metadata")
- oci_annotations, rock_metadata = project.generate_metadata(
- datetime.datetime.now(datetime.timezone.utc).isoformat(), base_digest
- )
- rock_metadata["architecture"] = build_for
- # TODO: add variant to rock_metadata too
- # if build_for_variant:
- # rock_metadata["variant"] = build_for_variant
- new_image.set_annotations(oci_annotations)
- new_image.set_control_data(rock_metadata)
- emit.progress("Metadata added")
-
- emit.progress("Exporting to OCI archive")
- archive_name = f"{project.name}_{project.version}_{rock_suffix}.rock"
- new_image.to_oci_archive(tag=project.version, filename=archive_name)
- emit.progress(f"Exported to OCI archive '{archive_name}'", permanent=True)
-
- return archive_name
-
-
-def run_in_provider(
- project: Project, command_name: str, parsed_args: "argparse.Namespace"
-) -> None:
- """Run lifecycle command in provider instance."""
- provider = providers.get_provider()
- providers.ensure_provider_is_available(provider)
-
- cmd = ["rockcraft", command_name]
-
- if hasattr(parsed_args, "parts"):
- cmd.extend(parsed_args.parts)
-
- mode = emit.get_mode().name.lower()
- cmd.append(f"--verbosity={mode}")
-
- if getattr(parsed_args, "shell", False):
- cmd.append("--shell")
- if getattr(parsed_args, "shell_after", False):
- cmd.append("--shell-after")
- if getattr(parsed_args, "debug", False):
- cmd.append("--debug")
-
- host_project_path = Path().absolute()
- instance_project_path = utils.get_managed_environment_project_path()
- instance_name = providers.get_instance_name(
- project_name=project.name, project_path=host_project_path
- )
- build_base = providers.ROCKCRAFT_BASE_TO_PROVIDER_BASE[str(project.build_base)]
-
- base_configuration = providers.get_base_configuration(
- alias=build_base,
- project_name=project.name,
- project_path=host_project_path,
- )
-
- emit.progress("Launching instance...")
- with provider.launched_environment(
- project_name=project.name,
- project_path=host_project_path,
- base_configuration=base_configuration,
- instance_name=instance_name,
- ) as instance:
- try:
- with emit.pause():
- instance.mount(
- host_source=host_project_path, target=instance_project_path
- )
- instance.execute_run(cmd, check=True, cwd=instance_project_path)
- except subprocess.CalledProcessError as err:
- raise ProviderError(
- f"Failed to execute {command_name} in instance."
- ) from err
- finally:
- providers.capture_logs_from_instance(instance)
-
-
-def clean_provider(project_name: str, project_path: Path) -> None:
- """Clean the provider environment.
-
- :param project_name: name of the project
- :param project_path: path of the project
- """
- emit.progress("Cleaning build provider")
- provider = providers.get_provider()
- instance_name = providers.get_instance_name(
- project_name=project_name, project_path=project_path
- )
- emit.debug(f"Cleaning instance {instance_name}")
- provider.clean_project_environments(instance_name=instance_name)
- emit.progress("Cleaned build provider", permanent=True)
-
-
-def _set_global_environment(info: ProjectInfo) -> None:
- """Set global environment variables."""
- info.global_environment.update(
- {
- "CRAFT_PROJECT_VERSION": info.get_project_var("version", raw_read=True),
- }
- )
-
-
-def _expand_environment(
- project_yaml: Dict[str, Any],
- *,
- project_vars: Dict[str, Any],
- work_dir: Path,
-) -> None:
- """Expand global variables in the provided dictionary values.
-
- :param project_yaml: A dictionary containing the rockcraft.yaml's contents.
- :param project_var: A dictionary with the project-specific variables.
- :param work_dir: The working directory.
- """
- info = ProjectInfo(
- application_name="rockcraft", # not used in environment expansion
- cache_dir=Path(), # not used in environment expansion
- project_name=project_yaml.get("name", ""),
- project_dirs=ProjectDirs(work_dir=work_dir),
- project_vars=project_vars,
- )
- _set_global_environment(info)
-
- expand_environment(project_yaml, info=info)
diff --git a/rockcraft/models/project.py b/rockcraft/models/project.py
index 7b4420571..d49c82cde 100644
--- a/rockcraft/models/project.py
+++ b/rockcraft/models/project.py
@@ -48,6 +48,7 @@
from overrides import override
from pydantic_yaml import YamlModelMixin
+from rockcraft.environment import expand_environment
from rockcraft.errors import ProjectLoadError, ProjectValidationError
from rockcraft.extensions import apply_extensions
from rockcraft.parts import part_has_overlay, validate_part
@@ -493,13 +494,12 @@ def from_yaml_file(
) -> "Project":
"""Instantiate this model from a YAML file."""
# pylint: disable=import-outside-toplevel
- from rockcraft.lifecycle import _expand_environment
data = load_project(path)
if work_dir is not None:
project_vars = {"version": data["version"]}
- _expand_environment(
+ expand_environment(
data,
project_vars=project_vars,
work_dir=work_dir,
diff --git a/rockcraft/parts.py b/rockcraft/parts.py
index a2ab62cb0..36ad00b16 100644
--- a/rockcraft/parts.py
+++ b/rockcraft/parts.py
@@ -15,258 +15,9 @@
# along with this program. If not, see .
"""Craft-parts lifecycle."""
-import contextlib
-import pathlib
-import subprocess
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict
import craft_parts
-from craft_archives import repo
-from craft_cli import emit
-from craft_parts import ActionType, Step, callbacks
-from craft_parts.errors import CallbackRegistrationError
-from xdg import BaseDirectory # type: ignore
-
-from rockcraft.errors import PartsLifecycleError
-
-_LIFECYCLE_STEPS = {
- "pull": Step.PULL,
- "overlay": Step.OVERLAY,
- "build": Step.BUILD,
- "stage": Step.STAGE,
- "prime": Step.PRIME,
-}
-
-
-craft_parts.Features(enable_overlay=True)
-
-
-class PartsLifecycle:
- """Create and manage the parts lifecycle.
-
- :param all_parts: A dictionary containing the parts defined in the project.
- :param work_dir: The working directory for parts processing.
- :param base_layer_dir: The path to the extracted base root filesystem.
- :param base_layer_hash: The base image digest.
-
- :raises PartsLifecycleError: On error initializing the parts lifecycle.
- """
-
- # _OVERLAY_CALLBACK_REGISTERED = False
-
- def __init__(
- self,
- all_parts: Dict[str, Any],
- *,
- project_name: str,
- work_dir: pathlib.Path,
- part_names: Optional[List[str]],
- base_layer_dir: pathlib.Path,
- base_layer_hash: bytes,
- base: str,
- package_repositories: Optional[List[Dict[str, Any]]] = None,
- project_vars: Optional[Dict[str, str]] = None,
- ):
- self._part_names = part_names
- self._package_repositories = package_repositories or []
-
- emit.progress("Initializing parts lifecycle")
-
- with contextlib.suppress(CallbackRegistrationError):
- callbacks.register_configure_overlay(_install_overlay_repositories)
-
- # set the cache dir for parts package management
- cache_dir = BaseDirectory.save_cache_path("rockcraft")
-
- try:
- self._lcm = craft_parts.LifecycleManager(
- {"parts": all_parts},
- application_name="rockcraft",
- work_dir=work_dir,
- cache_dir=cache_dir,
- base_layer_dir=base_layer_dir,
- base_layer_hash=base_layer_hash,
- ignore_local_sources=["*.rock"],
- base=base,
- package_repositories=self._package_repositories,
- project_name=project_name,
- project_vars=project_vars,
- )
- except craft_parts.PartsError as err:
- raise PartsLifecycleError.from_parts_error(err) from err
-
- def clean(self) -> None:
- """Remove lifecycle artifacts."""
- if self._part_names:
- message = "Cleaning parts: " + ", ".join(self._part_names)
- else:
- message = "Cleaning all parts"
-
- emit.progress(message)
- self._lcm.clean(part_names=self._part_names)
-
- @property
- def prime_dir(self) -> pathlib.Path:
- """Return the parts prime directory path."""
- return self._lcm.project_info.prime_dir
-
- @property
- def project_info(self) -> craft_parts.ProjectInfo:
- """Return the parts project info."""
- return self._lcm.project_info
-
- def run(
- self,
- step_name: str,
- *,
- shell: bool = False,
- shell_after: bool = False,
- debug: bool = False,
- ) -> None:
- """Run the parts lifecycle.
-
- :param step_name: The final step to execute.
- :param shell: Execute a shell instead of the target step.
- :param shell_after: Execute a shell after the target step.
- :param debug: Execute a shell on failure.
-
- :raises PartsLifecycleError: On error during lifecycle.
- :raises RuntimeError: On unexpected error.
- """
- # pylint: disable=too-many-branches,too-many-statements
- target_step = _LIFECYCLE_STEPS.get(step_name)
- if not target_step:
- raise RuntimeError(f"Invalid target step {step_name!r}")
-
- if shell:
- # convert shell to shell_after for the previous step
- previous_steps = target_step.previous_steps()
- target_step = previous_steps[-1] if previous_steps else None
- shell_after = True
-
- try:
- if target_step:
- actions = self._lcm.plan(target_step, part_names=self._part_names)
- else:
- actions = []
-
- if self._package_repositories:
- emit.progress("Installing package repositories")
- self._install_package_repositories()
-
- emit.progress("Executing parts lifecycle")
-
- with self._lcm.action_executor() as aex:
- for action in actions:
- message = _action_message(action)
- emit.progress(f"Executing parts lifecycle: {message}")
- with emit.open_stream("Executing action") as stream:
- aex.execute(action, stdout=stream, stderr=stream)
- emit.progress(f"Executed: {message}", permanent=True)
-
- if shell_after:
- launch_shell()
-
- emit.progress("Executed parts lifecycle", permanent=True)
- except craft_parts.PartsError as err:
- if debug:
- emit.progress(str(err), permanent=True)
- launch_shell()
- raise PartsLifecycleError.from_parts_error(err) from err
- except RuntimeError as err:
- if debug:
- emit.progress(str(err), permanent=True)
- launch_shell()
- raise RuntimeError(f"Parts processing internal error: {err}") from err
- except OSError as err:
- msg = err.strerror
- if err.filename:
- msg = f"{err.filename}: {msg}"
- if debug:
- emit.progress(msg, permanent=True)
- launch_shell()
- raise PartsLifecycleError(msg) from err
- except Exception as err:
- if debug:
- emit.progress(str(err), permanent=True)
- launch_shell()
- raise PartsLifecycleError(str(err)) from err
-
- def _install_package_repositories(self) -> None:
- """Install package repositories in the environment."""
- if not self._package_repositories:
- emit.debug("No package repositories specified, none to install.")
- return
-
- refresh_required = repo.install(
- self._package_repositories, key_assets=pathlib.Path("/dev/null")
- )
- if refresh_required:
- emit.progress("Refreshing repositories")
- self._lcm.refresh_packages_list()
-
- emit.progress("Package repositories installed", permanent=True)
-
-
-def launch_shell(*, cwd: Optional[pathlib.Path] = None) -> None:
- """Launch a user shell for debugging environment.
-
- :param cwd: Working directory to start user in.
- """
- emit.progress("Launching shell on build environment...", permanent=True)
- with emit.pause():
- subprocess.run(["bash"], check=False, cwd=cwd)
-
-
-def _install_overlay_repositories(overlay_dir, project_info):
- if project_info.base != "bare":
- package_repositories = project_info.package_repositories
- repo.install_in_root(
- project_repositories=package_repositories,
- root=overlay_dir,
- key_assets=pathlib.Path("/dev/null"),
- )
-
-
-def _action_message(action: craft_parts.Action) -> str:
- msg = {
- Step.PULL: {
- ActionType.RUN: "pull",
- ActionType.RERUN: "repull",
- ActionType.SKIP: "skip pull",
- ActionType.UPDATE: "update sources for",
- },
- Step.OVERLAY: {
- ActionType.RUN: "overlay",
- ActionType.RERUN: "re-overlay",
- ActionType.SKIP: "skip overlay",
- ActionType.UPDATE: "update overlay for",
- ActionType.REAPPLY: "reapply",
- },
- Step.BUILD: {
- ActionType.RUN: "build",
- ActionType.RERUN: "rebuild",
- ActionType.SKIP: "skip build",
- ActionType.UPDATE: "update build for",
- },
- Step.STAGE: {
- ActionType.RUN: "stage",
- ActionType.RERUN: "restage",
- ActionType.SKIP: "skip stage",
- },
- Step.PRIME: {
- ActionType.RUN: "prime",
- ActionType.RERUN: "re-prime",
- ActionType.SKIP: "skip prime",
- },
- }
-
- message = f"{msg[action.step][action.action_type]} {action.part_name}"
-
- if action.reason:
- message += f" ({action.reason})"
-
- return message
def validate_part(data: Dict[str, Any]) -> None:
diff --git a/rockcraft/providers.py b/rockcraft/providers.py
deleted file mode 100644
index f5cf88210..000000000
--- a/rockcraft/providers.py
+++ /dev/null
@@ -1,196 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2022 Canonical Ltd.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-
-"""Rockcraft-specific code to interface with craft-providers."""
-
-import os
-import sys
-from pathlib import Path
-from typing import Dict, Optional
-
-from craft_cli import emit
-from craft_providers import Provider, ProviderError, bases, executor
-from craft_providers.actions.snap_installer import Snap
-from craft_providers.lxd import LXDProvider
-from craft_providers.multipass import MultipassProvider
-
-from .utils import (
- confirm_with_user,
- get_managed_environment_log_path,
- get_managed_environment_snap_channel,
-)
-
-ROCKCRAFT_BASE_TO_PROVIDER_BASE = {
- "ubuntu:18.04": bases.BuilddBaseAlias.BIONIC,
- "ubuntu:20.04": bases.BuilddBaseAlias.FOCAL,
- "ubuntu:22.04": bases.BuilddBaseAlias.JAMMY,
-}
-
-
-def get_command_environment() -> Dict[str, Optional[str]]:
- """Construct the required environment."""
- env = bases.buildd.default_command_environment()
- env["CRAFT_MANAGED_MODE"] = "1"
-
- # Pass-through host environment that target may need.
- for env_key in ["http_proxy", "https_proxy", "no_proxy"]:
- if env_key in os.environ:
- env[env_key] = os.environ[env_key]
-
- return env
-
-
-def get_instance_name(*, project_name: str, project_path: Path) -> str:
- """Formulate the name for an instance using each of the given parameters.
-
- Incorporate each of the parameters into the name to come up with a
- predictable naming schema that avoids name collisions across multiple
- projects.
-
- :param project_name: Name of the project.
- :param project_path: Directory of the project.
- """
- return "-".join(
- [
- "rockcraft",
- project_name,
- str(project_path.stat().st_ino),
- ]
- )
-
-
-def capture_logs_from_instance(instance: executor.Executor) -> None:
- """Capture and emit rockcraft logs from an instance.
-
- :param instance: instance to retrieve logs from
- """
- source_log_path = get_managed_environment_log_path()
- with instance.temporarily_pull_file(
- source=source_log_path, missing_ok=True
- ) as log_path:
- if log_path:
- emit.debug("Logs retrieved from managed instance:")
- with open(log_path, "r") as log_file:
- for line in log_file:
- emit.debug(":: " + line.rstrip())
- else:
- emit.debug(
- f"Could not find log file {source_log_path.as_posix()} in instance."
- )
-
-
-def get_base_configuration(
- *, alias: bases.BuilddBaseAlias, project_name: str, project_path: Path
-) -> bases.BuilddBase:
- """Create a BuilddBase configuration for rockcraft."""
- instance_name = get_instance_name(
- project_name=project_name,
- project_path=project_path,
- )
-
- # injecting a snap on a non-linux system is not supported, so default to
- # install rockcraft from the store's stable channel
- snap_channel = get_managed_environment_snap_channel()
- if sys.platform != "linux" and not snap_channel:
- snap_channel = "stable"
-
- return bases.BuilddBase(
- alias=alias,
- compatibility_tag=f"rockcraft-{bases.BuilddBase.compatibility_tag}.0",
- environment=get_command_environment(),
- hostname=instance_name,
- snaps=[
- Snap(
- name="rockcraft",
- channel=snap_channel,
- classic=True,
- )
- ],
- packages=["gpg", "dirmngr"],
- )
-
-
-def ensure_provider_is_available(provider: Provider) -> None:
- """Ensure provider is installed, running, and properly configured.
-
- If the provider is not installed, the user is prompted to install it.
-
- :param instance: the provider to ensure is available
-
- :raises ProviderError: if provider is unknown, not available, or if the user
- chooses not to install the provider.
- """
- if isinstance(provider, LXDProvider):
- if not LXDProvider.is_provider_installed() and not confirm_with_user(
- "LXD is required but not installed. Do you wish to install LXD and configure "
- "it with the defaults?",
- default=False,
- ):
- raise ProviderError(
- "LXD is required, but not installed. Visit https://snapcraft.io/lxd "
- "for instructions on how to install the LXD snap for your distribution",
- )
- LXDProvider.ensure_provider_is_available()
- elif isinstance(provider, MultipassProvider):
- if not MultipassProvider.is_provider_installed() and not confirm_with_user(
- "Multipass is required but not installed. Do you wish to install Multipass"
- " and configure it with the defaults?",
- default=False,
- ):
- raise ProviderError(
- "Multipass is required, but not installed. Visit https://multipass.run/"
- "for instructions on installing Multipass for your operating system."
- )
- MultipassProvider.ensure_provider_is_available()
- else:
- raise ProviderError("cannot install unknown provider")
-
-
-def get_provider() -> Provider:
- """Get the configured or appropriate provider for the host OS.
-
- To determine the appropriate provider,
- (1) get the provider from the environment variable `ROCKCRAFT_PROVIDER`
- (2) default to platform default (LXD on Linux, otherwise Multipass)
-
- :return: Provider instance.
- """
- env_provider = os.getenv("ROCKCRAFT_PROVIDER")
-
- # (1) get the provider from the environment variable `ROCKCRAFT_PROVIDER`
- if env_provider:
- emit.debug(
- f"Using provider {env_provider!r} from environmental variable "
- "'ROCKCRAFT_PROVIDER'"
- )
- chosen_provider = env_provider
-
- # (2) default to platform default (LXD on Linux, otherwise Multipass)
- elif sys.platform == "linux":
- emit.debug("Using default provider 'lxd' on linux system")
- chosen_provider = "lxd"
- else:
- emit.debug("Using default provider 'multipass' on non-linux system")
- chosen_provider = "multipass"
-
- # return the chosen provider
- if chosen_provider == "lxd":
- return LXDProvider(lxd_project="rockcraft")
- if chosen_provider == "multipass":
- return MultipassProvider()
-
- raise ValueError(f"unsupported provider specified: {chosen_provider!r}")
diff --git a/rockcraft/services/package.py b/rockcraft/services/package.py
index 795b0673d..66cda0045 100644
--- a/rockcraft/services/package.py
+++ b/rockcraft/services/package.py
@@ -18,15 +18,18 @@
from __future__ import annotations
+import datetime
import pathlib
import typing
from typing import cast
from craft_application import AppMetadata, PackageService, models, util
+from craft_cli import emit
from overrides import override
-from rockcraft import errors
+from rockcraft import errors, oci
from rockcraft.models import Project
+from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES
if typing.TYPE_CHECKING:
from rockcraft.services import RockcraftServiceFactory
@@ -55,11 +58,8 @@ def pack(self, prime_dir: pathlib.Path, dest: pathlib.Path) -> list[pathlib.Path
:param dest: Directory into which to write the package(s).
:returns: A list of paths to created packages.
"""
- # pylint: disable=import-outside-toplevel
- # This import stays here until we refactor 'lifecycle' completely out.
- from rockcraft.lifecycle import _pack
-
# This inner import is necessary to resolve a cyclic import
+ # pylint: disable=import-outside-toplevel
from rockcraft.services import RockcraftServiceFactory
services = cast(RockcraftServiceFactory, self._services)
@@ -113,3 +113,93 @@ def metadata(self) -> models.BaseMetadata:
"""Get the metadata model for this project."""
# nop (no metadata file for Rockcraft)
return models.BaseMetadata()
+
+
+def _pack(
+ *,
+ prime_dir: pathlib.Path,
+ project: Project,
+ project_base_image: oci.Image,
+ base_digest: bytes,
+ rock_suffix: str,
+ build_for: str,
+ base_layer_dir: pathlib.Path,
+) -> str:
+ """Create the rock image for a given architecture.
+
+ :param lifecycle:
+ The lifecycle object containing the primed payload for the rock.
+ :param project_base_image:
+ The Image for the base over which the payload was primed.
+ :param base_digest:
+ The digest of the base image, to add to the new image's metadata.
+ :param rock_suffix:
+ The suffix to append to the image's filename, after the name and version.
+ :param build_for:
+ The architecture of the built rock, to add as metadata.
+ :param base_layer_dir:
+ The directory where the rock's base image was extracted.
+ """
+ emit.progress("Creating new layer")
+ new_image = project_base_image.add_layer(
+ tag=project.version,
+ new_layer_dir=prime_dir,
+ base_layer_dir=base_layer_dir,
+ )
+ emit.progress("Created new layer")
+
+ if project.run_user:
+ emit.progress(f"Creating new user {project.run_user}")
+ new_image.add_user(
+ prime_dir=prime_dir,
+ base_layer_dir=base_layer_dir,
+ tag=project.version,
+ username=project.run_user,
+ uid=SUPPORTED_GLOBAL_USERNAMES[project.run_user]["uid"],
+ )
+
+ emit.progress(f"Setting the default OCI user to be {project.run_user}")
+ new_image.set_default_user(project.run_user)
+
+ emit.progress("Adding Pebble entrypoint")
+
+ new_image.set_entrypoint()
+
+ services = project.dict(exclude_none=True, by_alias=True).get("services", {})
+
+ checks = project.dict(exclude_none=True, by_alias=True).get("checks", {})
+
+ if services or checks:
+ new_image.set_pebble_layer(
+ services=services,
+ checks=checks,
+ name=project.name,
+ tag=project.version,
+ summary=project.summary,
+ description=project.description,
+ base_layer_dir=base_layer_dir,
+ )
+
+ if project.environment:
+ new_image.set_environment(project.environment)
+
+ # Set annotations and metadata, both dynamic and the ones based on user-provided properties
+ # Also include the "created" timestamp, just before packing the image
+ emit.progress("Adding metadata")
+ oci_annotations, rock_metadata = project.generate_metadata(
+ datetime.datetime.now(datetime.timezone.utc).isoformat(), base_digest
+ )
+ rock_metadata["architecture"] = build_for
+ # TODO: add variant to rock_metadata too
+ # if build_for_variant:
+ # rock_metadata["variant"] = build_for_variant
+ new_image.set_annotations(oci_annotations)
+ new_image.set_control_data(rock_metadata)
+ emit.progress("Metadata added")
+
+ emit.progress("Exporting to OCI archive")
+ archive_name = f"{project.name}_{project.version}_{rock_suffix}.rock"
+ new_image.to_oci_archive(tag=project.version, filename=archive_name)
+ emit.progress(f"Exported to OCI archive '{archive_name}'", permanent=True)
+
+ return archive_name
diff --git a/spread.yaml b/spread.yaml
index 4b26cc4c0..50f8b114d 100644
--- a/spread.yaml
+++ b/spread.yaml
@@ -94,7 +94,7 @@ restore-each: |
if lxc project info rockcraft > /dev/null 2>&1 ; then
for instance in $(lxc --project=rockcraft list -c n --format csv); do
# Don't remove the base instance, we want to re-use it between tests
- if ! [[ $instance =~ ^base-instance-rockcraft* ]]; then
+ if ! [[ $instance =~ ^base-instance-* ]]; then
lxc --project=rockcraft delete --force "$instance"
fi
done
diff --git a/tests/conftest.py b/tests/conftest.py
index 61db670b4..22d39c9d6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
import os
+from pathlib import Path
import pytest
import xdg # type: ignore
@@ -100,3 +101,137 @@ def assert_recorded(self, expected):
def assert_recorded_raw(self, expected):
"""Verify that the given messages (with specific level) were recorded consecutively."""
self._check(expected, self.raw)
+
+
+@pytest.fixture
+def extra_project_params():
+ """Configuration fixture for the Project used by the default services."""
+ return {}
+
+
+@pytest.fixture
+def default_project(extra_project_params):
+ from craft_application.models import VersionStr
+
+ from rockcraft.models.project import NameStr, Project
+
+ parts = extra_project_params.pop("parts", {})
+
+ return Project(
+ name=NameStr("default"),
+ version=VersionStr("1.0"),
+ summary="default project",
+ description="default project",
+ base="ubuntu:22.04",
+ parts=parts,
+ license="MIT",
+ platforms={"amd64": None},
+ **extra_project_params,
+ )
+
+
+@pytest.fixture
+def default_factory(default_project):
+ from rockcraft.application import APP_METADATA
+ from rockcraft.services import RockcraftServiceFactory
+
+ factory = RockcraftServiceFactory(
+ app=APP_METADATA,
+ project=default_project,
+ )
+ factory.set_kwargs("image", work_dir=Path("work"), build_for="amd64")
+ return factory
+
+
+@pytest.fixture
+def default_image_info():
+ from rockcraft import oci
+ from rockcraft.services.image import ImageInfo
+
+ return ImageInfo(
+ base_image=oci.Image(image_name="fake_image", path=Path()),
+ base_layer_dir=Path(),
+ base_digest=b"deadbeef",
+ )
+
+
+@pytest.fixture
+def default_application(default_factory, default_project):
+ from rockcraft.application import APP_METADATA, Rockcraft
+
+ return Rockcraft(APP_METADATA, default_factory)
+
+
+@pytest.fixture
+def image_service(default_project, default_factory, tmp_path):
+ from rockcraft.application import APP_METADATA
+ from rockcraft.services import RockcraftImageService
+
+ return RockcraftImageService(
+ app=APP_METADATA,
+ project=default_project,
+ services=default_factory,
+ work_dir=tmp_path,
+ build_for="amd64",
+ )
+
+
+@pytest.fixture
+def provider_service(default_project, default_factory, tmp_path):
+ from rockcraft.application import APP_METADATA
+ from rockcraft.services import RockcraftProviderService
+
+ return RockcraftProviderService(
+ app=APP_METADATA,
+ project=default_project,
+ services=default_factory,
+ work_dir=tmp_path,
+ )
+
+
+@pytest.fixture
+def package_service(default_project, default_factory):
+ from rockcraft.application import APP_METADATA
+ from rockcraft.services import RockcraftPackageService
+
+ return RockcraftPackageService(
+ app=APP_METADATA,
+ project=default_project,
+ services=default_factory,
+ platform="amd64",
+ build_for="amd64",
+ )
+
+
+@pytest.fixture
+def lifecycle_service(default_project, default_factory):
+ from rockcraft.application import APP_METADATA
+ from rockcraft.services import RockcraftLifecycleService
+
+ return RockcraftLifecycleService(
+ app=APP_METADATA,
+ project=default_project,
+ services=default_factory,
+ work_dir=Path("work/"),
+ cache_dir=Path("cache/"),
+ build_for="amd64",
+ )
+
+
+@pytest.fixture
+def mock_obtain_image(default_factory, mocker):
+ """Mock and return the "obtain_image()" method of the default image service."""
+ image_service = default_factory.image
+ return mocker.patch.object(image_service, "obtain_image")
+
+
+@pytest.fixture
+def run_lifecycle(mocker):
+ """Helper to call testing.run_mocked_lifecycle()."""
+
+ def _inner(**kwargs):
+ from tests.testing.lifecycle import run_mocked_lifecycle
+
+ return run_mocked_lifecycle(mocker=mocker, **kwargs)
+
+ return _inner
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
new file mode 100644
index 000000000..eee9de05a
--- /dev/null
+++ b/tests/integration/conftest.py
@@ -0,0 +1,15 @@
+# This file is part of Rockcraft.
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU 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 warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
diff --git a/tests/integration/plugins/test_python_plugin.py b/tests/integration/plugins/test_python_plugin.py
index 00dd78311..862350d74 100644
--- a/tests/integration/plugins/test_python_plugin.py
+++ b/tests/integration/plugins/test_python_plugin.py
@@ -19,14 +19,15 @@
from pathlib import Path
import pytest
+from craft_application import errors
from craft_cli import EmitterMode, emit
from craft_parts.errors import OsReleaseVersionIdError
from craft_parts.utils.os_utils import OsRelease
-from rockcraft import errors, plugins
+from rockcraft import plugins
from rockcraft.models.project import Project
-from rockcraft.parts import PartsLifecycle
from rockcraft.plugins.python_plugin import SITECUSTOMIZE_TEMPLATE
+from tests.testing.project import create_project
from tests.util import ubuntu_only
pytestmark = ubuntu_only
@@ -46,9 +47,8 @@ def setup_python_test(monkeypatch):
plugins.register()
-def run_lifecycle(base: str, work_dir: Path, extra_part_props=None) -> None:
+def create_python_project(base, extra_part_props=None) -> Project:
source = Path(__file__).parent / "python_source"
-
extra = extra_part_props or {}
parts = {
@@ -60,18 +60,11 @@ def run_lifecycle(base: str, work_dir: Path, extra_part_props=None) -> None:
}
}
- lifecycle = PartsLifecycle(
- all_parts=parts,
- work_dir=work_dir,
- part_names=None,
- base_layer_dir=Path("unused"),
- base_layer_hash=b"deadbeef",
+ return create_project(
base=base,
- project_name="python-project",
+ parts=parts,
)
- lifecycle.run("stage")
-
@dataclass
class ExpectedValues:
@@ -107,8 +100,9 @@ class ExpectedValues:
@pytest.mark.parametrize("base", tuple(UBUNTU_BASES))
-def test_python_plugin_ubuntu(base, tmp_path):
- run_lifecycle(base, tmp_path)
+def test_python_plugin_ubuntu(base, tmp_path, run_lifecycle):
+ project = create_python_project(base=base)
+ run_lifecycle(project=project, work_dir=tmp_path)
bin_dir = tmp_path / "stage/bin"
@@ -134,8 +128,9 @@ def test_python_plugin_ubuntu(base, tmp_path):
assert not pyvenv_cfg.is_file()
-def test_python_plugin_bare(tmp_path):
- run_lifecycle("bare", tmp_path)
+def test_python_plugin_bare(tmp_path, run_lifecycle):
+ project = create_python_project(base="bare")
+ run_lifecycle(project=project, work_dir=tmp_path)
bin_dir = tmp_path / "stage/bin"
@@ -168,7 +163,7 @@ def test_python_plugin_bare(tmp_path):
assert not pyvenv_cfg.is_file()
-def test_python_plugin_invalid_interpreter(tmp_path):
+def test_python_plugin_invalid_interpreter(tmp_path, run_lifecycle):
"""Check that an invalid value for PARTS_PYTHON_INTERPRETER fails the build"""
log_filepath = tmp_path / "log.txt"
emit.init(EmitterMode.VERBOSE, "rockcraft", "rockcraft", log_filepath=log_filepath)
@@ -177,8 +172,10 @@ def test_python_plugin_invalid_interpreter(tmp_path):
"build-environment": [{"PARTS_PYTHON_INTERPRETER": "/full/path/python3"}]
}
+ project = create_python_project(base="bare", extra_part_props=extra_part)
+
with pytest.raises(errors.PartsLifecycleError):
- run_lifecycle("bare", tmp_path, extra_part_props=extra_part)
+ run_lifecycle(project=project, work_dir=tmp_path)
emit.ended_ok()
diff --git a/tests/integration/services/__init__.py b/tests/integration/services/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/integration/test_parts.py b/tests/integration/services/test_lifecycle.py
similarity index 84%
rename from tests/integration/test_parts.py
rename to tests/integration/services/test_lifecycle.py
index 49bc167a8..eca213d1f 100644
--- a/tests/integration/test_parts.py
+++ b/tests/integration/services/test_lifecycle.py
@@ -19,7 +19,8 @@
import pytest
from craft_parts import overlays
-from rockcraft.parts import PartsLifecycle
+from rockcraft.services import lifecycle
+from tests.testing.project import create_project
from tests.util import jammy_only
pytestmark = [jammy_only, pytest.mark.usefixtures("reset_callbacks")]
@@ -27,7 +28,7 @@
# pyright: reportPrivateImportUsage=false
-def test_package_repositories_in_overlay(new_dir, mocker):
+def test_package_repositories_in_overlay(new_dir, mocker, run_lifecycle):
# Mock overlay-related calls that need root; we won't be actually installing
# any packages, just checking that the repositories are correctly installed
# in the overlay.
@@ -57,23 +58,24 @@ def test_package_repositories_in_overlay(new_dir, mocker):
{"type": "apt", "ppa": "mozillateam/ppa", "priority": "always"}
]
- lifecycle = PartsLifecycle(
- all_parts=parts,
- work_dir=work_dir,
- part_names=None,
- base_layer_dir=base_layer_dir,
- base_layer_hash=b"deadbeef",
- base="fake-ubuntu",
- package_repositories=package_repositories,
- project_name="package-repos",
- )
# Mock the installation of package repositories in the base system, as that
# is undesired and will fail without root.
mocker.patch.object(lifecycle, "_install_package_repositories")
- lifecycle.run("prime")
+ project = create_project(
+ base="ubuntu:22.04",
+ parts=parts,
+ package_repositories=package_repositories,
+ )
+ lifecycle_service = run_lifecycle(
+ project=project,
+ work_dir=work_dir,
+ base_layer_dir=base_layer_dir,
+ )
+ # pylint: disable=protected-access
+ parts_lifecycle = lifecycle_service._lcm
- overlay_apt = lifecycle.project_info.overlay_dir / "packages/etc/apt"
+ overlay_apt = parts_lifecycle.project_info.overlay_dir / "packages/etc/apt"
assert overlay_apt.is_dir()
# Checking that the files are present should be enough
diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_application.py
similarity index 73%
rename from tests/integration/test_lifecycle.py
rename to tests/integration/test_application.py
index 3ba39a559..a2c887d72 100644
--- a/tests/integration/test_lifecycle.py
+++ b/tests/integration/test_application.py
@@ -13,13 +13,15 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-import argparse
+import sys
from pathlib import Path
import pytest
import yaml
-from rockcraft import lifecycle, oci
+from rockcraft.application import APP_METADATA, Rockcraft
+from rockcraft.services import RockcraftServiceFactory
+from rockcraft.services.image import ImageInfo, RockcraftImageService
from tests.util import jammy_only
pytestmark = [jammy_only, pytest.mark.usefixtures("reset_callbacks")]
@@ -52,21 +54,34 @@
"""
-def test_global_environment(new_dir, monkeypatch, mocker, reset_callbacks):
+def test_global_environment(
+ new_dir,
+ monkeypatch,
+ mocker,
+):
"""Test our additions to the global environment that is available to the
build process."""
- # Mock some OCI operations that are not relevant to this test.
rootfs = Path(new_dir) / "rootfs"
rootfs.mkdir()
fake_digest = b"deadbeef"
- mocker.patch.object(oci.Image, "extract_to", return_value=rootfs)
- mocker.patch.object(oci.Image, "digest", return_value=fake_digest)
+
+ image_info = ImageInfo(
+ base_image=mocker.MagicMock(), base_layer_dir=rootfs, base_digest=fake_digest
+ )
+ mocker.patch.object(RockcraftImageService, "obtain_image", return_value=image_info)
Path("rockcraft.yaml").write_text(ROCKCRAFT_YAML)
- args = argparse.Namespace(destructive_mode=True)
- lifecycle.run("stage", args)
+ monkeypatch.setattr(sys, "argv", ["rockcraft", "prime", "--destructive-mode"])
+
+ services = RockcraftServiceFactory(
+ # type: ignore # type: ignore[call-arg]
+ app=APP_METADATA,
+ )
+
+ app = Rockcraft(app=APP_METADATA, services=services)
+ app.run()
variables_yaml = Path(new_dir) / "stage/variables.yaml"
assert variables_yaml.is_file()
diff --git a/tests/integration/test_oci.py b/tests/integration/test_oci.py
index 72109472f..8f9a64161 100644
--- a/tests/integration/test_oci.py
+++ b/tests/integration/test_oci.py
@@ -20,8 +20,10 @@
from pathlib import Path
from typing import Callable, List, Tuple
+import pytest
+
from rockcraft import oci
-from rockcraft.parts import PartsLifecycle
+from rockcraft.services.image import ImageInfo
from tests.util import jammy_only
pytestmark = jammy_only
@@ -109,10 +111,6 @@ def populate_base_layer(base_layer_dir):
new_layer_dir = Path("new")
new_layer_dir.mkdir()
- # Create the following structure to use as a new layer:
- # /bin/new_bin_file
- # /lib/new_lib_file
- # /tmp/new_tmp_file
for target in targets + ["tmp"]:
new_target_dir = new_layer_dir / target
new_target_dir.mkdir()
@@ -130,7 +128,28 @@ def populate_base_layer(base_layer_dir):
]
-def test_add_layer_with_overlay(new_dir, mocker):
+@pytest.fixture
+def extra_project_params():
+ """Fixture used to configure the Project used by the default test services."""
+ return {
+ "parts": {
+ "with-overlay": {
+ "plugin": "nil",
+ "override-build": "touch ${CRAFT_PART_INSTALL}/file_from_override_build",
+ "overlay-script": textwrap.dedent(
+ """
+ cd ${CRAFT_OVERLAY}
+ unlink bin
+ mkdir bin
+ touch bin/file_from_overlay_script
+ """
+ ),
+ }
+ }
+ }
+
+
+def test_add_layer_with_overlay(new_dir, mocker, lifecycle_service, mock_obtain_image):
"""Test "overwriting" directories in the base layer via overlays."""
def populate_base_layer(base_layer_dir):
@@ -139,42 +158,28 @@ def populate_base_layer(base_layer_dir):
image, base_layer_dir = create_base_image(Path(new_dir), populate_base_layer)
- parts = {
- "with-overlay": {
- "plugin": "nil",
- "override-build": "touch ${CRAFT_PART_INSTALL}/file_from_override_build",
- "overlay-script": textwrap.dedent(
- """
- cd ${CRAFT_OVERLAY}
- unlink bin
- mkdir bin
- touch bin/file_from_overlay_script
- """
- ),
- }
- }
- work_dir = Path()
+ image_info = ImageInfo(
+ base_image=image,
+ base_layer_dir=base_layer_dir,
+ base_digest=b"deadbeef",
+ )
+ mock_obtain_image.return_value = image_info
# Mock os.geteuid() because currently craft-parts doesn't allow overlays
# without superuser privileges.
mock_geteuid = mocker.patch.object(os, "geteuid", return_value=0)
- lifecycle = PartsLifecycle(
- all_parts=parts,
- work_dir=work_dir,
- part_names=None,
- base_layer_dir=base_layer_dir,
- base_layer_hash=b"deadbeef",
- base="unused",
- project_name="overlay",
- )
-
+ # Setup the service, to create the LifecycleManager.
+ lifecycle_service.setup()
assert mock_geteuid.called
- lifecycle.run("prime")
+ # Run the lifecycle.
+ lifecycle_service.run("prime")
new_image = image.add_layer(
- tag="new", new_layer_dir=lifecycle.prime_dir, base_layer_dir=base_layer_dir
+ tag="new",
+ new_layer_dir=lifecycle_service.prime_dir,
+ base_layer_dir=base_layer_dir,
)
assert get_names_in_layer(new_image) == [
diff --git a/tests/spread/general/invalid-name/task.yaml b/tests/spread/general/invalid-name/task.yaml
index 368891f8c..c44d99b56 100644
--- a/tests/spread/general/invalid-name/task.yaml
+++ b/tests/spread/general/invalid-name/task.yaml
@@ -4,5 +4,5 @@ execute: |
for name in a_a a@a a--a aa-
do
sed "s/placeholder-name/$name/" rockcraft.orig.yaml > rockcraft.yaml
- rockcraft 2>&1 >/dev/null | MATCH "Invalid name for ROCK"
+ rockcraft pack 2>&1 >/dev/null | MATCH "Invalid name for ROCK"
done
\ No newline at end of file
diff --git a/tests/spread/general/overlay-logs/task.yaml b/tests/spread/general/overlay-logs/task.yaml
index fb72f9b30..dcb395be7 100644
--- a/tests/spread/general/overlay-logs/task.yaml
+++ b/tests/spread/general/overlay-logs/task.yaml
@@ -5,7 +5,7 @@ execute: |
error_pattern="E: Unable to locate package idontexist"
# Check that the message is shown to the "terminal"
- rockcraft --verbosity=debug 2>&1 >/dev/null | MATCH "$error_pattern"
+ rockcraft pack --verbosity=debug 2>&1 >/dev/null | MATCH "$error_pattern"
# Check that the message was actually written to the logfile.
rockcraft_log_file=$(find /root/.local/state/rockcraft/log/ -name 'rockcraft*.log' | sort -n | tail -n1)
diff --git a/tests/spread/general/plugin-python-error/task.yaml b/tests/spread/general/plugin-python-error/task.yaml
index 6f4c0f087..2b30f1de9 100644
--- a/tests/spread/general/plugin-python-error/task.yaml
+++ b/tests/spread/general/plugin-python-error/task.yaml
@@ -4,4 +4,4 @@ environment:
BASE/bare: "bare"
execute: |
sed "s/placeholder-base/$BASE/" rockcraft.orig.yaml > rockcraft.yaml
- rockcraft -v 2>&1 >/dev/null | MATCH ":: No suitable Python interpreter found, giving up."
+ rockcraft pack -v 2>&1 >/dev/null | MATCH ":: No suitable Python interpreter found, giving up."
diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/testing/lifecycle.py b/tests/testing/lifecycle.py
new file mode 100644
index 000000000..2ae21a5db
--- /dev/null
+++ b/tests/testing/lifecycle.py
@@ -0,0 +1,61 @@
+# This file is part of Rockcraft.
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU 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 warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+"""Project-related utility functions for running lifecycles."""
+from __future__ import annotations
+
+import pathlib
+from typing import cast
+
+from rockcraft.application import APP_METADATA
+from rockcraft.models import Project
+from rockcraft.services import RockcraftLifecycleService, RockcraftServiceFactory
+from rockcraft.services.image import ImageInfo
+
+
+def run_mocked_lifecycle(
+ *,
+ project: Project,
+ work_dir: pathlib.Path,
+ mocker,
+ base_layer_dir: pathlib.Path | None = None,
+) -> RockcraftLifecycleService:
+ """Run a project's lifecycle with a mocked base image."""
+
+ factory = RockcraftServiceFactory(APP_METADATA)
+ factory.set_kwargs(
+ "lifecycle",
+ work_dir=work_dir,
+ cache_dir=work_dir / "cache_dir",
+ build_for="amd64",
+ )
+ factory.set_kwargs(
+ "image",
+ work_dir=work_dir,
+ build_for="amd64",
+ )
+ factory.project = project
+
+ image_info = ImageInfo(
+ base_image=mocker.MagicMock(),
+ base_layer_dir=base_layer_dir or pathlib.Path("unused"),
+ base_digest=b"deadbeef",
+ )
+ mocker.patch.object(factory.image, "obtain_image", return_value=image_info)
+
+ lifecycle_service = factory.lifecycle
+ lifecycle_service.run("stage")
+
+ return cast(RockcraftLifecycleService, lifecycle_service)
diff --git a/tests/testing/project.py b/tests/testing/project.py
new file mode 100644
index 000000000..551bf2885
--- /dev/null
+++ b/tests/testing/project.py
@@ -0,0 +1,41 @@
+# This file is part of Rockcraft.
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU 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 warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+"""Project-related utility functions for testing."""
+from rockcraft.models import Project
+
+
+def create_project(**kwargs) -> Project:
+ """Utility function to create test Projects with defaults."""
+ base = kwargs.get("base", "ubuntu:22.04")
+
+ build_base = kwargs.get("build_base")
+ if not build_base:
+ build_base = base if base != "bare" else "ubuntu:22.04"
+
+ return Project.unmarshal(
+ {
+ "name": kwargs.get("name", "default"),
+ "version": kwargs.get("version", "1.0"),
+ "summary": kwargs.get("summary", "default project"),
+ "description": kwargs.get("description", "default project"),
+ "base": base,
+ "build-base": build_base,
+ "parts": kwargs.get("parts", {}),
+ "license": kwargs.get("license", "MIT"),
+ "platforms": kwargs.get("platforms", {"amd64": None}),
+ "package-repositories": kwargs.get("package_repositories", None),
+ }
+ )
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 146e3c0cd..c02d00700 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -85,114 +85,3 @@ def launched_environment(
yield mock_instance
return FakeProvider()
-
-
-@pytest.fixture
-def extra_project_params():
- return {}
-
-
-@pytest.fixture
-def default_project(extra_project_params):
- from craft_application.models import VersionStr
-
- from rockcraft.models.project import NameStr, Project
-
- return Project(
- name=NameStr("default"),
- version=VersionStr("1.0"),
- summary="default project",
- description="default project",
- base="ubuntu:22.04",
- parts={},
- license="MIT",
- platforms={"amd64": None},
- **extra_project_params,
- )
-
-
-@pytest.fixture
-def default_factory(default_project):
- from rockcraft.application import APP_METADATA
- from rockcraft.services import RockcraftServiceFactory
-
- factory = RockcraftServiceFactory(
- app=APP_METADATA,
- project=default_project,
- )
- factory.set_kwargs("image", work_dir=Path("work"), build_for="amd64")
- return factory
-
-
-@pytest.fixture
-def default_image_info():
- from rockcraft import oci
- from rockcraft.services.image import ImageInfo
-
- return ImageInfo(
- base_image=oci.Image(image_name="fake_image", path=Path()),
- base_layer_dir=Path(),
- base_digest=b"deadbeef",
- )
-
-
-@pytest.fixture
-def default_application(default_factory, default_project):
- from rockcraft.application import APP_METADATA, Rockcraft
-
- return Rockcraft(APP_METADATA, default_factory)
-
-
-@pytest.fixture
-def image_service(default_project, default_factory, tmp_path):
- from rockcraft.application import APP_METADATA
- from rockcraft.services import RockcraftImageService
-
- return RockcraftImageService(
- app=APP_METADATA,
- project=default_project,
- services=default_factory,
- work_dir=tmp_path,
- build_for="amd64",
- )
-
-
-@pytest.fixture
-def provider_service(default_project, default_factory, tmp_path):
- from rockcraft.application import APP_METADATA
- from rockcraft.services import RockcraftProviderService
-
- return RockcraftProviderService(
- app=APP_METADATA,
- project=default_project,
- services=default_factory,
- )
-
-
-@pytest.fixture
-def package_service(default_project, default_factory):
- from rockcraft.application import APP_METADATA
- from rockcraft.services import RockcraftPackageService
-
- return RockcraftPackageService(
- app=APP_METADATA,
- project=default_project,
- services=default_factory,
- platform="amd64",
- build_for="amd64",
- )
-
-
-@pytest.fixture
-def lifecycle_service(default_project, default_factory):
- from rockcraft.application import APP_METADATA
- from rockcraft.services import RockcraftLifecycleService
-
- return RockcraftLifecycleService(
- app=APP_METADATA,
- project=default_project,
- services=default_factory,
- work_dir=Path("work/"),
- cache_dir=Path("cache/"),
- build_for="amd64",
- )
diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py
index 4b340c1ea..9d3bfdc92 100644
--- a/tests/unit/services/test_package.py
+++ b/tests/unit/services/test_package.py
@@ -15,7 +15,7 @@
# along with this program. If not, see .
from pathlib import Path
-from rockcraft import lifecycle
+from rockcraft.services import package
def test_pack(package_service, default_factory, default_image_info, mocker):
@@ -24,7 +24,7 @@ def test_pack(package_service, default_factory, default_image_info, mocker):
mock_obtain_image = mocker.patch.object(
image_service, "obtain_image", return_value=default_image_info
)
- mock_inner_pack = mocker.patch.object(lifecycle, "_pack")
+ mock_inner_pack = mocker.patch.object(package, "_pack")
package_service.pack(prime_dir=Path("prime"), dest=Path())
diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py
index c55ac6291..cace8e6a3 100644
--- a/tests/unit/test_application.py
+++ b/tests/unit/test_application.py
@@ -15,22 +15,6 @@
# along with this program. If not, see .
from pathlib import Path
-from craft_application.commands.lifecycle import PackCommand
-
-
-def test_application_commands(default_application):
- commands = default_application.command_groups
-
- assert len(commands) == 1
-
- group = commands[0]
- assert group.name == "Lifecycle"
-
- # Only the Pack command is supported currently.
- assert len(group.commands) == 1
- assert group.commands[0] is PackCommand
-
-
ENVIRONMENT_YAML = """\
name: environment-test
version: 2.0
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 166526d9e..95bacfecd 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -15,29 +15,18 @@
# along with this program. If not, see .
import sys
-from argparse import Namespace
from pathlib import Path
from unittest.mock import DEFAULT, call, patch
import pytest
import yaml
-from craft_cli import CraftError, ProvideHelpException, emit
-from craft_providers import ProviderError
+from craft_cli import emit
from rockcraft import cli, services
from rockcraft.application import Rockcraft
-from rockcraft.errors import RockcraftError
from rockcraft.models import project
-@pytest.fixture
-def lifecycle_pack_mock():
- """Mock for ui.pack."""
- patcher = patch("rockcraft.lifecycle.pack")
- yield patcher.start()
- patcher.stop()
-
-
@pytest.fixture
def lifecycle_init_mock():
"""Mock for ui.init."""
@@ -46,111 +35,6 @@ def lifecycle_init_mock():
patcher.stop()
-def create_namespace(
- *, parts=None, shell=False, shell_after=False, debug=False, destructive_mode=False
-) -> Namespace:
- """Shortcut to create a Namespace object with the correct default values."""
- return Namespace(
- parts=parts or [],
- shell=shell,
- shell_after=shell_after,
- debug=debug,
- destructive_mode=destructive_mode,
- )
-
-
-@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"])
-def test_run_command(mocker, cmd):
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- mocker.patch.object(sys, "argv", ["rockcraft", cmd])
- cli.run()
-
- assert run_mock.mock_calls == [call(cmd, create_namespace())]
- assert mock_ended_ok.mock_calls == [call()]
-
-
-@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"])
-def test_run_command_parts(mocker, cmd):
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- mocker.patch.object(sys, "argv", ["rockcraft", cmd, "part1", "part2"])
- cli.run()
-
- assert run_mock.mock_calls == [
- call(
- cmd,
- create_namespace(parts=["part1", "part2"]),
- )
- ]
- assert mock_ended_ok.mock_calls == [call()]
-
-
-@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"])
-def test_run_command_shell(mocker, cmd):
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--shell"])
- cli.run()
-
- assert run_mock.mock_calls == [call(cmd, create_namespace(shell=True))]
- assert mock_ended_ok.mock_calls == [call()]
-
-
-@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"])
-def test_run_command_shell_after(mocker, cmd):
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--shell-after"])
- cli.run()
-
- assert run_mock.mock_calls == [call(cmd, create_namespace(shell_after=True))]
- assert mock_ended_ok.mock_calls == [call()]
-
-
-@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"])
-def test_run_command_debug(mocker, cmd):
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--debug"])
- cli.run()
-
- assert run_mock.mock_calls == [call(cmd, create_namespace(debug=True))]
- assert mock_ended_ok.mock_calls == [call()]
-
-
-@pytest.mark.parametrize("cmd", ["pull", "overlay", "build", "stage", "prime"])
-def test_run_command_destructive_mode(mocker, cmd):
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- mocker.patch.object(sys, "argv", ["rockcraft", cmd, "--destructive-mode"])
- cli.run()
-
- assert run_mock.mock_calls == [call(cmd, create_namespace(destructive_mode=True))]
- assert mock_ended_ok.mock_calls == [call()]
-
-
-@pytest.mark.parametrize("destructive_opt", [True, False])
-@pytest.mark.parametrize("debug_opt", [True, False])
-def test_run_pack(mocker, debug_opt, destructive_opt):
- if not destructive_opt:
- pytest.skip("regular 'rockcraft pack' is tested in 'test_run_pack_services'.")
- run_mock = mocker.patch("rockcraft.lifecycle.run")
- mock_ended_ok = mocker.spy(emit, "ended_ok")
- command_line = ["rockcraft", "pack"]
- if debug_opt:
- command_line.append("--debug")
- if destructive_opt:
- command_line.append("--destructive-mode")
- mocker.patch.object(sys, "argv", command_line)
- cli.run()
-
- assert run_mock.mock_calls == [
- call("pack", Namespace(debug=debug_opt, destructive_mode=destructive_opt))
- ]
- assert mock_ended_ok.mock_calls == [call()]
-
-
def test_run_pack_services(mocker, monkeypatch, tmp_path):
# Pretend it's running inside the managed instance
monkeypatch.setenv("CRAFT_MANAGED_MODE", "1")
@@ -207,60 +91,3 @@ def test_run_init(mocker, lifecycle_init_mock):
call(cli.commands.InitCommand._INIT_TEMPLATE_YAML) # pylint: disable=W0212
]
assert mock_ended_ok.mock_calls == [call()]
-
-
-def test_run_arg_parse_error(capsys, mocker):
- """Catch ArgumentParsingError and exit cleanly."""
- mocker.patch.object(sys, "argv", ["rockcraft", "invalid-command"])
- mock_emit = mocker.patch("rockcraft.cli.emit")
- mock_exit = mocker.patch("rockcraft.cli.sys.exit")
-
- cli.run()
-
- mock_emit.ended_ok.assert_called_once()
- mock_exit.assert_called_once()
-
- out, err = capsys.readouterr()
- assert not out
- assert "Error: no such command 'invalid-command'" in err
-
-
-def test_run_arg_provider_help_exception(capsys, mocker):
- """Catch ProviderHelpException and exit cleanly."""
- mocker.patch(
- "craft_cli.Dispatcher.pre_parse_args",
- side_effect=ProvideHelpException("test help message"),
- )
- mock_emit = mocker.patch("rockcraft.cli.emit")
-
- cli.run()
-
- mock_emit.ended_ok.assert_called_once()
-
- out, err = capsys.readouterr()
- assert not out
- assert err == "test help message\n"
-
-
-@pytest.mark.parametrize(
- "input_error, output_error",
- [
- (RockcraftError("test error"), CraftError("test error")),
- (ProviderError("test error"), CraftError("craft-providers error: test error")),
- (
- Exception("test error"),
- CraftError("rockcraft internal error: Exception('test error')"),
- ),
- ],
-)
-def test_run_with_error(mocker, input_error, output_error):
- """Application errors should be caught for a clean exit."""
- mocker.patch("craft_cli.Dispatcher.run", side_effect=input_error)
- mocker.patch.object(sys, "argv", ["rockcraft", "pack", "--destructive"])
- mock_emit = mocker.patch("rockcraft.cli.emit")
- mock_exit = mocker.patch("rockcraft.cli.sys.exit")
-
- cli.run()
-
- mock_exit.assert_called_once()
- mock_emit.error.assert_called_once_with(output_error)
diff --git a/tests/unit/test_lifecycle.py b/tests/unit/test_lifecycle.py
deleted file mode 100644
index 692119a1d..000000000
--- a/tests/unit/test_lifecycle.py
+++ /dev/null
@@ -1,465 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2022 Canonical Ltd.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-import argparse
-from pathlib import Path
-from unittest.mock import Mock, call
-
-import pytest
-from craft_cli import EmitterMode, emit
-from craft_providers.bases import BuilddBaseAlias
-
-import tests
-from rockcraft import lifecycle
-
-pytestmark = pytest.mark.usefixtures("reset_callbacks")
-
-
-@pytest.fixture
-def mock_project_yaml(mocker):
- _project_yaml = {
- "name": "test-name",
- "platforms": {
- "test-platform": {
- "build_on": ["test-build-on"],
- "build_for": ["test-build-for"],
- }
- },
- "parts": {"foo": {"plugin": "nil"}},
- "package_repositories": [],
- "version": "1.1.1",
- }
- yield _project_yaml
-
-
-@pytest.fixture
-def mock_project(mocker):
- _mock_project = mocker.Mock()
- _mock_project.name = "test-name"
- _mock_project.platforms = {
- "test-platform": {
- "build_on": ["test-build-on"],
- "build_for": ["test-build-for"],
- }
- }
- _mock_project.parts = {"foo": {"plugin": "nil"}}
- _mock_project.package_repositories = []
- _mock_project.version = "1.1.1"
- yield _mock_project
-
-
-@pytest.fixture()
-def mock_provider(mocker, mock_instance, fake_provider):
- _mock_provider = Mock(wraps=fake_provider)
- mocker.patch(
- "rockcraft.lifecycle.providers.get_provider", return_value=_mock_provider
- )
- yield _mock_provider
-
-
-@pytest.fixture()
-def mock_get_instance_name(mocker):
- yield mocker.patch(
- "rockcraft.lifecycle.providers.get_instance_name",
- return_value="test-instance-name",
- )
-
-
-@pytest.mark.parametrize(
- "command_name", ["pull", "overlay", "build", "stage", "prime", "pack"]
-)
-def test_run_run_in_provider(command_name, mocker, mock_project_yaml, mock_project):
- """Verify `run()` calls `run_in_provider()` when not in managed or destructive
- mode.
- """
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False)
- mock_run_in_provider = mocker.patch("rockcraft.lifecycle.run_in_provider")
- lifecycle.run(command_name=command_name, parsed_args=argparse.Namespace())
-
- mock_run_in_provider.assert_called_once_with(
- mock_project, command_name, argparse.Namespace()
- )
-
-
-def test_run_clean_provider(mocker, mock_project_yaml):
- """Verify `run()` calls `clean_provider()` when not in managed or destructive
- mode.
- """
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False)
- mock_clean_provider = mocker.patch("rockcraft.lifecycle.clean_provider")
- lifecycle.run(command_name="clean", parsed_args=argparse.Namespace())
-
- mock_clean_provider.assert_called_once_with(
- project_name="test-name", project_path=Path().absolute()
- )
-
-
-# region Tests for running inside the provider
-@tests.linux_only
-def test_run_clean_part(mocker, mock_project_yaml, mock_project, new_dir):
- """Verify cleaning a specific part."""
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=new_dir,
- )
- mocker.patch(
- "rockcraft.lifecycle.oci.Image.from_docker_registry",
- return_value=(Mock(), Mock()),
- )
- clean_mock = mocker.patch("rockcraft.parts.PartsLifecycle.clean")
-
- mock_project.parts = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle.run(command_name="clean", parsed_args=argparse.Namespace(parts=["foo"]))
-
- assert clean_mock.mock_calls == [call()]
-
-
-@tests.linux_only
-def test_run_clean_destructive_mode(mocker, mock_project_yaml, mock_project, new_dir):
- """Verify cleaning in destructive mode."""
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=new_dir,
- )
- mocker.patch(
- "rockcraft.lifecycle.oci.Image.from_docker_registry",
- return_value=(Mock(), Mock()),
- )
- clean_mock = mocker.patch("rockcraft.parts.PartsLifecycle.clean")
-
- mock_project.parts = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle.run(
- command_name="clean",
- parsed_args=argparse.Namespace(parts=["foo"], destructive_mode=True),
- )
-
- assert clean_mock.mock_calls == [call()]
-
-
-@tests.linux_only
-def test_run_destructive_mode(mocker, mock_project_yaml, mock_project, new_dir):
- """Verify running in destructive mode."""
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=False)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=new_dir,
- )
- mocker.patch(
- "rockcraft.lifecycle.oci.Image.from_docker_registry",
- return_value=(Mock(), Mock()),
- )
- run_mock = mocker.patch("rockcraft.parts.PartsLifecycle.run")
- pack_mock = mocker.patch("rockcraft.lifecycle._pack")
-
- mock_project.parts = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle.run(
- command_name="pack",
- parsed_args=argparse.Namespace(parts=["foo"], destructive_mode=True),
- )
-
- assert run_mock.called
- assert pack_mock.called
-
-
-@tests.linux_only
-@pytest.mark.parametrize(
- "repos",
- [
- [],
- [{"type": "apt", "ppa": "ppa/ppa"}],
- ],
-)
-def test_install_repositories(mocker, mock_project_yaml, mock_project, tmp_path, repos):
- mock_project.package_repositories = repos
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=tmp_path,
- )
- mocker.patch(
- "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock())
- )
- mock_lifecycle_class = mocker.patch("rockcraft.lifecycle.PartsLifecycle")
-
- lifecycle.run(command_name="build", parsed_args=argparse.Namespace())
-
- mock_lifecycle_class.assert_called_once_with(
- mock_project.parts,
- work_dir=tmp_path,
- part_names=mocker.ANY,
- base_layer_dir=mocker.ANY,
- base_layer_hash=mocker.ANY,
- package_repositories=repos,
- base=mocker.ANY,
- project_vars=mocker.ANY,
- project_name=mocker.ANY,
- )
-
-
-def test_install_repositories_none_conversion(
- mocker, mock_project_yaml, mock_project, tmp_path
-):
- mock_project.package_repositories = None
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=tmp_path,
- )
- mocker.patch(
- "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock())
- )
- mock_lifecycle_class = mocker.patch("rockcraft.lifecycle.PartsLifecycle")
-
- lifecycle.run(command_name="build", parsed_args=argparse.Namespace())
-
- mock_lifecycle_class.assert_called_once_with(
- mock_project.parts,
- work_dir=tmp_path,
- part_names=mocker.ANY,
- base_layer_dir=mocker.ANY,
- base_layer_hash=mocker.ANY,
- package_repositories=[],
- base=mocker.ANY,
- project_vars=mocker.ANY,
- project_name=mocker.ANY,
- )
-
-
-def test_project_variables(mocker, mock_project_yaml, mock_project, tmp_path):
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.lifecycle.Project.unmarshal", return_value=mock_project)
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=tmp_path,
- )
- mocker.patch(
- "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock())
- )
-
- # Set the project's name and version
- mock_project.name = "fake-name"
- # The version is set from the loaded YAML
- mock_project_yaml["version"] = "1.2.3"
-
- mock_lifecycle_class = mocker.patch("rockcraft.lifecycle.PartsLifecycle")
-
- lifecycle.run(command_name="build", parsed_args=argparse.Namespace())
- mock_lifecycle_class.assert_called_once_with(
- mock_project.parts,
- work_dir=tmp_path,
- part_names=mocker.ANY,
- base_layer_dir=mocker.ANY,
- base_layer_hash=mocker.ANY,
- package_repositories=mocker.ANY,
- base=mocker.ANY,
- project_vars={"version": "1.2.3"},
- project_name="fake-name",
- )
-
-
-def test_project_variables_expansion(mocker, mock_project_yaml, mock_project, tmp_path):
- mocker.patch("rockcraft.lifecycle.load_project", return_value=mock_project_yaml)
- mocker.patch("rockcraft.parts.PartsLifecycle.run")
- mocker.patch("rockcraft.lifecycle._pack")
- mocker.patch("rockcraft.lifecycle.utils.is_managed_mode", return_value=True)
- mocker.patch(
- "rockcraft.lifecycle.utils.get_managed_environment_home_path",
- return_value=tmp_path,
- )
- mocker.patch(
- "rockcraft.oci.Image.from_docker_registry", return_value=(Mock(), Mock())
- )
- unmarshal_mock = mocker.patch(
- "rockcraft.lifecycle.Project.unmarshal", return_value=mock_project
- )
-
- # Add a global environment variable to the loaded YAML
- mock_project_yaml["parts"]["foo"]["source"] = "v$CRAFT_PROJECT_VERSION"
- # The expected expansion shall replace the above var with the project version
- expanded_project_yaml = mock_project_yaml.copy()
- expanded_project_yaml["parts"]["foo"]["source"] = f"v{mock_project_yaml['version']}"
-
- lifecycle.run(
- command_name="pack",
- parsed_args=argparse.Namespace(parts=["foo"], destructive_mode=True),
- )
-
- unmarshal_mock.assert_called_once_with(expanded_project_yaml)
-
-
-# endregion
-# region Tests for managing the provider
-@pytest.mark.parametrize(
- "emit_mode,verbosity",
- [
- (EmitterMode.VERBOSE, ["--verbosity=verbose"]),
- (EmitterMode.QUIET, ["--verbosity=quiet"]),
- (EmitterMode.DEBUG, ["--verbosity=debug"]),
- (EmitterMode.TRACE, ["--verbosity=trace"]),
- (EmitterMode.BRIEF, ["--verbosity=brief"]),
- ],
-)
-@pytest.mark.parametrize(
- "rockcraft_base, provider_base",
- [
- ("ubuntu:18.04", BuilddBaseAlias.BIONIC),
- ("ubuntu:20.04", BuilddBaseAlias.FOCAL),
- ("ubuntu:22.04", BuilddBaseAlias.JAMMY),
- ],
-)
-def test_lifecycle_run_in_provider(
- mock_get_instance_name,
- mock_instance,
- mock_provider,
- mock_project,
- mocker,
- emit_mode,
- verbosity,
- rockcraft_base,
- provider_base,
-):
- # mock provider calls
- mock_base_configuration = Mock()
- mock_get_base_configuration = mocker.patch(
- "rockcraft.lifecycle.providers.get_base_configuration",
- return_value=mock_base_configuration,
- )
- mock_capture_logs_from_instance = mocker.patch(
- "rockcraft.lifecycle.providers.capture_logs_from_instance"
- )
- mock_ensure_provider_is_available = mocker.patch(
- "rockcraft.lifecycle.providers.ensure_provider_is_available"
- )
- mock_project.build_base = rockcraft_base
-
- cwd = Path().absolute()
-
- # set emitter mode
- emit.set_mode(emit_mode)
-
- lifecycle.run_in_provider(
- project=mock_project,
- command_name="test",
- parsed_args=argparse.Namespace(),
- )
-
- mock_ensure_provider_is_available.assert_called_once_with(mock_provider)
- mock_get_instance_name.assert_called_once_with(
- project_name="test-name",
- project_path=cwd,
- )
- mock_get_base_configuration.assert_called_once_with(
- alias=provider_base,
- project_name="test-name",
- project_path=cwd,
- )
- mock_provider.launched_environment.assert_called_once_with(
- project_name="test-name",
- project_path=cwd,
- base_configuration=mock_base_configuration,
- instance_name="test-instance-name",
- )
- mock_instance.mount.assert_called_once_with(
- host_source=cwd, target=Path("/root/project")
- )
- mock_instance.execute_run.assert_called_once_with(
- ["rockcraft", "test"] + verbosity,
- check=True,
- cwd=Path("/root/project"),
- )
- mock_capture_logs_from_instance.assert_called_once_with(mock_instance)
-
-
-def test_lifecycle_run_in_provider_debug(
- mock_get_instance_name,
- mock_instance,
- mock_provider,
- mock_project,
- mocker,
-):
- # mock provider calls
- mock_base_configuration = Mock()
- mocker.patch(
- "rockcraft.lifecycle.providers.get_base_configuration",
- return_value=mock_base_configuration,
- )
- mocker.patch("rockcraft.lifecycle.providers.capture_logs_from_instance")
- mocker.patch("rockcraft.lifecycle.providers.ensure_provider_is_available")
- mock_project.build_base = "ubuntu:22.04"
-
- lifecycle.run_in_provider(
- project=mock_project,
- command_name="test",
- parsed_args=argparse.Namespace(debug=True),
- )
- # Check that `rockcraft` was called with `--debug`.
- mock_instance.execute_run.assert_called_once_with(
- ["rockcraft", "test", "--verbosity=quiet", "--debug"],
- check=True,
- cwd=Path("/root/project"),
- )
-
-
-def test_clean_provider(emitter, mock_get_instance_name, mock_provider, tmpdir):
- lifecycle.clean_provider(project_name="test-project", project_path=tmpdir)
-
- mock_get_instance_name.assert_called_once_with(
- project_name="test-project", project_path=tmpdir
- )
- mock_provider.clean_project_environments.assert_called_once_with(
- instance_name="test-instance-name"
- )
- emitter.assert_interactions(
- [
- call("progress", "Cleaning build provider"),
- call("debug", "Cleaning instance test-instance-name"),
- call("progress", "Cleaned build provider", permanent=True),
- ]
- )
-
-
-# endregion
diff --git a/tests/unit/test_parts.py b/tests/unit/test_parts.py
deleted file mode 100644
index ac89ac55c..000000000
--- a/tests/unit/test_parts.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2021-2022 Canonical Ltd.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-from pathlib import Path
-from typing import cast
-from unittest.mock import call
-
-import craft_cli
-import pytest
-from craft_parts import Action, PartsError, Step, callbacks
-
-import tests
-from rockcraft import parts
-from rockcraft.errors import PartsLifecycleError, RockcraftError
-
-
-@pytest.fixture
-def parts_data():
- yield {
- "foo": {
- "plugin": "nil",
- }
- }
-
-
-@pytest.fixture
-def create_lifecycle(new_dir):
- def _inner_create(**kwargs) -> parts.PartsLifecycle:
- kwargs.setdefault("base_layer_hash", b"digest")
- kwargs.setdefault("base", "unused")
- kwargs.setdefault("project_name", "my-project")
- kwargs.setdefault("part_names", None)
- kwargs.setdefault("base_layer_dir", new_dir)
- kwargs.setdefault("work_dir", new_dir)
-
- return parts.PartsLifecycle(
- **kwargs,
- )
-
- return _inner_create
-
-
-@tests.linux_only
-def test_parts_lifecycle_prime_dir(create_lifecycle):
- parts_data = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("/some/workdir"))
- assert lifecycle.prime_dir == Path("/some/workdir/prime")
-
-
-@tests.linux_only
-def test_parts_lifecycle_project_info(create_lifecycle):
- parts_data = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("/some/workdir"))
- assert lifecycle.project_info.work_dir == Path("/some/workdir")
- assert lifecycle.project_info.base == "unused"
-
-
-@tests.linux_only
-def test_parts_lifecycle_run(create_lifecycle):
- parts_data = {
- "foo": {
- "plugin": "dump",
- "source": "dir1",
- }
- }
-
- Path("dir1").mkdir()
- Path("dir1/foo.txt").touch()
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("."))
- lifecycle.run("prime")
-
- assert Path(lifecycle.prime_dir, "foo.txt").is_file()
-
-
-@pytest.mark.parametrize("repos", [[{"type": "apt", "ppa": "mozillateam/ppa"}]])
-@tests.linux_only
-def test_run_installs_package_repositories(
- mocker, parts_data, tmp_path, repos, create_lifecycle
-):
- lifecycle = create_lifecycle(
- all_parts=parts_data,
- work_dir=tmp_path,
- base_layer_dir=tmp_path,
- package_repositories=repos,
- )
- lcm = mocker.patch.object(lifecycle, "_lcm")
- lcm.plan.return_value = []
- mocker.patch("craft_archives.repo.install", return_value=False)
- progress_spy = mocker.spy(craft_cli.emit, "progress")
-
- lifecycle.run("pull")
-
- progress_spy.assert_has_calls(
- [
- call("Installing package repositories"),
- call("Package repositories installed", permanent=True),
- ]
- )
-
-
-@pytest.mark.parametrize(
- "step_name,expected_last_step",
- [
- ("pull", None),
- ("overlay", Step.PULL),
- ("build", Step.OVERLAY),
- ("stage", Step.BUILD),
- ("prime", Step.STAGE),
- ],
-)
-@tests.linux_only
-def test_parts_lifecycle_run_shell(
- mocker, step_name, expected_last_step, parts_data, create_lifecycle
-):
- """Check if the last step executed before shell is the previous step."""
- last_step = None
-
- def _fake_execute(_, action: Action, **kwargs): # pylint: disable=unused-argument
- nonlocal last_step
- last_step = action.step
-
- mocker.patch("craft_parts.executor.Executor.execute", new=_fake_execute)
- shell_mock = mocker.patch("subprocess.run")
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("."))
- lifecycle.run(step_name, shell=True)
-
- assert last_step == expected_last_step
- assert shell_mock.mock_calls == [call(["bash"], check=False, cwd=None)]
-
-
-@pytest.mark.parametrize(
- "step_name,expected_last_step",
- [
- ("pull", Step.PULL),
- ("overlay", Step.OVERLAY),
- ("build", Step.BUILD),
- ("stage", Step.STAGE),
- ("prime", Step.PRIME),
- ],
-)
-@tests.linux_only
-def test_parts_lifecycle_run_shell_after(
- mocker, step_name, expected_last_step, create_lifecycle
-):
- """Check if the last step executed before shell is the current step."""
- last_step = None
-
- def _fake_execute(_, action: Action, **kwargs): # pylint: disable=unused-argument
- nonlocal last_step
- last_step = action.step
-
- mocker.patch("craft_parts.executor.Executor.execute", new=_fake_execute)
- shell_mock = mocker.patch("subprocess.run")
-
- parts_data = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("."))
- lifecycle.run(step_name, shell_after=True)
-
- assert last_step == expected_last_step
- assert shell_mock.mock_calls == [call(["bash"], check=False, cwd=None)]
-
-
-@pytest.mark.parametrize(
- "internal_exception,expected_exception_type",
- [
- (PartsError("Unexpected error"), PartsLifecycleError),
- (RuntimeError("Unexpected error"), RuntimeError),
- (FileNotFoundError(2, "Unexpected error"), PartsLifecycleError),
- (Exception("Unexpected error"), PartsLifecycleError),
- ],
-)
-@tests.linux_only
-def test_parts_lifecycle_run_debug(
- mocker, internal_exception, expected_exception_type, create_lifecycle
-):
- """Check that when "debug" is True, a shell is launched when an error is raised."""
-
- mocker.patch(
- "craft_parts.executor.Executor.execute",
- side_effect=internal_exception,
- )
- shell_mock = mocker.patch("rockcraft.parts.launch_shell")
-
- parts_data = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("."))
- with pytest.raises(expected_exception_type, match="Unexpected error"):
- lifecycle.run("prime", debug=True)
-
- shell_mock.assert_called_once_with()
-
-
-def assert_errors_match(
- rockcraft_error: RockcraftError, parts_error: PartsError
-) -> None:
- """Assert that the RockcraftError's fields match those on the PartsError."""
- assert str(rockcraft_error) == parts_error.brief
- assert rockcraft_error.details == parts_error.details
- assert rockcraft_error.resolution == parts_error.resolution
-
-
-@tests.linux_only
-def test_parts_lifecycle_init_error(create_lifecycle):
- parts_data = {
- "foo": {
- "invalid": True,
- }
- }
-
- with pytest.raises(parts.PartsLifecycleError) as exc:
- create_lifecycle(all_parts=parts_data, work_dir=Path("."))
-
- rockcraft_error = exc.value
- parts_error = cast(PartsError, rockcraft_error.__cause__)
-
- assert_errors_match(rockcraft_error, parts_error)
-
-
-@tests.linux_only
-def test_parts_lifecycle_run_error(create_lifecycle):
- parts_data = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle = create_lifecycle(
- all_parts=parts_data, work_dir=Path("."), part_names=["fake_part"]
- )
-
- with pytest.raises(parts.PartsLifecycleError) as exc:
- # This fails because `part_names` references a part that doesn't exist
- lifecycle.run(step_name="pull")
-
- rockcraft_error = exc.value
- parts_error = cast(PartsError, rockcraft_error.__cause__)
-
- assert_errors_match(rockcraft_error, parts_error)
-
-
-@tests.linux_only
-def test_parts_lifecycle_clean(create_lifecycle, emitter):
- parts_data = {
- "foo": {
- "plugin": "nil",
- }
- }
-
- lifecycle = create_lifecycle(all_parts=parts_data, work_dir=Path("."))
- lifecycle.clean()
- emitter.assert_progress("Cleaning all parts")
-
-
-@tests.linux_only
-def test_parts_lifecycle_clean_parts(create_lifecycle, emitter):
- parts_data = {
- "foo": {
- "plugin": "nil",
- },
- "bar": {
- "plugin": "nil",
- },
- }
-
- lifecycle = create_lifecycle(all_parts=parts_data, part_names=["foo"])
- lifecycle.clean()
- emitter.assert_progress("Cleaning parts: foo")
-
-
-@tests.linux_only
-def test_parts_register_overlay_callback(mocker, create_lifecycle):
- """Test that PartsLifecycle registers the overlay callback."""
- # pylint: disable=protected-access
- register_spy = mocker.spy(callbacks, "register_configure_overlay")
-
- # Check that creating the lifecycle registers the callback
- assert not register_spy.called
- create_lifecycle(all_parts={})
- register_spy.assert_called_once_with(parts._install_overlay_repositories)
-
- # Check that creating another lifecycle does *not* raise an error from re-
- # registering the callback.
- create_lifecycle(all_parts={})
diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py
deleted file mode 100644
index 6922f8fed..000000000
--- a/tests/unit/test_providers.py
+++ /dev/null
@@ -1,296 +0,0 @@
-# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
-#
-# Copyright 2022 Canonical Ltd.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-from pathlib import Path
-from unittest.mock import MagicMock, Mock, call
-
-import pytest
-from craft_providers import ProviderError, bases
-from craft_providers.actions.snap_installer import Snap
-from craft_providers.lxd import LXDProvider
-from craft_providers.multipass import MultipassProvider
-
-from rockcraft import providers
-
-
-def test_get_command_environment_minimal(monkeypatch):
- monkeypatch.setenv("IGNORE_ME", "or-im-failing")
- monkeypatch.setenv("PATH", "not-using-host-path")
-
- assert providers.get_command_environment() == {
- "CRAFT_MANAGED_MODE": "1",
- "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin",
- }
-
-
-def test_get_command_environment_all_opts(monkeypatch):
- monkeypatch.setenv("IGNORE_ME", "or-im-failing")
- monkeypatch.setenv("PATH", "not-using-host-path")
- monkeypatch.setenv("http_proxy", "test-http-proxy")
- monkeypatch.setenv("https_proxy", "test-https-proxy")
- monkeypatch.setenv("no_proxy", "test-no-proxy")
-
- assert providers.get_command_environment() == {
- "CRAFT_MANAGED_MODE": "1",
- "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin",
- "http_proxy": "test-http-proxy",
- "https_proxy": "test-https-proxy",
- "no_proxy": "test-no-proxy",
- }
-
-
-def test_get_instance_name(tmp_path):
- assert (
- providers.get_instance_name(
- project_name="my-project-name",
- project_path=tmp_path,
- )
- == f"rockcraft-my-project-name-{tmp_path.stat().st_ino}"
- )
-
-
-def test_capture_logs_from_instance(mocker, emitter, mock_instance, new_dir):
- """Verify logs from an instance are retrieved and emitted."""
- fake_log = Path(new_dir / "fake.file")
- fake_log_data = "some\nlog data\nhere"
- fake_log.write_text(fake_log_data, encoding="utf-8")
-
- mock_instance.temporarily_pull_file = MagicMock()
- mock_instance.temporarily_pull_file.return_value = fake_log
-
- providers.capture_logs_from_instance(mock_instance)
-
- assert mock_instance.mock_calls == [
- call.temporarily_pull_file(source=Path("/tmp/rockcraft.log"), missing_ok=True)
- ]
- expected = [
- call("debug", "Logs retrieved from managed instance:"),
- call("debug", ":: some"),
- call("debug", ":: log data"),
- call("debug", ":: here"),
- ]
- emitter.assert_interactions(expected)
-
-
-def test_capture_log_from_instance_not_found(mocker, emitter, mock_instance, new_dir):
- """Verify a missing log file is handled properly."""
- mock_instance.temporarily_pull_file = MagicMock(return_value=None)
- mock_instance.temporarily_pull_file.return_value = (
- mock_instance.temporarily_pull_file
- )
- mock_instance.temporarily_pull_file.__enter__ = Mock(return_value=None)
-
- providers.capture_logs_from_instance(mock_instance)
-
- emitter.assert_debug("Could not find log file /tmp/rockcraft.log in instance.")
- mock_instance.temporarily_pull_file.assert_called_with(
- source=Path("/tmp/rockcraft.log"), missing_ok=True
- )
-
-
-@pytest.mark.parametrize(
- "platform, snap_channel, expected_snap_channel",
- [
- ("linux", None, None),
- ("linux", "edge", "edge"),
- ("darwin", "edge", "edge"),
- # default to stable on non-linux system
- ("darwin", None, "stable"),
- ],
-)
-@pytest.mark.parametrize(
- "alias",
- [
- bases.BuilddBaseAlias.BIONIC,
- bases.BuilddBaseAlias.FOCAL,
- bases.BuilddBaseAlias.JAMMY,
- ],
-)
-def test_get_base_configuration(
- platform,
- snap_channel,
- expected_snap_channel,
- alias,
- tmp_path,
- mocker,
-):
- """Verify the rockcraft snap is installed from the correct channel."""
- mocker.patch("sys.platform", platform)
- mocker.patch(
- "rockcraft.providers.get_managed_environment_snap_channel",
- return_value=snap_channel,
- )
- mocker.patch(
- "rockcraft.providers.get_command_environment",
- return_value="test-env",
- )
- mocker.patch(
- "rockcraft.providers.get_instance_name",
- return_value="test-instance-name",
- )
- mock_buildd_base = mocker.patch("rockcraft.providers.bases.BuilddBase")
- mock_buildd_base.compatibility_tag = "buildd-base-v0"
-
- providers.get_base_configuration(
- alias=alias, project_name="test-name", project_path=tmp_path
- )
-
- mock_buildd_base.assert_called_with(
- alias=alias,
- compatibility_tag="rockcraft-buildd-base-v0.0",
- environment="test-env",
- hostname="test-instance-name",
- snaps=[Snap(name="rockcraft", channel=expected_snap_channel, classic=True)],
- packages=["gpg", "dirmngr"],
- )
-
-
-@pytest.mark.parametrize(
- "is_provider_installed, confirm_with_user",
- [(True, True), (True, False), (False, True)],
-)
-def test_ensure_provider_is_available_lxd(
- is_provider_installed, confirm_with_user, mocker
-):
- """Verify LXD is ensured to be available when LXD is installed or the user chooses
- to install LXD."""
- mock_lxd_provider = Mock(spec=LXDProvider)
- mocker.patch(
- "rockcraft.providers.LXDProvider.is_provider_installed",
- return_value=is_provider_installed,
- )
- mocker.patch(
- "rockcraft.providers.confirm_with_user",
- return_value=confirm_with_user,
- )
- mock_ensure_provider_is_available = mocker.patch(
- "rockcraft.providers.ensure_provider_is_available"
- )
-
- providers.ensure_provider_is_available(mock_lxd_provider)
-
- mock_ensure_provider_is_available.assert_called_once()
-
-
-def test_ensure_provider_is_available_lxd_error(mocker):
- """Raise an error if the user does not choose to install LXD."""
- mock_lxd_provider = Mock(spec=LXDProvider)
- mocker.patch(
- "rockcraft.providers.LXDProvider.is_provider_installed",
- return_value=False,
- )
- mocker.patch("rockcraft.providers.confirm_with_user", return_value=False)
-
- with pytest.raises(ProviderError) as error:
- providers.ensure_provider_is_available(mock_lxd_provider)
-
- assert error.value.brief == (
- "LXD is required, but not installed. Visit https://snapcraft.io/lxd for "
- "instructions on how to install the LXD snap for your distribution"
- )
-
-
-@pytest.mark.parametrize(
- "is_provider_installed, confirm_with_user",
- [(True, True), (True, False), (False, True)],
-)
-def test_ensure_provider_is_available_multipass(
- is_provider_installed, confirm_with_user, mocker
-):
- """Verify Multipass is ensured to be available when Multipass is installed or the
- user chooses to install Multipass."""
- mock_multipass_provider = Mock(spec=MultipassProvider)
- mocker.patch(
- "rockcraft.providers.MultipassProvider.is_provider_installed",
- return_value=is_provider_installed,
- )
- mocker.patch(
- "rockcraft.providers.confirm_with_user",
- return_value=confirm_with_user,
- )
- mock_ensure_provider_is_available = mocker.patch(
- "rockcraft.providers.ensure_provider_is_available"
- )
-
- providers.ensure_provider_is_available(mock_multipass_provider)
-
- mock_ensure_provider_is_available.assert_called_once()
-
-
-def test_ensure_provider_is_available_multipass_error(mocker):
- """Raise an error if the user does not choose to install Multipass."""
- mock_multipass_provider = Mock(spec=MultipassProvider)
- mocker.patch(
- "rockcraft.providers.MultipassProvider.is_provider_installed",
- return_value=False,
- )
- mocker.patch("rockcraft.providers.confirm_with_user", return_value=False)
-
- with pytest.raises(ProviderError) as error:
- providers.ensure_provider_is_available(mock_multipass_provider)
-
- assert error.value.brief == (
- "Multipass is required, but not installed. Visit https://multipass.run/for "
- "instructions on installing Multipass for your operating system."
- )
-
-
-def test_ensure_provider_is_available_unknown_error():
- """Raise an error if the provider type is unknown."""
- mock_multipass_provider = Mock()
-
- with pytest.raises(ProviderError) as error:
- providers.ensure_provider_is_available(mock_multipass_provider)
-
- assert error.value.brief == "cannot install unknown provider"
-
-
-def test_get_provider_default_lxd(emitter, mocker):
- """Verify lxd is the default provider when running on a linux system."""
- mocker.patch("sys.platform", "linux")
- provider = providers.get_provider()
- assert isinstance(provider, LXDProvider)
- assert provider.lxd_project == "rockcraft"
- emitter.assert_debug("Using default provider 'lxd' on linux system")
-
-
-@pytest.mark.parametrize("system", ["darwin", "win32", "other-system"])
-def test_get_provider_default_multipass(emitter, mocker, system):
- """Verify multipass is the default provider when running on a non-linux system."""
- mocker.patch("sys.platform", system)
- assert isinstance(providers.get_provider(), MultipassProvider)
- emitter.assert_debug("Using default provider 'multipass' on non-linux system")
-
-
-def test_get_provider_environmental_variable(monkeypatch):
- """Verify the provider can be set by an environmental variable."""
- monkeypatch.setenv("ROCKCRAFT_PROVIDER", "lxd")
- provider = providers.get_provider()
- assert isinstance(provider, LXDProvider)
- assert provider.lxd_project == "rockcraft"
-
- monkeypatch.setenv("ROCKCRAFT_PROVIDER", "multipass")
- assert isinstance(providers.get_provider(), MultipassProvider)
-
-
-def test_get_provider_error(monkeypatch):
- """Raise a ValueError when an invalid provider is passed."""
- monkeypatch.setenv("ROCKCRAFT_PROVIDER", "invalid-provider")
-
- with pytest.raises(ValueError) as error:
- providers.get_provider()
-
- assert str(error.value) == "unsupported provider specified: 'invalid-provider'"
diff --git a/tools/docs/gen_cli_docs.py b/tools/docs/gen_cli_docs.py
index 2773e6560..eb106bbb4 100755
--- a/tools/docs/gen_cli_docs.py
+++ b/tools/docs/gen_cli_docs.py
@@ -71,12 +71,14 @@ def main(docs_dir):
commands_ref_dir.mkdir()
# Create a dispatcher like Rockcraft does to get access to the same options.
+ app = cli._create_app()
+ command_groups = app.command_groups
+
dispatcher = Dispatcher(
- "rockcraft",
- cli.COMMAND_GROUPS,
- summary="A tool to create OCI images",
- extra_global_args=cli.GLOBAL_ARGS,
- default_command=cli.commands.PackCommand,
+ app.app.name,
+ command_groups,
+ summary=str(app.app.summary),
+ extra_global_args=app._global_arguments,
)
help_builder = dispatcher._help_builder
@@ -88,13 +90,13 @@ def main(docs_dir):
toc = []
- for group in cli.COMMAND_GROUPS:
+ for group in command_groups:
group_name = group.name.lower() + "-commands" + os.extsep + "rst"
group_path = commands_ref_dir / group_name
g = group_path.open("w")
for cmd_class in sorted(group.commands, key=lambda c: c.name):
- cmd = cmd_class({})
+ cmd = cmd_class({"app": {}, "services": {}})
p = _CustomArgumentParser(help_builder)
cmd.fill_parser(p)