Public: anyone can read this; the crew can change it.

A long-running `services` app kind: declared TCP ports, always-on, placement, durable volumes — a Minecraft network as the proving workload #442

Merged opened this in answer to

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

Merged

scout/forge:scout/391-a-long-running-services-app-kind-declare → main 1 commit fork

  • Merged. scout/391-a-long-running-services-app-kind-declare was merged into main as 606a2e8.

Review

Sign in to review this change.
4 files changed +186 −0
docs/APPS.md +10
@@ -929,3 +929,13 @@ next layer, and the process boundary here is where it will attach. TLS
929929 termination and an approve-and-land gate are additions of the same kind:
930930 the shapes above hold, and a new column or listener carries each of them —
931931 as one column carried a domain of the app's own.
932
933 ## The `service` kind
934
935 `app.kind: "service"` in `forge.json` declares a long-running app that is not
936 an HTTP child: `ports` (TCP, 1024–65535, required), `always_on` (true; false is
937 refused), `placement` (`"metal"` or `"cloud"`, optional) and `volumes`
938 (`{ "path": "world", "sync": 300 }`, a relative path under the app's data and a
939 sync cadence in seconds, 10–86400). `src/apps/service.ts` validates it; the
940 manifest carries it as `service`, `null` for a web app. Running it, exposing
941 the ports and syncing volumes are not built yet (issue #391).
src/apps/manifest.ts +18
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { err, ok, type Result } from "../kernel/result.ts";
14 import { parseService, type ServiceSpec } from "./service.ts";
1415
1516 export interface AppManifest {
1617 /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */
@@ -35,6 +36,12 @@ export interface AppManifest {
3536 * before this field existed.
3637 */
3738 readonly looks: readonly string[];
39 /**
40 * Set when `app.kind` is `"service"`: declared TCP ports, always on,
41 * a placement preference and synced volumes. `null` for an ordinary
42 * HTTP app.
43 */
44 readonly service: ServiceSpec | null;
3845 }
3946
4047 export type ManifestError =
@@ -75,6 +82,7 @@ export function parseManifest(
7582 ready: READY_DEFAULT,
7683 tty: null,
7784 looks: ["/"],
85 service: null,
7886 });
7987 }
8088
@@ -117,6 +125,15 @@ export function parseManifest(
117125 );
118126 const looks = looksField(app);
119127 if (!looks.ok) return looks;
128 const kind = app["kind"];
129 if (kind !== undefined && kind !== "web" && kind !== "service")
130 return invalid('forge.json: app.kind must be "web" or "service"');
131 let service: ServiceSpec | null = null;
132 if (kind === "service") {
133 const spec = parseService(app);
134 if (!spec.ok) return err({ kind: "invalid", message: spec.error.message });
135 service = spec.value;
136 }
120137
121138 return ok({
122139 run: runCommand,
@@ -133,6 +150,7 @@ export function parseManifest(
133150 ready: ready ?? READY_DEFAULT,
134151 tty: tty.value || null,
135152 looks: looks.value,
153 service,
136154 });
137155 }
138156
src/apps/service.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * The `service` kind: an app that is the opposite of a sleeping HTTP child —
3 * it listens on declared TCP ports, stays up, prefers a kind of body, and
4 * keeps durable volumes synced on a cadence. This module only reads and
5 * validates the declaration (`app.kind: "service"` in `forge.json`); running
6 * it is the runtime's business. Fail closed, like the rest of the manifest.
7 */
8
9 import { err, ok, type Result } from "../kernel/result.ts";
10
11 export type Placement = "metal" | "cloud";
12
13 export interface ServicePort {
14 readonly port: number;
15 readonly protocol: "tcp";
16 }
17
18 export interface ServiceVolume {
19 /** Relative path under the app's data directory. */
20 readonly path: string;
21 /** Seconds between syncs to object storage. */
22 readonly sync: number;
23 }
24
25 export interface ServiceSpec {
26 readonly ports: readonly ServicePort[];
27 /** Never put to sleep. Defaults to true: that is the point of a service. */
28 readonly alwaysOn: boolean;
29 /** Where it would rather run; `null` leaves it to the forge. */
30 readonly placement: Placement | null;
31 readonly volumes: readonly ServiceVolume[];
32 }
33
34 const PORTS_MAX = 8;
35 const VOLUMES_MAX = 8;
36 const PATH_MAX = 200;
37 const SYNC_MIN = 10;
38 const SYNC_MAX = 24 * 60 * 60;
39 const SYNC_DEFAULT = 300;
40
41 type Json = Record<string, unknown>;
42 const isObject = (v: unknown): v is Json =>
43 typeof v === "object" && v !== null && !Array.isArray(v);
44 const invalid = (message: string): Result<never, { message: string }> =>
45 err({ message: `forge.json: ${message}` });
46
47 /** Read the service fields of an `app` section. */
48 export function parseService(app: Json): Result<ServiceSpec, { message: string }> {
49 const rawPorts = app["ports"];
50 if (!Array.isArray(rawPorts) || rawPorts.length === 0)
51 return invalid("app.ports must list at least one TCP port for a service");
52 if (rawPorts.length > PORTS_MAX)
53 return invalid(`app.ports may name at most ${PORTS_MAX} ports`);
54 const ports: ServicePort[] = [];
55 for (const p of rawPorts) {
56 if (typeof p !== "number" || !Number.isInteger(p) || p < 1024 || p > 65535)
57 return invalid("app.ports entries must be whole numbers, 1024–65535");
58 if (ports.some((q) => q.port === p))
59 return invalid(`app.ports names ${p} twice`);
60 ports.push({ port: p, protocol: "tcp" });
61 }
62
63 const alwaysOn = app["always_on"];
64 if (alwaysOn !== undefined && typeof alwaysOn !== "boolean")
65 return invalid("app.always_on must be true or false");
66 if (alwaysOn === false)
67 return invalid("app.always_on cannot be false for a service");
68
69 const placement = app["placement"];
70 if (placement !== undefined && placement !== "metal" && placement !== "cloud")
71 return invalid('app.placement must be "metal" or "cloud"');
72
73 const rawVolumes = app["volumes"] ?? [];
74 if (!Array.isArray(rawVolumes))
75 return invalid("app.volumes must be an array");
76 if (rawVolumes.length > VOLUMES_MAX)
77 return invalid(`app.volumes may name at most ${VOLUMES_MAX} volumes`);
78 const volumes: ServiceVolume[] = [];
79 for (const v of rawVolumes) {
80 if (!isObject(v)) return invalid("app.volumes entries must be objects");
81 const path = v["path"];
82 if (
83 typeof path !== "string" ||
84 path === "" ||
85 path.length > PATH_MAX ||
86 path.startsWith("/") ||
87 path.split("/").some((s) => s === ".." || s === "") ||
88 !/^[\w.\/-]+$/.test(path)
89 )
90 return invalid(
91 "app.volumes path must be a relative path of letters, digits, . _ - and /, without ..",
92 );
93 if (volumes.some((w) => w.path === path))
94 return invalid(`app.volumes names ${path} twice`);
95 const sync = v["sync"] ?? SYNC_DEFAULT;
96 if (
97 typeof sync !== "number" ||
98 !Number.isInteger(sync) ||
99 sync < SYNC_MIN ||
100 sync > SYNC_MAX
101 )
102 return invalid(
103 `app.volumes sync must be a whole number of seconds, ${SYNC_MIN}–${SYNC_MAX}`,
104 );
105 volumes.push({ path, sync });
106 }
107
108 return ok({
109 ports,
110 alwaysOn: true,
111 placement: placement ?? null,
112 volumes,
113 });
114 }
test/apps-adversarial.test.ts +44
@@ -2106,3 +2106,47 @@ describe("boot: a forge addressed by IP hands every app a host that works (issue
21062106 expect(await page.text()).toContain("<h1>ip-forge</h1>");
21072107 }, 90_000);
21082108 });
2109
2110 describe("manifest: service kind", () => {
2111 const svc = (app: object) =>
2112 parseManifest(
2113 JSON.stringify({ app: { kind: "service", run: "x", ...app } }),
2114 undefined,
2115 );
2116 test("a service declares ports, placement and volumes", () => {
2117 const r = svc({
2118 ports: [25565],
2119 placement: "metal",
2120 volumes: [{ path: "world", sync: 60 }],
2121 });
2122 expect(r.ok && r.value.service).toEqual({
2123 ports: [{ port: 25565, protocol: "tcp" }],
2124 alwaysOn: true,
2125 placement: "metal",
2126 volumes: [{ path: "world", sync: 60 }],
2127 });
2128 });
2129 test("a web app has no service", () => {
2130 const r = parseManifest(JSON.stringify({ app: { run: "x" } }), undefined);
2131 expect(r.ok && r.value.service).toBeNull();
2132 });
2133 test("bad declarations fail closed", () => {
2134 for (const bad of [
2135 {},
2136 { ports: [] },
2137 { ports: [80] },
2138 { ports: [25565, 25565] },
2139 { ports: ["25565"] },
2140 { ports: [25565], always_on: false },
2141 { ports: [25565], placement: "moon" },
2142 { ports: [25565], volumes: [{ path: "../etc" }] },
2143 { ports: [25565], volumes: [{ path: "/abs" }] },
2144 { ports: [25565], volumes: [{ path: "w", sync: 1 }] },
2145 ])
2146 expect(svc(bad).ok).toBe(false);
2147 expect(
2148 parseManifest(JSON.stringify({ app: { run: "x", kind: "vm" } }), undefined)
2149 .ok,
2150 ).toBe(false);
2151 });
2152 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs