Public: anyone can read this; the crew can change it.
Archive, part 3a: archiving stops an app's production and previews with its data kept, and its URL says it is archived #2462
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2456-archive-part-3a-archiving-stops-an-app-s → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +71
−2
src/actions/repos.ts +7
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import { requireVerified } from "./verify.ts"; |
| 8 | 8 | import { requireAbility } from "./roles.ts"; |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 10 | 11 | import { canSeeSealed } from "../core/sealed.ts"; |
| 11 | 12 | import { capacityValue } from "../core/capacity.ts"; |
| 12 | 13 | import { NEW_FOR_MS } from "./accounts.ts"; |
| @@ -310,6 +311,12 @@ export async function updateRepoSettings( | ||
| 310 | 311 | if (!moved.ok) return err("unknown_branch"); |
| 311 | 312 | } |
| 312 | 313 | const updated = writeSettings(app.db, repo, settings); |
| 314 | // Archiving puts the app away: production and every preview stop through | |
| 315 | // the runtime's ordinary stop; their data stays where it is. | |
| 316 | if (updated.archivedAt !== null && repo.archivedAt === null) { | |
| 317 | const runtime = appsRuntime(app); | |
| 318 | if (runtime !== undefined) await runtime.stopRepo(repo.id); | |
| 319 | } | |
| 313 | 320 | const changed: string[] = ( |
| 314 | 321 | [ |
| 315 | 322 | "visibility", |
src/apps/proxy.ts +33
| @@ -453,6 +453,10 @@ function admit( | ||
| 453 | 453 | return err(new Response("not found", { status: 404 })); |
| 454 | 454 | } |
| 455 | 455 | } |
| 456 | // An archived repository's app is put away: stopped, its data kept, and | |
| 457 | // never woken by a visit. | |
| 458 | const repo = getRepoById(runtime.app.db, target.app.repoId); | |
| 459 | if (repo?.archivedAt != null) return err(archivedPage(target)); | |
| 456 | 460 | // A preview stopped for want of attention is asked for: it deploys again |
| 457 | 461 | // the ordinary way, and this request sees "starting" until it answers. |
| 458 | 462 | if (runtime.envStatus(target.app, target.env)?.dormant !== undefined) |
| @@ -1087,6 +1091,35 @@ ${report ? '<p><a href="/__forge/report">Report a problem</a> to the people who | ||
| 1087 | 1091 | }); |
| 1088 | 1092 | } |
| 1089 | 1093 | |
| 1094 | /** The short page an archived repository's app answers with: 503, not a 502. */ | |
| 1095 | function archivedPage(target: AppTarget): Response { | |
| 1096 | const name = escape(target.app.slug); | |
| 1097 | const html = `<!doctype html> | |
| 1098 | <meta charset="utf-8"> | |
| 1099 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 1100 | <title>${name} — archived</title> | |
| 1101 | <style> | |
| 1102 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 1103 | body { max-width: 34rem; margin: 20vh auto; padding: 0 1.5rem; } | |
| 1104 | h1 { font-size: 1.25rem; margin: 0 0 .5rem; } | |
| 1105 | p { margin: .5rem 0; opacity: .85; } | |
| 1106 | </style> | |
| 1107 | <h1>${name} is archived</h1> | |
| 1108 | <p>Its people have put it away. It is not running, and its data is kept.</p> | |
| 1109 | `; | |
| 1110 | return new Response(html, { | |
| 1111 | status: 503, | |
| 1112 | headers: { | |
| 1113 | "content-type": "text/html; charset=utf-8", | |
| 1114 | "cache-control": "no-store", | |
| 1115 | "x-content-type-options": "nosniff", | |
| 1116 | "referrer-policy": "same-origin", | |
| 1117 | "content-security-policy": | |
| 1118 | "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'", | |
| 1119 | }, | |
| 1120 | }); | |
| 1121 | } | |
| 1122 | ||
| 1090 | 1123 | const escape = (s: string): string => |
| 1091 | 1124 | s.replace(/[&<>"']/g, (c) => |
| 1092 | 1125 | c === "&" |
src/apps/runtime.ts +7 −2
| @@ -931,8 +931,13 @@ export class AppsRuntime { | ||
| 931 | 931 | reason: string, |
| 932 | 932 | actorId?: string, |
| 933 | 933 | ): Run | undefined { |
| 934 | // A suspended or removed owner's app does not deploy. | |
| 935 | if (this.stopped || !accountActive(this.app.db, repo.ownerId)) | |
| 934 | // A suspended or removed owner's app does not deploy, nor does an | |
| 935 | // archived repository's: it is put away, its data kept. | |
| 936 | if ( | |
| 937 | this.stopped || | |
| 938 | repo.archivedAt !== null || | |
| 939 | !accountActive(this.app.db, repo.ownerId) | |
| 940 | ) | |
| 936 | 941 | return undefined; |
| 937 | 942 | if (!this.mayRun(record, repo, env, commitOid)) return undefined; |
| 938 | 943 | const state = this.envFor(record, env); |
test/apps.test.ts +24
| @@ -437,6 +437,30 @@ describe("apps: production, previews, data, gates", () => { | ||
| 437 | 437 | expect(merged.ok).toBe(true); |
| 438 | 438 | }, 120_000); |
| 439 | 439 | |
| 440 | test("archiving stops the app, keeps its data, and its URL says it is archived", async () => { | |
| 441 | expect(runtime.envStatus(record, "production")?.state).toBe("running"); | |
| 442 | const data = envDataDir(dataDir, record.id, "production"); | |
| 443 | writeFileSync(join(data, "archive-marker"), "kept"); | |
| 444 | ||
| 445 | const archived = await updateRepoSettings(app, alice, repo, { | |
| 446 | archived: true, | |
| 447 | }); | |
| 448 | expect(archived.ok).toBe(true); | |
| 449 | ||
| 450 | expect(runtime.envStatus(record, "production")?.state).not.toBe("running"); | |
| 451 | expect(existsSync(join(data, "archive-marker"))).toBe(true); | |
| 452 | const page = await viaHost(`hello.${DOMAIN}`); | |
| 453 | expect(page.status).toBe(503); | |
| 454 | expect(await page.text()).toContain("hello is archived"); | |
| 455 | expect(page.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 456 | expect(page.headers.get("referrer-policy")).toBe("same-origin"); | |
| 457 | expect(page.headers.get("content-security-policy")).toContain( | |
| 458 | "default-src 'none'", | |
| 459 | ); | |
| 460 | // A visit does not wake it. | |
| 461 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(503); | |
| 462 | expect(runtime.envStatus(record, "production")?.state).not.toBe("running"); | |
| 463 | }, 60_000); | |
Add a preview-host assertion once a preview survives to this point; the preview stop is the half that is untested. |
||
| 440 | 464 | }); |
| 441 | 465 | |
| 442 | 466 | describe("a preview whose copied data the build cannot read starts fresh (issue #36)", () => { |
new above
offline
If stopRepo throws, settings are already written as archived with the app still running. Consider catching and logging so the archive still reports ok, since admit() already blocks visits.