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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2456-archive-part-3a-archiving-stops-an-app-s → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2456-archive-part-3a-archiving-stops-an-app-s was merged into main as fca5360.

Review

Sign in to review this change.
4 files changed +71 −2
src/actions/repos.ts +7
@@ -7,6 +7,7 @@
77 import { requireVerified } from "./verify.ts";
88 import { requireAbility } from "./roles.ts";
99 import type { App } from "../app.ts";
10 import { appsRuntime } from "../apps/runtime.ts";
1011 import { canSeeSealed } from "../core/sealed.ts";
1112 import { capacityValue } from "../core/capacity.ts";
1213 import { NEW_FOR_MS } from "./accounts.ts";
@@ -310,6 +311,12 @@ export async function updateRepoSettings(
310311 if (!moved.ok) return err("unknown_branch");
311312 }
312313 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);

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.

318 if (runtime !== undefined) await runtime.stopRepo(repo.id);
319 }
313320 const changed: string[] = (
314321 [
315322 "visibility",
src/apps/proxy.ts +33
@@ -453,6 +453,10 @@ function admit(
453453 return err(new Response("not found", { status: 404 }));
454454 }
455455 }
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));
456460 // A preview stopped for want of attention is asked for: it deploys again
457461 // the ordinary way, and this request sees "starting" until it answers.
458462 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
10871091 });
10881092 }
10891093
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
10901123 const escape = (s: string): string =>
10911124 s.replace(/[&<>"']/g, (c) =>
10921125 c === "&"
src/apps/runtime.ts +7 −2
@@ -931,8 +931,13 @@ export class AppsRuntime {
931931 reason: string,
932932 actorId?: string,
933933 ): 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 )
936941 return undefined;
937942 if (!this.mayRun(record, repo, env, commitOid)) return undefined;
938943 const state = this.envFor(record, env);
test/apps.test.ts +24
@@ -437,6 +437,30 @@ describe("apps: production, previews, data, gates", () => {
437437 expect(merged.ok).toBe(true);
438438 }, 120_000);
439439
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.

440464 });
441465
442466 describe("a preview whose copied data the build cannot read starts fresh (issue #36)", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs