diff --git a/.pipelines/templates/stages/trident_images/trident-testimg-template.yml b/.pipelines/templates/stages/trident_images/trident-testimg-template.yml index c2d53a4694..d6d1587aca 100644 --- a/.pipelines/templates/stages/trident_images/trident-testimg-template.yml +++ b/.pipelines/templates/stages/trident_images/trident-testimg-template.yml @@ -134,6 +134,21 @@ steps: cp $(Build.ArtifactStagingDirectory)/ssh/id_rsa* ${{ parameters.tridentSourceDirectory }}/artifacts/ displayName: Copy SSH Keys + # Stage the SSH public key into the test image tree so the builder bakes it + # into the image. The Makefile previously did this via the files/id_rsa.pub + # prerequisite; those explicit image targets were removed, so the pipeline + # now stages the key before building. Superseded by the shared + # prepare-testimage-requirements template. + - ${{ if eq(parameters.useStagedSshKeys, true) }}: + - bash: | + set -eux + + SRC="${{ parameters.tridentSourceDirectory }}/artifacts/id_rsa.pub" + DEST="${{ parameters.tridentSourceDirectory }}/tests/images/trident-vm-testimage/base/files" + mkdir -p "$DEST" + cp "$SRC" "$DEST/" + displayName: Stage SSH public key into testimage tree + - script: | echo "##[warning]THE PIPELINE TEMPLATE trident-testimg-template.yaml IS DEPRECATED. PLEASE SWITCH TO USING testimages.py TO BUILD TEST IMAGES." cat /etc/os-release diff --git a/Makefile b/Makefile index bba5720ba1..d45e32f5b7 100644 --- a/Makefile +++ b/Makefile @@ -943,40 +943,20 @@ validate-pipeline-website-artifact: npm install && \ npm run serve -- --port $(SERVER_PORT) -# -# Generic COSI image build target pattern -# -COSI_TARGETS = $(shell ./tests/images/testimages.py list --filter-type cosi) - -.PHONY: $(COSI_TARGETS) -$(COSI_TARGETS): %: artifacts/%.cosi - -.PHONY: all-cosi -all-cosi: $(COSI_TARGETS) - -# -# Generic ISO image build target pattern -# -ISO_TARGETS = $(shell ./tests/images/testimages.py list --filter-type iso) - -.PHONY: $(ISO_TARGETS) -$(ISO_TARGETS): %: artifacts/%.iso - -.PHONY: all-iso -all-iso: $(ISO_TARGETS) - # Fun trick to use the stem of the target (%) as a variable ($*) in the # prerequisites so that we can use find to get all the files in the directory. # https://www.gnu.org/software/make/manual/make.html#Secondary-Expansion .SECONDEXPANSION: -artifacts/%.cosi artifacts/%.iso artifacts/%.vhdx: $$(shell ./tests/images/testimages.py dependencies $$*) +artifacts/%.cosi artifacts/%.iso artifacts/%.vhdx artifacts/%.vhd artifacts/%.qcow2: $$(shell ./tests/images/testimages.py dependencies $$*) @echo "Building '$*' [$@] from $<" + @echo "Extension is: $(subst .,,$(suffix $@))" @echo "Prerequisites:" @echo "$^" | tr ' ' '\n' | sed 's/^/ /' @echo "Building image..." sudo ./tests/images/testimages.py build \ $* \ --output-dir ./artifacts \ + --output-type $(subst .,,$(suffix $@)) \ $(if $(strip $(MIC_CONTAINER_IMAGE)),--container $(MIC_CONTAINER_IMAGE)) \ $(if $(strip $(MIC_ARCHITECTURE)),--image-architecture $(MIC_ARCHITECTURE)) @@ -1046,155 +1026,6 @@ $(MINIMAL_IMAGE_AARCH64): @mkdir -p artifacts @tests/images/testimages.py download-image minimal_aarch64 -artifacts/trident-vm-grub-testimage.qcow2: \ - $(QEMU_GUEST_IMAGE) \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-grub.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub \ - artifacts/rpm-overrides - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --rpm-source /repo/artifacts/rpm-overrides \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format qcow2 \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-grub.yaml - -artifacts/trident-vm-grub-testimage-arm64.qcow2: \ - base/core_arm64.vhdx \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-grub.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format qcow2 \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-grub-verity.yaml - -artifacts/trident-vm-grub-verity-testimage.qcow2: \ - $(QEMU_GUEST_IMAGE) \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-grub-verity.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/etc-mount.service \ - $(VM_IMAGE_PATH_PREFIX)/files/etc-mount.sh \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub \ - artifacts/rpm-overrides - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --rpm-source /repo/artifacts/rpm-overrides \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format qcow2 \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-grub-verity.yaml - -artifacts/trident-vm-root-verity-testimage.qcow2: \ - $(QEMU_GUEST_IMAGE) \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-root-verity.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub \ - artifacts/rpm-overrides - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --rpm-source /repo/artifacts/rpm-overrides \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format qcow2 \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-root-verity.yaml - -artifacts/trident-vm-verity-testimage-arm64.qcow2: \ - base/core_arm64.vhdx \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-verity.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/etc-mount.service \ - $(VM_IMAGE_PATH_PREFIX)/files/etc-mount.sh \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format qcow2 \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-verity.yaml - -artifacts/trident-vm-usr-verity-testimage.qcow2: \ - $(QEMU_GUEST_IMAGE) \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-usr-verity.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub \ - artifacts/rpm-overrides - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --rpm-source /repo/artifacts/rpm-overrides \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format qcow2 \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-usr-verity.yaml - -artifacts/trident-vm-grub-verity-azure-testimage.vhd: \ - $(CORE_SELINUX_IMAGE) \ - $(TRIDENT_VM_DEPENDENCIES) \ - $(VM_IMAGE_PATH_PREFIX)/baseimg-grub-verity-azure.yaml \ - $(VM_IMAGE_PATH_PREFIX)/files/etc-mount.service \ - $(VM_IMAGE_PATH_PREFIX)/files/etc-mount.sh \ - $(VM_IMAGE_PATH_PREFIX)/files/id_rsa.pub \ - artifacts/rpm-overrides - @echo "Building $@ from $<" - docker run --rm \ - --privileged \ - -v ".:/repo:z" \ - -v "/dev:/dev" \ - ${MIC_CONTAINER_IMAGE} \ - --log-level debug \ - --rpm-source /repo/bin/RPMS \ - --rpm-source /repo/artifacts/rpm-overrides \ - --build-dir /build \ - --image-file /repo/$< \ - --output-image-file /repo/$@ \ - --output-image-format vhd-fixed \ - --config-file /repo/$(VM_IMAGE_PATH_PREFIX)/baseimg-grub-verity-azure.yaml - .PHONY: imagecustomizer-dev-amd64 imagecustomizer-dev-amd64: make -C ../azure-linux-image-tools/toolkit go-imagecustomizer diff --git a/tests/images/builder/README.md b/tests/images/builder/README.md index 0fafbd66a9..a78cfb2cfa 100644 --- a/tests/images/builder/README.md +++ b/tests/images/builder/README.md @@ -100,7 +100,7 @@ The following commands are supported: python3 ./testimages.py dependencies # Build an image locally python3 ./testimages.py build - # Show key info about the image, such as name, source, config path, etc. + # Show key info about the image, such as name, source, base image, etc. python3 ./testimages.py show-image # Show info on key artifacts, such as the Image Customizer version or container image python3 ./testimages.py show-artifact @@ -111,16 +111,22 @@ The following commands are supported: To build an image with Builder or test your changes to Builder, follow these steps: 1. If necessary, make changes to the Builder source code. -1. Download the base image: +1. Download the base image. To find which base image an image builds on (as the build pipeline does), run: + +```bash + python3 ./testimages.py show-image base-image +``` + + Then download it: ```bash ./testimages.py download-image ``` -1. If necessary, update the Image Customizer config for the image you want to build, by modifying the corresponding YAML in `test-images/platform-integration-images`. You can also find the relevant YAML by running: +1. If necessary, update the Image Customizer config for the image you want to build, by modifying the corresponding YAML in `test-images/platform-integration-images`. To see the config YAML used for each output type, run: ```bash - python3 ./testimages.py show-image config-file + python3 ./testimages.py show-image output-and-config ``` 1. Build the image: diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index ca82f58db9..e269a7199e 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -1,40 +1,67 @@ +import logging import yaml from dataclasses import dataclass, field, fields from enum import Enum from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Union + +log = logging.getLogger(__name__) + + +class Distro(Enum): + AZL3 = "azl3" + AZL4 = "azl4" + OTHER = "other" @dataclass class BaseImageData: name: str path: Path + mcr_name: Optional[str] = None + distro: Distro = Distro.AZL3 class BaseImage(Enum): BAREMETAL = BaseImageData("baremetal", Path("artifacts/baremetal.vhdx")) CORE_SELINUX = BaseImageData("core_selinux", Path("artifacts/core_selinux.vhdx")) QEMU_GUEST = BaseImageData("qemu_guest", Path("artifacts/qemu_guest.vhdx")) + AZL4_QEMU_GUEST = BaseImageData( + "azl4_qemu_guest", + Path("artifacts/azl4_qemu_guest.vhdx"), + distro=Distro.AZL4, + ) + # AZL4_CORE = BaseImageData( + # "azl4_core", Path("artifacts/azl4_core.vhdx"), "core", Distro.AZL4 + # ) CORE_ARM64 = BaseImageData("core_arm64", Path("artifacts/core_arm64.vhdx")) MINIMAL = BaseImageData("minimal", Path("artifacts/minimal.vhdx")) MINIMAL_AARCH64 = BaseImageData( "minimal_aarch64", Path("artifacts/minimal_aarch64.vhdx") ) UBUNTU_2204_AMD64 = BaseImageData( - "ubuntu_2204_amd64", Path("artifacts/ubuntu_2204_amd64.vhdx") + "ubuntu_2204_amd64", + Path("artifacts/ubuntu_2204_amd64.vhdx"), + distro=Distro.OTHER, ) UBUNTU_2204_ARM64 = BaseImageData( - "ubuntu_2204_arm64", Path("artifacts/ubuntu_2204_arm64.vhdx") + "ubuntu_2204_arm64", + Path("artifacts/ubuntu_2204_arm64.vhdx"), + distro=Distro.OTHER, ) UBUNTU_2404_AMD64 = BaseImageData( - "ubuntu_2404_amd64", Path("artifacts/ubuntu_2404_amd64.vhdx") + "ubuntu_2404_amd64", + Path("artifacts/ubuntu_2404_amd64.vhdx"), + distro=Distro.OTHER, ) UBUNTU_2404_ARM64 = BaseImageData( - "ubuntu_2404_arm64", Path("artifacts/ubuntu_2404_arm64.vhdx") + "ubuntu_2404_arm64", + Path("artifacts/ubuntu_2404_arm64.vhdx"), + distro=Distro.OTHER, ) GB200_2404_ARM64 = BaseImageData( - "gb200_2404_arm64", Path("artifacts/gb200_2404_arm64.vhdx") + "gb200_2404_arm64", Path("artifacts/gb200_2404_arm64.vhdx"), distro=Distro.OTHER ) @property @@ -45,6 +72,12 @@ def path(self) -> Path: def name(self) -> str: return self.value.name + @property + def mcr_name(self) -> str: + if self.value.mcr_name is not None: + return self.value.mcr_name + return self.value.name + def __str__(self) -> str: return self.value.name @@ -54,12 +87,41 @@ class BaseImageManifest: image: BaseImage package_name: str version: str + distro: Distro = Distro.AZL3 org: str = "https://dev.azure.com/mariner-org/" project: str = "36d030d6-1d99-4ebd-878b-09af1f4f722f" feed: str = "AzureLinuxArtifacts" glob: str = "*.vhdx" +@dataclass +class BlobImageManifest: + """Manifest for a base image fetched from Azure Storage Blob. + + Used for distros that don't yet publish to an ADO universal artifact + feed (e.g., Azure Linux 4.0 alpha builds). The storage account name + and container are NOT baked in here -- they are supplied at + invocation time via the --blob-storage-account / --blob-container + flags (or the BLOB_STORAGE_ACCOUNT / BLOB_CONTAINER env vars) so the + pipeline can parameterize them and rotate the location without a + code change. + + Authentication is via `az` CLI logged-in identity (`--auth-mode + login`). The pipeline running this must have a federated identity + with read access to the storage account. + """ + + image: BaseImage + # Blob name prefix to search under + # (e.g. "azure-linux/core-efi-vhdx-4.0-amd64") + path_prefix: str + # Suffix the final blob name must end with. + # The downloader lists all blobs under path_prefix, filters to ones + # ending with this suffix, and picks the lexically largest (= most + # recent version) to download. + file_suffix: str = "/image.vhdx" + + class OutputFormat(Enum): BAREMETAL_IMAGE = "baremetal-image" COSI = "cosi" @@ -111,9 +173,6 @@ class ImageConfig: # Second-level dir, generally same as name config: str = None - # YAML config file inside the config dir - config_file: Path = Path("base/baseimg.yaml") - # The base image to use base_image: BaseImage = BaseImage.BAREMETAL @@ -124,7 +183,9 @@ class ImageConfig: requires_dhcp: bool = False # Desired output format for this image - output_format: OutputFormat = OutputFormat.COSI + output_and_config: dict[OutputFormat, Path] = field( + default_factory=lambda: {OutputFormat.COSI: Path("base/baseimg.yaml")} + ) # Extra dependencies for this image extra_dependencies: List[Path] = field(default_factory=list) @@ -142,6 +203,9 @@ class ImageConfig: # Use ImageCustomizer convert command rather than customize image_customizer_convert: bool = False + # Runtime variable used to configure output format + runtime_output_format: Optional[OutputFormat] = None + @classmethod def kebab_fields(cls) -> List[str]: """Return a list of fields in kebab-case.""" @@ -156,9 +220,11 @@ def __post_init__(self): if isinstance(self.ssh_key, str): self.ssh_key = Path(self.ssh_key) - # Update config_file to be a Path object if it's a string - if isinstance(self.config_file, str): - self.config_file = Path(self.config_file) + # Normalize output_and_config values to Path objects + for fmt in self.output_and_config: + cfg = self.output_and_config[fmt] + if isinstance(cfg, str): + self.output_and_config[fmt] = Path(cfg) # Automatically set the architecture to arm64 if the base image is ARM64 if self.base_image == BaseImage.CORE_ARM64: @@ -183,8 +249,40 @@ def base_ic_config(self) -> dict: def base_dir(self) -> Path: return Path(self.source) / self.config + def output_format(self) -> OutputFormat: + if self.runtime_output_format is not None: + # baremetal-image and vhd-fixed share a file extension with + # cosi and vhd respectively (ext() collapses them), so an + # explicit request for either must match that exact format + # when declared, rather than silently downgrading to the + # same-extension format. + if ( + self.runtime_output_format + in (OutputFormat.BAREMETAL_IMAGE, OutputFormat.VHD_FIXED) + and self.runtime_output_format in self.output_and_config + ): + return self.runtime_output_format + # Otherwise resolve by file extension to preserve the + # Makefile's extension-based selection (e.g. --output-type + # cosi / vhd). + for fmt in self.output_and_config: + if fmt.ext() == self.runtime_output_format.ext(): + return fmt + supported = ", ".join(sorted({fmt.ext() for fmt in self.output_and_config})) + raise ValueError( + f"Output type '{self.runtime_output_format.value}' " + f"(extension '{self.runtime_output_format.ext()}') is not supported " + f"by image '{self.name}'. Supported output extensions: {supported}." + ) + return next(iter(self.output_and_config)) + + def config_path(self) -> Path: + # output_format() returns a key of output_and_config (or raises), + # so index directly. + return self.output_and_config[self.output_format()] + def full_yaml_path(self) -> Path: - return self.base_dir() / self.config_file + return self.base_dir() / self.config_path() def dependencies(self) -> List[Path]: deps = [self.base_image.path] @@ -207,7 +305,7 @@ def file_name(self) -> str: """ Returns the file name for the image. """ - return f"{self.id}.{self.output_format.ext()}" + return f"{self.id}.{self.output_format().ext()}" def file_name_unsigned_raw(self) -> str: """Returns the file name for the unsigned raw image.""" @@ -223,6 +321,32 @@ def id(self) -> str: return self.name return f"{self.name}_{self.suffix}" + def set_output_type(self, output_type: str) -> None: + """Set the runtime output type based on a string.""" + try: + self.runtime_output_format = OutputFormat(output_type) + # Only warn about the ambiguous same-extension alternative when + # this image actually declares it; otherwise the hint is noise. + if ( + output_type == OutputFormat.COSI.ext() + and OutputFormat.BAREMETAL_IMAGE in self.output_and_config + ): + log.warning( + "Output type 'cosi' was specified; if 'baremetal-image' was intended, use that as the output type." + ) + if ( + output_type == OutputFormat.VHD.ext() + and OutputFormat.VHD_FIXED in self.output_and_config + ): + log.warning( + "Output type 'vhd' was specified; if 'vhd-fixed' was intended, use that as the output type." + ) + except ValueError as e: + valid_formats = ", ".join([fmt.value for fmt in OutputFormat]) + raise ValueError( + f"Invalid output type '{output_type}'. Valid options are: {valid_formats}" + ) from e + def get_output_artifacts_dir(self) -> Optional[str]: """ Return the output.artifacts.path from the image configuration YAML. @@ -249,7 +373,9 @@ class ArtifactManifest: customizer_version: str customizer_container: str customizer_container_full: str = None - base_images: List[BaseImageManifest] = field(default_factory=list) + base_images: List[Union["BaseImageManifest", "BlobImageManifest"]] = field( + default_factory=list + ) def __post_init__(self): if self.customizer_container_full is None: @@ -264,7 +390,9 @@ def kebab_fields(cls) -> List[str]: """Return a list of fields in kebab-case.""" return [f.name.replace("_", "-") for f in fields(cls)] - def find_base_image(self, img: BaseImage) -> Optional[BaseImageManifest]: + def find_base_image( + self, img: BaseImage + ) -> Optional[Union["BaseImageManifest", "BlobImageManifest"]]: """Find a base image by its name.""" for base_image in self.base_images: if base_image.image == img: diff --git a/tests/images/builder/builder.py b/tests/images/builder/builder.py index 3c066d0216..4357c61d84 100644 --- a/tests/images/builder/builder.py +++ b/tests/images/builder/builder.py @@ -147,7 +147,7 @@ def build_one( container_image, image.id, image.base_image.path, - image.output_format.ic_name(), + image.output_format().ic_name(), output_file, image_architecture, dry_run, @@ -188,7 +188,7 @@ def build_one( image.id, image.full_yaml_path(), image.base_image.path, - image.output_format.ic_name(), + image.output_format().ic_name(), output_file, tmp_rpm_sources, image_architecture, @@ -459,7 +459,7 @@ def build_signed_image( container_image, inject_files_yaml_path, unsigned_output_file, - image.output_format.ic_name(), + image.output_format().ic_name(), output_file, dry_run, ) diff --git a/tests/images/builder/cli.py b/tests/images/builder/cli.py index 741f0c2396..af30758ec8 100644 --- a/tests/images/builder/cli.py +++ b/tests/images/builder/cli.py @@ -1,6 +1,7 @@ import argparse from enum import Enum import logging +import os from pathlib import Path from typing import List @@ -12,7 +13,6 @@ run, ) - logging.basicConfig(level=logging.INFO) log = logging.getLogger("trident-testimages") @@ -117,6 +117,12 @@ def setup_parser_build( parser_build.add_argument( "image", help="The image to build", choices=[c.name for c in configs] ) + parser_build.add_argument( + "--output-type", + default=None, + type=str, + help="Specify output type for image configs. If unspecified, the first output type defined in the image config will be used.", + ) parser_build.add_argument( "--output-dir", help="Where to write the output image.", @@ -183,14 +189,26 @@ def setup_parser_download_image( ) -> None: parser_download_img = subparsers.add_parser( SubCommand.DOWNLOAD_IMAGE.value, - help="Download a base image from the Azure DevOps feed", + help="Download a base image.", ) parser_download_img.set_defaults(artifacts=artifacts) parser_download_img.add_argument( "image", - help="The image to download", + help="The image to download.", choices=[c.image.name for c in artifacts.base_images], ) + parser_download_img.add_argument( + "--blob-storage-account", + default=os.environ.get("BLOB_STORAGE_ACCOUNT"), + help="Azure Storage account name for blob-sourced images. " + "Env: BLOB_STORAGE_ACCOUNT.", + ) + parser_download_img.add_argument( + "--blob-container", + default=os.environ.get("BLOB_CONTAINER"), + help="Azure Storage container name for blob-sourced images. " + "Env: BLOB_CONTAINER.", + ) def setup_parser_matrix( @@ -263,6 +281,7 @@ def run_cmd( artifacts=args.artifacts, configs=configs, name=args.image, + output_type=args.output_type, container_name=args.container, output_dir=args.output_dir, clones=args.clones, @@ -285,6 +304,8 @@ def run_cmd( run.download_base_image( artifacts=args.artifacts, name=args.image, + blob_storage_account=args.blob_storage_account, + blob_container=args.blob_container, ) elif subcommand == SubCommand.MATRIX: run.generate_matrix( diff --git a/tests/images/builder/download.py b/tests/images/builder/download.py index 6f9db4c9f0..d825be8927 100644 --- a/tests/images/builder/download.py +++ b/tests/images/builder/download.py @@ -1,19 +1,33 @@ +import json +import logging +import os +import re from pathlib import Path import shutil import subprocess import tempfile +from typing import Optional -from builder import BaseImageManifest +from builder import BaseImageManifest, BlobImageManifest, Distro + +log = logging.getLogger(__name__) def download_base_image(image: BaseImageManifest) -> None: """Download the base image from MCR.""" + if image.distro not in (Distro.AZL3, Distro.AZL4): + raise ValueError(f"Unsupported distro {image.distro} for base image download") with tempfile.TemporaryDirectory() as tempdir: + url = ( + f"mcr.microsoft.com/azurelinux-beta/base/{image.image.mcr_name}:4.0" + if image.distro == Distro.AZL4 + else f"mcr.microsoft.com/azurelinux/3.0/image/{image.image.name}:latest" + ) subprocess.run( [ "oras", "pull", - f"mcr.microsoft.com/azurelinux/3.0/image/{image.image.name}:latest", + url, "--output", tempdir, "--platform", @@ -39,3 +53,140 @@ def download_base_image(image: BaseImageManifest) -> None: # Copy the .vhdx file to the target location shutil.copy2(vhdx_files[0], image.image.path) + + +# Constrain blob filename selection to a date-prefixed shape so a stray +# blob with a name that lexically sorts last (`zzz-evil/image.vhdx`) +# cannot win selection. Matches `YYYYMMDD/` or `YYYY-MM-DD/`-style +# version prefixes, which is the upstream publisher's convention. +# +# This is defense against a broader governance issue: the storage account +# is owned by another team, so write access is out of Trident's control. +# The regex narrows the attack surface to "names matching this shape" +# while still letting us track the latest published version. Tracked +# longer-term in the AZL4 supply-chain governance discussion. +_BLOB_NAME_VERSION_RE = re.compile(r"/([^/]*\d{4}-?\d{2}-?\d{2}[^/]*)/") + + +def download_blob_image( + image: BlobImageManifest, + storage_account: Optional[str], + container: Optional[str], +) -> None: + """Download a base image from Azure Storage Blob. + + Lists blobs under `image.path_prefix`, filters to ones whose name + matches a date-prefixed version pattern AND ends with + `image.file_suffix`, picks the lexically largest (= most recent + date), and downloads it atomically to `image.image.path`. + + Requires `az` CLI with a logged-in identity that has read access + to the storage account. Uses `--auth-mode login` so no storage + keys are needed. + """ + if not storage_account or not container: + raise RuntimeError( + f"Blob storage account/container required to download " + f"'{image.image.name}'. Pass --blob-storage-account and " + f"--blob-container, or set BLOB_STORAGE_ACCOUNT and " + f"BLOB_CONTAINER env vars." + ) + + az = shutil.which("az") + if az is None: + raise RuntimeError( + "az CLI not found on PATH; required to fetch blob-sourced " + "base images. Install azure-cli." + ) + + log.info( + f"Listing blobs in '{storage_account}/{container}' under " + f"prefix '{image.path_prefix}/'" + ) + # No `--query` interpolation: do the filtering in Python so caller + # control of `image.file_suffix` (or any other field that might + # become externally settable later) cannot inject JMESPath. + list_proc = subprocess.run( + [ + az, + "storage", + "blob", + "list", + "--auth-mode", + "login", + "--account-name", + storage_account, + "--container-name", + container, + "--prefix", + f"{image.path_prefix}/", + "--query", + "[].name", + "-o", + "json", + ], + check=True, + capture_output=True, + text=True, + ) + all_names = json.loads(list_proc.stdout) + suffix = image.file_suffix + eligible = [ + n for n in all_names if n.endswith(suffix) and _BLOB_NAME_VERSION_RE.search(n) + ] + if not eligible: + raise RuntimeError( + f"No date-versioned blobs ending with '{suffix}' found under " + f"'{image.path_prefix}/' in '{storage_account}/{container}' " + f"(saw {len(all_names)} total blobs under the prefix)" + ) + + latest = sorted(eligible)[-1] + log.info(f"Latest: {latest}") + + image.image.path.parent.mkdir(parents=True, exist_ok=True) + + # Download to a sibling temp file then atomically rename. `az + # storage blob download` writes in place — if the step is killed + # (timeout / OOM / agent reboot) between create and complete, the + # next run sees a truncated VHDX and MIC fails with an opaque + # error. The temp-then-rename pattern guarantees the target either + # has the full bytes or doesn't exist. + target = image.image.path + fd, tmp_path = tempfile.mkstemp( + prefix=target.name + ".", + suffix=".part", + dir=str(target.parent), + ) + os.close(fd) + try: + subprocess.run( + [ + az, + "storage", + "blob", + "download", + "--auth-mode", + "login", + "--account-name", + storage_account, + "--container-name", + container, + "--name", + latest, + "--file", + tmp_path, + "--output", + "none", + ], + check=True, + ) + os.replace(tmp_path, target) + except BaseException: + # On any failure, remove the temp file so we don't leave + # partial-state debris next to the final path. + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise diff --git a/tests/images/builder/run.py b/tests/images/builder/run.py index d465beb2f2..cc4b9af789 100644 --- a/tests/images/builder/run.py +++ b/tests/images/builder/run.py @@ -3,7 +3,7 @@ import json from typing import List, Optional -from builder import ImageConfig, RpmSources, ArtifactManifest +from builder import ArtifactManifest, BlobImageManifest, ImageConfig, RpmSources from .builder import build_image from .convert import convert_image from . import download @@ -22,9 +22,13 @@ def list_configs( *, configs: List[ImageConfig], filter_type: Optional[str] = None ) -> None: for config in configs: - if filter_type is None or config.output_format.ext() == filter_type: + if filter_type is None: print(config.name) + for output_format in config.output_and_config: + if filter_type == output_format.ext(): + print(config.name) + def list_files(*, configs: List[ImageConfig], output_dir: Path) -> None: for config in configs: @@ -66,6 +70,8 @@ def show_image( out = field elif isinstance(field, list): out = "\n".join([str(i) for i in field]) + elif isinstance(field, dict): + out = "\n".join(f"{getattr(k, 'value', k)}: {v}" for k, v in field.items()) elif hasattr(field, "__str__") and callable(field.__str__): out = str(field) else: @@ -82,6 +88,7 @@ def build( artifacts: ArtifactManifest, configs: List[ImageConfig], name: str, + output_type: Optional[str], container_name: str, output_dir: Path, clones: int, @@ -93,6 +100,14 @@ def build( image = find_image(configs, name) log.info(f"Building image '{image.name}'") + if output_type is not None: + image.set_output_type(output_type) + log.info(f"Building image with output type '{image.output_format().ic_name()}'") + else: + log.info( + f"Building image with default output type '{image.output_format().ic_name()}'" + ) + container_image: Optional[str] = container_name if container_image is None: log.error("Image Customizer container image is required") @@ -104,7 +119,7 @@ def build( container_image, image.id, image.base_image.path, - image.output_format.ic_name(), + image.output_format().ic_name(), output_dir / image.file_name(), image_architecture, dry_run, @@ -148,6 +163,8 @@ def download_base_image( *, artifacts: ArtifactManifest, name: str, + blob_storage_account: Optional[str] = None, + blob_container: Optional[str] = None, ) -> None: image_manifest = next( (img for img in artifacts.base_images if img.image.name == name), None @@ -155,7 +172,15 @@ def download_base_image( if image_manifest is None: raise ValueError(f"Image '{name}' not found in artifacts") log.info(f"Downloading base image '{name}' to '{image_manifest.image.path}'") - download.download_base_image(image_manifest) + + if isinstance(image_manifest, BlobImageManifest): + download.download_blob_image( + image_manifest, + storage_account=blob_storage_account, + container=blob_container, + ) + else: + download.download_base_image(image_manifest) def generate_matrix( diff --git a/tests/images/testimages.py b/tests/images/testimages.py index 9ab341cba9..9a0683f76f 100755 --- a/tests/images/testimages.py +++ b/tests/images/testimages.py @@ -7,6 +7,7 @@ ArtifactManifest, BaseImage, BaseImageManifest, + BlobImageManifest, ImageConfig, OutputFormat, SystemArchitecture, @@ -28,45 +29,42 @@ ImageConfig( "trident-installer", config="trident-installer", - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "base/baseimg.yaml"}, ), ImageConfig( "trident-split-installer", config="trident-installer", - config_file="base/baseimg-split.yaml", - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "base/baseimg-split.yaml"}, ), ImageConfig( "trident-installer-arm64", config="trident-installer", - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "base/baseimg.yaml"}, base_image=BaseImage.CORE_ARM64, architecture=SystemArchitecture.ARM64, ), ImageConfig( "trident-container-installer", config="trident-container-installer", - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "base/baseimg.yaml"}, requires_trident=False, ), ImageConfig( "trident-direct-streaming-installer-amd64", config="trident-installer", - config_file="base/baseimg-direct-streaming.yaml", - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "base/baseimg-direct-streaming.yaml"}, ), ImageConfig( "trident-direct-streaming-installer-arm64", config="trident-installer", - config_file="base/baseimg-direct-streaming.yaml", - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "base/baseimg-direct-streaming.yaml"}, base_image=BaseImage.CORE_ARM64, architecture=SystemArchitecture.ARM64, ), # Test images ImageConfig( "trident-functest", - output_format=OutputFormat.QCOW2, + output_and_config={OutputFormat.QCOW2: "base/baseimg.yaml"}, requires_trident=False, ), ImageConfig("trident-testimage"), @@ -80,19 +78,19 @@ ImageConfig( "trident-usrverity-testimage", config="trident-verity-testimage", - config_file="usr/host.yaml", + output_and_config={OutputFormat.COSI: "usr/host.yaml"}, requires_ukify=True, ), ImageConfig( "trident-container-verity-testimage", config="trident-verity-testimage", - config_file="base/baseimg-container.yaml", + output_and_config={OutputFormat.COSI: "base/baseimg-container.yaml"}, requires_trident=False, ), ImageConfig( "trident-container-usrverity-testimage", config="trident-verity-testimage", - config_file="usr/container.yaml", + output_and_config={OutputFormat.COSI: "usr/container.yaml"}, requires_ukify=True, requires_trident=False, ), @@ -104,46 +102,54 @@ ImageConfig( "azurelinux-direct-streaming-testimage-amd64", config="azurelinux-direct-streaming-testimage", - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, ), ImageConfig( "azurelinux-direct-streaming-testimage-arm64", config="azurelinux-direct-streaming-testimage", - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, base_image=BaseImage.CORE_ARM64, architecture=SystemArchitecture.ARM64, ), # AZL installer ImageConfig( "azl-installer", - config_file=Path("installer-iso.yaml"), - output_format=OutputFormat.ISO, + output_and_config={OutputFormat.ISO: "installer-iso.yaml"}, requires_trident=True, extra_dependencies=[ Path("tests/images/azl-installer/iso/bin/liveinstaller"), Path("tests/images/azl-installer/iso/images/trident-testimage.cosi"), ], ), - # VM test images + # VM test images (azl3) ImageConfig( "trident-vm-grub-testimage", base_image=BaseImage.QEMU_GUEST, config="trident-vm-testimage", - config_file="base/updateimg-grub.yaml", + output_and_config={ + OutputFormat.COSI: "base/updateimg-grub.yaml", + OutputFormat.QCOW2: "base/baseimg-grub.yaml", + }, ssh_key="files/id_rsa.pub", ), ImageConfig( "trident-vm-grub-verity-testimage", base_image=BaseImage.QEMU_GUEST, config="trident-vm-testimage", - config_file="base/updateimg-grub-verity.yaml", + output_and_config={ + OutputFormat.COSI: "base/updateimg-grub-verity.yaml", + OutputFormat.QCOW2: "base/baseimg-grub-verity.yaml", + }, ssh_key="files/id_rsa.pub", ), ImageConfig( "trident-vm-root-verity-testimage", base_image=BaseImage.QEMU_GUEST, config="trident-vm-testimage", - config_file="base/baseimg-root-verity.yaml", + output_and_config={ + OutputFormat.COSI: "base/baseimg-root-verity.yaml", + OutputFormat.QCOW2: "base/baseimg-root-verity.yaml", + }, requires_ukify=True, ssh_key="files/id_rsa.pub", ), @@ -151,7 +157,10 @@ "trident-vm-usr-verity-testimage", base_image=BaseImage.QEMU_GUEST, config="trident-vm-testimage", - config_file="base/baseimg-usr-verity.yaml", + output_and_config={ + OutputFormat.COSI: "base/baseimg-usr-verity.yaml", + OutputFormat.QCOW2: "base/baseimg-usr-verity.yaml", + }, requires_ukify=True, ssh_key="files/id_rsa.pub", ), @@ -159,13 +168,20 @@ "trident-vm-grub-verity-azure-testimage", base_image=BaseImage.CORE_SELINUX, config="trident-vm-testimage", - config_file="base/updateimg-grub-verity-azure.yaml", + output_and_config={ + OutputFormat.COSI: "base/updateimg-grub-verity-azure.yaml", + OutputFormat.QCOW2: "base/baseimg-grub-verity-azure.yaml", + OutputFormat.VHD_FIXED: "base/baseimg-grub-verity-azure.yaml", + }, ), ImageConfig( "trident-vm-grub-testimage-arm64", base_image=BaseImage.CORE_ARM64, config="trident-vm-testimage", - config_file="base/updateimg-grub.yaml", + output_and_config={ + OutputFormat.COSI: "base/updateimg-grub.yaml", + OutputFormat.QCOW2: "base/baseimg-grub.yaml", + }, ssh_key="files/id_rsa.pub", architecture=SystemArchitecture.ARM64, ), @@ -173,21 +189,36 @@ "trident-vm-grub-verity-testimage-arm64", base_image=BaseImage.CORE_ARM64, config="trident-vm-testimage", - config_file="base/updateimg-grub-verity.yaml", + output_and_config={ + OutputFormat.COSI: "base/updateimg-grub-verity.yaml", + OutputFormat.QCOW2: "base/baseimg-grub-verity.yaml", + }, ssh_key="files/id_rsa.pub", architecture=SystemArchitecture.ARM64, ), + # VM test images (azl4) + ImageConfig( + "trident-vm-grub-azl4-testimage", + base_image=BaseImage.AZL4_QEMU_GUEST, + config="trident-vm-testimage", + output_and_config={ + OutputFormat.COSI: "base/updateimg-grub-azl4.yaml", + OutputFormat.QCOW2: "base/baseimg-grub-azl4.yaml", + }, + ssh_key="files/id_rsa.pub", + ), + # stream-image test images ImageConfig( "ubuntu-direct-streaming-testimage-2204-amd64", base_image=BaseImage.UBUNTU_2204_AMD64, - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, image_customizer_convert=True, requires_trident=False, ), ImageConfig( "ubuntu-direct-streaming-testimage-2204-arm64", base_image=BaseImage.UBUNTU_2204_ARM64, - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, architecture=SystemArchitecture.ARM64, image_customizer_convert=True, requires_trident=False, @@ -195,14 +226,14 @@ ImageConfig( "ubuntu-direct-streaming-testimage-2404-amd64", base_image=BaseImage.UBUNTU_2404_AMD64, - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, image_customizer_convert=True, requires_trident=False, ), ImageConfig( "ubuntu-direct-streaming-testimage-2404-arm64", base_image=BaseImage.UBUNTU_2404_ARM64, - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, architecture=SystemArchitecture.ARM64, image_customizer_convert=True, requires_trident=False, @@ -210,7 +241,7 @@ ImageConfig( "gb200-direct-streaming-testimage-2404-arm64", base_image=BaseImage.GB200_2404_ARM64, - output_format=OutputFormat.BAREMETAL_IMAGE, + output_and_config={OutputFormat.BAREMETAL_IMAGE: "base/baseimg.yaml"}, architecture=SystemArchitecture.ARM64, image_customizer_convert=True, requires_trident=False, @@ -246,6 +277,29 @@ package_name="minimal_vhdx-3.0-stable", version="*", ), + # BaseImageManifest( + # image=BaseImage.AZL4_CORE, + # package_name="core_vhdx-4.0-stable", + # version="*", + # distro=Distro.AZL4, + # ), + BlobImageManifest( + # Azure Linux 4.0 base VHDX from the AZL preview gallery's + # backing storage. Pinned to a specific daily build — bump + # the version segment in path_prefix to pick up a newer one. + # + # Source gallery: + # azlpubDevGallery2mruiyvi / azure-linux-4-daily-x64 + # subscription e4ab81f8-030f-4593-a8f2-3ea2c7630a19 + # RG azl-acg-preview-publishing + # + # Storage account + container are supplied at runtime via + # --blob-storage-account / --blob-container CLI flags or + # the BLOB_STORAGE_ACCOUNT / BLOB_CONTAINER env vars. + image=BaseImage.AZL4_QEMU_GUEST, + path_prefix="staging/azure-linux-4-daily-x64/4.0.2026051502", + file_suffix=".vhdfixed", + ), ], ) diff --git a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml new file mode 100644 index 0000000000..44370eb396 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml @@ -0,0 +1,175 @@ +# Base image config for trident-vm-grub-azl4-testimage. +# +# This builds the BOOTABLE base qcow2 that storm-trident rollback tests +# start the VM from. After this qcow2 boots, trident is installed and ready +# to drive A/B updates to the .cosi produced by updateimg-grub-azl4.yaml. +# +# Layout mirrors AZL3's baseimg-grub.yaml (A/B partitions) but uses +# AZL4-specific package names (dnf5, grub2-efi-x64, shim, etc.) matching +# the updateimg-grub-azl4.yaml flavor. + +storage: + disks: + - partitionTableType: gpt + maxSize: 10G + partitions: + - id: esp + type: esp + size: 32M + + - id: root-a + size: 4G + + - id: root-b + size: 4G + + - id: trident + size: 1G + + - id: srv + size: grow + + bootType: efi + + filesystems: + - deviceId: esp + type: fat32 + mountPoint: + path: /boot/efi + options: umask=0077 + + - deviceId: root-a + type: ext4 + mountPoint: / + + - deviceId: trident + type: ext4 + mountPoint: /var/lib/trident + + - deviceId: srv + type: ext4 + mountPoint: /srv + +os: + bootloader: + resetType: hard-reset + hostname: trident-vm-testimg + + selinux: + mode: disabled + + kernelCommandLine: + # Mirrors AZL3 baseimg-grub.yaml; same console + debug settings so + # serial output works the same on both flavors. `net.ifnames=0` + # keeps interface naming as eth0/eth1/... so the + # `99-dhcp-eth0.network` systemd-networkd config matches the only + # virtio NIC the qemu test VM ships with. + extraCommandLine: + - console=tty0 + - console=tty1 + - console=ttyS0 + - net.ifnames=0 + - rd.debug + - loglevel=6 + - log_buf_len=1M + - systemd.journald.forward_to_console=1 + + packages: + install: + # AZL4 equivalents of the AZL3 set. See updateimg-grub-azl4.yaml + # for the rationale on each substitution. + - curl + - dnf5 + - efibootmgr + - grub2-efi-x64 + - grub2-efi-x64-modules + - grub2-tools + - grub2-tools-efi + - iproute + - iptables-nft + - jq + - lsof + - netplan + - netplan-default-backend-networkd + - openssh-server + - shim + - sudo + - systemd-networkd + - systemd-resolved + - vim + # AZL4 systemd (258.4) moves systemd-dissect and mount.ddi from the + # main systemd package into the systemd-container subpackage. mount.ddi + # is needed for sysext/sysconf + - systemd-sysext + - systemd-confext + - systemd-dissect + # get trident from RPM + - trident-service + + services: + enable: + - sshd + - systemd-networkd + - systemd-resolved + # netplan-main (generate/configure split) defers virtual-device + # creation to this service; Fedora ships it preset-disabled. + - netplan-configure.service + # Trident socket-activated daemon. Storm-trident drives all + # update/commit/rollback through `trident grpc-client ...` which + # talks to this socket. + - trident + - tridentd.socket + + additionalFiles: + # AZL4 lacks a /usr/bin/hostname binary; the pytest framework + # smoke-tests SSH with `hostname`, so we ship a tiny shim. + - source: files/hostname-shim.sh + destination: /usr/local/bin/hostname + permissions: "755" + - source: files/sudoers-wheel + destination: /etc/sudoers.d/wheel + - source: files/99-dhcp-eth0.network + destination: /etc/systemd/network/99-dhcp-eth0.network + - source: files/regen-sshd-keys.service + destination: /etc/systemd/system/regen-sshd-keys.service + + users: + - name: testuser + sshPublicKeyPaths: + - files/id_rsa.pub + secondaryGroups: + - wheel + +scripts: + postCustomization: + # Mirrors AZL3's baseimg-grub.yaml ordering: post-install runs + # first, then we bake the trident datastore at build time (so first + # boot is fast and storm-trident can immediately drive updates), + # then ssh + network housekeeping, then initrd rebuild + xattr + # strip last. + - path: scripts/post-install.sh + # Bake trident's hoststatus into the datastore at build time. AZL3 + # does this via update-host-status.sh; AZL4 reuses the same script. + # Requires trident's offline-init + # to tolerate the absence of /dev/sda inside MIC's chroot (the + # `disk` argument is a runtime label, not a build-time assertion); + # the fix lives in crates/trident/src/init/offline/mod.rs. + - path: scripts/update-host-status.sh + - path: scripts/enable-trident-service-azl4.sh + - path: scripts/prepare-update-config.sh + - path: scripts/ssh-move-host-keys-azl4.sh + - path: scripts/enable-regen-sshd-keys.sh + # Rebuild initramfs with --no-hostonly + extra SATA drivers so the + # qcow2 boots regardless of which bus the consumer's libvirt config + # picks (storm-trident uses bus=sata; the original boot test on + # karhu-ubuntu used bus=virtio). MUST run BEFORE strip-selinux-xattrs + # because dracut writes new files with the build-time SELinux + # context, and we want those stripped too. + - path: scripts/rebuild-initrd-azl4.sh + # Strip security.selinux xattrs from all files. See updateimg-grub- + # azl4.yaml for the parallel write-up; the same MOS-side AZL3 + # SELinux policy rejects AZL4 contexts when any future operation + # tries to preserve them. Keeping the qcow2 label-free is defensive. + # MUST run LAST so it sweeps any files produced by earlier scripts + # (initrd, etc.). + - path: scripts/strip-selinux-xattrs.sh \ No newline at end of file diff --git a/tests/images/trident-vm-testimage/base/files/hostname-shim.sh b/tests/images/trident-vm-testimage/base/files/hostname-shim.sh new file mode 100644 index 0000000000..b12b3807c9 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/files/hostname-shim.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# AZL4 doesn't ship a `hostname` binary in `coreutils` (Fedora moved it to +# its own package which AZL4 hasn't picked up yet). The pytest E2E +# framework uses `hostname` as a smoke test of the SSH session in +# tests/e2e_tests/conftest.py, so without this shim every test errors out +# at fixture setup. +# +# Tiny POSIX-only replacement that reads /etc/hostname, plus a passthrough +# for `hostname -s` and `hostname -f` for completeness. +case "$1" in + -s|--short) + cat /etc/hostname | cut -d. -f1 + ;; + -f|--fqdn|"") + cat /etc/hostname + ;; + *) + cat /etc/hostname + ;; +esac diff --git a/tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service b/tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service new file mode 100644 index 0000000000..e33478caae --- /dev/null +++ b/tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service @@ -0,0 +1,16 @@ +[Unit] +Description=Generate sshd host keys in /var/srv on first boot +ConditionPathExists=|!/var/srv/etc/ssh/ssh_host_rsa_key +ConditionPathExists=|!/var/srv/etc/ssh/ssh_host_ecdsa_key +ConditionPathExists=|!/var/srv/etc/ssh/ssh_host_ed25519_key +Before=sshd.service +After=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStartPre=/usr/bin/mkdir -p /var/srv/etc/ssh +ExecStart=/usr/bin/ssh-keygen -A -f /var/srv -q + +[Install] +WantedBy=multi-user.target diff --git a/tests/images/trident-vm-testimage/base/scripts/enable-regen-sshd-keys.sh b/tests/images/trident-vm-testimage/base/scripts/enable-regen-sshd-keys.sh new file mode 100755 index 0000000000..feb05323f3 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/enable-regen-sshd-keys.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# regen-sshd-keys is a one-shot service that generates SSH host keys in +# /var/srv on first boot. Enable it via wants symlink because the generic +# `services.enable` in MIC config is reserved for systemd unit names that +# come from packages, and our unit is delivered via additionalFiles. +set -euo pipefail +mkdir -p /etc/systemd/system/multi-user.target.wants +ln -sf /etc/systemd/system/regen-sshd-keys.service \ + /etc/systemd/system/multi-user.target.wants/regen-sshd-keys.service diff --git a/tests/images/trident-vm-testimage/base/scripts/enable-trident-service-azl4.sh b/tests/images/trident-vm-testimage/base/scripts/enable-trident-service-azl4.sh new file mode 100644 index 0000000000..37de39b178 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/enable-trident-service-azl4.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Defensive enable of trident.service and tridentd.socket. +# +# AZL3 gets these via the trident-service RPM's %systemd_post scriptlet. +# AZL4 now installs the trident-service RPM as well (the units ship with +# the RPM, not via additionalFiles), and we *should* be able to rely on the +# RPM scriptlet plus baseimg-grub-azl4.yaml's `services.enable:` stanza. +# In practice, `services.enable` did not create the +# multi-user.target.wants/trident.service symlink in MIC AZL4 builds +# (build 1120959 showed multi-user.target reached but trident.service +# never started post-reboot, leaving servicingState stuck at +# ab-update-finalized). Until we figure out why, manually link the +# units defensively. +# +# tridentd.socket gets the same treatment because (a) if services.enable +# is unreliable for one unit, it's likely unreliable for the other, and +# (b) storm-trident drives every update/commit/rollback through the +# tridentd gRPC socket — a missing /run/trident/trident.sock at boot +# would fail every subsequent storm-trident invocation in the test +# pipeline. +set -euxo pipefail + +mkdir -p /etc/systemd/system/multi-user.target.wants +mkdir -p /etc/systemd/system/sockets.target.wants +ln -sf /usr/lib/systemd/system/trident.service \ + /etc/systemd/system/multi-user.target.wants/trident.service +ln -sf /usr/lib/systemd/system/tridentd.socket \ + /etc/systemd/system/sockets.target.wants/tridentd.socket + +# Belt and braces: log the enabled state for diagnostics. systemctl is-enabled +# may fail inside MIC's chroot without a running dbus, so don't gate the +# script on it. +systemctl is-enabled trident.service 2>&1 || true +systemctl is-enabled tridentd.socket 2>&1 || true +ls -l /etc/systemd/system/multi-user.target.wants/trident.service || true +ls -l /etc/systemd/system/sockets.target.wants/tridentd.socket || true diff --git a/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh b/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh new file mode 100644 index 0000000000..b9e68b05ca --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Regenerate initrd with --no-hostonly so all storage drivers are +# included, not just the ones MIC's build environment happens to need. +# +# Why: storm-trident's rollback test (tools/storm/utils/vm/qemu/qemu.go) +# attaches the qcow2 to a virt-install VM with `bus=sata`. MIC builds +# the qcow2 in a virtio-backed environment, so dracut's default +# hostonly mode produces an initramfs with only virtio drivers. On a +# SATA-backed boot, the initramfs can't find the root partition by +# UUID and systemd hangs forever waiting for /dev/disk/by-uuid/. +# +# Rebuilding with --no-hostonly bakes in ahci, ata_piix, sata_sil, etc. +# along with virtio so the same qcow2 boots regardless of the bus type +# the consumer chooses. +# +# Runs inside the MIC chroot where /sys and /proc are bind-mounted but +# the host's SELinux is not loaded (MIC strips that), so dracut's +# cp -a doesn't hit the security.selinux setxattr issue that bites in +# AZL3 MOS during install (see strip-selinux-xattrs.sh for the parallel +# write-up). + +set -euo pipefail + +# Find the kernel version installed in this image. We require exactly +# one — `ls | head -1` would silently pick the wrong one if any future +# AZL4 variant ships multiple (kernel + kernel-hyperv, extramodules-*, +# etc.). Fail loudly rather than generate an initramfs for the wrong +# kernel: the failure mode of that misstep is "boot hangs waiting for +# /dev/disk/by-uuid/", which is the exact bug this script is +# meant to prevent. +# nullglob so an empty/missing modules dir yields a zero-length array +# (reaching the 0) arm below) instead of the literal glob pattern. +shopt -s nullglob +KVERS=( /usr/lib/modules/* ) +case ${#KVERS[@]} in + 0) + echo "ERROR: no kernel modules dir under /usr/lib/modules" >&2 + exit 1 + ;; + 1) + KVER=$(basename "${KVERS[0]}") + ;; + *) + echo "ERROR: expected exactly one kernel under /usr/lib/modules, found:" >&2 + printf ' %s\n' "${KVERS[@]}" >&2 + exit 1 + ;; +esac +echo "Regenerating initramfs for kernel $KVER with --no-hostonly" + +# `--no-hostonly` includes all storage modules; `--no-hostonly-cmdline` +# prevents dracut from baking the build-host's /proc/cmdline parameters +# into the initramfs (which would fight the qcow2's grub cmdline at +# runtime); `--reproducible` keeps the output bit-stable across builds +# so we can detect spurious regenerations. +dracut \ + --no-hostonly \ + --no-hostonly-cmdline \ + --reproducible \ + --add-drivers "ahci ata_piix sata_sil sata_nv sata_via sd_mod" \ + --force \ + --kver "$KVER" + +echo "Regenerated initramfs:" +ls -lh /boot/initramfs-* diff --git a/tests/images/trident-vm-testimage/base/scripts/ssh-move-host-keys-azl4.sh b/tests/images/trident-vm-testimage/base/scripts/ssh-move-host-keys-azl4.sh new file mode 100755 index 0000000000..ede3fdbaa2 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/ssh-move-host-keys-azl4.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# AZL4-compatible variant of ssh-move-host-keys.sh. +# +# AZL3 sshd reads the main /etc/ssh/sshd_config and we appended HostKey +# lines to it. AZL4 sshd 10.0+ supports drop-ins under /etc/ssh/sshd_config.d/ +# which is the cleaner approach. +SSH_VAR_DIR="/var/srv/etc/ssh" +mkdir -p /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/50-trident-host-keys.conf <&1 >/dev/null) || rc=$? && rc=${rc:-0} + if [ "$rc" -eq 0 ]; then + count=$((count + 1)) + elif echo "$err" | grep -qE "No such attribute|Operation not supported"; then + : # nothing to strip, expected for files without the xattr + else + fail_count=$((fail_count + 1)) + echo "setfattr failed on '$f': $err" >&2 + fi + rc=0 +done < <(find / \( -path /proc -o -path /sys -o -path /dev -o -path /run \) -prune \ + -o \( -type f -o -type d -o -type l \) -print0) + +echo "Stripped security.selinux from ${count} files/dirs" + +if [ "$fail_count" -gt 0 ]; then + echo "ERROR: setfattr failed (non-ENODATA) on ${fail_count} entries" >&2 + exit 1 +fi + +# Verify the strip actually took effect by scanning a representative set +# of paths (rootfs, /boot if present, /usr/lib/systemd, /etc). Any +# residual security.selinux means we missed something — fail loudly +# rather than warning, since the whole point of the script is to leave +# the image bare. +sentinel_dirs=( "/etc" "/usr/lib/systemd" "/usr/bin" ) +if [ -d /boot ]; then + sentinel_dirs+=( "/boot" ) +fi +for d in "${sentinel_dirs[@]}"; do + if getfattr -R -m security.selinux "$d" 2>/dev/null | grep -q security.selinux; then + echo "ERROR: security.selinux xattr still present under '$d'" >&2 + getfattr -R -m security.selinux "$d" 2>/dev/null | head -10 >&2 + exit 1 + fi +done diff --git a/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml b/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml new file mode 100644 index 0000000000..c164178d8d --- /dev/null +++ b/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml @@ -0,0 +1,120 @@ +storage: + bootType: efi + disks: + - maxSize: 5G + partitionTableType: gpt + partitions: + - id: esp + size: 16M + type: esp + - id: root + size: 4G + + filesystems: + - deviceId: esp + mountPoint: + options: umask=0077 + path: /boot/efi + type: fat32 + - deviceId: root + mountPoint: / + type: ext4 + +os: + hostname: trident-vm-testimg + packages: + install: + # AZL4 equivalents of the AZL3 set in updateimg-grub.yaml. + # Notable differences: + # - dnf5 (no `dnf` package on AZL4) + # - grub2-efi-x64 + grub2-efi-x64-modules (no `-noprefix` package) + # - iptables-nft (no plain `iptables` meta on AZL4 base) + # - shim added (AZL4 ships a real signed shim chain) + - curl + - dnf5 + - efibootmgr + - grub2-efi-x64 + - grub2-efi-x64-modules + - grub2-tools + - grub2-tools-efi + - iproute + - iptables-nft + - jq + - lsof + - netplan + - netplan-default-backend-networkd + - openssh-server + - shim + - sudo + - systemd-networkd + - systemd-resolved + - vim + # AZL4 systemd (258.4) moves systemd-dissect and mount.ddi from the + # main systemd package into the systemd-container subpackage. mount.ddi + # is needed for sysext/sysconf + - systemd-sysext + - systemd-confext + - systemd-dissect + # get trident from RPM + - trident-service + + bootloader: + resetType: hard-reset + selinux: + mode: disabled + kernelCommandLine: + extraCommandLine: + - console=tty0 + - console=ttyS0 + - net.ifnames=0 + - rd.debug + - loglevel=6 + - log_buf_len=1M + - systemd.journald.forward_to_console=1 + services: + enable: + - sshd + - systemd-networkd + - systemd-resolved + # netplan-main (generate/configure split) defers virtual-device + # creation to this service; Fedora ships it preset-disabled. + - netplan-configure.service + # Trident socket-activated daemon. Storm-trident drives all + # update/commit/rollback through `trident grpc-client ...` which + # talks to this socket. + - trident + - tridentd.socket + users: + - name: testuser + sshPublicKeyPaths: + - files/id_rsa.pub + secondaryGroups: + - wheel + additionalFiles: + # AZL4 lacks a /usr/bin/hostname binary; the pytest framework smoke- + # tests SSH with `hostname`, so we ship a tiny shim. + - source: files/hostname-shim.sh + destination: /usr/local/bin/hostname + permissions: "755" + - source: files/sudoers-wheel + destination: /etc/sudoers.d/wheel + - source: files/99-dhcp-eth0.network + destination: /etc/systemd/network/99-dhcp-eth0.network + - source: files/regen-sshd-keys.service + destination: /etc/systemd/system/regen-sshd-keys.service + +scripts: + postCustomization: + # AZL3 image scripts are largely safe on AZL4, but a couple touch + # /etc paths that read-only differently. We use AZL4-specific copies + # as we discover divergence. + - path: scripts/post-install.sh + - path: scripts/ssh-move-host-keys-azl4.sh + - path: scripts/enable-regen-sshd-keys.sh + # Strip SELinux labels from all files. See the script header for the + # full rationale, but in short: the AZL4 base VHDX bakes in AZL4 + # SELinux contexts that Trident-from-AZL3-MOS can't preserve during + # install (MOS's loaded policy rejects unknown contexts via + # setxattr). Stripping at cosi build time sidesteps the cascade. + # Must run LAST so other postCustomization scripts don't re-label. + - path: scripts/strip-selinux-xattrs.sh \ No newline at end of file