From 490d136bb782dfa73efeb747e8b7e017b4799de4 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Wed, 24 Jun 2026 22:09:13 +0000 Subject: [PATCH 01/14] azl4: build target and test image definitions Add the Makefile output targets and the azl4 grub test image definitions (base/update) plus the supporting first-boot scripts and units used to build Azure Linux 4 test images: initrd rebuild, SSH host-key move/regeneration, hostname shim, SELinux xattr stripping, and the testimages.py / builder plumbing to produce them. Stacked on the azl4 runtime PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../trident-testimg-template.yml | 15 ++ Makefile | 175 +----------------- tests/images/builder/__init__.py | 110 +++++++++-- tests/images/builder/builder.py | 6 +- tests/images/builder/cli.py | 27 ++- tests/images/builder/download.py | 154 ++++++++++++++- tests/images/builder/run.py | 33 +++- tests/images/testimages.py | 115 +++++++++--- .../base/baseimg-grub-azl4.yaml | 175 ++++++++++++++++++ .../base/files/hostname-shim.sh | 20 ++ .../base/files/regen-sshd-keys.service | 14 ++ .../base/scripts/enable-regen-sshd-keys.sh | 7 + .../scripts/enable-trident-service-azl4.sh | 35 ++++ .../base/scripts/rebuild-initrd-azl4.sh | 62 +++++++ .../base/scripts/ssh-move-host-keys-azl4.sh | 13 ++ .../base/scripts/strip-selinux-xattrs.sh | 85 +++++++++ .../base/updateimg-grub-azl4.yaml | 117 ++++++++++++ 17 files changed, 936 insertions(+), 227 deletions(-) create mode 100644 tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml create mode 100644 tests/images/trident-vm-testimage/base/files/hostname-shim.sh create mode 100644 tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service create mode 100755 tests/images/trident-vm-testimage/base/scripts/enable-regen-sshd-keys.sh create mode 100644 tests/images/trident-vm-testimage/base/scripts/enable-trident-service-azl4.sh create mode 100644 tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh create mode 100755 tests/images/trident-vm-testimage/base/scripts/ssh-move-host-keys-azl4.sh create mode 100644 tests/images/trident-vm-testimage/base/scripts/strip-selinux-xattrs.sh create mode 100644 tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml 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/__init__.py b/tests/images/builder/__init__.py index ca82f58db9..450b619320 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -3,38 +3,52 @@ 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 + + +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") + ) + # 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.OTHER ) UBUNTU_2204_ARM64 = BaseImageData( - "ubuntu_2204_arm64", Path("artifacts/ubuntu_2204_arm64.vhdx") + "ubuntu_2204_arm64", Path("artifacts/ubuntu_2204_arm64.vhdx"), 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.OTHER ) UBUNTU_2404_ARM64 = BaseImageData( - "ubuntu_2404_arm64", Path("artifacts/ubuntu_2404_arm64.vhdx") + "ubuntu_2404_arm64", Path("artifacts/ubuntu_2404_arm64.vhdx"), 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.OTHER ) @property @@ -45,6 +59,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 +74,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" @@ -124,7 +173,9 @@ class ImageConfig: requires_dhcp: bool = False # Desired output format for this image - output_format: OutputFormat = OutputFormat.COSI + output_and_config: dict[OutputFormat, str] = field( + default_factory=lambda: {OutputFormat.COSI: "base/baseimg.yaml"} + ) # Extra dependencies for this image extra_dependencies: List[Path] = field(default_factory=list) @@ -142,6 +193,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.""" @@ -157,8 +211,10 @@ def __post_init__(self): 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) + for fmt in self.output_and_config: + config_file = self.output_and_config[fmt] + if isinstance(config_file, str): + self.output_and_config[fmt] = Path(config_file) # Automatically set the architecture to arm64 if the base image is ARM64 if self.base_image == BaseImage.CORE_ARM64: @@ -183,8 +239,22 @@ 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: + return self.runtime_output_format + return next(iter(self.output_and_config)) + + def config_path(self) -> str: + output_type = self.output_format().ext() + for fmt in self.output_and_config: + if fmt.ext() == output_type: + return self.output_and_config[fmt] + raise RuntimeError( + f"Error loading image config for output format '{output_type}': '{self.output_and_config}'" + ) + 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 +277,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 +293,16 @@ 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) + 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 +329,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 +346,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..08cb20e112 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..26be435f7c 100644 --- a/tests/images/builder/download.py +++ b/tests/images/builder/download.py @@ -1,19 +1,32 @@ +import json +import logging +import os +import re from pathlib import Path import shutil import subprocess import tempfile -from builder import BaseImageManifest +from builder import BaseImageManifest, BlobImageManifest, Distro + +log = logging.getLogger(__name__) def download_base_image(image: BaseImageManifest) -> None: + if image.distro not in (Distro.AZL3, Distro.AZL4): + raise ValueError(f"Unsupported distro {image.distro} for base image download") """Download the base image from MCR.""" 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 +52,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: str, + container: 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..c70b2eb578 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: @@ -82,6 +86,7 @@ def build( artifacts: ArtifactManifest, configs: List[ImageConfig], name: str, + output_type: Optional[str], container_name: str, output_dir: Path, clones: int, @@ -93,6 +98,16 @@ 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.runtime_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..68cde11a35 100755 --- a/tests/images/testimages.py +++ b/tests/images/testimages.py @@ -7,6 +7,8 @@ ArtifactManifest, BaseImage, BaseImageManifest, + BlobImageManifest, + Distro, ImageConfig, OutputFormat, SystemArchitecture, @@ -28,45 +30,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 +79,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 +103,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 +158,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 +169,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: "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 +190,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 +227,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 +242,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 +278,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..9cc734d64a --- /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. +# +# TODO(azl4-rpm): Drop the trident additionalFiles entries +# once an AZL4 trident-service RPM is published. Until then we bake +# the binary inline. + +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 + # Trident socket-activated daemon. Storm-trident drives all + # update/commit/rollback through `trident grpc-client ...` which + # talks to this socket. + - 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..0fe938ddc3 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service @@ -0,0 +1,14 @@ +[Unit] +Description=Generate sshd host keys in /var/srv on first boot +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..bdf901cd2e --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/enable-regen-sshd-keys.sh @@ -0,0 +1,7 @@ +#!/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. +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..29889ea587 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/enable-trident-service-azl4.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Defensive enable of trident.service and tridentd.socket. +# +# AZL3 gets these via the trident-service RPM's %systemd_post scriptlet. +# AZL4 doesn't have that RPM yet, so we ship the units via additionalFiles +# and *should* be able to rely on 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..b07b3a8c0a --- /dev/null +++ b/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh @@ -0,0 +1,62 @@ +#!/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. +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..1d404a37a4 --- /dev/null +++ b/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml @@ -0,0 +1,117 @@ +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 + # 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 From 9f4e3a3c948dce487540c520e8b68aac309c5226 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Fri, 26 Jun 2026 21:30:59 +0000 Subject: [PATCH 02/14] images: enable netplan-configure.service and tridentd in grub-azl4 test images The netplan-main generate/configure split defers virtual-device creation to netplan-configure.service (Fedora ships it preset-disabled), and the grub-azl4 base image needs the socket-activated tridentd unit enabled. Enable both in the grub-azl4 base and update test images. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml | 4 ++++ .../images/trident-vm-testimage/base/updateimg-grub-azl4.yaml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml index 9cc734d64a..7b10133234 100644 --- a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml +++ b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml @@ -115,9 +115,13 @@ os: - 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. + - tridentd - tridentd.socket additionalFiles: diff --git a/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml b/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml index 1d404a37a4..c164178d8d 100644 --- a/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml +++ b/tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml @@ -76,6 +76,9 @@ os: - 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. From 175cdc7165d621b7aa572e5ddcb9bab37833741f Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Fri, 26 Jun 2026 21:56:47 +0000 Subject: [PATCH 03/14] images: address PR review feedback on builder and testimage scripts - builder/__init__.py: pass Distro.OTHER via distro= keyword so it maps to the distro field instead of mcr_name for Ubuntu/GB200 entries - builder/download.py: move docstring to first statement so it is a real docstring rather than a no-op expression - rebuild-initrd-azl4.sh: enable nullglob so an empty modules dir yields an empty array and the 0-kernels error arm is reachable - enable-regen-sshd-keys.sh: add set -euo pipefail and mkdir -p the wants directory so enabling fails loudly and works on minimal images - regen-sshd-keys.service: condition on all host key types via OR-negated ConditionPathExists so partial key state still triggers regeneration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 18 +++++++++++++----- tests/images/builder/download.py | 2 +- .../base/files/regen-sshd-keys.service | 4 +++- .../base/scripts/enable-regen-sshd-keys.sh | 2 ++ .../base/scripts/rebuild-initrd-azl4.sh | 3 +++ 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index 450b619320..4d5f0bd969 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -36,19 +36,27 @@ class BaseImage(Enum): "minimal_aarch64", Path("artifacts/minimal_aarch64.vhdx") ) UBUNTU_2204_AMD64 = BaseImageData( - "ubuntu_2204_amd64", Path("artifacts/ubuntu_2204_amd64.vhdx"), Distro.OTHER + "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"), Distro.OTHER + "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"), Distro.OTHER + "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"), Distro.OTHER + "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"), Distro.OTHER + "gb200_2404_arm64", Path("artifacts/gb200_2404_arm64.vhdx"), distro=Distro.OTHER ) @property diff --git a/tests/images/builder/download.py b/tests/images/builder/download.py index 26be435f7c..77e71f2c29 100644 --- a/tests/images/builder/download.py +++ b/tests/images/builder/download.py @@ -13,9 +13,9 @@ 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") - """Download the base image from MCR.""" with tempfile.TemporaryDirectory() as tempdir: url = ( f"mcr.microsoft.com/azurelinux-beta/base/{image.image.mcr_name}:4.0" 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 index 0fe938ddc3..e33478caae 100644 --- a/tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service +++ b/tests/images/trident-vm-testimage/base/files/regen-sshd-keys.service @@ -1,6 +1,8 @@ [Unit] Description=Generate sshd host keys in /var/srv on first boot -ConditionPathExists=!/var/srv/etc/ssh/ssh_host_ed25519_key +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 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 index bdf901cd2e..feb05323f3 100755 --- 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 @@ -3,5 +3,7 @@ # /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/rebuild-initrd-azl4.sh b/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh index b07b3a8c0a..b9e68b05ca 100644 --- a/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh +++ b/tests/images/trident-vm-testimage/base/scripts/rebuild-initrd-azl4.sh @@ -28,6 +28,9 @@ set -euo pipefail # 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) From 9e50bd53adeabadb2fb5ca853cb8e66cfe1f9959 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Fri, 26 Jun 2026 22:16:14 +0000 Subject: [PATCH 04/14] make: note COSI vs baremetal-image ambiguity on .cosi targets The pattern rule passes the file extension as --output-type, so a .cosi target always resolves to OutputFormat.COSI even for configs that intend OutputFormat.BAREMETAL_IMAGE (both use extension cosi). Echo a note on .cosi builds pointing users who want a baremetal-image COSI to invoke testimages.py directly with --output-type baremetal-image. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index d45e32f5b7..8cd0eaa26c 100644 --- a/Makefile +++ b/Makefile @@ -950,6 +950,7 @@ validate-pipeline-website-artifact: artifacts/%.cosi artifacts/%.iso artifacts/%.vhdx artifacts/%.vhd artifacts/%.qcow2: $$(shell ./tests/images/testimages.py dependencies $$*) @echo "Building '$*' [$@] from $<" @echo "Extension is: $(subst .,,$(suffix $@))" + @test "$(subst .,,$(suffix $@))" != "cosi" || echo "NOTE: customizing a COSI image (OutputFormat.COSI). If you intend a baremetal-image COSI (OutputFormat.BAREMETAL_IMAGE), invoke ./tests/images/testimages.py build $* --output-type baremetal-image directly." @echo "Prerequisites:" @echo "$^" | tr ' ' '\n' | sed 's/^/ /' @echo "Building image..." From 8559e3db3df97d9a70b71dd6b03c794b6042ccdb Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Fri, 26 Jun 2026 22:44:54 +0000 Subject: [PATCH 05/14] images: remove unused config_file field; improve show-image docs The config_file field on ImageConfig is no longer read; full_yaml_path() derives the YAML from config_path()/output_and_config. Left in place it made show-image config-file return a stale default, so remove it. - show_image: render dict fields one entry per line so show-image output-and-config prints a readable outputtype: yaml mapping instead of a raw dict repr. - README: document show-image base-image with the download step (it identifies which base image to fetch, as the build pipeline does) and show-image output-and-config with the config-edit step (it lists the YAML used for each output type). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/README.md | 14 ++++++++++---- tests/images/builder/__init__.py | 11 ++++------- tests/images/builder/run.py | 2 ++ 3 files changed, 16 insertions(+), 11 deletions(-) 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 4d5f0bd969..16df62a4e5 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -168,9 +168,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 @@ -218,11 +215,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 + # Normalize output_and_config values to Path objects for fmt in self.output_and_config: - config_file = self.output_and_config[fmt] - if isinstance(config_file, str): - self.output_and_config[fmt] = Path(config_file) + 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: diff --git a/tests/images/builder/run.py b/tests/images/builder/run.py index c70b2eb578..2e39a1230d 100644 --- a/tests/images/builder/run.py +++ b/tests/images/builder/run.py @@ -70,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: From 2aa98c76695257939ef26bf569f1d5612daf0881 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Fri, 26 Jun 2026 23:03:02 +0000 Subject: [PATCH 06/14] trident, not tridentd --- tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml index 7b10133234..ac3f312eff 100644 --- a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml +++ b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml @@ -121,7 +121,7 @@ os: # Trident socket-activated daemon. Storm-trident drives all # update/commit/rollback through `trident grpc-client ...` which # talks to this socket. - - tridentd + - trident - tridentd.socket additionalFiles: From 5c889fffda451e4ae593f0d92fb385a46cd77130 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 00:01:11 +0000 Subject: [PATCH 07/14] outout_and_config is ultimate truth of output type --- Makefile | 1 - tests/images/builder/__init__.py | 16 +++++++++++++++- tests/images/testimages.py | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 8cd0eaa26c..d45e32f5b7 100644 --- a/Makefile +++ b/Makefile @@ -950,7 +950,6 @@ validate-pipeline-website-artifact: artifacts/%.cosi artifacts/%.iso artifacts/%.vhdx artifacts/%.vhd artifacts/%.qcow2: $$(shell ./tests/images/testimages.py dependencies $$*) @echo "Building '$*' [$@] from $<" @echo "Extension is: $(subst .,,$(suffix $@))" - @test "$(subst .,,$(suffix $@))" != "cosi" || echo "NOTE: customizing a COSI image (OutputFormat.COSI). If you intend a baremetal-image COSI (OutputFormat.BAREMETAL_IMAGE), invoke ./tests/images/testimages.py build $* --output-type baremetal-image directly." @echo "Prerequisites:" @echo "$^" | tr ' ' '\n' | sed 's/^/ /' @echo "Building image..." diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index 16df62a4e5..1d31c8a269 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -1,3 +1,4 @@ +import logging import yaml from dataclasses import dataclass, field, fields @@ -5,6 +6,9 @@ from pathlib import Path from typing import List, Optional, Union +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + class Distro(Enum): AZL3 = "azl3" @@ -246,7 +250,9 @@ def base_dir(self) -> Path: def output_format(self) -> OutputFormat: if self.runtime_output_format is not None: - return self.runtime_output_format + for fmt in self.output_and_config: + if fmt.ext() == self.runtime_output_format.ext(): + return fmt return next(iter(self.output_and_config)) def config_path(self) -> str: @@ -302,6 +308,14 @@ 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) + if output_type == OutputFormat.COSI.ext(): + log.warning( + f"Output type 'cosi' was specified, if 'baremetal-cosi' was intended, use that as output type." + ) + if output_type == OutputFormat.VHD.ext(): + log.warning( + f"Output type 'vhd' was specified, if 'vhd-fixed' was intended, use that as output type." + ) except ValueError as e: valid_formats = ", ".join([fmt.value for fmt in OutputFormat]) raise ValueError( diff --git a/tests/images/testimages.py b/tests/images/testimages.py index 68cde11a35..4e5b2df335 100755 --- a/tests/images/testimages.py +++ b/tests/images/testimages.py @@ -172,7 +172,7 @@ output_and_config={ OutputFormat.COSI: "base/updateimg-grub-verity-azure.yaml", OutputFormat.QCOW2: "base/baseimg-grub-verity-azure.yaml", - OutputFormat.VHD: "base/baseimg-grub-verity-azure.yaml", + OutputFormat.VHD_FIXED: "base/baseimg-grub-verity-azure.yaml", }, ), ImageConfig( From 1c1818b47b24a7c7a0ded96f8f4262c2f34f4b1d Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 00:06:18 +0000 Subject: [PATCH 08/14] images: fix type hints and stale RPM comments - Annotate output_and_config values and config_path() return as Path to match __post_init__ normalization. - Drop stale baseimg-grub-azl4.yaml TODO about trident additionalFiles / inline binary; the config installs the trident-service RPM. - Update enable-trident-service-azl4.sh header: AZL4 now installs the trident-service RPM (units ship with the RPM, not additionalFiles). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 4 ++-- .../trident-vm-testimage/base/baseimg-grub-azl4.yaml | 4 ---- .../base/scripts/enable-trident-service-azl4.sh | 7 ++++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index 1d31c8a269..73dc06374a 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -182,7 +182,7 @@ class ImageConfig: requires_dhcp: bool = False # Desired output format for this image - output_and_config: dict[OutputFormat, str] = field( + output_and_config: dict[OutputFormat, Path] = field( default_factory=lambda: {OutputFormat.COSI: "base/baseimg.yaml"} ) @@ -255,7 +255,7 @@ def output_format(self) -> OutputFormat: return fmt return next(iter(self.output_and_config)) - def config_path(self) -> str: + def config_path(self) -> Path: output_type = self.output_format().ext() for fmt in self.output_and_config: if fmt.ext() == output_type: diff --git a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml index ac3f312eff..44370eb396 100644 --- a/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml +++ b/tests/images/trident-vm-testimage/base/baseimg-grub-azl4.yaml @@ -7,10 +7,6 @@ # 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. -# -# TODO(azl4-rpm): Drop the trident additionalFiles entries -# once an AZL4 trident-service RPM is published. Until then we bake -# the binary inline. storage: disks: 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 index 29889ea587..37de39b178 100644 --- 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 @@ -2,9 +2,10 @@ # Defensive enable of trident.service and tridentd.socket. # # AZL3 gets these via the trident-service RPM's %systemd_post scriptlet. -# AZL4 doesn't have that RPM yet, so we ship the units via additionalFiles -# and *should* be able to rely on baseimg-grub-azl4.yaml's `services.enable:` -# stanza. In practice, `services.enable` did not create the +# 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 From adcce34d55e4fda62cde823e6706028c49691c2b Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 00:18:18 +0000 Subject: [PATCH 09/14] images: address review feedback on output-type logging - Drop module-level logging.basicConfig from the builder library module; cli.py already configures root logging. - Fix set_output_type warning to point at the valid 'baremetal-image' output type (was 'baremetal-cosi', which is not an OutputFormat). - Log the resolved output_format() instead of the raw runtime value so vhd->vhd-fixed / cosi->baremetal-image resolution is visible. - Set distro=Distro.AZL4 on BaseImage.AZL4_QEMU_GUEST. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 9 +++++---- tests/images/builder/run.py | 4 +--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index 73dc06374a..1d275020e0 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import List, Optional, Union -logging.basicConfig(level=logging.INFO) log = logging.getLogger(__name__) @@ -29,7 +28,9 @@ class BaseImage(Enum): 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") + "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 @@ -310,11 +311,11 @@ def set_output_type(self, output_type: str) -> None: self.runtime_output_format = OutputFormat(output_type) if output_type == OutputFormat.COSI.ext(): log.warning( - f"Output type 'cosi' was specified, if 'baremetal-cosi' was intended, use that as output type." + "Output type 'cosi' was specified; if 'baremetal-image' was intended, use that as the output type." ) if output_type == OutputFormat.VHD.ext(): log.warning( - f"Output type 'vhd' was specified, if 'vhd-fixed' was intended, use that as output type." + "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]) diff --git a/tests/images/builder/run.py b/tests/images/builder/run.py index 2e39a1230d..cc4b9af789 100644 --- a/tests/images/builder/run.py +++ b/tests/images/builder/run.py @@ -102,9 +102,7 @@ def build( if output_type is not None: image.set_output_type(output_type) - log.info( - f"Building image with output type '{image.runtime_output_format.ic_name()}'" - ) + 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()}'" From dd39bf9cea998840e903161918f95ee323ecf65b Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 00:32:57 +0000 Subject: [PATCH 10/14] images: fail fast on unsupported --output-type output_format() previously fell back to the first declared output format when --output-type matched no declared format, so make artifacts/. could complete without producing the requested artifact. It now raises a ValueError listing the supported output extensions for the image. Also simplify config_path() to index output_and_config by the resolved output_format() key, since that key is guaranteed present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index 1d275020e0..0daebd5cd4 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -254,16 +254,18 @@ def output_format(self) -> OutputFormat: 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_type = self.output_format().ext() - for fmt in self.output_and_config: - if fmt.ext() == output_type: - return self.output_and_config[fmt] - raise RuntimeError( - f"Error loading image config for output format '{output_type}': '{self.output_and_config}'" - ) + # 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_path() From b1fc26839d756d5c90d721c0141a4dc4d278e07b Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 00:43:01 +0000 Subject: [PATCH 11/14] images: tidy type annotations and unused import - output_and_config default_factory now returns a Path value to match the dict[OutputFormat, Path] annotation. - Remove unused Distro import from testimages.py (only referenced in commented-out code). - Annotate download_blob_image storage_account/container as Optional[str] to match the None defaults passed from the run layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 2 +- tests/images/builder/download.py | 5 +++-- tests/images/testimages.py | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index 0daebd5cd4..fdcb7d0b80 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -184,7 +184,7 @@ class ImageConfig: # Desired output format for this image output_and_config: dict[OutputFormat, Path] = field( - default_factory=lambda: {OutputFormat.COSI: "base/baseimg.yaml"} + default_factory=lambda: {OutputFormat.COSI: Path("base/baseimg.yaml")} ) # Extra dependencies for this image diff --git a/tests/images/builder/download.py b/tests/images/builder/download.py index 77e71f2c29..d825be8927 100644 --- a/tests/images/builder/download.py +++ b/tests/images/builder/download.py @@ -6,6 +6,7 @@ import shutil import subprocess import tempfile +from typing import Optional from builder import BaseImageManifest, BlobImageManifest, Distro @@ -69,8 +70,8 @@ def download_base_image(image: BaseImageManifest) -> None: def download_blob_image( image: BlobImageManifest, - storage_account: str, - container: str, + storage_account: Optional[str], + container: Optional[str], ) -> None: """Download a base image from Azure Storage Blob. diff --git a/tests/images/testimages.py b/tests/images/testimages.py index 4e5b2df335..9a0683f76f 100755 --- a/tests/images/testimages.py +++ b/tests/images/testimages.py @@ -8,7 +8,6 @@ BaseImage, BaseImageManifest, BlobImageManifest, - Distro, ImageConfig, OutputFormat, SystemArchitecture, From 02fb36b72d15d65db25364de11f069dfabcba9b4 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 00:49:16 +0000 Subject: [PATCH 12/14] images: capitalize --output-type help text sentence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/images/builder/cli.py b/tests/images/builder/cli.py index 08cb20e112..af30758ec8 100644 --- a/tests/images/builder/cli.py +++ b/tests/images/builder/cli.py @@ -121,7 +121,7 @@ def setup_parser_build( "--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", + 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", From 5d01a5f982d2adac3c186ead42bb574ef32cef73 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 01:06:25 +0000 Subject: [PATCH 13/14] images: exact-match baremetal-image/vhd-fixed before ext fallback output_format() resolved a requested --output-type purely by file extension. Because ext() collapses baremetal-image->cosi and vhd-fixed->vhd, an explicit request for baremetal-image or vhd-fixed could silently downgrade to a same-extension format (COSI/VHD), and two declared formats sharing an extension could not be selected reliably. Now, when the requested format is baremetal-image or vhd-fixed and that exact format is declared in output_and_config, return it directly; otherwise fall back to extension-based matching to preserve the Makefile's extension-driven selection (--output-type cosi / vhd). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index fdcb7d0b80..f7399d517e 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -251,6 +251,20 @@ def base_dir(self) -> Path: 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 From 18c4449aa65bff17b5b62f50b39709c023d7f902 Mon Sep 17 00:00:00 2001 From: Brian Fjeldstad Date: Sat, 27 Jun 2026 01:14:50 +0000 Subject: [PATCH 14/14] images: only warn about ambiguous output type when alternative declared set_output_type() warned on every --output-type cosi/vhd, even when the image config does not declare the same-extension alternative (BAREMETAL_IMAGE/VHD_FIXED). Gate each warning on the alternative being present in output_and_config so the hint only appears when the ambiguity is real. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index f7399d517e..e269a7199e 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -325,11 +325,19 @@ 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) - if output_type == OutputFormat.COSI.ext(): + # 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(): + 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." )