@intentius/chant-lexicon-fly 0.124.63 → 0.124.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/components/fly-release.d.ts +14 -0
  3. package/dist/components/fly-release.d.ts.map +1 -1
  4. package/dist/components/fly-release.js +38 -2
  5. package/dist/integrity.json +5 -4
  6. package/dist/lint/audit-catalog.d.ts +1 -1
  7. package/dist/lint/audit-catalog.d.ts.map +1 -1
  8. package/dist/lint/audit-catalog.js +2 -1
  9. package/dist/lint/audit-lineage.d.ts.map +1 -1
  10. package/dist/lint/audit-lineage.js +3 -0
  11. package/dist/lint/post-synth/fly012-machine-schedule.d.ts +11 -0
  12. package/dist/lint/post-synth/fly012-machine-schedule.d.ts.map +1 -0
  13. package/dist/lint/post-synth/fly012-machine-schedule.js +37 -0
  14. package/dist/lint/post-synth/index.d.ts.map +1 -1
  15. package/dist/lint/post-synth/index.js +2 -0
  16. package/dist/manifest.json +1 -1
  17. package/dist/okf/index.md +1 -0
  18. package/dist/okf/rules/FLY012.md +15 -0
  19. package/dist/okf/types/Machine.md +1 -0
  20. package/dist/op/activities/fly-apply.d.ts +25 -0
  21. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  22. package/dist/op/activities/fly-apply.js +59 -3
  23. package/dist/op/activities/machine-release.d.ts +35 -3
  24. package/dist/op/activities/machine-release.d.ts.map +1 -1
  25. package/dist/op/activities/machine-release.js +70 -18
  26. package/dist/op/activities/machines-fake.d.ts.map +1 -1
  27. package/dist/op/activities/machines-fake.js +5 -2
  28. package/dist/rules/fly012-machine-schedule.ts +39 -0
  29. package/dist/skills/chant-fly-ops.md +47 -0
  30. package/dist/skills/chant-fly.md +3 -1
  31. package/package.json +3 -3
  32. package/src/components/fly-release.test.ts +79 -0
  33. package/src/components/fly-release.ts +41 -2
  34. package/src/lint/audit-catalog.ts +8 -1
  35. package/src/lint/audit-lineage.ts +3 -0
  36. package/src/lint/post-synth/fly012-machine-schedule.test.ts +30 -0
  37. package/src/lint/post-synth/fly012-machine-schedule.ts +39 -0
  38. package/src/lint/post-synth/index.ts +2 -0
  39. package/src/op/activities/fly-apply.test.ts +84 -0
  40. package/src/op/activities/fly-apply.ts +70 -3
  41. package/src/op/activities/machine-release.ts +86 -17
  42. package/src/op/activities/machines-fake.ts +5 -2
  43. package/src/skills/chant-fly-ops.md +47 -0
  44. 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":"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"}
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
- * The Machine a release targets: the plan's one Machine, or the one `machine`
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
- const machines = Object.entries(plan)
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
- const [m] = picked;
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
- const applied = await flyApply({ plan: { ...plan, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait }, signal, http);
104
- const action = applied.machines.find((m) => m.app === target.app && m.name === target.name)?.action ?? "noop";
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`. Given a
168
- * `url`, its health endpoint answers 2xx too, and when that answer is JSON
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
- if (m.state !== "started")
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 res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", machinePath(ctx, args.app, m.id), { name: m.name, config: args.config }, { [LEASE_NONCE_HEADER]: nonce }, signal));
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
- await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
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,CA+JlF"}
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
- m.state = settle(m);
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
- m.state = settle(m);
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.
@@ -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.63",
3
+ "version": "0.124.64",
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.63",
61
+ "@intentius/chant-lexicon-otel": "^0.124.64",
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.63",
72
+ "@intentius/chant": "^0.124.64",
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
- * Both entries carry `yamlBased: false`: each check reads the chant model
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
+ });