@intentius/chant-lexicon-fly 0.91.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.
Files changed (68) hide show
  1. package/README.md +27 -0
  2. package/dist/components/fly-release.d.ts +51 -4
  3. package/dist/components/fly-release.d.ts.map +1 -1
  4. package/dist/composites/catalog.d.ts.map +1 -1
  5. package/dist/composites/fly-site.d.ts +86 -0
  6. package/dist/composites/fly-site.d.ts.map +1 -0
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/integrity.json +4 -4
  10. package/dist/manifest.json +1 -1
  11. package/dist/op/activities/fly-release-step.d.ts +24 -0
  12. package/dist/op/activities/fly-release-step.d.ts.map +1 -0
  13. package/dist/op/activities/fly-rollback-step.d.ts +25 -0
  14. package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
  15. package/dist/op/activities/index.d.ts +7 -1
  16. package/dist/op/activities/index.d.ts.map +1 -1
  17. package/dist/op/activities/machine-release.d.ts +16 -0
  18. package/dist/op/activities/machine-release.d.ts.map +1 -1
  19. package/dist/op/activities/machines-fake.d.ts +4 -2
  20. package/dist/op/activities/machines-fake.d.ts.map +1 -1
  21. package/dist/op/activities/machines-local-cli.d.ts +20 -0
  22. package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
  23. package/dist/op/activities/machines-local.d.ts +107 -0
  24. package/dist/op/activities/machines-local.d.ts.map +1 -0
  25. package/dist/op/activities/sprite-fs.d.ts +12 -3
  26. package/dist/op/activities/sprite-fs.d.ts.map +1 -1
  27. package/dist/op/activities/sprite-service-converge.d.ts +107 -0
  28. package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
  29. package/dist/op/activities/sprites-fake.d.ts +18 -1
  30. package/dist/op/activities/sprites-fake.d.ts.map +1 -1
  31. package/dist/op/activities/sprites.d.ts +29 -9
  32. package/dist/op/activities/sprites.d.ts.map +1 -1
  33. package/dist/op/activity-contracts.d.ts +62 -0
  34. package/dist/op/activity-contracts.d.ts.map +1 -0
  35. package/dist/op/builders.d.ts +32 -0
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/skills/chant-fly-ops.md +67 -0
  38. package/dist/skills/chant-fly-sprites.md +2 -1
  39. package/package.json +11 -4
  40. package/src/components/fly-release.test.ts +135 -1
  41. package/src/components/fly-release.ts +125 -8
  42. package/src/composites/catalog.test.ts +2 -1
  43. package/src/composites/catalog.ts +98 -0
  44. package/src/composites/fly-site.test.ts +66 -0
  45. package/src/composites/fly-site.ts +140 -0
  46. package/src/index.ts +8 -0
  47. package/src/op/activities/fly-release-step.ts +32 -0
  48. package/src/op/activities/fly-rollback-step.ts +33 -0
  49. package/src/op/activities/index.ts +25 -0
  50. package/src/op/activities/machine-release.ts +22 -0
  51. package/src/op/activities/machines-fake.ts +5 -3
  52. package/src/op/activities/machines-local-cli.ts +73 -0
  53. package/src/op/activities/machines-local.test.ts +225 -0
  54. package/src/op/activities/machines-local.ts +437 -0
  55. package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
  56. package/src/op/activities/sprite-fs.test.ts +55 -0
  57. package/src/op/activities/sprite-fs.ts +14 -5
  58. package/src/op/activities/sprite-service-converge.test.ts +268 -0
  59. package/src/op/activities/sprite-service-converge.ts +268 -0
  60. package/src/op/activities/sprites-fake.ts +51 -8
  61. package/src/op/activities/sprites.integration.test.ts +30 -0
  62. package/src/op/activities/sprites.test.ts +20 -0
  63. package/src/op/activities/sprites.ts +50 -10
  64. package/src/op/activity-contracts.test.ts +71 -0
  65. package/src/op/activity-contracts.ts +67 -0
  66. package/src/op/builders.ts +32 -0
  67. package/src/skills/chant-fly-ops.md +67 -0
  68. package/src/skills/chant-fly-sprites.md +2 -1
