Skip to content

Commit

Permalink
feat(application): enable build plans
Browse files Browse the repository at this point in the history
Enable multi-artefact builds by using the application-defined build plan
resolver to create multiple provider instances. Further refactoring may
be necessary if applications such as Rockcraft and Charmcraft require
additional features.

Signed-off-by: Claudio Matsuoka <[email protected]>
  • Loading branch information
cmatsuoka committed Sep 7, 2023
1 parent bd77044 commit 8a6de1f
Show file tree
Hide file tree
Showing 18 changed files with 316 additions and 59 deletions.
68 changes: 48 additions & 20 deletions craft_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""Main application classes for a craft-application."""
from __future__ import annotations

import argparse
import functools
import os
import pathlib
Expand All @@ -40,6 +41,15 @@
)


class _Dispatcher(craft_cli.Dispatcher):
"""Application command dispatcher."""

@property
def parsed_args(self) -> argparse.Namespace:
"""The map of parsed command-line arguments."""
return self._parsed_command_args


@final
@dataclass(frozen=True)
class AppMetadata:
Expand Down Expand Up @@ -119,7 +129,7 @@ def cache_dir(self) -> str:
# xdg types: https://github.com/python/typeshed/pull/10163
return save_cache_path(self.app.name) # type: ignore[no-any-return]

def _configure_services(self) -> None:
def _configure_services(self, build_for: str) -> None:
"""Configure additional keyword arguments for any service classes.
Any child classes that override this must either call this directly or must
Expand All @@ -129,6 +139,7 @@ def _configure_services(self) -> None:
"lifecycle",
cache_dir=self.cache_dir,
work_dir=self._work_dir,
build_for=build_for,
)

@functools.cached_property
Expand All @@ -137,27 +148,42 @@ def project(self) -> models.Project:
project_file = (self._work_dir / f"{self.app.name}.yaml").resolve()
return self.app.ProjectClass.from_yaml_file(project_file)

def run_managed(self) -> None:
def run_managed(self, build_for: str | None) -> None:
"""Run the application in a managed instance."""
craft_cli.emit.debug(f"Running {self.app.name} in a managed instance...")
instance_path = pathlib.PosixPath("/root/project")
with self.services.provider.instance(
self.project.effective_base, work_dir=self._work_dir
) as instance:
try:
# Pyright doesn't fully understand craft_providers's CompletedProcess.
instance.execute_run( # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
[self.app.name, *sys.argv[1:]], cwd=instance_path, check=True
)
except subprocess.CalledProcessError as exc:
raise craft_providers.ProviderError(
f"Failed to execute {self.app.name} in instance."
) from exc
build_plan = self.project.get_build_plan()
extra_args: dict[str, Any] = {}

# filter out builds not matching argument `--build_for` or env `CRAFT_BUILD_FOR`
if build_for:
build_plan = [b for b in build_plan if b.build_for == build_for]
extra_args["env"] = {"CRAFT_BUILD_FOR": build_for}

for build_info in build_plan:
craft_cli.emit.debug(
f"Running {self.app.name} in {build_info.build_for} instance..."
)
instance_path = pathlib.PosixPath("/root/project")

with self.services.provider.instance(
build_info, work_dir=self._work_dir
) as instance:
try:
# Pyright doesn't fully understand craft_providers's CompletedProcess.
instance.execute_run( # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
[self.app.name, *sys.argv[1:]],
cwd=instance_path,
check=True,
**extra_args,
)
except subprocess.CalledProcessError as exc:
raise craft_providers.ProviderError(
f"Failed to execute {self.app.name} in instance."
) from exc

def configure(self, global_args: dict[str, Any]) -> None:
"""Configure the application using any global arguments."""

