@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.
- 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 +148 -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/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +3 -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/emulator-images.d.ts +13 -0
- package/dist/op/activities/emulator-images.d.ts.map +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/index.d.ts +6 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +176 -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 +53 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- package/dist/op/activities/sprite-services.d.ts +158 -0
- package/dist/op/activities/sprite-services.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +17 -1
- package/dist/op/activities/sprites-contract.d.ts.map +1 -1
- package/dist/op/activities/sprites-emulator.d.ts +12 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
- package/dist/op/activities/sprites-fake.d.ts +4 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +53 -0
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/builders.d.ts +33 -1
- 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 +33 -0
- package/dist/skills/chant-fly-sprites.md +4 -2
- package/package.json +2 -2
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +244 -0
- package/src/components/fly-release.ts +341 -0
- package/src/components/index.ts +16 -0
- package/src/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +21 -0
- package/src/op/activities/emulator-images.ts +14 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/index.ts +57 -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 +407 -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 +165 -0
- package/src/op/activities/sprite-config.test.ts +19 -0
- package/src/op/activities/sprite-services.docker.integration.test.ts +87 -0
- package/src/op/activities/sprite-services.test.ts +263 -0
- package/src/op/activities/sprite-services.ts +311 -0
- package/src/op/activities/sprites-contract.test.ts +15 -4
- package/src/op/activities/sprites-contract.ts +24 -6
- package/src/op/activities/sprites-emulator.ts +52 -1
- package/src/op/activities/sprites-fake.ts +57 -8
- package/src/op/activities/sprites.test.ts +102 -0
- package/src/op/activities/sprites.ts +108 -7
- package/src/op/builders.ts +53 -1
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +33 -0
- package/src/skills/chant-fly-sprites.md +4 -2
|
@@ -53,3 +53,36 @@ Each prune logs the resource and endpoint it removed, so a prune run is auditabl
|
|
|
53
53
|
## Re-applying is safe
|
|
54
54
|
|
|
55
55
|
A re-apply of an unchanged stack is a no-op per resource: machines whose config is structurally equal to live are skipped, volumes and certificates that already exist are skipped, and an IP of an already-present family is skipped. Only apply-only secrets are always re-set, because flaps exposes no value to diff against.
|
|
56
|
+
|
|
57
|
+
## Releases on a Machine
|
|
58
|
+
|
|
59
|
+
A component can deploy to Fly with this lexicon alone. It declares an `App` and the `Machine` that serves, builds them with `chant build --lexicon fly -o dist/fly.json`, and deploys with the `fly-release` step. Here a `Publish` phase (`publish-image`, elided) pushes the image, and the release serves it by digest:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { phase, type Component } from "@intentius/chant/components";
|
|
63
|
+
|
|
64
|
+
export const web: Component = {
|
|
65
|
+
name: "web",
|
|
66
|
+
deploy: [
|
|
67
|
+
phase("Publish", [/* publish-image */]),
|
|
68
|
+
phase("Release", [
|
|
69
|
+
{
|
|
70
|
+
kind: "fly-release",
|
|
71
|
+
plan: "dist/fly.json",
|
|
72
|
+
digest: "@Publish.digest",
|
|
73
|
+
image: "@Publish.uri",
|
|
74
|
+
migrations: [{ name: "001_init.sql", command: "node migrate.js 001_init.sql" }],
|
|
75
|
+
verify: { url: "https://web.example.com", healthPath: "/health" },
|
|
76
|
+
},
|
|
77
|
+
]),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The step updates the Machine in place with the release in its `config.metadata`: `chant-release-digest`, `chant-release-git-sha` (the commit, `git rev-parse HEAD` unless given), and `chant-release-previous-digest` (the release it replaced). Each migration then runs inside the Machine through the Machines API's exec, once per environment: its receipt is kept on `chant/lifecycle` at `<env>/receipts/`, and a migration fires again only when its `sha` (or command) changes. After a migration fires, the Machine restarts, and the step checks that it is started with this release and, given a `url`, that its health endpoint answers with this commit or digest. If anything fails after the Machine changed, the step puts back the config the Machine served before (on a first release, it stops the Machine) and fails.
|
|
83
|
+
|
|
84
|
+
The step's output carries `uri` and `digest`, so `chant run --components web --env prod` records the release in the ledger. `chant components status prod --live` reads the Machine's metadata back and compares its digest with the ledger's: the row is `reconciled` when they agree and `drifted` when the Machine serves something the ledger does not name. The component joins the Machine by name: name the component after the Machine entity, or list it in `liveNames`.
|
|
85
|
+
|
|
86
|
+
Each release's Machine config is kept on `chant/lifecycle` at `<env>/fly/<app>/<machine>/<digest>.json`. `fly-rollback` puts back the config of the release the serving one replaced (or the digest given as `to`), checks it, and outputs that digest so the ledger records it again. `fly-release`'s own saga compensation does the same.
|
|
87
|
+
|
|
88
|
+
The steps are also Op activities, for an Op that composes them itself: `flyMachineRelease`, `flyMachineExec`, `flyMachineRestart`, `flyMachineStop`, `flyMachineVerify` and `flyMachineRestore`. Wrap a migration's `flyMachineExec` in `effect()` so it fires once.
|
|
@@ -78,7 +78,7 @@ When the `Run` phase's command exits non-zero, `spriteExec` throws, the phase fa
|
|
|
78
78
|
|
|
79
79
|
## Targeting the emulator or real Sprites
|
|
80
80
|
|
|
81
|
-
The activities resolve their endpoint in this order: an explicit `endpoint` arg, then `SPRITES_BASE_URL`, then the real Sprites base. The same Op targets an emulator or real Sprites with no code change. The default `fetch` client adds `Authorization: Bearer
|
|
81
|
+
The activities resolve their endpoint in this order: an explicit `endpoint` arg, then `SPRITES_BASE_URL`, then its alias `SPRITES_API_URL` (the name the studio and wisp's own docs use), then the real Sprites base. The same Op targets an emulator or real Sprites with no code change. The default `fetch` client adds `Authorization: Bearer <token>` when a token is set — `SPRITES_API_TOKEN`, or its alias `SPRITE_TOKEN` — the emulator ignores it. Either alias is read only when the original name is unset, so an existing deployment using `SPRITES_BASE_URL`/`SPRITES_API_TOKEN` is unaffected.
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
84
|
# Point at a self-hosted or in-process emulator.
|
|
@@ -105,9 +105,11 @@ The same lexicon ships more Sprite primitives, all imported from `@intentius/cha
|
|
|
105
105
|
|--------|-----------|-----|
|
|
106
106
|
| Filesystem (#848) | `spriteWriteFile` / `spriteReadFile` / `spriteListDir` / `spriteRemove` | stage an input file and read a result out without shelling `spriteExec` + `cat` |
|
|
107
107
|
| Config reconcile (#849) | `spriteApplyNetworkPolicy` / `spriteApplyServices` | reconcile a Sprite's egress allowlist and background services against typed config (validated before any HTTP; a whole-object replace for policy, create-or-update by name for services) |
|
|
108
|
+
| Services (#2711) | `spriteServiceCreate` / `spriteServiceGet` / `spriteServiceList` / `spriteServiceStart` / `spriteServiceStop` / `spriteServiceDelete` / `spriteServiceLogs` | the single-service primitives underneath `spriteApplyServices` — create-and-start one long-lived service (a box's door, hud or chud), inspect or list what's running, stop/start or delete one by name, read its log tail |
|
|
109
|
+
| Sprite URL / delete (#2711) | `spriteUrl` / `spriteDelete` | resolve a Sprite's URL, optionally waiting until a path on it answers; delete the Sprite (`spriteDelete` is `spriteDestroy` under the name spritzer/wisp and the other lexicons' delete activities use — same call, either name) |
|
|
108
110
|
| Keep-alive (#847) | `spriteTaskCreate` / `spriteTaskRefresh` / `spriteTaskRelease` | hold a Sprite active for a session so it will not pause; a session past the 1-hour task cap refreshes on an interval |
|
|
109
111
|
|
|
110
|
-
These are still runtime-orchestration primitives, not declarable resources — a Sprite has no desired-state create body to reconcile.
|
|
112
|
+
These are still runtime-orchestration primitives, not declarable resources — a Sprite has no desired-state create body to reconcile. Services created directly with `spriteServiceCreate` are what a box's `spritzer` preset provisions door/hud/chud on (arugula-salad/studio#27) — the same wire surface as `sprite-env services` inside the sprite, spritzer 0.6.0's container mode, and wisp.
|
|
111
113
|
|
|
112
114
|
## Where it fits
|
|
113
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typescript": "^5.9.3"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
|
-
"@intentius/chant": "^0.
|
|
66
|
+
"@intentius/chant": "^0.91.0",
|
|
67
67
|
"typescript": "^5.9.3"
|
|
68
68
|
},
|
|
69
69
|
"description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* `flyCapabilityPlugin` — the fly lexicon's capability plugin (#1942, epic
|
|
3
3
|
* #1564 phase 2).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* (`./run-agent.ts`)
|
|
5
|
+
* Leaves: `run-agent`, the sprite-lifecycle-backed agent-turn verb
|
|
6
|
+
* (`./run-agent.ts`), and `fly-release` / `fly-rollback`, a release served by
|
|
7
|
+
* a Fly Machine (`./fly-release.ts`, #2736). Contributed through core's `CapabilityPlugin` contract
|
|
7
8
|
* the same way aws contributes `cfn-deploy` and helm contributes
|
|
8
9
|
* `helm-upgrade` (docs/components/cloud-boundary) — a project declaring
|
|
9
10
|
* `lexicons: ["fly"]` gets `run-agent` registered automatically when it runs
|
|
@@ -15,9 +16,12 @@
|
|
|
15
16
|
import type { Capability } from "@intentius/chant/components/capability";
|
|
16
17
|
import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
17
18
|
import { flyRunAgentCapability } from "./run-agent";
|
|
19
|
+
import { flyReleaseCapability, flyRollbackCapability } from "./fly-release";
|
|
18
20
|
|
|
19
21
|
export const FLY_VERB_FAMILIES = {
|
|
20
22
|
agentExecution: ["run-agent"],
|
|
23
|
+
// A release served by a Fly Machine, and going back to an earlier one (#2736).
|
|
24
|
+
apply: ["fly-release", "fly-rollback"],
|
|
21
25
|
} as const;
|
|
22
26
|
|
|
23
27
|
export const flyCapabilityPlugin: CapabilityPlugin = {
|
|
@@ -28,7 +32,11 @@ export const flyCapabilityPlugin: CapabilityPlugin = {
|
|
|
28
32
|
return ownPackageVersion(import.meta.url);
|
|
29
33
|
},
|
|
30
34
|
capabilities(): Array<Capability<never, unknown>> {
|
|
31
|
-
return [
|
|
35
|
+
return [
|
|
36
|
+
flyRunAgentCapability as Capability<never, unknown>,
|
|
37
|
+
flyReleaseCapability as Capability<never, unknown>,
|
|
38
|
+
flyRollbackCapability as Capability<never, unknown>,
|
|
39
|
+
];
|
|
32
40
|
},
|
|
33
41
|
families(): Record<string, readonly string[]> {
|
|
34
42
|
return FLY_VERB_FAMILIES;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach } from "vitest";
|
|
6
|
+
import type { DeployContext } from "@intentius/chant/components/capability";
|
|
7
|
+
import type { EffectReceiptRef, ReceiptStore } from "@intentius/chant/op/receipt-store";
|
|
8
|
+
import { buildChangeSet } from "@intentius/chant/lifecycle/change-set";
|
|
9
|
+
import { liveEvidenceFromChangeSet, reconcileStatus, type EntityLiveRead } from "@intentius/chant/lifecycle/status";
|
|
10
|
+
import type { ReleaseRecord } from "@intentius/chant/lifecycle/release-ledger";
|
|
11
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
12
|
+
import { createFlyReleaseCapability, createFlyRollbackCapability, migrationReceipt, type FlyReleaseDeps } from "./fly-release";
|
|
13
|
+
import { flyCapabilityPlugin } from "./capability-plugin";
|
|
14
|
+
import { createMachinesFake, type MachinesFake } from "../op/activities/machines-fake";
|
|
15
|
+
import { memoryMachineConfigStore } from "../release-store";
|
|
16
|
+
import { readMachineRelease } from "../release-metadata";
|
|
17
|
+
import { describeResources } from "../describe-resources";
|
|
18
|
+
|
|
19
|
+
const ENDPOINT = "http://flaps.test";
|
|
20
|
+
const NO_WAIT = { intervalMs: 0, deadlineMs: 2_000 };
|
|
21
|
+
const CTX: DeployContext = { env: "prod", component: "shop" };
|
|
22
|
+
|
|
23
|
+
const PLAN = {
|
|
24
|
+
shop: { endpoint: "/v1/apps", method: "POST", body: { app_name: "shop", org_slug: "personal" } },
|
|
25
|
+
web: {
|
|
26
|
+
endpoint: "/v1/apps/shop/machines",
|
|
27
|
+
method: "POST",
|
|
28
|
+
body: { name: "web", config: { image: "shop:declared", metadata: { "managed-by": "chant" } } },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function memoryReceipts(): ReceiptStore & { values: Map<string, string> } {
|
|
33
|
+
const values = new Map<string, string>();
|
|
34
|
+
return {
|
|
35
|
+
values,
|
|
36
|
+
read: async (r: EffectReceiptRef) => values.get(r.name),
|
|
37
|
+
write: async (r: EffectReceiptRef, v: string) => void values.set(r.name, v),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let dir: string;
|
|
42
|
+
let planPath: string;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
dir = mkdtempSync(join(tmpdir(), "fly-release-"));
|
|
45
|
+
planPath = join(dir, "fly.json");
|
|
46
|
+
writeFileSync(planPath, JSON.stringify(PLAN));
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
49
|
+
|
|
50
|
+
function setup(fake: MachinesFake = createMachinesFake()) {
|
|
51
|
+
const configs = memoryMachineConfigStore();
|
|
52
|
+
const receipts = memoryReceipts();
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
const deps: FlyReleaseDeps = {
|
|
55
|
+
http: fake.http,
|
|
56
|
+
configStore: () => configs,
|
|
57
|
+
receiptStore: () => receipts,
|
|
58
|
+
headCommit: async () => "0000000000000000000000000000000000000000",
|
|
59
|
+
log: (l) => lines.push(l),
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
fake,
|
|
63
|
+
configs,
|
|
64
|
+
receipts,
|
|
65
|
+
lines,
|
|
66
|
+
release: createFlyReleaseCapability(deps),
|
|
67
|
+
rollback: createFlyRollbackCapability(deps),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const input = (digest: string, extra: Record<string, unknown> = {}) => ({
|
|
72
|
+
plan: planPath,
|
|
73
|
+
digest,
|
|
74
|
+
gitSha: `${digest.slice(7, 14)}`,
|
|
75
|
+
endpoint: ENDPOINT,
|
|
76
|
+
wait: NO_WAIT,
|
|
77
|
+
verify: { intervalMs: 1, timeoutMs: 20 },
|
|
78
|
+
...extra,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("fly-release", () => {
|
|
82
|
+
test("is registered by the fly capability plugin, with a native rollback", () => {
|
|
83
|
+
const kinds = flyCapabilityPlugin.capabilities().map((c) => c.kind);
|
|
84
|
+
expect(kinds).toEqual(expect.arrayContaining(["fly-release", "fly-rollback"]));
|
|
85
|
+
const release = flyCapabilityPlugin.capabilities().find((c) => c.kind === "fly-release");
|
|
86
|
+
expect(release?.rollbackPolicy).toBe("native");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("serves the release, records it on the Machine, and returns what the ledger records", async () => {
|
|
90
|
+
const s = setup();
|
|
91
|
+
const out = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop@sha256:img" }));
|
|
92
|
+
expect(out).toMatchObject({ uri: "fly://shop/web", digest: "sha256:aaaaaaaaaaaa", app: "shop", previous: null, migrations: [] });
|
|
93
|
+
const m = s.fake.machine("shop", "web")!;
|
|
94
|
+
expect(m.config.image).toBe("shop@sha256:img");
|
|
95
|
+
expect(readMachineRelease(m.config.metadata)).toEqual({ digest: "sha256:aaaaaaaaaaaa", gitSha: "aaaaaaa" });
|
|
96
|
+
// The config it applied is kept for a later rollback.
|
|
97
|
+
expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:aaaaaaaaaaaa" })).toEqual(m.config);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the commit defaults to HEAD, as the ledger records it", async () => {
|
|
101
|
+
const s = setup();
|
|
102
|
+
const out = await s.release.run(CTX, { ...input("sha256:aaaaaaaaaaaa"), gitSha: undefined });
|
|
103
|
+
expect(out.gitSha).toBe("0000000000000000000000000000000000000000");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("each migration fires once per environment, then the Machine restarts", async () => {
|
|
107
|
+
const s = setup();
|
|
108
|
+
const migrations = [
|
|
109
|
+
{ name: "001_init.sql", command: "node migrate.js 001_init.sql", sha: "sha256:m1" },
|
|
110
|
+
{ name: "002_add.sql", command: ["node", "migrate.js", "002_add.sql"] },
|
|
111
|
+
];
|
|
112
|
+
const first = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { migrations }));
|
|
113
|
+
expect(first.migrations).toEqual([
|
|
114
|
+
{ name: "001_init.sql", fired: true },
|
|
115
|
+
{ name: "002_add.sql", fired: true },
|
|
116
|
+
]);
|
|
117
|
+
expect(s.fake.execs.map((e) => e.command)).toEqual([
|
|
118
|
+
["sh", "-c", "node migrate.js 001_init.sql"],
|
|
119
|
+
["node", "migrate.js", "002_add.sql"],
|
|
120
|
+
]);
|
|
121
|
+
expect(s.fake.calls.some((c) => c.endsWith("/restart"))).toBe(true);
|
|
122
|
+
expect(s.receipts.values.get("fly-migration:shop/001_init.sql")).toBe("sha256:m1");
|
|
123
|
+
|
|
124
|
+
const second = await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { migrations: [...migrations, { name: "003.sql", command: "m 3" }] }));
|
|
125
|
+
expect(second.migrations).toEqual([
|
|
126
|
+
{ name: "001_init.sql", fired: false },
|
|
127
|
+
{ name: "002_add.sql", fired: false },
|
|
128
|
+
{ name: "003.sql", fired: true },
|
|
129
|
+
]);
|
|
130
|
+
expect(s.fake.execs).toHaveLength(3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a failed migration restores the previous release's config, leaves its receipt unwritten, and fails", async () => {
|
|
134
|
+
const fake = createMachinesFake({ exec: (_a, _m, cmd) => (cmd.join(" ").includes("boom") ? { exit_code: 1, stderr: "boom" } : { exit_code: 0 }) });
|
|
135
|
+
const s = setup(fake);
|
|
136
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
137
|
+
const before = structuredClone(fake.machine("shop", "web")!.config);
|
|
138
|
+
await expect(
|
|
139
|
+
s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2", migrations: [{ name: "004.sql", command: "boom" }] })),
|
|
140
|
+
).rejects.toThrow(/exited 1/);
|
|
141
|
+
expect(fake.machine("shop", "web")!.config).toEqual(before);
|
|
142
|
+
expect(s.receipts.values.has("fly-migration:shop/004.sql")).toBe(false);
|
|
143
|
+
expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:bbbbbbbbbbbb" })).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a failed first release stops the Machine: nothing was served before", async () => {
|
|
147
|
+
const fake = createMachinesFake({ exec: () => ({ exit_code: 2 }) });
|
|
148
|
+
const s = setup(fake);
|
|
149
|
+
await expect(s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { migrations: [{ name: "1", command: "x" }] }))).rejects.toThrow();
|
|
150
|
+
expect(fake.machine("shop", "web")!.state).toBe("stopped");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("its saga rollback puts back the previous release's Machine config", async () => {
|
|
154
|
+
const s = setup();
|
|
155
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
156
|
+
const one = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
157
|
+
const i2 = input("sha256:bbbbbbbbbbbb", { image: "shop:2" });
|
|
158
|
+
const out = await s.release.run(CTX, i2);
|
|
159
|
+
expect(out.previous?.digest).toBe("sha256:aaaaaaaaaaaa");
|
|
160
|
+
await s.release.rollback!(CTX, i2, out);
|
|
161
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(one);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a Machine released before configs were kept can still be rolled back to", async () => {
|
|
165
|
+
const s = setup();
|
|
166
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
167
|
+
s.configs.entries.clear();
|
|
168
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2" }));
|
|
169
|
+
expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:aaaaaaaaaaaa" })).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("fly-rollback", () => {
|
|
174
|
+
test("restores the previous release's Machine config, and names it for the ledger", async () => {
|
|
175
|
+
const s = setup();
|
|
176
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1" }));
|
|
177
|
+
const one = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
178
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2" }));
|
|
179
|
+
const back = await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
180
|
+
expect(back).toMatchObject({ uri: "fly://shop/web", digest: "sha256:aaaaaaaaaaaa", gitSha: "aaaaaaa", previous: { digest: "sha256:bbbbbbbbbbbb" } });
|
|
181
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(one);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("with no release before the serving one, it refuses", async () => {
|
|
185
|
+
const s = setup();
|
|
186
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
187
|
+
await expect(s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT })).rejects.toThrow(/no release before it/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("to a digest with no recorded config, it refuses", async () => {
|
|
191
|
+
const s = setup();
|
|
192
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
193
|
+
await expect(s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, to: "sha256:zzz" })).rejects.toThrow(/no recorded Machine config/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("components status --live over a Fly release", () => {
|
|
198
|
+
const record = (digest: string): ReleaseRecord => ({
|
|
199
|
+
version: 1,
|
|
200
|
+
component: "web",
|
|
201
|
+
env: "prod",
|
|
202
|
+
digest,
|
|
203
|
+
gitSha: "0000000",
|
|
204
|
+
runId: "run-1",
|
|
205
|
+
timestamp: "2026-09-25T00:00:00.000Z",
|
|
206
|
+
actor: "lex00",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
async function status(fake: MachinesFake, recorded: string) {
|
|
210
|
+
const entities = new Map([
|
|
211
|
+
["shop", { entityType: "Fly::Machines::App", props: { name: "shop" } as Record<string, unknown> }],
|
|
212
|
+
["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } as Record<string, unknown> }],
|
|
213
|
+
]);
|
|
214
|
+
const observed = normalizeObservation(
|
|
215
|
+
await describeResources({ environment: "prod", buildOutput: "", entityNames: ["shop", "web"], entities, endpoint: ENDPOINT }, fake.http),
|
|
216
|
+
);
|
|
217
|
+
const cs = buildChangeSet("prod", { declared: new Set(entities.keys()), observedNow: observed.resources, observedThen: undefined, unobserved: observed.unobserved }, { lexicon: "fly" });
|
|
218
|
+
const observations = new Map<string, EntityLiveRead>(cs.entries.map((e) => [e.name, { now: observed.resources[e.name] }]));
|
|
219
|
+
const evidence = liveEvidenceFromChangeSet(cs, undefined, { observations });
|
|
220
|
+
return reconcileStatus("prod", [record(recorded)], { liveEvidence: evidence }).find((r) => r.component === "web")!;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test("the Machine's release is compared with the ledger: reconciled when they agree, drifted when not", async () => {
|
|
224
|
+
const s = setup();
|
|
225
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
226
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb"));
|
|
227
|
+
|
|
228
|
+
const agreed = await status(s.fake, "sha256:bbbbbbbbbbbb");
|
|
229
|
+
expect(agreed.reconciliation).toBe("reconciled");
|
|
230
|
+
|
|
231
|
+
// The Machine rolled back with no ledger record: live and recorded disagree.
|
|
232
|
+
await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
233
|
+
const drifted = await status(s.fake, "sha256:bbbbbbbbbbbb");
|
|
234
|
+
expect(drifted.reconciliation).toBe("drifted");
|
|
235
|
+
expect(drifted.detail).toContain("reports digest sha256:aaaaaaaaaaaa");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("the migration receipt is named for the app and migration", () => {
|
|
239
|
+
expect(migrationReceipt("shop", { name: "001.sql", command: "x", sha: "sha256:1" })).toMatchObject({
|
|
240
|
+
ref: { name: "fly-migration:shop/001.sql", effect: "fly-migrate", flavor: "hash" },
|
|
241
|
+
expectation: "sha256:1",
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|