@intentius/chant-lexicon-fly 0.89.0 → 0.91.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 (75) hide show
  1. package/dist/components/capability-plugin.d.ts +1 -0
  2. package/dist/components/capability-plugin.d.ts.map +1 -1
  3. package/dist/components/fly-release.d.ts +148 -0
  4. package/dist/components/fly-release.d.ts.map +1 -0
  5. package/dist/components/index.d.ts +2 -0
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/describe-resources.d.ts +11 -0
  8. package/dist/describe-resources.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +4 -4
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/emulator-images.d.ts +13 -0
  14. package/dist/op/activities/emulator-images.d.ts.map +1 -1
  15. package/dist/op/activities/fly-apply.d.ts +8 -22
  16. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  17. package/dist/op/activities/index.d.ts +6 -2
  18. package/dist/op/activities/index.d.ts.map +1 -1
  19. package/dist/op/activities/machine-release.d.ts +176 -0
  20. package/dist/op/activities/machine-release.d.ts.map +1 -0
  21. package/dist/op/activities/machines-contract.d.ts +8 -0
  22. package/dist/op/activities/machines-contract.d.ts.map +1 -1
  23. package/dist/op/activities/machines-fake.d.ts +53 -0
  24. package/dist/op/activities/machines-fake.d.ts.map +1 -0
  25. package/dist/op/activities/sprite-services.d.ts +158 -0
  26. package/dist/op/activities/sprite-services.d.ts.map +1 -0
  27. package/dist/op/activities/sprites-contract.d.ts +17 -1
  28. package/dist/op/activities/sprites-contract.d.ts.map +1 -1
  29. package/dist/op/activities/sprites-emulator.d.ts +12 -0
  30. package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
  31. package/dist/op/activities/sprites-fake.d.ts +4 -0
  32. package/dist/op/activities/sprites-fake.d.ts.map +1 -1
  33. package/dist/op/activities/sprites.d.ts +53 -0
  34. package/dist/op/activities/sprites.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +33 -1
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/release-metadata.d.ts +48 -0
  38. package/dist/release-metadata.d.ts.map +1 -0
  39. package/dist/release-store.d.ts +36 -0
  40. package/dist/release-store.d.ts.map +1 -0
  41. package/dist/skills/chant-fly-ops.md +33 -0
  42. package/dist/skills/chant-fly-sprites.md +4 -2
  43. package/package.json +2 -2
  44. package/src/components/capability-plugin.ts +11 -3
  45. package/src/components/fly-release.test.ts +244 -0
  46. package/src/components/fly-release.ts +341 -0
  47. package/src/components/index.ts +16 -0
  48. package/src/describe-resources.test.ts +18 -3
  49. package/src/describe-resources.ts +69 -5
  50. package/src/index.ts +21 -0
  51. package/src/op/activities/emulator-images.ts +14 -0
  52. package/src/op/activities/fly-apply.ts +15 -3
  53. package/src/op/activities/index.ts +57 -0
  54. package/src/op/activities/machine-release.integration.test.ts +113 -0
  55. package/src/op/activities/machine-release.test.ts +138 -0
  56. package/src/op/activities/machine-release.ts +407 -0
  57. package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
  58. package/src/op/activities/machines-contract.test.ts +11 -1
  59. package/src/op/activities/machines-contract.ts +17 -0
  60. package/src/op/activities/machines-fake.ts +165 -0
  61. package/src/op/activities/sprite-config.test.ts +19 -0
  62. package/src/op/activities/sprite-services.docker.integration.test.ts +87 -0
  63. package/src/op/activities/sprite-services.test.ts +263 -0
  64. package/src/op/activities/sprite-services.ts +311 -0
  65. package/src/op/activities/sprites-contract.test.ts +15 -4
  66. package/src/op/activities/sprites-contract.ts +24 -6
  67. package/src/op/activities/sprites-emulator.ts +52 -1
  68. package/src/op/activities/sprites-fake.ts +57 -8
  69. package/src/op/activities/sprites.test.ts +102 -0
  70. package/src/op/activities/sprites.ts +108 -7
  71. package/src/op/builders.ts +53 -1
  72. package/src/release-metadata.ts +72 -0
  73. package/src/release-store.ts +70 -0
  74. package/src/skills/chant-fly-ops.md +33 -0
  75. package/src/skills/chant-fly-sprites.md +4 -2
@@ -35,6 +35,29 @@ export {
35
35
  } from "./fly-apply";
