From 77f8ae179c593d1a1ee6e945e77524a81481ed37 Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 15:02:45 -0700 Subject: [PATCH 1/7] CI: Add Partner Center publishing tasks Publishes Azure Marketplace image versions through the Microsoft Graph Product Ingestion API, replacing the deprecated Cloud Partner Portal API. ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/common-scripts/partner-center.sh | 255 ++++++++++++++++++ ci/tasks/partner-center-go-live/run | 96 +++++++ ci/tasks/partner-center-go-live/task.yml | 15 ++ ci/tasks/partner-center-submit-preview/run | 172 ++++++++++++ .../partner-center-submit-preview/task.yml | 20 ++ ci/tasks/partner-center-wait-live/run | 100 +++++++ ci/tasks/partner-center-wait-live/task.yml | 19 ++ ci/tasks/partner-center-wait-preview/run | 100 +++++++ ci/tasks/partner-center-wait-preview/task.yml | 21 ++ 9 files changed, 798 insertions(+) create mode 100644 ci/common-scripts/partner-center.sh create mode 100755 ci/tasks/partner-center-go-live/run create mode 100644 ci/tasks/partner-center-go-live/task.yml create mode 100755 ci/tasks/partner-center-submit-preview/run create mode 100644 ci/tasks/partner-center-submit-preview/task.yml create mode 100755 ci/tasks/partner-center-wait-live/run create mode 100644 ci/tasks/partner-center-wait-live/task.yml create mode 100755 ci/tasks/partner-center-wait-preview/run create mode 100644 ci/tasks/partner-center-wait-preview/task.yml diff --git a/ci/common-scripts/partner-center.sh b/ci/common-scripts/partner-center.sh new file mode 100644 index 000000000..c54c38291 --- /dev/null +++ b/ci/common-scripts/partner-center.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Shared helpers for the Microsoft Partner Center Product Ingestion API (Graph). +# Requires PARTNER_PORTAL_TENANT_ID, PARTNER_PORTAL_CLIENT_ID and PARTNER_PORTAL_CLIENT_SECRET. +# API bodies are returned raw (configure must resend existing SAS URIs unchanged); anything +# printed to stderr goes through pc_redact_sas, and callers must redact before saving. +# Call pc_token once up front: functions used in $(...) cannot cache a token for the caller. + +PC_API="https://graph.microsoft.com/rp/product-ingestion" +PC_TOKEN="" +PC_TOKEN_FETCHED_AT=0 +PC_POLL_FIRST_FAILURE="" +# Seconds before a retried request (token, GET) gives up, so a hung connection is retried. +PC_CURL_MAX_TIME=120 + +# Replaces SAS signatures with sig=REDACTED, and URL-encoded ones (sig%3D, up to the next +# %26) with sig%3DREDACTED (stdin -> stdout). +function pc_redact_sas() { + sed -E -e "s/sig=[^&\"\\\\' <]*/sig=REDACTED/g" \ + -e "s/sig(%3[dD])([^&%\"\\\\' <]|%[^2&\"\\\\' <]|%2[^6&\"\\\\' <])*/sig\\1REDACTED/g" +} + +# True for HTTP codes worth retrying: network error (000), 429 and 5xx. +function _pc_retryable() { + [[ "${1}" == 000 || "${1}" == 429 || "${1}" == 5* ]] +} + +# Fetches a client-credentials token into PC_TOKEN. The secret goes to curl on stdin and +# the token is never written to disk. +function pc_token() { + local attempt response code rc + for attempt in 1 2 3 4; do + if response=$(printf '%s' "${PARTNER_PORTAL_CLIENT_SECRET}" | curl -sS --max-time "${PC_CURL_MAX_TIME}" -w '\n%{http_code}' \ + --data-urlencode "client_id=${PARTNER_PORTAL_CLIENT_ID}" \ + --data-urlencode "client_secret@-" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "scope=https://graph.microsoft.com/.default" \ + "https://login.microsoftonline.com/${PARTNER_PORTAL_TENANT_ID}/oauth2/v2.0/token"); then + rc=0 + else + rc=$? + fi + code=${response##*$'\n'} + response=${response%"${code}"} + if [[ ${rc} -eq 0 && "${code}" == 2* ]]; then + if ! PC_TOKEN=$(jq -er .access_token <<<"${response}"); then + echo "ERROR: token response has no access_token" >&2 + return 1 + fi + PC_TOKEN_FETCHED_AT=$(date +%s) + return 0 + fi + echo "ERROR: token request failed (curl exit ${rc}, HTTP ${code}): $(pc_redact_sas <<<"${response}")" >&2 + if [[ ${rc} -eq 0 ]] && ! _pc_retryable "${code}"; then + return 1 + fi + [[ ${attempt} -eq 4 ]] || sleep 30 + done + return 1 +} + +# Fetches a token if there is none or it is older than 50 minutes (tokens last 60). +function _pc_ensure_token() { + if [[ -z "${PC_TOKEN}" || $(( $(date +%s) - PC_TOKEN_FETCHED_AT )) -ge 3000 ]]; then + pc_token + fi +} + +# pc_get : GET against the API; prints the raw body. Retries network errors, +# 429 and 5xx three times, 30s apart; any other non-2xx fails immediately. +function pc_get() { + local path="${1}" version="${2}" url attempt body code rc + url="${PC_API}/${path}" + if [[ "${url}" == *\?* ]]; then url+="&\$version=${version}"; else url+="?\$version=${version}"; fi + for attempt in 1 2 3 4; do + _pc_ensure_token || return 1 + body=$(mktemp "${TMPDIR:-/tmp}/pc-body.XXXXXX") + if code=$(curl -sS --max-time "${PC_CURL_MAX_TIME}" -o "${body}" -w '%{http_code}' \ + -H @<(printf 'Authorization: Bearer %s\n' "${PC_TOKEN}") \ + "${url}"); then + rc=0 + else + rc=$? + fi + if [[ ${rc} -eq 0 && "${code}" == 2* ]]; then + cat "${body}" + rm -f "${body}" + return 0 + fi + echo "ERROR: GET ${path} failed (curl exit ${rc}, HTTP ${code}): $(pc_redact_sas < "${body}")" >&2 + rm -f "${body}" + if [[ ${rc} -eq 0 ]] && ! _pc_retryable "${code}"; then + return 1 + fi + [[ ${attempt} -eq 4 ]] || sleep 30 + done + return 1 +} + +# pc_configure : POSTs a configure request (never retried) and prints its jobId. +# Returns 1 on a definite rejection (4xx) and 2 when the request may have been applied +# (network error, 5xx, or a 2xx without a jobId), so the caller can re-read the submissions +# before failing. +function pc_configure() { + local payload="${1}" body code rc + _pc_ensure_token || return 1 + body=$(mktemp "${TMPDIR:-/tmp}/pc-body.XXXXXX") + # No --max-time: a timeout after Microsoft accepted the POST would leave the outcome unknown. + if code=$(printf '%s' "${payload}" | curl -sS -o "${body}" -w '%{http_code}' -X POST \ + -H @<(printf 'Authorization: Bearer %s\n' "${PC_TOKEN}") \ + -H "Content-Type: application/json" \ + --data-binary @- \ + "${PC_API}/configure?\$version=2022-03-01-preview2"); then + rc=0 + else + rc=$? + fi + if [[ ${rc} -eq 0 && "${code}" == 2* ]]; then + if ! jq -er .jobId "${body}"; then + echo "ERROR: configure response has no jobId: $(pc_redact_sas < "${body}")" >&2 + rm -f "${body}" + return 2 + fi + rm -f "${body}" + return 0 + fi + echo "ERROR: POST configure failed (curl exit ${rc}, HTTP ${code}): $(pc_redact_sas < "${body}")" >&2 + rm -f "${body}" + if [[ ${rc} -ne 0 || "${code}" == 5* ]]; then + return 2 + fi + return 1 +} + +# pc_wait_job []: polls the configure job every 60s for up to 60 minutes until it +# completes; fails with its redacted errors unless jobResult is succeeded. While the job is still +# running, (a function name) is called after each poll, in this shell; if it returns 0, +# the wait ends with success without waiting for the job. +function pc_wait_job() { + local job_id="${1}" done_fn="${2:-}" deadline status job_status job_result + deadline=$(( $(date +%s) + 3600 )) + while true; do + # Refresh here, in this shell: a refresh inside $(pc_get ...) would not outlive the subshell. + _pc_ensure_token || return 1 + status=$(pc_get "configure/${job_id}/status" 2022-03-01-preview2) || return 1 + job_status=$(jq -r '.jobStatus // empty' <<<"${status}") + if [[ "${job_status}" == completed ]]; then + job_result=$(jq -r '.jobResult // empty' <<<"${status}") + if [[ "${job_result}" == succeeded ]]; then + echo "Configure job ${job_id} succeeded" + return 0 + fi + echo "ERROR: configure job ${job_id} finished with jobResult '${job_result}':" >&2 + jq -r '(.errors // [])[] | " \(.code // "-"): \(.message // "-") (resource \(.resourceId // "-"))"' \ + <<<"${status}" | pc_redact_sas >&2 + return 1 + fi + if [[ -n "${done_fn}" ]] && "${done_fn}"; then + return 0 + fi + if [[ $(date +%s) -ge ${deadline} ]]; then + echo "ERROR: configure job ${job_id} timed out after 60 minutes (jobStatus '${job_status}')" >&2 + return 1 + fi + echo "Configure job ${job_id} is ${job_status:-unknown}; checking again in 60s" + sleep 60 + done +} + +# pc_product_id : prints the bare product GUID for an offer's externalID. +function pc_product_id() { + local offer="${1}" product id + product=$(pc_get "product?externalID=${offer}" 2022-03-01-preview3) || return 1 + if ! id=$(jq -er '(.value // [])[0].id // empty' <<<"${product}"); then + echo "ERROR: offer '${offer}' not found (or not visible to this Entra app)" >&2 + return 1 + fi + echo "${id#product/}" +} + +# pc_plan_tech_config [draft|preview|live]: prints (raw, compact) the +# virtual-machine-plan-technical-configuration of the plan whose externalId is . +function pc_plan_tech_config() { + local product="${1#product/}" sku="${2}" target="${3:-draft}" path tree + path="resource-tree/product/${product}" + [[ "${target}" == draft ]] || path+="?targetType=${target}" + tree=$(pc_get "${path}" 2022-03-01-preview5) || return 1 + if ! jq -ce --arg sku "${sku}" ' + (.resources // []) as $r + | [$r[] | select((."$schema" // "") | test("/schema/plan/")) | select(.identity.externalId == $sku) | .id] as $plans + | if ($plans | length) != 1 then error("expected exactly one plan with externalId \($sku), found \($plans | length)") else $plans[0] end + | . as $plan + | [$r[] | select((."$schema" // "") | test("/schema/virtual-machine-plan-technical-configuration/")) | select(.plan == $plan)] + | if length != 1 then error("expected exactly one technical configuration for plan \($plan) (SKU \($sku)), found \(length)") else .[0] end + | if ([.skus[]?.skuId] | index($sku)) == null then error("technical configuration for plan \($plan) does not list SKU \($sku)") else . end + ' <<<"${tree}"; then + echo "ERROR: could not resolve SKU '${sku}' to a technical configuration in the ${target} tree" >&2 + return 1 + fi +} + +# pc_tech_config_blob_state : prints "same" when the +# technical configuration holds with an image matching , "different" when it +# holds with other images only, and "absent" otherwise. Blob match rule: strip the +# query string, drop the scheme, lowercase the host, compare the path as is. +function pc_tech_config_blob_state() { + jq -r --arg v "${2}" --arg b "${3}" ' + def norm: sub("\\?.*$"; "") | capture("^[A-Za-z][A-Za-z0-9+.-]*://(?[^/]+)(?/.*)?$") + | "\(.host | ascii_downcase)\(.path // "")"; + ([$b | norm] | first) as $want + | [(.vmImageVersions // [])[] | select(.versionNumber == $v)] as $found + | if ($found | length) == 0 then "absent" + elif [$found[] | (.vmImages // [])[] | .source.osDisk.uri? // empty | select(norm == $want)] | length > 0 then "same" + else "different" end' <<<"${1}" +} + +# pc_release_submission : prints +# {"newest": , "isRelease": true|false}, where isRelease +# means the tree for that submission's state holds with a matching blob. +function pc_release_submission() { + local product="${1#product/}" sku="${2}" version="${3}" blob="${4}" submissions newest tree tech_config is_release=false + submissions=$(pc_get "submission/${product}" 2022-03-01-preview2) || return 1 + newest=$(jq -c '[(.value // [])[] | select((.target.targetType? // "draft") != "draft")] | max_by(.created)' <<<"${submissions}") + tree=$(jq -r ' + if . == null or .result == "failed" then "" + elif .target.targetType == "preview" then (if .status == "completed" and .result == "succeeded" then "preview" else "draft" end) + elif .target.targetType == "live" then (if .status == "completed" and .result == "succeeded" then "live" else "preview" end) + else "" end' <<<"${newest}") + if [[ -n "${tree}" ]]; then + tech_config=$(pc_plan_tech_config "${product}" "${sku}" "${tree}") || return 1 + [[ $(pc_tech_config_blob_state "${tech_config}" "${version}" "${blob}") != same ]] || is_release=true + fi + jq -cn --argjson newest "${newest}" --argjson isRelease "${is_release}" '{newest: $newest, isRelease: $isRelease}' +} + +# Multi-day waits: call after a poll (including its token request) failed after retries. +# Fails once the current run of failures has lasted 60 minutes; otherwise waits 10 minutes +# before the caller's next poll. On failing, prints the caller's PC_POLL_GIVE_UP_HINT, if set, +# as the next step. +function pc_poll_failed() { + local now + now=$(date +%s) + PC_POLL_FIRST_FAILURE=${PC_POLL_FIRST_FAILURE:-${now}} + if [[ $(( now - PC_POLL_FIRST_FAILURE )) -ge 3600 ]]; then + echo "ERROR: polls have been failing for $(( (now - PC_POLL_FIRST_FAILURE) / 60 )) minutes; giving up" >&2 + [[ -z "${PC_POLL_GIVE_UP_HINT:-}" ]] || echo "${PC_POLL_GIVE_UP_HINT}" >&2 + return 1 + fi + echo "Poll failed; retrying in 10 minutes" + sleep 600 +} + +# Multi-day waits: call after a successful poll to clear the failure run. +function pc_poll_succeeded() { + PC_POLL_FIRST_FAILURE="" +} diff --git a/ci/tasks/partner-center-go-live/run b/ci/tasks/partner-center-go-live/run new file mode 100755 index 000000000..358b1f3a3 --- /dev/null +++ b/ci/tasks/partner-center-go-live/run @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Promotes the preview submission in submission-id/id to live through the Partner Center +# Product Ingestion API (TNZ-155310). Ends once the submission is promoted to live; the configure +# job stays running through Publish, which wait-live waits for. Promoted means the submission is +# live, or a newer live submission that hasn't failed is listed; a promoted submission exits 0 +# without configuring, so a re-trigger never sends a second promotion. +set -euo pipefail + +# shellcheck source=/dev/null +source "$(dirname "${BASH_SOURCE[0]}")/../../common-scripts/partner-center.sh" + +submission_id=$(cat submission-id/id) +if [[ ! "${submission_id}" =~ ^submission/([0-9a-f-]{36})/[0-9]+$ ]]; then + echo "ERROR: submission-id/id '${submission_id}' is not like submission//" >&2 + echo "Nothing was sent to Partner Center; wait-preview writes submission-id/id, so check Partner Center before re-triggering publish-azure-offer" >&2 + exit 1 +fi +product=${BASH_REMATCH[1]} +echo "to debug, 'fly intercept -u ' and look at the redacted *.json files in ${PWD}" + +describe='"\(.id) (target \(.target.targetType), status \(.status), result \(.result))"' + +promoted_desc="" +# promoted_in : 0 when the submission is promoted (it is live, or a live +# submission that hasn't failed was created after it), setting promoted_desc to the match. +promoted_in() { + promoted_desc=$(jq -r --arg id "${submission_id}" '(.value // []) as $v + | first($v[] | select(.id == $id)) as $pinned + | [($v[] | select(.id == $id and .target.targetType? == "live")), + ($v[] | select(.target.targetType? == "live" and .result != "failed" and .created > $pinned.created))] + | first // empty | '"${describe}" <<<"$1") + [[ -n "${promoted_desc}" ]] +} +# promoted: promoted_in on a fresh GET; a failed GET counts as not promoted yet. +promoted() { + local subs + subs=$(pc_get "submission/${product}" 2022-03-01-preview2) || return 1 + promoted_in "${subs}" +} +failed_hint="See the submission's errors in Partner Center; once the cause is fixed, re-trigger submit-azure-offer (it submits the draft to preview again after a failed submission), then publish-azure-offer if it does not start" +recheck="Check Partner Center before re-triggering publish-azure-offer: once ${submission_id} (or a newer live submission) is listed as live, a re-trigger skips go-live and waits for live; while it is still only a preview, a re-trigger sends go-live again" + +# 1. An already-promoted submission (for example a re-trigger after go-live) needs nothing more. +pc_token +submissions=$(pc_get "submission/${product}" 2022-03-01-preview2) +pc_redact_sas <<<"${submissions}" | jq . > submissions.json +submission=$(jq -c --arg id "${submission_id}" '[(.value // [])[] | select(.id == $id)] | first' <<<"${submissions}") +if [[ "${submission}" == null ]]; then + echo "ERROR: submission ${submission_id} is not listed for product ${product}" >&2 + echo "Check Partner Center before re-running" >&2 + exit 1 +fi +if jq -e '.result == "failed"' <<<"${submission}" >/dev/null; then + echo "ERROR: submission $(jq -r "${describe}" <<<"${submission}") failed" >&2 + echo "${failed_hint}" >&2 + exit 1 +fi +if promoted_in "${submissions}"; then + echo "Submission ${submission_id} is already promoted to live (${promoted_desc}); nothing to do" + exit 0 +fi + +# 2. Configure: promote the submission to live, then wait until the job succeeds or the submission +# is promoted; the job stays running through Publish (up to 4 days). +payload=$(jq -cn --arg id "${submission_id}" --arg product "product/${product}" '{ + "$schema": "https://schema.mp.microsoft.com/schema/configure/2022-03-01-preview2", + resources: [{"$schema": "https://schema.mp.microsoft.com/schema/submission/2022-03-01-preview2", + id: $id, product: $product, target: {targetType: "live"}}]}') +pc_redact_sas <<<"${payload}" | jq . > configure-request.json + +log_submissions() { + pc_get "submission/${product}" 2022-03-01-preview2 | jq -c '(.value // [])[] + | select((.target.targetType? // "draft") != "draft") + | {id, target: .target.targetType, status, result, created}' | pc_redact_sas +} +if job_id=$(pc_configure "${payload}"); then + echo "Configure job ${job_id} started" +else + rc=$? + if [[ ${rc} -eq 2 ]]; then + echo "ERROR: configure may have been applied; current non-draft submissions:" >&2 + log_submissions >&2 || true + fi + echo "${recheck}" >&2 + exit 1 +fi + +if ! pc_wait_job "${job_id}" promoted; then + echo "${recheck}" >&2 + exit 1 +fi +if [[ -n "${promoted_desc}" ]]; then + echo "Submission ${submission_id} is promoted to live; publishing continues in wait-live" +else + echo "Submission ${submission_id} promoted to live" +fi diff --git a/ci/tasks/partner-center-go-live/task.yml b/ci/tasks/partner-center-go-live/task.yml new file mode 100644 index 000000000..31922af43 --- /dev/null +++ b/ci/tasks/partner-center-go-live/task.yml @@ -0,0 +1,15 @@ +--- +platform: linux + +inputs: + - name: bosh-windows-stemcell-builder-ci + - name: submission-id + +run: + path: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-go-live/run + +params: + PARTNER_PORTAL_TENANT_ID: + PARTNER_PORTAL_CLIENT_ID: + PARTNER_PORTAL_CLIENT_SECRET: + AZURE_OFFER: diff --git a/ci/tasks/partner-center-submit-preview/run b/ci/tasks/partner-center-submit-preview/run new file mode 100755 index 000000000..8c82f8ee0 --- /dev/null +++ b/ci/tasks/partner-center-submit-preview/run @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Adds this release's image version to the offer's draft technical configuration, then submits the +# draft to preview in a second configure call, through the Partner Center Product Ingestion API +# (TNZ-155310). Ends once this release's preview submission is listed; the submission's configure +# job stays running through certification, which publish-azure-offer waits for. Idempotent: a +# re-run for a release that is already submitted exits 0 without configuring. +set -euo pipefail + +# shellcheck source=/dev/null +source "$(dirname "${BASH_SOURCE[0]}")/../../common-scripts/partner-center.sh" + +version="${IMAGE_VERSION:-}" +if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: IMAGE_VERSION '${version}' is not a version like 1.9019.0 or 2022.7.009001" >&2 + echo "Nothing was sent to Partner Center; check the IMAGE_VERSION param" >&2 + exit 1 +fi +shopt -s nullglob +vhd_uri_files=(vhd-uri/*vhd-uri.txt) +shopt -u nullglob +if [[ ${#vhd_uri_files[@]} -ne 1 ]]; then + echo "ERROR: expected exactly one vhd-uri/*vhd-uri.txt, found ${#vhd_uri_files[@]}" >&2 + echo "Nothing was sent to Partner Center; check the vhd-uri input" >&2 + exit 1 +fi +blob=$(cat "${vhd_uri_files[0]}") +echo "Version ${version}" +preview_recheck="Check Partner Center: if a preview submission for ${version} is listed and has not failed, re-running this job is safe (it finds it and exits 0); if it is listed but failed, fix the cause first, as a re-run submits to preview again; if none is listed yet, wait a few minutes and check again before re-running, or a second preview submission is sent" +guard_hint="Nothing was sent to Partner Center; this is a bug in partner-center-submit-preview.sh" +echo "to debug, 'fly intercept -u ' and look at the redacted *.json files in ${PWD}" + +pc_token + +# 1. Product, plan and draft technical configuration. +product=$(pc_product_id "${AZURE_OFFER}") +tech_config=$(pc_plan_tech_config "${product}" "${AZURE_SKU}") +pc_redact_sas <<<"${tech_config}" | jq . > tech-config-draft.json +echo "Product ${product}, technical configuration $(jq -r .id <<<"${tech_config}")" + +# 2. Staleness guard: is the newest non-draft submission this release's, or another one in flight? +release=$(pc_release_submission "${product}" "${AZURE_SKU}" "${version}" "${blob}") +newest=$(jq -c .newest <<<"${release}") +describe=$(jq -r 'if . == null then "none" else "\(.id) (target \(.target.targetType), status \(.status), result \(.result))" end' <<<"${newest}") +if [[ $(jq -r .isRelease <<<"${release}") == true ]]; then + echo "Submission ${describe} already carries ${version} with this VHD; nothing to do" + exit 0 +fi +if ! jq -e '. == null or .result == "failed" + or (.target.targetType == "live" and .status == "completed" and .result == "succeeded")' <<<"${newest}" >/dev/null; then + echo "ERROR: newest submission ${describe} is not for ${version}: another release is in flight; resolve it in Partner Center" >&2 + echo "Nothing was sent to Partner Center; once that submission is live or has failed, re-running this job is safe" >&2 + exit 1 +fi +echo "Newest submission: ${describe}" + +# 3. Keep a matching version, refuse a different blob, or append the new version. +existing=$(pc_tech_config_blob_state "${tech_config}" "${version}" "${blob}") +case "${existing}" in + same) + echo "Version ${version} is already in the draft with this VHD; keeping the technical configuration as it is" + new_tech_config=${tech_config} + ;; + different) + echo "ERROR: version ${version} is already in the draft with a different VHD; refusing to overwrite it" >&2 + echo "Nothing was sent to Partner Center; a re-run fails the same way until the draft's ${version} image in Partner Center matches this VHD" >&2 + exit 1 + ;; + absent) + yesterday=$(date -u --date="-1 day" '+%Y-%m-%dT%TZ') + in_two_years=$(date -u --date="+2 years" '+%Y-%m-%dT%TZ') + sas=$(AZURE_STORAGE_KEY="${AZURE_PUBLISHED_STORAGE_ACCESS_KEY}" az storage container generate-sas --name "${AZURE_CONTAINER_NAME}" \ + --permissions rl \ + --account-name "${AZURE_PUBLISHED_STORAGE_ACCOUNT}" \ + --start "${yesterday}" --expiry "${in_two_years}" \ + --output tsv) + image=$(jq -cn --arg v "${version}" --arg uri "${blob}?${sas}" '{versionNumber: $v, + vmImages: [{imageType: "x64Gen1", source: {sourceType: "sasUri", osDisk: {uri: $uri}, dataDisks: []}}], + lifecycleState: "generallyAvailable"}') + new_tech_config=$(jq -c --argjson image "${image}" '.vmImageVersions += [$image]' <<<"${tech_config}") + echo "Appending version ${version}" + ;; +esac + +# 4. Guards: existing versions byte-identical, at most one appended, nothing else changed. +old_count=$(jq '(.vmImageVersions // []) | length' <<<"${tech_config}") +new_count=$(jq '(.vmImageVersions // []) | length' <<<"${new_tech_config}") +if [[ "$(jq -c --argjson n "${old_count}" '(.vmImageVersions // [])[:$n]' <<<"${new_tech_config}")" \ + != "$(jq -c '.vmImageVersions // []' <<<"${tech_config}")" ]]; then + echo "ERROR: guard: the existing vmImageVersions changed" >&2 + echo "${guard_hint}" >&2 + exit 1 +fi +if [[ ${new_count} -ne ${old_count} && ${new_count} -ne $((old_count + 1)) ]]; then + echo "ERROR: guard: vmImageVersions went from ${old_count} to ${new_count} entries" >&2 + echo "${guard_hint}" >&2 + exit 1 +fi +if [[ "$(jq -c 'del(.vmImageVersions)' <<<"${new_tech_config}")" != "$(jq -c 'del(.vmImageVersions)' <<<"${tech_config}")" ]]; then + echo "ERROR: guard: a technical configuration field other than vmImageVersions changed" >&2 + echo "${guard_hint}" >&2 + exit 1 +fi +pc_redact_sas <<<"${new_tech_config}" | jq . > tech-config-new.json +echo "Technical configuration diff (redacted):" +diff -u tech-config-draft.json tech-config-new.json || true + +log_submissions() { + pc_get "submission/${product}" 2022-03-01-preview2 | jq -c '(.value // [])[] + | select((.target.targetType? // "draft") != "draft") + | {id, target: .target.targetType, status, result, created}' | pc_redact_sas +} +# configure_and_wait []: POSTs one configure request and waits for its +# job (or until returns 0, see pc_wait_job); exits 1 naming if either fails. +configure_and_wait() { + local what=$1 payload=$2 done_fn=${3:-} job_id rc hint="Re-running this job is safe" + [[ "${what}" != "preview submission" ]] || hint=${preview_recheck} + if job_id=$(pc_configure "${payload}"); then + echo "Configure job ${job_id} (${what}) started" + else + rc=$? + if [[ ${rc} -eq 2 ]]; then + echo "ERROR: ${what} may have been applied; current non-draft submissions:" >&2 + log_submissions >&2 || true + if [[ "${what}" == "preview submission" ]]; then + echo "${preview_recheck}" >&2 + else + echo "Check Partner Center; re-running this job is safe" >&2 + fi + else + echo "ERROR: ${what} failed" >&2 + echo "${hint}" >&2 + fi + exit 1 + fi + if ! pc_wait_job "${job_id}" ${done_fn:+"${done_fn}"}; then + echo "ERROR: ${what} failed" >&2 + echo "${hint}" >&2 + exit 1 + fi +} + +# 5. Configure the tech config on its own; always sent, even when unchanged. Sending it together +# with a submission would be a modular publish, which needs every draft resource. +payload=$(jq -c '{"$schema": "https://schema.mp.microsoft.com/schema/configure/2022-03-01-preview2", + resources: [.]}' <<<"${new_tech_config}") +pc_redact_sas <<<"${payload}" | jq . > configure-request.json +configure_and_wait "technical configuration" "${payload}" + +# 6. Then submit the draft to preview, and log the preview submission. Its job stays running +# through certification (up to 2 business days), so stop waiting once this release's preview +# submission is listed; publish-azure-offer waits for certification. +preview_id="" +# preview_listed: 0 once the newest non-draft submission is a preview carrying this release; a +# failed lookup counts as not listed yet. +preview_listed() { + local release + release=$(pc_release_submission "${product}" "${AZURE_SKU}" "${version}" "${blob}") || return 1 + jq -e '.isRelease == true and .newest.target.targetType == "preview"' <<<"${release}" >/dev/null || return 1 + preview_id=$(jq -r .newest.id <<<"${release}") + echo "Preview submission ${preview_id} is listed; certification continues in publish-azure-offer" +} +payload=$(jq -cn --arg product "product/${product}" '{ + "$schema": "https://schema.mp.microsoft.com/schema/configure/2022-03-01-preview2", + resources: [{"$schema": "https://schema.mp.microsoft.com/schema/submission/2022-03-01-preview2", + product: $product, target: {targetType: "preview"}}]}') +jq . <<<"${payload}" > submission-request.json +configure_and_wait "preview submission" "${payload}" preview_listed +if [[ -z "${preview_id}" ]]; then + preview_id=$(pc_get "submission/${product}" 2022-03-01-preview2 | jq -r ' + [(.value // [])[] | select(.target.targetType? == "preview")] | max_by(.created) | .id // "not found yet"') +fi +echo "Preview submission: ${preview_id}" diff --git a/ci/tasks/partner-center-submit-preview/task.yml b/ci/tasks/partner-center-submit-preview/task.yml new file mode 100644 index 000000000..a7cd7c331 --- /dev/null +++ b/ci/tasks/partner-center-submit-preview/task.yml @@ -0,0 +1,20 @@ +--- +platform: linux + +inputs: + - name: bosh-windows-stemcell-builder-ci + - name: vhd-uri + +run: + path: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-submit-preview/run + +params: + PARTNER_PORTAL_TENANT_ID: + PARTNER_PORTAL_CLIENT_ID: + PARTNER_PORTAL_CLIENT_SECRET: + AZURE_OFFER: + AZURE_SKU: + IMAGE_VERSION: + AZURE_PUBLISHED_STORAGE_ACCOUNT: + AZURE_PUBLISHED_STORAGE_ACCESS_KEY: + AZURE_CONTAINER_NAME: diff --git a/ci/tasks/partner-center-wait-live/run b/ci/tasks/partner-center-wait-live/run new file mode 100755 index 000000000..ee6ee3ebe --- /dev/null +++ b/ci/tasks/partner-center-wait-live/run @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Waits for the submission in submission-id/id to go live through the Partner Center Product +# Ingestion API (TNZ-155310): that ID must be live and succeeded, and the live tree must hold +# this release's version with its blob. Then waits 4 hours for the offer to become visible. +set -euo pipefail + +# shellcheck source=/dev/null +source "$(dirname "${BASH_SOURCE[0]}")/../../common-scripts/partner-center.sh" + +version="${IMAGE_VERSION:-}" +if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: IMAGE_VERSION '${version}' is not a version like 1.9019.0 or 2022.7.009001" >&2 + echo "Nothing was sent to Partner Center; check the IMAGE_VERSION param" >&2 + exit 1 +fi +if [[ -z "${PUBLISH_JOB:-}" ]]; then + echo "ERROR: PUBLISH_JOB is not set (expected /)" >&2 + echo "Nothing was sent to Partner Center; check the PUBLISH_JOB param" >&2 + exit 1 +fi +shopt -s nullglob +vhd_uri_files=(vhd-uri/*vhd-uri.txt) +shopt -u nullglob +if [[ ${#vhd_uri_files[@]} -ne 1 ]]; then + echo "ERROR: expected exactly one vhd-uri/*vhd-uri.txt, found ${#vhd_uri_files[@]}" >&2 + echo "Nothing was sent to Partner Center; check the vhd-uri input" >&2 + exit 1 +fi +blob=$(cat "${vhd_uri_files[0]}") +submission_id=$(cat submission-id/id) +if [[ ! "${submission_id}" =~ ^submission/([0-9a-f-]{36})/[0-9]+$ ]]; then + echo "ERROR: submission-id/id '${submission_id}' is not like submission//" >&2 + echo "Nothing was sent to Partner Center; wait-preview writes submission-id/id, so check Partner Center before re-triggering publish-azure-offer" >&2 + exit 1 +fi +product=${BASH_REMATCH[1]} +echo "Version ${version}, submission ${submission_id}" +echo "to debug, 'fly intercept -u ' and look at the redacted *.json files in ${PWD}" + +failed_hint="See the submission's errors in Partner Center; once the cause is fixed, re-trigger submit-azure-offer (it submits the draft to preview again after a failed submission), then publish-azure-offer if it does not start" +resume_hint="Check Partner Center before re-triggering publish-azure-offer (fly -t bosh-ecosystem trigger-job -j ${PUBLISH_JOB}): while ${submission_id} is still going live, a re-trigger re-pins it, skips go-live and waits again; once it is live but ${version} is missing from the live offer, a re-trigger fails in wait-preview, so wait for the live offer instead" +# shellcheck disable=SC2034 # read by pc_poll_failed in the library +PC_POLL_GIVE_UP_HINT=${resume_hint} +describe='"\(.id) (target \(.target.targetType), status \(.status), result \(.result))"' +# 113h, so a release that goes live on the poll after the last check still ends inside the +# pipeline's 120h timeout: 113h + 1h poll sleep + up to ~1h outage + 4h grace <= 120h. +deadline=$(( $(date +%s) + 113 * 3600 )) + +# One poll: the pinned submission's own status and, once it is live and succeeded, whether the +# live tree holds the version. Any token or GET failure (after the library's retries) fails the +# poll; the token is fetched in this shell so it outlives $(...). +poll() { + pc_token || return 1 + submissions=$(pc_get "submission/${product}" 2022-03-01-preview2) || return 1 + submission=$(jq -c --arg id "${submission_id}" '[(.value // [])[] | select(.id == $id)] | first' <<<"${submissions}") + blob_state="" + if jq -e '.target.targetType == "live" and .status == "completed" and .result == "succeeded"' <<<"${submission}" >/dev/null; then + tech_config=$(pc_plan_tech_config "${product}" "${AZURE_SKU}" live) || return 1 + blob_state=$(pc_tech_config_blob_state "${tech_config}" "${version}" "${blob}") + fi +} + +while true; do + until poll; do + pc_poll_failed + done + pc_poll_succeeded + pc_redact_sas <<<"${submissions}" | jq . > submissions.json + if [[ "${submission}" == null ]]; then + echo "ERROR: submission ${submission_id} is not listed" >&2 + echo "Check Partner Center before re-running" >&2 + exit 1 + fi + if jq -e '.result == "failed"' <<<"${submission}" >/dev/null; then + echo "ERROR: submission $(jq -r "${describe}" <<<"${submission}") failed" >&2 + echo "${failed_hint}" >&2 + exit 1 + fi + if [[ -n "${blob_state}" ]]; then + if [[ "${blob_state}" == same ]]; then + echo "Submission $(jq -r "${describe}" <<<"${submission}") is live with ${version}" + break + fi + echo "Submission ${submission_id} succeeded but the live tree does not show ${version} yet" + else + echo "Submission $(jq -r "${describe}" <<<"${submission}") is not live yet" + fi + if [[ $(date +%s) -ge ${deadline} ]]; then + echo "ERROR: ${version} is still not live in ${submission_id} after 113 hours" >&2 + echo "${resume_hint}" >&2 + exit 1 + fi + echo "Checking again in 60 minutes" + sleep 3600 +done + +# Grace period for the live offer to become visible (matches the builder's +# wait-for-azure-marketplace-offer-live task). +echo "Waiting 4 hours for the live offer to become visible" +sleep 4h diff --git a/ci/tasks/partner-center-wait-live/task.yml b/ci/tasks/partner-center-wait-live/task.yml new file mode 100644 index 000000000..0527d582a --- /dev/null +++ b/ci/tasks/partner-center-wait-live/task.yml @@ -0,0 +1,19 @@ +--- +platform: linux + +inputs: + - name: bosh-windows-stemcell-builder-ci + - name: vhd-uri + - name: submission-id + +run: + path: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-wait-live/run + +params: + PARTNER_PORTAL_TENANT_ID: + PARTNER_PORTAL_CLIENT_ID: + PARTNER_PORTAL_CLIENT_SECRET: + AZURE_OFFER: + AZURE_SKU: + IMAGE_VERSION: + PUBLISH_JOB: diff --git a/ci/tasks/partner-center-wait-preview/run b/ci/tasks/partner-center-wait-preview/run new file mode 100755 index 000000000..81fd1c5bc --- /dev/null +++ b/ci/tasks/partner-center-wait-preview/run @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Waits for this release's preview submission to succeed through the Partner Center Product +# Ingestion API (TNZ-155310) and writes its ID to submission-id/id. The first successful poll +# identifies (pins) the submission; later polls only read that submission's own status. +set -euo pipefail + +# shellcheck source=/dev/null +source "$(dirname "${BASH_SOURCE[0]}")/../../common-scripts/partner-center.sh" + +version="${IMAGE_VERSION:-}" +if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: IMAGE_VERSION '${version}' is not a version like 1.9019.0 or 2022.7.009001" >&2 + echo "Nothing was sent to Partner Center; check the IMAGE_VERSION param" >&2 + exit 1 +fi +if [[ -z "${PUBLISH_JOB:-}" ]]; then + echo "ERROR: PUBLISH_JOB is not set (expected /)" >&2 + echo "Nothing was sent to Partner Center; check the PUBLISH_JOB param" >&2 + exit 1 +fi +shopt -s nullglob +vhd_uri_files=(vhd-uri/*vhd-uri.txt) +shopt -u nullglob +if [[ ${#vhd_uri_files[@]} -ne 1 ]]; then + echo "ERROR: expected exactly one vhd-uri/*vhd-uri.txt, found ${#vhd_uri_files[@]}" >&2 + echo "Nothing was sent to Partner Center; check the vhd-uri input" >&2 + exit 1 +fi +blob=$(cat "${vhd_uri_files[0]}") +echo "Version ${version}" +echo "to debug, 'fly intercept -u ' and look at the redacted *.json files in ${PWD}" + +failed_hint="See the submission's errors in Partner Center; once the cause is fixed, re-trigger submit-azure-offer (it submits the draft to preview again after a failed submission), then publish-azure-offer if it does not start" +describe='"\(.id) (target \(.target.targetType), status \(.status), result \(.result))"' +# shellcheck disable=SC2034 # read by pc_poll_failed in the library +PC_POLL_GIVE_UP_HINT="Check Partner Center before re-triggering publish-azure-offer (fly -t bosh-ecosystem trigger-job -j ${PUBLISH_JOB}): once the API answers again, a re-trigger finds this release's newest submission and waits again" +# 166h, so the task fails with this message inside the pipeline's 168h timeout: +# 166h + 30 min poll sleep + up to ~60 min outage <= 168h. +deadline=$(( $(date +%s) + 166 * 3600 )) + +# 1. First poll: identify this release's submission. A token or GET failure (after the +# library's retries) is a failed poll; the token is fetched in this shell so it outlives $(...). +product="" +until pc_token && { [[ -n "${product}" ]] || product=$(pc_product_id "${AZURE_OFFER}"); } \ + && release=$(pc_release_submission "${product}" "${AZURE_SKU}" "${version}" "${blob}"); do + pc_poll_failed +done +pc_poll_succeeded +newest=$(jq -c .newest <<<"${release}") +if [[ $(jq -r .isRelease <<<"${release}") != true ]]; then + if jq -e '. != null and .result == "failed"' <<<"${newest}" >/dev/null; then + echo "ERROR: newest submission $(jq -r '"\(.id) (\(.target.targetType))"' <<<"${newest}") failed" >&2 + echo "${failed_hint}" >&2 + else + echo "ERROR: no submission for ${version}; run submit-azure-offer" >&2 + echo "Check Partner Center before running submit-azure-offer: if this release's submission is already live but ${version} is not in the live offer yet, running it sends a second preview submission" >&2 + fi + exit 1 +fi +submission_id=$(jq -r .id <<<"${newest}") +echo "Pinned submission $(jq -r "${describe}" <<<"${newest}")" + +# 2. From here on, only the pinned submission's own status counts; it is never re-identified. +submission=${newest} +while true; do + if jq -e '.result == "failed"' <<<"${submission}" >/dev/null; then + echo "ERROR: submission $(jq -r "${describe}" <<<"${submission}") failed" >&2 + echo "${failed_hint}" >&2 + exit 1 + fi + if jq -e '.target.targetType == "live"' <<<"${submission}" >/dev/null; then + echo "Submission $(jq -r "${describe}" <<<"${submission}") has already been promoted to live" + break + fi + if jq -e '.status == "completed" and .result == "succeeded"' <<<"${submission}" >/dev/null; then + echo "Preview submission $(jq -r "${describe}" <<<"${submission}") succeeded" + break + fi + if [[ $(date +%s) -ge ${deadline} ]]; then + echo "ERROR: preview submission ${submission_id} is still pending after 166 hours" >&2 + echo "Check Partner Center; while ${submission_id} is this release's newest submission, re-triggering publish-azure-offer (fly -t bosh-ecosystem trigger-job -j ${PUBLISH_JOB}) pins it again and waits another 166 hours" >&2 + exit 1 + fi + echo "Preview submission $(jq -r "${describe}" <<<"${submission}") is pending; checking again in 30 minutes" + sleep 1800 + until pc_token && submissions=$(pc_get "submission/${product}" 2022-03-01-preview2); do + pc_poll_failed + done + pc_poll_succeeded + pc_redact_sas <<<"${submissions}" | jq . > submissions.json + submission=$(jq -c --arg id "${submission_id}" '[(.value // [])[] | select(.id == $id)] | first' <<<"${submissions}") + if [[ "${submission}" == null ]]; then + echo "ERROR: pinned submission ${submission_id} is no longer listed" >&2 + echo "Check Partner Center before re-running" >&2 + exit 1 + fi +done + +printf '%s\n' "${submission_id}" > submission-id/id +echo "Wrote ${submission_id} to submission-id/id" diff --git a/ci/tasks/partner-center-wait-preview/task.yml b/ci/tasks/partner-center-wait-preview/task.yml new file mode 100644 index 000000000..3dc7d75f9 --- /dev/null +++ b/ci/tasks/partner-center-wait-preview/task.yml @@ -0,0 +1,21 @@ +--- +platform: linux + +inputs: + - name: bosh-windows-stemcell-builder-ci + - name: vhd-uri + +outputs: + - name: submission-id + +run: + path: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-wait-preview/run + +params: + PARTNER_PORTAL_TENANT_ID: + PARTNER_PORTAL_CLIENT_ID: + PARTNER_PORTAL_CLIENT_SECRET: + AZURE_OFFER: + AZURE_SKU: + IMAGE_VERSION: + PUBLISH_JOB: From a2d777925f8adf79dc213f28e923838722d91060 Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 15:40:24 -0700 Subject: [PATCH 2/7] CI: Name the submit-preview script path in its guard message ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/tasks/partner-center-submit-preview/run | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/tasks/partner-center-submit-preview/run b/ci/tasks/partner-center-submit-preview/run index 8c82f8ee0..199de2a9f 100755 --- a/ci/tasks/partner-center-submit-preview/run +++ b/ci/tasks/partner-center-submit-preview/run @@ -26,7 +26,7 @@ fi blob=$(cat "${vhd_uri_files[0]}") echo "Version ${version}" preview_recheck="Check Partner Center: if a preview submission for ${version} is listed and has not failed, re-running this job is safe (it finds it and exits 0); if it is listed but failed, fix the cause first, as a re-run submits to preview again; if none is listed yet, wait a few minutes and check again before re-running, or a second preview submission is sent" -guard_hint="Nothing was sent to Partner Center; this is a bug in partner-center-submit-preview.sh" +guard_hint="Nothing was sent to Partner Center; this is a bug in ci/tasks/partner-center-submit-preview/run" echo "to debug, 'fly intercept -u ' and look at the redacted *.json files in ${PWD}" pc_token From 0cc78f4aab400c0d81f2e99508fa8396f2df9f14 Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 15:41:50 -0700 Subject: [PATCH 3/7] CI: Make azure-image-upload copy the VHD only The Partner Center tasks publish the image, so this task only copies the VHD to the published account and hands them its SAS-free blob URL. ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/tasks/azure-image-upload/run | 51 ++++++++++++++-------------- ci/tasks/azure-image-upload/task.yml | 9 +++-- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/ci/tasks/azure-image-upload/run b/ci/tasks/azure-image-upload/run index a95ea7074..634a5696d 100755 --- a/ci/tasks/azure-image-upload/run +++ b/ci/tasks/azure-image-upload/run @@ -1,25 +1,23 @@ #!/usr/bin/env bash set -euo pipefail -uri_filename='bosh-stemcell-*-azure-vhd-uri.txt' -image_url=$(cat "azure-base-vhd-uri/${uri_filename}") -vhd_path=$(echo "${image_url}" | sed -E 's/^.*(Microsoft\.Compute\/Images\/.*vhd).*$/\1/') +# Copies the release VHD to the published storage account and writes its SAS-free URL +# for the partner-center tasks. The input URL carries a SAS; never print it. -create_microsoft_token() { - tenant_id=${PARTNER_PORTAL_TENANT_ID} - client_id=${PARTNER_PORTAL_CLIENT_ID} - client_secret=${PARTNER_PORTAL_CLIENT_SECRET} - grant_type="client_credentials" - resource="https://cloudpartner.azure.com" +main_version=$(cat version/number) +if ! [[ "${main_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-build\.[0-9]+$ ]]; then + echo "ERROR: version/number '${main_version}' does not match X.Y.Z-build.N" >&2 + exit 1 +fi - microsoft_auth_endpoint="https://login.microsoftonline.com/${tenant_id}/oauth2/token" - token=$(curl --fail -X POST "${microsoft_auth_endpoint}" \ - -F "client_id=${client_id}" \ - -F "client_secret=${client_secret}" \ - -F "grant_type=${grant_type}" \ - -F "resource=${resource}" | jq -r .access_token - ) - echo "$token" +image_url=$(cat azure-base-vhd-uri/bosh-stemcell-*-azure-vhd-uri.txt) +vhd_filename=$(basename "${image_url}" | cut -d? -f1) + +format_version() { + main_version=$1 + version_regex='([0-9]+)\.([0-9]+)\.([0-9]+)-build\.([0-9]+)' + version_number_tokens=$(echo "$main_version" | sed -E "s/^$version_regex$/\1 \2 \3 \4/") + echo "$version_number_tokens" | awk '{ printf("%d.%d.%03d%03d", $1, $2, $3, $4) } ' } copy_blob_to_premiumstore() { @@ -27,11 +25,11 @@ copy_blob_to_premiumstore() { --source-account-key "${AZURE_STORAGE_ACCESS_KEY}" \ --source-account-name "${AZURE_STORAGE_ACCOUNT}" \ --source-container "${AZURE_CONTAINER_NAME}" \ - --source-blob "${vhd_path}" \ + --source-blob "${vhd_filename}" \ --account-name "${AZURE_PUBLISHED_STORAGE_ACCOUNT}" \ --account-key "${AZURE_PUBLISHED_STORAGE_ACCESS_KEY}" \ --destination-container "${AZURE_CONTAINER_NAME}" \ - --destination-blob "${vhd_path}" + --destination-blob "${vhd_filename}" > /dev/null } wait_for_copy_blob_to_premiumstore() { @@ -54,18 +52,14 @@ wait_for_copy_blob_to_premiumstore() { get_copy_blob_status() { az storage blob show \ --container-name "${AZURE_CONTAINER_NAME}" \ - --name "${vhd_path}" \ + --name "${vhd_filename}" \ --account-name "${AZURE_PUBLISHED_STORAGE_ACCOUNT}" \ --account-key "${AZURE_PUBLISHED_STORAGE_ACCESS_KEY}" | jq -r ".properties.copy.status" } -echo -n "Authenticating with Microsoft..." -echo -token=$(create_microsoft_token) -echo OK +image_version=$(format_version "${main_version}") -echo -echo "Copying Image from ${AZURE_STORAGE_ACCOUNT} to ${AZURE_PUBLISHED_STORAGE_ACCOUNT}" +echo "Copying ${AZURE_CONTAINER_NAME}/${vhd_filename} from ${AZURE_STORAGE_ACCOUNT} to ${AZURE_PUBLISHED_STORAGE_ACCOUNT}" copy_blob_to_premiumstore echo OK @@ -73,3 +67,8 @@ echo echo "Waiting for copy to finish from ${AZURE_STORAGE_ACCOUNT} to ${AZURE_PUBLISHED_STORAGE_ACCOUNT}" wait_for_copy_blob_to_premiumstore echo OK + +echo +echo "Image version ${image_version}" +echo "https://${AZURE_PUBLISHED_STORAGE_ACCOUNT}.blob.core.windows.net/${AZURE_CONTAINER_NAME}/${vhd_filename}" \ + > "published-vhd-uri/bosh-windows-image-${image_version}-vhd-uri.txt" diff --git a/ci/tasks/azure-image-upload/task.yml b/ci/tasks/azure-image-upload/task.yml index 9b40e354b..c4351bf77 100644 --- a/ci/tasks/azure-image-upload/task.yml +++ b/ci/tasks/azure-image-upload/task.yml @@ -3,9 +3,12 @@ platform: linux inputs: - name: bosh-windows-stemcell-builder-ci - - name: stemcell-builder + - name: version - name: azure-base-vhd-uri +outputs: + - name: published-vhd-uri + run: path: bosh-windows-stemcell-builder-ci/ci/tasks/azure-image-upload/run @@ -15,7 +18,3 @@ params: AZURE_PUBLISHED_STORAGE_ACCESS_KEY: AZURE_PUBLISHED_STORAGE_ACCOUNT: AZURE_CONTAINER_NAME: - AZURE_OFFER: - PARTNER_PORTAL_TENANT_ID: - PARTNER_PORTAL_CLIENT_ID: - PARTNER_PORTAL_CLIENT_SECRET: From 3f6e7d00e809bdba86ca653b2ebabe3685de2aab Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 15:45:27 -0700 Subject: [PATCH 4/7] CI: Publish the 2019 Azure offer through Partner Center tasks The Cloud Partner Portal API is deprecated; submit-azure-offer and publish-azure-offer now use the Product Ingestion API tasks, keyed by the image version recorded in azure-published-vhd-uri. ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/pipelines/stemcells-windows.yml | 113 ++++++++++++++--------------- 1 file changed, 56 insertions(+), 57 deletions(-) diff --git a/ci/pipelines/stemcells-windows.yml b/ci/pipelines/stemcells-windows.yml index ec82d3f22..df5e0e434 100644 --- a/ci/pipelines/stemcells-windows.yml +++ b/ci/pipelines/stemcells-windows.yml @@ -504,6 +504,14 @@ resources: access_key_id: ((bosh_windows_ci_assume_aws_access_key.username)) secret_access_key: ((bosh_windows_ci_assume_aws_access_key.password)) aws_role_arn: ((bosh_windows_ci_assume_aws_access_key.role_arn)) + - name: azure-published-vhd-uri + type: s3 + source: + bucket: ((ROOT_BUCKET)) + regexp: ((BASE_OS_VERSION))/untested/azure/published/bosh-windows-image-(.*)-vhd-uri.txt + access_key_id: ((bosh_windows_ci_assume_aws_access_key.username)) + secret_access_key: ((bosh_windows_ci_assume_aws_access_key.password)) + aws_role_arn: ((bosh_windows_ci_assume_aws_access_key.role_arn)) - name: azure-tested type: s3 source: @@ -2619,52 +2627,40 @@ jobs: trigger: true - get: azure-base-vhd-uri passed: [promote] - # TODO: delete `ci/tasks/azure-image-upload-and-start-publishing/` once this task is replaced by the two below - - task: upload-image-and-start-publishing - file: bosh-windows-stemcell-builder-ci/ci/tasks/azure-image-upload-and-start-publishing/task.yml + - task: azure-image-upload + file: bosh-windows-stemcell-builder-ci/ci/tasks/azure-image-upload/task.yml image: bosh-windows-stemcell-builder-ci-image + timeout: 3h params: AZURE_STORAGE_ACCESS_KEY: ((heavy_azure_stemcell_azure_storage_access_key_koala)) AZURE_STORAGE_ACCOUNT: unpublishedstemcells AZURE_PUBLISHED_STORAGE_ACCESS_KEY: ((azure_published_storage_access_key)) AZURE_PUBLISHED_STORAGE_ACCOUNT: publishedstemcells AZURE_CONTAINER_NAME: ((AZURE_CONTAINER_NAME)) - AZURE_SKU: ((AZURE_SKU)) - AZURE_PUBLISHER: ((AZURE_PUBLISHER)) - AZURE_OFFER: bosh-windows-server-((BASE_OS_VERSION)) - PARTNER_PORTAL_TENANT_ID: ((azure_partner_portal.tenant_id)) - PARTNER_PORTAL_CLIENT_ID: ((azure_partner_portal.client_id)) - PARTNER_PORTAL_CLIENT_SECRET: ((azure_partner_portal.client_secret)) - OFFER_NOTIFICATION_EMAIL: boshwindows@groups.vmware.com - MANUALLY_BYPASS_SUBMISSION: "" - # TODO: replace `upload-image-and-start-publishing` with the two tasks below - # - task: azure-image-upload - # file: bosh-windows-stemcell-builder-ci/ci/tasks/azure-image-upload/task.yml - # image: bosh-windows-stemcell-builder-ci-image - # params: - # AZURE_STORAGE_ACCESS_KEY: ((heavy_azure_stemcell_azure_storage_access_key_koala)) - # AZURE_STORAGE_ACCOUNT: unpublishedstemcells - # AZURE_PUBLISHED_STORAGE_ACCESS_KEY: ((azure_published_storage_access_key)) - # AZURE_PUBLISHED_STORAGE_ACCOUNT: publishedstemcells - # AZURE_CONTAINER_NAME: ((AZURE_CONTAINER_NAME)) - # AZURE_OFFER: bosh-windows-server-((BASE_OS_VERSION)) - # PARTNER_PORTAL_TENANT_ID: ((azure_partner_portal.tenant_id)) - # PARTNER_PORTAL_CLIENT_ID: ((azure_partner_portal.client_id)) - # PARTNER_PORTAL_CLIENT_SECRET: ((azure_partner_portal.client_secret)) - # - task: azure-start-publishing - # file: bosh-windows-stemcell-builder-ci/ci/tasks/azure-start-publishing/task.yml - # image: bosh-windows-stemcell-builder-ci-image - # params: - # AZURE_PUBLISHED_STORAGE_ACCESS_KEY: ((azure_published_storage_access_key)) - # AZURE_PUBLISHED_STORAGE_ACCOUNT: publishedstemcells - # AZURE_CONTAINER_NAME: ((AZURE_CONTAINER_NAME)) - # AZURE_SKU: ((AZURE_SKU)) - # AZURE_PUBLISHER: ((AZURE_PUBLISHER)) - # AZURE_OFFER: bosh-windows-server-((BASE_OS_VERSION)) - # PARTNER_PORTAL_TENANT_ID: ((azure_partner_portal.tenant_id)) - # PARTNER_PORTAL_CLIENT_ID: ((azure_partner_portal.client_id)) - # PARTNER_PORTAL_CLIENT_SECRET: ((azure_partner_portal.client_secret)) - # OFFER_NOTIFICATION_EMAIL: boshwindows@groups.vmware.com + - put: azure-published-vhd-uri + params: + file: published-vhd-uri/*-vhd-uri.txt + - load_var: image-version + file: azure-published-vhd-uri/version + reveal: true + - task: partner-center-submit-preview + file: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-submit-preview/task.yml + image: bosh-windows-stemcell-builder-ci-image + timeout: 3h + input_mapping: + vhd-uri: azure-published-vhd-uri + params: + <<: &partner_center_params + AZURE_OFFER: bosh-windows-server-((BASE_OS_VERSION)) + AZURE_SKU: ((AZURE_SKU)) + IMAGE_VERSION: ((.:image-version)) + PUBLISH_JOB: stemcells-windows-((BASE_OS_VERSION))/publish-azure-offer + PARTNER_PORTAL_TENANT_ID: ((azure_partner_portal.tenant_id)) + PARTNER_PORTAL_CLIENT_ID: ((azure_partner_portal.client_id)) + PARTNER_PORTAL_CLIENT_SECRET: ((azure_partner_portal.client_secret)) + AZURE_PUBLISHED_STORAGE_ACCOUNT: publishedstemcells + AZURE_PUBLISHED_STORAGE_ACCESS_KEY: ((azure_published_storage_access_key)) + AZURE_CONTAINER_NAME: ((AZURE_CONTAINER_NAME)) - name: publish-azure-offer serial: true @@ -2685,27 +2681,30 @@ jobs: - get: azure-tested passed: [submit-azure-offer] trigger: true - - task: wait-for-azure-publisher-signoff - file: bosh-windows-stemcell-builder-ci/ci/tasks/wait-for-azure-publisher-signoff/task.yml + - get: azure-published-vhd-uri + passed: [submit-azure-offer] + - load_var: image-version + file: azure-published-vhd-uri/version + reveal: true + - task: partner-center-wait-preview + file: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-wait-preview/task.yml image: bosh-windows-stemcell-builder-ci-image - timeout: 168h # 7 days - params: &azure_publisher_information - AZURE_PUBLISHER: ((AZURE_PUBLISHER)) - AZURE_OFFER: bosh-windows-server-((BASE_OS_VERSION)) - PARTNER_PORTAL_TENANT_ID: ((azure_partner_portal.tenant_id)) - PARTNER_PORTAL_CLIENT_ID: ((azure_partner_portal.client_id)) - PARTNER_PORTAL_CLIENT_SECRET: ((azure_partner_portal.client_secret)) - - task: signoff-azure-live - file: bosh-windows-stemcell-builder-ci/ci/tasks/signoff-azure-live/task.yml + timeout: 168h + input_mapping: + vhd-uri: azure-published-vhd-uri + params: *partner_center_params + - task: partner-center-go-live + file: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-go-live/task.yml image: bosh-windows-stemcell-builder-ci-image - params: - <<: *azure_publisher_information - - task: wait-for-azure-marketplace-offer-live - file: bosh-windows-stemcell-builder-ci/ci/tasks/wait-for-azure-marketplace-offer-live/task.yml + timeout: 2h + params: *partner_center_params + - task: partner-center-wait-live + file: bosh-windows-stemcell-builder-ci/ci/tasks/partner-center-wait-live/task.yml image: bosh-windows-stemcell-builder-ci-image - timeout: 120h # 5 days - params: - <<: *azure_publisher_information + timeout: 120h + input_mapping: + vhd-uri: azure-published-vhd-uri + params: *partner_center_params - name: promote-azure serial: true From d13115ea3c60af459d4b68f81681b1eeed5ec9fa Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 16:12:14 -0700 Subject: [PATCH 5/7] CI: Harden the Azure VHD upload and preview SAS The upload now requires exactly one VHD URI file and names the blob from the URL path only, so a slash in the SAS query cannot change it. A failed copy start, usually a pending copy left by an aborted run, prints how to cancel it. The preview submit refuses an empty SAS before calling Partner Center. ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/tasks/azure-image-upload/run | 25 +++++++++++++++++++--- ci/tasks/partner-center-submit-preview/run | 5 +++++ ci/tasks/partner-center-wait-live/run | 3 +-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/ci/tasks/azure-image-upload/run b/ci/tasks/azure-image-upload/run index 634a5696d..fe49c8b91 100755 --- a/ci/tasks/azure-image-upload/run +++ b/ci/tasks/azure-image-upload/run @@ -10,8 +10,20 @@ if ! [[ "${main_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-build\.[0-9]+$ ]]; then exit 1 fi -image_url=$(cat azure-base-vhd-uri/bosh-stemcell-*-azure-vhd-uri.txt) -vhd_filename=$(basename "${image_url}" | cut -d? -f1) +shopt -s nullglob +vhd_uri_files=(azure-base-vhd-uri/bosh-stemcell-*-azure-vhd-uri.txt) +shopt -u nullglob +if [[ ${#vhd_uri_files[@]} -ne 1 ]]; then + echo "ERROR: expected exactly one azure-base-vhd-uri/bosh-stemcell-*-azure-vhd-uri.txt, found ${#vhd_uri_files[@]}" >&2 + exit 1 +fi +image_url=$(cat "${vhd_uri_files[0]}") +vhd_path=${image_url%%\?*} +vhd_filename=$(basename "${vhd_path}") +if [[ -z "${vhd_filename}" || "${vhd_filename}" != *.vhd ]]; then + echo "ERROR: the URL in ${vhd_uri_files[0]} does not name a .vhd blob" >&2 + exit 1 +fi format_version() { main_version=$1 @@ -29,7 +41,14 @@ copy_blob_to_premiumstore() { --account-name "${AZURE_PUBLISHED_STORAGE_ACCOUNT}" \ --account-key "${AZURE_PUBLISHED_STORAGE_ACCESS_KEY}" \ --destination-container "${AZURE_CONTAINER_NAME}" \ - --destination-blob "${vhd_filename}" > /dev/null + --destination-blob "${vhd_filename}" > /dev/null || { + echo "ERROR: could not start the copy to ${AZURE_PUBLISHED_STORAGE_ACCOUNT}/${AZURE_CONTAINER_NAME}/${vhd_filename}" >&2 + echo "If an earlier run left a pending copy on that blob, wait for it to finish or cancel it:" >&2 + echo " az storage blob show --account-name ${AZURE_PUBLISHED_STORAGE_ACCOUNT} --container-name ${AZURE_CONTAINER_NAME} --name ${vhd_filename} --query properties.copy.id" >&2 + echo " az storage blob copy cancel --account-name ${AZURE_PUBLISHED_STORAGE_ACCOUNT} --destination-container ${AZURE_CONTAINER_NAME} --destination-blob ${vhd_filename} --copy-id " >&2 + echo "then re-run this job" >&2 + exit 1 + } } wait_for_copy_blob_to_premiumstore() { diff --git a/ci/tasks/partner-center-submit-preview/run b/ci/tasks/partner-center-submit-preview/run index 199de2a9f..cf0c9a1eb 100755 --- a/ci/tasks/partner-center-submit-preview/run +++ b/ci/tasks/partner-center-submit-preview/run @@ -73,6 +73,11 @@ case "${existing}" in --account-name "${AZURE_PUBLISHED_STORAGE_ACCOUNT}" \ --start "${yesterday}" --expiry "${in_two_years}" \ --output tsv) + if [[ -z "${sas}" ]]; then + echo "ERROR: could not generate a SAS for container ${AZURE_CONTAINER_NAME} in ${AZURE_PUBLISHED_STORAGE_ACCOUNT}" >&2 + echo "Nothing was sent to Partner Center; re-running this job is safe" >&2 + exit 1 + fi image=$(jq -cn --arg v "${version}" --arg uri "${blob}?${sas}" '{versionNumber: $v, vmImages: [{imageType: "x64Gen1", source: {sourceType: "sasUri", osDisk: {uri: $uri}, dataDisks: []}}], lifecycleState: "generallyAvailable"}') diff --git a/ci/tasks/partner-center-wait-live/run b/ci/tasks/partner-center-wait-live/run index ee6ee3ebe..82aca7c88 100755 --- a/ci/tasks/partner-center-wait-live/run +++ b/ci/tasks/partner-center-wait-live/run @@ -94,7 +94,6 @@ while true; do sleep 3600 done -# Grace period for the live offer to become visible (matches the builder's -# wait-for-azure-marketplace-offer-live task). +# Partner Center documents up to 4 hours before a live listing is visible. echo "Waiting 4 hours for the live offer to become visible" sleep 4h From a20d7f2fd91ae4632483f9dbcdde3b570138fb2b Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 16:20:03 -0700 Subject: [PATCH 6/7] CI: Name the key the pending-copy recovery commands need ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/tasks/azure-image-upload/run | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/tasks/azure-image-upload/run b/ci/tasks/azure-image-upload/run index fe49c8b91..3fb4af19b 100755 --- a/ci/tasks/azure-image-upload/run +++ b/ci/tasks/azure-image-upload/run @@ -43,7 +43,7 @@ copy_blob_to_premiumstore() { --destination-container "${AZURE_CONTAINER_NAME}" \ --destination-blob "${vhd_filename}" > /dev/null || { echo "ERROR: could not start the copy to ${AZURE_PUBLISHED_STORAGE_ACCOUNT}/${AZURE_CONTAINER_NAME}/${vhd_filename}" >&2 - echo "If an earlier run left a pending copy on that blob, wait for it to finish or cancel it:" >&2 + echo "If an earlier run left a pending copy on that blob, wait for it to finish or cancel it (with AZURE_STORAGE_KEY set to the ${AZURE_PUBLISHED_STORAGE_ACCOUNT} key):" >&2 echo " az storage blob show --account-name ${AZURE_PUBLISHED_STORAGE_ACCOUNT} --container-name ${AZURE_CONTAINER_NAME} --name ${vhd_filename} --query properties.copy.id" >&2 echo " az storage blob copy cancel --account-name ${AZURE_PUBLISHED_STORAGE_ACCOUNT} --destination-container ${AZURE_CONTAINER_NAME} --destination-blob ${vhd_filename} --copy-id " >&2 echo "then re-run this job" >&2 From 66d51e194a7552854686246e72711c0382e1b993 Mon Sep 17 00:00:00 2001 From: Chris Selzo Date: Tue, 29 Sep 2026 16:49:52 -0700 Subject: [PATCH 7/7] CI: Promote only the pinned submission in partner-center-go-live go-live counts the release as promoted only when its pinned submission is live, the same rule wait-live uses. Partner Center keeps a submission's ID when it goes live. ai-assisted=yes [TNZ-155310] Co-Authored-By: Claude Opus 5.5 --- ci/tasks/partner-center-go-live/run | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/ci/tasks/partner-center-go-live/run b/ci/tasks/partner-center-go-live/run index 358b1f3a3..0c39084e5 100755 --- a/ci/tasks/partner-center-go-live/run +++ b/ci/tasks/partner-center-go-live/run @@ -1,8 +1,7 @@ #!/usr/bin/env bash # Promotes the preview submission in submission-id/id to live through the Partner Center -# Product Ingestion API (TNZ-155310). Ends once the submission is promoted to live; the configure -# job stays running through Publish, which wait-live waits for. Promoted means the submission is -# live, or a newer live submission that hasn't failed is listed; a promoted submission exits 0 +# Product Ingestion API (TNZ-155310). Ends once the submission is live; the configure job stays +# running through Publish, which wait-live waits for. A submission that is already live exits 0 # without configuring, so a re-trigger never sends a second promotion. set -euo pipefail @@ -21,14 +20,10 @@ echo "to debug, 'fly intercept -u ' and look at the redacted * describe='"\(.id) (target \(.target.targetType), status \(.status), result \(.result))"' promoted_desc="" -# promoted_in : 0 when the submission is promoted (it is live, or a live -# submission that hasn't failed was created after it), setting promoted_desc to the match. +# promoted_in : 0 when the submission is live, setting promoted_desc to it. promoted_in() { - promoted_desc=$(jq -r --arg id "${submission_id}" '(.value // []) as $v - | first($v[] | select(.id == $id)) as $pinned - | [($v[] | select(.id == $id and .target.targetType? == "live")), - ($v[] | select(.target.targetType? == "live" and .result != "failed" and .created > $pinned.created))] - | first // empty | '"${describe}" <<<"$1") + promoted_desc=$(jq -r --arg id "${submission_id}" \ + 'first((.value // [])[] | select(.id == $id and .target.targetType? == "live")) // empty | '"${describe}" <<<"$1") [[ -n "${promoted_desc}" ]] } # promoted: promoted_in on a fresh GET; a failed GET counts as not promoted yet. @@ -38,7 +33,7 @@ promoted() { promoted_in "${subs}" } failed_hint="See the submission's errors in Partner Center; once the cause is fixed, re-trigger submit-azure-offer (it submits the draft to preview again after a failed submission), then publish-azure-offer if it does not start" -recheck="Check Partner Center before re-triggering publish-azure-offer: once ${submission_id} (or a newer live submission) is listed as live, a re-trigger skips go-live and waits for live; while it is still only a preview, a re-trigger sends go-live again" +recheck="Check Partner Center before re-triggering publish-azure-offer: once ${submission_id} is listed as live, a re-trigger skips go-live and waits for live; while it is still only a preview, a re-trigger sends go-live again" # 1. An already-promoted submission (for example a re-trigger after go-live) needs nothing more. pc_token