def _get_dispatcher(self) -> craft_cli.Dispatcher:
def _get_dispatcher(self) -> _Dispatcher:
"""Configure this application. Should be called by the run method.
Side-effect: This method may exit the process.
Expand All @@ -171,7 +197,7 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher:
log_filepath=self.log_path,
)

dispatcher = craft_cli.Dispatcher(
dispatcher = _Dispatcher(
self.app.name,
self.command_groups,
summary=str(self.app.summary),
Expand Down Expand Up @@ -223,7 +249,6 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher:
def run(self) -> int:
"""Bootstrap and run the application."""
dispatcher = self._get_dispatcher()
self._configure_services()
craft_cli.emit.trace("Preparing application...")

return_code = 1 # General error
Expand All @@ -237,14 +262,17 @@ def run(self) -> int:
}
),
)
build_for = getattr(dispatcher.parsed_args, "build_for", None)
self._configure_services(build_for)

if not command.run_managed:
# command runs in the outer instance
craft_cli.emit.debug(f"Running {self.app.name} {command.name} on host")
return_code = dispatcher.run() or 0
elif not self.services.ProviderClass.is_managed():
# command runs in inner instance, but this is the outer instance
self.services.project = self.project
self.run_managed()
self.run_managed(build_for)
return_code = 0
else:
# command runs in inner instance
Expand Down
15 changes: 14 additions & 1 deletion craft_application/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"""Basic lifecycle commands for a Craft Application."""
from __future__ import annotations

import os
import pathlib
import textwrap
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -95,6 +96,13 @@ def fill_parser(self, parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Shell into the environment after the step has run.",
)
group.add_argument(
"--build-for",
type=str,
metavar="arch",
default=os.getenv("CRAFT_BUILD_FOR"),
help="Set architecture to build for",
)

@override
def get_managed_cmd(self, parsed_args: argparse.Namespace) -> list[str]:
Expand Down Expand Up @@ -122,7 +130,12 @@ def run(

step_name = step_name or self.name

self._services.lifecycle.run(step_name=step_name, part_names=parsed_args.parts)
# resolve build matrix, depending on the environment (managed/manager)

self._services.lifecycle.run(
step_name=step_name,
part_names=parsed_args.parts,
)


class PullCommand(_LifecycleStepCommand):
Expand Down
3 changes: 2 additions & 1 deletion craft_application/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
VersionStr,
)
from craft_application.models.metadata import BaseMetadata
from craft_application.models.project import Project
from craft_application.models.project import BuildInfo, Project


__all__ = [
"BaseMetadata",
"BuildInfo",
"CraftBaseConfig",
"CraftBaseModel",
"Project",
Expand Down
20 changes: 19 additions & 1 deletion craft_application/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@
This defines the structure of the input file (e.g. snapcraft.yaml)
"""
from typing import Any, Dict, Optional, Union
from __future__ import annotations

import abc
import dataclasses
from typing import Any, Dict, List, Optional, Union

import craft_parts
import craft_providers.bases
import pydantic
from pydantic import AnyUrl

Expand All @@ -33,6 +38,15 @@
)


@dataclasses.dataclass
class BuildInfo:
"""Platform build information."""

build_on: str
build_for: str
base: craft_providers.bases.BaseName


class Project(CraftBaseModel):
"""Craft Application project definition."""

Expand Down Expand Up @@ -64,3 +78,7 @@ def effective_base(self) -> Any: # noqa: ANN401 app specific classes can improv
if self.base is not None:
return self.base
raise RuntimeError("Could not determine effective base")

@abc.abstractmethod
def get_build_plan(self) -> List[BuildInfo]:
"""Obtain the list of architectures and bases from the project file."""
3 changes: 3 additions & 0 deletions craft_application/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import abc
import typing

from craft_cli import emit

if typing.TYPE_CHECKING:
from craft_application import models
from craft_application.application import AppMetadata
Expand All @@ -34,3 +36,4 @@ def __init__(self, app: AppMetadata, project: models.Project) -> None:

def setup(self) -> None:
"""Application-specific service preparation."""
emit.debug(f"Setting up service {self._app.name!r}")
9 changes: 7 additions & 2 deletions craft_application/services/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from craft_application import errors
from craft_application.services import base
from craft_application.util import convert_architecture_deb_to_platform