36
36
  export type { FlyApplyArgs, FlyPlan, FlapsRequest, FlapsMachine, FlyHttp, WaitOpts, ApplyCtx } from "./fly-apply";
37
37
 
38
+ // The release a Machine serves and the site steps over the Machines API
39
+ // (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
40
+ // restore. `loadActivities(["fly"])` binds these by name; the `fly-release`
41
+ // and `fly-rollback` capabilities compose them.
42
+ export {
43
+ flyMachineRelease,
44
+ flyMachineExec,
45
+ flyMachineRestart,
46
+ flyMachineStop,
47
+ flyMachineVerify,
48
+ flyMachineRestore,
49
+ } from "./machine-release";
50
+ export type {
51
+ MachineRelease,
52
+ FlyMachineReleaseArgs,
53
+ FlyMachineReleaseResult,
54
+ FlyMachineExecArgs,
55
+ FlyMachineExecResult,
56
+ FlyMachineStateArgs,
57
+ FlyMachineVerifyArgs,
58
+ FlyMachineRestoreArgs,
59
+ } from "./machine-release";
60
+
38
61
  // mudflaps (Fly Machines API emulator) lifecycle — boots/tears down the local
39
62
  // flaps target flyApply is exercised against.
40
63
  export {
@@ -61,7 +84,10 @@ export {
61
84
  spriteRestore,
62
85
  listCheckpoints,
63
86
  spriteDestroy,
87
+ spriteDelete,
88
+ spriteUrl,
64
89
  resolveSpritesEndpoint,
90
+ resolveSpritesToken,
65
91
  defaultSpritesHttp,
66
92
  spriteCreateBody,
67
93
  parseCreateResponse,
@@ -84,8 +110,39 @@ export type {
84
110
  ListCheckpointsArgs,
85
111
  Checkpoint,
86
112
  SpriteDestroyArgs,
113
+ SpriteDeleteArgs,
114
+ SpriteUrlArgs,
115
+ SpriteUrlResult,
87
116
  } from "./sprites";
88
117
 
118
+ // Sprite Services activities (#2711) — imperative create/get/list/start/stop/
119
+ // delete/logs for one background service at a time, the single-service
120
+ // primitives underneath `spriteApplyServices`'s batch reconcile.
121
+ // `loadActivities(["fly"])` binds these; the step builders live in core.
122
+ export {
123
+ spriteServiceCreate,
124
+ spriteServiceGet,
125
+ spriteServiceList,
126
+ spriteServiceStart,
127
+ spriteServiceStop,
128
+ spriteServiceDelete,
129
+ spriteServiceLogs,
130
+ spriteServiceCreateBody,
131
+ parseServiceLogNdjson,
132
+ } from "./sprite-services";
133
+ export type {
134
+ SpriteService,
135
+ SpriteServiceState,
136
+ SpriteServiceCreateArgs,
137
+ SpriteServiceGetArgs,
138
+ SpriteServiceListArgs,
139
+ SpriteServiceStartArgs,
140
+ SpriteServiceStopArgs,
141
+ SpriteServiceDeleteArgs,
142
+ SpriteServiceLogsArgs,
143
+ SpriteServiceLogsResult,
144
+ } from "./sprite-services";
145
+
89
146
  // Sprite filesystem activities (#848) — imperative file I/O over the fs API.
90
147
  // `loadActivities(["fly"])` binds these; the step builders live in core.
91
148
  export {
@@ -0,0 +1,113 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { flapsUp, flapsDown } from "./flaps";
6
+ import { defaultFlyHttp, deleteApp } from "./fly-apply";
7
+ import { findMachine } from "./machine-release";
8
+ import { readMachineRelease } from "../../release-metadata";
9
+ import { createFlyReleaseCapability, createFlyRollbackCapability } from "../../components/fly-release";
10
+ import { memoryMachineConfigStore } from "../../release-store";
11
+ import { describeResources } from "../../describe-resources";
12
+ import type { EffectReceiptRef } from "@intentius/chant/op/receipt-store";
13
+
14
+ // A release, a migration, a second release and a rollback against a live
15
+ // mudflaps container (#2736): the `fly-release` / `fly-rollback` capabilities
16
+ // over the real Machines wire protocol, then describeResources reading the
17
+ // release back the way `chant components status --live` does. Docker is
18
+ // required; the suite skips in CI unless a job opts in with FLY_IT=1, like
19
+ // ./fly-apply.integration.test.ts. No Fly credentials: mudflaps ignores tokens.
20
+
21
+ const APP = "chant-release-it";
22
+ const CONTAINER = "chant-mudflaps-release-it";
23
+ const PORT = 4284;
24
+ const WAIT = { intervalMs: 50, timeoutSecs: 5, deadlineMs: 30_000 };
25
+ const http = defaultFlyHttp();
26
+
27
+ let endpoint = "";
28
+ let available = false;
29
+ let tmp = "";
30
+
31
+ beforeAll(async () => {
32
+ if (process.env.CI && !process.env.FLY_IT) return;
33
+ tmp = mkdtempSync(join(tmpdir(), "fly-release-it-"));
34
+ try {
35
+ endpoint = (await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 })).endpoint;
36
+ available = true;
37
+ } catch {
38
+ available = false;
39
+ }
40
+ }, 60_000);
41
+
42
+ afterAll(async () => {
43
+ if (available) {
44
+ await deleteApp({ base: endpoint }, APP, http).catch(() => undefined);
45
+ await flapsDown({ name: CONTAINER });
46
+ }
47
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ describe("fly-release against mudflaps", () => {
51
+ test("release, migrate, release again, roll back; the Machine's metadata names each release", async (ctx) => {
52
+ if (!available) ctx.skip();
53
+ const planPath = join(tmp, "fly.json");
54
+ writeFileSync(
55
+ planPath,
56
+ JSON.stringify({
57
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
58
+ web: {
59
+ endpoint: `/v1/apps/${APP}/machines`,
60
+ method: "POST",
61
+ body: { name: "web", region: "iad", config: { image: "nginx:1", metadata: { "managed-by": "chant" } } },
62
+ },
63
+ }),
64
+ );
65
+ const configs = memoryMachineConfigStore();
66
+ const receipts = new Map<string, string>();
67
+ const deps = {
68
+ http,
69
+ configStore: () => configs,
70
+ receiptStore: () => ({
71
+ read: async (r: EffectReceiptRef) => receipts.get(r.name),
72
+ write: async (r: EffectReceiptRef, v: string) => void receipts.set(r.name, v),
73
+ }),
74
+ headCommit: async () => "1111111111111111111111111111111111111111",
75
+ log: () => undefined,
76
+ };
77
+ const release = createFlyReleaseCapability(deps);
78
+ const rollback = createFlyRollbackCapability(deps);
79
+ const c = { env: "it", component: "web" };
80
+ const common = { plan: planPath, endpoint, wait: WAIT, verify: { intervalMs: 100, timeoutMs: 10_000 } };
81
+
82
+ const one = await release.run(c, { ...common, digest: "sha256:one", image: "nginx:1", migrations: [{ name: "001.sql", command: "echo migrate 001" }] });
83
+ expect(one.migrations).toEqual([{ name: "001.sql", fired: true }]);
84
+ const two = await release.run(c, { ...common, digest: "sha256:two", image: "nginx:2", migrations: [{ name: "001.sql", command: "echo migrate 001" }] });
85
+ expect(two.migrations).toEqual([{ name: "001.sql", fired: false }]);
86
+ expect(two.previous?.digest).toBe("sha256:one");
87
+
88
+ let m = await findMachine({ base: endpoint }, APP, "web", http);
89
+ expect(readMachineRelease(m?.config?.metadata)).toMatchObject({ digest: "sha256:two", previousDigest: "sha256:one" });
90
+ expect(m?.config?.image).toBe("nginx:2");
91
+
92
+ const observed = await describeResources(
93
+ {
94
+ environment: "it",
95
+ buildOutput: "",
96
+ entityNames: ["app", "web"],
97
+ entities: new Map([
98
+ ["app", { entityType: "Fly::Machines::App", props: { name: APP } }],
99
+ ["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } }],
100
+ ]),
101
+ endpoint,
102
+ },
103
+ http,
104
+ );
105
+ expect((observed.resources.web?.attributes as { digest?: string }).digest).toBe("sha256:two");
106
+
107
+ const back = await rollback.run(c, { plan: planPath, endpoint, wait: WAIT, verify: { intervalMs: 100, timeoutMs: 10_000 } });
108
+ expect(back.digest).toBe("sha256:one");
109
+ m = await findMachine({ base: endpoint }, APP, "web", http);
110
+ expect(m?.config?.image).toBe("nginx:1");
111
+ expect(readMachineRelease(m?.config?.metadata)?.digest).toBe("sha256:one");
112
+ }, 120_000);
113
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ RELEASE_METADATA_KEYS,
4
+ flyMachineExec,
5
+ flyMachineRelease,
6
+ flyMachineRestart,
7
+ flyMachineRestore,
8
+ flyMachineStop,
9
+ flyMachineVerify,
10
+ readMachineRelease,
11
+ releaseTarget,
12
+ withReleaseMetadata,
13
+ } from "./machine-release";
14
+ import { createMachinesFake } from "./machines-fake";
15
+ import type { FlyPlan } from "./fly-apply";
16
+
17
+ const ENDPOINT = "http://flaps.test";
18
+ const NO_WAIT = { intervalMs: 0, deadlineMs: 2_000 };
19
+
20
+ const PLAN: FlyPlan = {
21
+ shop: { endpoint: "/v1/apps", method: "POST", body: { app_name: "shop", org_slug: "personal" } },
22
+ web: {
23
+ endpoint: "/v1/apps/shop/machines",
24
+ method: "POST",
25
+ body: { name: "web", config: { image: "shop:1", env: { PORT: "8080" }, metadata: { "managed-by": "chant" } } },
26
+ },
27
+ };
28
+
29
+ describe("release metadata (pure)", () => {
30
+ test("withReleaseMetadata stamps the release and keeps other metadata", () => {
31
+ const config = withReleaseMetadata({ image: "x", metadata: { "managed-by": "chant", [RELEASE_METADATA_KEYS.release]: "old" } }, {
32
+ digest: "sha256:a",
33
+ gitSha: "abc1234",
34
+ });
35
+ expect(config.metadata).toEqual({
36
+ "managed-by": "chant",
37
+ [RELEASE_METADATA_KEYS.digest]: "sha256:a",
38
+ [RELEASE_METADATA_KEYS.gitSha]: "abc1234",
39
+ });
40
+ expect(readMachineRelease(config.metadata)).toEqual({ digest: "sha256:a", gitSha: "abc1234" });
41
+ });
42
+
43
+ test("readMachineRelease names no release without a digest", () => {
44
+ expect(readMachineRelease({ "managed-by": "chant" })).toBeUndefined();
45
+ expect(readMachineRelease(undefined)).toBeUndefined();
46
+ });
47
+
48
+ test("releaseTarget picks the plan's one Machine, or the one named", () => {
49
+ expect(releaseTarget(PLAN)).toMatchObject({ app: "shop", entity: "web", name: "web" });
50
+ expect(() => releaseTarget(PLAN, "worker")).toThrow(/no Machine named "worker"/);
51
+ const two: FlyPlan = { ...PLAN, worker: { endpoint: "/v1/apps/shop/machines", method: "POST", body: { name: "worker", config: {} } } };
52
+ expect(() => releaseTarget(two)).toThrow(/declares 2 Machines/);
53
+ expect(releaseTarget(two, "worker").name).toBe("worker");
54
+ });
55
+ });
56
+
57
+ describe("Machines activities against the fake", () => {
58
+ test("a first release creates the App and Machine with the release in its metadata", async () => {
59
+ const fake = createMachinesFake();
60
+ const r = await flyMachineRelease(
61
+ { plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one", gitSha: "1111111" }, image: "shop@sha256:img1", env: { MODE: "prod" }, wait: NO_WAIT },
62
+ undefined,
63
+ fake.http,
64
+ );
65
+ expect(r).toMatchObject({ app: "shop", action: "created", previous: null, release: { digest: "sha256:one", gitSha: "1111111" } });
66
+ const m = fake.machine("shop", "web")!;
67
+ expect(m.config.image).toBe("shop@sha256:img1");
68
+ expect(m.config.env).toEqual({ PORT: "8080", MODE: "prod" });
69
+ expect(readMachineRelease(m.config.metadata)).toEqual({ digest: "sha256:one", gitSha: "1111111" });
70
+ expect(m.config.metadata?.["managed-by"]).toBe("chant");
71
+ });
72
+
73
+ test("a second release records the one it replaced, and re-running it keeps that", async () => {
74
+ const fake = createMachinesFake();
75
+ const args = { plan: PLAN, endpoint: ENDPOINT, wait: NO_WAIT };
76
+ await flyMachineRelease({ ...args, release: { digest: "sha256:one" } }, undefined, fake.http);
77
+ const two = await flyMachineRelease({ ...args, release: { digest: "sha256:two" } }, undefined, fake.http);
78
+ expect(two.action).toBe("updated");
79
+ expect(two.release.previousDigest).toBe("sha256:one");
80
+ expect(two.previous?.release?.digest).toBe("sha256:one");
81
+ expect(readMachineRelease(two.previous?.config.metadata)?.digest).toBe("sha256:one");
82
+ const again = await flyMachineRelease({ ...args, release: { digest: "sha256:two" } }, undefined, fake.http);
83
+ expect(again.action).toBe("noop");
84
+ expect(again.release.previousDigest).toBe("sha256:one");
85
+ });
86
+
87
+ test("exec runs a command in the Machine and fails on a non-zero exit", async () => {
88
+ const fake = createMachinesFake({ exec: (_app, _m, cmd) => (cmd.join(" ").includes("bad") ? { exit_code: 3, stderr: "no table" } : { exit_code: 0, stdout: "ok" }) });
89
+ await flyMachineRelease({ plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one" }, wait: NO_WAIT }, undefined, fake.http);
90
+ const ok = await flyMachineExec({ app: "shop", machine: "web", command: "migrate 001", endpoint: ENDPOINT }, undefined, fake.http);
91
+ expect(ok).toEqual({ exitCode: 0, stdout: "ok", stderr: "" });
92
+ expect(fake.execs[0].command).toEqual(["sh", "-c", "migrate 001"]);
93
+ await expect(flyMachineExec({ app: "shop", machine: "web", command: ["bad"], endpoint: ENDPOINT }, undefined, fake.http)).rejects.toThrow(
94
+ /exited 3.*no table/,
95
+ );
96
+ });
97
+
98
+ test("stop and restart settle the Machine under a lease", async () => {
99
+ const fake = createMachinesFake();
100
+ await flyMachineRelease({ plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one" }, wait: NO_WAIT }, undefined, fake.http);
101
+ await flyMachineStop({ app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
102
+ expect(fake.machine("shop", "web")!.state).toBe("stopped");
103
+ await flyMachineRestart({ app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
104
+ expect(fake.machine("shop", "web")!.state).toBe("started");
105
+ expect(fake.calls.filter((c) => c.endsWith("/lease"))).toHaveLength(4);
106
+ });
107
+
108
+ test("verify: started with the release, and the health endpoint reports it", async () => {
109
+ const fake = createMachinesFake();
110
+ await flyMachineRelease(
111
+ { plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one", gitSha: "1111111" }, wait: NO_WAIT },
112
+ undefined,
113
+ fake.http,
114
+ );
115
+ const health = (revision: string) => (async () => new Response(JSON.stringify({ status: "healthy", revision }))) as unknown as typeof fetch;
116
+ const base = { app: "shop", machine: "web", endpoint: ENDPOINT, intervalMs: 1, timeoutMs: 20 };
117
+ await expect(flyMachineVerify({ ...base, digest: "sha256:one", url: "https://shop.test" }, undefined, fake.http, health("1111111"))).resolves.toMatchObject({
118
+ release: { digest: "sha256:one" },
119
+ });
120
+ await expect(flyMachineVerify({ ...base, digest: "sha256:two" }, undefined, fake.http)).rejects.toThrow(/serves sha256:one, expected sha256:two/);
121
+ await expect(flyMachineVerify({ ...base, digest: "sha256:one", url: "https://shop.test" }, undefined, fake.http, health("2222222"))).rejects.toThrow(
122
+ /reports 2222222/,
123
+ );
124
+ await flyMachineStop({ app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
125
+ await expect(flyMachineVerify({ ...base, digest: "sha256:one" }, undefined, fake.http)).rejects.toThrow(/is stopped/);
126
+ });
127
+
128
+ test("restore puts a recorded Machine config back", async () => {
129
+ const fake = createMachinesFake();
130
+ const args = { plan: PLAN, endpoint: ENDPOINT, wait: NO_WAIT };
131
+ const one = await flyMachineRelease({ ...args, release: { digest: "sha256:one" }, image: "shop:1" }, undefined, fake.http);
132
+ await flyMachineRelease({ ...args, release: { digest: "sha256:two" }, image: "shop:2" }, undefined, fake.http);
133
+ expect(fake.machine("shop", "web")!.config.image).toBe("shop:2");
134
+ const restored = await flyMachineRestore({ app: "shop", machine: "web", config: one.config, endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
135
+ expect(restored.release?.digest).toBe("sha256:one");
136
+ expect(fake.machine("shop", "web")!.config).toEqual(one.config);
137
+ });
138
+ });