autumn-cli 0.10.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
#!/usr/bin/env bash
# azure-cutover.sh — generated by `autumn release init --target azure-container-apps`
#
# Sets the real image on the {{project_name}} Container App (#2314).
#
# Terraform creates the app with no managed identity, registry or secret refs,
# so the public placeholder image never gets production credentials. This
# script copies them from the migration job. It sends them and the real image
# in ONE write (an ARM merge-PATCH). Separate `az containerapp identity
# assign` / `secret set` calls apply to all revisions, so the placeholder
# would hold the identity until the image update completes.
#
# The identity applies to all revisions, and Azure keeps the old revision
# active until the new one is ready. So the first cutover disables ingress
# (also the internal route), and starts only when the placeholder revision
# has no running replica. Without ingress, with min_replicas = 0 and only
# the HTTP scale rule, nothing can start it again.
#
# Then the script waits for the new revision. It opens external ingress
# (#2312) only when that revision runs the real image. If the FIRST cutover
# fails, the script removes the credentials from the app again. If the
# current revision stays (the same image and env), the script restarts it,
# because a revision reads changed secrets only when it starts.
#
# An app made by an older template has the credentials while it runs the
# placeholder, and terraform apply does not remove them. To remove them, run
# `bash azure-cutover.sh --remove-credentials`. It does nothing to an app that
# runs a real release.
#
# To turn Redis off, run `bash azure-cutover.sh --without-redis` with the
# tag that runs now, then set enable_redis_cache = false and run terraform
# apply. The app stops using Redis before Terraform deletes the cache.
#
# azure-deploy.yml runs this script after migrations. To run it manually, set
# these variables. All except IMAGE_TAG come from `terraform output`. Then run
# `bash azure-cutover.sh`.
#
#   AZURE_APP_NAME, AZURE_RESOURCE_GROUP, AZURE_MIGRATE_JOB_NAME,
#   ACR_LOGIN_SERVER, IMAGE_TAG (the tag you pushed to ACR; not used by
#   --remove-credentials)
#
# If you changed bootstrap_image in Terraform, also set
# AZURE_BOOTSTRAP_IMAGE to that image. The script treats only a revision
# that runs it as the placeholder.
#
# Needs: az (logged in), jq and curl.

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

# The script checks its tools before it changes anything. curl sends the
# request that starts a replica of a revision scaled to zero after a restart.
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)}"
# The placeholder image (bootstrap_image in variables.tf).
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"
# Secret values go through these private files, never through a process
# argument (visible in /proc/*/cmdline).
# An explicit template in $TMPDIR: GNU and BSD (macOS) mktemp both make it
# with mode 0600, in the same place.
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")
# A private directory (mode 0700) for the JSON values of jq calls.
JQ_VARS=$(mktemp -d "$TMP_TEMPLATE")
trap 'rm -f "$BODY" "$APP_SECRETS" "$JOB_SECRETS" "$LIVE_SECRETS"; rm -rf "$JQ_VARS"' EXIT

# Every jq call goes through this function. The app and job JSON hold the
# plain env values of their containers, so a --argjson value goes through a
# private file, not the command line. printf is a builtin, so the value never
# becomes a process argument. The filter binds the name to the file's value,
# so the jq programs read it as before.
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)
# GET omits secret values. The job can have an inline secret (a name and a
# value), so the script reads the job's secrets with their values too.
az containerapp job secret list \
  --name "$AZURE_MIGRATE_JOB_NAME" \
  --resource-group "$AZURE_RESOURCE_GROUP" \
  --show-values \
  --output json > "$JOB_SECRETS"
# To turn Redis off, the app stops using it first, while the cache still
# exists. Then terraform apply deletes the cache and the job's redis-url.
if [ -n "$WITHOUT_REDIS" ]; then
  JOB=$(jq -c '.properties.configuration.secrets |= map(select(.name != "redis-url"))' <<< "$JOB")
  # The filtered list goes back into the same private file (mode 0600 from
  # mktemp). printf is a builtin, so the values are not in a process
  # argument either.
  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)