if TYPE_CHECKING: # pragma: no cover
from pathlib import Path
Expand Down Expand Up @@ -106,18 +107,20 @@ class LifecycleService(base.BaseService):
LifecycleManager on initialisation.
"""

def __init__(
def __init__( # noqa: PLR0913 (too many arguments)
self,
app: AppMetadata,
project: Project,
*,
work_dir: Path | str,
cache_dir: Path | str,
build_for: str,
**lifecycle_kwargs: Any, # noqa: ANN401 - eventually used in an Any
) -> None:
super().__init__(app, project)
self._work_dir = work_dir
self._cache_dir = cache_dir
self._build_for = build_for
self._manager_kwargs = lifecycle_kwargs
self._lcm = self._init_lifecycle_manager()

Expand All @@ -133,6 +136,7 @@ def _init_lifecycle_manager(self) -> LifecycleManager:
return LifecycleManager(
{"parts": self._project.parts},
application_name=self._app.name,
arch=convert_architecture_deb_to_platform(self._build_for),
cache_dir=self._cache_dir,
work_dir=self._work_dir,
ignore_local_sources=self._app.source_ignore_patterns,
Expand Down Expand Up @@ -183,7 +187,8 @@ def clean(self, part_names: list[str] | None = None) -> None:
def __repr__(self) -> str:
work_dir = self._work_dir
cache_dir = self._cache_dir
build_for = self._build_for
return (
f"{self.__class__.__name__}({self._app!r}, {self._project!r}, "
f"{work_dir=}, {cache_dir=}, **{self._manager_kwargs!r})"
f"{work_dir=}, {cache_dir=}, {build_for=}, **{self._manager_kwargs!r})"
)
3 changes: 2 additions & 1 deletion craft_application/services/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def is_managed(cls) -> bool:
@contextlib.contextmanager
def instance(
self,
base_name: bases.BaseName | tuple[str, str],
build_info: models.BuildInfo,
*,
work_dir: pathlib.Path,
allow_unstable: bool = True,
Expand All @@ -87,6 +87,7 @@ def instance(
emit.debug("Preparing managed instance")
work_dir_inode = work_dir.stat().st_ino
instance_name = f"{self._app.name}-{self._project.name}-{work_dir_inode}"
base_name = build_info.base
base = self.get_base(base_name, instance_name=instance_name, **kwargs)
provider = self.get_provider()

Expand Down
6 changes: 6 additions & 0 deletions craft_application/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
"""Utilities for craft-application."""

from craft_application.util.yaml import safe_yaml_load
from craft_application.util.platforms import (
get_host_architecture,
convert_architecture_deb_to_platform,
)

__all__ = [
"safe_yaml_load",
"get_host_architecture",
"convert_architecture_deb_to_platform",
]
65 changes: 65 additions & 0 deletions craft_application/util/platforms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# This file is part of craft_application.
#
# Copyright 2023 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""OS and architecture helpers for craft applications."""
from __future__ import annotations

import functools
import platform


@functools.lru_cache(maxsize=1)
def get_host_architecture() -> str:
"""Get host architecture in deb format."""
machine = platform.machine()
return _ARCH_TRANSLATIONS_PLATFORM_TO_DEB.get(machine, machine)


def convert_architecture_deb_to_platform(arch: str) -> str:
"""Convert an architecture from deb/snap syntax to platform syntax.
:param architecture: architecture string in debian/snap syntax
:return: architecture in platform syntax
"""
return _ARCH_TRANSLATIONS_DEB_TO_PLATFORM.get(arch, arch)


# architecture translations from the platform syntax to the deb/snap syntax
# These two architecture mappings are almost inverses of each other, except one map is
# not reversible (same value for different keys)
_ARCH_TRANSLATIONS_PLATFORM_TO_DEB = {
"aarch64": "arm64",
"armv7l": "armhf",
"i686": "i386",
"ppc": "powerpc",
"ppc64le": "ppc64el",
"x86_64": "amd64",
"AMD64": "amd64", # Windows support
"s390x": "s390x",
"riscv64": "riscv64",
}

# architecture translations from the deb/snap syntax to the platform syntax
_ARCH_TRANSLATIONS_DEB_TO_PLATFORM = {
"arm64": "aarch64",
"armhf": "armv7l",
"i386": "i686",
"powerpc": "ppc",
"ppc64el": "ppc64le",
"amd64": "x86_64",
"s390x": "s390x",
"riscv64": "riscv64",
}

Loading

0 comments on commit 8a6de1f

Please sign in to comment.