Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(application): enable build plans #70

Merged
merged 14 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 51 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 or argparse.Namespace()


@final
@dataclass(frozen=True)
class AppMetadata:
Expand Down Expand Up @@ -119,7 +129,7 @@
# 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) -> 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,35 +139,52 @@
"lifecycle",
cache_dir=self.cache_dir,
work_dir=self._work_dir,
build_for=build_for,
)

@functools.cached_property
def project(self) -> models.Project:
"""Get this application's Project metadata."""
project_file = (self._work_dir / f"{self.app.name}.yaml").resolve()
craft_cli.emit.debug(f"Loading project file '{project_file!s}'")

Check warning on line 149 in craft_application/application.py

View check run for this annotation

Codecov / codecov/patch

craft_application/application.py#L149

Added line #L149 was not covered by tests
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:]],
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
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 +198,7 @@
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 +250,6 @@
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 +263,19 @@
}
),
)
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")
if command.always_load_project:
self.services.project = self.project

Check warning on line 273 in craft_application/application.py

View check run for this annotation

Codecov / codecov/patch

craft_application/application.py#L273

Added line #L273 was not covered by tests
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
3 changes: 3 additions & 0 deletions craft_application/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class AppCommand(BaseCommand):
run_managed: bool = False
"""Whether this command should run in managed mode."""

always_load_project: bool = False
"""The project is also loaded in non-managed mode."""

def __init__(self, config: dict[str, Any]) -> None:
super().__init__(config)
self._app: application.AppMetadata = config["app"]
Expand Down
16 changes: 13 additions & 3 deletions 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(
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
"--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 All @@ -119,10 +127,11 @@ def run(
) -> None:
"""Run a lifecycle step command."""
super().run(parsed_args)

step_name = step_name or self.name

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


class PullCommand(_LifecycleStepCommand):
Expand Down Expand Up @@ -259,6 +268,7 @@ class CleanCommand(_LifecyclePartsCommand):
remove the packing environment.
"""
)
always_load_project = True

@override
def run(self, parsed_args: argparse.Namespace) -> None:
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
19 changes: 18 additions & 1 deletion craft_application/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@

This defines the structure of the input file (e.g. snapcraft.yaml)
"""
from typing import Any, Dict, Optional, Union
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 +35,15 @@
)


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

build_on: str
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
build_for: str
base: craft_providers.bases.BaseName


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

Expand Down Expand Up @@ -67,3 +78,9 @@ 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")

def get_build_plan(self) -> List[BuildInfo]:
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
"""Obtain the list of architectures and bases from the project file."""
raise NotImplementedError(
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
f"{self.__class__.__name__!s} must implement get_build_plan"
)
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,
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
**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}"
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
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",
]
64 changes: 64 additions & 0 deletions craft_application/util/platforms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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)
cmatsuoka marked this conversation as resolved.
Show resolved Hide resolved
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