diff --git a/.github/workflows/canary-ramp.yml b/.github/workflows/canary-ramp.yml index 993e87f..b79810c 100644 --- a/.github/workflows/canary-ramp.yml +++ b/.github/workflows/canary-ramp.yml @@ -31,6 +31,14 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} EDGE_CONFIG_ID: ${{ secrets.VERCEL_EDGE_CONFIG_ID }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + # Vercel Deployment Protection blocks raw *.vercel.app deploy URLs with + # HTTP 401 "Authentication Required" by default. The SLO check curls the + # raw prod deploy URL (needed for per-deployment canary routing), so + # without this bypass every SLO check fails and the canary rolls back to + # 0% every 15 min. Generate via Vercel → Project Settings → Deployment + # Protection → Protection Bypass for Automation. + # Local patch — not yet upstream in shadow-canary-templates (PR #16 style). + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} # GitHub injects GITHUB_REPOSITORY ("owner/repo") into every step by # default; we restate it here so the derivation still works if someone # later scopes env at the step level or containerizes the job. @@ -100,13 +108,26 @@ jobs: URL='${{ steps.state.outputs.prod_url }}/api/slo' case "$URL" in http*) ;; *) URL="https://$URL" ;; esac + # Send the Vercel Deployment Protection bypass header when the secret + # is present. Without it, raw *.vercel.app URLs return 401 HTML and + # every SLO check fails. If the secret is unset (e.g. protection is + # disabled), the curl just omits the header and proceeds normally. + # Local patch — not yet upstream in shadow-canary-templates. + BYPASS_ARGS=() + if [ -n "$VERCEL_AUTOMATION_BYPASS_SECRET" ]; then + BYPASS_ARGS=(-H "x-vercel-protection-bypass: $VERCEL_AUTOMATION_BYPASS_SECRET") + fi + OK=0 LAST_BODY="" for i in 1 2; do - CODE=$(curl -s -o /tmp/slo_body.txt -w "%{http_code}" --max-time 10 "$URL" || echo "000") + CODE=$(curl -s -o /tmp/slo_body.txt -w "%{http_code}" --max-time 10 "${BYPASS_ARGS[@]}" "$URL" || echo "000") BODY=$(cat /tmp/slo_body.txt 2>/dev/null || true) - # Trim to 80 chars, strip newlines/CR for YAML-safe single-line output - BODY_TRIMMED="$(echo "${BODY:0:80}" | tr '\n\r' ' ')" + # Trim to 500 chars, strip newlines/CR for YAML-safe single-line output. + # 500 gives enough room to show a full JSON payload or a truncated + # stack trace without blowing the Edge Config 8 KB per-item cap + # (10 entries × 500 B ≈ 5 KB, plus the rest of ShadowConfig). + BODY_TRIMMED="$(echo "${BODY:0:500}" | tr '\n\r' ' ')" echo "SLO check $i → HTTP $CODE :: $BODY_TRIMMED" if [ "$CODE" = "200" ]; then OK=$((OK + 1)); fi echo "code$i=$CODE" >> "$GITHUB_OUTPUT" @@ -203,15 +224,26 @@ jobs: # Runs on both pass and fail paths (not on skip) so the admin UI has a # trail of recent checks to diagnose "why isn't the canary moving?". if: steps.gate.outputs.skip == '0' + env: + # Pass step outputs through env instead of ${{ … }} substitution in + # the shell script — GitHub substitutes ${{ }} as raw text, so a body + # excerpt like `{"ok":true,...` breaks the surrounding "…" shell + # string with its embedded quotes. env vars are properly escaped and + # available as $SLO_* below. + # Local patch — not yet upstream in shadow-canary-templates (PR #17 style). + SLO_OK: ${{ steps.slo.outputs.ok }} + SLO_CODE1: ${{ steps.slo.outputs.code1 }} + SLO_CODE2: ${{ steps.slo.outputs.code2 }} + SLO_BODY_EXCERPT: ${{ steps.slo.outputs.body_excerpt }} + SLO_PCT_BEFORE: ${{ steps.state.outputs.pct }} + SLO_PCT_AFTER: ${{ steps.compute.outputs.next }} run: | - OK='${{ steps.slo.outputs.ok }}' - PCT_BEFORE='${{ steps.state.outputs.pct }}' - if [ "$OK" = "1" ]; then - PCT_AFTER='${{ steps.compute.outputs.next }}' + if [ "$SLO_OK" = "1" ]; then + PCT_AFTER="$SLO_PCT_AFTER" else PCT_AFTER="0" fi - OK_BOOL=$( [ "$OK" = "1" ] && echo true || echo false ) + OK_BOOL=$( [ "$SLO_OK" = "1" ] && echo true || echo false ) # Re-read the current Edge Config value because the bump/rollback # step just mutated it — we want to merge sloChecks on top of the @@ -224,10 +256,10 @@ jobs: ENTRY=$(jq -n \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --argjson ok "$OK_BOOL" \ - --argjson code1 "${{ steps.slo.outputs.code1 }}" \ - --argjson code2 "${{ steps.slo.outputs.code2 }}" \ - --arg body "${{ steps.slo.outputs.body_excerpt }}" \ - --argjson pctBefore "$PCT_BEFORE" \ + --argjson code1 "$SLO_CODE1" \ + --argjson code2 "$SLO_CODE2" \ + --arg body "$SLO_BODY_EXCERPT" \ + --argjson pctBefore "$SLO_PCT_BEFORE" \ --argjson pctAfter "$PCT_AFTER" \ '{ts: $ts, ok: $ok, codes: [$code1, $code2], bodyExcerpt: $body, pctBefore: $pctBefore, pctAfter: $pctAfter}') diff --git a/.github/workflows/deploy-shadow.yml b/.github/workflows/deploy-shadow.yml index 5034263..2f7053c 100644 --- a/.github/workflows/deploy-shadow.yml +++ b/.github/workflows/deploy-shadow.yml @@ -54,14 +54,28 @@ jobs: MERGED=$(echo "$CURRENT" | jq \ --arg shadowUrl "${{ steps.deploy.outputs.url }}" \ - '. + { - deploymentDomainProd: (.deploymentDomainProd // $shadowUrl), - deploymentDomainShadowPrevious: ( - if (.deploymentDomainShadow // "") != "" and .deploymentDomainShadow != $shadowUrl - then .deploymentDomainShadow - else (.deploymentDomainShadowPrevious // null) + '. as $cur + | ( + # Prepend the outgoing shadow URL to history iff it exists + # and differs from the incoming one. Dedupe while preserving + # order (keeps the FIRST occurrence, which is the most + # recent). Trim to 20 — matches the admin UI rollback picker. + # Commit metadata (sha/ref/ts) is fetched on-demand by the + # admin UI from the Vercel API, so history stays compact. + if ($cur.deploymentDomainShadow // "") != "" and $cur.deploymentDomainShadow != $shadowUrl then + ([$cur.deploymentDomainShadow] + ($cur.shadowHistory // [])) + | reduce .[] as $x ([]; if any(. == $x) then . else . + [$x] end) + | .[0:20] + else + ($cur.shadowHistory // []) end - ), + ) as $history + | . + { + deploymentDomainProd: (.deploymentDomainProd // $shadowUrl), + shadowHistory: $history, + # Deprecated: kept populated for back-compat with v0.4.x admin + # UIs reading the old field. Remove in v0.5. + deploymentDomainShadowPrevious: ($history[0] // null), deploymentDomainShadow: $shadowUrl, trafficShadowPercent: (.trafficShadowPercent // 1), trafficProdCanaryPercent: (.trafficProdCanaryPercent // 100), diff --git a/app/admin/admin.css b/app/admin/admin.css index ec2cfed..1e8e93c 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -383,6 +383,11 @@ color: rgba(255, 255, 255, 0.75); } +.adm-timing-countdown--overdue { + color: #fbbf24; + font-weight: 600; +} + /* ---------- Traffic bar ---------- */ .adm-bar-wrap { @@ -524,6 +529,16 @@ font-weight: 500; font-size: 0.88rem; padding-top: 2px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} + +.adm-legend-value-hint { + font-size: 0.68rem; + font-weight: 400; + opacity: 0.5; } /* ---------- SLO log ---------- */ @@ -538,15 +553,11 @@ } .adm-slo-row { - display: grid; - grid-template-columns: 16px auto auto 1fr auto; - align-items: center; - gap: 10px; font-size: 0.82rem; - padding: 6px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.02); border-left: 2px solid transparent; + overflow: hidden; } .adm-slo-row--ok { @@ -558,6 +569,32 @@ background: rgba(239, 68, 68, 0.05); } +.adm-slo-summary { + all: unset; + box-sizing: border-box; + display: grid; + grid-template-columns: 16px auto auto 1fr auto; + align-items: center; + gap: 10px; + font-size: inherit; + padding: 6px 10px; + width: 100%; + cursor: default; +} + +.adm-slo-row--clickable .adm-slo-summary { + cursor: pointer; +} + +.adm-slo-row--clickable .adm-slo-summary:hover { + background: rgba(255, 255, 255, 0.03); +} + +.adm-slo-summary:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.3); + outline-offset: -1px; +} + .adm-slo-icon { font-weight: 700; font-size: 0.95rem; @@ -603,7 +640,23 @@ font-weight: 600; } -.adm-slo-body { +.adm-slo-caret { + opacity: 0.45; + font-size: 0.75rem; + margin-left: 6px; + transition: opacity 0.12s; +} + +.adm-slo-row--clickable .adm-slo-summary:hover .adm-slo-caret { + opacity: 0.85; +} + +.adm-slo-body-wrap { + padding: 0 10px 0 36px; +} + +.adm-slo-body-preview { + display: block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.7rem; opacity: 0.5; @@ -611,9 +664,25 @@ text-overflow: ellipsis; white-space: nowrap; max-width: 100%; - grid-column: 1 / -1; - padding-left: 26px; - padding-top: 2px; + padding-bottom: 6px; +} + +.adm-slo-body-wrap--open { + padding-bottom: 10px; +} + +.adm-slo-body-full { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + opacity: 0.8; + margin: 6px 0 0; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.25); + border-radius: 4px; + white-space: pre-wrap; + word-break: break-all; + max-height: 300px; + overflow-y: auto; } .adm-slo-ok-inline { @@ -883,30 +952,25 @@ padding-right: 24px; } -.adm-shadow-rollback { - display: flex; +.adm-step-input-wrap { + position: relative; + display: inline-flex; align-items: center; - gap: 10px; - margin-top: 18px; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.05); - flex-wrap: wrap; } -.adm-shadow-rollback-label { - font-size: 0.82rem; - opacity: 0.75; -} - -.adm-shadow-rollback-urls { - font-size: 0.74rem; - opacity: 0.55; - flex: 1; - min-width: 0; +.adm-step-input-wrap::after { + content: '%'; + position: absolute; + right: 9px; + pointer-events: none; + color: rgba(255, 255, 255, 0.4); + font-size: 0.78rem; } -.adm-shadow-rollback-urls code { - font-size: 0.74rem; +.adm-step-input { + width: 62px; + padding-right: 22px; + font-size: 0.85rem; } /* ---------- Bucket forcer ---------- */ diff --git a/app/admin/dashboard-client.tsx b/app/admin/dashboard-client.tsx index 56dad80..daf3115 100644 --- a/app/admin/dashboard-client.tsx +++ b/app/admin/dashboard-client.tsx @@ -29,6 +29,15 @@ type BucketInfoMap = { prodPrevious: BucketInfo; }; +type ShadowHistoryEntry = { + url: string; + sha: string | null; + ref: string | null; + message: string | null; + createdAt: number | null; + state: string | null; +}; + type Status = | 'stable' | 'starting' @@ -42,7 +51,7 @@ type ModalState = | { kind: 'cancel' } | { kind: 'promote' } | { kind: 'rollback'; deploy: Deployment } - | { kind: 'rollback-shadow' }; + | { kind: 'rollback-shadow'; target: ShadowHistoryEntry }; function deriveStatus(cfg: ShadowConfig | null): Status { if (!cfg) return 'unknown'; @@ -67,6 +76,12 @@ function shortHost(url?: string): string { return url.replace(/^https?:\/\//, '').split('.')[0]; } +function stepSize(draft: string): number | null { + const n = Number(draft); + if (!Number.isFinite(n) || n < 1 || n > 50) return null; + return Math.round(n); +} + function prettyTimeAgo(ms: number): string { const diff = Date.now() - ms; const m = Math.floor(diff / 60_000); @@ -112,19 +127,22 @@ export function AdminDashboard({ initial }: Props) { const [config, setConfig] = useState(initial.config); const [deployments, setDeployments] = useState(initial.deployments); const [bucketInfo, setBucketInfo] = useState(null); + const [shadowHistory, setShadowHistory] = useState([]); const [error, setError] = useState(initial.error); const [actionError, setActionError] = useState(null); const [pendingAction, setPendingAction] = useState(null); const [refreshing, setRefreshing] = useState(false); const [modal, setModal] = useState(null); + const [stepInput, setStepInput] = useState('4'); const refresh = useCallback(async () => { setRefreshing(true); try { - const [stateRes, deployRes, bucketRes] = await Promise.all([ + const [stateRes, deployRes, bucketRes, historyRes] = await Promise.all([ fetch('/api/admin/state', { cache: 'no-store' }), fetch('/api/admin/deployments', { cache: 'no-store' }), fetch('/api/admin/bucket-info', { cache: 'no-store' }), + fetch('/api/admin/shadow-history', { cache: 'no-store' }), ]); if (stateRes.ok) { const { config: c } = await stateRes.json(); @@ -137,6 +155,12 @@ export function AdminDashboard({ initial }: Props) { if (bucketRes.ok) { setBucketInfo((await bucketRes.json()) as BucketInfoMap); } + if (historyRes.ok) { + const { entries } = (await historyRes.json()) as { + entries: ShadowHistoryEntry[]; + }; + setShadowHistory(entries ?? []); + } setError(null); } catch (e) { setError(e instanceof Error ? e.message : 'refresh failed'); @@ -202,7 +226,20 @@ export function AdminDashboard({ initial }: Props) { status === 'ramping' || status === 'starting' || status === 'paused'; const elapsed = startedAt ? now - startedAt : null; const hour = parisHour(now); - const msToNext = nextCronFireMs(now) - now; + // "Next check" is based on the last recorded SLO check + 15min when we have + // one (what actually happened), not the theoretical cron schedule. GH Actions + // cron has multi-minute latency and can fire at :03 instead of :00 — using + // the theoretical next :00/:15/:30/:45 drifts from reality by that margin. + // When no SLO check has run yet, fall back to the cron schedule. + const lastSloTs = config?.sloChecks?.[0]?.ts + ? new Date(config.sloChecks[0].ts).getTime() + : null; + const expectedNextTs = lastSloTs + ? lastSloTs + 15 * 60_000 + : nextCronFireMs(now); + // Signed: positive = still to come, negative = overdue (cron is late). + const msToNext = expectedNextTs - now; + const nextCheckOverdue = msToNext < 0; const activePhase = statusToPhase(status); const isBusy = pendingAction !== null; @@ -246,6 +283,7 @@ export function AdminDashboard({ initial }: Props) { status={status} elapsed={elapsed} msToNext={msToNext} + overdue={nextCheckOverdue} phase={phaseLabel(hour)} /> @@ -269,6 +307,7 @@ export function AdminDashboard({ initial }: Props) { host: shortHost(prevHost), active: canaryLive, info: bucketInfo?.prodPrevious, + valueHint: `${(100 - canaryPct).toFixed(0)}% du prod`, }, ] : []), @@ -282,6 +321,7 @@ export function AdminDashboard({ initial }: Props) { status === 'complete-sticky' || status === 'stable', info: bucketInfo?.prodNew, + valueHint: `${canaryPct}% du prod`, }, ]} /> @@ -324,25 +364,52 @@ export function AdminDashboard({ initial }: Props) {
Manuel + + setStepInput(e.target.value)} + aria-label="Taille du pas (en points de %)" + className="adm-input adm-step-input" + disabled={isBusy} + /> + - void run('step-back', '/api/admin/canary/step-back') + void run('step-back', '/api/admin/canary/step-back', { + step: stepSize(stepInput) ?? 4, + }) } > - − 4% (step back) + − {stepSize(stepInput) ?? 4}% (step back) = 100 || !prevHost} + disabled={ + isBusy || + canaryPct >= 100 || + !prevHost || + !stepSize(stepInput) + } onClick={() => - void run('step-forward', '/api/admin/canary/step-forward') + void run('step-forward', '/api/admin/canary/step-forward', { + step: stepSize(stepInput) ?? 4, + }) } > - + 4% (step forward) + + {stepSize(stepInput) ?? 4}% (step forward) void run('shadow-percent', '/api/admin/shadow-percent', { value }) } - onRollback={() => setModal({ kind: 'rollback-shadow' })} + /> + + {/* ---------- Shadow history ---------- */} + + setModal({ kind: 'rollback-shadow', target: entry }) + } /> {/* ---------- Phases diagram ---------- */} @@ -523,32 +597,51 @@ export function AdminDashboard({ initial }: Props) { -

- Swap deploymentDomainShadow et{' '} - deploymentDomainShadowPrevious. Le shadow actuel ( - {shortHost(config?.deploymentDomainShadow)}) passe - en "previous", le précédent ( - {shortHost(config?.deploymentDomainShadowPrevious)}) - reprend les {shadowPct}% de trafic shadow. -

-

- Pas de re-alias de domaine (contrairement au rollback prod) — - le shadow est adressé directement par URL dans le middleware. - Action instantanée et réversible (le swap est symétrique, tu - peux cliquer à nouveau pour revenir). -

- + modal?.kind === 'rollback-shadow' ? ( + <> +

+ Passe deploymentDomainShadow sur{' '} + {shortHost(modal.target.url)} + {modal.target.sha && ( + <> + {' '} + ({modal.target.sha.slice(0, 7)}) + + )} + . L'ancien shadow ( + {shortHost(config?.deploymentDomainShadow)}) + remonte en tête d'historique, donc tu pourras y revenir + si besoin. +

+

+ Pas de re-alias de domaine (contrairement au rollback prod) — + le shadow est adressé directement par URL dans le middleware. + Propagation en ≤ 60s (TTL cache Edge Config). +

+ + ) : null + } + confirmPhrase={ + modal?.kind === 'rollback-shadow' + ? (modal.target.sha?.slice(0, 7) ?? 'rollback-shadow') + : undefined } - confirmPhrase="rollback-shadow" confirmLabel="Rollback shadow" - pending={pendingAction === 'rollback-shadow'} - onClose={() => setModal(null)} - onConfirm={() => - void run('rollback-shadow', '/api/admin/rollback-shadow') + pending={ + modal?.kind === 'rollback-shadow' && + pendingAction === `rollback-shadow-${modal.target.url}` } + onClose={() => setModal(null)} + onConfirm={() => { + if (modal?.kind !== 'rollback-shadow') return; + void run( + `rollback-shadow-${modal.target.url}`, + '/api/admin/rollback-shadow', + { targetUrl: modal.target.url }, + ); + }} /> ); @@ -588,12 +681,14 @@ function TimingLine({ status, elapsed, msToNext, + overdue, phase, }: { canaryLive: boolean; status: Status; elapsed: number | null; msToNext: number; + overdue: boolean; phase: string; }) { const items: React.ReactNode[] = [phase]; @@ -602,12 +697,21 @@ function TimingLine({ } if (status === 'ramping' || status === 'starting') { items.push( - <> - Prochain check dans{' '} - - {formatDuration(msToNext)} - - , + overdue ? ( + <> + Check attendu{' '} + + il y a {formatDuration(-msToNext)} + + + ) : ( + <> + Prochain check dans{' '} + + {formatDuration(msToNext)} + + + ), ); } else if (status === 'paused') { items.push(<>Pause · cron skippé); @@ -641,6 +745,10 @@ type Segment = { host: string; active: boolean; info?: BucketInfo; + // Optional secondary number shown after the main %. Example: "8% du prod" + // on the new-prod bucket so the operator can map the traffic share (7.9% + // of total) back to the canary knob (8% of prod). + valueHint?: string; }; function TrafficBar({ segments }: { segments: Segment[] }) { @@ -692,7 +800,12 @@ function TrafficBar({ segments }: { segments: Segment[] }) { )} - {s.value.toFixed(1)}% + + {s.value.toFixed(1)}% + {s.valueHint && ( + {s.valueHint} + )} + ))} @@ -707,6 +820,16 @@ function SloLog({ checks: NonNullable; now: number; }) { + const [expanded, setExpanded] = useState>(new Set()); + const toggle = useCallback((i: number) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); + }, []); + return (
@@ -729,7 +852,8 @@ function SloLog({ ne tourne pas ; si elle est pleine de{' '} , le ramp avance ; des{' '} indiquent un SLO qui a - rollback. + rollback. Clique sur une ligne pour voir le body complet du dernier + probe.

{checks.length === 0 ? (

@@ -744,32 +868,67 @@ function SloLog({ const fullTs = new Date(c.ts).toLocaleString('fr-FR'); const codes = c.codes.map((x) => x || '—').join(' / '); const isRollback = !c.ok && c.pctAfter === 0; + const isOpen = expanded.has(i); + const hasBody = Boolean(c.bodyExcerpt); return (

  • - - - {ago} - - {codes} - - {c.pctBefore}% →{' '} - {c.pctAfter}% - {isRollback && ( - rollback +
  • ); @@ -818,22 +977,14 @@ function ActionBtn({ function ShadowPercentCard({ current, - previousShadowUrl, - currentShadowUrl, pending, - pendingRollback, disabled, onSave, - onRollback, }: { current: number; - previousShadowUrl?: string; - currentShadowUrl?: string; pending: boolean; - pendingRollback: boolean; disabled: boolean; onSave: (value: number) => void; - onRollback: () => void; }) { const [draft, setDraft] = useState(String(current)); @@ -887,44 +1038,129 @@ function ShadowPercentCard({ )}
    +
    + ); +} -
    - - Rollback shadow au deploy précédent - - - {currentShadowUrl ? ( - <> - actuel {shortHost(currentShadowUrl)} - - ) : ( - aucun shadow actuel - )} - {previousShadowUrl && ( - <> - {' '} - · précédent {shortHost(previousShadowUrl)} - - )} - - + {entries.length} / 20 +
    +

    + Les 20 derniers deploys de la branche master. Chaque push + sur master empile l'ancien URL ici avant d'être + remplacé. Cliquer « Rollback » passe deploymentDomainShadow{' '} + sur ce deploy — pas de re-alias de domaine, propagation ≤ 60s. +

    + {entries.length === 0 ? ( +

    + Aucun shadow précédent — le premier apparaîtra ici après le prochain + push sur master. +

    + ) : ( +
      + {entries.map((e) => ( + onRollback(e)} + /> + ))} +
    + )} ); } +function ShadowHistoryRow({ + entry, + isCurrent, + onRollback, + disabled, + pending, +}: { + entry: ShadowHistoryEntry; + isCurrent: boolean; + onRollback: () => void; + disabled: boolean; + pending: boolean; +}) { + const ref = entry.ref ?? 'master'; + const sha = entry.sha?.slice(0, 7) ?? ''; + const msg = entry.message ?? shortHost(entry.url); + const state = entry.state; + const stateClass = + state === 'READY' + ? 'adm-deploy-state--ready' + : state === 'ERROR' + ? 'adm-deploy-state--error' + : 'adm-deploy-state--other'; + + return ( +
  • +
  • + ); +} + function DeploymentRow({ deployment, isCurrent, diff --git a/app/api/admin/canary/step-back/route.ts b/app/api/admin/canary/step-back/route.ts index 6ed28be..3a7afb2 100644 --- a/app/api/admin/canary/step-back/route.ts +++ b/app/api/admin/canary/step-back/route.ts @@ -4,19 +4,38 @@ import { patchShadowConfig, readShadowConfig } from '@/lib/admin-vercel'; export const dynamic = 'force-dynamic'; -const STEP = 4; +const DEFAULT_STEP = 4; +const MAX_STEP = 50; -export async function POST() { +export async function POST(request: Request) { if (!(await requireAdmin())) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } + + let step = DEFAULT_STEP; + try { + const body = (await request.json().catch(() => ({}))) as { step?: unknown }; + if (body.step !== undefined) { + const n = Number(body.step); + if (!Number.isFinite(n) || n < 1 || n > MAX_STEP) { + return NextResponse.json( + { error: `invalid_step — must be a number in [1, ${MAX_STEP}]` }, + { status: 400 }, + ); + } + step = Math.round(n); + } + } catch { + // fall through to default + } + try { const current = (await readShadowConfig()) ?? {}; const pct = current.trafficProdCanaryPercent ?? 100; if (pct <= 0) { return NextResponse.json({ error: 'already_at_0' }, { status: 409 }); } - const next = Math.max(0, pct - STEP); + const next = Math.max(0, pct - step); const config = await patchShadowConfig({ trafficProdCanaryPercent: next }); return NextResponse.json({ config }); } catch (e) { diff --git a/app/api/admin/canary/step-forward/route.ts b/app/api/admin/canary/step-forward/route.ts index f8c62c1..fb1e538 100644 --- a/app/api/admin/canary/step-forward/route.ts +++ b/app/api/admin/canary/step-forward/route.ts @@ -4,19 +4,38 @@ import { patchShadowConfig, readShadowConfig } from '@/lib/admin-vercel'; export const dynamic = 'force-dynamic'; -const STEP = 4; +const DEFAULT_STEP = 4; +const MAX_STEP = 50; -export async function POST() { +export async function POST(request: Request) { if (!(await requireAdmin())) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } + + let step = DEFAULT_STEP; + try { + const body = (await request.json().catch(() => ({}))) as { step?: unknown }; + if (body.step !== undefined) { + const n = Number(body.step); + if (!Number.isFinite(n) || n < 1 || n > MAX_STEP) { + return NextResponse.json( + { error: `invalid_step — must be a number in [1, ${MAX_STEP}]` }, + { status: 400 }, + ); + } + step = Math.round(n); + } + } catch { + // fall through to default + } + try { const current = (await readShadowConfig()) ?? {}; const pct = current.trafficProdCanaryPercent ?? 100; if (pct >= 100) { return NextResponse.json({ error: 'already_at_100' }, { status: 409 }); } - const next = Math.min(100, pct + STEP); + const next = Math.min(100, pct + step); const config = await patchShadowConfig({ trafficProdCanaryPercent: next }); return NextResponse.json({ config }); } catch (e) { diff --git a/app/api/admin/rollback-shadow/route.ts b/app/api/admin/rollback-shadow/route.ts index 4859eff..15a773e 100644 --- a/app/api/admin/rollback-shadow/route.ts +++ b/app/api/admin/rollback-shadow/route.ts @@ -4,35 +4,90 @@ import { patchShadowConfig, readShadowConfig } from '@/lib/admin-vercel'; export const dynamic = 'force-dynamic'; -// Swap the current shadow deploy URL with the previous one saved by -// deploy-shadow.yml. Symmetric to the prod rollback, but no Vercel promote -// needed — shadow is addressed by per-deploy URL in the middleware rewrite, -// not via the custom domain alias. The current shadow URL moves into -// `deploymentDomainShadowPrevious` so the operator can toggle back if the -// rollback itself was a mistake. -export async function POST() { +// Swap the current shadow deploy URL with an older one from history. No +// Vercel promote needed — shadow is addressed by per-deploy URL in the +// middleware rewrite, not via the custom domain alias. +// +// Body: { targetUrl?: string } +// - If `targetUrl` is provided, it must be in `shadowHistory` (or the legacy +// `deploymentDomainShadowPrevious`). Lets operators rollback further than +// one step when a few recent shadows are all known-bad. +// - If omitted, defaults to the most recent history entry (previous shadow). +// +// After the swap, the old current URL is prepended to history (deduped) and +// the chosen URL is removed from its old position — so the rollback target +// doesn't appear twice. +export async function POST(request: Request) { if (!(await requireAdmin())) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } + let body: { targetUrl?: string } = {}; + try { + body = (await request.json().catch(() => ({}))) as { targetUrl?: string }; + } catch { + // Empty body = use default (most recent previous). + } + try { const current = await readShadowConfig(); - const prev = current?.deploymentDomainShadowPrevious; const cur = current?.deploymentDomainShadow; - if (!prev) { + // Union of new shadowHistory and the deprecated deploymentDomainShadowPrevious. + const history = (current?.shadowHistory ?? []).slice(); + if ( + current?.deploymentDomainShadowPrevious && + !history.includes(current.deploymentDomainShadowPrevious) + ) { + history.unshift(current.deploymentDomainShadowPrevious); + } + + const target = body.targetUrl ?? history[0]; + if (!target) { return NextResponse.json( { error: - 'no_previous_shadow — there is no deploymentDomainShadowPrevious to swap to. Push a new shadow deploy first.', + 'no_history — no previous shadow to swap to. Push a new shadow deploy first.', + }, + { status: 400 }, + ); + } + if (body.targetUrl && !history.includes(body.targetUrl)) { + return NextResponse.json( + { + error: `target_not_in_history — ${body.targetUrl} is not in shadowHistory. Refresh and pick an entry from the list.`, }, { status: 400 }, ); } + if (target === cur) { + return NextResponse.json( + { error: 'target_is_current — cannot rollback to the active shadow.' }, + { status: 400 }, + ); + } + + // New history: prepend the outgoing (cur), remove target from its old + // position, dedupe + trim to 20. + const nextHistory = (() => { + const pool: string[] = []; + if (cur) pool.push(cur); + for (const u of history) if (u !== target) pool.push(u); + const seen = new Set(); + const out: string[] = []; + for (const u of pool) { + if (seen.has(u)) continue; + seen.add(u); + out.push(u); + } + return out.slice(0, 20); + })(); const config = await patchShadowConfig({ - deploymentDomainShadow: prev, - deploymentDomainShadowPrevious: cur, // keep the "rolled-back" URL so we can toggle back + deploymentDomainShadow: target, + shadowHistory: nextHistory, + // Deprecated: keep in sync for v0.4.x admin UIs still reading it. + deploymentDomainShadowPrevious: nextHistory[0] ?? undefined, }); return NextResponse.json({ config }); } catch (e) { diff --git a/app/api/admin/shadow-history/route.ts b/app/api/admin/shadow-history/route.ts new file mode 100644 index 0000000..ddc1300 --- /dev/null +++ b/app/api/admin/shadow-history/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/admin-auth'; +import { getDeploymentByUrl, readShadowConfig } from '@/lib/admin-vercel'; +import type { Deployment } from '@/lib/admin-vercel'; + +export const dynamic = 'force-dynamic'; + +type HistoryEntry = { + url: string; + sha: string | null; + ref: string | null; + message: string | null; + createdAt: number | null; + state: string | null; +}; + +function summarize(url: string, d: Deployment | null): HistoryEntry { + if (!d) + return { url, sha: null, ref: null, message: null, createdAt: null, state: null }; + return { + url, + sha: d.meta?.githubCommitSha ?? null, + ref: d.meta?.githubCommitRef ?? null, + message: d.meta?.githubCommitMessage ?? null, + createdAt: d.createdAt ?? null, + state: d.state ?? null, + }; +} + +export async function GET() { + if (!(await requireAdmin())) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + try { + const config = await readShadowConfig(); + // Union of new `shadowHistory` + legacy `deploymentDomainShadowPrevious`. + // The legacy field will be absent once v0.4.x deploys roll over. + const urls = (config?.shadowHistory ?? []).slice(); + if ( + config?.deploymentDomainShadowPrevious && + !urls.includes(config.deploymentDomainShadowPrevious) + ) { + urls.unshift(config.deploymentDomainShadowPrevious); + } + + const entries = await Promise.all( + urls.map(async (url) => { + try { + const d = await getDeploymentByUrl(url); + return summarize(url, d); + } catch { + return summarize(url, null); + } + }), + ); + return NextResponse.json({ entries }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +} diff --git a/package-lock.json b/package-lock.json index 56e343b..482da9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "stargaze", "version": "0.1.0", "dependencies": { - "@dotworld/shadow-canary-core": "^0.4.0", + "@dotworld/shadow-canary-core": "^0.5.0", "@vercel/edge-config": "^1.4.3", "next": "^15.0.0", "react": "^19.0.0", @@ -38,9 +38,9 @@ } }, "node_modules/@dotworld/shadow-canary-core": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@dotworld/shadow-canary-core/-/shadow-canary-core-0.4.0.tgz", - "integrity": "sha512-f1CfvkFedLYw9+7qzooE8GEU7I4WZSqwJX3m3s4IVbGbR775HvQZaatBGubntb4RM9DUDryMAmB5sINRvci4Pw==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dotworld/shadow-canary-core/-/shadow-canary-core-0.5.0.tgz", + "integrity": "sha512-4gSu1y8ewLrqIePgTkOMTMKAQittZt9VfAsXodMXSmS+UVQlVUW7iF2zJEKSHJIV+Inre5bpLN1JAP3BU9Qntw==", "license": "MIT", "peerDependencies": { "@vercel/edge-config": ">=1.0.0", diff --git a/package.json b/package.json index 8cceeaa..62bbda3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start" }, "dependencies": { - "@dotworld/shadow-canary-core": "^0.4.0", + "@dotworld/shadow-canary-core": "^0.5.0", "@vercel/edge-config": "^1.4.3", "next": "^15.0.0", "react": "^19.0.0",