# GET omits secret values. Send the app's own secrets back with their values,
# because the PATCH replaces the whole secrets array.
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")
# The ingress as it is now. The first cutover disables ingress, and the CLI
# "ingress enable" command would build a new ingress object without custom
# domains, IP restrictions or CORS. So the script sends this saved
# object back. fqdn is read-only.
# The app container is the one named after the app (main.tf names it so).
# A template with one container uses that one. It must be the first
# container: main.tf ignores the env of the first container only, so with a
# sidecar first, a later terraform apply removes the secret env vars.
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")
# Before the first cutover disables ingress, the script also saves the
# snapshot in the app's tags (base64 JSON, in parts of 256 characters, the
# tag value limit). If a run stops after the disable (a canceled run, a lost
# runner), the retry reads the snapshot from the tags. The tags win over
# the live ingress: after the stop, terraform apply can make a plain
# ingress again, without the custom domains, CORS or IP restrictions.
# shellcheck disable=SC2016 # a jq program, not a shell expression
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"
# The first cutover also records, in its PATCH, the secret names and
# identities that it copies (autumn-copied-* tags). A rollback after an
# interrupted run removes them, also when the job no longer has them.
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")
# The active revisions decide if a real release runs. The template image is
# not enough: after a canceled first cutover, the template has the real
# image while the placeholder revision stays active. Only the bootstrap
# image is the placeholder; a release from any registry is a real release.
# The app is released only when no placeholder revision is active: during a
# handoff, the placeholder and the new revision are both active, and the
# first-cutover safeguards and rollback still apply.
# During a handoff, Azure can list no active revision. That does not tell a
# placeholder from a real release, so the script reads the list again, and
# stops before any write if it stays empty.
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
# The identity of the job's ACR registry entry. The job can list other
# registries too, so the script selects the entry by server.
JOB_IDENTITY=$(jq -r --arg acr "$ACR_LOGIN_SERVER" \
  '[.properties.configuration.registries // [] | .[] | select(.server == $acr)][0].identity // ""' <<< "$JOB")
# The identities that the app needs: the ACR identity, and each
# user-assigned identity that a Key Vault job secret uses. A job secret on
# the job's system identity cannot move: the app's system identity is
# another principal, without access to the vault. The cutover stops on it.
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")
# The job can pull from the ACR with its system identity. "system" is not a
# user-assigned identity, and the app's system identity has no AcrPull, so
# the cutover cannot copy it. The script stops before any write.
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")
# A secret in PATCH form: a Key Vault ref, or a name and value.
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")
# The managed secrets: every secret of the migration job (the cutover copies
# them all to the app) and the three that main.tf makes. Credential removal
# and rollback remove all of them.
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");"
# The identities that credential removal drops: the job's, the ones that an
# earlier first cutover recorded, and the ones that the app's managed
# secrets and its ACR entry use (an older placeholder can use an identity
# that the job no longer uses; it can still read the vault).
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")
# The app's own (unmanaged) secrets and other registries that use a job
# identity. Credential removal drops that identity, which breaks them, and
# keeping it would keep vault access on the placeholder. So the script
# stops before a first cutover (its rollback removes the credentials) or a
# removal, and names them.
# The templates of the active placeholder revisions: stage 1 of credential
# removal starts from them, so their sidecars count too.
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")
# Redis is off now, but the app still has the redis-url secret. The old
# revision refers to it, so the script removes it only after that revision
# stops.
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
# The cutover removes redis-url from every env var (of the containers and
# the init containers). A scale rule or a volume can also refer to it, and
# the script cannot remove that ref without changing what the app does, so
# it stops before any write.
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
# A registry can use redis-url as its password. Without that secret it could
# not pull, so the script stops before any write here too.
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

