@intentius/chant-lexicon-fly 0.16.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 (149) hide show
  1. package/README.md +91 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +27 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +6 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate-lexicon.d.ts +18 -0
  9. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  10. package/dist/codegen/generate-typescript.d.ts +11 -0
  11. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  12. package/dist/codegen/generate.d.ts +15 -0
  13. package/dist/codegen/generate.d.ts.map +1 -0
  14. package/dist/codegen/naming.d.ts +14 -0
  15. package/dist/codegen/naming.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +17 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/fly-deploy.d.ts +110 -0
  19. package/dist/composites/fly-deploy.d.ts.map +1 -0
  20. package/dist/describe-resources.d.ts +74 -0
  21. package/dist/describe-resources.d.ts.map +1 -0
  22. package/dist/detect.d.ts +22 -0
  23. package/dist/detect.d.ts.map +1 -0
  24. package/dist/export-resources.d.ts +25 -0
  25. package/dist/export-resources.d.ts.map +1 -0
  26. package/dist/generated/index.d.ts +42 -0
  27. package/dist/generated/index.d.ts.map +1 -0
  28. package/dist/generated/runtime.d.ts +5 -0
  29. package/dist/generated/runtime.d.ts.map +1 -0
  30. package/dist/import/generator.d.ts +21 -0
  31. package/dist/import/generator.d.ts.map +1 -0
  32. package/dist/import/live-export.d.ts +42 -0
  33. package/dist/import/live-export.d.ts.map +1 -0
  34. package/dist/import/parser.d.ts +40 -0
  35. package/dist/import/parser.d.ts.map +1 -0
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/integrity.json +9 -0
  39. package/dist/lint/post-synth/fly-helpers.d.ts +15 -0
  40. package/dist/lint/post-synth/fly-helpers.d.ts.map +1 -0
  41. package/dist/lint/post-synth/fly010-machine-requires-image.d.ts +10 -0
  42. package/dist/lint/post-synth/fly010-machine-requires-image.d.ts.map +1 -0
  43. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +12 -0
  44. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -0
  45. package/dist/lint/post-synth/index.d.ts +3 -0
  46. package/dist/lint/post-synth/index.d.ts.map +1 -0
  47. package/dist/lint/rules/guest-sizing.d.ts +9 -0
  48. package/dist/lint/rules/guest-sizing.d.ts.map +1 -0
  49. package/dist/lint/rules/index.d.ts +7 -0
  50. package/dist/lint/rules/index.d.ts.map +1 -0
  51. package/dist/lint/rules/no-secret-literals.d.ts +13 -0
  52. package/dist/lint/rules/no-secret-literals.d.ts.map +1 -0
  53. package/dist/lint/rules/valid-region.d.ts +9 -0
  54. package/dist/lint/rules/valid-region.d.ts.map +1 -0
  55. package/dist/lsp/completions.d.ts +9 -0
  56. package/dist/lsp/completions.d.ts.map +1 -0
  57. package/dist/lsp/hover.d.ts +9 -0
  58. package/dist/lsp/hover.d.ts.map +1 -0
  59. package/dist/manifest.json +4 -0
  60. package/dist/meta.json +285 -0
  61. package/dist/op/activities/flaps.d.ts +29 -0
  62. package/dist/op/activities/flaps.d.ts.map +1 -0
  63. package/dist/op/activities/fly-apply.d.ts +382 -0
  64. package/dist/op/activities/fly-apply.d.ts.map +1 -0
  65. package/dist/op/activities/index.d.ts +12 -0
  66. package/dist/op/activities/index.d.ts.map +1 -0
  67. package/dist/ownership.d.ts +27 -0
  68. package/dist/ownership.d.ts.map +1 -0
  69. package/dist/package-cli.d.ts +3 -0
  70. package/dist/package-cli.d.ts.map +1 -0
  71. package/dist/plugin.d.ts +8 -0
  72. package/dist/plugin.d.ts.map +1 -0
  73. package/dist/pseudo.d.ts +23 -0
  74. package/dist/pseudo.d.ts.map +1 -0
  75. package/dist/serializer.d.ts +36 -0
  76. package/dist/serializer.d.ts.map +1 -0
  77. package/dist/spec/fetch.d.ts +12 -0
  78. package/dist/spec/fetch.d.ts.map +1 -0
  79. package/dist/spec/parse.d.ts +58 -0
  80. package/dist/spec/parse.d.ts.map +1 -0
  81. package/dist/types/index.d.ts +590 -0
  82. package/dist/validate-cli.d.ts +3 -0
  83. package/dist/validate-cli.d.ts.map +1 -0
  84. package/dist/validate.d.ts +15 -0
  85. package/dist/validate.d.ts.map +1 -0
  86. package/package.json +76 -0
  87. package/src/codegen/docs-cli.ts +7 -0
  88. package/src/codegen/docs.ts +208 -0
  89. package/src/codegen/generate-cli.ts +36 -0
  90. package/src/codegen/generate-lexicon.ts +52 -0
  91. package/src/codegen/generate-typescript.ts +69 -0
  92. package/src/codegen/generate.ts +106 -0
  93. package/src/codegen/naming.ts +45 -0
  94. package/src/codegen/package.ts +25 -0
  95. package/src/composites/fly-deploy.ts +169 -0
  96. package/src/describe-resources.test.ts +188 -0
  97. package/src/describe-resources.ts +0 -0
  98. package/src/detect.ts +64 -0
  99. package/src/export-resources.ts +123 -0
  100. package/src/generated/.gitkeep +0 -0
  101. package/src/generated/index.d.ts +590 -0
  102. package/src/generated/index.ts +47 -0
  103. package/src/generated/lexicon-fly.json +285 -0
  104. package/src/generated/runtime.ts +4 -0
  105. package/src/import/generator.test.ts +123 -0
  106. package/src/import/generator.ts +242 -0
  107. package/src/import/live-export.test.ts +150 -0
  108. package/src/import/live-export.ts +107 -0
  109. package/src/import/parser.test.ts +132 -0
  110. package/src/import/parser.ts +185 -0
  111. package/src/import/roundtrip.test.ts +77 -0
  112. package/src/index.ts +23 -0
  113. package/src/lint/post-synth/fly-helpers.ts +25 -0
  114. package/src/lint/post-synth/fly010-machine-requires-image.test.ts +59 -0
  115. package/src/lint/post-synth/fly010-machine-requires-image.ts +39 -0
  116. package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +92 -0
  117. package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +61 -0
  118. package/src/lint/post-synth/index.ts +9 -0
  119. package/src/lint/rules/guest-sizing.ts +100 -0
  120. package/src/lint/rules/index.ts +15 -0
  121. package/src/lint/rules/no-secret-literals.ts +67 -0
  122. package/src/lint/rules/rules.test.ts +127 -0
  123. package/src/lint/rules/valid-region.ts +58 -0
  124. package/src/lsp/completions.test.ts +10 -0
  125. package/src/lsp/completions.ts +14 -0
  126. package/src/lsp/hover.test.ts +10 -0
  127. package/src/lsp/hover.ts +14 -0
  128. package/src/op/activities/describe-resources.integration.test.ts +105 -0
  129. package/src/op/activities/flaps.test.ts +29 -0
  130. package/src/op/activities/flaps.ts +44 -0
  131. package/src/op/activities/fly-apply.integration.test.ts +245 -0
  132. package/src/op/activities/fly-apply.test.ts +513 -0
  133. package/src/op/activities/fly-apply.ts +1014 -0
  134. package/src/op/activities/index.ts +48 -0
  135. package/src/ownership.ts +32 -0
  136. package/src/package-cli.ts +24 -0
  137. package/src/plugin.test.ts +17 -0
  138. package/src/plugin.ts +285 -0
  139. package/src/pseudo.test.ts +32 -0
  140. package/src/pseudo.ts +29 -0
  141. package/src/serializer.test.ts +232 -0
  142. package/src/serializer.ts +293 -0
  143. package/src/skills/chant-fly-ops.md +55 -0
  144. package/src/skills/chant-fly-patterns.md +71 -0
  145. package/src/skills/chant-fly.md +123 -0
  146. package/src/spec/fetch.ts +34 -0
  147. package/src/spec/parse.ts +323 -0
  148. package/src/validate-cli.ts +5 -0
  149. package/src/validate.ts +28 -0
