set -euo pipefail
MODE=cutover
WITHOUT_REDIS=""
case "${1:-}" in
"") ;;
--remove-credentials) MODE=remove ;;
--without-redis) WITHOUT_REDIS=1 ;;
*) echo "usage: azure-cutover.sh [--remove-credentials | --without-redis]" >&2; exit 2 ;;
esac
for tool in az jq curl; do
command -v "$tool" > /dev/null \
|| { echo "::error::azure-cutover.sh needs $tool. Install it, and try again." >&2; exit 1; }
done
: "${AZURE_APP_NAME:?set AZURE_APP_NAME (terraform output app_name)}"
: "${AZURE_RESOURCE_GROUP:?set AZURE_RESOURCE_GROUP (terraform output resource_group_name)}"
: "${AZURE_MIGRATE_JOB_NAME:?set AZURE_MIGRATE_JOB_NAME (terraform output migrate_job_name)}"
: "${ACR_LOGIN_SERVER:?set ACR_LOGIN_SERVER (terraform output acr_login_server)}"
BOOTSTRAP_IMAGE="${AZURE_BOOTSTRAP_IMAGE:-mcr.microsoft.com/k8se/quickstart:latest}"
if [ "$MODE" = cutover ]; then
: "${IMAGE_TAG:?set IMAGE_TAG (the tag you pushed to ACR)}"
IMAGE="$ACR_LOGIN_SERVER/$AZURE_APP_NAME:$IMAGE_TAG"
fi
API="api-version=2024-03-01"
TMP_TEMPLATE="${TMPDIR:-/tmp}/azure-cutover.XXXXXX"
BODY=$(mktemp "$TMP_TEMPLATE")
APP_SECRETS=$(mktemp "$TMP_TEMPLATE")
JOB_SECRETS=$(mktemp "$TMP_TEMPLATE")
LIVE_SECRETS=$(mktemp "$TMP_TEMPLATE")
JQ_VARS=$(mktemp -d "$TMP_TEMPLATE")
trap 'rm -f "$BODY" "$APP_SECRETS" "$JOB_SECRETS" "$LIVE_SECRETS"; rm -rf "$JQ_VARS"' EXIT
jq() {
local args=() files=() nfiles=0 binds="" filter=-1 file status=0
while [ $# -gt 0 ]; do
case "$1" in
--argjson)
file=$(mktemp "$JQ_VARS/v.XXXXXX") || return 1
printf '%s' "$3" > "$file"
files+=("$file")
nfiles=$((nfiles + 1))
args+=(--slurpfile "$2" "$file")
binds="$binds\$$2[0] as \$$2 | "
shift 3 ;;
--arg|--slurpfile|--rawfile) args+=("$1" "$2" "$3"); shift 3 ;;
-*) args+=("$1"); shift ;;
*) [ "$filter" -ge 0 ] || filter=${#args[@]}; args+=("$1"); shift ;;
esac
done
[ "$filter" -lt 0 ] || args[filter]="$binds${args[filter]}"
command jq "${args[@]}" || status=$?
[ "$nfiles" -eq 0 ] || rm -f "${files[@]}"
return "$status"
}
JOB=$(az containerapp job show \
--name "$AZURE_MIGRATE_JOB_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json)
az containerapp job secret list \
--name "$AZURE_MIGRATE_JOB_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--show-values \
--output json > "$JOB_SECRETS"
if [ -n "$WITHOUT_REDIS" ]; then
JOB=$(jq -c '.properties.configuration.secrets |= map(select(.name != "redis-url"))' <<< "$JOB")
FILTERED=$(jq -c 'map(select(.name != "redis-url"))' "$JOB_SECRETS")
printf '%s' "$FILTERED" > "$JOB_SECRETS"
unset FILTERED
fi
APP=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json)
az containerapp secret list \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--show-values \
--output json > "$APP_SECRETS"
APP_ID=$(jq -r '.id' <<< "$APP")
NAME_JSON=$(jq -n --arg n "$AZURE_APP_NAME" '$n')
APP_C="def app_i: (map(.name) | index($NAME_JSON)) // (if length == 1 then 0 else error(\"no container named \" + $NAME_JSON) end);"
APP_IMAGE="(properties.template.containers[?name=='$AZURE_APP_NAME'] || properties.template.containers)[0].image"
if ! jq -e "$APP_C"' .properties.template.containers | app_i == 0' <<< "$APP" > /dev/null 2>&1; then
echo "::error::the first container of $AZURE_APP_NAME must be the app container, named $AZURE_APP_NAME. main.tf ignores the env of the first container only. Put the app container first in main.tf, run terraform apply, and try again." >&2
exit 1
fi
SAVED_INGRESS=$(jq -c '.properties.configuration.ingress // null | if . then del(.fqdn) else . end' <<< "$APP")
INGRESS_TAG_NAMES='[.tags // {} | to_entries[] | select(.key | startswith("autumn-ingress-")) | .key]'
TAGGED_INGRESS=$(jq -c 'try ([.tags // {} | to_entries[]
| select((.key | startswith("autumn-ingress-")) and .value != null)
| {i: (.key | ltrimstr("autumn-ingress-") | tonumber), v: .value}]
| if length == 0 then null
else sort_by(.i) | map(.v) | join("") | @base64d | fromjson end) catch null' <<< "$APP")
[ "$TAGGED_INGRESS" = null ] || SAVED_INGRESS="$TAGGED_INGRESS"
COPIED=$(jq -c 'try ([.tags // {} | to_entries[]
| select((.key | startswith("autumn-copied-")) and .value != null)
| {i: (.key | ltrimstr("autumn-copied-") | tonumber), v: .value}]
| if length == 0 then {secrets: [], uids: []}
else sort_by(.i) | map(.v) | join("") | @base64d | fromjson end)
catch {secrets: [], uids: []}' <<< "$APP")
OLD_REVISION=$(jq -r '.properties.latestRevisionName // ""' <<< "$APP")
ACTIVE=""
for _ in $(seq 1 30); do
ACTIVE=$(az containerapp revision list \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "[?properties.active].[name, $APP_IMAGE]" \
--output tsv)
[ -n "$(cut -f1 <<< "$ACTIVE" | sed '/^$/d')" ] && break
sleep 10
done
if [ -z "$(cut -f1 <<< "$ACTIVE" | sed '/^$/d')" ]; then
echo "::error::$AZURE_APP_NAME has no active revision. The script cannot tell if a real release runs. Try again when a revision is active." >&2
exit 1
fi
RELEASED=""
REAL_ACTIVE=""
PLACEHOLDER_REVISIONS=""
while IFS=$'\t' read -r name image; do
[ -n "$name" ] || continue
if [ "$image" = "$BOOTSTRAP_IMAGE" ]; then
PLACEHOLDER_REVISIONS="$PLACEHOLDER_REVISIONS $name"
else
REAL_ACTIVE=1
fi
done <<< "$ACTIVE"
[ -n "$REAL_ACTIVE" ] && [ -z "$PLACEHOLDER_REVISIONS" ] && RELEASED=1
JOB_IDENTITY=$(jq -r --arg acr "$ACR_LOGIN_SERVER" \
'[.properties.configuration.registries // [] | .[] | select(.server == $acr)][0].identity // ""' <<< "$JOB")
JOB_UIDS=$(jq -c --arg id "$JOB_IDENTITY" '[$id] + [.[] | select(.keyVaultUrl) | .identity // empty]
| map(select(. != "" and ascii_downcase != "system")) | unique_by(ascii_downcase)' "$JOB_SECRETS")
if [ "$(tr '[:upper:]' '[:lower:]' <<< "$JOB_IDENTITY")" = system ]; then
echo "::error::the migration job pulls from $ACR_LOGIN_SERVER with its system identity. The app cannot use it. Give the job's ACR registry entry a user-assigned identity with AcrPull, and try again." >&2
exit 1
fi
SYSTEM_SECRETS=$(jq -r '[.[] | select(.keyVaultUrl and ((.identity // "") | ascii_downcase) == "system") | .name]
| join(", ")' "$JOB_SECRETS")
SECRET_REF='def secret_ref: if .keyVaultUrl then {name, keyVaultUrl, identity} else {name, value} end;'
JOB_SECRET_NAMES=$(jq -c '[.properties.configuration.secrets // [] | .[].name]' <<< "$JOB")
MANAGED_DEF="def managed: $(jq -c --argjson copied "$COPIED" '. + ["database-url", "signing-secret", "redis-url"]
+ ($copied.secrets // [] | map(select(type == "string"))) | unique' <<< "$JOB_SECRET_NAMES");"
REMOVE_UIDS=$(jq -c --argjson job "$JOB_UIDS" --argjson copied "$COPIED" --argjson app "$APP" \
--arg acr "$ACR_LOGIN_SERVER" "$MANAGED_DEF"'
$job + ($copied.uids // [])
+ [.[] | select(.keyVaultUrl and (.name | IN(managed[]))) | .identity // empty]
+ [$app.properties.configuration.registries // [] | .[] | select(.server == $acr) | .identity // empty]
| map(select(type == "string" and . != "" and ascii_downcase != "system")) | unique_by(ascii_downcase)' "$APP_SECRETS")
ACTIVE_TEMPLATES="[]"
for revision in $PLACEHOLDER_REVISIONS; do
TEMPLATE=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$revision" \
--query properties.template \
--output json) || { echo "::error::cannot read the template of revision $revision" >&2; exit 1; }
ACTIVE_TEMPLATES=$(jq -c --argjson t "${TEMPLATE:-null}" '. + [$t]' <<< "$ACTIVE_TEMPLATES")
done
SHARED_IDENTITY=$(jq -r --argjson app "$APP" --argjson uids "$REMOVE_UIDS" --arg acr "$ACR_LOGIN_SERVER" \
--arg name "$AZURE_APP_NAME" --argjson actives "$ACTIVE_TEMPLATES" "$MANAGED_DEF"'
($uids | map(ascii_downcase)) as $lids
| [(.[] | select(.keyVaultUrl and ((.identity // "" | ascii_downcase) | IN($lids[]))
and (.name | IN(managed[]) | not)) | "secret " + .name),
($app.properties.configuration.registries // [] | .[]
| select(.server != $acr and ((.identity // "" | ascii_downcase) | IN($lids[]))) | "registry " + .server),
# A sidecar or init container that pulls from the ACR needs the ACR
# entry, which credential removal drops.
([$app.properties.template] + $actives | map(select(. != null))
| [.[] | ((.containers // []) | map(select(.name != $name))) + (.initContainers // []) | .[]]
| map(select((.image // "") | startswith($acr + "/"))) | unique_by(.name) | .[]
| "container " + .name + " (image from " + $acr + ")"),
($app.properties.configuration.registries // [] | .[]
| select((.passwordSecretRef // "") | IN(managed[]))
| "registry " + .server + " (password secret " + .passwordSecretRef + ")"),
($app.properties.template.scale.rules // [] | .[]
| select([.. | objects | .identity? // empty | strings | ascii_downcase] | any(IN($lids[])))
| "scale rule " + .name)]
| join(", ")' "$APP_SECRETS")
shared_identity_stop() {
echo "::error::the app's $SHARED_IDENTITY use(s) an identity or a secret of the migration job. Credential removal would drop it. Give them their own identity or secret, and try again." >&2
exit 1
}
JOB_SECRET_REFS=$(jq -c '[.properties.configuration.secrets // [] | .[]
| {name, keyVaultUrl, identity: (.identity // "" | ascii_downcase)}]' <<< "$JOB")
STALE_REDIS=""
if jq -e 'any(.[]; .name == "redis-url")' "$APP_SECRETS" > /dev/null \
&& ! jq -e 'index("redis-url")' <<< "$JOB_SECRET_NAMES" > /dev/null; then
STALE_REDIS=1
fi
if [ "$MODE" = cutover ] && [ -n "$STALE_REDIS" ] \
&& jq -e '.properties.template | (.containers |= map(del(.env)))
| (.initContainers |= if . then map(del(.env)) else . end)
| [.. | objects | .secretRef? // empty] | index("redis-url")' <<< "$APP" > /dev/null; then
echo "::error::a scale rule or volume of $AZURE_APP_NAME uses redis-url. Remove that reference, then run the script again." >&2
exit 1
fi
if [ "$MODE" = cutover ] && [ -n "$STALE_REDIS" ] \
&& jq -e 'any(.properties.configuration.registries // [] | .[]; .passwordSecretRef == "redis-url")' \
<<< "$APP" > /dev/null; then
echo "::error::a registry of $AZURE_APP_NAME uses redis-url as its password. Give it its own secret, then run the script again." >&2
exit 1
fi
APPLIED='
($id | ascii_downcase) as $lid
| [.identity.userAssignedIdentities // {} | to_entries[]
| select(.value != null) | .key | ascii_downcase] as $ids
| [.properties.configuration.secrets // [] | .[]
| {name, keyVaultUrl, identity: (.identity // "" | ascii_downcase)}] as $have
| .properties.provisioningState == "Succeeded"
and ($uids | all(ascii_downcase as $u | $ids | index($u) != null))
and any(.properties.configuration.registries // [] | .[];
.server == $acr and (.identity // "" | ascii_downcase) == $lid)
and ($want | all(. as $s | $have | index([$s]) != null))'
REMOVED="$MANAGED_DEF"'
($ruids | map(ascii_downcase)) as $lids
| [.identity.userAssignedIdentities // {} | to_entries[]
| select(.value != null) | .key | ascii_downcase] as $ids
| .properties.provisioningState == "Succeeded"
and ($ids | all(IN($lids[]) | not))
and all(.properties.configuration.registries // [] | .[];
(.identity // "" | ascii_downcase) | IN($lids[]) | not)
and all(.properties.configuration.secrets // [] | .[];
.name | IN(managed[]) | not)'
INGRESS_OPEN='.properties.provisioningState == "Succeeded" and .properties.configuration.ingress.external == true'
INGRESS_CLOSED='.properties.provisioningState == "Succeeded" and .properties.configuration.ingress.external == false'
REDIS_GONE='
.properties.provisioningState == "Succeeded"
and all(.properties.configuration.secrets // [] | .[]; .name != "redis-url")'
REFS_MANAGED="$MANAGED_DEF"'[.properties.template | .. | objects | .secretRef? // empty]
| any(IN(managed[]))'
app_shows() {
local now
now=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json) || return 1
jq -e --arg id "$JOB_IDENTITY" --arg acr "$ACR_LOGIN_SERVER" \
--argjson uids "$JOB_UIDS" --argjson ruids "$REMOVE_UIDS" \
--argjson want "$JOB_SECRET_REFS" "$1" <<< "$now" > /dev/null
}
inline_secrets_applied() {
jq -e 'any(.[]; .keyVaultUrl == null)' "$JOB_SECRETS" > /dev/null || return 0
az containerapp secret list \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--show-values \
--output json > "$LIVE_SECRETS" || return 1
jq -e --slurpfile job "$JOB_SECRETS" '. as $live
| $job[0] | map(select(.keyVaultUrl == null))
| all(.[]; . as $s | any($live[]; .name == $s.name and .value == $s.value))' \
"$LIVE_SECRETS" > /dev/null
}
patch_app() {
printf '%s' "$1" > "$BODY"
az rest --method patch --url "$APP_ID?$API" --body "@$BODY" --output none
}
set_ingress() {
local body want now
body=$(jq -cn --argjson app "$APP" --argjson saved "$SAVED_INGRESS" --argjson external "$1" '
{location: $app.location, properties: {configuration: {ingress:
(($saved // {targetPort: 3000, transport: "http"}) | .external = $external)}}}')
patch_app "$body" || return 1
local check="$INGRESS_CLOSED"
[ "$1" = true ] && check="$INGRESS_OPEN"
want=$(jq -c '(. // {}) | with_entries(select(.key | IN("targetPort", "transport",
"customDomains", "ipSecurityRestrictions", "corsPolicy", "clientCertificateMode",
"stickySessions", "additionalPortMappings", "exposedPort", "allowInsecure")))' <<< "$SAVED_INGRESS")
for _ in $(seq 1 30); do
sleep 10
now=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json) || return 1
jq -e --argjson want "$want" "($check)"' and (.properties.configuration.ingress as $i
| $want | to_entries | all(($i[.key] // null) == (.value // null)))' <<< "$now" > /dev/null \
&& return 0
done
return 1
}
tags_show() {
local now
for _ in $(seq 1 30); do
now=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json) || return 1
jq -e --argjson want "$1" '(.tags // {}) as $t | $want | to_entries | all(.value == $t[.key])' \
<<< "$now" > /dev/null && return 0
sleep 10
done
return 1
}
save_ingress_tags() {
local tags
[ "$SAVED_INGRESS" = null ] && return 0
tags=$(jq -cn --argjson app "$APP" --argjson saved "$SAVED_INGRESS" '($app | '"$INGRESS_TAG_NAMES"') as $old
| ($saved | tojson | @base64) as $b
| [range(0; $b | length; 256) as $i | $b[$i:$i + 256]]
| (to_entries | map({key: ("autumn-ingress-" + (.key | tostring)), value}) | from_entries) as $new
| ($old | map(select(in($new) | not) | {(.): null}) | add // {}) + $new') || return 1
patch_app "$(jq -cn --argjson app "$APP" --argjson tags "$tags" '{location: $app.location, tags: $tags}')" \
|| return 1
tags_show "$tags"
}
clear_tags() {
local tags now
now=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json) || return 1
tags=$(jq -c --argjson prefixes "$1" '[.tags // {} | keys[] | select(. as $k | any($prefixes[]; . as $p | $k | startswith($p)))]
| map({(.): null}) | add // {}' <<< "$now")
[ "$tags" = "{}" ] && return 0
patch_app "$(jq -cn --argjson app "$APP" --argjson tags "$tags" '{location: $app.location, tags: $tags}')" \
&& tags_show "$tags"
}
restore_ingress() {
[ "$SAVED_INGRESS" = null ] && return 0
if ! set_ingress "$(jq -r '.external // false' <<< "$SAVED_INGRESS")"; then
echo "::error::cannot restore the ingress of $AZURE_APP_NAME. Run the script again, or restore it manually: its autumn-ingress-* tags hold the saved ingress (base64 JSON)." >&2
return 1
fi
clear_tags '["autumn-ingress-"]' || {
echo "::error::the ingress of $AZURE_APP_NAME is back, but its autumn-ingress-* tags stay. Remove them: a later run would send that saved ingress again." >&2
return 1
}
}
open_ingress() {
if [ -n "$RELEASED" ] && [ "$TAGGED_INGRESS" = null ] \
&& [ "$(jq -r '.properties.configuration.ingress.external // false' <<< "$APP")" = true ]; then
return 0
fi
set_ingress true
}
sole_active() {
local list
for _ in $(seq 1 30); do
list=$(az containerapp revision list \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "[?properties.active].[name, $APP_IMAGE]" \
--output tsv) || return 1
[ "$(cut -f1 <<< "$list" | sed '/^$/d')" = "$1" ] && return 0
sleep 10
done
return 1
}
remove_credentials() {
local live status state latest revision body containers inits source active actives template first patch marker
marker="$(date +%s)-$$-$RANDOM"
live=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json) || return 1
active=$(jq -c '.properties.template' <<< "$APP")
actives=$(jq -c '[.]' <<< "$active")
first=""
for revision in $PLACEHOLDER_REVISIONS; do
template=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$revision" \
--query properties.template \
--output json) || return 1
if [ -z "$first" ]; then
first="$revision"
active="$template"
actives="[]"
fi
actives=$(jq -c --argjson t "$template" '. + [$t]' <<< "$actives")
done
source=$(jq -c '.containers' <<< "$active")
containers=$(jq -c "$APP_C$MANAGED_DEF"'
map(if .env then .env = [.env[] | select((.secretRef // "")
| IN(managed[]) | not)] else . end)
| app_i as $i
| .[$i].env = [(.[$i].env // [])[] | select(.name | IN(
"AUTUMN_DATABASE__PRIMARY_URL", "AUTUMN_SECURITY__SIGNING_SECRET",
"AUTUMN_CACHE__BACKEND", "AUTUMN_CACHE__REDIS__URL") | not)]' <<< "$source")
inits=$(jq -c "$MANAGED_DEF"'
(.initContainers // [])
| map(if .env then .env = [.env[] | select((.secretRef // "") | IN(managed[]) | not)] else . end)' <<< "$active")
if jq -c --argjson c "$containers" --argjson i "$inits" \
'.properties.template.containers = $c | .properties.template.initContainers = $i' <<< "$APP" \
| jq -e "$REFS_MANAGED" > /dev/null; then
echo "::error::a scale rule or volume of $AZURE_APP_NAME uses a managed secret. Remove that reference, then run the script again." >&2
return 1
fi
if jq -e "$REFS_MANAGED" <<< "$live" > /dev/null \
|| jq -e --argjson uids "$REMOVE_UIDS" "$MANAGED_DEF"'
($uids | map(ascii_downcase)) as $lids
| ([.. | objects | .secretRef? // empty] | any(IN(managed[])))
or ([.[] | .scale.rules // [] | .. | objects | .identity? // empty | strings | ascii_downcase]
| any(IN($lids[])))' <<< "$actives" > /dev/null; then
status=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "[[properties.provisioningState, properties.latestRevisionName]]" \
--output tsv) || return 1
read -r _ latest <<< "$status"
revision="$latest"
patch=""
if jq -e "$REFS_MANAGED" <<< "$live" > /dev/null; then
patch=1
else
state=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$latest" \
--query properties.provisioningState \
--output tsv) || return 1
{ [ "$state" = Failed ] || [ "$state" = Canceled ]; } && patch=1
fi
if [ -n "$patch" ]; then
body=$(jq -cn --argjson app "$APP" --argjson containers "$containers" --argjson inits "$inits" \
--arg now "$(date +%s)" "$APP_C"'
{location: $app.location, properties: {template: {containers: ($containers
| app_i as $i
| .[$i].env = [(.[$i].env // [])[] | select(.name != "AUTUMN_CREDENTIAL_CLEANUP")]
+ [{name: "AUTUMN_CREDENTIAL_CLEANUP", value: $now}]),
initContainers: $inits}}}')
patch_app "$body" || return 1
revision=""
fi
state=""
for _ in $(seq 1 30); do
if [ -z "$revision" ]; then
sleep 10
status=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "[[properties.provisioningState, properties.latestRevisionName]]" \
--output tsv) || return 1
read -r state revision <<< "$status"
if [ "$state" != Succeeded ] || [ -z "$revision" ] || [ "$revision" = "$latest" ]; then
revision=""
continue
fi
fi
state=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$revision" \
--query properties.provisioningState \
--output tsv) || return 1
case "$state" in
Provisioned) break ;;
Failed|Canceled) return 1 ;;
esac
sleep 10
done
[ "$state" = Provisioned ] || return 1
sole_active "$revision" || return 1
fi
body=$(jq -cn --argjson app "$APP" --argjson live "$live" --slurpfile app_secrets "$APP_SECRETS" \
--argjson uids "$REMOVE_UIDS" --arg marker "$marker" "$SECRET_REF$MANAGED_DEF"'
($uids | map(ascii_downcase)) as $lids
# The cutover never turns the system identity on, so it stays.
| ($live.identity.type // "None" | test("SystemAssigned")) as $keep_system
| ($app.identity.userAssignedIdentities // {}) as $ids
| ($ids | with_entries(select(.key | ascii_downcase | IN($lids[]) | not)) | map_values({})) as $own
# The snapshot can predate the cutover, so it can lack a job identity.
# Merge-patch keeps an omitted key, so the body always names each one:
# with the app spelling if the snapshot has one, else as the job has it.
| ($uids | map(. as $u
| ($ids | with_entries(select(.key | ascii_downcase == ($u | ascii_downcase))) | map_values(null))
| if . == {} then {($u): null} else . end) | add // {}) as $drop
| {location: $app.location,
identity: (if $own != {}
then {type: (if $keep_system then "SystemAssigned,UserAssigned" else "UserAssigned" end),
userAssignedIdentities: ($drop + $own)}
elif $keep_system then {type: "SystemAssigned", userAssignedIdentities: null}
else {type: "None", userAssignedIdentities: null} end),
properties: {
configuration: {
registries: [($app.properties.configuration.registries // [])[]
| select((.identity // "" | ascii_downcase) | IN($lids[]) | not)],
secrets: ($app_secrets[0]
| map(select(.name | IN(managed[]) | not)
| secret_ref))}}}
# The record of what a first cutover copied goes with the credentials.
+ ([$live.tags // {} | keys[] | select(startswith("autumn-copied-"))] as $copied
| if $copied == [] then {} else {tags: ($copied | map({(.): null}) | add)} end)
# A marker of this PATCH. A read can still show the state before an
# earlier PATCH (a cutover whose response was lost): the placeholder
# without credentials. Only a read that shows the marker proves that
# this PATCH, which Azure applies after the earlier one, applied.
| .tags = ((.tags // {}) + {"autumn-cleanup": $marker})')
patch_app "$body" || return 1
for _ in $(seq 1 30); do
sleep 10
if app_shows "$REMOVED"' and (.tags["autumn-cleanup"] // "") == "'"$marker"'"'; then
clear_tags '["autumn-cleanup"]' \
|| echo "::warning::remove the autumn-cleanup tag from $AZURE_APP_NAME" >&2
return 0
fi
done
return 1
}
rollback() {
[ -n "$RELEASED" ] && return
if [ -n "$OPENED" ] && ! az containerapp ingress disable \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output none; then
echo "::error::rollback stopped: cannot disable the ingress of $AZURE_APP_NAME. The app keeps its credentials. Disable its ingress, then run the script with --remove-credentials." >&2
return
fi
echo "::warning::removing the credentials from the placeholder app" >&2
if remove_credentials; then
restore_ingress
return
fi
echo "::error::rollback failed: remove the identity and secrets from $AZURE_APP_NAME manually" >&2
}
COPIED_RECORD=$(jq -cn --argjson names "$JOB_SECRET_NAMES" --argjson uids "$REMOVE_UIDS" \
--argjson copied "$COPIED" '{secrets: ($names + ($copied.secrets // []) | unique), uids: $uids}')
check_tag_budget() {
local total
total=$(jq -n --argjson app "$APP" --argjson saved "$SAVED_INGRESS" --argjson copied_parts "$1" '
def parts(x): if x == null then 0 else ((x | tojson | @base64 | length) + 255) / 256 | floor end;
([$app.tags // {} | keys[]
| select((startswith("autumn-ingress-") or startswith("autumn-copied-")) | not)] | length)
+ parts($saved) + $copied_parts')
if [ "$total" -gt 50 ]; then
echo "::error::$AZURE_APP_NAME needs $total tags (its own, the saved ingress and the record of copied credentials), and Azure allows 50. Remove some tags from the app, and try again." >&2
exit 1
fi
}
COPIED_PARTS=$(jq -n --argjson r "$COPIED_RECORD" '((($r | tojson | @base64 | length) + 255) / 256) | floor')
EXISTING_COPIED_PARTS=$(jq '[.tags // {} | keys[] | select(startswith("autumn-copied-"))] | length' <<< "$APP")
if [ "$MODE" = remove ]; then
if [ -n "$REAL_ACTIVE" ]; then
echo "::error::$AZURE_APP_NAME runs a real release. It needs its credentials." >&2
exit 1
fi
if [ -z "$JOB_IDENTITY" ]; then
echo "::error::the migration job has no identity. Run terraform apply first." >&2
exit 1
fi
[ -z "$SHARED_IDENTITY" ] || shared_identity_stop
check_tag_budget "$(( EXISTING_COPIED_PARTS > 1 ? EXISTING_COPIED_PARTS : 1 ))"
if [ "$(jq -r '.properties.configuration.ingress != null' <<< "$APP")" = true ]; then
save_ingress_tags \
|| { echo "::error::cannot save the ingress of $AZURE_APP_NAME in its tags" >&2; exit 1; }
az containerapp ingress disable \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output none || {
echo "::error::cannot disable the ingress of $AZURE_APP_NAME" >&2
restore_ingress
exit 1
}
fi
remove_credentials || {
echo "::error::cannot remove the credentials from $AZURE_APP_NAME. Its ingress stays disabled; the next run restores it from the app's tags." >&2
exit 1
}
restore_ingress || {
echo "::error::the credentials are removed from $AZURE_APP_NAME. See the error above." >&2
exit 1
}
echo "removed the credentials from the placeholder app $AZURE_APP_NAME"
exit 0
fi
OPENED=""
fail() {
echo "::error::$1" >&2
rollback
exit 1
}
if [ -z "$JOB_IDENTITY" ] \
|| ! jq -e '(.properties.configuration.secrets // []) | length > 0' <<< "$JOB" > /dev/null; then
echo "::error::the migration job has no identity or secrets to copy. Run terraform apply first." >&2
exit 1
fi
if [ -z "$RELEASED" ] && [ -n "$SHARED_IDENTITY" ]; then
shared_identity_stop
fi
if [ -n "$SYSTEM_SECRETS" ]; then
echo "::error::the job secret(s) $SYSTEM_SECRETS use the job's system identity. The app's system identity is another principal, without access to the vault. Give the secret a user-assigned identity that can read the vault, and try again." >&2
exit 1
fi
if [ -z "$RELEASED" ]; then
CUSTOM_RULE='[(.rules // []) | .[] | select(.http == null)] | length > 0'
MIN_REPLICAS='(.minReplicas // 0) > 0'
RULE_AT=""
MIN_AT=""
jq -e ".properties.template.scale // {} | $CUSTOM_RULE" <<< "$APP" > /dev/null && RULE_AT="the template"
jq -e ".properties.template.scale // {} | $MIN_REPLICAS" <<< "$APP" > /dev/null && MIN_AT="the template"
for revision in $PLACEHOLDER_REVISIONS; do
SCALE=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$revision" \
--query properties.template.scale \
--output json) || { echo "::error::cannot read the scale settings of revision $revision" >&2; exit 1; }
jq -e "(. // {}) | $CUSTOM_RULE" <<< "${SCALE:-null}" > /dev/null && RULE_AT="revision $revision"
jq -e "(. // {}) | $MIN_REPLICAS" <<< "${SCALE:-null}" > /dev/null && MIN_AT="revision $revision"
done
if [ -n "$RULE_AT" ]; then
echo "::error::$RULE_AT of the placeholder has a scale rule that is not HTTP. It can start the placeholder after the script disables ingress. Remove the rule, wait until no active revision has it, and run the script again." >&2
exit 1
fi
if [ -n "$MIN_AT" ]; then
echo "::error::$MIN_AT of the placeholder has min_replicas above zero. Azure then runs the placeholder after the script disables ingress. Set min_replicas = 0, run terraform apply, wait until no active revision has it, and run the script again." >&2
exit 1
fi
stop_before_patch() {
echo "::error::$1" >&2
restore_ingress
exit 1
}
check_tag_budget "$COPIED_PARTS"
save_ingress_tags \
|| { echo "::error::cannot save the ingress of $AZURE_APP_NAME in its tags. The script stops before it disables ingress." >&2; exit 1; }
az containerapp ingress disable \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output none || stop_before_patch "cannot disable the ingress of $AZURE_APP_NAME"
for _ in $(seq 1 60); do
REPLICAS=0
for revision in $PLACEHOLDER_REVISIONS; do
COUNT=$(az containerapp replica list \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$revision" \
--query "length(@)" \
--output tsv) || stop_before_patch "cannot list the replicas of revision $revision"
REPLICAS=$((REPLICAS + COUNT))
done
[ "$REPLICAS" = "0" ] && break
sleep 10
done
if [ "$REPLICAS" != "0" ]; then
stop_before_patch "the placeholder runs $REPLICAS replica(s). Set min_replicas = 0, run terraform apply, and try again."
fi
fi
CONTAINERS=$(jq -cn --argjson job "$JOB" --argjson app "$APP" --arg image "$IMAGE" "$APP_C"'
any(($job.properties.configuration.secrets // [])[]; .name == "redis-url") as $redis
| ([{name: "AUTUMN_DATABASE__PRIMARY_URL", secretRef: "database-url"},
{name: "AUTUMN_SECURITY__SIGNING_SECRET", secretRef: "signing-secret"}]
+ if $redis
then [{name: "AUTUMN_CACHE__BACKEND", value: "redis"},
{name: "AUTUMN_CACHE__REDIS__URL", secretRef: "redis-url"}]
else [] end) as $env
| $app.properties.template.containers
# Without Redis, no env var in any container may refer to redis-url.
| (if $redis then . else
map(if .env then .env = [.env[] | select(.secretRef != "redis-url")] else . end)
end)
| app_i as $i
| .[$i] |= (.image = $image
| .env = ([(.env // [])[] | select(.name | IN(
"AUTUMN_DATABASE__PRIMARY_URL", "AUTUMN_SECURITY__SIGNING_SECRET",
"AUTUMN_CACHE__BACKEND", "AUTUMN_CACHE__REDIS__URL",
"AUTUMN_CREDENTIAL_CLEANUP", "AUTUMN_FORCE_REVISION") | not)] + $env))
') || fail "cannot build the new containers"
INIT_CONTAINERS=$(jq -cn --argjson job "$JOB" --argjson app "$APP" '
any(($job.properties.configuration.secrets // [])[]; .name == "redis-url") as $redis
| $app.properties.template.initContainers
| if . == null or $redis then . else
map(if .env then .env = [.env[] | select(.secretRef != "redis-url")] else . end)
end') || fail "cannot build the new init containers"
RESTART=""
if jq -en --argjson app "$APP" --argjson containers "$CONTAINERS" --argjson inits "$INIT_CONTAINERS" \
'$app.properties.template.containers == $containers
and $app.properties.template.initContainers == $inits' > /dev/null; then
LATEST_STATE=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$OLD_REVISION" \
--query properties.provisioningState \
--output tsv) || fail "cannot read revision $OLD_REVISION"
if [ "$LATEST_STATE" = Failed ] || [ "$LATEST_STATE" = Canceled ]; then
CONTAINERS=$(jq -c --arg now "$(date +%s)" \
"$APP_C"' .[app_i].env += [{name: "AUTUMN_FORCE_REVISION", value: $now}]' <<< "$CONTAINERS")
else
OLD_REVISION=""
RESTART=1
fi
fi
COPIED_TAGS='{}'
if [ -z "$RELEASED" ]; then
COPIED_TAGS=$(jq -cn --argjson app "$APP" --argjson record "$COPIED_RECORD" '
[$app.tags // {} | keys[] | select(startswith("autumn-copied-"))] as $old
| ($record | tojson | @base64) as $b
| [range(0; $b | length; 256) as $i | $b[$i:$i + 256]]
| (to_entries | map({key: ("autumn-copied-" + (.key | tostring)), value}) | from_entries) as $new
| ($old | map(select(in($new) | not) | {(.): null}) | add // {}) + $new') \
|| stop_before_patch "cannot record the copied credentials in the tags of $AZURE_APP_NAME"
fi
PATCH=$(jq -cn --argjson app "$APP" --slurpfile app_secrets "$APP_SECRETS" --slurpfile job_secrets "$JOB_SECRETS" \
--arg id "$JOB_IDENTITY" --arg acr "$ACR_LOGIN_SERVER" --argjson containers "$CONTAINERS" \
--argjson uids "$JOB_UIDS" --argjson copied_tags "$COPIED_TAGS" --argjson inits "$INIT_CONTAINERS" "$SECRET_REF"'
($job_secrets[0] | map(secret_ref)) as $job_secrets
| if $id == "" or ($job_secrets | length) == 0
then error("the migration job has no identity or secrets to copy")
else . end
| ($job_secrets | map(.name)) as $job_names
| ($app_secrets[0]
| map(select(.name as $n | $job_names | index($n) | not) | secret_ref)
+ $job_secrets) as $secrets
| {location: $app.location,
identity: {
type: (if ($app.identity.type // "None" | test("SystemAssigned"))
then "SystemAssigned,UserAssigned" else "UserAssigned" end),
userAssignedIdentities: ($uids | map({(.): {}}) | add)},
properties: {
configuration: {
registries: ([($app.properties.configuration.registries // [])[]
| select(.server != $acr)] + [{server: $acr, identity: $id}]),
secrets: $secrets},
template: {containers: $containers}}}
+ (if $copied_tags == {} then {} else {tags: $copied_tags} end)
| if $inits == null then . else .properties.template.initContainers = $inits end
') || fail "cannot build the cutover PATCH"
patch_app "$PATCH" || fail "the cutover PATCH failed"
READY=""
MOVED=""
for _ in $(seq 1 60); do
sleep 10
STATUS=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "[[properties.provisioningState, properties.latestRevisionName]]" \
--output tsv) || fail "cannot read the app state"
read -r STATE REVISION <<< "$STATUS"
case "$STATE" in
Failed|Canceled)
if [ -n "$MOVED" ] || { [ -n "$OLD_REVISION" ] && [ -n "$REVISION" ] && [ "$REVISION" != "$OLD_REVISION" ]; }; then
fail "the app update ended as $STATE"
fi
continue
;;
*) MOVED=1 ;;
esac
if [ "$STATE" != "Succeeded" ] || [ -z "$REVISION" ] || [ "$REVISION" = "$OLD_REVISION" ]; then
continue
fi
app_shows "$APPLIED" || continue
inline_secrets_applied || continue
REVISION_STATUS=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$REVISION" \
--query "[[properties.provisioningState, $APP_IMAGE]]" \
--output tsv) || fail "cannot read revision $REVISION"
read -r REVISION_STATE REVISION_IMAGE <<< "$REVISION_STATUS"
case "$REVISION_STATE" in
Provisioned)
[ "$REVISION_IMAGE" = "$IMAGE" ] || fail "revision $REVISION runs $REVISION_IMAGE, not $IMAGE"
READY=1
break
;;
Failed|Canceled) fail "revision $REVISION ended as $REVISION_STATE" ;;
esac
done
[ -n "$READY" ] || fail "the new revision was not provisioned in time (app state: $STATE)"
REVISION_TEMPLATE=$(az containerapp revision show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$REVISION" \
--query properties.template \
--output json) || fail "cannot read the template of revision $REVISION"
APP_NOW=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--output json) || fail "cannot read the secrets of $AZURE_APP_NAME"
APP_SECRET_NAMES=$(jq -c '[.properties.configuration.secrets // [] | .[].name]' <<< "$APP_NOW")
MISSING_REFS=$(jq -r --argjson names "$APP_SECRET_NAMES" '
[((.containers // []) + (.initContainers // []))[] | (.env // [])[]
| .secretRef // empty | select(IN($names[]) | not)]
| unique | join(", ")' <<< "$REVISION_TEMPLATE")
[ -z "$MISSING_REFS" ] \
|| fail "revision $REVISION refers to secret(s) $MISSING_REFS, which $AZURE_APP_NAME does not have. Azure can rewrite secret refs (azure-container-apps issue 1705)."
sole_active "$REVISION" || fail "revision $REVISION did not become the only active revision"
OPENED=1
open_ingress || fail "cannot open external ingress"
FQDN=$(az containerapp show \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query properties.configuration.ingress.fqdn \
--output tsv) || FQDN=""
replicas() {
az containerapp replica list \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$REVISION" \
--output json
}
BEFORE='[]'
if [ -n "$RESTART" ]; then
BEFORE=$(replicas | jq -c 'map(.name)') || fail "cannot list the replicas of revision $REVISION"
az containerapp revision restart \
--name "$AZURE_APP_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--revision "$REVISION" \
--output none || fail "cannot restart revision $REVISION"
fi
STARTED=""
for _ in $(seq 1 30); do
sleep 10
NOW=$(replicas) || fail "cannot list the replicas of revision $REVISION"
if jq -e 'length == 0' <<< "$NOW" > /dev/null && [ -n "$FQDN" ]; then
curl --silent --output /dev/null --max-time 10 "https://$FQDN/" || true
fi
if jq -e --argjson before "$BEFORE" '
(map(.name) | any(IN($before[])) | not)
and any(.[]; .properties.runningState == "Running"
and ((.properties.containers // []) | length > 0 and all(.ready == true)))
' <<< "$NOW" > /dev/null; then
STARTED=1
break
fi
done
[ -n "$STARTED" ] || fail "revision $REVISION has no ready replica. If the app's address is not reachable from this runner (an internal environment) and min_replicas = 0, the script cannot start one: set min_replicas to 1 or more, and try again."
if [ -n "$STALE_REDIS" ]; then
ACTIVE=true
if [ -n "$OLD_REVISION" ]; then
sole_active "$REVISION" && ACTIVE=false
else
ACTIVE=false
fi
CLEANUP=$(jq -cn --argjson app "$APP" --slurpfile app_secrets "$APP_SECRETS" --slurpfile job_secrets "$JOB_SECRETS" \
"$SECRET_REF"'
($job_secrets[0] | map(secret_ref)) as $job_secrets
| ($job_secrets | map(.name) + ["redis-url"]) as $drop
| {location: $app.location,
properties: {configuration: {secrets: (($app_secrets[0]
| map(select(.name as $n | $drop | index($n) | not) | secret_ref)) + $job_secrets)}}}')
REMOVED_REDIS=""
if [ "$ACTIVE" = false ] && patch_app "$CLEANUP"; then
for _ in $(seq 1 30); do
sleep 10
if app_shows "$REDIS_GONE"; then REMOVED_REDIS=1; break; fi
done
fi
if [ -n "$REMOVED_REDIS" ]; then
echo "removed the unused redis-url secret"
else
echo "::warning::remove the unused redis-url secret from $AZURE_APP_NAME when no revision uses it" >&2
fi
fi
if ! clear_tags '["autumn-ingress-", "autumn-copied-"]'; then
echo "::error::$AZURE_APP_NAME runs $IMAGE, but its autumn-ingress-* and autumn-copied-* tags stay. Remove them: a later run would send that saved ingress again, and a credential removal would use that record." >&2
exit 1
fi
echo "cutover complete: $AZURE_APP_NAME runs $IMAGE"