# A PATCH can return 202 Accepted, and a GET can still show the old state.
# So the script reads the app until it shows the change. These checks use
# the app GET, which has no secret values.
# shellcheck disable=SC2016 # a jq program, not a shell expression
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))'
# shellcheck disable=SC2016 # a jq program, not a shell expression
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)'
# shellcheck disable=SC2016 # a jq program, not a shell expression
INGRESS_OPEN='.properties.provisioningState == "Succeeded" and .properties.configuration.ingress.external == true'
# shellcheck disable=SC2016 # a jq program, not a shell expression
INGRESS_CLOSED='.properties.provisioningState == "Succeeded" and .properties.configuration.ingress.external == false'
# shellcheck disable=SC2016 # a jq program, not a shell expression
REDIS_GONE='
  .properties.provisioningState == "Succeeded"
  and all(.properties.configuration.secrets // [] | .[]; .name != "redis-url")'
# True when the template (any container, scale rule or volume) refers to a
# managed secret.
# shellcheck disable=SC2016 # a jq program, not a shell expression
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
}

# The app GET has no secret values, so a rotated inline job secret looks
# applied before it is. This reads the app's secrets with their values into
# a private file and checks that each inline job secret has the job's value.
# The values never appear in a process argument.
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
}

# A PATCH body goes in a file, not on the command line: it can hold secret
# values. `--body @file` reads it.
patch_app() {
  printf '%s' "$1" > "$BODY"
  az rest --method patch --url "$APP_ID?$API" --body "@$BODY" --output none
}

# Sends the saved ingress back with the given external value, and waits until
# the app shows it. Without a saved ingress, it uses the port and transport
# that main.tf sets.
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"
  # The settings of the saved ingress must show too, not only external: the
  # app can show an open ingress before the PATCH applies (a plain one that
  # terraform apply made), and the caller then removes the snapshot tags.
  # The snapshot comes from a GET, so its values have the form Azure shows.
  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
}

# Waits until the app's tags show the given values (null: no tag).
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
}

# Saves the ingress snapshot in the app's tags. Parts that an earlier,
# longer snapshot left are removed.
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"
}

# Removes the tags with the given prefixes (a JSON array): the ingress
# snapshot after the ingress is back, and the copied record after a
# successful cutover. It fails when they stay: a later run prefers the
# snapshot in the tags, so a stale one would overwrite later changes.
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"
}

# Sends the saved ingress back, as it was, then removes the snapshot 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
  }
}

# Opens external ingress. On a later deploy whose live ingress is open,
# nothing changes, so the script sends nothing. The check reads the live
# app, not the snapshot: after an interrupted first cutover, the snapshot
# from the tags can say external while the app has no ingress. With such a
# snapshot, the script always sends it: terraform apply can have made a
# plain, open ingress without the custom domains, CORS or IP restrictions,
# and the end of the run removes the tags.
open_ingress() {
  if [ -n "$RELEASED" ] && [ "$TAGGED_INGRESS" = null ] \
    && [ "$(jq -r '.properties.configuration.ingress.external // false' <<< "$APP")" = true ]; then
    return 0
  fi
  set_ingress true
}

# Waits until the given revision is the only active one. Provisioned is not
# ready: Azure keeps the old revision active until the new one scales and
# passes its probes. The script checks 30 times, 10 seconds apart.
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
    # The list must name only this revision. An empty list (during the
    # handoff) does not show that it serves.
    [ "$(cut -f1 <<< "$list" | sed '/^$/d')" = "$1" ] && return 0
    sleep 10
  done
  return 1
}

# Removes the job's credentials from the app: its identity, the registry
# entry that uses that identity, the managed secrets (every job secret and
# the three that main.tf makes), the env vars that refer to them, and the
# four env vars that this script sets. Other identities, registries,
# secrets, env vars and containers stay.
#
# Azure deletes a secret only when no active revision refers to it. So if
# the current template refers to one, stage 1 deploys the snapshot
# containers without the four env vars, and waits until that revision is
# provisioned and no other revision is active. Stage 2 then removes the
# identity, registry and secrets, and waits until the app shows it.
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
  # Stage 1 starts from the active revision's containers. After a canceled
  # first cutover, the app template has the real image, which cannot start
  # without its secrets; the active revision still runs the placeholder.
  # The script reads the complete template of every active placeholder
  # revision (during a handoff there can be more than one): scale rules and
  # volumes can also refer to a secret or an identity.
  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")
  # The stage 1 containers: no env var that refers to a managed secret (in
  # any container), and not the four env vars that this script sets.
  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")
  # The init containers the same way: no env var that refers to a managed
  # secret.
  inits=$(jq -c "$MANAGED_DEF"'
    (.initContainers // [])
    | map(if .env then .env = [.env[] | select((.secretRef // "") | IN(managed[]) | not)] else . end)' <<< "$active")
  # A scale rule or a volume can also refer to a secret. The script cannot
  # remove that ref without changing what the app does, so it stops.
  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
  # Stage 1 runs when the desired template or an active placeholder
  # revision (in a container, a scale rule or a volume) refers to a managed
  # secret, or when a scale rule of an active revision uses a job identity.
  # If only an active revision does, the template is clean already (an
  # earlier run, or the operator): the script waits for the clean revision.
  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"
    # A PATCH is needed when the template still refers to a secret, or when
    # the clean revision of an earlier run failed. The marker env var makes
    # the template differ, so Azure makes a fresh revision.
    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
      # A failed or canceled revision never provisions.
      { [ "$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
    # Wait until no other revision is active. The latest revision before
    # stage 1 can be a failed one while an older placeholder is the active
    # one, so the script checks every active revision.
    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
}

# Only the first cutover rolls back. On a later deploy, the active revision
# is a real release, so the script keeps the credentials.
rollback() {
  [ -n "$RELEASED" ] && return
  # This run opened ingress already (a restart that then failed). The
  # placeholder template comes back before stage 2 removes the identity,
  # so ingress closes again first.
  # If ingress cannot close, stage 1 would put the placeholder behind
  # open ingress while the identity is still attached. So the rollback
  # stops: the credentials stay with the real image.
  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
    # The first cutover disabled ingress. Bring the saved ingress back, as
    # it was, now that the placeholder has no credentials.
    restore_ingress
    return
  fi
  echo "::error::rollback failed: remove the identity and secrets from $AZURE_APP_NAME manually" >&2
}

# The record of copied credentials that a first cutover writes in its PATCH.
COPIED_RECORD=$(jq -cn --argjson names "$JOB_SECRET_NAMES" --argjson uids "$REMOVE_UIDS" \
  --argjson copied "$COPIED" '{secrets: ($names + ($copied.secrets // []) | unique), uids: $uids}')

# Azure allows 50 tags on a resource. The operator's tags, the ingress
# snapshot and the copied record must fit together, so the script checks
# before any write. $1 is the number of copied-record parts the run keeps.
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
  # Any active real revision needs the credentials, also during a handoff
  # when a placeholder revision is still active too. (A first-cutover
  # rollback calls remove_credentials directly, not through this check.)
  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
  # The removal keeps the existing copied record until stage 2.
  # The cleanup PATCH adds one marker tag while it removes the copied
  # record, so the budget counts at least one tag for these.
  check_tag_budget "$(( EXISTING_COPIED_PARTS > 1 ? EXISTING_COPIED_PARTS : 1 ))"
  # The placeholder has credentials until stage 2. Ingress closes for the
  # removal (the tags keep the snapshot) and comes back only after it.
  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; }
    # A failure can follow an applied disable (a lost response), so the
    # script sends the saved ingress back, and stops.
    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
}

# The cutover copies the job's identity and secrets. Without them, the
# script stops before it changes anything.
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
# A first cutover can roll back, which removes the credentials.
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
    # Without ingress, only a scale rule that is not HTTP can start the
    # placeholder again. Then zero replicas is no proof, so the script stops
    # before any write. The template and each active revision must have no
    # such rule: a revision keeps its own rules, and the old revision stays
    # active until a new one is ready.
    # Azure also keeps min_replicas replicas running without ingress, so
    # a min_replicas above zero is no different.
    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
    # The first cutover. Disable ingress, so no request (internal or
    # external) can start the placeholder. Then wait until no placeholder
    # replica runs. The HTTP scaler stops a replica after a 300-second cool
    # down, so the script checks 60 times, 10 seconds apart.
    # If the script stops before the PATCH, the placeholder has no
    # credentials yet. It gets its saved ingress back, so a retry saves it
    # again.
    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; }
    # A failure can follow an applied disable (a lost response): the
    # saved ingress goes back.
    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

# The new containers. The script changes only the app container (the one
# named after the app): it sets the image and the env. Other containers
# (sidecars) stay, in their order. The
# env keeps the variables that this script does not set, and replaces the
# four that it sets. This JSON has no secret values.
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"
# The init containers stay, but without Redis no env var in them may refer
# to redis-url either. null: the app has none, and the PATCH leaves them.
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"

# A changed template makes a new revision, so the script waits for it. If
# nothing changes (a re-run with the same tag and config), Azure makes no
# new revision. Then the script checks the current revision. But if that
# revision failed (the old one still serves), a marker env var makes the
# template differ, so Azure makes a fresh revision.
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"
  # A failed or canceled revision never provisions.
  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

# On a first cutover, the PATCH also records what it copies (with what an
# earlier interrupted run recorded), so a later rollback can remove it.
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

# The secrets, registries and identities that you add to the app stay.
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"

# Wait for the new revision. The script checks 60 times, 10 seconds apart.
# After a 202, the GET can still show the state of an earlier update. So a
# Failed or Canceled state counts only after the GET shows a new revision,
# or after it showed another state since the PATCH.
READY=""
MOVED=""
for _ in $(seq 1 60); do
  sleep 10
  # The nested list makes `--output tsv` print one tab-separated row.
  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)"

# Azure can provision a revision whose secret refs point at a secret the app
# does not have (azure-container-apps issue 1705: the resource provider
# rewrites them). Such a revision cannot start. So every secret ref in the
# revision, in its containers and its init containers, must name a secret of
# the app before the script trusts it.
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)."

# The revision serves only when it is the only active one; until then, the
# old revision serves. On the first cutover the placeholder now has the
# identity and secrets, and ingress must not wake it. On a later deploy the
# deploy is not done while the old image serves. The script also waits on a
# same-tag retry: an earlier run can stop before the revision took over.
sole_active "$REVISION" || fail "revision $REVISION did not become the only active revision"

# The sole active revision runs the real image, so external ingress can
# open now. It opens before the readiness check below: a revision without a
# replica needs a request through ingress to start one.
# A failed call has an unknown outcome: Azure can apply the PATCH and the
# answer can be lost. So a rollback treats ingress as open from here on.
OPENED=1
open_ingress || fail "cannot open external ingress"

# The deploy is done when the revision has a ready replica: Provisioned and
# sole-active do not show that the image starts. A new revision can become
# the only active one without a replica: Azure scales it to the replica count
# of the old revision, and that is zero when the old one had scaled to zero
# (min_replicas = 0). A revision without a replica gets a request through
# ingress, so that the HTTP scale rule starts one.
# No new revision: secrets are application-scope, and the running revision
# reads them only when it starts. So the script restarts it, and only a
# replica from after the restart counts.
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."

# Remove the unused redis-url secret when no other revision is active. The
# release runs already, so a failure here is only a warning.
if [ -n "$STALE_REDIS" ]; then
  ACTIVE=true
  if [ -n "$OLD_REVISION" ]; then
    sole_active "$REVISION" && ACTIVE=false
  else
    # No new revision: the current env has no Redis reference.
    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
# The release runs with its credentials now: no rollback needs the record of
# what was copied, and the ingress is back.
if ! clear_tags '["autumn-ingress-", "autumn-copied-"]'; then
  # Not a rollback: the release runs with its credentials.
  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"