@@ -0,0 +1,140 @@
1
+ /**
2
+ * FlySite composite: a Fly app that serves one app's releases.
3
+ *
4
+ * It declares the App, the Machine that serves (behind Fly's proxy on 443 and
5
+ * 80), and, when asked for, a Volume the app's data lives on, mounted into the
6
+ * Machine, a public IP, and the app's Secrets. It is the fly lexicon's home
7
+ * for chud's `FlySite` and `ChudLocalSite` sites (ws-056, #2809): a repo that
8
+ * `chant workspace upgrade` takes off the chud lexicon declares its Fly site
9
+ * with it, so `chant workspace graph --composites` lists the site as a
10
+ * composite instance, and a component that names `FlySite` in its
11
+ * `composites` deploys it.
12
+ *
13
+ * The Machine is the one a release ships to: `chant build --lexicon fly`
14
+ * serializes the site into a plan with exactly one Machine, so the
15
+ * `fly-release` steps (a component's deploy, or an Op's `flyRelease`) find it
16
+ * without being told its name. `image` is what the Machine runs until a
17
+ * release puts its own image, or its files and start command, on it.
18
+ */
19
+
20
+ import { Composite, mergeDefaults } from "@intentius/chant";
21
+ import type { Declarable } from "@intentius/chant/declarable";
22
+ import { Fly } from "../pseudo";
23
+ import {
24
+ App,
25
+ IPAddress,
26
+ Machine,
27
+ MachineConfig,
28
+ MachineGuest,
29
+ MachineMount,
30
+ MachinePort,
31
+ MachineService,
32
+ Secret,
33
+ Volume,
34
+ } from "../generated/index";
35
+
36
+ export interface FlySiteProps {
37
+ /** The App's name. Fly app names are global. */
38
+ app: string;
39
+ /** The owning org (default: `Fly.OrgSlug`, which the build resolves from `FLY_ORG`). */
40
+ org?: string | Declarable;
41
+ /** Region of the Machine and the Volume (default: `Fly.Region`, from `FLY_REGION`). */
42
+ region?: string | Declarable;
43
+ /** The Machine's name (default: "web"). */
44
+ machine?: string;
45
+ /** The image the Machine runs until a release replaces it. */
46
+ image: string;
47
+ /** The port the app listens on inside the Machine (default: 8080). Fly's proxy sends 443 (TLS) and 80 to it. */
48
+ port?: number;
49
+ /** The Machine's env. */
50
+ env?: Record<string, string>;
51
+ /** Guest CPU kind (default: "shared"). */
52
+ cpuKind?: string;
53
+ /** Guest CPUs (default: 1). */
54
+ cpus?: number;
55
+ /** Guest memory in MB (default: 256). */
56
+ memoryMb?: number;
57
+ /** A Volume for the app's data, mounted at `path`. It outlives releases. Omitted: no Volume. */
58
+ volume?: { name: string; sizeGb: number; path: string };
59
+ /** A public IP of this type (for example "shared_v4"). Omitted: none. */
60
+ ip?: "shared_v4" | "v4" | "v6";
61
+ /**
62
+ * The app's Secrets, by name. An undefined value declares the Secret
63
+ * without a value, so the release checks the app already has it.
64
+ */
65
+ secrets?: Record<string, string | undefined>;
66
+ /** Per-member defaults for fine-grained overrides. */
67
+ defaults?: {
68
+ app?: Partial<Record<string, unknown>>;
69
+ machine?: Partial<Record<string, unknown>>;
70
+ volume?: Partial<Record<string, unknown>>;
71
+ };
72
+ }
73
+
74
+ /** `APP_SECRET` -> `appSecret`: a Secret's member name, never one of the other members'. */
75
+ function memberName(secret: string): string {
76
+ const words = secret.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
77
+ const name = words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join("") || "secret";
78
+ return ["app", "machine", "volume", "ip"].includes(name) ? `${name}Secret` : name;
79
+ }
80
+
81
+ /**
82
+ * Create a FlySite composite. Returns the App and the Machine, and the
83
+ * Volume, IP and Secrets it was asked for.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * import { FlySite } from "@intentius/chant-lexicon-fly";
88
+ *
89
+ * export const flySite = FlySite({
90
+ * app: "notes",
91
+ * region: "iad",
92
+ * image: "node:22-slim",
93
+ * env: { PORT: "8080", APP_DATA: "/data" },
94
+ * volume: { name: "data", sizeGb: 1, path: "/data" },
95
+ * ip: "shared_v4",
96
+ * secrets: { APP_SECRET: process.env.APP_SECRET || undefined },
97
+ * });
98
+ * ```
99
+ */
100
+ export const FlySite = Composite((props: FlySiteProps) => {
101
+ const { port = 8080, cpuKind = "shared", cpus = 1, memoryMb = 256, defaults: defs } = props;
102
+ const region = props.region ?? Fly.Region;
103
+
104
+ const app = new App(mergeDefaults({ name: props.app, org_slug: props.org ?? Fly.OrgSlug } as Record<string, unknown>, defs?.app) as ConstructorParameters<typeof App>[0]);
105
+
106
+ const volume = props.volume
107
+ ? new Volume(mergeDefaults({ name: props.volume.name, region, size_gb: props.volume.sizeGb } as Record<string, unknown>, defs?.volume) as ConstructorParameters<typeof Volume>[0])
108
+ : undefined;
109
+
110
+ const config = new MachineConfig({
111
+ image: props.image,
112
+ guest: new MachineGuest({ cpu_kind: cpuKind, cpus, memory_mb: memoryMb }),
113
+ ...(props.volume ? { mounts: [new MachineMount({ volume: props.volume.name, path: props.volume.path })] } : {}),
114
+ services: [
115
+ new MachineService({
116
+ protocol: "tcp",
117
+ internal_port: port,
118
+ ports: [new MachinePort({ port: 443, handlers: ["tls", "http"] }), new MachinePort({ port: 80, handlers: ["http"] })],
119
+ }),
120
+ ],
121
+ ...(props.env ? { env: props.env } : {}),
122
+ });
123
+
124
+ const machine = new Machine(mergeDefaults({ name: props.machine ?? "web", region, config } as Record<string, unknown>, defs?.machine) as ConstructorParameters<typeof Machine>[0]);
125
+
126
+ const ip = props.ip ? new IPAddress({ type: props.ip }) : undefined;
127
+
128
+ const secrets: Record<string, Declarable> = {};
129
+ for (const [name, value] of Object.entries(props.secrets ?? {})) {
130
+ secrets[memberName(name)] = new Secret({ name, value } as ConstructorParameters<typeof Secret>[0]);
131
+ }
132
+
133
+ return {
134
+ app,
135
+ machine,
136
+ ...(volume ? { volume } : {}),
137
+ ...(ip ? { ip } : {}),
138
+ ...secrets,
139
+ };
140
+ }, "FlySite");
package/src/index.ts CHANGED
@@ -32,6 +32,10 @@ export type { FlyDeployOpts, FlyApplyStepOpts, FlapsStepOpts } from "./composite
32
32
  export { FlyOtelCollector } from "./composites/fly-otel-collector";
