Introduced in v0.8.7. Preflight job added in v0.8.76. Opt-in, off by default. The job is inert unless the repository (GH) / pipeline (ADO) variable
SIDELOAD_UPDATESis one of'true','True','TRUE', or'1'.Every run starts with a
preflightjob/stage (~10s on a Microsoft-hosted Windows runner) that writes a clear panel to the run step summary explaining what is set, what is missing, and how to enable Step.6. When the gate is OFF the preflight succeeds with an enablement walkthrough; when the gate is ON but required configuration is missing it fails fast and thesideloadjob/stage is skipped. See section 9 below for the preflight behaviour matrix.
The Sideload Updates pipeline (sideload-updates.yml, logical pipeline id
sideload-updates, displayed as Step.6) pre-stages Azure Local solution-update
media onto clusters that cannot pull updates from Azure directly - dark,
air-gapped, or restricted-egress fabrics. Once the media is staged, verified, and
imported, the pipeline flips the UpdateSideloaded=True gate so the downstream
Step.7 - Apply Updates pipeline can proceed exactly as it does for
internet-connected clusters.
For the per-pipeline reference card (inputs, artefacts, RBAC, exit conditions) see appendix-pipelines.md - Step 6. For robocopy throttling guidance see sideload-robocopy.md.
The runner (GitHub Actions) or agent (Azure DevOps) must sit on the same network as the target clusters so it can:
- Robocopy the
CombinedSolutionBundle(or OEM SBE package) to each cluster's infrastructureimportSMB share. - PowerShell-remote (WinRM) into a cluster node to verify the SHA256 and run
Add-SolutionUpdate.
Microsoft-hosted / cloud-hosted runners cannot reach the on-prem fabric, so this pipeline targets:
- GitHub Actions - a self-hosted runner labelled
azlocal-sideload(runs-on: [self-hosted, azlocal-sideload]). - Azure DevOps - a self-hosted agent in a pool that satisfies the
azlocal-sideloaddemand (pool: { name: <pool>, demands: azlocal-sideload }).
Terminology: GitHub uses runners (in runner groups); Azure DevOps uses agents (in agent pools). This is never an AKS "node pool".
A solution-bundle copy can take hours over a constrained on-prem link. A pipeline run that blocked for that long would burn agent time, hit job timeouts, and lose all progress if the agent restarted. Instead, the pipeline is a re-entrant state machine:
- Each pipeline run advances every in-scope cluster by exactly one transition, then exits. No run is ever long-lived.
- The multi-hour copy itself runs in a detached Windows Scheduled Task (driven by
the bundled
Tools/Invoke-AzLocalSideloadCopyTask.ps1worker) that survives the pipeline run ending and the agent process recycling. - Progress is persisted in shared-UNC state JSON, so any runner/agent can read the state and advance/report without cross-agent remoting.
Drive the pipeline on a frequent CRON (every 30 minutes) so successive short runs walk each cluster through the states:
Planned -> Copying -> Copied -> Verified -> Imported -> SideloadFlagged
Per cluster, the current shared state determines the action taken by
Invoke-AzLocalSideloadUpdate:
| Current state | Action |
|---|---|
| (no state) + due now | Set UpdateSideloaded=False, ensure the verified bundle is in the shared cache, register + start the detached copy Scheduled Task, write Copying state. |
Copying + fresh heartbeat |
Report progress, leave the task running. |
Copying + stale heartbeat (> SIDELOAD_HEARTBEAT_STALE_MINUTES) |
Treat the host as dead and re-drive on the current (live) host, up to MaxRetries. |
Copied |
Open a WinRM session, verify the remote SHA256, run Add-SolutionUpdate + discovery, flip UpdateSideloaded=True + stamp UpdateVersionInProgress, mark Imported, remove the task. |
Failed |
Bounded retry, else surface the error as a JUnit <failure>. |
Imported |
Done - nothing to do. |
Re-running is always safe - the state machine is idempotent.
SIDELOAD_STATE_ROOT must be a UNC path that every runner/agent can read and write.
It holds three subfolders:
state\- one JSON document per cluster tracking the current transition + heartbeat.logs\- per-run copy / verify / import logs.cache\- the verified media cache (overridable viaSIDELOAD_CACHE_ROOT; defaults to<state-root>\cache).
Because a bundle is downloaded and hashed once into the shared cache and then reused
across every cluster that needs that version, only the first cluster pays the download +
hash cost. The pipeline's concurrency / queueing settings serialize overlapping runs so
they do not race on the same shared state.
Two distinct identities are used - do not conflate them:
- Pipeline identity (Azure plane) - reads the fleet via Azure Resource Graph, reads
the Key Vault secrets, and writes the
UpdateSideloaded/UpdateVersionInProgresstags. Defaults toazure/loginOIDC;enable-AzPSSession=trueis required because the Key Vault secrets are read viaGet-AzKeyVaultSecret(Az PowerShell). For on-prem runners where OIDC is not viable, switch via theSIDELOAD_KV_AUTHvariable (oidc | managedidentity | serviceprincipal). Tag writes need only Tag Contributor (they reuseSet-AzLocalClusterTagsMerge). - Cluster WinRM credential (fabric plane) - an Active Directory
[pscredential]built at run time from two Key Vault secrets named in the matching sideload auth-map row (a username secret + a password secret). This credential - not the pipeline identity - is used for the WinRM session andAdd-SolutionUpdate. The detached copy Scheduled Task runs as the runner service account, which needs UNC rights to the shared cache and the cluster import share.
Maps the numeric UpdateAuthAccountId tag (written onto clusters by Step.2) to the Key
Vault + secret names that hold the AD credential:
UpdateAuthAccountId,KeyVaultName,UsernameSecretName,PasswordSecretName
1,kv-fabric-east,sideload-user,sideload-pass
2,kv-fabric-west,sideload-user,sideload-passUpdateAuthAccountIdmust match^\d{1,3}$(numeric, 1-3 digits) and be unique (a duplicate is a hard error).- All four columns are required.
A source-controlled YAML describing the media available to the automation. Two package
classes are supported via packageType:
- Solution - a Microsoft
CombinedSolutionBundle.<build>.zipdownloadable from a directdownloadUri(published in the Microsoft Learn "import and discover updates offline" table).sha256is required so the download / pre-staged copy can be verified.Update-AzLocalSideloadCatalogcan auto-populate these rows by parsing the Learn table. - SBE - an OEM Solution Builder Extension package that Microsoft does not host.
The operator stages the OEM files manually and records a
sourceFolder(local or UNC path).downloadUriis not applicable;sha256is optional (verified only when supplied). These rows are added manually.
schemaVersion: 1
packages:
- version: '12.2605.1003.210'
packageType: Solution
buildNumber: '12.2605.1003.210'
osBuild: '26100.4061'
downloadUri: 'https://.../CombinedSolutionBundle.12.2605.1003.210.zip'
sha256: 'ABCD...' # required for Solution; ^[0-9A-Fa-f]{64}$
availabilityDate: '2026-05-13'
localPath: ''
- version: 'DellSBE-4.1.2412.1'
packageType: SBE
sourceFolder: '\\fileserver\sbe\Dell\4.1.2412.1'
sha256: '' # optional for SBE
availabilityDate: '2026-05-20'
notes: 'Dell OEM SBE package, staged manually'| Variable | Default | Purpose |
|---|---|---|
SIDELOAD_UPDATES |
(unset) | Master gate. The job is skipped unless this is one of 'true', 'True', 'TRUE', or '1'. Any other value (including blanks, 'false', 'yes') keeps the pipeline inert. |
SIDELOAD_STATE_ROOT |
(none) | Shared UNC root holding state\, logs\, cache\. Required when enabled. Validated by the preflight (section 9). |
SIDELOAD_CACHE_ROOT |
<state-root>\cache |
Shared verified media cache. |
SIDELOAD_AUTH_MAP_PATH |
./config/sideload-auth-map.csv |
Auth-map CSV (see 4.1). Copy-AzLocalPipelineExample drops a header-only starter here (same config/ folder on GitHub and Azure DevOps). |
SIDELOAD_CATALOG_PATH |
./config/sideload-catalog.yml |
Catalog YAML (see 5). Copy-AzLocalPipelineExample drops an empty skeleton starter here. |
SIDELOAD_LEAD_DAYS |
7 |
Days before a cluster's next apply window that media should be sideloaded. |
SIDELOAD_ROBOCOPY_SWITCHES |
/R:5 /W:30 |
Extra robocopy switches for the detached worker (see sideload-robocopy.md). |
SIDELOAD_HEARTBEAT_STALE_MINUTES |
60 |
Minutes after which a Copying heartbeat is considered stale and re-driven. |
SIDELOAD_REMOTING_FQDN_SUFFIX |
(empty) | Global FQDN suffix appended to a cluster name to form the WinRM host when the auth-map row does not override it. |
SIDELOAD_KV_AUTH |
oidc |
Key Vault auth mode for the on-prem runner. |
APPLY_UPDATES_SCHEDULE_PATH |
./config/apply-updates-schedule.yml |
Ring-aware apply-updates schedule; the planner reads it to find each cluster's next apply window. |
| Cmdlet | Role |
|---|---|
Resolve-AzLocalSideloadPlan |
Read-only planner. Reads the fleet (ARG), apply schedule, auth-map, and catalog; emits one plan row per UpdateAuthAccountId-tagged cluster (plus error rows for misconfigurations) and marks which clusters are due within LeadDays. Reuses the same "next update" selection as the apply path. |
Invoke-AzLocalSideloadUpdate |
The re-entrant state machine (section 2). SupportsShouldProcess - -WhatIf previews transitions with no staging / task / tag changes. |
Export-AzLocalSideloadStatusReport / Add-AzLocalSideloadStepSummary |
JUnit XML + Markdown step-summary emitters. |
Update-AzLocalSideloadCatalog |
Auto-populates Solution rows by parsing the Microsoft Learn offline-updates table. SBE rows are added manually. |
Reset-AzLocalSideloadedTag |
Operator escape hatch to clear a stuck UpdateSideloaded tag. |
- Stand up the runner/agent on the cluster fabric network and label it
azlocal-sideload(GH) / give the pool theazlocal-sideloaddemand (ADO). - Create the shared UNC root (
SIDELOAD_STATE_ROOT) readable + writable by the runner service account, and grant that account rights to each cluster's import share. - Populate Key Vault with the per-fabric AD username/password secrets and author
sideload-auth-map.csv. - Author
sideload-catalog.yml- runUpdate-AzLocalSideloadCatalogto fill the Microsoft Solution rows, then add any OEM SBE rows manually. - Tag the fleet (Step.1 / Step.2): set
UpdateRing,UpdateStartWindow, andUpdateAuthAccountIdon each sideloaded cluster. - Set the repository variables (section 6), starting with
SIDELOAD_UPDATES=true. - Dry run: trigger the pipeline manually with
dry_run=trueand review the planned transitions + thesideload-statusartefacts. - Enable the CRON: uncomment the bundled
*/30 * * * *schedule inside theBEGIN/END-AZLOCAL-CUSTOMIZE:schedule-triggersblock (preserved acrossUpdate-AzLocalPipelineExampleupgrades). The Step.3 schedule-coverage audit can recommend a lead-time-aware cron (apply window minusSIDELOAD_LEAD_DAYS). - The state machine advances each cluster to
Imported/SideloadFlagged; the downstream Step.7 - Apply Updates wave then applies the staged update during the cluster'sUpdateStartWindow.
Step.6 is opt-in and off by default and requires an on-prem self-hosted runner /
agent that most repos and projects do not have. Before v0.8.76 a triggered run with
no setup completed simply showed Status: Skipped (no logs, no annotation), which
gave operators no actionable feedback. v0.8.76 prepends a preflight job
(GitHub Actions) / Preflight stage (Azure DevOps) that always runs on
windows-latest (Microsoft-hosted, no Azure/Key Vault access, ~10s).
SIDELOAD_UPDATES |
SIDELOAD_STATE_ROOT |
Self-hosted runner (GH only) | Preflight outcome | sideload job |
|---|---|---|---|---|
unset / 'false' / other |
n/a | n/a | Succeeds with enablement walkthrough in step summary + ::notice annotation |
Skipped by its own if: / condition: |
'true' / 'True' / 'TRUE' / '1' |
unset | n/a | Fails (exit 1) with "missing variable" panel + ::error |
Skipped (needs: / dependsOn: unmet) |
| accepted | set | no online azlocal-sideload runner AND runners API returned a list |
Fails (exit 1) with "no online runner" panel + ::error |
Skipped |
| accepted | set | runners API returned 403/404 (most repos) | Succeeds with warning ("could not enumerate runners - verify manually") | Runs |
| accepted | set | online azlocal-sideload runner found |
Succeeds with "Preflight passed" panel | Runs |
The preflight does NOT touch the cluster fabric, Key Vault, or any Azure resource.
It only reads workflow / pipeline variables, optionally queries the GH runners API,
and writes markdown to the step summary. There is no domain-membership or
VLAN-reachability requirement, so windows-latest is the correct minimal-cost host.
The actual sideload job still must run on the self-hosted runner with the
azlocal-sideload label (GH) or pool capability (ADO), because the on-prem copy +
WinRM steps cannot work from a Microsoft-hosted runner (no line-of-sight to the
fabric VLAN, no AD trust).
The preflight tries to call GET /repos/{owner}/{repo}/actions/runners with
GITHUB_TOKEN and the workflow's actions: read permission. GitHub's repo
runners API actually requires repo-admin privilege, which GITHUB_TOKEN does
not hold by default - so for most callers the API will return 403 and the
preflight degrades to a warning ("verify manually under Settings -> Actions ->
Runners"). When the API does return a list (e.g. a fine-grained PAT or a GitHub
App is wired into the workflow with administration: read) the preflight reports
the exact set of matching online runners.
The ADO preflight stage validates the gate + SIDELOAD_STATE_ROOT only. The
Agent Pools (read) scope required to call the ADO agent enumeration API is
not normally granted to the pipeline identity, so the preflight relies on the
operator having verified the azlocal-sideload capability is present in their
self-hosted pool (Project Settings -> Agent pools -> <pool> -> Capabilities).
If no matching agent is online the Sideload stage will sit Queued until
manually cancelled.