@auth-craft/aws-cf-stack 1.8.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.
- package/.env.example +15 -0
- package/README.md +82 -4
- package/assets/admin/cli.mjs +90 -90
- package/assets/gateway/worker.js +1 -1
- package/assets/lambda/index.mjs +52 -50
- package/bin/deploy.sh +12 -0
- package/cdk/bin/app.ts +13 -2
- package/cdk/lib/dynamodb-stack.ts +59 -90
- package/cdk/lib/lambda-stack.ts +101 -4
- package/cdk/lib/table-schema.json +192 -0
- package/lib/common.sh +10 -0
- package/lib/deploy-lambda.sh +4 -0
- package/lib/sync-table-gsis.sh +257 -0
- package/package.json +17 -9
- package/cdk/cdk.out/asset.530567cf1e6fd5236cd0e483ba02427949a4a68b619db930921f8d3ba9f03689/index.mjs +0 -135
- package/cdk/cdk.out/cdk.out +0 -1
- package/cdk/cdk.out/manifest.json +0 -565
- package/cdk/cdk.out/snapshot-commerce-prod-auth-api.assets.json +0 -36
- package/cdk/cdk.out/snapshot-commerce-prod-auth-api.metadata.json +0 -277
- package/cdk/cdk.out/snapshot-commerce-prod-auth-api.template.json +0 -412
- package/cdk/cdk.out/snapshot-commerce-prod-auth-data.assets.json +0 -21
- package/cdk/cdk.out/snapshot-commerce-prod-auth-data.metadata.json +0 -151
- package/cdk/cdk.out/snapshot-commerce-prod-auth-data.template.json +0 -371
- package/cdk/cdk.out/tree.json +0 -1
|
@@ -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.
|
|
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": {
|
|
@@ -10,23 +10,29 @@
|
|
|
10
10
|
"bin",
|
|
11
11
|
"lib",
|
|
12
12
|
"cdk",
|
|
13
|
+
"!cdk/cdk.out",
|
|
13
14
|
"assets",
|
|
14
15
|
"wrangler",
|
|
15
16
|
"README.md",
|
|
16
17
|
".env.example"
|
|
17
18
|
],
|
|
18
19
|
"dependencies": {
|
|
19
|
-
"aws-cdk-lib": "^2.
|
|
20
|
-
"constructs": "^10.
|
|
20
|
+
"aws-cdk-lib": "^2.273.0",
|
|
21
|
+
"constructs": "^10.8.1"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^
|
|
24
|
-
"tsx": "^4.
|
|
25
|
-
"typescript": "^
|
|
24
|
+
"@types/node": "^26.6.5",
|
|
25
|
+
"tsx": "^4.23.15",
|
|
26
|
+
"typescript": "^7.0.2",
|
|
27
|
+
"vitest": "^5.0.3",
|
|
28
|
+
"@auth-craft/auth-admin-cli": "2.0.0",
|
|
29
|
+
"@auth-craft/cloudflare-auth-gateway": "1.0.0",
|
|
30
|
+
"@auth-craft/auth-hono-dynamodb-lambda": "1.0.0",
|
|
31
|
+
"@auth-craft/database-plugin-dynamodb": "1.0.1"
|
|
26
32
|
},
|
|
27
33
|
"peerDependencies": {
|
|
28
|
-
"aws-cdk": "^2.
|
|
29
|
-
"wrangler": "^4.
|
|
34
|
+
"aws-cdk": "^2.1145.0",
|
|
35
|
+
"wrangler": "^4.149.0"
|
|
30
36
|
},
|
|
31
37
|
"peerDependenciesMeta": {
|
|
32
38
|
"aws-cdk": {
|
|
@@ -40,8 +46,10 @@
|
|
|
40
46
|
"node": ">=20"
|
|
41
47
|
},
|
|
42
48
|
"scripts": {
|
|
49
|
+
"sync:table-schema": "node ./scripts/sync-table-schema.mjs",
|
|
43
50
|
"prebuild:assets": "node ./scripts/prebuild-assets.mjs",
|
|
44
51
|
"build": "pnpm prebuild:assets",
|
|
45
|
-
"type-check": "tsc --noEmit -p cdk/tsconfig.json"
|
|
52
|
+
"type-check": "tsc --noEmit -p cdk/tsconfig.json",
|
|
53
|
+
"test": "vitest run"
|
|
46
54
|
}
|
|
47
55
|
}
|