From 1796dfc8660bd16600976a30954fc234665cf59e Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Tue, 2 Jun 2026 17:40:14 -0700 Subject: [PATCH 1/5] infra: Add AZL4 builder infrastructure and image acquisition Adds AZL4 build pipeline stages with MCR-hosted MIC container, BlobImageManifest class for ACG blob source downloads, and service connection runbook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 22 ++ .gitignore | 5 +- .../stages/build_image/build-image-azl4.yml | 79 ++++++ .../build_image/build-image-template-azl4.yml | 165 +++++++++++++ tests/images/SERVICE-CONNECTION-RUNBOOK.md | 225 ++++++++++++++++++ tests/images/builder/__init__.py | 41 +++- tests/images/builder/cli.py | 22 +- tests/images/builder/download.py | 145 ++++++++++- tests/images/builder/run.py | 14 +- tests/images/testimages.py | 59 +++++ 10 files changed, 769 insertions(+), 8 deletions(-) create mode 100644 .gitattributes create mode 100644 .pipelines/templates/stages/build_image/build-image-azl4.yml create mode 100644 .pipelines/templates/stages/build_image/build-image-template-azl4.yml create mode 100644 tests/images/SERVICE-CONNECTION-RUNBOOK.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..0a680fcc46 --- /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 e7d3febb7d..a8fd852362 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 0000000000..0fae10eb29 --- /dev/null +++ b/.pipelines/templates/stages/build_image/build-image-azl4.yml @@ -0,0 +1,79 @@ +# 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 has feed-published base VHDXes +# and RPMs. + +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 0000000000..77f26a7c41 --- /dev/null +++ b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml @@ -0,0 +1,165 @@ +# 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`. None of +# that works for AZL4 today because: +# +# 1. There is no AzureLinuxArtifacts feed entry for AZL4 base VHDX. We +# download from the AZL preview gallery's backing storage account +# (azlpubdev2mruiyvi/images-dev) instead. See the BlobImageManifest +# registration in tests/images/testimages.py. +# +# 2. There is no Trident RPM for AZL4. The binary is baked in via +# additionalFiles in tests/images/trident-vm-testimage/base/updateimg-grub-azl4.yaml. +# +# TODO(azl4-merge-back): When AZL4 has feed-published base VHDXes and RPMs, +# fold this template back into build-image-template.yml by adding a +# `azureLinuxVersion: "4.0"` branch. + +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. + - bash: | + set -euxo pipefail + docker pull "mcr.microsoft.com/azurelinux/${{ 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/SERVICE-CONNECTION-RUNBOOK.md b/tests/images/SERVICE-CONNECTION-RUNBOOK.md new file mode 100644 index 0000000000..2a17d49d2d --- /dev/null +++ b/tests/images/SERVICE-CONNECTION-RUNBOOK.md @@ -0,0 +1,225 @@ +# ADO Service Connection Runbook — UAMI + Workload Identity Federation + +Step-by-step recipe for creating an ADO Azure Resource Manager service +connection authenticated by a User-Assigned Managed Identity (UAMI) via +Workload Identity Federation (WIF). This is the SFI-compliant pattern; no +secrets are stored anywhere. + +Adapted from Brian's wiki [Creating an ADO Service Connection authenticated +with UMI](https://dev.azure.com/mariner-org/mariner/_wiki/wikis/mariner.wiki/5697/Creating-an-ADO-Service-Connection-authenticated-with-UMI), +with the concrete commands and gotchas from setting up the +`trident-azl4-blob-reader` connection on 2026-05-14. + +## What you end up with + +``` +Azure UAMI ─(federated)→ ADO Service Connection ─(used by)→ Pipeline + │ + └─(role assignment)→ Target Azure resource +``` + +The pipeline uses `AzureCLI@2` referencing the SC. ADO mints an OIDC token, +exchanges it for an Azure access token via the UAMI's federated credential, +and the pipeline gets an `az login`'d session with the UAMI's RBAC. + +## Prerequisites + +- **Azure:** Contributor on the resource group where you'll create the UAMI +- **Azure:** User Access Administrator or Owner on the target resource you're + granting access to (for the role assignment) +- **ADO:** Project Administrator on the project where the service connection + will live + +## Step 1 — Create the UAMI (Azure CLI) + +```powershell +$sub = "" +$rg = "" +$loc = "" # match siblings if reusing an RG +$umi = "" # naming convention: see notes below + +az account set -s $sub + +# Pre-flight: confirm UAMI doesn't already exist +az identity show -g $rg -n $umi 2>$null +# (should return nothing) + +# Create +az identity create -g $rg -n $umi -l $loc ` + --tags purpose= owner= project= +``` + +The output contains `clientId` (use as ADO's Application ID later) and +`principalId` (use as the role-assignment assignee). + +### Naming convention notes + +Match what's already in the RG. Examples from +`maritimus-github-runner` (b3e01d89... sub): + +- `maritimus-github-runner-umi-*` for GitHub Actions identities +- `maritimus-github-storage-ado-*-umi` for ADO pipeline identities + +When in doubt, ask the RG owner before deviating. + +## Step 2 — Grant the UAMI access to the target resource + +For the trident-azl4-blob-reader UAMI, the target was the +`azlpubdev2mruiyvi` storage account (backing the AZL preview gallery), +with `Storage Blob Data Reader` (least privilege — we only need to read +base VHDXes). + +```powershell +$objId = az identity show -g $rg -n $umi --query principalId -o tsv +$scope = "/subscriptions/$sub/resourceGroups/$rg/providers///" + +az role assignment create ` + --assignee-object-id $objId ` + --assignee-principal-type ServicePrincipal ` + --role "" ` + --scope $scope + +# Verify +az role assignment list --assignee $objId --all -o table +``` + +**Always use least privilege.** Don't pick `Owner` when `Reader` will do. + +## Step 3 — Start service connection in ADO (do NOT click Verify yet) + +In ADO project → Project Settings → Service Connections → New service +connection. + +| Field | Value | +|---|---| +| Connection type | **Azure Resource Manager** | +| Identity type | **App registration or managed identity (manual)** | +| Credential | **Workload Identity Federation** | +| Scope Level | **Subscription** | +| Subscription ID | `` | +| Subscription Name | `` | +| **Application (client) ID** | the UAMI's **clientId** from step 1 | +| Tenant ID | `72f988bf-86f1-41af-91ab-2d7cd011db47` (MSIT) | +| Service connection name | `` | +| Grant access permission to all pipelines | **uncheck** (see SFI note below) | + +After filling these in but **before saving**, ADO shows you: + +- **Issuer URL** +- **Subject identifier** + +Both are needed for step 4. Keep this ADO tab open. + +### Issuer/Subject gotcha — read them off the form + +⚠️ Do NOT guess these values. They are not the same as `vstoken.dev.azure.com/...` +that older service connections may show. ADO assigns a new pair when you +create the SC, and the issuer is the Entra tenant authority URL +(`https://login.microsoftonline.com//v2.0`), not the ADO token +issuer URL. The subject is opaque (looks like +`/eid1/c/pub/t/.../sc/.../`). + +Copy the exact strings from the ADO form into the FIC. Do not transcribe; +copy-paste. + +## Step 4 — Add the federated credential to the UAMI + +```powershell +$issuer = "" +$subject = "" + +az identity federated-credential create ` + -g $rg ` + --identity-name $umi ` + --name "" ` + --issuer "$issuer" ` + --subject "$subject" ` + --audiences "api://AzureADTokenExchange" + +# Verify +az identity federated-credential list -g $rg --identity-name $umi -o table +``` + +FIC name should describe the consumer. For ADO connections we use +`ado--` (e.g. `ado-ecf-trident-azl4-blob-reader`). + +## Step 5 — Verify and save in ADO + +Wait ~30 seconds for Entra to propagate the FIC, then return to the ADO +form and click **Verify and save**. + +### Common errors + +**`AADSTS70025: client has no configured federated identity credentials`** +- The FIC hasn't been added yet. Run step 4. + +**`AADSTS700211: No matching federated identity record found for presented +assertion issuer 'https://login.microsoftonline.com//v2.0'`** +- The FIC exists but the issuer or subject doesn't match what ADO is + presenting. Re-read the ADO form carefully (do not transcribe — copy). +- A common mistake is reusing the issuer URL from an unrelated existing + service connection. Each new SC may get its own issuer string. + +**Verify succeeds but pipeline fails with `You do not have the required +permissions...`** +- The role assignment in step 2 either targeted the wrong scope, or + Azure RBAC hasn't propagated yet (wait up to 10 minutes). Re-check that + `az role assignment list --assignee --all` shows the role + on the correct scope. + +## Step 6 — SFI compliance — restrict pipeline permissions + +[SFI-ES2.4.11](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-security-configuration/azdo-config-remediation/all-pipeline-access-es-2-4-tsg) +prohibits leaving a service connection accessible to all pipelines. + +After saving: + +1. Open the new service connection in ADO +2. Click **More options (⋮) → Security** +3. Under **Pipeline permissions**, click **Restrict permission** +4. Click **+** and add each pipeline that needs the SC by ID/name. Do not + add "all pipelines." + +## When to use the manual cleanup path + +If something goes wrong mid-setup and you need to start over cleanly: + +```powershell +# Remove an FIC that pointed at the wrong issuer/subject +az identity federated-credential delete -g $rg --identity-name $umi --name "" --yes + +# Confirm no stray role assignments +az role assignment list --assignee --all -o table + +# In ADO: delete the SC via Project Settings → Service connections → ⋮ → Delete +# In Azure: only delete the UAMI itself if you're sure nothing else uses it +``` + +The UAMI does no harm by itself — it's a managed identity with role +assignments and FICs. Deleting it cascades to role assignments +automatically; FICs are removed with the parent UAMI. + +## Reference — the trident-azl4-blob-reader connection + +| Field | Value | +|---|---| +| Purpose | Read AZL4 base VHDX from the AZL preview gallery's backing storage for trident CI | +| Storage account | `azlpubdev2mruiyvi` (subscription `e4ab81f8-030f-4593-a8f2-3ea2c7630a19`, RG `azl-acg-preview-publishing`) | +| Gallery source | `azlpubDevGallery2mruiyvi / azure-linux-4-daily-x64` (same subscription/RG) | +| UAMI name | `maritimus-github-storage-ado-trident-reader-umi` | +| UAMI subscription | `b3e01d89-bd55-414f-bbb4-cdfeb2628caa` (`AzureCNMP_CNP_AzureLinux_Polar_ImageTools_Staging`) | +| UAMI resource group | `maritimus-github-runner` | +| UAMI region | `westus2` | +| UAMI clientId | `5eaafbf5-279b-4f16-b797-50bd730dcdb8` | +| UAMI principalId | `97c7c5f1-db58-4e65-8c4a-b6d614a72657` | +| Role granted | `Storage Blob Data Reader` on `azlpubdev2mruiyvi` | +| FIC name | `ado-ecf-trident-azl4-blob-reader` | +| ADO project | `mariner-org/ECF` | +| ADO SC name | `trident-azl4-blob-reader` | +| Pipelines allowed | `[GITHUB]-trident-pr-e2e`, `[GITHUB]-trident-ci`, `[GITHUB]-trident-pr-e2e-azure` | +| Created | 2026-05-14 | +| Updated | 2026-06-01 (re-scoped from `maritimusgithubstorage` to `azlpubdev2mruiyvi`) | + +When the `AzureLinuxArtifacts` ADO feed publishes AZL4 base VHDXes, +this connection can be deleted — the standard `BaseImageManifest` +download path will handle it. diff --git a/tests/images/builder/__init__.py b/tests/images/builder/__init__.py index ca82f58db9..2881fe8518 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 741f0c2396..39e8e9aadd 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,7 +184,8 @@ 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 (from the Azure DevOps feed, or from " + "Azure Storage Blob for distros without a published feed).", ) parser_download_img.set_defaults(artifacts=artifacts) parser_download_img.add_argument( @@ -191,6 +193,22 @@ def setup_parser_download_image( 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 to pull blob-sourced base images " + "from. Required when downloading an image whose manifest is a " + "BlobImageManifest. Falls back to the BLOB_STORAGE_ACCOUNT env " + "var. Not used for ADO-feed base images.", + ) + parser_download_img.add_argument( + "--blob-container", + default=os.environ.get("BLOB_CONTAINER"), + help="Azure Storage container name to pull blob-sourced base " + "images from. Required when downloading an image whose manifest " + "is a BlobImageManifest. Falls back to the BLOB_CONTAINER env " + "var. Not used for ADO-feed base images.", + ) def setup_parser_matrix( @@ -285,6 +303,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..56a1313af0 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 d465beb2f2..8c93bdcb1e 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 9ab341cba9..b4d8b5416d 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", + ), ], ) From eae6848b8d41ab8aa29951a77393b863887d46c2 Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Wed, 3 Jun 2026 18:26:53 -0700 Subject: [PATCH 2/5] fix: Tag MCR MIC container with local short name after pull testimages.py runs docker with the short tag (imagecustomizer:1.4.0-1) but docker pull uses the full MCR path. Without a local tag, docker run fails with 'pull access denied'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../stages/build_image/build-image-template-azl4.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.pipelines/templates/stages/build_image/build-image-template-azl4.yml b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml index 77f26a7c41..31e163b596 100644 --- a/.pipelines/templates/stages/build_image/build-image-template-azl4.yml +++ b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml @@ -91,10 +91,12 @@ steps: workingDirectory: ${{ parameters.tridentSourceDirectory }} # Pull the released MIC container from MCR. AZL4 support is included - # in imagecustomizer >= 1.4.0. + # 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 From 73835d5fcbf42a823a52b4655103d6875dadb99a Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Mon, 8 Jun 2026 12:47:55 -0700 Subject: [PATCH 3/5] docs: Update TODOs to not assume AzureLinuxArtifacts feed for AZL4 AZL4 base VHDXes may continue to come from blob storage rather than the ADO feed. The trident-service RPM will come from an AZL4 package repo, not ADO. Update comments to reflect this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../stages/build_image/build-image-azl4.yml | 6 ++++-- .../build_image/build-image-template-azl4.yml | 18 +++++++++--------- tests/images/SERVICE-CONNECTION-RUNBOOK.md | 6 +++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.pipelines/templates/stages/build_image/build-image-azl4.yml b/.pipelines/templates/stages/build_image/build-image-azl4.yml index 0fae10eb29..a2901cd842 100644 --- a/.pipelines/templates/stages/build_image/build-image-azl4.yml +++ b/.pipelines/templates/stages/build_image/build-image-azl4.yml @@ -5,8 +5,10 @@ # external test-images repo template. # # TODO(azl4-merge-back): Merge this back into build-image.yml with an -# `azureLinuxVersion` parameter switch once AZL4 has feed-published base VHDXes -# and RPMs. +# `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 diff --git a/.pipelines/templates/stages/build_image/build-image-template-azl4.yml b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml index 31e163b596..7b679b0848 100644 --- a/.pipelines/templates/stages/build_image/build-image-template-azl4.yml +++ b/.pipelines/templates/stages/build_image/build-image-template-azl4.yml @@ -2,20 +2,20 @@ # # 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`. None of -# that works for AZL4 today because: +# trident-binaries pipeline artifact, then runs `testimages.py build`. AZL4 +# uses different acquisition paths: # -# 1. There is no AzureLinuxArtifacts feed entry for AZL4 base VHDX. We -# download from the AZL preview gallery's backing storage account -# (azlpubdev2mruiyvi/images-dev) instead. See the BlobImageManifest +# 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. The binary is baked in via +# 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): When AZL4 has feed-published base VHDXes and RPMs, -# fold this template back into build-image-template.yml by adding a -# `azureLinuxVersion: "4.0"` branch. +# 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 diff --git a/tests/images/SERVICE-CONNECTION-RUNBOOK.md b/tests/images/SERVICE-CONNECTION-RUNBOOK.md index 2a17d49d2d..fe448ae4b2 100644 --- a/tests/images/SERVICE-CONNECTION-RUNBOOK.md +++ b/tests/images/SERVICE-CONNECTION-RUNBOOK.md @@ -220,6 +220,6 @@ automatically; FICs are removed with the parent UAMI. | Created | 2026-05-14 | | Updated | 2026-06-01 (re-scoped from `maritimusgithubstorage` to `azlpubdev2mruiyvi`) | -When the `AzureLinuxArtifacts` ADO feed publishes AZL4 base VHDXes, -this connection can be deleted — the standard `BaseImageManifest` -download path will handle it. +When AZL4 base VHDX acquisition is standardized (either via the +`AzureLinuxArtifacts` ADO feed or a permanent blob location), this +connection can be re-evaluated. From 9dabb187ef8042494e7d3b0137dd1907ae6412cb Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Mon, 8 Jun 2026 12:52:41 -0700 Subject: [PATCH 4/5] fix: Remove SERVICE-CONNECTION-RUNBOOK from public repo Contains internal infrastructure details (UAMI names, principal IDs, subscription IDs, FIC configuration) that should not be published to a public GitHub repository. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/SERVICE-CONNECTION-RUNBOOK.md | 225 --------------------- 1 file changed, 225 deletions(-) delete mode 100644 tests/images/SERVICE-CONNECTION-RUNBOOK.md diff --git a/tests/images/SERVICE-CONNECTION-RUNBOOK.md b/tests/images/SERVICE-CONNECTION-RUNBOOK.md deleted file mode 100644 index fe448ae4b2..0000000000 --- a/tests/images/SERVICE-CONNECTION-RUNBOOK.md +++ /dev/null @@ -1,225 +0,0 @@ -# ADO Service Connection Runbook — UAMI + Workload Identity Federation - -Step-by-step recipe for creating an ADO Azure Resource Manager service -connection authenticated by a User-Assigned Managed Identity (UAMI) via -Workload Identity Federation (WIF). This is the SFI-compliant pattern; no -secrets are stored anywhere. - -Adapted from Brian's wiki [Creating an ADO Service Connection authenticated -with UMI](https://dev.azure.com/mariner-org/mariner/_wiki/wikis/mariner.wiki/5697/Creating-an-ADO-Service-Connection-authenticated-with-UMI), -with the concrete commands and gotchas from setting up the -`trident-azl4-blob-reader` connection on 2026-05-14. - -## What you end up with - -``` -Azure UAMI ─(federated)→ ADO Service Connection ─(used by)→ Pipeline - │ - └─(role assignment)→ Target Azure resource -``` - -The pipeline uses `AzureCLI@2` referencing the SC. ADO mints an OIDC token, -exchanges it for an Azure access token via the UAMI's federated credential, -and the pipeline gets an `az login`'d session with the UAMI's RBAC. - -## Prerequisites - -- **Azure:** Contributor on the resource group where you'll create the UAMI -- **Azure:** User Access Administrator or Owner on the target resource you're - granting access to (for the role assignment) -- **ADO:** Project Administrator on the project where the service connection - will live - -## Step 1 — Create the UAMI (Azure CLI) - -```powershell -$sub = "" -$rg = "" -$loc = "" # match siblings if reusing an RG -$umi = "" # naming convention: see notes below - -az account set -s $sub - -# Pre-flight: confirm UAMI doesn't already exist -az identity show -g $rg -n $umi 2>$null -# (should return nothing) - -# Create -az identity create -g $rg -n $umi -l $loc ` - --tags purpose= owner= project= -``` - -The output contains `clientId` (use as ADO's Application ID later) and -`principalId` (use as the role-assignment assignee). - -### Naming convention notes - -Match what's already in the RG. Examples from -`maritimus-github-runner` (b3e01d89... sub): - -- `maritimus-github-runner-umi-*` for GitHub Actions identities -- `maritimus-github-storage-ado-*-umi` for ADO pipeline identities - -When in doubt, ask the RG owner before deviating. - -## Step 2 — Grant the UAMI access to the target resource - -For the trident-azl4-blob-reader UAMI, the target was the -`azlpubdev2mruiyvi` storage account (backing the AZL preview gallery), -with `Storage Blob Data Reader` (least privilege — we only need to read -base VHDXes). - -```powershell -$objId = az identity show -g $rg -n $umi --query principalId -o tsv -$scope = "/subscriptions/$sub/resourceGroups/$rg/providers///" - -az role assignment create ` - --assignee-object-id $objId ` - --assignee-principal-type ServicePrincipal ` - --role "" ` - --scope $scope - -# Verify -az role assignment list --assignee $objId --all -o table -``` - -**Always use least privilege.** Don't pick `Owner` when `Reader` will do. - -## Step 3 — Start service connection in ADO (do NOT click Verify yet) - -In ADO project → Project Settings → Service Connections → New service -connection. - -| Field | Value | -|---|---| -| Connection type | **Azure Resource Manager** | -| Identity type | **App registration or managed identity (manual)** | -| Credential | **Workload Identity Federation** | -| Scope Level | **Subscription** | -| Subscription ID | `` | -| Subscription Name | `` | -| **Application (client) ID** | the UAMI's **clientId** from step 1 | -| Tenant ID | `72f988bf-86f1-41af-91ab-2d7cd011db47` (MSIT) | -| Service connection name | `` | -| Grant access permission to all pipelines | **uncheck** (see SFI note below) | - -After filling these in but **before saving**, ADO shows you: - -- **Issuer URL** -- **Subject identifier** - -Both are needed for step 4. Keep this ADO tab open. - -### Issuer/Subject gotcha — read them off the form - -⚠️ Do NOT guess these values. They are not the same as `vstoken.dev.azure.com/...` -that older service connections may show. ADO assigns a new pair when you -create the SC, and the issuer is the Entra tenant authority URL -(`https://login.microsoftonline.com//v2.0`), not the ADO token -issuer URL. The subject is opaque (looks like -`/eid1/c/pub/t/.../sc/.../`). - -Copy the exact strings from the ADO form into the FIC. Do not transcribe; -copy-paste. - -## Step 4 — Add the federated credential to the UAMI - -```powershell -$issuer = "" -$subject = "" - -az identity federated-credential create ` - -g $rg ` - --identity-name $umi ` - --name "" ` - --issuer "$issuer" ` - --subject "$subject" ` - --audiences "api://AzureADTokenExchange" - -# Verify -az identity federated-credential list -g $rg --identity-name $umi -o table -``` - -FIC name should describe the consumer. For ADO connections we use -`ado--` (e.g. `ado-ecf-trident-azl4-blob-reader`). - -## Step 5 — Verify and save in ADO - -Wait ~30 seconds for Entra to propagate the FIC, then return to the ADO -form and click **Verify and save**. - -### Common errors - -**`AADSTS70025: client has no configured federated identity credentials`** -- The FIC hasn't been added yet. Run step 4. - -**`AADSTS700211: No matching federated identity record found for presented -assertion issuer 'https://login.microsoftonline.com//v2.0'`** -- The FIC exists but the issuer or subject doesn't match what ADO is - presenting. Re-read the ADO form carefully (do not transcribe — copy). -- A common mistake is reusing the issuer URL from an unrelated existing - service connection. Each new SC may get its own issuer string. - -**Verify succeeds but pipeline fails with `You do not have the required -permissions...`** -- The role assignment in step 2 either targeted the wrong scope, or - Azure RBAC hasn't propagated yet (wait up to 10 minutes). Re-check that - `az role assignment list --assignee --all` shows the role - on the correct scope. - -## Step 6 — SFI compliance — restrict pipeline permissions - -[SFI-ES2.4.11](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-security-configuration/azdo-config-remediation/all-pipeline-access-es-2-4-tsg) -prohibits leaving a service connection accessible to all pipelines. - -After saving: - -1. Open the new service connection in ADO -2. Click **More options (⋮) → Security** -3. Under **Pipeline permissions**, click **Restrict permission** -4. Click **+** and add each pipeline that needs the SC by ID/name. Do not - add "all pipelines." - -## When to use the manual cleanup path - -If something goes wrong mid-setup and you need to start over cleanly: - -```powershell -# Remove an FIC that pointed at the wrong issuer/subject -az identity federated-credential delete -g $rg --identity-name $umi --name "" --yes - -# Confirm no stray role assignments -az role assignment list --assignee --all -o table - -# In ADO: delete the SC via Project Settings → Service connections → ⋮ → Delete -# In Azure: only delete the UAMI itself if you're sure nothing else uses it -``` - -The UAMI does no harm by itself — it's a managed identity with role -assignments and FICs. Deleting it cascades to role assignments -automatically; FICs are removed with the parent UAMI. - -## Reference — the trident-azl4-blob-reader connection - -| Field | Value | -|---|---| -| Purpose | Read AZL4 base VHDX from the AZL preview gallery's backing storage for trident CI | -| Storage account | `azlpubdev2mruiyvi` (subscription `e4ab81f8-030f-4593-a8f2-3ea2c7630a19`, RG `azl-acg-preview-publishing`) | -| Gallery source | `azlpubDevGallery2mruiyvi / azure-linux-4-daily-x64` (same subscription/RG) | -| UAMI name | `maritimus-github-storage-ado-trident-reader-umi` | -| UAMI subscription | `b3e01d89-bd55-414f-bbb4-cdfeb2628caa` (`AzureCNMP_CNP_AzureLinux_Polar_ImageTools_Staging`) | -| UAMI resource group | `maritimus-github-runner` | -| UAMI region | `westus2` | -| UAMI clientId | `5eaafbf5-279b-4f16-b797-50bd730dcdb8` | -| UAMI principalId | `97c7c5f1-db58-4e65-8c4a-b6d614a72657` | -| Role granted | `Storage Blob Data Reader` on `azlpubdev2mruiyvi` | -| FIC name | `ado-ecf-trident-azl4-blob-reader` | -| ADO project | `mariner-org/ECF` | -| ADO SC name | `trident-azl4-blob-reader` | -| Pipelines allowed | `[GITHUB]-trident-pr-e2e`, `[GITHUB]-trident-ci`, `[GITHUB]-trident-pr-e2e-azure` | -| Created | 2026-05-14 | -| Updated | 2026-06-01 (re-scoped from `maritimusgithubstorage` to `azlpubdev2mruiyvi`) | - -When AZL4 base VHDX acquisition is standardized (either via the -`AzureLinuxArtifacts` ADO feed or a permanent blob location), this -connection can be re-evaluated. From f81d73e3faa1dfd996967fce67a0085a8605cfe3 Mon Sep 17 00:00:00 2001 From: Brian Telfer Date: Mon, 8 Jun 2026 12:58:40 -0700 Subject: [PATCH 5/5] docs: Trim verbose CLI help strings in testimages.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/images/builder/cli.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/images/builder/cli.py b/tests/images/builder/cli.py index 39e8e9aadd..784c4c534d 100644 --- a/tests/images/builder/cli.py +++ b/tests/images/builder/cli.py @@ -184,30 +184,25 @@ 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, or from " - "Azure Storage Blob for distros without a published 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 to pull blob-sourced base images " - "from. Required when downloading an image whose manifest is a " - "BlobImageManifest. Falls back to the BLOB_STORAGE_ACCOUNT env " - "var. Not used for ADO-feed base images.", + 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 to pull blob-sourced base " - "images from. Required when downloading an image whose manifest " - "is a BlobImageManifest. Falls back to the BLOB_CONTAINER env " - "var. Not used for ADO-feed base images.", + help="Azure Storage container name for blob-sourced images. " + "Env: BLOB_CONTAINER.", )