@intentius/chant-lexicon-fly 0.124.63 → 0.124.65
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 +1 -0
- package/dist/components/fly-release.d.ts +14 -0
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/components/fly-release.js +38 -2
- package/dist/integrity.json +5 -4
- package/dist/lint/audit-catalog.d.ts +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-catalog.js +2 -1
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/audit-lineage.js +3 -0
- package/dist/lint/post-synth/fly012-machine-schedule.d.ts +11 -0
- package/dist/lint/post-synth/fly012-machine-schedule.d.ts.map +1 -0
- package/dist/lint/post-synth/fly012-machine-schedule.js +37 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +2 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/FLY012.md +15 -0
- package/dist/okf/types/Machine.md +1 -0
- package/dist/op/activities/fly-apply.d.ts +25 -0
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.js +59 -3
- package/dist/op/activities/machine-release.d.ts +35 -3
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machine-release.js +70 -18
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.js +5 -2
- package/dist/rules/fly012-machine-schedule.ts +39 -0
- package/dist/skills/chant-fly-ops.md +47 -0
- package/dist/skills/chant-fly.md +3 -1
- package/package.json +3 -3
- package/src/components/fly-release.test.ts +79 -0
- package/src/components/fly-release.ts +41 -2
- package/src/lint/audit-catalog.ts +8 -1
- package/src/lint/audit-lineage.ts +3 -0
- package/src/lint/post-synth/fly012-machine-schedule.test.ts +30 -0
- package/src/lint/post-synth/fly012-machine-schedule.ts +39 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/op/activities/fly-apply.test.ts +84 -0
- package/src/op/activities/fly-apply.ts +70 -3
- package/src/op/activities/machine-release.ts +86 -17
- package/src/op/activities/machines-fake.ts +5 -2
- package/src/skills/chant-fly-ops.md +47 -0
- package/src/skills/chant-fly.md +3 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"machine-release.d.ts","sourceRoot":"","sources":["../../../src/op/activities/machine-release.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"machine-release.d.ts","sourceRoot":"","sources":["../../../src/op/activities/machine-release.ts"],"names":[],"mappings":"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"}
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
*
|
|
17
17
|
* - {@link flyMachineRelease}: upload and start. Apply the declared plan with
|
|
18
18
|
* the Machine serving the release (its image or env overridden when the
|
|
19
|
-
* release says so), and report the release and config it replaced.
|
|
19
|
+
* release says so), and report the release and config it replaced. A
|
|
20
|
+
* scheduled Machine (`config.schedule`, #4696) is updated without starting:
|
|
21
|
+
* it runs on its schedule. The plan's other Machines can take images of
|
|
22
|
+
* their own (`images`).
|
|
20
23
|
* - {@link flyMachineExec}: run a command inside the Machine (a migration).
|
|
21
24
|
* - {@link flyMachineRestart}, {@link flyMachineStop}: under a lease, then
|
|
22
25
|
* wait for the Machine to settle.
|
|
@@ -29,33 +32,37 @@
|
|
|
29
32
|
* (`FLY_FLAPS_BASE_URL`, `FLY_API_TOKEN`), so the same code reaches mudflaps
|
|
30
33
|
* or a real Fly org, and an injectable `http` for tests (./machines-fake.ts).
|
|
31
34
|
*/
|
|
32
|
-
import { appNameFromRequest, defaultFlyHttp, flyApply, isAppRequest, isMachineRequest, listMachines, machineAppSegment, resolveApp, resolveEndpoint, waitForMachine, withLease, LEASE_NONCE_HEADER, } from "./fly-apply.js";
|
|
35
|
+
import { appNameFromRequest, defaultFlyHttp, flyApply, isAppRequest, isMachineRequest, listMachines, machineAppSegment, resolveApp, resolveEndpoint, skipsLaunch, waitForMachine, waitForUnlaunched, withLease, LEASE_NONCE_HEADER, } from "./fly-apply.js";
|
|
33
36
|
import { parsePlan } from "./fly-apply.js";
|
|
34
37
|
import { readFileSync } from "node:fs";
|
|
35
38
|
import { readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
|
|
36
39
|
export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
* names (an entity name or a Machine name). Pure.
|
|
40
|
-
*/
|
|
41
|
-
export function releaseTarget(plan, machine) {
|
|
40
|
+
/** Every Machine the plan declares, with its App resolved. Pure. */
|
|
41
|
+
export function planMachines(plan) {
|
|
42
42
|
const appNames = Object.values(plan).filter(isAppRequest).map(appNameFromRequest);
|
|
43
43
|
const soleApp = appNames.length === 1 ? appNames[0] : undefined;
|
|
44
|
-
|
|
44
|
+
return Object.entries(plan)
|
|
45
45
|
.filter(([, req]) => isMachineRequest(req))
|
|
46
46
|
.map(([entity, request]) => ({
|
|
47
|
+
app: resolveApp(machineAppSegment(request.endpoint), soleApp),
|
|
47
48
|
entity,
|
|
48
49
|
request,
|
|
49
50
|
name: typeof request.body.name === "string" && request.body.name ? request.body.name : entity,
|
|
50
51
|
}));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The Machine a release targets: the plan's one Machine, or the one `machine`
|
|
55
|
+
* names (an entity name or a Machine name). Pure.
|
|
56
|
+
*/
|
|
57
|
+
export function releaseTarget(plan, machine) {
|
|
58
|
+
const machines = planMachines(plan);
|
|
51
59
|
const picked = machine ? machines.filter((m) => m.entity === machine || m.name === machine) : machines;
|
|
52
60
|
if (picked.length !== 1) {
|
|
53
61
|
throw new Error(machine
|
|
54
62
|
? `the fly plan declares no Machine named "${machine}" (declared: ${machines.map((m) => m.entity).join(", ") || "none"})`
|
|
55
63
|
: `the fly plan declares ${machines.length} Machines (${machines.map((m) => m.entity).join(", ")}): name the one that serves the release`);
|
|
56
64
|
}
|
|
57
|
-
|
|
58
|
-
return { app: resolveApp(machineAppSegment(m.request.endpoint), soleApp), entity: m.entity, name: m.name, request: m.request };
|
|
65
|
+
return picked[0];
|
|
59
66
|
}
|
|
60
67
|
const TERMINAL = new Set(["destroyed", "destroying"]);
|
|
61
68
|
/** The live Machine named `name` (or with id `name`) in `app`; undefined when there is none. */
|
|
@@ -76,6 +83,11 @@ const machinePath = (ctx, app, id) => `${ctx.base}/v1/apps/${encodeURIComponent(
|
|
|
76
83
|
* under a lease, so the release serves from here on. A Machine that already
|
|
77
84
|
* serves this release keeps the `previousDigest` it shipped with, so running a
|
|
78
85
|
* release twice never makes it its own predecessor.
|
|
86
|
+
*
|
|
87
|
+
* A scheduled Machine (its config has a `schedule`, #4696), the target or
|
|
88
|
+
* another in the plan, is created or updated with `skip_launch` and left
|
|
89
|
+
* unstarted: Fly starts it on its schedule. Fly counts the interval from the
|
|
90
|
+
* update, so each release pushes the next run back by up to one interval.
|
|
79
91
|
*/
|
|
80
92
|
export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args.token)) {
|
|
81
93
|
const plan = args.plan ?? parsePlan(readFileSync(requirePlanPath(args.planPath), "utf8"));
|
|
@@ -100,16 +112,46 @@ export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args
|
|
|
100
112
|
...(cmd ? { init: { ...(declared.init ?? {}), cmd } } : {}),
|
|
101
113
|
}, release);
|
|
102
114
|
const request = { ...target.request, body: { ...target.request.body, config } };
|
|
103
|
-
|
|
104
|
-
const
|
|
115
|
+
// The plan's other Machines, each with its own image when `images` names it.
|
|
116
|
+
const others = planMachines(plan).filter((m) => m.entity !== target.entity);
|
|
117
|
+
const images = { ...(args.images ?? {}) };
|
|
118
|
+
const overrides = {};
|
|
119
|
+
for (const m of others) {
|
|
120
|
+
const image = images[m.entity] ?? images[m.name];
|
|
121
|
+
delete images[m.entity];
|
|
122
|
+
delete images[m.name];
|
|
123
|
+
if (image === undefined)
|
|
124
|
+
continue;
|
|
125
|
+
const declaredOther = (m.request.body.config ?? {});
|
|
126
|
+
overrides[m.entity] = { ...m.request, body: { ...m.request.body, config: { ...declaredOther, image } } };
|
|
127
|
+
}
|
|
128
|
+
const unknown = Object.keys(images);
|
|
129
|
+
if (unknown.length > 0) {
|
|
130
|
+
throw new Error(`images names ${unknown.map((n) => `"${n}"`).join(", ")}, which the fly plan declares as no Machine other than the one that serves (others: ${others.map((m) => m.entity).join(", ") || "none"}); the serving Machine's image is \`image\``);
|
|
131
|
+
}
|
|
132
|
+
const before = new Map();
|
|
133
|
+
for (const m of others) {
|
|
134
|
+
const found = await findMachine(ctx, m.app, m.name, http, signal).catch(() => undefined);
|
|
135
|
+
before.set(m.entity, found ? (found.config ?? {}) : null);
|
|
136
|
+
}
|
|
137
|
+
const applied = await flyApply({ plan: { ...plan, ...overrides, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait }, signal, http);
|
|
138
|
+
const actionOf = (app, name) => applied.machines.find((m) => m.app === app && m.name === name)?.action ?? "noop";
|
|
105
139
|
const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
|
|
106
140
|
return {
|
|
107
141
|
app: target.app,
|
|
108
142
|
machine: { id: now.id, name: target.name },
|
|
109
|
-
action,
|
|
143
|
+
action: actionOf(target.app, target.name),
|
|
110
144
|
release,
|
|
111
145
|
config,
|
|
112
146
|
previous: live ? { ...(liveRelease ? { release: liveRelease } : {}), config: liveConfig ?? {} } : null,
|
|
147
|
+
unlaunched: skipsLaunch(request.body),
|
|
148
|
+
others: others.map((m) => ({
|
|
149
|
+
name: m.name,
|
|
150
|
+
app: m.app,
|
|
151
|
+
action: actionOf(m.app, m.name),
|
|
152
|
+
unlaunched: skipsLaunch((overrides[m.entity] ?? m.request).body),
|
|
153
|
+
previous: before.get(m.entity) ?? null,
|
|
154
|
+
})),
|
|
113
155
|
};
|
|
114
156
|
}
|
|
115
157
|
function requirePlanPath(planPath) {
|
|
@@ -164,8 +206,9 @@ export async function flyMachineStop(args, signal, http = defaultFlyHttp(args.to
|
|
|
164
206
|
return transition(args, "stop", signal, http);
|
|
165
207
|
}
|
|
166
208
|
/**
|
|
167
|
-
* The Machine is started and its metadata names release `digest`.
|
|
168
|
-
*
|
|
209
|
+
* The Machine is started and its metadata names release `digest`. A scheduled
|
|
210
|
+
* Machine (#4696) may also be resting unstarted (`created`, `stopped` or
|
|
211
|
+
* `suspended`) between runs. Given a `url`, its health endpoint answers 2xx too, and when that answer is JSON
|
|
169
212
|
* naming a `revision` or `digest`, it is this release's (the commit or the
|
|
170
213
|
* digest). Retries until `timeoutMs`, then throws the last reason.
|
|
171
214
|
*/
|
|
@@ -179,7 +222,9 @@ export async function flyMachineVerify(args, signal, http = defaultFlyHttp(args.
|
|
|
179
222
|
throw new Error("flyMachineVerify aborted");
|
|
180
223
|
try {
|
|
181
224
|
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
182
|
-
|
|
225
|
+
const scheduled = skipsLaunch({ config: m.config });
|
|
226
|
+
const resting = scheduled && ["created", "stopped", "suspended"].includes(m.state);
|
|
227
|
+
if (m.state !== "started" && !resting)
|
|
183
228
|
throw new Error(`machine ${args.app}/${m.id} is ${m.state}`);
|
|
184
229
|
const release = readMachineRelease(m.config?.metadata);
|
|
185
230
|
if (release?.digest !== args.digest) {
|
|
@@ -216,11 +261,15 @@ export async function flyMachineVerify(args, signal, http = defaultFlyHttp(args.
|
|
|
216
261
|
* Put a recorded Machine config back: update the Machine to exactly `config`
|
|
217
262
|
* under a lease and wait for it to be started. Used on a failed release (the
|
|
218
263
|
* config it replaced) and by a rollback (the config an earlier release applied).
|
|
264
|
+
* A scheduled config (#4696) goes back with `skip_launch`, and the wait is for
|
|
265
|
+
* the Machine to rest unstarted.
|
|
219
266
|
*/
|
|
220
267
|
export async function flyMachineRestore(args, signal, http = defaultFlyHttp(args.token)) {
|
|
221
268
|
const ctx = { base: resolveEndpoint(args) };
|
|
222
269
|
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
223
|
-
const
|
|
270
|
+
const unlaunched = skipsLaunch({ config: args.config });
|
|
271
|
+
const body = { name: m.name, config: args.config, ...(unlaunched ? { skip_launch: true } : {}) };
|
|
272
|
+
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", machinePath(ctx, args.app, m.id), body, { [LEASE_NONCE_HEADER]: nonce }, signal));
|
|
224
273
|
if (res.status >= 300)
|
|
225
274
|
throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
226
275
|
let instance = "";
|
|
@@ -230,7 +279,10 @@ export async function flyMachineRestore(args, signal, http = defaultFlyHttp(args
|
|
|
230
279
|
catch {
|
|
231
280
|
instance = "";
|
|
232
281
|
}
|
|
233
|
-
|
|
282
|
+
if (unlaunched)
|
|
283
|
+
await waitForUnlaunched(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
284
|
+
else
|
|
285
|
+
await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
234
286
|
const release = readMachineRelease(args.config.metadata);
|
|
235
287
|
return { id: m.id, ...(release ? { release } : {}) };
|
|
236
288
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"machines-fake.d.ts","sourceRoot":"","sources":["../../../src/op/activities/machines-fake.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,kCAAkC;AAClC,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;CACzE;AAED,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACrC,2DAA2D;IAC3D,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,qEAAqE;IACrE,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7D,wDAAwD;IACxD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,sDAAsD;IACtD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gIAAgI;IAChI,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC1G,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;CAC3C;AAID,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,aAAa,MAAM,CAAC;AAWjC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,
|
|
1
|
+
{"version":3,"file":"machines-fake.d.ts","sourceRoot":"","sources":["../../../src/op/activities/machines-fake.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,kCAAkC;AAClC,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;CACzE;AAED,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACrC,2DAA2D;IAC3D,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,qEAAqE;IACrE,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7D,wDAAwD;IACxD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,sDAAsD;IACtD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gIAAgI;IAChI,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC1G,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;CAC3C;AAID,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,aAAa,MAAM,CAAC;AAWjC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CAkKlF"}
|
|
@@ -84,7 +84,8 @@ export function createMachinesFake(options = {}) {
|
|
|
84
84
|
instance_id: nextId("I"),
|
|
85
85
|
config: structuredClone((b.config ?? {})),
|
|
86
86
|
};
|
|
87
|
-
|
|
87
|
+
// skip_launch: the Machine is created and not booted (#4696).
|
|
88
|
+
m.state = b.skip_launch === true ? "created" : settle(m);
|
|
88
89
|
machines.set(app, [...list(app), m]);
|
|
89
90
|
return json(200, m);
|
|
90
91
|
}
|
|
@@ -98,7 +99,9 @@ export function createMachinesFake(options = {}) {
|
|
|
98
99
|
if (method === "POST") {
|
|
99
100
|
m.config = structuredClone((b.config ?? {}));
|
|
100
101
|
m.instance_id = nextId("I");
|
|
101
|
-
|
|
102
|
+
// skip_launch: the new config isn't started (#4696). A Machine that
|
|
103
|
+
// never ran stays created; one that ran is stopped.
|
|
104
|
+
m.state = b.skip_launch === true ? (m.state === "created" ? "created" : "stopped") : settle(m);
|
|
102
105
|
return json(200, m);
|
|
103
106
|
}
|
|
104
107
|
if (method === "DELETE") {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLY012: a Machine's `schedule` is one the Machines API runs (#4696).
|
|
3
|
+
*
|
|
4
|
+
* Fly runs a scheduled Machine `hourly`, `daily`, `weekly` or `monthly`, and
|
|
5
|
+
* rejects anything else when the Machine is created or updated. A cron
|
|
6
|
+
* expression is the usual mistake, so name the four values at build time
|
|
7
|
+
* rather than at the first release.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { readProps, entityTypeOf } from "./fly-helpers";
|
|
12
|
+
|
|
13
|
+
const SCHEDULES = ["hourly", "daily", "weekly", "monthly"];
|
|
14
|
+
|
|
15
|
+
export const fly012: PostSynthCheck = {
|
|
16
|
+
id: "FLY012",
|
|
17
|
+
description: "Machine schedule must be hourly, daily, weekly or monthly",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
for (const [name, entity] of ctx.entities) {
|
|
22
|
+
if (entityTypeOf(entity) !== "Fly::Machines::Machine") continue;
|
|
23
|
+
const config = readProps(entity).config;
|
|
24
|
+
if (!config) continue;
|
|
25
|
+
const schedule = readProps(config).schedule;
|
|
26
|
+
if (schedule === undefined) continue;
|
|
27
|
+
if (typeof schedule !== "string" || !SCHEDULES.includes(schedule)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "FLY012",
|
|
30
|
+
severity: "error",
|
|
31
|
+
message: `Machine "${name}" has schedule ${JSON.stringify(schedule)}; Fly runs a Machine ${SCHEDULES.join(", ")}`,
|
|
32
|
+
entity: name,
|
|
33
|
+
lexicon: "fly",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -31,6 +31,8 @@ If a machine never reaches `started`:
|
|
|
31
31
|
| Wait fails with a non-408 status | flaps rejected the machine (bad config the build check did not catch, or an org-side limit) | Read the error body; fix the config and re-apply |
|
|
32
32
|
| Wait times out at the deadline | The machine cannot reach `started` in time | Inspect the machine directly on the org, then re-apply once the cause is fixed |
|
|
33
33
|
|
|
34
|
+
A scheduled machine is the exception: it isn't started, so the applier polls the machine until it rests `created` or `stopped` at its new config instead (see "A scheduled Machine" below).
|
|
35
|
+
|
|
34
36
|
## Lease conflicts
|
|
35
37
|
|
|
36
38
|
Mutating an existing machine (update or destroy) is gated behind a Machines API lease. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and releases the lease afterward. A leaked lease expires on its own TTL, so release is best-effort.
|
|
@@ -161,3 +163,48 @@ export default Op({
|
|
|
161
163
|
```
|
|
162
164
|
|
|
163
165
|
A rollback's own ledger record is not counted as a release, so running the Op again plans the same rollback, leaves the Machine as it is and records nothing. `to: "sha256:..."` on `releaseRollbackPlan` picks another release. Migrations are not undone.
|
|
166
|
+
|
|
167
|
+
### A scheduled Machine
|
|
168
|
+
|
|
169
|
+
A Machine with a `schedule` in its config runs once an hour, day, week or month (`hourly`, `daily`, `weekly`, `monthly`; FLY012 refuses anything else, a cron expression included) and is stopped between runs. A backup or a dump is the usual case. Declare it beside the Machine that serves, with no process group of its own:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { App, Machine, MachineConfig } from "@intentius/chant-lexicon-fly";
|
|
173
|
+
|
|
174
|
+
export const platform = new App({ name: "platform", org_slug: "acme" });
|
|
175
|
+
|
|
176
|
+
export const web = new Machine({
|
|
177
|
+
name: "web",
|
|
178
|
+
region: "iad",
|
|
179
|
+
config: new MachineConfig({ image: "registry.fly.io/platform:declared" }),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const dump = new Machine({
|
|
183
|
+
name: "platform-dump",
|
|
184
|
+
region: "iad",
|
|
185
|
+
config: new MachineConfig({
|
|
186
|
+
image: "registry.fly.io/platform:dump-declared",
|
|
187
|
+
schedule: "hourly",
|
|
188
|
+
metadata: { role: "platform-dump" },
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`flyApply` creates and updates a scheduled Machine with `skip_launch: true`, so an apply or a release moves it to its new config and image and never starts it. The next run is Fly's. Set `skip_launch: false` on the Machine to have each apply start it as well. Fly counts the interval from the Machine's last update, so each apply that changes it pushes the next run back by up to one interval: under `daily`, releases less than a day apart can keep it from ever running. `hourly`, with the job skipping a run when its last output is recent enough, keeps a daily job daily.
|
|
194
|
+
|
|
195
|
+
`fly-release` releases the Machine named by `machine` and applies the rest of the plan with it. `images` gives the plan's other Machines images of their own, by entity or Machine name; a name that is no other Machine in the plan is refused before anything changes:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
{
|
|
199
|
+
kind: "fly-release",
|
|
200
|
+
plan: "dist/fly.json",
|
|
201
|
+
machine: "web",
|
|
202
|
+
digest: "@Publish.digest",
|
|
203
|
+
image: "@Publish.uri",
|
|
204
|
+
images: { dump: "@PublishDump.uri" },
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The serving Machine is started and verified as usual. The scheduled one is updated and left stopped. If the release fails after the Machines changed, the serving Machine goes back to what it served and the scheduled Machine goes back to its old config, still stopped. Only the serving Machine's configs are recorded, so `fly-rollback` and the step's saga rollback put back the serving Machine alone.
|
|
209
|
+
|
|
210
|
+
A scheduled Machine can be the one released, too (`machine: "platform-dump"`, or the plan's only Machine). It's updated and left stopped, its metadata names the release, and verify accepts it stopped, or started in the middle of a run, as long as it names this release. Such a release takes no `migrations` and no `verify.url`, since nothing runs in the Machine between runs to exec into or answer a health check; either is refused before the Machine changes. A restore or rollback puts its old config back without starting it.
|
package/dist/skills/chant-fly.md
CHANGED
|
@@ -73,6 +73,7 @@ Build synthesizes the flaps plan and runs the lint rules before anything reaches
|
|
|
73
73
|
| FLY004 | A secret value written inline in machine config |
|
|
74
74
|
| FLY010 | A machine config with no `image` |
|
|
75
75
|
| FLY011 | A machine mount that references a `Volume` not declared in the stack (checked across files) |
|
|
76
|
+
| FLY012 | A machine `schedule` other than `hourly`, `daily`, `weekly` or `monthly` |
|
|
76
77
|
|
|
77
78
|
Fix every reported violation before applying. Secret values belong in a `Secret` or a reference, never inline (FLY004).
|
|
78
79
|
|
|
@@ -81,6 +82,7 @@ Fix every reported violation before applying. Secret values belong in a `Secret`
|
|
|
81
82
|
`flyApply` reads the serialized plan and applies it to flaps in dependency order: app, then volumes, then machines, then IPs, certificates, and secrets. Per machine it does a GET-then-create or update, then waits.
|
|
82
83
|
|
|
83
84
|
- Create or update: POST the machine, then poll `GET .../wait` until it reaches `started` at its new `instance_id`. flaps caps its own long-poll at 60 seconds and answers 408 on expiry, so the client re-polls until its deadline (default 300 seconds).
|
|
85
|
+
- A scheduled machine (`config.schedule`) is created and updated with `skip_launch: true`, and the wait is for it to rest unstarted (`created` or `stopped`) at its new `instance_id`. Fly starts it on its schedule. `skip_launch: false` on the Machine starts it like any other.
|
|
84
86
|
- No-op on no drift: a re-apply of an unchanged machine (config structurally equal to live) does nothing.
|
|
85
87
|
- Leases: mutating an existing machine goes through the Machines API lease protocol. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and re-acquires and retries once if the lease was lost. Concurrent operators stay out of each other's way.
|
|
86
88
|
|
|
@@ -112,7 +114,7 @@ export default flyDeploy({ app: "local-fly-demo" });
|
|
|
112
114
|
|
|
113
115
|
| Command | Description |
|
|
114
116
|
|---------|-------------|
|
|
115
|
-
| `chant build src/` | Synthesize the flaps plan and run lint (FLY001/FLY002/FLY004/FLY010/FLY011) |
|
|
117
|
+
| `chant build src/` | Synthesize the flaps plan and run lint (FLY001/FLY002/FLY004/FLY010/FLY011/FLY012) |
|
|
116
118
|
| `chant run fly` | Run the deploy Op (boot, build, apply, verify, teardown) |
|
|
117
119
|
| `FLY_FLAPS_BASE_URL=...` | Point the same code at mudflaps or a real Fly org |
|
|
118
120
|
| `FLY_API_TOKEN=...` | Bearer token for a real Fly org (mudflaps ignores it) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fly",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.65",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@intentius/chant-lexicon-otel": "^0.124.
|
|
61
|
+
"@intentius/chant-lexicon-otel": "^0.124.65",
|
|
62
62
|
"ws": "^8.18.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"zod": "^4.3.6"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@intentius/chant": "^0.124.
|
|
72
|
+
"@intentius/chant": "^0.124.65",
|
|
73
73
|
"zod": "^4.3.6"
|
|
74
74
|
},
|
|
75
75
|
"description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",
|
|
@@ -412,3 +412,82 @@ describe("fly-rollback with a source tree (#2800)", () => {
|
|
|
412
412
|
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");
|
|
413
413
|
});
|
|
414
414
|
});
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* #4696: a scheduled Machine, stopped between runs, is moved to the release
|
|
418
|
+
* without being started, whether it sits beside the Machine that serves or is
|
|
419
|
+
* the one released.
|
|
420
|
+
*/
|
|
421
|
+
describe("fly-release with a scheduled Machine (#4696)", () => {
|
|
422
|
+
const DUMP = {
|
|
423
|
+
endpoint: "/v1/apps/shop/machines",
|
|
424
|
+
method: "POST",
|
|
425
|
+
body: { name: "shop-dump", config: { image: "shop:dump-declared", schedule: "hourly", metadata: { "managed-by": "chant", role: "dump" } } },
|
|
426
|
+
};
|
|
427
|
+
const writePlan = (plan: Record<string, unknown>) => writeFileSync(planPath, JSON.stringify(plan));
|
|
428
|
+
|
|
429
|
+
test("beside the serving Machine: it takes its image from images and stays stopped", async () => {
|
|
430
|
+
writePlan({ ...PLAN, dump: DUMP });
|
|
431
|
+
const s = setup();
|
|
432
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { dump: "shop:dump-1" } }));
|
|
433
|
+
expect(s.fake.machine("shop", "web")).toMatchObject({ state: "started", config: { image: "shop:1" } });
|
|
434
|
+
expect(s.fake.machine("shop", "shop-dump")).toMatchObject({ state: "created", config: { image: "shop:dump-1", schedule: "hourly" } });
|
|
435
|
+
expect(s.lines).toContain("fly-release: shop/shop-dump created, stopped until its schedule starts it");
|
|
436
|
+
|
|
437
|
+
s.fake.machine("shop", "shop-dump")!.state = "stopped"; // it ran once, on its schedule
|
|
438
|
+
// By Machine name as well as by entity name.
|
|
439
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { "shop-dump": "shop:dump-2" } }));
|
|
440
|
+
expect(s.fake.machine("shop", "shop-dump")).toMatchObject({ state: "stopped", config: { image: "shop:dump-2" } });
|
|
441
|
+
expect(s.fake.calls.filter((c) => c.includes("/machines/") && /\/(start|restart)$/.test(c))).toEqual([]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("an images entry that names no other Machine is refused before anything changes", async () => {
|
|
445
|
+
writePlan({ ...PLAN, dump: DUMP });
|
|
446
|
+
const s = setup();
|
|
447
|
+
await expect(s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", images: { dmp: "x" } }))).rejects.toThrow(/images names "dmp"/);
|
|
448
|
+
await expect(s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", images: { web: "x" } }))).rejects.toThrow(/serving Machine's image is `image`/);
|
|
449
|
+
expect(s.fake.calls.some((c) => c.startsWith("POST /v1/apps/shop/machines"))).toBe(false);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("a failed release puts the scheduled Machine back on its old config, still stopped", async () => {
|
|
453
|
+
writePlan({ ...PLAN, dump: DUMP });
|
|
454
|
+
const fake = createMachinesFake({ exec: (_a, _m, cmd) => (cmd.join(" ").includes("boom") ? { exit_code: 1, stderr: "boom" } : { exit_code: 0 }) });
|
|
455
|
+
const s = setup(fake);
|
|
456
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { dump: "shop:dump-1" } }));
|
|
457
|
+
fake.machine("shop", "shop-dump")!.state = "stopped";
|
|
458
|
+
const before = structuredClone(fake.machine("shop", "shop-dump")!.config);
|
|
459
|
+
await expect(
|
|
460
|
+
s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { dump: "shop:dump-2" }, migrations: [{ name: "5.sql", command: "boom" }] })),
|
|
461
|
+
).rejects.toThrow(/exited 1/);
|
|
462
|
+
expect(fake.machine("shop", "shop-dump")).toMatchObject({ state: "stopped", config: before });
|
|
463
|
+
expect(s.lines).toContain("fly-release: restored shop/shop-dump to its previous config");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("released itself: updated, verified and recorded while stopped", async () => {
|
|
467
|
+
writePlan({ shop: PLAN.shop, dump: DUMP });
|
|
468
|
+
const s = setup();
|
|
469
|
+
const out = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:dump-1" }));
|
|
470
|
+
expect(out.uri).toBe("fly://shop/shop-dump");
|
|
471
|
+
const m = s.fake.machine("shop", "shop-dump")!;
|
|
472
|
+
expect(m).toMatchObject({ state: "created", config: { image: "shop:dump-1", schedule: "hourly" } });
|
|
473
|
+
expect(readMachineRelease(m.config.metadata)?.digest).toBe("sha256:aaaaaaaaaaaa");
|
|
474
|
+
expect(s.lines[0]).toBe("fly-release: shop/shop-dump created, serving sha256:aaaaaaaaaaaa, stopped until its schedule starts it");
|
|
475
|
+
|
|
476
|
+
m.state = "stopped";
|
|
477
|
+
const second = await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:dump-2" }));
|
|
478
|
+
expect(s.fake.machine("shop", "shop-dump")).toMatchObject({ state: "stopped", config: { image: "shop:dump-2" } });
|
|
479
|
+
// Its saga rollback puts the earlier config back, still stopped.
|
|
480
|
+
await s.release.rollback!(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:dump-2" }), second);
|
|
481
|
+
expect(s.fake.machine("shop", "shop-dump")).toMatchObject({ state: "stopped", config: { image: "shop:dump-1" } });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("released itself, it takes no migrations and no health URL: nothing runs in it between runs", async () => {
|
|
485
|
+
writePlan({ shop: PLAN.shop, dump: DUMP });
|
|
486
|
+
const s = setup();
|
|
487
|
+
await expect(s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { migrations: [{ name: "1", command: "x" }] }))).rejects.toThrow(
|
|
488
|
+
/shop\/shop-dump is scheduled \(hourly\) and stays stopped between runs, so no migration can run in it/,
|
|
489
|
+
);
|
|
490
|
+
await expect(s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { verify: { url: "https://shop.example.com" } }))).rejects.toThrow(/no health endpoint/);
|
|
491
|
+
expect(s.fake.calls.some((c) => c.startsWith("POST"))).toBe(false);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
* goes back (or, on a first release, the Machine is stopped), and the step
|
|
33
33
|
* fails.
|
|
34
34
|
*
|
|
35
|
+
* A scheduled Machine (`config.schedule`, #4696) is released without being
|
|
36
|
+
* started, whether it is the one that serves or another Machine in the plan:
|
|
37
|
+
* its config and image change, and Fly starts it on its schedule. Verify
|
|
38
|
+
* accepts it stopped between runs. A scheduled Machine that serves takes no
|
|
39
|
+
* migrations and no health URL, since nothing runs in it between runs. The
|
|
40
|
+
* plan's other Machines take their images from `images`; one the release
|
|
41
|
+
* updated goes back to its old config when the release fails.
|
|
42
|
+
*
|
|
35
43
|
* Its output carries `uri` and `digest`, so `chant run --components` records
|
|
36
44
|
* the release in the ledger, and the Machine's metadata carries the same
|
|
37
45
|
* digest, which `chant components status --live` compares with it.
|
|
@@ -57,10 +65,11 @@ import {
|
|
|
57
65
|
flyMachineVerify,
|
|
58
66
|
readMachineRelease,
|
|
59
67
|
releaseTarget,
|
|
68
|
+
type FlyMachineReleaseResult,
|
|
60
69
|
type MachineFile,
|
|
61
70
|
type MachineRelease,
|
|
62
71
|
} from "../op/activities/machine-release";
|
|
63
|
-
import { defaultFlyHttp, parsePlan, resolveEndpoint, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
|
|
72
|
+
import { defaultFlyHttp, machineSchedule, parsePlan, resolveEndpoint, skipsLaunch, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
|
|
64
73
|
import type { MachineConfigStore } from "../release-store";
|
|
65
74
|
import { readFileSync } from "node:fs";
|
|
66
75
|
import { releaseAttributesSuffix, type ReleaseIdentity } from "@intentius/chant/telemetry-attribution";
|
|
@@ -147,6 +156,12 @@ export interface FlyReleaseInput {
|
|
|
147
156
|
release?: string;
|
|
148
157
|
/** An image to run in place of the declared one. */
|
|
149
158
|
image?: string;
|
|
159
|
+
/**
|
|
160
|
+
* Images for the plan's other Machines, by entity or Machine name (#4696),
|
|
161
|
+
* such as a scheduled Machine built from the same commit. Each is applied
|
|
162
|
+
* with the release; a scheduled one is left stopped.
|
|
163
|
+
*/
|
|
164
|
+
images?: Record<string, string>;
|
|
150
165
|
/** Env added to the declared Machine's env, over `APP_REVISION` (the commit, #2834) unless this names its own. */
|
|
151
166
|
env?: Record<string, string>;
|
|
152
167
|
/**
|
|
@@ -282,6 +297,19 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
282
297
|
const log = deps.log ?? ((line: string) => console.error(line));
|
|
283
298
|
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
284
299
|
|
|
300
|
+
/** On a failed release, put the plan's other Machines it updated back on their old configs. */
|
|
301
|
+
async function restoreOthers(released: FlyMachineReleaseResult, input: FlyReleaseInput, http: FlyHttp): Promise<void> {
|
|
302
|
+
for (const other of released.others) {
|
|
303
|
+
if (other.action !== "updated" || !other.previous) continue;
|
|
304
|
+
try {
|
|
305
|
+
await flyMachineRestore({ app: other.app, machine: other.name, config: other.previous, endpoint: input.endpoint, wait: input.wait }, undefined, http);
|
|
306
|
+
log(`fly-release: restored ${other.app}/${other.name} to its previous config`);
|
|
307
|
+
} catch (restoreErr) {
|
|
308
|
+
log(`fly-release: restore of ${other.app}/${other.name} failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
285
313
|
async function rollBack(ctx: DeployContext, input: FlyReleaseInput, output?: FlyReleaseOutput): Promise<void> {
|
|
286
314
|
if (!output) return;
|
|
287
315
|
const http = httpFor();
|
|
@@ -307,6 +335,12 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
307
335
|
if (!input.digest) throw new Error("fly-release: a release needs its digest");
|
|
308
336
|
const http = httpFor();
|
|
309
337
|
const { plan, target } = loadTarget(input.plan, input.machine);
|
|
338
|
+
// A scheduled Machine is stopped between runs: nothing can exec in it or answer a health check.
|
|
339
|
+
if (skipsLaunch(target.request.body)) {
|
|
340
|
+
const why = `${target.app}/${target.name} is scheduled (${machineSchedule(target.request.body.config) ?? "skip_launch"}) and stays stopped between runs`;
|
|
341
|
+
if (input.migrations?.length) throw new Error(`fly-release: ${why}, so no migration can run in it`);
|
|
342
|
+
if (input.verify?.url) throw new Error(`fly-release: ${why}, so it has no health endpoint to verify at ${input.verify.url}`);
|
|
343
|
+
}
|
|
310
344
|
const gitSha = input.gitSha ?? (await (deps.headCommit ?? defaultHeadCommit)());
|
|
311
345
|
const configs = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
312
346
|
// The tree is checked against its digest before the Machine changes.
|
|
@@ -331,6 +365,7 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
331
365
|
machine: input.machine,
|
|
332
366
|
release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
|
|
333
367
|
image: input.image,
|
|
368
|
+
...(input.images ? { images: input.images } : {}),
|
|
334
369
|
env,
|
|
335
370
|
...(source ? { files: source.files, cmd: source.cmd } : {}),
|
|
336
371
|
endpoint: input.endpoint,
|
|
@@ -340,7 +375,10 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
340
375
|
http,
|
|
341
376
|
);
|
|
342
377
|
const where = { app: released.app, machine: released.machine.name, endpoint: input.endpoint };
|
|
343
|
-
log(`fly-release: ${released.app}/${released.machine.name} ${released.action}, serving ${input.digest}`);
|
|
378
|
+
log(`fly-release: ${released.app}/${released.machine.name} ${released.action}, serving ${input.digest}${released.unlaunched ? ", stopped until its schedule starts it" : ""}`);
|
|
379
|
+
for (const other of released.others) {
|
|
380
|
+
if (other.action !== "noop") log(`fly-release: ${other.app}/${other.name} ${other.action}${other.unlaunched ? ", stopped until its schedule starts it" : ""}`);
|
|
381
|
+
}
|
|
344
382
|
|
|
345
383
|
// The config the Machine served before is kept too, so a Machine
|
|
346
384
|
// released before chant recorded configs can still be rolled back to.
|
|
@@ -388,6 +426,7 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
388
426
|
} catch (restoreErr) {
|
|
389
427
|
log(`fly-release: restore failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
|
|
390
428
|
}
|
|
429
|
+
await restoreOthers(released, input, http);
|
|
391
430
|
throw err;
|
|
392
431
|
}
|
|
393
432
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The fly lexicon's chant audit catalog — metadata for the FLY post-synth
|
|
3
3
|
* checks, contributed via `flyPlugin.auditCatalog()` (#687).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Every entry carries `yamlBased: false`: each check reads the chant model
|
|
6
6
|
* (`ctx.entities`) rather than the emitted manifest, because what they need —
|
|
7
7
|
* whether a mount's volume is declared in the same stack — lives in the typed
|
|
8
8
|
* graph and is gone by the time `fly.toml` exists. So they fire on
|
|
@@ -45,6 +45,13 @@ export const flyAuditCatalog: Record<string, RuleMeta> = {
|
|
|
45
45
|
"Machine mount references an undeclared volume",
|
|
46
46
|
"Declare the Volume in the same stack, or point the mount at one that is.",
|
|
47
47
|
),
|
|
48
|
+
FLY012: rule(
|
|
49
|
+
"FLY012",
|
|
50
|
+
"merge-worthy",
|
|
51
|
+
"correctness",
|
|
52
|
+
"Machine schedule is not one Fly runs",
|
|
53
|
+
"Set `config.schedule` to `hourly`, `daily`, `weekly` or `monthly`; the Machines API takes no cron expression.",
|
|
54
|
+
),
|
|
48
55
|
};
|
|
49
56
|
|
|
50
57
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
@@ -11,4 +11,7 @@ export const flyAuditLineage: Record<string, Lineage[]> = {
|
|
|
11
11
|
FLY010: [
|
|
12
12
|
{ tool: "fly-docs", rule: "Create a Machine: config.image required", url: "https://fly.io/docs/machines/api/machines-resource/#create-a-machine", relation: "overlaps" },
|
|
13
13
|
],
|
|
14
|
+
FLY012: [
|
|
15
|
+
{ tool: "fly-docs", rule: "Machine config: schedule is hourly, daily, weekly or monthly", url: "https://fly.io/docs/machines/api/machines-resource/#machine-config-object-properties", relation: "overlaps" },
|
|
16
|
+
],
|
|
14
17
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { Declarable } from "@intentius/chant";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { fly012 } from "./fly012-machine-schedule";
|
|
5
|
+
import { Machine, MachineConfig } from "../../generated/index";
|
|
6
|
+
|
|
7
|
+
function ctx(...entries: Array<[string, unknown]>): PostSynthContext {
|
|
8
|
+
const entities = new Map(entries as Array<[string, Declarable]>);
|
|
9
|
+
return {
|
|
10
|
+
outputs: new Map(),
|
|
11
|
+
entities,
|
|
12
|
+
buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 1 },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("FLY012: machine schedule", () => {
|
|
17
|
+
test("passes each schedule Fly runs, and a Machine with none", () => {
|
|
18
|
+
for (const schedule of ["hourly", "daily", "weekly", "monthly"]) {
|
|
19
|
+
expect(fly012.check(ctx(["dump", new Machine({ config: new MachineConfig({ image: "x", schedule }) })]))).toEqual([]);
|
|
20
|
+
}
|
|
21
|
+
expect(fly012.check(ctx(["web", new Machine({ config: new MachineConfig({ image: "x" }) })]))).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("flags a cron expression", () => {
|
|
25
|
+
const diags = fly012.check(ctx(["dump", new Machine({ config: new MachineConfig({ image: "x", schedule: "0 * * * *" }) })]));
|
|
26
|
+
expect(diags).toHaveLength(1);
|
|
27
|
+
expect(diags[0]).toMatchObject({ checkId: "FLY012", severity: "error", entity: "dump" });
|
|
28
|
+
expect(diags[0].message).toContain("hourly, daily, weekly, monthly");
|
|
29
|
+
});
|
|
30
|
+
});
|