@@ -0,0 +1,14 @@
1
+ import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
2
+ // import { LexiconIndex, lexiconCompletions } from "@intentius/chant/lsp/lexicon-providers";
3
+
4
+ /**
5
+ * Provide LSP completions for fly resources.
6
+ *
7
+ * TODO: Build a LexiconIndex from your generated lexicon data
8
+ * and delegate to lexiconCompletions().
9
+ */
10
+ export function completions(ctx: CompletionContext): CompletionItem[] {
11
+ // const index = new LexiconIndex(lexiconData);
12
+ // return lexiconCompletions(ctx, index, "fly resource");
13
+ return [];
14
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hover } from "./hover";
3
+
4
+ describe("LSP hover", () => {
5
+ it("returns undefined for unknown context", () => {
6
+ // TODO: Replace with a real HoverContext
7
+ const result = hover({} as any);
8
+ expect(result).toBeUndefined();
9
+ });
10
+ });
@@ -0,0 +1,14 @@
1
+ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
2
+ // import { LexiconIndex, lexiconHover } from "@intentius/chant/lsp/lexicon-providers";
3
+
4
+ /**
5
+ * Provide LSP hover information for fly resources.
6
+ *
7
+ * TODO: Build a LexiconIndex from your generated lexicon data
8
+ * and delegate to lexiconHover().
9
+ */
10
+ export function hover(ctx: HoverContext): HoverInfo | undefined {
11
+ // const index = new LexiconIndex(lexiconData);
12
+ // return lexiconHover(ctx, index, myCustomHoverFormatter);
13
+ return undefined;
14
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { flapsUp, flapsDown } from "./flaps";
6
+ import { flyApply, defaultFlyHttp } from "./fly-apply";
7
+ import { describeResources, flyPlan } from "../../describe-resources";
8
+
9
+ // End-to-end against a live mudflaps container (the #740 wrapper), proving the
10
+ // read-back seam (#767) against real wire behavior: flyApply writes, then
11
+ // describeResources reads it back with the right ownership verdict, and the
12
+ // declared-vs-live plan classifies create/noop/delete without ever deleting an
13
+ // unmarked machine. Docker is required; CI skips cleanly (same guard as
14
+ // fly-apply.integration.test.ts).
15
+
16
+ const APP = "chant-desc-it";
17
+ const CONTAINER = "chant-mudflaps-desc-it";
18
+ const PORT = 4282;
19
+ const http = defaultFlyHttp();
20
+ const WAIT = { intervalMs: 50, timeoutSecs: 5, deadlineMs: 30_000 };
21
+
22
+ let endpoint = "";
23
+ let available = false;
24
+ let tmp = "";
25
+
26
+ function planFile(name: string, plan: Record<string, unknown>): string {
27
+ const path = join(tmp, `${name}.json`);
28
+ writeFileSync(path, JSON.stringify(plan));
29
+ return path;
30
+ }
31
+
32
+ const appReq = { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } };
33
+ const machineReq = (image: string) => ({
34
+ endpoint: `/v1/apps/${APP}/machines`,
35
+ method: "POST",
36
+ body: { name: "web", region: "iad", config: { image, metadata: { "managed-by": "chant" } } },
37
+ });
38
+
39
+ /** entities map keyed by declared entity name (props unused by describeResources). */
40
+ function entities(pairs: Array<[string, string]>) {
41
+ return new Map(pairs.map(([name, entityType]) => [name, { entityType, props: {} as Record<string, unknown> }]));
42
+ }
43
+
44
+ beforeAll(async () => {
45
+ if (process.env.CI && !process.env.FLY_IT) {
46
+ available = false;
47
+ return;
48
+ }
49
+ tmp = mkdtempSync(join(tmpdir(), "fly-desc-it-"));
50
+ try {
51
+ const up = await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
52
+ endpoint = up.endpoint;
53
+ available = true;
54
+ } catch {
55
+ available = false;
56
+ }
57
+ }, 60_000);
58
+
59
+ afterAll(async () => {
60
+ if (available) await flapsDown({ name: CONTAINER });
61
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
62
+ });
63
+
64
+ describe("describeResources against live mudflaps (#767)", () => {
65
+ test("applied machine reads back owned; unmarked reads foreign; removed owned → delete, unmarked never deletes", async (ctx) => {
66
+ if (!available) ctx.skip();
67
+
68
+ // 1. Apply an App + owned Machine via flyApply.
69
+ const buildOutput = JSON.stringify({ app: appReq, web: machineReq("nginx:1") });
70
+ await flyApply({ planPath: planFile("v1", { app: appReq, web: machineReq("nginx:1") }), endpoint, wait: WAIT });
71
+
72
+ // 2. Plant an UNMARKED machine directly (no managed-by: chant).
73
+ const plant = await http("POST", `${endpoint}/v1/apps/${APP}/machines`, { name: "legacy", region: "iad", config: { image: "redis:1" } });
74
+ expect(plant.status).toBe(200);
75
+
76
+ const ents = entities([["app", "Fly::Machines::App"], ["web", "Fly::Machines::Machine"]]);
77
+
78
+ // 3. describeResources sees web owned, legacy foreign.
79
+ const live = await describeResources({ environment: "it", buildOutput, entityNames: [...ents.keys()], entities: ents, endpoint });
80
+ expect(live.web?.ownership).toBe("owned");
81
+ expect(live.web?.status).toBe("started");
82
+ expect(live.legacy?.ownership).toBe("foreign");
83
+ expect(live.app?.ownership).toBe("owned"); // app-boundary: carries an owned machine
84
+
85
+ // 4. The owned filter drops the unmarked machine.
86
+ const ownedOnly = await describeResources({ environment: "it", buildOutput, entityNames: [...ents.keys()], entities: ents, owned: true, endpoint });
87
+ expect(ownedOnly.web?.ownership).toBe("owned");
88
+ expect(ownedOnly.legacy).toBeUndefined();
89
+
90
+ // 5. Drop "web" from the declared plan. The plan now classifies the live
91
+ // owned "web" as a delete; the unmarked "legacy" stays adopt (never delete).
92
+ const removedOutput = JSON.stringify({ app: appReq });
93
+ const { changeSet } = await flyPlan({
94
+ environment: "it",
95
+ buildOutput: removedOutput,
96
+ entityNames: ["app"],
97
+ entities: entities([["app", "Fly::Machines::App"]]),
98
+ endpoint,
99
+ });
100
+ const action = (name: string) => changeSet.entries.find((e) => e.name === name)?.action;
101
+ expect(action("web")).toBe("delete"); // owned + undeclared
102
+ expect(action("legacy")).toBe("adopt"); // unmarked + undeclared → never a delete
103
+ expect(changeSet.entries.some((e) => e.action === "delete" && e.name === "legacy")).toBe(false);
104
+ }, 120_000);
105
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ flapsRunCommand,
4
+ flapsRmCommand,
5
+ flapsExistsCommand,
6
+ flapsHealthUrl,
7
+ flapsEndpoint,
8
+ } from "./flaps";
9
+
10
+ describe("flaps (mudflaps) lifecycle commands", () => {
11
+ test("run command uses defaults and maps the port", () => {
12
+ expect(flapsRunCommand({})).toBe(
13
+ "docker run -d --rm --name chant-mudflaps -p 4280:4280 ghcr.io/intentius/mudflaps:0.3.0",
14
+ );
15
+ });
16
+
17
+ test("run command honors name/port/image overrides", () => {
18
+ expect(flapsRunCommand({ name: "mf2", port: 4599, image: "ghcr.io/intentius/mudflaps:latest" })).toBe(
19
+ "docker run -d --rm --name mf2 -p 4599:4280 ghcr.io/intentius/mudflaps:latest",
20
+ );
21
+ });
22
+
23
+ test("rm / exists / health / endpoint", () => {
24
+ expect(flapsRmCommand("mf2")).toBe("docker rm -f mf2");
25
+ expect(flapsExistsCommand("mf2")).toBe("docker ps -q -f name=mf2");
26
+ expect(flapsHealthUrl(4280)).toBe("http://localhost:4280/_mudflaps/health");
27
+ expect(flapsEndpoint(4280)).toBe("http://localhost:4280");
28
+ });
29
+ });
@@ -0,0 +1,44 @@
1
+ import { emulatorLifecycle } from "@intentius/chant/op";
2
+
3
+ export interface FlapsUpArgs {
4
+ /** Container name. Default: `chant-mudflaps`. */
5
+ name?: string;
6
+ /** Host port mapped to the emulator's `:4280`. Default: `4280`. */
7
+ port?: number;
8
+ /** Image. Default: `ghcr.io/intentius/mudflaps:0.3.0`. */
9
+ image?: string;
10
+ /** Readiness timeout in ms. Default: `60000`. */
11
+ timeoutMs?: number;
12
+ /** Health poll interval in ms. Default: `2000`. */
13
+ intervalMs?: number;
14
+ }
15
+
16
+ export interface FlapsDownArgs {
17
+ /** Container name to remove. Default: `chant-mudflaps`. */
18
+ name?: string;
19
+ }
20
+
21
+ // mudflaps is a stateful fake of the Fly Machines API (flaps) — a plain 200 on
22
+ // its health endpoint means ready. The local target for flyApply; point it there
23
+ // with FLY_FLAPS_BASE_URL. Shared lifecycle: emulatorLifecycle (#746).
24
+ const flaps = emulatorLifecycle({
25
+ name: "chant-mudflaps",
26
+ image: "ghcr.io/intentius/mudflaps:0.3.0",
27
+ containerPort: 4280,
28
+ healthPath: "/_mudflaps/health",
29
+ });
30
+
31
+ export const flapsExistsCommand = flaps.existsCommand;
32
+ export const flapsRmCommand = flaps.rmCommand;
33
+ export const flapsHealthUrl = flaps.healthUrl;
34
+ /** The flaps endpoint URL (what `flyApply`'s `FLY_FLAPS_BASE_URL`/`endpoint` points at). */
35
+ export const flapsEndpoint = flaps.endpoint;
36
+ export const flapsRunCommand = (args: FlapsUpArgs = {}): string => flaps.runCommand(args);
37
+
38
+ /** Boot a local mudflaps (Fly Machines API emulator) in Docker and return its endpoint. */
39
+ export const flapsUp = (args: FlapsUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> =>
40
+ flaps.up(args, signal);
41
+
42
+ /** Stop and remove the local mudflaps container (no-op if already gone). */
43
+ export const flapsDown = (args: FlapsDownArgs = {}, signal?: AbortSignal): Promise<void> =>
44
+ flaps.down(args, signal);
@@ -0,0 +1,245 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { flapsUp, flapsDown } from "./flaps";
6
+ import {
7
+ flyApply,
8
+ defaultFlyHttp,
9
+ listMachines,
10
+ listVolumes,
11
+ listIps,
12
+ listCerts,
13
+ listSecrets,
14
+ acquireLease,
15
+ releaseLease,
16
+ isLeaseConflict,
17
+ LEASE_NONCE_HEADER,
18
+ type ApplyCtx,
19
+ } from "./fly-apply";
20
+
21
+ // End-to-end against a live mudflaps container (the #740 wrapper). Docker is
22
+ // required; when it is unavailable (CI) the whole suite skips cleanly rather
23
+ // than hard-failing. The tricky paths (lease-conflict retry, wait-on-new-
24
+ // version, owned-only prune) are proven here against real wire behavior.
25
+
26
+ const APP = "chant-it";
27
+ const CONTAINER = "chant-mudflaps-it";
28
+ const PORT = 4281;
29
+ const http = defaultFlyHttp();
30
+ const WAIT = { intervalMs: 50, timeoutSecs: 5, deadlineMs: 30_000 };
31
+
32
+ let endpoint = "";
33
+ let available = false;
34
+ let tmp = "";
35
+
36
+ /** Write a serializer-shaped plan file and return its path. */
37
+ function planFile(name: string, plan: Record<string, unknown>): string {
38
+ const path = join(tmp, `${name}.json`);
39
+ writeFileSync(path, JSON.stringify(plan));
40
+ return path;
41
+ }
42
+
43
+ const machineReq = (image: string) => ({
44
+ endpoint: `/v1/apps/${APP}/machines`,
45
+ method: "POST",
46
+ body: {
47
+ name: "web",
48
+ region: "iad",
49
+ config: { image, metadata: { "managed-by": "chant" } },
50
+ },
51
+ });
52
+
53
+ const appReq = { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } };
54
+
55
+ const volumeReq = { endpoint: `/v1/apps/${APP}/volumes`, method: "POST", body: { name: "data", region: "iad", size_gb: 1 } };
56
+ const mountingMachineReq = {
57
+ endpoint: `/v1/apps/${APP}/machines`,
58
+ method: "POST",
59
+ body: { name: "web", region: "iad", config: { image: "nginx:1", mounts: [{ volume: "data", path: "/data" }], metadata: { "managed-by": "chant" } } },
60
+ };
61
+ const ipReq = { endpoint: `/v1/apps/${APP}/ip_assignments`, method: "POST", body: { type: "shared_v4", region: "global", org_slug: "personal" } };
62
+ const certReq = { endpoint: `/v1/apps/${APP}/certificates`, method: "POST", body: { hostname: "example.com" } };
63
+ const secretReq = { endpoint: `/v1/apps/${APP}/secrets/db-password`, method: "POST", body: { value: "s3cret" }, applyOnly: true };
64
+
65
+ beforeAll(async () => {
66
+ // Deterministically skip in CI. GitHub runners have docker, so relying on
67
+ // docker-absence alone would pull mudflaps from ghcr on every CI run — a
68
+ // network dependency that can flake. This is the only emulator-backed test in
69
+ // the repo; it is a local-fidelity check, run explicitly (docker up) or in a
70
+ // job that opts in with FLY_IT=1.
71
+ if (process.env.CI && !process.env.FLY_IT) {
72
+ available = false;
73
+ return;
74
+ }
75
+ tmp = mkdtempSync(join(tmpdir(), "fly-it-"));
76
+ try {
77
+ const up = await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 });
78
+ endpoint = up.endpoint;
79
+ available = true;
80
+ } catch {
81
+ available = false;
82
+ }
83
+ }, 60_000);
84
+
85
+ afterAll(async () => {
86
+ if (available) await flapsDown({ name: CONTAINER });
87
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
88
+ });
89
+
90
+ describe("flyApply against live mudflaps (#739)", () => {
91
+ test("full acceptance loop: create → started → no-op → update → prune; unmarked survives", async (ctx) => {
92
+ if (!available) ctx.skip();
93
+ const cctx: ApplyCtx = { base: endpoint };
94
+
95
+ // 1. Create App + Machine → machine reaches started (wait resolves).
96
+ const created = await flyApply({ planPath: planFile("v1", { app: appReq, web: machineReq("nginx:1") }), endpoint, wait: WAIT });
97
+ expect(created.apps).toEqual([{ app: APP, created: true }]);
98
+ expect(created.machines).toEqual([{ app: APP, name: "web", action: "created" }]);
99
+
100
+ const afterCreate = await listMachines(cctx, APP, http);
101
+ expect(afterCreate.map((m) => m.name)).toContain("web");
102
+ expect(afterCreate.find((m) => m.name === "web")?.state).toBe("started");
103
+
104
+ // 2. Re-apply identical plan → no-op.
105
+ const reapply = await flyApply({ planPath: planFile("v1b", { app: appReq, web: machineReq("nginx:1") }), endpoint, wait: WAIT });
106
+ expect(reapply.machines).toEqual([{ app: APP, name: "web", action: "noop" }]);
107
+
108
+ // 3. Image change → update to a new version.
109
+ const before = await listMachines(cctx, APP, http);
110
+ const oldInstance = before.find((m) => m.name === "web")?.instance_id;
111
+ const updated = await flyApply({ planPath: planFile("v2", { app: appReq, web: machineReq("nginx:2") }), endpoint, wait: WAIT });
112
+ expect(updated.machines).toEqual([{ app: APP, name: "web", action: "updated" }]);
113
+ const after = await listMachines(cctx, APP, http);
114
+ const newWeb = after.find((m) => m.name === "web");
115
+ expect(newWeb?.config?.image).toBe("nginx:2");
116
+ expect(newWeb?.instance_id).not.toBe(oldInstance); // new version minted
117
+
118
+ // 4. Plant an UNMARKED machine directly (no managed-by: chant) — prune must spare it.
119
+ const plant = await http("POST", `${endpoint}/v1/apps/${APP}/machines`, { name: "legacy", region: "iad", config: { image: "redis:1" } });
120
+ expect(plant.status).toBe(200);
121
+
122
+ // 5. Remove the owned machine from the plan and prune. Owned "web" is
123
+ // destroyed; unmarked "legacy" survives.
124
+ const pruned = await flyApply({ planPath: planFile("v3", { app: appReq }), endpoint, prune: true, wait: WAIT });
125
+ expect(pruned.pruned.map((p) => p.name)).toEqual(["web"]);
126
+
127
+ const finalMachines = await listMachines(cctx, APP, http);
128
+ const names = finalMachines.map((m) => m.name);
129
+ expect(names).toContain("legacy"); // unmarked survived
130
+ expect(names).not.toContain("web"); // owned + undeclared pruned
131
+ }, 90_000);
132
+
133
+ test("lease-conflict retry: gated update 409 → re-acquire → succeed", async (ctx) => {
134
+ if (!available) ctx.skip();
135
+ const cctx: ApplyCtx = { base: endpoint };
136
+
137
+ // Fresh owned machine to mutate.
138
+ await flyApply({ planPath: planFile("lc-setup", { app: appReq, lc: { ...machineReq("nginx:1"), body: { name: "leased", region: "iad", config: { image: "nginx:1", metadata: { "managed-by": "chant" } } } } }), endpoint, wait: WAIT });
139
+ const target = (await listMachines(cctx, APP, http)).find((m) => m.name === "leased");
140
+ expect(target).toBeDefined();
141
+ const id = target!.id;
142
+
143
+ // A foreign holder takes the lease out of band (nonce the applier never sees).
144
+ const foreignNonce = await acquireLease(cctx, APP, id, http);
145
+
146
+ // An http wrapper that, on the applier's FIRST lease acquire (which the real
147
+ // server 409s because the foreign lease is held), releases the foreign lease
148
+ // so the applier's retry acquire succeeds against a now-free machine. This
149
+ // drives the real wire path: acquire 409 → re-acquire → mutate → wait.
150
+ let sawConflict = false;
151
+ let released = false;
152
+ const wrapped: typeof http = async (method, url, body, headers, signal) => {
153
+ const res = await http(method, url, body, headers, signal);
154
+ if (!released && method === "POST" && url.endsWith("/lease") && isLeaseConflict(res.status, res.text)) {
155
+ sawConflict = true;
156
+ await releaseLease(cctx, APP, id, foreignNonce, http);
157
+ released = true;
158
+ }
159
+ return res;
160
+ };
161
+
162
+ const gatedReq = { endpoint: `/v1/apps/${APP}/machines`, method: "POST", body: { name: "leased", region: "iad", config: { image: "nginx:2", metadata: { "managed-by": "chant" } } } };
163
+ const out = await flyApply({ planPath: planFile("lc-update", { app: appReq, lc: gatedReq }), endpoint, wait: WAIT }, undefined, wrapped);
164
+ expect(out.machines).toEqual([{ app: APP, name: "leased", action: "updated" }]);
165
+ expect(sawConflict).toBe(true); // the applier really hit a 409 and recovered
166
+ const settled = (await listMachines(cctx, APP, http)).find((m) => m.name === "leased");
167
+ expect(settled?.config?.image).toBe("nginx:2");
168
+ }, 90_000);
169
+
170
+ // ── #741 breadth: Volume + IPAddress + Certificate + Secret ─────────────────
171
+
172
+ test("volume applies before a mounting machine; ip + cert prune app-scoped; secret set never diffs", async (ctx) => {
173
+ if (!available) ctx.skip();
174
+ const cctx: ApplyCtx = { base: endpoint };
175
+
176
+ // 1. Apply app + volume + a machine that mounts it, plus an ip, a cert, and
177
+ // an apply-only secret. The volume must be created before the machine.
178
+ // An http wrapper records the real POST order against live mudflaps.
179
+ const createOrder: string[] = [];
180
+ const recording: typeof http = async (method, url, body, headers, signal) => {
181
+ if (method === "POST" && url.endsWith("/volumes")) createOrder.push("volume");
182
+ if (method === "POST" && url.endsWith("/machines")) createOrder.push("machine");
183
+ return http(method, url, body, headers, signal);
184
+ };
185
+ const applied = await flyApply({
186
+ planPath: planFile("b1", { app: appReq, web: mountingMachineReq, data: volumeReq, ip: ipReq, cert: certReq, "db-password": secretReq }),
187
+ endpoint,
188
+ wait: WAIT,
189
+ }, undefined, recording);
190
+
191
+ // Volume provisioned; machine that mounts it reached started.
192
+ expect(applied.volumes).toEqual([{ app: APP, name: "data", action: "created" }]);
193
+ expect(applied.machines).toEqual([{ app: APP, name: "web", action: "created" }]);
194
+ // Dependency order proven on the wire: the volume was POSTed first.
195
+ expect(createOrder).toEqual(["volume", "machine"]);
196
+ const vols = await listVolumes(cctx, APP, http);
197
+ expect(vols.map((v) => v.name)).toContain("data");
198
+ const web = (await listMachines(cctx, APP, http)).find((m) => m.name === "web");
199
+ expect(web?.state).toBe("started");
200
+
201
+ // IP + certificate applied.
202
+ expect(applied.ips).toEqual([{ app: APP, type: "shared_v4", action: "created" }]);
203
+ expect(applied.certs).toEqual([{ app: APP, hostname: "example.com", action: "created" }]);
204
+ expect((await listIps(cctx, APP, http)).length).toBeGreaterThan(0);
205
+ expect((await listCerts(cctx, APP, http)).map((c) => c.hostname)).toContain("example.com");
206
+
207
+ // Secret set. flaps returns only a digest, never the value → apply-only (D7).
208
+ expect(applied.secrets).toEqual([{ app: APP, name: "db-password" }]);
209
+ const liveSecret = (await listSecrets(cctx, APP, http)).find((s) => s.name === "db-password");
210
+ expect(liveSecret).toBeDefined();
211
+ expect((liveSecret as { value?: unknown } | undefined)?.value ?? null).toBeNull(); // never read back
212
+
213
+ // 2. Re-apply the identical plan. The secret is always re-set (apply-only,
214
+ // never a diff-driven no-op); the ip/cert/volume are idempotent no-ops.
215
+ const reapply = await flyApply({
216
+ planPath: planFile("b1b", { app: appReq, web: mountingMachineReq, data: volumeReq, ip: ipReq, cert: certReq, "db-password": secretReq }),
217
+ endpoint,
218
+ wait: WAIT,
219
+ });
220
+ expect(reapply.volumes).toEqual([{ app: APP, name: "data", action: "noop" }]);
221
+ expect(reapply.ips).toEqual([{ app: APP, type: "shared_v4", action: "noop" }]);
222
+ expect(reapply.certs).toEqual([{ app: APP, hostname: "example.com", action: "noop" }]);
223
+ expect(reapply.secrets).toEqual([{ app: APP, name: "db-password" }]); // set again, no diff
224
+
225
+ // 3. Drop the ip + cert + secret from the plan and prune. App-scoped (D2):
226
+ // the metadata-less types are owned wholesale, so undeclared ones go.
227
+ const pruned = await flyApply({
228
+ planPath: planFile("b2", { app: appReq, web: mountingMachineReq, data: volumeReq }),
229
+ endpoint,
230
+ prune: true,
231
+ wait: WAIT,
232
+ });
233
+ expect(pruned.prunedIps.length).toBeGreaterThan(0);
234
+ expect(pruned.prunedCerts.map((c) => c.hostname)).toContain("example.com");
235
+ expect(pruned.prunedSecrets.map((s) => s.name)).toContain("db-password");
236
+ // The still-declared volume + machine survive the app-scoped prune.
237
+ expect(pruned.prunedVolumes).toEqual([]);
238
+ expect(pruned.pruned.map((p) => p.name)).not.toContain("web");
239
+
240
+ expect((await listCerts(cctx, APP, http)).map((c) => c.hostname)).not.toContain("example.com");
241
+ expect((await listSecrets(cctx, APP, http)).map((s) => s.name)).not.toContain("db-password");
242
+ expect((await listVolumes(cctx, APP, http)).map((v) => v.name)).toContain("data");
243
+ expect((await listMachines(cctx, APP, http)).map((m) => m.name)).toContain("web");
244
+ }, 120_000);
245
+ });