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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/391-a-long-running-services-app-kind-declare → main 1 commit fork
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 | ||
| 929 | 929 | termination and an approve-and-land gate are additions of the same kind: |
| 930 | 930 | the shapes above hold, and a new column or listener carries each of them — |
| 931 | 931 | 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 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 14 | import { parseService, type ServiceSpec } from "./service.ts"; | |
| 14 | 15 | |
| 15 | 16 | export interface AppManifest { |
| 16 | 17 | /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */ |
| @@ -35,6 +36,12 @@ export interface AppManifest { | ||
| 35 | 36 | * before this field existed. |
| 36 | 37 | */ |
| 37 | 38 | 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; | |
| 38 | 45 | } |
| 39 | 46 | |
| 40 | 47 | export type ManifestError = |
| @@ -75,6 +82,7 @@ export function parseManifest( | ||
| 75 | 82 | ready: READY_DEFAULT, |
| 76 | 83 | tty: null, |
| 77 | 84 | looks: ["/"], |
| 85 | service: null, | |
| 78 | 86 | }); |
| 79 | 87 | } |
| 80 | 88 | |
| @@ -117,6 +125,15 @@ export function parseManifest( | ||
| 117 | 125 | ); |
| 118 | 126 | const looks = looksField(app); |
| 119 | 127 | 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 | } | |
| 120 | 137 | |
| 121 | 138 | return ok({ |
| 122 | 139 | run: runCommand, |
| @@ -133,6 +150,7 @@ export function parseManifest( | ||
| 133 | 150 | ready: ready ?? READY_DEFAULT, |
| 134 | 151 | tty: tty.value || null, |
| 135 | 152 | looks: looks.value, |
| 153 | service, | |
| 136 | 154 | }); |
| 137 | 155 | } |
| 138 | 156 |
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 | ||
| 2106 | 2106 | expect(await page.text()).toContain("<h1>ip-forge</h1>"); |
| 2107 | 2107 | }, 90_000); |
| 2108 | 2108 | }); |
| 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 | }); |
new above
offline