@auth-craft/aws-cf-stack 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bash
2
+ # Stage 1a — bring an existing table's GSIs up to the schema before the normal deploy.
3
+ #
4
+ # CloudFormation creates or deletes at most ONE GSI per table update, so a table deployed
5
+ # by an older package version (missing several GSIs) cannot jump to the full schema in one
6
+ # `cdk deploy`. This adds the missing GSIs one per data-stack update, in schema order,
7
+ # waiting for each to finish backfilling. A new table needs none of this (CreateTable takes
8
+ # every GSI at once), and an up-to-date table costs one DescribeTable.
9
+ #
10
+ # The schema comes from cdk/lib/table-schema.json (generated from database-plugin-dynamodb).
11
+ # Each rollout step passes the GSI subset to the CDK app as context (`-c dataGsis=...`) on
12
+ # that command line only — never through the environment, so it cannot leak into the full
13
+ # deploy that follows.
14
+ #
15
+ # Needs, besides the CDK deploy roles: dynamodb:DescribeTable on the table and
16
+ # cloudformation:GetTemplate on the data stack, for the identity running this CLI.
17
+
18
+ # Poll interval / ceiling while a GSI backfills (populated tables take minutes).
19
+ GSI_POLL_SECONDS="${AUTH_CRAFT_GSI_POLL_SECONDS:-15}"
20
+ GSI_WAIT_MINUTES="${AUTH_CRAFT_GSI_WAIT_MINUTES:-60}"
21
+
22
+ # describe-table JSON on stdout; returns 3 when the table does not exist.
23
+ _describe_table() {
24
+ local table="$1" out err
25
+ err="$(mktemp)"
26
+ if out="$(aws dynamodb describe-table --table-name "$table" --region "$AWS_REGION" --output json 2>"$err")"; then
27
+ rm -f "$err"
28
+ printf '%s' "$out"
29
+ return 0
30
+ fi
31
+ if grep -q ResourceNotFoundException "$err"; then
32
+ rm -f "$err"
33
+ return 3
34
+ fi
35
+ error "$(cat "$err")"
36
+ rm -f "$err"
37
+ die "Could not describe table $table (the deploy identity needs dynamodb:DescribeTable on it)."
38
+ }
39
+
40
+ # The DynamoDB table's Properties from the data stack's deployed template (JSON); returns 3
41
+ # when the stack does not exist.
42
+ _template_table() {
43
+ local stack="$1" out err
44
+ err="$(mktemp)"
45
+ if ! out="$(aws cloudformation get-template --stack-name "$stack" --region "$AWS_REGION" \
46
+ --query TemplateBody --output json 2>"$err")"; then
47
+ if grep -q 'does not exist' "$err"; then
48
+ rm -f "$err"
49
+ return 3
50
+ fi
51
+ error "$(cat "$err")"
52
+ rm -f "$err"
53
+ die "Could not read the template of stack $stack (the deploy identity needs cloudformation:GetTemplate)."
54
+ fi
55
+ rm -f "$err"
56
+ # TemplateBody comes back as an object for JSON templates, as a string otherwise.
57
+ jq -c '(if type == "string" then fromjson else . end)
58
+ | first(.Resources[] | select(.Type == "AWS::DynamoDB::Table")) | .Properties' <<<"$out"
59
+ }
60
+
61
+ # `aws dynamodb update-table` command that recreates GSI $2 exactly as template table JSON $1
62
+ # declares it (keys, attribute types, projection) — so the table matches CloudFormation again.
63
+ _recreate_gsi_command() {
64
+ local props="$1" name="$2" attrs gsi
65
+ attrs="$(jq -c --arg n "$name" '
66
+ (.GlobalSecondaryIndexes[] | select(.IndexName == $n) | [.KeySchema[].AttributeName]) as $keys
67
+ | [.AttributeDefinitions[] | select(.AttributeName as $a | $keys | index($a))]' <<<"$props")"
68
+ gsi="$(jq -c --arg n "$name" '[{Create: (.GlobalSecondaryIndexes[] | select(.IndexName == $n)
69
+ | {IndexName, KeySchema, Projection})}]' <<<"$props")"
70
+ printf 'aws dynamodb update-table --table-name %s --region %s \\\n --attribute-definitions %s \\\n --global-secondary-index-updates %s' \
71
+ "$table" "$AWS_REGION" "'$attrs'" "'$gsi'"
72
+ }
73
+
74
+ # One line per GSI: "<name>\t<definition>", the definition being keys (with attribute
75
+ # types) and projection in a canonical JSON form, so a live index and a schema entry compare
76
+ # equal exactly when CloudFormation would leave the index alone. Comparing names only misses
77
+ # a changed projection/key — CloudFormation replaces such a GSI (delete + create = two GSI
78
+ # operations), which fails a single update.
79
+ _GSI_SIG_JQ='def sig: "\(.name)\t\({keys: .keys, projection: .projection} | tojson)";
80
+ def proj($type; $nonKey): {type: $type, nonKey: (($nonKey // []) | sort)};'
81
+
82
+ # Definitions of the live GSIs, from describe-table JSON on stdin.
83
+ _live_gsi_sigs() {
84
+ jq -r "$_GSI_SIG_JQ"'
85
+ (.Table.AttributeDefinitions // [] | map({key: .AttributeName, value: .AttributeType}) | from_entries) as $types
86
+ | .Table.GlobalSecondaryIndexes[]?
87
+ | {name: .IndexName,
88
+ keys: [.KeySchema[] | {name: .AttributeName, keyType: .KeyType, type: ($types[.AttributeName] // "?")}]
89
+ | sort_by(.keyType),
90
+ projection: proj(.Projection.ProjectionType; .Projection.NonKeyAttributes)}
91
+ | sig'
92
+ }
93
+
94
+ # Definitions of the schema GSIs (table-schema.json given as $1), in schema order.
95
+ _schema_gsi_sigs() {
96
+ jq -r "$_GSI_SIG_JQ"'
97
+ .globalSecondaryIndexes[]
98
+ | {name: .indexName,
99
+ keys: ([{name: .partitionKey.name, keyType: "HASH", type: .partitionKey.type}]
100
+ + (if .sortKey then [{name: .sortKey.name, keyType: "RANGE", type: .sortKey.type}] else [] end)),
101
+ projection: proj(.projection.type; .projection.nonKeyAttributes)}
102
+ | sig' "$1"
103
+ }
104
+
105
+ # Wait until the table and all its GSIs are ACTIVE; print the describe-table JSON.
106
+ _wait_table_active() {
107
+ local table="$1" deadline desc pending
108
+ deadline=$(( $(date +%s) + GSI_WAIT_MINUTES * 60 ))
109
+ local rc
110
+ while :; do
111
+ rc=0
112
+ desc="$(_describe_table "$table")" || rc=$?
113
+ [[ $rc -eq 3 ]] && die "Table $table disappeared while waiting for it (deleted mid-deploy?). Re-run the deploy."
114
+ [[ $rc -eq 0 ]] || exit 1
115
+ pending="$(jq -r '[.Table.TableStatus] + [.Table.GlobalSecondaryIndexes[]?.IndexStatus]
116
+ | map(select(. != "ACTIVE")) | length' <<<"$desc")"
117
+ if [[ "$pending" == "0" ]]; then
118
+ printf '%s' "$desc"
119
+ return 0
120
+ fi
121
+ (( $(date +%s) < deadline )) \
122
+ || die "Table $table still not ACTIVE after ${GSI_WAIT_MINUTES} min (AUTH_CRAFT_GSI_WAIT_MINUTES). Re-run the deploy once it is."
123
+ info " waiting for $table: $(jq -r '[.Table.GlobalSecondaryIndexes[]? | select(.IndexStatus != "ACTIVE")
124
+ | "\(.IndexName)=\(.IndexStatus)"] | join(", ") | if . == "" then "table updating" else . end' <<<"$desc")" >&2
125
+ sleep "$GSI_POLL_SECONDS"
126
+ done
127
+ }
128
+
129
+ # Read newline-separated text into the array named $1 (empty text → empty array).
130
+ _to_array() {
131
+ local -n _arr="$1"
132
+ _arr=()
133
+ [[ -n "$2" ]] && mapfile -t _arr <<<"$2"
134
+ return 0
135
+ }
136
+
137
+ _contains() {
138
+ local needle="$1"
139
+ shift
140
+ local item
141
+ for item in "$@"; do [[ "$item" == "$needle" ]] && return 0; done
142
+ return 1
143
+ }
144
+
145
+ sync_table_gsis() {
146
+ local table="${RESOURCE_NAME}-data"
147
+ local schema="$CDK_DIR/lib/table-schema.json"
148
+ require_cmd jq
149
+ [[ -f "$schema" ]] || die "Table schema missing: $schema (was the package built?)"
150
+
151
+ local -a desired live managed include
152
+ local -A want_sig live_sig
153
+ local name sig
154
+ while IFS=$'\t' read -r name sig; do
155
+ desired+=("$name")
156
+ want_sig[$name]="$sig"
157
+ done < <(_schema_gsi_sigs "$schema")
158
+
159
+ local rc=0
160
+ _describe_table "$table" >/dev/null || rc=$?
161
+ if [[ $rc -eq 3 ]]; then
162
+ info "Table $table does not exist yet — it will be created with all ${#desired[@]} GSIs."
163
+ return 0
164
+ fi
165
+
166
+ local added=0 missing="" previous="" out template
167
+ while :; do
168
+ out="$(_wait_table_active "$table")" || exit 1
169
+ live=()
170
+ live_sig=()
171
+ while IFS=$'\t' read -r name sig; do
172
+ [[ -n "$name" ]] || continue
173
+ live+=("$name")
174
+ live_sig[$name]="$sig"
175
+ done < <(_live_gsi_sigs <<<"$out")
176
+
177
+ rc=0
178
+ template="$(_template_table "$table")" || rc=$?
179
+ [[ $rc -eq 3 ]] \
180
+ && die "Table $table exists but stack $table does not — CloudFormation cannot create a table that already exists. Import the table into the stack or delete it."
181
+ [[ $rc -eq 0 ]] || exit 1
182
+ _to_array managed "$(jq -r '.GlobalSecondaryIndexes[]?.IndexName' <<<"$template")"
183
+
184
+ # A GSI on the table that the deployed template does not declare was created by hand.
185
+ # CloudFormation would try to create it again and fail halfway — stop before that.
186
+ for name in "${live[@]}"; do
187
+ _contains "$name" "${managed[@]}" && continue
188
+ die "GSI $name exists on $table but not in stack $table (created outside CloudFormation).
189
+ Delete it first (aws dynamodb update-table --table-name $table --region $AWS_REGION \\
190
+ --global-secondary-index-updates '[{\"Delete\":{\"IndexName\":\"$name\"}}]'),
191
+ wait until the table is ACTIVE, then re-run the deploy — it will be recreated under CloudFormation."
192
+ done
193
+
194
+ # The reverse: the template declares a GSI the table lacks (deleted by hand). CloudFormation
195
+ # thinks it exists, so no update would ever recreate it. Recreating it exactly as the
196
+ # template declares it puts the table back in line with the stack (needs only the AWS CLI,
197
+ # one GSI per call — re-running reports the next one).
198
+ for name in "${managed[@]}"; do
199
+ _contains "$name" "${live[@]}" && continue
200
+ die "GSI $name is declared by stack $table but missing from the table (deleted outside CloudFormation).
201
+ CloudFormation will not recreate it. Recreate it as the stack declares it:
202
+ $(_recreate_gsi_command "$template" "$name")
203
+ wait until the table is ACTIVE (backfill can take minutes), then re-run the deploy."
204
+ done
205
+
206
+ # A deployed GSI the schema no longer has would be deleted in the same update as the
207
+ # next addition (two GSI changes) — CloudFormation rejects that.
208
+ for name in "${live[@]}"; do
209
+ _contains "$name" "${desired[@]}" && continue
210
+ die "GSI $name is on $table but not in this package's schema. Removing it while adding missing GSIs needs two CloudFormation updates; deploy with a package version that still declares it, or remove it in its own update first."
211
+ done
212
+
213
+ # Same name, different keys or projection: CloudFormation would replace the index
214
+ # (delete + create) in one update and fail. GSIs are append-only — a changed access
215
+ # pattern gets a new index name in TABLE_SCHEMA, the old one is retired separately.
216
+ for name in "${live[@]}"; do
217
+ [[ "${live_sig[$name]}" == "${want_sig[$name]}" ]] && continue
218
+ die "GSI $name on $table differs from this package's schema:
219
+ table : ${live_sig[$name]}
220
+ schema: ${want_sig[$name]}
221
+ CloudFormation cannot change a GSI's keys or projection in place (it replaces it — two GSI
222
+ operations in one update). Give the new definition a new index name in TABLE_SCHEMA instead."
223
+ done
224
+
225
+ missing=""
226
+ for name in "${desired[@]}"; do
227
+ _contains "$name" "${live[@]}" || { missing="$name"; break; }
228
+ done
229
+ [[ -z "$missing" ]] && break
230
+
231
+ # Progress guard: the previous update reported success but the GSI is still not on the
232
+ # table — looping would redeploy the same no-op forever.
233
+ [[ "$missing" != "$previous" ]] \
234
+ || die "Deploying stack $table did not add GSI $missing to the table. Check the stack events and the table, then re-run."
235
+ previous="$missing"
236
+
237
+ include=()
238
+ for name in "${desired[@]}"; do
239
+ if _contains "$name" "${live[@]}" || [[ "$name" == "$missing" ]]; then include+=("$name"); fi
240
+ done
241
+
242
+ added=$((added + 1))
243
+ info "Adding GSI $missing to $table (one GSI per CloudFormation update; backfill can take minutes)…"
244
+ (
245
+ cd "$CDK_DIR"
246
+ CDK_DEFAULT_ACCOUNT="$AWS_ACCOUNT_ID" \
247
+ CDK_DEFAULT_REGION="$AWS_REGION" \
248
+ cdk deploy "$table" --exclusively --require-approval never -c "dataGsis=$(IFS=,; echo "${include[*]}")"
249
+ ) || die "Adding GSI $missing to $table failed — fix the cause above and re-run; GSIs already added are kept."
250
+ done
251
+
252
+ if [[ $added -gt 0 ]]; then
253
+ success "Table $table now has all ${#desired[@]} GSIs (added $added)."
254
+ else
255
+ info "Table $table already has all ${#desired[@]} GSIs, matching the schema."
256
+ fi
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auth-craft/aws-cf-stack",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Self-contained, versioned distribution of the Auth Craft AWS (DynamoDB + Lambda) + Cloudflare gateway stack. Bundles prebuilt Lambda/worker artifacts + CDK app so consumers deploy without cloning auth-craft.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,20 +17,22 @@
17
17
  ".env.example"
18
18
  ],
19
19
  "dependencies": {
20
- "aws-cdk-lib": "^2.265.0",
20
+ "aws-cdk-lib": "^2.273.0",
21
21
  "constructs": "^10.8.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/node": "^26.2.0",
25
- "tsx": "^4.23.12",
24
+ "@types/node": "^26.6.5",
25
+ "tsx": "^4.23.15",
26
26
  "typescript": "^7.0.2",
27
+ "vitest": "^5.0.3",
27
28
  "@auth-craft/auth-admin-cli": "2.0.0",
29
+ "@auth-craft/cloudflare-auth-gateway": "1.0.0",
28
30
  "@auth-craft/auth-hono-dynamodb-lambda": "1.0.0",
29
- "@auth-craft/cloudflare-auth-gateway": "1.0.0"
31
+ "@auth-craft/database-plugin-dynamodb": "1.0.1"
30
32
  },
31
33
  "peerDependencies": {
32
- "aws-cdk": "^2.1128.0",
33
- "wrangler": "^4.101.0"
34
+ "aws-cdk": "^2.1145.0",
35
+ "wrangler": "^4.149.0"
34
36
  },
35
37
  "peerDependenciesMeta": {
36
38
  "aws-cdk": {
@@ -44,8 +46,10 @@
44
46
  "node": ">=20"
45
47
  },
46
48
  "scripts": {
49
+ "sync:table-schema": "node ./scripts/sync-table-schema.mjs",
47
50
  "prebuild:assets": "node ./scripts/prebuild-assets.mjs",
48
51
  "build": "pnpm prebuild:assets",
49
- "type-check": "tsc --noEmit -p cdk/tsconfig.json"
52
+ "type-check": "tsc --noEmit -p cdk/tsconfig.json",
53
+ "test": "vitest run"
50
54
  }
51
55
  }