-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTaskfile.yml
More file actions
482 lines (453 loc) · 31.3 KB
/
Copy pathTaskfile.yml
File metadata and controls
482 lines (453 loc) · 31.3 KB
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
# AllSource ops tasks. Requires go-task (`brew install go-task`) + fly, openssl, jq, curl.
# task --list # show tasks
# task backfill-usage # dry-run (default)
# Tasks here are operator glue (shell orchestration of fly/openssl/curl), run by the
# fleet owner — not application code.
#
# Secrets load from the gitignored .env (see .env.example). The LemonSqueezy
# cutover tasks (ls-*) read LEMON_SQUEEZY_API_KEY from there — NEVER hardcode it.
version: '3'
dotenv: ['.env']
vars:
TENANT: '{{.TENANT | default "decebal-dobrica-at-gmail-com"}}'
DRY: '{{.DRY | default "true"}}'
EMAIL: '{{.EMAIL | default "decebal.dobrica@gmail.com"}}'
QS_APP: '{{.QS_APP | default "allsource-query"}}'
CP_URL: '{{.CP_URL | default "https://api.all-source.xyz"}}'
CP_APP: '{{.CP_APP | default "allsource-control-plane"}}'
LS_STORE: '{{.LS_STORE | default "282851"}}'
LS_API: '{{.LS_API | default "https://api.lemonsqueezy.com/v1"}}'
LS_WEBHOOK_URL: '{{.LS_WEBHOOK_URL | default "https://allsource-control-plane.fly.dev/api/v1/webhooks/lemonsqueezy"}}'
VARIANT_ID: '{{.VARIANT_ID | default "1755406"}}'
tasks:
backfill-usage:
desc: >-
Backfill a tenant's events_used via the Control Plane admin endpoint. Mints a 1h
admin JWT from the fleet JWT_SECRET (read-only from the QS container) since admin
is allowlist-gated. Vars: TENANT, DRY=true|false, EMAIL, QS_APP, CP_URL.
silent: true
cmds:
- |
set -eu
echo ">> pulling JWT_SECRET from {{.QS_APP}} (marker-fenced to drop the fly-ssh banner)..."
RAW=$(fly ssh console -a "{{.QS_APP}}" -C 'sh -c "printf __S__; printenv JWT_SECRET; printf __E__"' 2>/dev/null || true)
SECRET="${RAW##*__S__}"; SECRET="${SECRET%%__E__*}"
# printenv appends a newline INSIDE the markers; strip CR/LF so the HMAC key is exactly the secret.
SECRET=$(printf '%s' "$SECRET" | tr -d '\r\n')
if [ -z "$SECRET" ] || [ "$SECRET" = "$RAW" ]; then
echo "!! could not read JWT_SECRET from {{.QS_APP}} (fly ssh failed or markers missing). First lines of raw output:"
printf '%s\n' "$RAW" | head -3
exit 1
fi
echo ">> JWT_SECRET ok (${#SECRET} chars). minting 1h admin token for {{.EMAIL}}..."
now=$(date +%s); exp=$((now + 3600))
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
hdr=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64)
pl=$(printf '%s' "{\"sub\":\"owner-backfill\",\"email\":\"{{.EMAIL}}\",\"role\":\"admin\",\"iss\":\"allsource\",\"iat\":${now},\"exp\":${exp}}" | b64)
sig=$(printf '%s' "${hdr}.${pl}" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64)
TOKEN="${hdr}.${pl}.${sig}"
echo ">> POST {{.CP_URL}}/api/v1/admin/billing/backfill-usage (tenant={{.TENANT}} dry_run={{.DRY}})"
curl -sS -X POST "{{.CP_URL}}/api/v1/admin/billing/backfill-usage" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"tenant_id\":\"{{.TENANT}}\",\"dry_run\":{{.DRY}}}" | jq .
admin-health:
desc: >-
Probe EVERY admin-console page's backing endpoint against PROD and print a
per-page health table: WORKING (200 + real non-empty data), EMPTY (200 but
zero rows / zero value — a documented no-data-yet state), or BROKEN (non-2xx,
error shape, or a list field that isn't a list). Exits NON-ZERO if any page
is BROKEN, so "healthy" is verified on demand, never claimed from a green
deploy. Mints a 1h admin JWT from the fleet JWT_SECRET (read-only from the QS
container) exactly like reap-demo/backfill-usage. Prints the real values it
checked (per-tenant counts, metric values, list lengths). Vars: CP_URL,
QS_APP, EMAIL. Optional: TENANT (a tenant expected to have events — its
non-zero event_count is asserted end-to-end), STRICT_EMPTY=true (treat the
known-empty surfaces as BROKEN too, for a stricter gate).
silent: true
cmds:
- |
set -eu
echo ">> minting 1h admin token from {{.QS_APP}} JWT_SECRET..."
RAW=$(fly ssh console -a "{{.QS_APP}}" -C 'sh -c "printf __S__; printenv JWT_SECRET; printf __E__"' 2>/dev/null || true)
SECRET="${RAW##*__S__}"; SECRET="${SECRET%%__E__*}"
SECRET=$(printf '%s' "$SECRET" | tr -d '\r\n')
if [ -z "$SECRET" ] || [ "$SECRET" = "$RAW" ]; then
echo "!! could not read JWT_SECRET from {{.QS_APP}} (fly ssh failed). First lines of raw output:"
printf '%s\n' "$RAW" | head -3; exit 1
fi
now=$(date +%s); exp=$((now + 3600))
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
hdr=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64)
pl=$(printf '%s' "{\"sub\":\"owner-admin-health\",\"email\":\"{{.EMAIL}}\",\"role\":\"admin\",\"iss\":\"allsource\",\"iat\":${now},\"exp\":${exp}}" | b64)
sig=$(printf '%s' "${hdr}.${pl}" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64)
TOKEN="${hdr}.${pl}.${sig}"
CP="{{.CP_URL}}"
STRICT="{{.STRICT_EMPTY | default "false"}}"
WANT_TENANT="{{.TENANT | default ""}}"
BROKEN=0
# GATE mode: the goal-critical pages MUST be WORKING (real data), else the
# gate fails — this is what "the admin meets its goals" means. Data-dependent
# pages (alerts/slos/dunning/ip-rules/token-audit/suspicious/notices/inbox/
# invoices) may legitimately be EMPTY and are NOT gated. Set GATE=true to enforce.
GATE="{{.GATE | default "false"}}"
GATE_PAGES="tenants tenant-360 fleet monitoring mon-cluster billing-cat security-pol"
GATE_FAIL=0
# In GATE mode, always check a known-active tenant's 360 (real counts end-to-end).
[ "$GATE" = "true" ] && [ -z "$WANT_TENANT" ] && WANT_TENANT="decebal-dobrica-at-gmail-com"
echo ">> probing admin pages against ${CP}"
echo ""
printf "%-14s %-34s %-9s %s\n" "PAGE" "ENDPOINT" "STATUS" "EVIDENCE (real values)"
printf "%-14s %-34s %-9s %s\n" "----" "--------" "------" "----------------------"
# check PAGE ENDPOINT METHOD JQ-CLASSIFIER
# the classifier receives the parsed body on stdin and must print:
# "WORKING <evidence>" | "EMPTY <evidence>" | "BROKEN <reason>"
# It is only invoked on a 2xx. Non-2xx is BROKEN automatically (except a
# classifier may be given the raw status via $code for nuance).
check() {
page="$1"; ep="$2"; method="$3"; jqprog="$4"
resp=$(curl -sS -m 25 -w $'\n__HTTP__%{http_code}' -X "$method" "$CP$ep" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null || printf '\n__HTTP__000')
code=$(printf '%s' "$resp" | awk -F'__HTTP__' '/__HTTP__/{print $2}')
body=$(printf '%s' "$resp" | sed '/__HTTP__/d')
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
verdict="BROKEN"; ev="HTTP $code: $(printf '%s' "$body" | tr -d '\n' | awk '{print substr($0,1,60)}')"
else
out=$(printf '%s' "$body" | jq -r "$jqprog" 2>/dev/null || echo "BROKEN shape-error (body not the expected object)")
verdict=$(printf '%s' "$out" | awk '{print $1}')
ev=$(printf '%s' "$out" | cut -d' ' -f2- 2>/dev/null || printf '%s' "$out")
fi
# STRICT_EMPTY promotes EMPTY → BROKEN.
if [ "$verdict" = "EMPTY" ] && [ "$STRICT" = "true" ]; then verdict="BROKEN"; ev="(strict) $ev"; fi
[ "$verdict" = "BROKEN" ] && BROKEN=$((BROKEN+1))
# Goal gate: a goal-critical page that is not WORKING fails the gate.
if [ "$GATE" = "true" ] && [ "$verdict" != "WORKING" ]; then
case " $GATE_PAGES " in *" $page "*) GATE_FAIL=$((GATE_FAIL+1)); ev="GATE-FAIL(goal page not WORKING) $ev" ;; esac
fi
printf "%-14s %-34s %-9s [%s] %s\n" "$page" "$ep" "$code" "$verdict" "$ev"
}
# A list field is BROKEN only when it is PRESENT and a non-null, non-array
# type (a string/number where the page's `.map` would crash). null/absent is
# EMPTY — the frontend's asList() coerces it to [] (resilience §6), so it
# renders an empty state, not a crash. This mirrors the client exactly.
# ── Tenants list: counts are the priority. WORKING needs at least one
# tenant with event_count>0; otherwise EMPTY (no metered events yet).
# Also a data-integrity gate: every column must be backed by REAL data,
# so this BROKENs on two known fake-value smells —
# • created_at: ≥8 tenants all sharing ONE calendar date ⇒ the column
# was time.Now()-stamped on load, not persisted at creation.
# • event_count == 1000000 exactly ⇒ the backfill page-cap artifact
# (defaultBackfillMaxPages*queryPageLimit), not a real count.
check "tenants" "/api/v1/admin/tenants?per_page=100" GET \
'if (.tenants != null and (.tenants|type)!="array") then "BROKEN tenants field is a \(.tenants|type), not a list" else (.tenants // []) as $t | ($t|length) as $n | ($t|map(.event_count // 0)|max // 0) as $mx | ([$t[]|.created_at[0:10]]|unique) as $dates | ([$t[]|select((.event_count//0)==1000000)]|length) as $capped | if ($n>=8 and ($dates|length)==1) then "BROKEN created_at not backed by real data — all \($n) tenants share date \($dates[0]) (time.Now() stamped on load, not the persisted creation time)" elif ($capped>0) then "BROKEN \($capped) tenant(s) at event_count=1000000 exactly = backfill cap artifact, not a real count — let the events_used reconciler resolve it" else "\(if $mx>0 then "WORKING" else "EMPTY" end) total=\(.total) tenants_with_events=\([$t[]|select((.event_count//0)>0)]|length) max_event_count=\($mx) created_at_dates=\($dates|length)" end end'
# ── Optional: a specific tenant expected to have events — asserts the
# counts=0 fix end-to-end (Core /stats → CP → admin DTO).
if [ -n "$WANT_TENANT" ]; then
check "tenant-360" "/api/v1/admin/tenants/$WANT_TENANT" GET \
'if has("event_count")|not then "BROKEN no event_count field" else "\(if (.event_count//0)>0 then "WORKING" else "EMPTY" end) name=\(.name) event_count=\(.event_count//0) member_count=\(.member_count//0) created=\(.created_at[0:10] // "?")" end'
fi
check "fleet" "/api/v1/admin/fleet/health" GET \
'if (.summary|type)!="object" then "BROKEN no summary" else "WORKING total=\(.summary.total) healthy=\(.summary.healthy) at_risk=\(.summary.at_risk) critical=\(.summary.critical) worst=\((.worst//[])|length)" end'
check "monitoring" "/api/v1/admin/metrics/summary" GET \
'if has("events_total")|not then "BROKEN no events_total" else "\(if ((.events_total//0)>0 or (.active_tenants//0)>0 or (.uptime_seconds//0)>0) then "WORKING" else "EMPTY" end) events_total=\(.events_total//0) active_tenants=\(.active_tenants//0) uptime_s=\(.uptime_seconds//0) p99=\(.query_latency_p99_ms//0)ms" end'
check "mon-timeseries" "/api/v1/admin/metrics/timeseries?metric=events_per_second&range=1h" GET \
'if (.points != null and (.points|type)!="array") then "BROKEN points is a \(.points|type)" else (.points // []) as $p | "\(if ($p|length)>0 then "WORKING" else "EMPTY" end) points=\($p|length)" end'
check "mon-cluster" "/api/v1/admin/cluster/members" GET \
'if (.members != null and (.members|type)!="array") then "BROKEN members is a \(.members|type)" else (.members // []) as $m | "\(if ($m|length)>0 then "WORKING" else "EMPTY" end) members=\($m|length) roles=\([$m[]?.role]|join(","))" end'
check "mon-alerts" "/api/v1/admin/alerts" GET \
'if (.alerts != null and (.alerts|type)!="array") then "BROKEN alerts is a \(.alerts|type)" else (.alerts // []) as $a | "\(if ($a|length)>0 then "WORKING" else "EMPTY" end) alerts=\($a|length)" end'
check "mon-slos" "/api/v1/admin/slos" GET \
'if (.slos != null and (.slos|type)!="array") then "BROKEN slos is a \(.slos|type)" else (.slos // []) as $s | "\(if ($s|length)>0 then "WORKING" else "EMPTY" end) slos=\($s|length)" end'
check "billing-rev" "/api/v1/admin/billing/revenue?range=30d" GET \
'if has("mrr")|not then "BROKEN no mrr" else "\(if ((.mrr//0)>0 or ((.breakdown//[])|length)>0 or ((.tier_stats//{})|length)>0) then "WORKING" else "EMPTY" end) mrr=\(.mrr//0) arr=\(.arr//0) churn=\(.churn_rate//0) tiers=\((.tier_stats//{})|length)" end'
check "billing-inv" "/api/v1/admin/billing/invoices" GET \
'if (.invoices != null and (.invoices|type)!="array") then "BROKEN invoices is a \(.invoices|type)" else (.invoices // []) as $i | "\(if ($i|length)>0 then "WORKING" else "EMPTY" end) invoices=\($i|length) total=\(.total//0)" end'
check "billing-dun" "/api/v1/admin/billing/dunning" GET \
'if (.items != null and (.items|type)!="array") then "BROKEN items is a \(.items|type)" else (.items // []) as $i | "\(if ($i|length)>0 then "WORKING" else "EMPTY" end) items=\($i|length) total_count=\(.total_count//0)" end'
check "billing-cfg" "/api/v1/admin/billing/config-check" GET \
'if has("ok")|not then "BROKEN no ok field" else "WORKING ok=\(.ok) issues=\((.issues//[])|length) manual=\((.manual//[])|length)" end'
check "billing-cat" "/api/v1/billing/catalog" GET \
'if (.tiers != null and (.tiers|type)!="array") then "BROKEN tiers is a \(.tiers|type)" else (.tiers // []) as $t | "\(if ($t|length)>0 then "WORKING" else "EMPTY" end) tiers=\([$t[]?.tier // $t[]?.id]|join(","))" end'
check "security-ip" "/api/v1/admin/security/ip-rules" GET \
'if (.ip_rules != null and (.ip_rules|type)!="array") then "BROKEN ip_rules is a \(.ip_rules|type)" else (.ip_rules // []) as $r | "\(if ($r|length)>0 then "WORKING" else "EMPTY" end) ip_rules=\($r|length)" end'
check "security-tok" "/api/v1/admin/security/token-audit" GET \
'if (.entries != null and (.entries|type)!="array") then "BROKEN entries is a \(.entries|type)" else (.entries // []) as $e | "\(if ($e|length)>0 then "WORKING" else "EMPTY" end) entries=\($e|length) total=\(.total//0)" end'
check "security-susp" "/api/v1/admin/security/suspicious-activity" GET \
'if (.alerts != null and (.alerts|type)!="array") then "BROKEN alerts is a \(.alerts|type)" else (.alerts // []) as $a | "\(if ($a|length)>0 then "WORKING" else "EMPTY" end) alerts=\($a|length)" end'
check "security-pol" "/api/v1/policies" GET \
'if (.policies != null and (.policies|type)!="array") then "BROKEN policies is a \(.policies|type)" else (.policies // []) as $p | "\(if ($p|length)>0 then "WORKING" else "EMPTY" end) policies=\($p|length)" end'
check "outreach" "/api/v1/admin/notices" GET \
'if (.notices != null and (.notices|type)!="array") then "BROKEN notices is a \(.notices|type)" else (.notices // []) as $n | "\(if ($n|length)>0 then "WORKING" else "EMPTY" end) notices=\($n|length)" end'
# ── Inbox: admin-gated. Reaches the handler ⇒ 200 (configured) or 503
# ("inbox not configured" — a documented EMPTY, the feature simply has no
# Nylas creds). A 401 means the route is on the WRONG auth group (the
# e4b5b2c regression) — that is BROKEN. So: 200→WORKING/EMPTY, 503→EMPTY,
# 401/other→BROKEN (handled by the status gate below via a sentinel).
ibresp=$(curl -sS -m 25 -w $'\n__HTTP__%{http_code}' "$CP/api/v1/admin/inbox/connections" -H "Authorization: Bearer $TOKEN" 2>/dev/null || printf '\n__HTTP__000')
ibcode=$(printf '%s' "$ibresp" | awk -F'__HTTP__' '/__HTTP__/{print $2}')
ibbody=$(printf '%s' "$ibresp" | sed '/__HTTP__/d')
if [ "$ibcode" = "200" ]; then
n=$(printf '%s' "$ibbody" | jq -r '.connections|length' 2>/dev/null || echo "?")
iv="WORKING"; [ "$n" = "0" ] && iv="EMPTY"; ibev="connections=$n"
elif [ "$ibcode" = "503" ]; then
iv="EMPTY"; ibev="inbox not configured (no Nylas creds) — handler reached, auth OK"
else
iv="BROKEN"; BROKEN=$((BROKEN+1)); ibev="HTTP $ibcode: $(printf '%s' "$ibbody" | tr -d '\n' | awk '{print substr($0,1,50)}') (expect 200/503; 401=wrong-auth-group regression e4b5b2c not deployed)"
fi
printf "%-14s %-34s %-9s [%s] %s\n" "inbox" "/api/v1/admin/inbox/connections" "$ibcode" "$iv" "$ibev"
echo ""
if [ "$BROKEN" -gt 0 ]; then
echo ">> RESULT: ${BROKEN} page(s) BROKEN — admin console is NOT fully healthy."
exit 1
fi
echo ">> RESULT: 0 pages BROKEN — every admin page is WORKING or documented-EMPTY."
if [ "$GATE" = "true" ]; then
if [ "$GATE_FAIL" -gt 0 ]; then
echo ">> GATE FAIL: ${GATE_FAIL} goal-critical page(s) not WORKING — admin goals NOT met."
echo " goal-critical pages (must show real data): ${GATE_PAGES}"
exit 1
fi
echo ">> GATE PASS: all goal-critical pages WORKING — admin meets its goals (${GATE_PAGES})."
fi
reap-demo:
desc: >-
Reap the is_demo "Demo User" tenants (status-monitor litter, now that the
DEMO_ENABLED gate stops new ones) via the Control Plane admin reap-demo
endpoint. Mints a 1h admin JWT from the fleet JWT_SECRET (same as
backfill-usage). DRY=true (default) lists matched + count only; DRY=false
dry-runs then applies with the echoed confirm_token. Blast-radius cap is 100
per run; rerun for larger backlogs. Vars: DRY, EMAIL, QS_APP, CP_URL.
silent: true
cmds:
- |
set -eu
echo ">> minting 1h admin token from {{.QS_APP}} JWT_SECRET..."
RAW=$(fly ssh console -a "{{.QS_APP}}" -C 'sh -c "printf __S__; printenv JWT_SECRET; printf __E__"' 2>/dev/null || true)
SECRET="${RAW##*__S__}"; SECRET="${SECRET%%__E__*}"
SECRET=$(printf '%s' "$SECRET" | tr -d '\r\n')
if [ -z "$SECRET" ] || [ "$SECRET" = "$RAW" ]; then
echo "!! could not read JWT_SECRET from {{.QS_APP}} (fly ssh failed). First lines of raw output:"
printf '%s\n' "$RAW" | head -3; exit 1
fi
now=$(date +%s); exp=$((now + 3600))
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
hdr=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64)
pl=$(printf '%s' "{\"sub\":\"owner-reap-demo\",\"email\":\"{{.EMAIL}}\",\"role\":\"admin\",\"iss\":\"allsource\",\"iat\":${now},\"exp\":${exp}}" | b64)
sig=$(printf '%s' "${hdr}.${pl}" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64)
TOKEN="${hdr}.${pl}.${sig}"
echo ">> POST {{.CP_URL}}/api/v1/admin/tenants/reap-demo?dry_run=true"
DRYJSON=$(curl -sS -X POST "{{.CP_URL}}/api/v1/admin/tenants/reap-demo?dry_run=true" -H "Authorization: Bearer ${TOKEN}")
echo "$DRYJSON" | jq .
COUNT=$(printf '%s' "$DRYJSON" | jq -r '.matched_total // 0')
TOK=$(printf '%s' "$DRYJSON" | jq -r '.confirm_token // ""')
if [ "{{.DRY}}" = "true" ]; then
echo ">> DRY=true — ${COUNT} demo tenant(s) matched, nothing deleted. Run 'task reap-demo DRY=false' to apply."
exit 0
fi
if [ "${COUNT}" = "0" ] || [ -z "${TOK}" ]; then echo ">> nothing to reap."; exit 0; fi
echo ">> applying: deleting ${COUNT} demo tenant(s) (echoing confirm_token)..."
curl -sS -X POST "{{.CP_URL}}/api/v1/admin/tenants/reap-demo" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"confirm_token\":\"${TOK}\"}" | jq .
retire-free:
desc: >-
Migrate the EXISTING free-tier tenants (prompt 048 stops NEW free; this
cleans up the ones that predate it). DRY=true (default) LISTS every free
tenant and classifies each as LITTER (event_count<50 AND a test-litter
name) or REAL — and MUTATES NOTHING. DRY=false then ARCHIVES the litter
(reversible bulk archive) and COMPS the real ones to enterprise
(manual_override, merge-then-PUT so quotas/billing linkage survive). The
event_count<50 backstop means a tenant with real usage is never archived.
Reuses the same 1h-admin-token mint as reap-demo/backfill-usage and only
the existing guarded endpoints — no new mutation surface. Full procedure +
rollback: docs/runbooks/RETIRE_FREE_PLAN.md. Vars: DRY, EMAIL, QS_APP, CP_URL.
silent: true
cmds:
- |
set -eu
echo ">> minting 1h admin token from {{.QS_APP}} JWT_SECRET..."
RAW=$(fly ssh console -a "{{.QS_APP}}" -C 'sh -c "printf __S__; printenv JWT_SECRET; printf __E__"' 2>/dev/null || true)
SECRET="${RAW##*__S__}"; SECRET="${SECRET%%__E__*}"
SECRET=$(printf '%s' "$SECRET" | tr -d '\r\n')
if [ -z "$SECRET" ] || [ "$SECRET" = "$RAW" ]; then
echo "!! could not read JWT_SECRET from {{.QS_APP}} (fly ssh failed). First lines of raw output:"
printf '%s\n' "$RAW" | head -3; exit 1
fi
now=$(date +%s); exp=$((now + 3600))
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
hdr=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64)
pl=$(printf '%s' "{\"sub\":\"owner-retire-free\",\"email\":\"{{.EMAIL}}\",\"role\":\"admin\",\"iss\":\"allsource\",\"iat\":${now},\"exp\":${exp}}" | b64)
sig=$(printf '%s' "${hdr}.${pl}" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64)
TOKEN="${hdr}.${pl}.${sig}"
CP="{{.CP_URL}}"
echo ">> GET ${CP}/api/v1/admin/tenants?plan=free&per_page=100 (read-only)"
FREE=$(curl -sS "$CP/api/v1/admin/tenants?plan=free&per_page=100" -H "Authorization: Bearer $TOKEN")
TOTAL=$(printf '%s' "$FREE" | jq -r '.total // 0')
# Classify: LITTER = event_count<50 AND a test-litter name shape; else REAL.
# The event_count<50 backstop is the safety line — a tenant with real
# usage is REAL no matter what its name looks like.
LITTER_RE='(-[0-9]{8,}$)|(-[0-9a-f]{8,}$)|^(onboard|agent|trial|demo|test|mc|mantis)-'
CLASS=$(printf '%s' "$FREE" | jq -c --arg re "$LITTER_RE" '
[ .tenants[] | {
id, name, status,
event_count: (.event_count // 0),
kind: (if (.event_count // 0) < 50 and ((.name // "") | test($re)) then "LITTER" else "REAL" end)
} ]')
echo ""
printf "%-34s %-8s %-7s %s\n" "TENANT" "EVENTS" "CLASS" "NAME"
printf "%-34s %-8s %-7s %s\n" "------" "------" "-----" "----"
printf '%s' "$CLASS" | jq -r '.[] | [.id, (.event_count|tostring), .kind, .name] | @tsv' \
| awk -F'\t' '{ printf "%-34s %-8s %-7s %s\n", $1, $2, $3, $4 }'
LITTER_IDS=$(printf '%s' "$CLASS" | jq -c '[ .[] | select(.kind=="LITTER") | .id ]')
REAL_IDS=$(printf '%s' "$CLASS" | jq -c '[ .[] | select(.kind=="REAL") | .id ]')
NLIT=$(printf '%s' "$LITTER_IDS" | jq 'length')
NREAL=$(printf '%s' "$REAL_IDS" | jq 'length')
echo ""
echo ">> free total=${TOTAL} litter=${NLIT} (would be archived) real=${NREAL} (would be comped to enterprise)"
if [ "{{.DRY}}" = "true" ]; then
echo ">> DRY=true — nothing mutated. Run 'task retire-free DRY=false' to apply."
echo " (archives the ${NLIT} litter tenant(s); comps the ${NREAL} real one(s) to enterprise.)"
exit 0
fi
# ---- APPLY (DRY=false) ----
if [ "$NLIT" -gt 0 ]; then
echo ">> archiving ${NLIT} litter tenant(s) via bulk archive (reversible)..."
curl -sS -X POST "$CP/api/v1/admin/tenants/bulk" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -nc --argjson ids "$LITTER_IDS" '{action:"archive", tenant_ids:$ids}')" \
| jq '{action, total, succeeded, failed}'
else
echo ">> no litter to archive."
fi
if [ "$NREAL" -gt 0 ]; then
echo ">> comping ${NREAL} real tenant(s) to enterprise (merge-then-PUT)..."
printf '%s' "$REAL_IDS" | jq -r '.[]' | while IFS= read -r ID; do
CUR=$(curl -sS "$CP/api/v1/tenants/$ID" -H "Authorization: Bearer $TOKEN")
NEW_META=$(printf '%s' "$CUR" | jq '(.metadata // {}) * {
subscription: (((.metadata // {}).subscription // {}) * {
tier:"enterprise", status:"active", plan_name:"Custom Enterprise", manual_override:true
})
}')
OUT=$(curl -sS -X PUT "$CP/api/v1/tenants/$ID" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -nc --argjson m "$NEW_META" '{metadata:$m}')")
TIER=$(printf '%s' "$OUT" | jq -r '.metadata.subscription.tier // "?"')
printf " %-34s -> tier=%s\n" "$ID" "$TIER"
done
else
echo ">> no real free tenants to comp."
fi
echo ">> done. Verify: curl '$CP/api/v1/admin/tenants?plan=free&status=active&per_page=100' | jq .total (expect 0)"
# ── LemonSqueezy production cutover (runbook: docs/runbooks/PRICING_BILLING_CUTOVER.md) ──
# All read LEMON_SQUEEZY_API_KEY from .env. The live key is never printed or
# committed. Order for go-live: ls-variants (confirm IDs) → ls-webhook-register
# → ls-config-check (expect ok) → real checkout → revoke the test key in LS.
ls-variant:
desc: >-
Fetch ONE LemonSqueezy variant by id to prove the live key resolves it
(LS shares one catalog across test/live, so the test variant ids should be
identical live). Vars: VARIANT_ID (default 1755406 = indie:monthly).
silent: true
cmds:
- |
set -eu
: "${LEMON_SQUEEZY_API_KEY:?set LEMON_SQUEEZY_API_KEY in .env (your LIVE LS API key)}"
echo ">> GET {{.LS_API}}/variants/{{.VARIANT_ID}}"
RESP=$(curl -sS -H "Authorization: Bearer $LEMON_SQUEEZY_API_KEY" \
-H 'Accept: application/vnd.api+json' \
"{{.LS_API}}/variants/{{.VARIANT_ID}}")
if [ "$(printf '%s' "$RESP" | jq -r '(.data|type) // "null"')" != "object" ]; then
echo "!! variant {{.VARIANT_ID}} did not resolve under this key — raw response:"
printf '%s' "$RESP" | jq '.errors // .'; exit 1
fi
printf '%s' "$RESP" | jq '.data | {id, name: .attributes.name, status: .attributes.status, price: .attributes.price}'
ls-variants:
desc: >-
List every variant in the LS store with id/name/status/price, so you can
confirm the six VARIANT_MAP ids (indie/studio/scale × monthly/annual)
resolve under the LIVE key. Vars: LS_STORE (default 282851).
silent: true
cmds:
- |
set -eu
: "${LEMON_SQUEEZY_API_KEY:?set LEMON_SQUEEZY_API_KEY in .env (your LIVE LS API key)}"
# /v1/variants does NOT support filter[store_id] (variants belong to
# products, not stores). LS API keys are account-scoped, so an unfiltered
# list returns this account's variants; page[size]=100 covers our catalog.
echo ">> GET {{.LS_API}}/variants?page[size]=100"
RESP=$(curl -sS -H "Authorization: Bearer $LEMON_SQUEEZY_API_KEY" \
-H 'Accept: application/vnd.api+json' \
"{{.LS_API}}/variants?page%5Bsize%5D=100")
if [ "$(printf '%s' "$RESP" | jq -r '(.data|type) // "null"')" != "array" ]; then
echo "!! response has no .data array (auth error or bad request) — raw response:"
printf '%s' "$RESP" | jq '.errors // .'; exit 1
fi
printf '%s' "$RESP" \
| jq -r '.data[] | [.id, (.attributes.status), ((.attributes.price // "-")|tostring), (.attributes.name)] | @tsv' \
| awk -F'\t' 'BEGIN{printf "%-10s %-10s %-8s %s\n","ID","STATUS","PRICE","NAME"; printf "%-10s %-10s %-8s %s\n","--","------","-----","----"} {printf "%-10s %-10s %-8s %s\n",$1,$2,$3,$4}'
ls-webhook-register:
desc: >-
Register the LIVE LemonSqueezy webhook (subscription_created/updated/
cancelled/expired/payment_failed → the Control Plane), generating a fresh
≤40-char signing secret and setting LEMON_SQUEEZY_WEBHOOK_SECRET on the CP
to match (setting the secret rolls the machines). The secret is never
printed. Vars: LS_STORE, LS_WEBHOOK_URL, CP_APP.
silent: true
cmds:
- |
set -eu
: "${LEMON_SQUEEZY_API_KEY:?set LEMON_SQUEEZY_API_KEY in .env (your LIVE LS API key)}"
SECRET=$(openssl rand -hex 16) # 32 chars, under LS's 40-char cap
BODY=$(mktemp); trap 'rm -f "$BODY"' EXIT
jq -nc \
--arg url "{{.LS_WEBHOOK_URL}}" \
--arg secret "$SECRET" \
--arg store "{{.LS_STORE}}" \
'{data:{type:"webhooks",attributes:{url:$url,events:["subscription_created","subscription_updated","subscription_cancelled","subscription_expired","subscription_payment_failed"],secret:$secret},relationships:{store:{data:{type:"stores",id:$store}}}}}' > "$BODY"
echo ">> POST {{.LS_API}}/webhooks (url={{.LS_WEBHOOK_URL}})"
RESP=$(curl -sS -X POST "{{.LS_API}}/webhooks" \
-H "Authorization: Bearer $LEMON_SQUEEZY_API_KEY" \
-H 'Content-Type: application/vnd.api+json' \
-d @"$BODY")
WID=$(printf '%s' "$RESP" | jq -r '.data.id // empty')
if [ -z "$WID" ]; then
echo "!! webhook registration failed:"; printf '%s\n' "$RESP" | jq -r '.errors // .' ; exit 1
fi
# LS returns the secret write-only (absent from the response), so this is safe to print.
printf '%s' "$RESP" | jq '{id: .data.id, url: .data.attributes.url, events: .data.attributes.events}'
echo ">> webhook id=${WID} registered. setting matching secret on {{.CP_APP}} (rolls machines)..."
fly secrets set LEMON_SQUEEZY_WEBHOOK_SECRET="$SECRET" -a {{.CP_APP}}
echo ">> done. verify with: task ls-config-check"
ls-config-check:
desc: >-
Read the Control Plane's billing self-check (GET /admin/billing/config-check)
and print ok + any issues — confirms the variant map resolves, the webhook
secret is present + within the length cap, and the HMAC self-test passes.
Mints a 1h admin JWT from the fleet JWT_SECRET (same as the other admin
tasks). Vars: CP_URL, QS_APP, EMAIL.
silent: true
cmds:
- |
set -eu
echo ">> minting 1h admin token from {{.QS_APP}} JWT_SECRET..."
RAW=$(fly ssh console -a "{{.QS_APP}}" -C 'sh -c "printf __S__; printenv JWT_SECRET; printf __E__"' 2>/dev/null || true)
SECRET="${RAW##*__S__}"; SECRET="${SECRET%%__E__*}"
SECRET=$(printf '%s' "$SECRET" | tr -d '\r\n')
if [ -z "$SECRET" ] || [ "$SECRET" = "$RAW" ]; then
echo "!! could not read JWT_SECRET from {{.QS_APP}} (fly ssh failed). First lines of raw output:"
printf '%s\n' "$RAW" | head -3; exit 1
fi
now=$(date +%s); exp=$((now + 3600))
b64() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
hdr=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64)
pl=$(printf '%s' "{\"sub\":\"owner-ls-config-check\",\"email\":\"{{.EMAIL}}\",\"role\":\"admin\",\"iss\":\"allsource\",\"iat\":${now},\"exp\":${exp}}" | b64)
sig=$(printf '%s' "${hdr}.${pl}" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64)
TOKEN="${hdr}.${pl}.${sig}"
echo ">> GET {{.CP_URL}}/api/v1/admin/billing/config-check"
OUT=$(curl -sS "{{.CP_URL}}/api/v1/admin/billing/config-check" -H "Authorization: Bearer ${TOKEN}")
printf '%s' "$OUT" | jq '{ok, facts, issues, manual}'
if [ "$(printf '%s' "$OUT" | jq -r '.ok // false')" != "true" ]; then
echo ">> config-check NOT ok — fix the issues above before going live."; exit 1
fi
echo ">> config-check OK."