def test_lifecycle_run_command_clean(snapcraft_yaml, project_vars, new_dir, mocker): """Clean provider project when called without parts.""" project = Project.unmarshal(snapcraft_yaml(base="core22")) clean_mock = mocker.patch( "snapcraft.providers.LXDProvider.clean_project_environments", return_value=["instance-name"], ) parts_lifecycle._run_command( "clean", project=project, parse_info={}, assets_dir=Path(), start_time=datetime.now(), parallel_build_count=8, parsed_args=argparse.Namespace( directory=None, output=None, destructive_mode=False, use_lxd=False, parts=None, ), ) assert clean_mock.mock_calls == [ call( project_name="mytest", project_path=new_dir, build_on=get_host_architecture(), build_for=get_host_architecture(), ) ]
def _run_in_provider(project: Project, command_name: str, parsed_args: "argparse.Namespace") -> None: """Pack image in provider instance.""" emit.debug("Checking build provider availability") provider_name = "lxd" if parsed_args.use_lxd else None provider = providers.get_provider(provider_name) provider.ensure_provider_is_available() cmd = ["snapcraft", command_name] if hasattr(parsed_args, "parts"): cmd.extend(parsed_args.parts) if getattr(parsed_args, "output", None): cmd.extend(["--output", parsed_args.output]) if emit.get_mode() == EmitterMode.VERBOSE: cmd.append("--verbose") elif emit.get_mode() == EmitterMode.QUIET: cmd.append("--quiet") elif emit.get_mode() == EmitterMode.DEBUG: cmd.append("--verbosity=debug") elif emit.get_mode() == EmitterMode.TRACE: cmd.append("--verbosity=trace") if parsed_args.debug: cmd.append("--debug") if getattr(parsed_args, "shell", False): cmd.append("--shell") if getattr(parsed_args, "shell_after", False): cmd.append("--shell-after") if getattr(parsed_args, "enable_manifest", False): cmd.append("--enable-manifest") build_information = getattr(parsed_args, "manifest_build_information", None) if build_information: cmd.append("--manifest-build-information") cmd.append(build_information) output_dir = utils.get_managed_environment_project_path() emit.progress("Launching instance...") with provider.launched_environment( project_name=project.name, project_path=Path().absolute(), base=project.get_effective_base(), bind_ssh=parsed_args.bind_ssh, build_on=get_host_architecture(), build_for=get_host_architecture(), ) as instance: try: with emit.pause(): instance.execute_run(cmd, check=True, cwd=output_dir) capture_logs_from_instance(instance) except subprocess.CalledProcessError as err: capture_logs_from_instance(instance) raise providers.ProviderError( f"Failed to execute {command_name} in instance.") from err
def run(self, parsed_args): snap_project = get_snap_project() yaml_data = process_yaml(snap_project.project_file) expanded_yaml_data = extensions.apply_extensions( yaml_data, arch=get_host_architecture(), target_arch=get_host_architecture(), ) Project.unmarshal(expanded_yaml_data) emit.message( yaml.safe_dump(expanded_yaml_data, indent=4, sort_keys=False))
def _expand_environment(snapcraft_yaml: Dict[str, Any], *, parallel_build_count: int, target_arch: str) -> None: """Expand global variables in the provided dictionary values. :param snapcraft_yaml: A dictionary containing the contents of the snapcraft.yaml project file. """ if utils.is_managed_mode(): work_dir = utils.get_managed_environment_home_path() else: work_dir = Path.cwd() project_vars = { "version": snapcraft_yaml.get("version", ""), "grade": snapcraft_yaml.get("grade", ""), } if target_arch == "all": target_arch = get_host_architecture() dirs = craft_parts.ProjectDirs(work_dir=work_dir) info = craft_parts.ProjectInfo( application_name="snapcraft", # not used in environment expansion cache_dir=Path(), # not used in environment expansion arch=convert_architecture_deb_to_platform(target_arch), parallel_build_count=parallel_build_count, project_name=snapcraft_yaml.get("name", ""), project_dirs=dirs, project_vars=project_vars, ) _set_global_environment(info) craft_parts.expand_environment(snapcraft_yaml, info=info, skip=["name", "version"])
def test_get_host_architecture(platform_machine, platform_architecture, mocker, deb_arch): """Test all platform mappings in addition to unknown.""" mocker.patch("platform.machine", return_value=platform_machine) mocker.patch("platform.architecture", return_value=platform_architecture) assert utils.get_host_architecture() == deb_arch
class ArchitectureProject(ProjectModel, extra=pydantic.Extra.ignore): """Project definition containing only architecture data.""" architectures: List[Union[str, Architecture]] = [get_host_architecture()] @pydantic.validator("architectures", always=True) @classmethod def _validate_architecture_data(cls, architectures): """Validate architecture data.""" return _validate_architectures(architectures) @classmethod def unmarshal(cls, data: Dict[str, Any]) -> "ArchitectureProject": """Create and populate a new ``Project`` object from dictionary data. The unmarshal method validates entries in the input dictionary, populating the corresponding fields in the data object. :param data: The dictionary data to unmarshal. :return: The newly created object. :raise TypeError: If data is not a dictionary. """ if not isinstance(data, dict): raise TypeError("Project data is not a dictionary") try: architectures = ArchitectureProject(**data) except pydantic.ValidationError as err: raise ProjectValidationError(_format_pydantic_errors( err.errors())) from err return architectures
def _construct_deb822_source( *, architectures: Optional[List[str]] = None, components: Optional[List[str]] = None, formats: Optional[List[str]] = None, suites: List[str], url: str, ) -> str: """Construct deb-822 formatted sources.list config string.""" with io.StringIO() as deb822: if formats: type_text = " ".join(formats) else: type_text = "deb" print(f"Types: {type_text}", file=deb822) print(f"URIs: {url}", file=deb822) suites_text = " ".join(suites) print(f"Suites: {suites_text}", file=deb822) if components: components_text = " ".join(components) print(f"Components: {components_text}", file=deb822) if architectures: arch_text = " ".join(architectures) else: arch_text = utils.get_host_architecture() print(f"Architectures: {arch_text}", file=deb822) return deb822.getvalue()
def _clean_provider(project: Project, parsed_args: "argparse.Namespace") -> None: """Clean the provider environment. :param project: The project to clean. """ emit.debug("Clean build provider") provider_name = "lxd" if parsed_args.use_lxd else None provider = providers.get_provider(provider_name) instance_names = provider.clean_project_environments( project_name=project.name, project_path=Path().absolute(), build_on=get_host_architecture(), build_for=get_host_architecture(), ) if instance_names: emit.message(f"Removed instance: {', '.join(instance_names)}") else: emit.message("No instances to remove")
def __init__( self, all_parts: Dict[str, Any], *, work_dir: pathlib.Path, assets_dir: pathlib.Path, base: str, package_repositories: List[Dict[str, Any]], parallel_build_count: int, part_names: Optional[List[str]], adopt_info: Optional[str], parse_info: Dict[str, List[str]], project_name: str, project_vars: Dict[str, str], extra_build_snaps: Optional[List[str]] = None, target_arch: str, ): self._work_dir = work_dir self._assets_dir = assets_dir self._package_repositories = package_repositories self._part_names = part_names self._adopt_info = adopt_info self._parse_info = parse_info self._all_part_names = [*all_parts] emit.progress("Initializing parts lifecycle") # set the cache dir for parts package management cache_dir = BaseDirectory.save_cache_path("snapcraft") if target_arch == "all": target_arch = get_host_architecture() platform_arch = convert_architecture_deb_to_platform(target_arch) try: self._lcm = craft_parts.LifecycleManager( {"parts": all_parts}, application_name="snapcraft", work_dir=work_dir, cache_dir=cache_dir, arch=platform_arch, base=base, ignore_local_sources=["*.snap"], extra_build_snaps=extra_build_snaps, parallel_build_count=parallel_build_count, project_name=project_name, project_vars_part_name=adopt_info, project_vars=project_vars, ) except craft_parts.PartsError as err: raise errors.PartsLifecycleError(str(err)) from err
def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mocker): project = Project.unmarshal(snapcraft_yaml(base="core22")) run_in_provider_mock = mocker.patch( "snapcraft.parts.lifecycle._run_in_provider") run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") pack_mock = mocker.patch("snapcraft.pack.pack_snap") mocker.patch("snapcraft.meta.snap_yaml.write") mocker.patch("snapcraft.utils.is_managed_mode", return_value=True) mocker.patch( "snapcraft.utils.get_managed_environment_home_path", return_value=new_dir / "home", ) parts_lifecycle._run_command( cmd, project=project, parse_info={}, assets_dir=Path(), start_time=datetime.now(), parallel_build_count=8, parsed_args=argparse.Namespace( directory=None, output=None, debug=False, bind_ssh=False, enable_manifest=False, manifest_image_information=None, destructive_mode=False, shell=False, shell_after=False, use_lxd=False, parts=[], ), ) assert run_in_provider_mock.mock_calls == [] assert run_mock.mock_calls == [ call("prime", debug=False, shell=False, shell_after=False) ] assert pack_mock.mock_calls[:1] == [ call( new_dir / "home/prime", output=None, compression="xz", name="mytest", version="0.1", target_arch=get_host_architecture(), ) ]
def get_build_plan(yaml_data: Dict[str, Any], parsed_args: "argparse.Namespace") -> List[Tuple[str, str]]: """Get a list of all build_on->build_for architectures from the project file. Additionally, check for the command line argument `--build-for <architecture>` When defined, the build plan will only contain builds where `build-for` matches `SNAPCRAFT_BUILD_FOR`. Note: `--build-for` defaults to the environmental variable `SNAPCRAFT_BUILD_FOR`. :param yaml_data: The project YAML data. :param parsed_args: snapcraft's argument namespace :return: List of tuples of every valid build-on->build-for combination. """ archs = ArchitectureProject.unmarshal(yaml_data).architectures host_arch = get_host_architecture() build_plan: List[Tuple[str, str]] = [] # `isinstance()` calls are for mypy type checking and should not change logic for arch in [arch for arch in archs if isinstance(arch, Architecture)]: for build_on in arch.build_on: if build_on in host_arch and isinstance(arch.build_for, list): build_plan.append((host_arch, arch.build_for[0])) else: emit.verbose( f"Skipping build-on: {build_on} build-for: {arch.build_for}" f" because build-on doesn't match host arch: {host_arch}") # filter out builds not matching argument `--build_for` or env `SNAPCRAFT_BUILD_FOR` build_for_arg = parsed_args.build_for if build_for_arg is not None: build_plan = [ build for build in build_plan if build[1] == build_for_arg ] if len(build_plan) == 0: emit.message("Could not make build plan:" " build-on architectures in snapcraft.yaml" f" does not match host architecture ({host_arch}).") else: log_output = "Created build plan:" for build in build_plan: log_output += f"\n build-on: {build[0]} build-for: {build[1]}" emit.trace(log_output) return build_plan
def test_root_packages(minimal_yaml_data, key, value): minimal_yaml_data[key] = value arch = get_host_architecture() assert parts_lifecycle.apply_yaml( minimal_yaml_data, build_on=arch, build_for=arch ) == { "name": "name", "base": "core22", "confinement": "strict", "grade": "devel", "version": "1.0", "summary": "summary", "description": "description", "architectures": [Architecture(build_on=arch, build_for=arch)], "parts": {"nil": {}, "snapcraft/core": {"plugin": "nil", key: ["foo"]}}, }
def test_lifecycle_run_provider(cmd, snapcraft_yaml, new_dir, mocker): """Option --provider is not supported in core22.""" snapcraft_yaml(base="core22") run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") mocker.patch( "snapcraft.providers.Provider.is_base_available", return_value=(True, None) ) with pytest.raises(errors.SnapcraftError) as raised: parts_lifecycle.run( cmd, parsed_args=argparse.Namespace( destructive_mode=False, use_lxd=False, provider="some", build_for=get_host_architecture(), ), ) assert run_mock.mock_calls == [] assert str(raised.value) == "Option --provider is not supported."
def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir, mocker): project = Project.unmarshal(snapcraft_yaml(base="core22")) run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") pack_mock = mocker.patch("snapcraft.pack.pack_snap") parts_lifecycle._run_command( cmd, project=project, parse_info={}, assets_dir=Path(), start_time=datetime.now(), parallel_build_count=8, parsed_args=argparse.Namespace( directory=None, output=None, debug=False, destructive_mode=True, enable_manifest=False, shell=False, shell_after=False, use_lxd=False, parts=[], ), ) assert run_mock.mock_calls == [ call("prime", debug=False, shell=False, shell_after=False) ] assert pack_mock.mock_calls[:1] == [ call( new_dir / "prime", output=None, compression="xz", name="mytest", version="0.1", target_arch=get_host_architecture(), ) ]
class Project(ProjectModel): """Snapcraft project definition. See https://snapcraft.io/docs/snapcraft-yaml-reference XXX: Not implemented in this version - system-usernames """ name: constr(max_length=40) # type: ignore title: Optional[constr(max_length=40)] # type: ignore base: Optional[str] build_base: Optional[str] compression: Literal["lzo", "xz"] = "xz" version: Optional[constr(max_length=32, strict=True)] # type: ignore contact: Optional[Union[str, UniqueStrList]] donation: Optional[Union[str, UniqueStrList]] issues: Optional[Union[str, UniqueStrList]] source_code: Optional[str] website: Optional[str] summary: Optional[constr(max_length=78)] # type: ignore description: Optional[str] type: Optional[Literal["app", "base", "gadget", "kernel", "snapd"]] icon: Optional[str] confinement: Literal["classic", "devmode", "strict"] layout: Optional[ Dict[str, Dict[Literal["symlink", "bind", "bind-file", "type"], str]] ] license: Optional[str] grade: Optional[Literal["stable", "devel"]] architectures: List[Union[str, Architecture]] = [get_host_architecture()] assumes: UniqueStrList = [] package_repositories: List[Dict[str, Any]] = [] # handled by repo hooks: Optional[Dict[str, Hook]] passthrough: Optional[Dict[str, Any]] apps: Optional[Dict[str, App]] plugs: Optional[Dict[str, Union[ContentPlug, Any]]] slots: Optional[Dict[str, Any]] parts: Dict[str, Any] # parts are handled by craft-parts epoch: Optional[str] adopt_info: Optional[str] system_usernames: Optional[Dict[str, Any]] environment: Optional[Dict[str, Optional[str]]] build_packages: Optional[GrammarStrList] build_snaps: Optional[GrammarStrList] @pydantic.validator("plugs") @classmethod def _validate_plugs(cls, plugs): if plugs is not None: for plug_name, plug in plugs.items(): if ( isinstance(plug, dict) and plug.get("interface") == "content" and not plug.get("target") ): raise ValueError( f"ContentPlug '{plug_name}' must have a 'target' parameter." ) if isinstance(plug, list): raise ValueError(f"Plug '{plug_name}' cannot be a list.") return plugs @pydantic.root_validator(pre=True) @classmethod def _validate_adoptable_fields(cls, values): for field in MANDATORY_ADOPTABLE_FIELDS: if field not in values and "adopt-info" not in values: raise ValueError(f"Snap {field} is required if not using adopt-info") return values @pydantic.root_validator(pre=True) @classmethod def _validate_mandatory_base(cls, values): snap_type = values.get("type") base = values.get("base") if (base is not None) ^ (snap_type not in ["base", "kernel", "snapd"]): raise ValueError( "Snap base must be declared when type is not base, kernel or snapd" ) return values @pydantic.validator("name") @classmethod def _validate_name(cls, name): if not re.match(r"^[a-z0-9-]*[a-z][a-z0-9-]*$", name): raise ValueError( "Snap names can only use ASCII lowercase letters, numbers, and hyphens, " "and must have at least one letter" ) if name.startswith("-"): raise ValueError("Snap names cannot start with a hyphen") if name.endswith("-"): raise ValueError("Snap names cannot end with a hyphen") if "--" in name: raise ValueError("Snap names cannot have two hyphens in a row") return name @pydantic.validator("version") @classmethod def _validate_version(cls, version, values): if not version and "adopt_info" not in values: raise ValueError("Version must be declared if not adopting metadata") if version and not re.match( r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$", version ): raise ValueError( "Snap versions consist of upper- and lower-case alphanumeric characters, " "as well as periods, colons, plus signs, tildes, and hyphens. They cannot " "begin with a period, colon, plus sign, tilde, or hyphen. They cannot end " "with a period, colon, or hyphen" ) return version @pydantic.validator("grade", "summary", "description") @classmethod def _validate_adoptable_field(cls, field_value, values, field): if not field_value and "adopt_info" not in values: raise ValueError( f"{field.name.capitalize()} must be declared if not adopting metadata" ) return field_value @pydantic.validator("build_base", always=True) @classmethod def _validate_build_base(cls, build_base, values): """Build-base defaults to the base value if not specified.""" if not build_base: build_base = values.get("base") return build_base @pydantic.validator("package_repositories", each_item=True) @classmethod def _validate_package_repositories(cls, item): """Ensure package-repositories format is correct.""" repo.validate_repository(item) return item @pydantic.validator("parts", each_item=True) @classmethod def _validate_parts(cls, item): """Verify each part (craft-parts will re-validate this).""" parts_validation.validate_part(item) return item @pydantic.validator("epoch") @classmethod def _validate_epoch(cls, epoch): """Verify epoch format.""" if epoch is not None and not re.match(r"^(?:0|[1-9][0-9]*[*]?)$", epoch): raise ValueError( "Epoch is a positive integer followed by an optional asterisk" ) return epoch @pydantic.validator("architectures", always=True) @classmethod def _validate_architecture_data(cls, architectures): """Validate architecture data.""" return _validate_architectures(architectures) @classmethod def unmarshal(cls, data: Dict[str, Any]) -> "Project": """Create and populate a new ``Project`` object from dictionary data. The unmarshal method validates entries in the input dictionary, populating the corresponding fields in the data object. :param data: The dictionary data to unmarshal. :return: The newly created object. :raise TypeError: If data is not a dictionary. """ if not isinstance(data, dict): raise TypeError("Project data is not a dictionary") try: project = Project(**data) except pydantic.ValidationError as err: raise ProjectValidationError(_format_pydantic_errors(err.errors())) from err return project def _get_content_plugs(self) -> List[ContentPlug]: """Get list of content plugs.""" if self.plugs is not None: return [ plug for plug in self.plugs.values() if isinstance(plug, ContentPlug) ] return [] def get_content_snaps(self) -> List[str]: """Get list of snaps from ContentPlug `default-provider` fields.""" return [ x.default_provider for x in self._get_content_plugs() if x.default_provider is not None ] def get_extra_build_snaps(self) -> List[str]: """Get list of extra snaps required to build.""" # Build snaps defined by the user with channel stripped build_snaps: List[str] = [] for part in self.parts.values(): build_snaps.extend(part.get("build-snaps", [])) part_build_snaps = {p.split("/")[0] for p in build_snaps} # Content snaps the project uses content_snaps = set(self.get_content_snaps()) # Do not add the content snaps if provided by the user extra_build_snaps = list(content_snaps - part_build_snaps) # Always add the base as an extra build snap if self.base is not None: extra_build_snaps.append(self.base) extra_build_snaps.sort() return extra_build_snaps def get_effective_base(self) -> str: """Return the base to use to create the snap.""" base = get_effective_base( base=self.base, build_base=self.build_base, project_type=self.type, name=self.name, ) # will not happen after schema validation if base is None: raise RuntimeError("cannot determine build base") return base