@intentius/chant-lexicon-fly 0.90.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/components/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +195 -0
- package/dist/components/fly-release.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +86 -0
- package/dist/composites/fly-site.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fly-apply.d.ts +8 -22
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-release-step.d.ts +24 -0
- package/dist/op/activities/fly-release-step.d.ts.map +1 -0
- package/dist/op/activities/fly-rollback-step.d.ts +25 -0
- package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +8 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +192 -0
- package/dist/op/activities/machine-release.d.ts.map +1 -0
- package/dist/op/activities/machines-contract.d.ts +8 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +55 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- package/dist/op/activities/machines-local-cli.d.ts +20 -0
- package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
- package/dist/op/activities/machines-local.d.ts +107 -0
- package/dist/op/activities/machines-local.d.ts.map +1 -0
- package/dist/op/activities/sprite-fs.d.ts +12 -3
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts +107 -0
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +18 -1
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +29 -9
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +62 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/builders.d.ts +45 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/release-metadata.d.ts +48 -0
- package/dist/release-metadata.d.ts.map +1 -0
- package/dist/release-store.d.ts +36 -0
- package/dist/release-store.d.ts.map +1 -0
- package/dist/skills/chant-fly-ops.md +100 -0
- package/dist/skills/chant-fly-sprites.md +2 -1
- package/package.json +11 -4
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +378 -0
- package/src/components/fly-release.ts +458 -0
- package/src/components/index.ts +16 -0
- package/src/composites/catalog.test.ts +2 -1
- package/src/composites/catalog.ts +98 -0
- package/src/composites/fly-site.test.ts +66 -0
- package/src/composites/fly-site.ts +140 -0
- package/src/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +20 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/fly-release-step.ts +32 -0
- package/src/op/activities/fly-rollback-step.ts +33 -0
- package/src/op/activities/index.ts +48 -0
- package/src/op/activities/machine-release.integration.test.ts +113 -0
- package/src/op/activities/machine-release.test.ts +138 -0
- package/src/op/activities/machine-release.ts +429 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
- package/src/op/activities/machines-contract.test.ts +11 -1
- package/src/op/activities/machines-contract.ts +17 -0
- package/src/op/activities/machines-fake.ts +167 -0
- package/src/op/activities/machines-local-cli.ts +73 -0
- package/src/op/activities/machines-local.test.ts +225 -0
- package/src/op/activities/machines-local.ts +437 -0
- package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
- package/src/op/activities/sprite-fs.test.ts +55 -0
- package/src/op/activities/sprite-fs.ts +14 -5
- package/src/op/activities/sprite-service-converge.test.ts +268 -0
- package/src/op/activities/sprite-service-converge.ts +268 -0
- package/src/op/activities/sprites-fake.ts +51 -8
- package/src/op/activities/sprites.integration.test.ts +30 -0
- package/src/op/activities/sprites.test.ts +20 -0
- package/src/op/activities/sprites.ts +50 -10
- package/src/op/activity-contracts.test.ts +71 -0
- package/src/op/activity-contracts.ts +67 -0
- package/src/op/builders.ts +57 -0
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +100 -0
- package/src/skills/chant-fly-sprites.md +2 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory Fly Machines (flaps) API, as a {@link FlyHttp} (#2736).
|
|
3
|
+
*
|
|
4
|
+
* The twin of ./sprites-fake.ts for the Machines side: the endpoints the
|
|
5
|
+
* applier (./fly-apply.ts) and the release activities (./machine-release.ts)
|
|
6
|
+
* call, answered from memory with the response shapes flaps and mudflaps use.
|
|
7
|
+
* Unit tests inject it where the default client would reach the network; the
|
|
8
|
+
* docker-gated tests run the same activities against the pinned mudflaps.
|
|
9
|
+
* It runs nothing; ./machines-local.ts is the opt-in mode that runs each
|
|
10
|
+
* started Machine's files and command on this host (#2831).
|
|
11
|
+
*
|
|
12
|
+
* Not an activity: nothing in ./index.ts exports it, so `loadActivities`
|
|
13
|
+
* never binds it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FlyHttp } from "./fly-apply";
|
|
17
|
+
|
|
18
|
+
/** One Machine the fake holds. */
|
|
19
|
+
export interface FakeMachine {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
state: string;
|
|
23
|
+
instance_id: string;
|
|
24
|
+
config: Record<string, unknown> & { metadata?: Record<string, string> };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What an `exec` answers: the Machines API's exec envelope. */
|
|
28
|
+
export interface FakeExecResult {
|
|
29
|
+
exit_code: number;
|
|
30
|
+
stdout?: string;
|
|
31
|
+
stderr?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MachinesFake {
|
|
35
|
+
http: FlyHttp;
|
|
36
|
+
apps: Set<string>;
|
|
37
|
+
machines: Map<string, FakeMachine[]>;
|
|
38
|
+
/** Every exec, in order: the app, the machine id and the command. */
|
|
39
|
+
execs: Array<{ app: string; id: string; command: string[] }>;
|
|
40
|
+
/** Every call, as `METHOD path` (no base, no query). */
|
|
41
|
+
calls: string[];
|
|
42
|
+
/** The live machine named `name` in `app`, if any. */
|
|
43
|
+
machine(app: string, name: string): FakeMachine | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MachinesFakeOptions {
|
|
47
|
+
/** Answer an exec. Default: exit 0 with no output. The running mode (./machines-local.ts) answers it by running the command. */
|
|
48
|
+
exec?: (app: string, machine: FakeMachine, command: string[]) => FakeExecResult | Promise<FakeExecResult>;
|
|
49
|
+
/** The state a created or updated machine settles in. Default `started`. */
|
|
50
|
+
settle?: (machine: FakeMachine) => string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const json = (status: number, body: unknown) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
|
|
54
|
+
|
|
55
|
+
let seq = 0;
|
|
56
|
+
const nextId = (prefix: string) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
|
|
57
|
+
|
|
58
|
+
/** A fresh in-memory flaps. */
|
|
59
|
+
export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesFake {
|
|
60
|
+
const apps = new Set<string>();
|
|
61
|
+
const machines = new Map<string, FakeMachine[]>();
|
|
62
|
+
const execs: MachinesFake["execs"] = [];
|
|
63
|
+
const calls: string[] = [];
|
|
64
|
+
const settle = options.settle ?? (() => "started");
|
|
65
|
+
const list = (app: string) => machines.get(app) ?? [];
|
|
66
|
+
const find = (app: string, id: string) => list(app).find((m) => m.id === id);
|
|
67
|
+
|
|
68
|
+
const http: FlyHttp = async (method, url, body) => {
|
|
69
|
+
const { pathname, searchParams } = new URL(url);
|
|
70
|
+
calls.push(`${method} ${pathname}`);
|
|
71
|
+
const seg = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
72
|
+
// seg: ["v1", "apps", app?, kind?, id?, action?]
|
|
73
|
+
if (seg[0] !== "v1" || seg[1] !== "apps") return json(404, { error: "not found" });
|
|
74
|
+
const app = seg[2];
|
|
75
|
+
const b = (body ?? {}) as Record<string, unknown>;
|
|
76
|
+
|
|
77
|
+
if (!app) {
|
|
78
|
+
if (method === "POST") {
|
|
79
|
+
const name = String(b.app_name);
|
|
80
|
+
if (apps.has(name)) return json(409, { error: "app exists" });
|
|
81
|
+
apps.add(name);
|
|
82
|
+
return json(201, { name, status: "deployed" });
|
|
83
|
+
}
|
|
84
|
+
return json(200, [...apps].map((name) => ({ name })));
|
|
85
|
+
}
|
|
86
|
+
if (seg.length === 3) {
|
|
87
|
+
if (method === "GET") return apps.has(app) ? json(200, { name: app, status: "deployed" }) : json(404, { error: "app not found" });
|
|
88
|
+
if (method === "DELETE") {
|
|
89
|
+
apps.delete(app);
|
|
90
|
+
machines.delete(app);
|
|
91
|
+
return json(202, undefined);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!apps.has(app)) return json(404, { error: "app not found" });
|
|
95
|
+
const kind = seg[3];
|
|
96
|
+
|
|
97
|
+
if (kind === "machines") {
|
|
98
|
+
const id = seg[4];
|
|
99
|
+
const action = seg[5];
|
|
100
|
+
if (!id) {
|
|
101
|
+
if (method === "GET") return json(200, list(app));
|
|
102
|
+
if (method === "POST") {
|
|
103
|
+
const m: FakeMachine = {
|
|
104
|
+
id: nextId("m"),
|
|
105
|
+
name: String(b.name ?? ""),
|
|
106
|
+
state: "created",
|
|
107
|
+
instance_id: nextId("I"),
|
|
108
|
+
config: structuredClone((b.config ?? {}) as FakeMachine["config"]),
|
|
109
|
+
};
|
|
110
|
+
m.state = settle(m);
|
|
111
|
+
machines.set(app, [...list(app), m]);
|
|
112
|
+
return json(200, m);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const m = id ? find(app, id) : undefined;
|
|
116
|
+
if (!m) return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
|
|
117
|
+
if (!action) {
|
|
118
|
+
if (method === "GET") return json(200, m);
|
|
119
|
+
if (method === "POST") {
|
|
120
|
+
m.config = structuredClone((b.config ?? {}) as FakeMachine["config"]);
|
|
121
|
+
m.instance_id = nextId("I");
|
|
122
|
+
m.state = settle(m);
|
|
123
|
+
return json(200, m);
|
|
124
|
+
}
|
|
125
|
+
if (method === "DELETE") {
|
|
126
|
+
machines.set(app, list(app).filter((x) => x !== m));
|
|
127
|
+
return json(200, { ok: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (action === "lease") {
|
|
131
|
+
if (method === "POST") return json(200, { data: { nonce: nextId("n") } });
|
|
132
|
+
return json(200, { ok: true });
|
|
133
|
+
}
|
|
134
|
+
if (action === "wait") {
|
|
135
|
+
const want = searchParams.get("state") ?? "started";
|
|
136
|
+
return json(200, { ok: m.state === want });
|
|
137
|
+
}
|
|
138
|
+
if (action === "restart" || action === "start") {
|
|
139
|
+
m.state = "started";
|
|
140
|
+
return json(200, { ok: true });
|
|
141
|
+
}
|
|
142
|
+
if (action === "stop") {
|
|
143
|
+
m.state = "stopped";
|
|
144
|
+
return json(200, { ok: true });
|
|
145
|
+
}
|
|
146
|
+
if (action === "exec" && method === "POST") {
|
|
147
|
+
const command = (b.command as string[] | undefined) ?? String(b.cmd ?? "").split(" ");
|
|
148
|
+
execs.push({ app, id: m.id, command });
|
|
149
|
+
return json(200, (await options.exec?.(app, m, command)) ?? { exit_code: 0, stdout: "", stderr: "" });
|
|
150
|
+
}
|
|
151
|
+
return json(404, { error: `no ${method} ${action}` });
|
|
152
|
+
}
|
|
153
|
+
if (kind === "ip_assignments") return method === "GET" ? json(200, { ips: [] }) : json(200, {});
|
|
154
|
+
if (kind === "certificates") return method === "GET" ? json(200, { certificates: [] }) : json(200, {});
|
|
155
|
+
if (kind === "volumes" || kind === "secrets") return method === "GET" ? json(200, []) : json(200, {});
|
|
156
|
+
return json(404, { error: "not found" });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
http,
|
|
161
|
+
apps,
|
|
162
|
+
machines,
|
|
163
|
+
execs,
|
|
164
|
+
calls,
|
|
165
|
+
machine: (app, name) => list(app).find((m) => m.name === name && !["destroyed", "destroying"].includes(m.state)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local Fly Machines API that runs its Machines (#2831), as a command:
|
|
3
|
+
*
|
|
4
|
+
* tsx node_modules/@intentius/chant-lexicon-fly/src/op/activities/machines-local-cli.ts \
|
|
5
|
+
* [--listen 4280] [--root <dir>] [--publish <port> | --publish <name>=<port> ...] [--log <file>]
|
|
6
|
+
*
|
|
7
|
+
* Point the release activities at it with `FLY_FLAPS_BASE_URL=http://127.0.0.1:4280`.
|
|
8
|
+
* `--publish 8080` publishes every Machine's service on 8080; `--publish web=8080`
|
|
9
|
+
* publishes the Machine named `web` there; with no `--publish` each Machine gets a free
|
|
10
|
+
* port, which the event lines say. SIGINT or SIGTERM stops every Machine's process
|
|
11
|
+
* and exits. See ./machines-local.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { serveLocalMachines, type LocalPublish } from "./machines-local";
|
|
17
|
+
|
|
18
|
+
export function parseArgs(argv: string[]): { port: number; root?: string; publish?: LocalPublish; log?: string } {
|
|
19
|
+
let port = 4280;
|
|
20
|
+
let root: string | undefined;
|
|
21
|
+
let log: string | undefined;
|
|
22
|
+
let all: number | undefined;
|
|
23
|
+
const byName: Record<string, number> = {};
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const flag = argv[i];
|
|
26
|
+
const value = argv[i + 1];
|
|
27
|
+
if (value === undefined) throw new Error(`${flag} needs a value`);
|
|
28
|
+
if (flag === "--listen") port = Number(value);
|
|
29
|
+
else if (flag === "--root") root = value;
|
|
30
|
+
else if (flag === "--log") log = value;
|
|
31
|
+
else if (flag === "--publish") {
|
|
32
|
+
const eq = value.lastIndexOf("=");
|
|
33
|
+
if (eq === -1) all = Number(value);
|
|
34
|
+
else byName[value.slice(0, eq)] = Number(value.slice(eq + 1));
|
|
35
|
+
} else throw new Error(`unknown flag ${flag}`);
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
for (const n of [port, all, ...Object.values(byName)]) if (n !== undefined && !Number.isInteger(n)) throw new Error(`not a port: ${n}`);
|
|
39
|
+
const named = Object.keys(byName).length > 0;
|
|
40
|
+
const publish: LocalPublish | undefined =
|
|
41
|
+
named ? (app, m) => byName[`${app}/${m.name}`] ?? byName[m.name] ?? all : all;
|
|
42
|
+
return { port, root, publish, log };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main(): Promise<void> {
|
|
46
|
+
let args: ReturnType<typeof parseArgs>;
|
|
47
|
+
try {
|
|
48
|
+
args = parseArgs(process.argv.slice(2));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(`machines-local: ${(error as Error).message}`);
|
|
51
|
+
console.error("usage: machines-local-cli.ts [--listen <port>] [--root <dir>] [--publish <port> | --publish <name>=<port>]... [--log <file>]");
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
const served = await serveLocalMachines({ ...args, handleSignals: false, onEvent: (line) => console.log(`machines-local: ${line}`) });
|
|
55
|
+
console.log(`machines-local: flaps on ${served.url}${args.root ? `, guest paths under ${args.root}` : ""}`);
|
|
56
|
+
let closing = false;
|
|
57
|
+
const shutdown = () => {
|
|
58
|
+
if (closing) return;
|
|
59
|
+
closing = true;
|
|
60
|
+
void served.close().then(() => process.exit(0));
|
|
61
|
+
};
|
|
62
|
+
process.on("SIGINT", shutdown);
|
|
63
|
+
process.on("SIGTERM", shutdown);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const invoked = (() => {
|
|
67
|
+
try {
|
|
68
|
+
return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
if (invoked) void main();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { createLocalMachines, serveLocalMachines } from "./machines-local";
|
|
8
|
+
import { parseArgs } from "./machines-local-cli";
|
|
9
|
+
import { flyMachineExec, flyMachineRelease, flyMachineRestore, flyMachineStop, type MachineFile } from "./machine-release";
|
|
10
|
+
import type { FlyPlan } from "./fly-apply";
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const repoRoot = join(here, "../../../../..");
|
|
14
|
+
const tsxLoader = pathToFileURL(join(repoRoot, "node_modules/tsx/dist/loader.mjs")).href;
|
|
15
|
+
|
|
16
|
+
/** A tiny app: serves page.txt and, when a migration has written it, /data/state. */
|
|
17
|
+
const SERVER = `
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
20
|
+
const state = () => (existsSync(process.env.DATA + "/state") ? readFileSync(process.env.DATA + "/state", "utf8").trim() : "none");
|
|
21
|
+
createServer((req, res) => res.end(readFileSync(new URL("./page.txt", import.meta.url), "utf8").trim() + " " + state())).listen(Number(process.env.PORT), "127.0.0.1");
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const b64 = (s: string) => Buffer.from(s).toString("base64");
|
|
25
|
+
const files = (page: string): MachineFile[] => [
|
|
26
|
+
{ guest_path: "/srv/app/server.mjs", raw_value: b64(SERVER) },
|
|
27
|
+
{ guest_path: "/srv/app/page.txt", raw_value: b64(page) },
|
|
28
|
+
];
|
|
29
|
+
const config = (page: string) => ({
|
|
30
|
+
image: "node:22-slim",
|
|
31
|
+
env: { PORT: "8080", DATA: "/data" },
|
|
32
|
+
services: [{ protocol: "tcp", internal_port: 8080 }],
|
|
33
|
+
mounts: [{ volume: "data", path: "/data" }],
|
|
34
|
+
files: files(page),
|
|
35
|
+
init: { cmd: ["sh", "-c", "cd /srv/app && exec node server.mjs"] },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Is `pid` running, as `ps` sees it? */
|
|
39
|
+
const alive = (pid: number) => spawnSync("ps", ["-p", String(pid)]).status === 0;
|
|
40
|
+
const get = async (url: string) => (await fetch(url)).text();
|
|
41
|
+
|
|
42
|
+
let dirs: string[] = [];
|
|
43
|
+
let open: Array<{ close(): Promise<void> }> = [];
|
|
44
|
+
const tmp = () => {
|
|
45
|
+
const d = realpathSync(mkdtempSync(join(tmpdir(), "chant-machines-local-")));
|
|
46
|
+
dirs.push(d);
|
|
47
|
+
return d;
|
|
48
|
+
};
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
for (const o of open) await o.close();
|
|
51
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
52
|
+
open = [];
|
|
53
|
+
dirs = [];
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("the running mode of the local Machines API (#2831)", () => {
|
|
57
|
+
test("runs a Machine's files and command, serves it, restarts it on a new config, and stops it with no process left", { timeout: 60_000 }, async () => {
|
|
58
|
+
const root = tmp();
|
|
59
|
+
const served = await serveLocalMachines({ port: 0, root, log: join(root, "machines.log") });
|
|
60
|
+
open.push(served);
|
|
61
|
+
const machines = served.machines;
|
|
62
|
+
const call = async (method: string, path: string, body?: unknown) => {
|
|
63
|
+
const res = await fetch(`${served.url}${path}`, { method, body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json" } });
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
expect((await call("GET", "/_mudflaps/health")).status).toBe(200);
|
|
69
|
+
await call("POST", "/v1/apps", { app_name: "shop", org_slug: "personal" });
|
|
70
|
+
const created = await call("POST", "/v1/apps/shop/machines", { name: "web", config: config("A") });
|
|
71
|
+
expect(created.status).toBe(200);
|
|
72
|
+
const id = created.json.id as string;
|
|
73
|
+
|
|
74
|
+
const url = machines.endpoint("shop", "web");
|
|
75
|
+
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
76
|
+
expect(await get(url!)).toBe("A none");
|
|
77
|
+
// The files are under root, the guest paths the command named mapped there.
|
|
78
|
+
expect(readFileSync(join(root, "srv/app/page.txt"), "utf8")).toBe("A");
|
|
79
|
+
const [first] = machines.processes();
|
|
80
|
+
expect(first).toMatchObject({ app: "shop", name: "web", id });
|
|
81
|
+
expect(alive(first.pid!)).toBe(true);
|
|
82
|
+
|
|
83
|
+
// An exec runs in the Machine's env, against the Volume the app reads.
|
|
84
|
+
const exec = await call("POST", `/v1/apps/shop/machines/${id}/exec`, { command: ["sh", "-c", "echo migrated > $DATA/state && echo done"], timeout: 30 });
|
|
85
|
+
expect(exec.json).toMatchObject({ exit_code: 0, stdout: "done\n" });
|
|
86
|
+
expect(await get(url!)).toBe("A migrated");
|
|
87
|
+
|
|
88
|
+
// A new config is a new instance: the process starts again on the new files, on the same port, and the Volume is kept.
|
|
89
|
+
const updated = await call("POST", `/v1/apps/shop/machines/${id}`, { config: config("B") });
|
|
90
|
+
expect(updated.json.instance_id).not.toBe(created.json.instance_id);
|
|
91
|
+
const [second] = machines.processes();
|
|
92
|
+
expect(second.pid).not.toBe(first.pid);
|
|
93
|
+
expect(alive(first.pid!)).toBe(false);
|
|
94
|
+
expect(machines.endpoint("shop", "web")).toBe(url);
|
|
95
|
+
expect(await get(url!)).toBe("B migrated");
|
|
96
|
+
|
|
97
|
+
// A restart runs it again.
|
|
98
|
+
await call("POST", `/v1/apps/shop/machines/${id}/restart`);
|
|
99
|
+
const [third] = machines.processes();
|
|
100
|
+
expect(third.pid).not.toBe(second.pid);
|
|
101
|
+
expect(alive(second.pid!)).toBe(false);
|
|
102
|
+
expect(await get(url!)).toBe("B migrated");
|
|
103
|
+
|
|
104
|
+
// A stop leaves nothing running and removes the instance's files; the Volume stays.
|
|
105
|
+
await call("POST", `/v1/apps/shop/machines/${id}/stop`);
|
|
106
|
+
expect(machines.processes()).toEqual([]);
|
|
107
|
+
expect(alive(third.pid!)).toBe(false);
|
|
108
|
+
expect(machines.endpoint("shop", "web")).toBeUndefined();
|
|
109
|
+
expect(existsSync(join(root, "srv/app/page.txt"))).toBe(false);
|
|
110
|
+
expect(readFileSync(join(root, "data/state"), "utf8")).toBe("migrated\n");
|
|
111
|
+
|
|
112
|
+
// Start, then delete: running again, then gone.
|
|
113
|
+
await call("POST", `/v1/apps/shop/machines/${id}/start`);
|
|
114
|
+
const [fourth] = machines.processes();
|
|
115
|
+
expect(await get(url!)).toBe("B migrated");
|
|
116
|
+
await call("DELETE", `/v1/apps/shop/machines/${id}`);
|
|
117
|
+
expect(machines.processes()).toEqual([]);
|
|
118
|
+
expect(alive(fourth.pid!)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the release activities: release A, a migration, release B, then A restored serves A again", { timeout: 60_000 }, async () => {
|
|
122
|
+
const root = tmp();
|
|
123
|
+
const machines = createLocalMachines({ root });
|
|
124
|
+
open.push(machines);
|
|
125
|
+
const endpoint = "http://flaps.local";
|
|
126
|
+
const wait = { intervalMs: 20, deadlineMs: 10_000 };
|
|
127
|
+
const plan: FlyPlan = {
|
|
128
|
+
shop: { endpoint: "/v1/apps", method: "POST", body: { app_name: "shop", org_slug: "personal" } },
|
|
129
|
+
web: {
|
|
130
|
+
endpoint: "/v1/apps/shop/machines",
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: { name: "web", config: { image: "node:22-slim", env: { PORT: "8080", DATA: "/data" }, services: [{ internal_port: 8080 }], mounts: [{ volume: "data", path: "/data" }] } },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const release = (page: string, digest: string) =>
|
|
136
|
+
flyMachineRelease({ plan, endpoint, release: { digest }, files: files(page), cmd: "cd /srv/app && exec node server.mjs", wait }, undefined, machines.http);
|
|
137
|
+
|
|
138
|
+
const a = await release("A", "sha256:a");
|
|
139
|
+
const url = machines.endpoint("shop", "web")!;
|
|
140
|
+
expect(await get(url)).toBe("A none");
|
|
141
|
+
await flyMachineExec({ app: "shop", machine: "web", command: "echo v1 > /data/state", endpoint }, undefined, machines.http);
|
|
142
|
+
expect(await get(url)).toBe("A v1");
|
|
143
|
+
|
|
144
|
+
await release("B", "sha256:b");
|
|
145
|
+
expect(await get(url)).toBe("B v1");
|
|
146
|
+
|
|
147
|
+
await flyMachineRestore({ app: "shop", machine: "web", config: a.config, endpoint, wait }, undefined, machines.http);
|
|
148
|
+
expect(await get(url)).toBe("A v1");
|
|
149
|
+
|
|
150
|
+
const [p] = machines.processes();
|
|
151
|
+
await flyMachineStop({ app: "shop", machine: "web", endpoint, wait }, undefined, machines.http);
|
|
152
|
+
expect(machines.processes()).toEqual([]);
|
|
153
|
+
expect(alive(p.pid!)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a process that exits on its own leaves its Machine stopped", { timeout: 30_000 }, async () => {
|
|
157
|
+
const machines = createLocalMachines({ root: tmp(), log: join(tmp(), "log") });
|
|
158
|
+
open.push(machines);
|
|
159
|
+
await machines.http("POST", "http://f/v1/apps", { app_name: "shop" });
|
|
160
|
+
const r = await machines.http("POST", "http://f/v1/apps/shop/machines", { name: "web", config: { init: { cmd: ["sh", "-c", "exit 3"] } } });
|
|
161
|
+
const id = JSON.parse(r.text).id as string;
|
|
162
|
+
for (let i = 0; i < 100 && machines.machine("shop", "web")!.state === "started"; i++) await new Promise((ok) => setTimeout(ok, 20));
|
|
163
|
+
expect(machines.machine("shop", "web")!.state).toBe("stopped");
|
|
164
|
+
expect(machines.processes()).toEqual([]);
|
|
165
|
+
expect(JSON.parse((await machines.http("GET", `http://f/v1/apps/shop/machines/${id}/wait?state=started`)).text)).toEqual({ ok: false });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("with no root, guest paths are host paths", async () => {
|
|
169
|
+
const dir = tmp();
|
|
170
|
+
const machines = createLocalMachines({ log: join(dir, "log") });
|
|
171
|
+
open.push(machines);
|
|
172
|
+
await machines.http("POST", "http://f/v1/apps", { app_name: "shop" });
|
|
173
|
+
await machines.http("POST", "http://f/v1/apps/shop/machines", {
|
|
174
|
+
name: "web",
|
|
175
|
+
config: { files: [{ guest_path: join(dir, "a/b.txt"), raw_value: b64("hi") }] },
|
|
176
|
+
});
|
|
177
|
+
expect(readFileSync(join(dir, "a/b.txt"), "utf8")).toBe("hi");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
for (const how of ["SIGTERM", "SIGINT", "exit"] as const) {
|
|
181
|
+
test(`a Machine's process does not outlive its host process (${how})`, { timeout: 60_000 }, async () => {
|
|
182
|
+
const root = tmp();
|
|
183
|
+
const script = join(root, "host.mts");
|
|
184
|
+
writeFileSync(
|
|
185
|
+
script,
|
|
186
|
+
`import { createLocalMachines } from ${JSON.stringify(join(here, "machines-local.ts"))};
|
|
187
|
+
const m = createLocalMachines({ root: ${JSON.stringify(root)}, log: ${JSON.stringify(join(root, "log"))} });
|
|
188
|
+
await m.http("POST", "http://f/v1/apps", { app_name: "shop" });
|
|
189
|
+
await m.http("POST", "http://f/v1/apps/shop/machines", { name: "web", config: { init: { cmd: ["sh", "-c", "sleep 300 & exec sleep 301"] } } });
|
|
190
|
+
console.log(JSON.stringify(m.processes()[0].pid));
|
|
191
|
+
${how === "exit" ? "setTimeout(() => process.exit(0), 100);" : "setInterval(() => undefined, 1000);"}
|
|
192
|
+
`,
|
|
193
|
+
);
|
|
194
|
+
const host = spawn(process.execPath, ["--import", tsxLoader, script], {
|
|
195
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
196
|
+
// tsx's on-disk cache can stall for seconds under load; this script is too small to need it.
|
|
197
|
+
env: { ...process.env, TSX_DISABLE_CACHE: "1" },
|
|
198
|
+
});
|
|
199
|
+
const pid = await new Promise<number>((ok) => host.stdout!.once("data", (d) => ok(Number(String(d).trim()))));
|
|
200
|
+
expect(alive(pid)).toBe(true);
|
|
201
|
+
const exited = new Promise((ok) => host.once("exit", ok));
|
|
202
|
+
if (how !== "exit") host.kill(how);
|
|
203
|
+
await exited;
|
|
204
|
+
for (let i = 0; i < 50 && alive(pid); i++) await new Promise((ok) => setTimeout(ok, 20));
|
|
205
|
+
expect(alive(pid)).toBe(false);
|
|
206
|
+
// Nor does anything the command started in its group.
|
|
207
|
+
const left = spawnSync("pgrep", ["-g", String(pid)], { encoding: "utf8" }).stdout.trim();
|
|
208
|
+
expect(left).toBe("");
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("machines-local-cli args", () => {
|
|
214
|
+
test("--publish takes one port for every Machine, or name=port", () => {
|
|
215
|
+
expect(parseArgs([])).toMatchObject({ port: 4280, publish: undefined });
|
|
216
|
+
expect(parseArgs(["--listen", "4999", "--root", "/r", "--publish", "8080"])).toMatchObject({ port: 4999, root: "/r", publish: 8080 });
|
|
217
|
+
const { publish } = parseArgs(["--publish", "web=8080", "--publish", "shop/api=9090"]);
|
|
218
|
+
const fn = publish as (app: string, m: { name: string }) => number | undefined;
|
|
219
|
+
expect(fn("shop", { name: "web" })).toBe(8080);
|
|
220
|
+
expect(fn("shop", { name: "api" })).toBe(9090);
|
|
221
|
+
expect(fn("shop", { name: "other" })).toBeUndefined();
|
|
222
|
+
expect(() => parseArgs(["--publish"])).toThrow(/needs a value/);
|
|
223
|
+
expect(() => parseArgs(["--nope", "1"])).toThrow(/unknown flag/);
|
|
224
|
+
});
|
|
225
|
+
});
|