diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..0a680fcc4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +* text=auto eol=lf + +# Anything that gets executed inside an image must keep LF endings; CRLF +# on shebang lines breaks the interpreter lookup with `bad interpreter: +# /bin/bash^M`. +*.sh text eol=lf +*.py text eol=lf +*.service text eol=lf +*.network text eol=lf +*.yaml text eol=lf +*.yml text eol=lf + +# Binary artifacts — never normalize. +*.vhdx binary +*.cosi binary +*.qcow2 binary +*.iso binary +*.raw binary +*.png binary +*.jpg binary +*.zst binary +*.patch text eol=lf diff --git a/.gitignore b/.gitignore index e7d3febb7..a8fd85236 100644 --- a/.gitignore +++ b/.gitignore @@ -366,4 +366,7 @@ vendor/ # Virtdeploy files /tools/vm-netlaunch.yaml -/tools/virt-deploy-metadata.json \ No newline at end of file +/tools/virt-deploy-metadata.json +# AZL4 trident binary baked into test image (built locally) +tests/images/trident-vm-testimage/base/trident-bin/ +tests/images/trident-vm-testimage/base/osmodifier-bin/ diff --git a/.pipelines/templates/stages/build_image/build-image-azl4.yml b/.pipelines/templates/stages/build_image/build-image-azl4.yml new file mode 100644 index 000000000..a2901cd84 --- /dev/null +++ b/.pipelines/templates/stages/build_image/build-image-azl4.yml @@ -0,0 +1,81 @@ +# AZL4 variant of build-image.yml. +# +# Forked from build-image.yml on 2026-05-13. Calls build-image-template-azl4.yml +# (which uses MCR MIC container + blob-sourced base VHDX) instead of the +# external test-images repo template. +# +# TODO(azl4-merge-back): Merge this back into build-image.yml with an +# `azureLinuxVersion` parameter switch once AZL4 base VHDX acquisition +# and trident-service RPM packaging are resolved. The base VHDX may +# continue to come from blob storage (not the AzureLinuxArtifacts ADO +# feed); the RPM will come from an AZL4 package repo, not ADO. + +parameters: + - name: imageName + type: string + + - name: clones + displayName: "Number of clones to generate" + type: number + default: 2 + + - name: dependsOnTrident + type: boolean + default: true + + - name: dependsOnStage + type: string + default: "" + +stages: + - stage: TridentTestImg_${{ replace(parameters.imageName, '-', '_') }} + displayName: Build ${{ parameters.imageName }} + ${{ if parameters.dependsOnTrident }}: + dependsOn: + # AZL4 doesn't have RPM publication so we depend on the + # trident-binaries artifact (which the GetTridentBinaries stage + # produces and copies to artifacts/binaries/trident). + - GetTridentBinaries_rpms_amd64 + # PrepareSSHKeys produces the shared 'ssh-keys' artifact. + # build-image-template-azl4.yml stages it into the testimage + # tree so qcow2 + cosi builds share the same SSH keypair, + # which lets storm-trident SSH into both A/B sides after + # update. + - PrepareSSHKeys + - ${{ if ne(parameters.dependsOnStage, '') }}: + - ${{ parameters.dependsOnStage }} + ${{ elseif ne(parameters.dependsOnStage, '') }}: + dependsOn: + - PrepareSSHKeys + - ${{ parameters.dependsOnStage }} + + jobs: + - job: BuildTridentTestImgAzl4 + displayName: Build (AZL4 MIC) + # Pinned MIC container build adds ~5 min cold-cache. Bump the timeout + # accordingly. TODO(azl4-release): lower back to 20 min once we use a + # released MIC container. + timeoutInMinutes: 30 + pool: + type: linux + + variables: + ob_outputDirectory: /tmp/output + ob_artifactBaseName: ${{ parameters.imageName }} + + steps: + - template: ../common_tasks/checkout_trident.yml + + - task: DownloadPipelineArtifact@2 + inputs: + buildType: current + artifactName: trident-binaries + targetPath: "$(Build.ArtifactStagingDirectory)/trident-binaries" + displayName: Download Trident binaries + condition: eq('${{ parameters.dependsOnTrident }}', true) + + - template: build-image-template-azl4.yml + parameters: + tridentSourceDirectory: $(TRIDENT_SOURCE_DIR) + imageName: ${{ parameters.imageName }} + clones: ${{ parameters.clones }} diff --git a/.pipelines/templates/stages/build_image/build-image-template-azl4.yml b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml new file mode 100644 index 000000000..7b679b084 --- /dev/null +++ b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml @@ -0,0 +1,167 @@ +# AZL4 variant of build-image-template.yml. +# +# Forked from build-image-template.yml on 2026-05-13. The AZL3 path pulls the +# base VHDX from the AzureLinuxArtifacts ADO feed and the Trident RPM from the +# trident-binaries pipeline artifact, then runs `testimages.py build`. AZL4 +# uses different acquisition paths: +# +# 1. Base VHDX comes from the AZL preview gallery's backing storage +# (azlpubdev2mruiyvi/images-dev). See the BlobImageManifest +# registration in tests/images/testimages.py. +# +# 2. There is no Trident RPM for AZL4 yet. The binary is baked in via +# additionalFiles in tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml. +# +# TODO(azl4-merge-back): Fold this template back into build-image-template.yml +# once the AZL4 base VHDX and trident-service RPM acquisition paths are +# standardized. The base VHDX may stay as a blob download; the RPM will +# come from an AZL4 package repo. + +parameters: + - name: tridentSourceDirectory + type: string + + - name: imageName + type: string + + - name: clones + type: number + default: 1 + displayName: Number of clones to create + + # The AZL4 base VHDX is sourced from the Azure Linux preview gallery's + # backing storage account. The pipeline service connection at + # $(BLOB_SERVICE_CONNECTION) must have `Storage Blob Data Reader` on + # this account. See tests/images/SERVICE-CONNECTION-RUNBOOK.md. + - name: blobStorageAccount + type: string + default: "azlpubdev2mruiyvi" + + - name: blobContainer + type: string + default: "images-dev" + + - name: blobSubscription + type: string + # Subscription where the storage account lives. The SC's default + # subscription may differ — we explicitly set context before download. + default: "e4ab81f8-030f-4593-a8f2-3ea2c7630a19" + + - name: blobServiceConnection + type: string + # NB: this must be a service connection that exists in the ADO project. + # Trident infra needs to create it manually (Karhu can't); see the PR-5 + # follow-up validation report for the runbook. + default: "trident-azl4-blob-reader" + + - name: micContainerTag + type: string + default: "imagecustomizer:1.4.0-1" + +steps: + - template: ../common_tasks/avoid-pypi-usage.yml + + - template: common/sfi-enforce-isolation-with-etc-hosts.yaml@platform-pipelines + + # Stage the Trident binary that gets baked into the COSI via additionalFiles. + # The trident-binaries artifact comes from the same upstream Trident build + # stage the AZL3 path uses; we just copy the binary rather than installing + # an RPM. + # + # TODO(azl4-rpm): replace this binary copy with an RPM install once the + # trident-service RPM is packaged for AZL4 (same TODO as in + # tests/images/testimages.py registration). + - bash: | + set -euxo pipefail + TRIDENT_BIN_SRC="$(Build.ArtifactStagingDirectory)/trident-binaries" + TRIDENT_BIN_DEST="${{ parameters.tridentSourceDirectory }}/tests/images/trident-vm-testimage/base/trident-bin" + + if [ ! -f "$TRIDENT_BIN_SRC/trident" ]; then + echo "trident binary not found at $TRIDENT_BIN_SRC/trident" + echo "Available artifacts:" + find "$TRIDENT_BIN_SRC" -type f 2>/dev/null | head -20 || true + exit 1 + fi + + mkdir -p "$TRIDENT_BIN_DEST" + cp "$TRIDENT_BIN_SRC/trident" "$TRIDENT_BIN_DEST/trident" + chmod +x "$TRIDENT_BIN_DEST/trident" + file "$TRIDENT_BIN_DEST/trident" + displayName: "Stage Trident binary into testimage tree" + workingDirectory: ${{ parameters.tridentSourceDirectory }} + + # Pull the released MIC container from MCR. AZL4 support is included + # in imagecustomizer >= 1.4.0. Tag it locally so testimages.py can + # reference it by short name. + - bash: | + set -euxo pipefail + docker pull "mcr.microsoft.com/azurelinux/${{ parameters.micContainerTag }}" + docker tag "mcr.microsoft.com/azurelinux/${{ parameters.micContainerTag }}" "${{ parameters.micContainerTag }}" + displayName: "Pull MIC container from MCR" + + # Stage the pipeline-wide SSH key into the testimage tree before + # MIC runs. testimages.py's generate_ssh_keys() generates a new + # keypair UNLESS files/id_rsa.pub already exists at the source path + # — in which case it reuses it. By dropping the shared key from the + # PrepareSSHKeys artifact here, both the qcow2 base build and the + # COSI build end up with the same key baked into testuser's + # authorized_keys, so storm-trident's A/B update test can SSH into + # both A-side and B-side after the update reboot. + # + # The matching private key lives at ssh-keys/id_rsa from the + # PrepareSSHKeys stage. storm-trident's rollback stage picks it up + # the same way for AZL3 builds. + - task: DownloadPipelineArtifact@2 + displayName: "Download shared SSH keys" + inputs: + buildType: current + artifactName: "ssh-keys" + targetPath: "$(Build.ArtifactStagingDirectory)/ssh-keys" + + - bash: | + set -euxo pipefail + SSH_PUB_SRC="$(Build.ArtifactStagingDirectory)/ssh-keys/id_rsa.pub" + SSH_PUB_DEST="${{ parameters.tridentSourceDirectory }}/tests/images/trident-vm-testimage/base/files/id_rsa.pub" + if [ ! -f "$SSH_PUB_SRC" ]; then + echo "shared SSH public key not found at $SSH_PUB_SRC" + find "$(Build.ArtifactStagingDirectory)/ssh-keys" -type f + exit 1 + fi + cp "$SSH_PUB_SRC" "$SSH_PUB_DEST" + echo "Staged shared SSH public key:" + cat "$SSH_PUB_DEST" + displayName: "Stage shared SSH key into testimage tree" + workingDirectory: ${{ parameters.tridentSourceDirectory }} + + # Download the AZL4 base VHDX from the preview gallery's backing storage. + # Authenticates via the federated identity attached to the service + # connection — no storage keys handled here. + # + # The SC's default subscription (Polar_ImageTools_Staging) differs from + # the storage account's subscription (ControlTower_Test). We must switch + # context so `az storage blob list` resolves the account correctly. + - task: AzureCLI@2 + displayName: "Download AZL4 base VHDX from blob" + inputs: + azureSubscription: ${{ parameters.blobServiceConnection }} + scriptType: bash + scriptLocation: inlineScript + workingDirectory: ${{ parameters.tridentSourceDirectory }} + inlineScript: | + set -euxo pipefail + az account set --subscription "${{ parameters.blobSubscription }}" + python3 ./tests/images/testimages.py download-image azl4_qemu_guest \ + --blob-storage-account "${{ parameters.blobStorageAccount }}" \ + --blob-container "${{ parameters.blobContainer }}" + ls -la artifacts/azl4_qemu_guest.vhdx + + - bash: | + set -euxo pipefail + python3 ./tests/images/testimages.py build \ + "${{ parameters.imageName }}" \ + --container "${{ parameters.micContainerTag }}" \ + --output-dir "$(ob_outputDirectory)" \ + --no-download \ + --clones ${{ parameters.clones }} + displayName: "Build ${{ parameters.imageName }}" + workingDirectory: ${{ parameters.tridentSourceDirectory }} diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index ca82f58db..2881fe851 100644 --- a/tests/images/builder/__init__.py +++ b/tests/images/builder/__init__.py @@ -3,7 +3,7 @@ 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 @dataclass @@ -16,6 +16,9 @@ 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") + ) CORE_ARM64 = BaseImageData("core_arm64", Path("artifacts/core_arm64.vhdx")) MINIMAL = BaseImageData("minimal", Path("artifacts/minimal.vhdx")) MINIMAL_AARCH64 = BaseImageData( @@ -60,6 +63,34 @@ class BaseImageManifest: 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" @@ -249,7 +280,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 +297,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/cli.py b/tests/images/builder/cli.py index 741f0c239..784c4c534 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 @@ -183,14 +184,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( @@ -285,6 +298,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 6f9db4c9f..56a1313af 100644 --- a/tests/images/builder/download.py +++ b/tests/images/builder/download.py @@ -1,9 +1,15 @@ +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 + +log = logging.getLogger(__name__) def download_base_image(image: BaseImageManifest) -> None: @@ -39,3 +45,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 d465beb2f..8c93bdcb1 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 @@ -148,6 +148,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 +157,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 9ab341cba..b4d8b5416 100755 --- a/tests/images/testimages.py +++ b/tests/images/testimages.py @@ -7,6 +7,7 @@ ArtifactManifest, BaseImage, BaseImageManifest, + BlobImageManifest, ImageConfig, OutputFormat, SystemArchitecture, @@ -132,6 +133,47 @@ config_file="base/updateimg-grub.yaml", ssh_key="files/id_rsa.pub", ), + ImageConfig( + # AZL4 (Fedora-derived) variant of trident-vm-grub-testimage. + # The base VHDX is pulled from Azure Storage (see + # BlobImageManifest below) since there is no AzureLinuxArtifacts + # ADO feed entry for AZL4 yet. The Trident binary is baked in + # via additionalFiles because the trident-service RPM is not + # yet packaged for AZL4. + "trident-vm-grub-testimage-azl4", + base_image=BaseImage.AZL4_QEMU_GUEST, + config="trident-vm-testimage", + config_file="base/updateimg-grub-azl4.yaml", + ssh_key="files/id_rsa.pub", + # No trident-service RPM for AZL4 yet — the binary is delivered + # via additionalFiles. extra_dependencies enforces both binaries + # are in place before the image is built (osmodifier is delivered + # the same way until an AZL4 RPM exists; see + # tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml + # for the additionalFiles entries that consume both paths). + requires_trident=False, + extra_dependencies=[ + Path("tests/images/trident-vm-testimage/base/trident-bin/trident"), + Path("tests/images/trident-vm-testimage/base/osmodifier-bin/osmodifier"), + ], + ), + ImageConfig( + # AZL4 BASE qcow2: a bootable disk with the AZL4 OS plus trident + # installed, so storm-trident rollback testing can boot a VM and + # immediately drive A/B updates targeting the .cosi above. + # Mirrors AZL3's `make artifacts/trident-vm-grub-testimage.qcow2` + # path. See baseimg-grub-azl4.yaml for the layout / package set. + "trident-vm-grub-testimage-azl4-base", + base_image=BaseImage.AZL4_QEMU_GUEST, + config="trident-vm-testimage", + config_file="base/baseimg-grub-azl4.yaml", + output_format=OutputFormat.QCOW2, + ssh_key="files/id_rsa.pub", + requires_trident=False, + extra_dependencies=[ + Path("tests/images/trident-vm-testimage/base/trident-bin/trident"), + ], + ), ImageConfig( "trident-vm-grub-verity-testimage", base_image=BaseImage.QEMU_GUEST, @@ -246,6 +288,23 @@ package_name="minimal_vhdx-3.0-stable", version="*", ), + 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", + ), ], )