33
33
  export type { FlyOtelCollectorProps } from "./composites/fly-otel-collector";
34
34
 
35
+ // A Fly app that serves one app's releases: App, Machine, and its Volume, IP and Secrets (#2809, ws-056).
36
+ export { FlySite } from "./composites/fly-site";
37
+ export type { FlySiteProps } from "./composites/fly-site";
38
+
35
39
  // Sprite Op step builders. chant #1288 Stage 2: these author
36
40
  // `activity("spriteCreate", ...)` steps with authoring-time types derived
37
41
  // from this lexicon's own `Sprite*Args` interfaces (`./op/builders.ts`),
@@ -64,6 +68,8 @@ export {
64
68
  spriteServiceStop,
65
69
  spriteServiceDelete,
66
70
  spriteServiceLogs,
71
+ spriteServicesObserve,
72
+ spriteServiceRestart,
67
73
  spriteTaskCreate,
68
74
  spriteTaskRefresh,
69
75
  spriteTaskRelease,
@@ -75,6 +81,8 @@ export {
75
81
  flyMachineStop,
76
82
  flyMachineVerify,
77
83
  flyMachineRestore,
84
+ flyRelease,
85
+ flyRollback,
78
86
  } from "./op/builders";
79
87
 
80
88
  // Generated resources — export everything from generated index.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `flyRelease`: the `fly-release` capability (../../components/fly-release.ts)
3
+ * as an Op activity (#2782), so an Op's ship phase runs the same site steps a
4
+ * component's deploy does: upload and start, each migration once per
5
+ * environment under a receipt in chant's lifecycle receipt store, verify, and
6
+ * restore on a failure after the Machine changed.
7
+ *
8
+ * An Op has no environment of its own, so the step names the one it ships to
9
+ * (`environment`); the receipts and the recorded Machine configs are that
10
+ * environment's, as they are for the component.
11
+ *
12
+ * Retrying a release that already serves changes nothing: the apply finds the
13
+ * Machine's config unchanged, and every migration's receipt matches.
14
+ */
15
+
16
+ import type { FlyReleaseInput, FlyReleaseOutput } from "../../components/fly-release";
17
+
18
+ export interface FlyReleaseArgs extends FlyReleaseInput {
19
+ /** The environment the release ships to: its receipts and recorded configs. (`env` is the Machine's env, as for the capability.) */
20
+ environment: string;
21
+ /** The component the release belongs to, for attribution. Default `app`. */
22
+ component?: string;
23
+ }
24
+
25
+ export type FlyReleaseResult = FlyReleaseOutput;
26
+
27
+ export async function flyRelease(args: FlyReleaseArgs): Promise<FlyReleaseResult> {
28
+ const { environment, component, ...input } = args;
29
+ if (!environment) throw new Error("flyRelease: name the environment the release ships to (environment)");
30
+ const { flyReleaseCapability } = await import("../../components/fly-release");
31
+ return flyReleaseCapability.run({ env: environment, component: component ?? "app" }, input);
32
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `flyRollback`: the `fly-rollback` capability (../../components/fly-release.ts)
3
+ * as an Op activity (#2800), so a rollback Op puts an earlier release back on
4
+ * the Machine the way the release Op's `flyRelease` put it there.
5
+ *
6
+ * It restores the Machine config `fly-release` recorded for the release it
7
+ * goes back to (files included), in the environment `environment` names, and
8
+ * checks that the Machine is started with that release. Given `source`, the
9
+ * archive is read only once it hashes to its digest, before any flaps call,
10
+ * and the recorded config must carry exactly that tree, or nothing changes.
11
+ * A release with no recorded config is refused by name.
12
+ *
13
+ * Running it again once the Machine serves the release changes nothing.
14
+ * Migrations are not undone: the data stays where the later release left it.
15
+ */
16
+
17
+ import type { FlyRollbackInput, FlyRollbackOutput } from "../../components/fly-release";
18
+
19
+ export interface FlyRollbackArgs extends FlyRollbackInput {
20
+ /** The environment whose recorded Machine configs are read. */
21
+ environment: string;
22
+ /** The component the release belongs to, for attribution. Default `app`. */
23
+ component?: string;
24
+ }
25
+
26
+ export type FlyRollbackResult = FlyRollbackOutput;
27
+
28
+ export async function flyRollback(args: FlyRollbackArgs): Promise<FlyRollbackResult> {
29
+ const { environment, component, ...input } = args;
30
+ if (!environment) throw new Error("flyRollback: name the environment whose release it restores (environment)");
31
+ const { flyRollbackCapability } = await import("../../components/fly-release");
32
+ return flyRollbackCapability.run({ env: environment, component: component ?? "app" }, input);
33
+ }
@@ -47,10 +47,17 @@ export {
47
47
  flyMachineVerify,
48
48
  flyMachineRestore,
49
49
  } from "./machine-release";
50
+ // The fly-release capability as an Op step (#2782): a release Op's ship phase.
51
+ export { flyRelease } from "./fly-release-step";
52
+ export type { FlyReleaseArgs, FlyReleaseResult } from "./fly-release-step";
53
+ // The fly-rollback capability as an Op step (#2800): a rollback Op's restore.
54
+ export { flyRollback } from "./fly-rollback-step";
55
+ export type { FlyRollbackArgs, FlyRollbackResult } from "./fly-rollback-step";
50
56
  export type {
51
57
  MachineRelease,
52
58
  FlyMachineReleaseArgs,
53
59
  FlyMachineReleaseResult,
60
+ MachineFile,
54
61
  FlyMachineExecArgs,
55
62
  FlyMachineExecResult,
56
63
  FlyMachineStateArgs,
@@ -143,6 +150,24 @@ export type {
143
150
  SpriteServiceLogsResult,
144
151
  } from "./sprite-services";
145
152
 
153
+ // A sprite's services as resources a ConvergeOp observes and converges (#2778):
154
+ // the observer step and the restart a converge rule dispatches.
155
+ export {
156
+ spriteServicesObserve,
157
+ spriteServiceRestart,
158
+ declaredServices,
159
+ findSpriteEnv,
160
+ parseServicesList,
161
+ probeHealth,
162
+ } from "./sprite-service-converge";
163
+ export type {
164
+ DeclaredSpriteService,
165
+ SpriteServicesObserveArgs,
166
+ SpriteServicesObserveResult,
167
+ SpriteServiceRestartArgs,
168
+ SpriteServiceRestartResult,
169
+ } from "./sprite-service-converge";
170
+
146
171
  // Sprite filesystem activities (#848) — imperative file I/O over the fs API.
147
172
  // `loadActivities(["fly"])` binds these; the step builders live in core.
148
173
  export {
@@ -139,9 +139,26 @@ export interface FlyMachineReleaseArgs extends FlapsTarget {
139
139
  image?: string;
140
140
  /** Env to add to the declared Machine's env. */
141
141
  env?: Record<string, string>;
142
+ /**
143
+ * Files the release puts on the Machine, added to the declared ones: a
144
+ * source release's tree (#2782). A declared file at the same path is
145
+ * replaced.
146
+ */
147
+ files?: MachineFile[];
148
+ /** The command the Machine starts, in place of the image's: an argv, or a string run with `sh -c`. */
149
+ cmd?: string[] | string;
142
150
  wait?: WaitOpts;
143
151
  }
144
152
 
153
+ /** One file a Machine's config carries, as the Machines API takes it. */
154
+ export interface MachineFile {
155
+ guest_path: string;
156
+ /** The file's bytes, base64. */
157
+ raw_value: string;
158
+ /** Unix mode, such as 0o755 for an executable. */
159
+ mode?: number;
160
+ }
161
+
145
162
  export interface FlyMachineReleaseResult {
146
163
  app: string;
147
164
  machine: { id: string; name: string };
@@ -180,11 +197,16 @@ export async function flyMachineRelease(
180
197
  if (!previousDigest) delete release.previousDigest;
181
198
 
182
199
  const declared = (target.request.body.config ?? {}) as MachineConfig;
200
+ const declaredFiles = (declared.files as MachineFile[] | undefined) ?? [];
201
+ const releasePaths = new Set((args.files ?? []).map((f) => f.guest_path));
202
+ const cmd = typeof args.cmd === "string" ? ["sh", "-c", args.cmd] : args.cmd;
183
203
  const config = withReleaseMetadata(
184
204
  {
185
205
  ...declared,
186
206
  ...(args.image ? { image: args.image } : {}),
187
207
  ...(args.env ? { env: { ...(declared.env ?? {}), ...args.env } } : {}),
208
+ ...(args.files ? { files: [...declaredFiles.filter((f) => !releasePaths.has(f.guest_path)), ...args.files] } : {}),
209
+ ...(cmd ? { init: { ...((declared.init as Record<string, unknown> | undefined) ?? {}), cmd } } : {}),
188
210
  },
189
211
  release,
190
212
  );
@@ -6,6 +6,8 @@
6
6
  * call, answered from memory with the response shapes flaps and mudflaps use.
7
7
  * Unit tests inject it where the default client would reach the network; the
8
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).
9
11
  *
10
12
  * Not an activity: nothing in ./index.ts exports it, so `loadActivities`
11
13
  * never binds it.
@@ -42,8 +44,8 @@ export interface MachinesFake {
42
44
  }
43
45
 
44
46
  export interface MachinesFakeOptions {
45
- /** Answer an exec. Default: exit 0 with no output. */
46
- exec?: (app: string, machine: FakeMachine, command: string[]) => FakeExecResult;
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>;
47
49
  /** The state a created or updated machine settles in. Default `started`. */
48
50
  settle?: (machine: FakeMachine) => string;
49
51
  }
@@ -144,7 +146,7 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
144
146
  if (action === "exec" && method === "POST") {
145
147
  const command = (b.command as string[] | undefined) ?? String(b.cmd ?? "").split(" ");
146
148
  execs.push({ app, id: m.id, command });
147
- return json(200, options.exec?.(app, m, command) ?? { exit_code: 0, stdout: "", stderr: "" });
149
+ return json(200, (await options.exec?.(app, m, command)) ?? { exit_code: 0, stdout: "", stderr: "" });
148
150
  }
149
151
  return json(404, { error: `no ${method} ${action}` });
150
152
  }
@@ -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
+ });