@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,378 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { archiveSourceTree } from "@intentius/chant/op/source-archive";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { afterEach, beforeEach } from "vitest";
|
|
8
|
+
import type { DeployContext } from "@intentius/chant/components/capability";
|
|
9
|
+
import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
|
|
10
|
+
import { buildChangeSet } from "@intentius/chant/lifecycle/change-set";
|
|
11
|
+
import { liveEvidenceFromChangeSet, reconcileStatus, type EntityLiveRead } from "@intentius/chant/lifecycle/status";
|
|
12
|
+
import type { ReleaseRecord } from "@intentius/chant/lifecycle/release-ledger";
|
|
13
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
14
|
+
import { createFlyReleaseCapability, createFlyRollbackCapability, migrationReceipt, type FlyReleaseDeps } from "./fly-release";
|
|
15
|
+
import { flyCapabilityPlugin } from "./capability-plugin";
|
|
16
|
+
import { createMachinesFake, type MachinesFake } from "../op/activities/machines-fake";
|
|
17
|
+
import { memoryMachineConfigStore } from "../release-store";
|
|
18
|
+
import { readMachineRelease } from "../release-metadata";
|
|
19
|
+
import { describeResources } from "../describe-resources";
|
|
20
|
+
|
|
21
|
+
const ENDPOINT = "http://flaps.test";
|
|
22
|
+
const NO_WAIT = { intervalMs: 0, deadlineMs: 2_000 };
|
|
23
|
+
const CTX: DeployContext = { env: "prod", component: "shop" };
|
|
24
|
+
|
|
25
|
+
const PLAN = {
|
|
26
|
+
shop: { endpoint: "/v1/apps", method: "POST", body: { app_name: "shop", org_slug: "personal" } },
|
|
27
|
+
web: {
|
|
28
|
+
endpoint: "/v1/apps/shop/machines",
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: { name: "web", config: { image: "shop:declared", metadata: { "managed-by": "chant" } } },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function memoryReceipts(): ReceiptStore & { values: Map<string, string> } {
|
|
35
|
+
const values = new Map<string, string>();
|
|
36
|
+
return {
|
|
37
|
+
values,
|
|
38
|
+
read: async (r: EffectReceiptRef) => values.get(r.name),
|
|
39
|
+
write: async (r: EffectReceiptRef, v: string) => void values.set(r.name, v),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let dir: string;
|
|
44
|
+
let planPath: string;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
dir = mkdtempSync(join(tmpdir(), "fly-release-"));
|
|
47
|
+
planPath = join(dir, "fly.json");
|
|
48
|
+
writeFileSync(planPath, JSON.stringify(PLAN));
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
51
|
+
|
|
52
|
+
function setup(fake: MachinesFake = createMachinesFake()) {
|
|
53
|
+
const configs = memoryMachineConfigStore();
|
|
54
|
+
const receipts = memoryReceipts();
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
const deps: FlyReleaseDeps = {
|
|
57
|
+
http: fake.http,
|
|
58
|
+
configStore: () => configs,
|
|
59
|
+
receiptStore: () => receipts,
|
|
60
|
+
headCommit: async () => "0000000000000000000000000000000000000000",
|
|
61
|
+
log: (l) => lines.push(l),
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
fake,
|
|
65
|
+
configs,
|
|
66
|
+
receipts,
|
|
67
|
+
lines,
|
|
68
|
+
release: createFlyReleaseCapability(deps),
|
|
69
|
+
rollback: createFlyRollbackCapability(deps),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const input = (digest: string, extra: Record<string, unknown> = {}) => ({
|
|
74
|
+
plan: planPath,
|
|
75
|
+
digest,
|
|
76
|
+
gitSha: `${digest.slice(7, 14)}`,
|
|
77
|
+
endpoint: ENDPOINT,
|
|
78
|
+
wait: NO_WAIT,
|
|
79
|
+
verify: { intervalMs: 1, timeoutMs: 20 },
|
|
80
|
+
...extra,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("fly-release", () => {
|
|
84
|
+
test("is registered by the fly capability plugin, with a native rollback", () => {
|
|
85
|
+
const kinds = flyCapabilityPlugin.capabilities().map((c) => c.kind);
|
|
86
|
+
expect(kinds).toEqual(expect.arrayContaining(["fly-release", "fly-rollback"]));
|
|
87
|
+
const release = flyCapabilityPlugin.capabilities().find((c) => c.kind === "fly-release");
|
|
88
|
+
expect(release?.rollbackPolicy).toBe("native");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("serves the release, records it on the Machine, and returns what the ledger records", async () => {
|
|
92
|
+
const s = setup();
|
|
93
|
+
const out = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop@sha256:img" }));
|
|
94
|
+
expect(out).toMatchObject({ uri: "fly://shop/web", digest: "sha256:aaaaaaaaaaaa", app: "shop", previous: null, migrations: [] });
|
|
95
|
+
const m = s.fake.machine("shop", "web")!;
|
|
96
|
+
expect(m.config.image).toBe("shop@sha256:img");
|
|
97
|
+
expect(readMachineRelease(m.config.metadata)).toEqual({ digest: "sha256:aaaaaaaaaaaa", gitSha: "aaaaaaa" });
|
|
98
|
+
// The config it applied is kept for a later rollback.
|
|
99
|
+
expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:aaaaaaaaaaaa" })).toEqual(m.config);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("the commit defaults to HEAD, as the ledger records it", async () => {
|
|
103
|
+
const s = setup();
|
|
104
|
+
const out = await s.release.run(CTX, { ...input("sha256:aaaaaaaaaaaa"), gitSha: undefined });
|
|
105
|
+
expect(out.gitSha).toBe("0000000000000000000000000000000000000000");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the commit goes into the Machine's APP_REVISION env var, as chud's fly-site.mjs set it (#2834)", async () => {
|
|
109
|
+
const s = setup();
|
|
110
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
111
|
+
const m = s.fake.machine("shop", "web")!;
|
|
112
|
+
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("aaaaaaa");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a caller's own env.APP_REVISION overrides the commit (#2834)", async () => {
|
|
116
|
+
const s = setup();
|
|
117
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { env: { APP_REVISION: "custom" } }));
|
|
118
|
+
const m = s.fake.machine("shop", "web")!;
|
|
119
|
+
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("custom");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a rollback restores the recorded APP_REVISION with the rest of the config (#2834)", async () => {
|
|
123
|
+
const s = setup();
|
|
124
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
125
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb"));
|
|
126
|
+
await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
127
|
+
const m = s.fake.machine("shop", "web")!;
|
|
128
|
+
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("aaaaaaa");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("each migration fires once per environment, then the Machine restarts", async () => {
|
|
132
|
+
const s = setup();
|
|
133
|
+
const migrations = [
|
|
134
|
+
{ name: "001_init.sql", command: "node migrate.js 001_init.sql", sha: "sha256:m1" },
|
|
135
|
+
{ name: "002_add.sql", command: ["node", "migrate.js", "002_add.sql"] },
|
|
136
|
+
];
|
|
137
|
+
const first = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { migrations }));
|
|
138
|
+
expect(first.migrations).toEqual([
|
|
139
|
+
{ name: "001_init.sql", fired: true },
|
|
140
|
+
{ name: "002_add.sql", fired: true },
|
|
141
|
+
]);
|
|
142
|
+
expect(s.fake.execs.map((e) => e.command)).toEqual([
|
|
143
|
+
["sh", "-c", "node migrate.js 001_init.sql"],
|
|
144
|
+
["node", "migrate.js", "002_add.sql"],
|
|
145
|
+
]);
|
|
146
|
+
expect(s.fake.calls.some((c) => c.endsWith("/restart"))).toBe(true);
|
|
147
|
+
expect(s.receipts.values.get("fly-migration:shop/001_init.sql")).toBe("sha256:m1");
|
|
148
|
+
|
|
149
|
+
const second = await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { migrations: [...migrations, { name: "003.sql", command: "m 3" }] }));
|
|
150
|
+
expect(second.migrations).toEqual([
|
|
151
|
+
{ name: "001_init.sql", fired: false },
|
|
152
|
+
{ name: "002_add.sql", fired: false },
|
|
153
|
+
{ name: "003.sql", fired: true },
|
|
154
|
+
]);
|
|
155
|
+
expect(s.fake.execs).toHaveLength(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a failed migration restores the previous release's config, leaves its receipt unwritten, and fails", async () => {
|
|
159
|
+
const fake = createMachinesFake({ exec: (_a, _m, cmd) => (cmd.join(" ").includes("boom") ? { exit_code: 1, stderr: "boom" } : { exit_code: 0 }) });
|
|
160
|
+
const s = setup(fake);
|
|
161
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
162
|
+
const before = structuredClone(fake.machine("shop", "web")!.config);
|
|
163
|
+
await expect(
|
|
164
|
+
s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2", migrations: [{ name: "004.sql", command: "boom" }] })),
|
|
165
|
+
).rejects.toThrow(/exited 1/);
|
|
166
|
+
expect(fake.machine("shop", "web")!.config).toEqual(before);
|
|
167
|
+
expect(s.receipts.values.has("fly-migration:shop/004.sql")).toBe(false);
|
|
168
|
+
expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:bbbbbbbbbbbb" })).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a failed first release stops the Machine: nothing was served before", async () => {
|
|
172
|
+
const fake = createMachinesFake({ exec: () => ({ exit_code: 2 }) });
|
|
173
|
+
const s = setup(fake);
|
|
174
|
+
await expect(s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { migrations: [{ name: "1", command: "x" }] }))).rejects.toThrow();
|
|
175
|
+
expect(fake.machine("shop", "web")!.state).toBe("stopped");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("its saga rollback puts back the previous release's Machine config", async () => {
|
|
179
|
+
const s = setup();
|
|
180
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
181
|
+
const one = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
182
|
+
const i2 = input("sha256:bbbbbbbbbbbb", { image: "shop:2" });
|
|
183
|
+
const out = await s.release.run(CTX, i2);
|
|
184
|
+
expect(out.previous?.digest).toBe("sha256:aaaaaaaaaaaa");
|
|
185
|
+
await s.release.rollback!(CTX, i2, out);
|
|
186
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(one);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a Machine released before configs were kept can still be rolled back to", async () => {
|
|
190
|
+
const s = setup();
|
|
191
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
192
|
+
s.configs.entries.clear();
|
|
193
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2" }));
|
|
194
|
+
expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:aaaaaaaaaaaa" })).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("fly-rollback", () => {
|
|
199
|
+
test("restores the previous release's Machine config, and names it for the ledger", async () => {
|
|
200
|
+
const s = setup();
|
|
201
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
202
|
+
const one = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
203
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2" }));
|
|
204
|
+
const back = await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
205
|
+
expect(back).toMatchObject({ uri: "fly://shop/web", digest: "sha256:aaaaaaaaaaaa", gitSha: "aaaaaaa", previous: { digest: "sha256:bbbbbbbbbbbb" } });
|
|
206
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(one);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("with no release before the serving one, it refuses", async () => {
|
|
210
|
+
const s = setup();
|
|
211
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
212
|
+
await expect(s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT })).rejects.toThrow(/no release before it/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("to a digest with no recorded config, it refuses", async () => {
|
|
216
|
+
const s = setup();
|
|
217
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
218
|
+
await expect(s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, to: "sha256:zzz" })).rejects.toThrow(/no recorded Machine config/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("components status --live over a Fly release", () => {
|
|
223
|
+
const record = (digest: string): ReleaseRecord => ({
|
|
224
|
+
version: 1,
|
|
225
|
+
component: "web",
|
|
226
|
+
env: "prod",
|
|
227
|
+
digest,
|
|
228
|
+
gitSha: "0000000",
|
|
229
|
+
runId: "run-1",
|
|
230
|
+
timestamp: "2026-09-25T00:00:00.000Z",
|
|
231
|
+
actor: "lex00",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
async function status(fake: MachinesFake, recorded: string) {
|
|
235
|
+
const entities = new Map([
|
|
236
|
+
["shop", { entityType: "Fly::Machines::App", props: { name: "shop" } as Record<string, unknown> }],
|
|
237
|
+
["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } as Record<string, unknown> }],
|
|
238
|
+
]);
|
|
239
|
+
const observed = normalizeObservation(
|
|
240
|
+
await describeResources({ environment: "prod", buildOutput: "", entityNames: ["shop", "web"], entities, endpoint: ENDPOINT }, fake.http),
|
|
241
|
+
);
|
|
242
|
+
const cs = buildChangeSet("prod", { declared: new Set(entities.keys()), observedNow: observed.resources, observedThen: undefined, unobserved: observed.unobserved }, { lexicon: "fly" });
|
|
243
|
+
const observations = new Map<string, EntityLiveRead>(cs.entries.map((e) => [e.name, { now: observed.resources[e.name] }]));
|
|
244
|
+
const evidence = liveEvidenceFromChangeSet(cs, undefined, { observations });
|
|
245
|
+
return reconcileStatus("prod", [record(recorded)], { liveEvidence: evidence }).find((r) => r.component === "web")!;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
test("the Machine's release is compared with the ledger: reconciled when they agree, drifted when not", async () => {
|
|
249
|
+
const s = setup();
|
|
250
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
251
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb"));
|
|
252
|
+
|
|
253
|
+
const agreed = await status(s.fake, "sha256:bbbbbbbbbbbb");
|
|
254
|
+
expect(agreed.reconciliation).toBe("reconciled");
|
|
255
|
+
|
|
256
|
+
// The Machine rolled back with no ledger record: live and recorded disagree.
|
|
257
|
+
await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
258
|
+
const drifted = await status(s.fake, "sha256:bbbbbbbbbbbb");
|
|
259
|
+
expect(drifted.reconciliation).toBe("drifted");
|
|
260
|
+
expect(drifted.detail).toContain("reports digest sha256:aaaaaaaaaaaa");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("the migration receipt is named for the app and migration", () => {
|
|
264
|
+
expect(migrationReceipt("shop", { name: "001.sql", command: "x", sha: "sha256:1" })).toMatchObject({
|
|
265
|
+
ref: { name: "fly-migration:shop/001.sql", effect: "fly-migrate", flavor: "hash" },
|
|
266
|
+
expectation: "sha256:1",
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("fly-release with a source tree (#2782)", () => {
|
|
272
|
+
function archived() {
|
|
273
|
+
const repo = join(dir, "repo");
|
|
274
|
+
const git = (...args: string[]) => execFileSync("git", args, { cwd: repo, stdio: "pipe" });
|
|
275
|
+
mkdirSync(join(repo, "app/migrations"), { recursive: true });
|
|
276
|
+
writeFileSync(join(repo, "app/server.js"), "console.log('serving');\n");
|
|
277
|
+
writeFileSync(join(repo, "app/migrations/0001_init.sql"), "CREATE TABLE t (id INTEGER);\n");
|
|
278
|
+
git("init", "-q", "-b", "main");
|
|
279
|
+
git("-c", "user.email=t@t", "-c", "user.name=t", "add", "-A");
|
|
280
|
+
git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "app");
|
|
281
|
+
return archiveSourceTree({ dir: "app", cwd: repo });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
test("puts the approved tree on the Machine with its start command, and a retry changes nothing", async () => {
|
|
285
|
+
const s = setup();
|
|
286
|
+
const a = archived();
|
|
287
|
+
const source = { archive: a.archive, digest: a.digest, dir: a.dir, start: "node server.js" };
|
|
288
|
+
const first = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source }));
|
|
289
|
+
const m = s.fake.machine("shop", "web")!;
|
|
290
|
+
const files = m.config.files as Array<{ guest_path: string; raw_value: string }>;
|
|
291
|
+
expect(files.map((f) => f.guest_path).sort()).toEqual(["/srv/app/migrations/0001_init.sql", "/srv/app/server.js"]);
|
|
292
|
+
expect(Buffer.from(files.find((f) => f.guest_path === "/srv/app/server.js")!.raw_value, "base64").toString()).toBe("console.log('serving');\n");
|
|
293
|
+
expect(m.config.init).toEqual({ cmd: ["sh", "-c", "cd /srv/app && exec node server.js"] });
|
|
294
|
+
expect(m.config.image).toBe("shop:declared");
|
|
295
|
+
expect(first.digest).toBe("sha256:aaaaaaaaaaaa");
|
|
296
|
+
|
|
297
|
+
const calls = s.fake.calls.length;
|
|
298
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source }));
|
|
299
|
+
// The same release again: the Machine's config is unchanged, so nothing is updated.
|
|
300
|
+
expect(s.fake.calls.slice(calls).filter((c) => c.startsWith("POST") && !c.endsWith("/wait"))).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("an archive that is not the approved digest is refused before the Machine changes", async () => {
|
|
304
|
+
const s = setup();
|
|
305
|
+
const a = archived();
|
|
306
|
+
await expect(
|
|
307
|
+
s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { archive: a.archive, digest: "sha256:0000", dir: a.dir, start: "node server.js" } })),
|
|
308
|
+
).rejects.toThrow(/not the approved sha256:0000/);
|
|
309
|
+
expect(s.fake.calls.filter((c) => c.startsWith("POST"))).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("fly-rollback with a source tree (#2800)", () => {
|
|
314
|
+
/** Two commits of an app, each archived. */
|
|
315
|
+
function twoReleases() {
|
|
316
|
+
const repo = join(dir, "repo2");
|
|
317
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: repo, stdio: "pipe" });
|
|
318
|
+
mkdirSync(join(repo, "app"), { recursive: true });
|
|
319
|
+
git("init", "-q", "-b", "main");
|
|
320
|
+
const commit = (body: string, out: string) => {
|
|
321
|
+
writeFileSync(join(repo, "app/server.js"), body);
|
|
322
|
+
git("add", "-A");
|
|
323
|
+
git("commit", "-q", "-m", body);
|
|
324
|
+
return archiveSourceTree({ dir: "app", cwd: repo, out });
|
|
325
|
+
};
|
|
326
|
+
return { a: commit("console.log('a');\n", "a.tar"), b: commit("console.log('b');\n", "b.tar") };
|
|
327
|
+
}
|
|
328
|
+
const src = (x: { archive: string; digest: string; dir: string }) => ({ archive: x.archive, digest: x.digest, dir: x.dir });
|
|
329
|
+
const rollbackInput = (to: string, source: ReturnType<typeof src>) => ({ plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 }, to, source });
|
|
330
|
+
|
|
331
|
+
test("puts back the recorded config of the release whose tree it is, and a rerun changes nothing", async () => {
|
|
332
|
+
const s = setup();
|
|
333
|
+
const { a, b } = twoReleases();
|
|
334
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { ...src(a), start: "node server.js" } }));
|
|
335
|
+
const one = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
336
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
337
|
+
expect(s.fake.machine("shop", "web")!.config).not.toEqual(one);
|
|
338
|
+
|
|
339
|
+
const back = await s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(a)));
|
|
340
|
+
expect(back).toMatchObject({ digest: "sha256:aaaaaaaaaaaa", previous: { digest: "sha256:bbbbbbbbbbbb" } });
|
|
341
|
+
const m = s.fake.machine("shop", "web")!;
|
|
342
|
+
expect(m.config).toEqual(one);
|
|
343
|
+
const server = (m.config.files as Array<{ guest_path: string; raw_value: string }>).find((f) => f.guest_path === "/srv/app/server.js")!;
|
|
344
|
+
expect(Buffer.from(server.raw_value, "base64").toString()).toBe("console.log('a');\n");
|
|
345
|
+
|
|
346
|
+
const calls = s.fake.calls.length;
|
|
347
|
+
await s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(a)));
|
|
348
|
+
expect(s.fake.calls.slice(calls).filter((c) => c.startsWith("POST") && !c.endsWith("/wait"))).toEqual([]);
|
|
349
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(one);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("an archive that is not its digest is refused before any flaps call", async () => {
|
|
353
|
+
const s = setup();
|
|
354
|
+
const { a, b } = twoReleases();
|
|
355
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { ...src(a), start: "node server.js" } }));
|
|
356
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
357
|
+
const calls = s.fake.calls.length;
|
|
358
|
+
await expect(s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", { ...src(a), digest: b.digest }))).rejects.toThrow(/not the approved/);
|
|
359
|
+
expect(s.fake.calls.length).toBe(calls);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a recorded config that does not carry the tree is refused, and the Machine is left as it is", async () => {
|
|
363
|
+
const s = setup();
|
|
364
|
+
const { a, b } = twoReleases();
|
|
365
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { ...src(a), start: "node server.js" } }));
|
|
366
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
367
|
+
const serving = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
368
|
+
await expect(s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(b)))).rejects.toThrow(/does not carry the tree .*\/srv\/app\/server\.js has other bytes/);
|
|
369
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(serving);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("a release with no recorded Machine config is refused by name", async () => {
|
|
373
|
+
const s = setup();
|
|
374
|
+
const { a, b } = twoReleases();
|
|
375
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
376
|
+
await expect(s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(a)))).rejects.toThrow("fly-rollback: no recorded Machine config for sha256:aaaaaaaaaaaa on shop/web");
|
|
377
|
+
});
|
|
378
|
+
});
|