@intentius/chant-lexicon-fly 0.124.31 → 0.124.33

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 (83) hide show
  1. package/dist/codegen/docs-cli.js +6 -0
  2. package/dist/codegen/docs.js +143 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-lexicon.js +38 -0
  5. package/dist/codegen/generate-typescript.js +45 -0
  6. package/dist/codegen/generate.js +82 -0
  7. package/dist/codegen/naming.js +33 -0
  8. package/dist/codegen/package.js +35 -0
  9. package/dist/components/capability-plugin.js +26 -0
  10. package/dist/components/fly-release.js +335 -0
  11. package/dist/components/index.js +10 -0
  12. package/dist/components/run-agent.js +133 -0
  13. package/dist/composites/catalog.js +176 -0
  14. package/dist/composites/fly-deploy.js +91 -0
  15. package/dist/composites/fly-otel-collector.js +66 -0
  16. package/dist/composites/fly-site.js +80 -0
  17. package/dist/coverage.js +29 -0
  18. package/dist/describe-resources.js +443 -0
  19. package/dist/detect.js +63 -0
  20. package/dist/export-resources.js +81 -0
  21. package/dist/generated/index.js +44 -0
  22. package/dist/generated/runtime.js +4 -0
  23. package/dist/import/generator.js +208 -0
  24. package/dist/import/live-export.js +81 -0
  25. package/dist/import/parser.js +157 -0
  26. package/dist/index.js +40 -0
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/audit-catalog.js +28 -0
  29. package/dist/lint/audit-lineage.js +5 -0
  30. package/dist/lint/post-synth/fly-helpers.js +24 -0
  31. package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
  32. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
  33. package/dist/lint/post-synth/index.js +6 -0
  34. package/dist/lint/rules/guest-sizing.js +92 -0
  35. package/dist/lint/rules/index.js +12 -0
  36. package/dist/lint/rules/no-secret-literals.js +58 -0
  37. package/dist/lint/rules/valid-region.js +52 -0
  38. package/dist/lsp/completions.js +12 -0
  39. package/dist/lsp/hover.js +12 -0
  40. package/dist/manifest.json +1 -1
  41. package/dist/mcp/context-tools.js +113 -0
  42. package/dist/okf/types/Machine.md +1 -1
  43. package/dist/op/activities/box-services.js +88 -0
  44. package/dist/op/activities/emulator-images.js +32 -0
  45. package/dist/op/activities/flaps.js +34 -0
  46. package/dist/op/activities/fly-apply.js +911 -0
  47. package/dist/op/activities/fly-release-step.js +21 -0
  48. package/dist/op/activities/fly-rollback-step.js +22 -0
  49. package/dist/op/activities/index.js +53 -0
  50. package/dist/op/activities/machine-release.js +236 -0
  51. package/dist/op/activities/machines-contract.js +81 -0
  52. package/dist/op/activities/machines-fake.js +203 -0
  53. package/dist/op/activities/machines-local-cli.js +85 -0
  54. package/dist/op/activities/machines-local.js +395 -0
  55. package/dist/op/activities/progress.js +10 -0
  56. package/dist/op/activities/sprite-config.js +409 -0
  57. package/dist/op/activities/sprite-fs.js +131 -0
  58. package/dist/op/activities/sprite-service-converge.js +224 -0
  59. package/dist/op/activities/sprite-services.js +174 -0
  60. package/dist/op/activities/sprite-tasks.js +57 -0
  61. package/dist/op/activities/sprites-contract.js +87 -0
  62. package/dist/op/activities/sprites-emulator.js +72 -0
  63. package/dist/op/activities/sprites-fake.js +547 -0
  64. package/dist/op/activities/sprites.js +515 -0
  65. package/dist/op/activity-contracts.js +82 -0
  66. package/dist/op/builders.js +157 -0
  67. package/dist/ownership.js +29 -0
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +298 -0
  70. package/dist/pseudo.js +26 -0
  71. package/dist/reference-catalog.js +37 -0
  72. package/dist/release-metadata.js +59 -0
  73. package/dist/release-store.js +46 -0
  74. package/dist/serializer.js +299 -0
  75. package/dist/spec/fetch.js +32 -0
  76. package/dist/spec/parse.js +260 -0
  77. package/dist/teardown.js +194 -0
  78. package/dist/types/index.d.ts +33 -2
  79. package/dist/validate-cli.js +4 -0
  80. package/dist/validate.js +33 -0
  81. package/package.json +11 -11
  82. package/src/codegen/package.ts +2 -2
  83. package/src/generated/index.d.ts +33 -2
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Live introspection of Fly (flaps) resources — the read-back seam for chant's
3
+ * plan/drift machinery (#767).
4
+ *
5
+ * The write path (flyApply, #739/#741) already GETs live state to decide
6
+ * create/update/noop and to prune owned orphans. This surfaces that same read
7
+ * side through the standard `describeResources` seam so core's change set
8
+ * (packages/core/src/lifecycle/change-set.ts) and `chant lifecycle plan` can see
9
+ * live Fly state and classify create/update/delete/adopt/noop.
10
+ *
11
+ * Peer of gcp/aws describe-resources: list what is live, key it by chant entity
12
+ * name, attach an ownership verdict. Two-tier ownership, matching the applier
13
+ * (#743):
14
+ *
15
+ * - Machines carry the primary marker (`managed-by: chant`,
16
+ * FLY_METADATA_OWNERSHIP_KEYS), so their verdict is per-resource: `owned`
17
+ * when `isChantOwned(config.metadata)`, else `foreign`.
18
+ * - Volumes, IPs, and certificates carry no marker channel, so their boundary
19
+ * is the app (#741/#743): everything under a chant-managed app (one carrying
20
+ * the marker via its machines) is `owned`. When the `owned` filter asks for
21
+ * those types, this logs that ownership is inferred at the app boundary
22
+ * rather than silently returning everything, per the seam contract.
23
+ *
24
+ * Endpoint + auth reuse the applier verbatim (resolveEndpoint /
25
+ * FLY_FLAPS_BASE_URL / FLY_API_TOKEN), so plan reads the same mudflaps or
26
+ * real-Fly target flyApply writes. Secrets are apply-only (D7): flaps returns
27
+ * only a digest, so they never enter a diff and are excluded here.
28
+ */
29
+ import { normalizeObservation, observation, unobservedAll } from "@intentius/chant/observation";
30
+ import { readOwnership } from "@intentius/chant/ownership";
31
+ import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership.js";
32
+ import { readMachineRelease } from "./release-metadata.js";
33
+ import { buildChangeSet, renderChangeSet, } from "@intentius/chant/lifecycle/change-set";
34
+ import { resolveEndpoint, defaultFlyHttp, parsePlan, isAppRequest, isMachineRequest, isVolumeRequest, isIpRequest, isCertRequest, appNameFromRequest, machineAppSegment, resourceAppSegment, resolveApp, isChantOwned, configEqual, listMachines, listVolumes, listIps, listCerts, ipType, declaredIpType, } from "./op/activities/fly-apply.js";
35
+ /** The chant entity types the fly serializer emits, for the metadata `type` field. */
36
+ const TYPE = {
37
+ app: "Fly::Machines::App",
38
+ machine: "Fly::Machines::Machine",
39
+ volume: "Fly::Machines::Volume",
40
+ ip: "Fly::Machines::IPAddress",
41
+ cert: "Fly::Machines::Certificate",
42
+ };
43
+ /** Live machine states that mean the machine is gone or on its way out — not live. */
44
+ const TERMINAL_STATES = new Set(["destroyed", "destroying"]);
45
+ function pruneUndefined(obj) {
46
+ const out = {};
47
+ for (const [k, v] of Object.entries(obj)) {
48
+ if (v !== undefined)
49
+ out[k] = v;
50
+ }
51
+ return out;
52
+ }
53
+ /** The serialized machine name flaps stores: an explicit `body.name`, else the entity name (mirrors applyMachine). */
54
+ function serializedName(req, entityName) {
55
+ return typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
56
+ }
57
+ /** The release attributes of a Machine whose metadata names one (#2736). */
58
+ function releaseAttributes(metadata) {
59
+ const release = readMachineRelease(metadata);
60
+ return release ? { digest: release.digest, release } : {};
61
+ }
62
+ /** A string prop, or undefined. */
63
+ function stringProp(props, key) {
64
+ const v = props[key];
65
+ return typeof v === "string" && v ? v : undefined;
66
+ }
67
+ /**
68
+ * A plan rebuilt from declared entities (#2736), for a caller that has the
69
+ * entities and no serialized build. It holds only what a read is keyed by:
70
+ * each App's name, and each Machine, Volume, IP and Certificate with the App it
71
+ * belongs to (an `app` prop naming it, else the sole declared App). Returns ""
72
+ * when there are no entities to rebuild from.
73
+ */
74
+ export function planFromEntities(entities) {
75
+ const plan = {};
76
+ const apps = [];
77
+ for (const [name, { entityType, props }] of entities) {
78
+ if (entityType !== TYPE.app)
79
+ continue;
80
+ const appName = stringProp(props, "name") ?? name;
81
+ apps.push(appName);
82
+ plan[name] = { endpoint: "/v1/apps", method: "POST", body: { app_name: appName } };
83
+ }
84
+ const soleApp = apps.length === 1 ? apps[0] : "{app}";
85
+ const owning = (props) => {
86
+ const app = props.app;
87
+ if (typeof app === "string" && app)
88
+ return app;
89
+ const ref = app && typeof app === "object" ? app.props : undefined;
90
+ return (ref && stringProp(ref, "name")) ?? soleApp;
91
+ };
92
+ for (const [name, { entityType, props }] of entities) {
93
+ const app = owning(props);
94
+ const base = `/v1/apps/${app}`;
95
+ if (entityType === TYPE.machine) {
96
+ plan[name] = { endpoint: `${base}/machines`, method: "POST", body: { name: stringProp(props, "name") ?? name } };
97
+ }
98
+ else if (entityType === TYPE.volume) {
99
+ plan[name] = { endpoint: `${base}/volumes`, method: "POST", body: { name: stringProp(props, "name") ?? name } };
100
+ }
101
+ else if (entityType === TYPE.ip) {
102
+ plan[name] = { endpoint: `${base}/ip_assignments`, method: "POST", body: { type: props.type } };
103
+ }
104
+ else if (entityType === TYPE.cert) {
105
+ plan[name] = { endpoint: `${base}/certificates/acme`, method: "POST", body: { hostname: props.hostname } };
106
+ }
107
+ }
108
+ return Object.keys(plan).length ? JSON.stringify(plan) : "";
109
+ }
110
+ /**
111
+ * List live Fly resources over flaps and return them keyed by chant entity name,
112
+ * each tagged with an ownership verdict the core change set reads to decide
113
+ * whether an orphan is a delete (owned) or an adopt candidate (foreign). Reused
114
+ * by `flyPlugin.describeResources`.
115
+ *
116
+ * `http` is injectable for tests; the default reuses the applier's fetch client
117
+ * (bearer token from FLY_API_TOKEN).
118
+ */
119
+ export async function describeResources(options, http = defaultFlyHttp(), signal) {
120
+ const result = {};
121
+ const unobserved = {};
122
+ // `chant components status --live` hands over the declared entities with no
123
+ // serialized build (#2736): the plan is rebuilt from their props, enough to
124
+ // key each read by. Without either there is nothing to key a read by, so
125
+ // chant never asks flaps anything — every declared entity is unobserved, not
126
+ // absent (#1089). Returning an empty map here used to plan a create for each.
127
+ const buildOutput = options.buildOutput || planFromEntities(options.entities);
128
+ if (!buildOutput) {
129
+ return observation(result, unobservedAll(options.entities.keys(), "read-failed", "no fly plan in the build output to read back", options.entities));
130
+ }
131
+ let plan;
132
+ try {
133
+ plan = parsePlan(buildOutput);
134
+ }
135
+ catch (err) {
136
+ return observation(result, unobservedAll(options.entities.keys(), "read-failed", `build output is not a readable fly plan: ${err instanceof Error ? err.message : String(err)}`, options.entities));
137
+ }
138
+ const ctx = { base: resolveEndpoint({ endpoint: options.endpoint }) };
139
+ const owned = options.owned ?? false;
140
+ const entities = options.entities;
141
+ const typeOf = (entityName, fallback) => entities.get(entityName)?.entityType ?? fallback;
142
+ // Classify the declared plan (mirrors flyApply's dispatch). Secrets are
143
+ // apply-only (D7) and never enter a diff, so they are not collected.
144
+ const appReqs = [];
145
+ const machineReqs = [];
146
+ const volumeReqs = [];
147
+ const ipReqs = [];
148
+ const certReqs = [];
149
+ for (const [entityName, req] of Object.entries(plan)) {
150
+ if (isAppRequest(req))
151
+ appReqs.push([entityName, req]);
152
+ else if (isMachineRequest(req))
153
+ machineReqs.push([entityName, req]);
154
+ else if (isVolumeRequest(req))
155
+ volumeReqs.push([entityName, req]);
156
+ else if (isIpRequest(req))
157
+ ipReqs.push([entityName, req]);
158
+ else if (isCertRequest(req))
159
+ certReqs.push([entityName, req]);
160
+ }
161
+ const appNames = appReqs.map(([, r]) => appNameFromRequest(r));
162
+ const soleApp = appNames.length === 1 ? appNames[0] : undefined;
163
+ const owningApp = (req, segment) => {
164
+ try {
165
+ return resolveApp(segment(req.endpoint), soleApp);
166
+ }
167
+ catch {
168
+ return undefined; // unresolved {app} placeholder — nothing to query
169
+ }
170
+ };
171
+ // Reverse maps: a live resource's natural key → the chant entity that declares
172
+ // it, so a declared+live resource keys back to the entity a re-apply touches;
173
+ // an undeclared live resource falls through to its natural key (an orphan).
174
+ const appEntityByName = new Map();
175
+ for (const [entityName, req] of appReqs)
176
+ appEntityByName.set(appNameFromRequest(req), entityName);
177
+ const machineEntity = new Map(); // `${app} ${name}` → entityName
178
+ const volumeEntity = new Map(); // `${app} ${name}`
179
+ const ipEntity = new Map(); // `${app} ${family}`
180
+ const certEntity = new Map(); // `${app} ${hostname}`
181
+ const appsToQuery = new Set(appNames);
182
+ /** An entity whose owning app can't be resolved is never queried — a hole, not an absence (#1089). */
183
+ const unresolvedApp = (entityName) => {
184
+ const type = options.entities.get(entityName)?.entityType;
185
+ unobserved[entityName] = {
186
+ ...(type ? { type } : {}),
187
+ reason: "read-failed",
188
+ detail: "owning app could not be resolved from the plan (unresolved app placeholder)",
189
+ };
190
+ };
191
+ for (const [entityName, req] of machineReqs) {
192
+ const app = owningApp(req, machineAppSegment);
193
+ if (!app) {
194
+ unresolvedApp(entityName);
195
+ continue;
196
+ }
197
+ appsToQuery.add(app);
198
+ machineEntity.set(`${app} ${serializedName(req, entityName)}`, entityName);
199
+ }
200
+ for (const [entityName, req] of volumeReqs) {
201
+ const app = owningApp(req, resourceAppSegment);
202
+ if (!app) {
203
+ unresolvedApp(entityName);
204
+ continue;
205
+ }
206
+ appsToQuery.add(app);
207
+ volumeEntity.set(`${app} ${serializedName(req, entityName)}`, entityName);
208
+ }
209
+ for (const [entityName, req] of ipReqs) {
210
+ const app = owningApp(req, resourceAppSegment);
211
+ if (!app) {
212
+ unresolvedApp(entityName);
213
+ continue;
214
+ }
215
+ appsToQuery.add(app);
216
+ ipEntity.set(`${app} ${declaredIpType(req.body.type)}`, entityName);
217
+ }
218
+ for (const [entityName, req] of certReqs) {
219
+ const app = owningApp(req, resourceAppSegment);
220
+ if (!app) {
221
+ unresolvedApp(entityName);
222
+ continue;
223
+ }
224
+ appsToQuery.add(app);
225
+ certEntity.set(`${app} ${String(req.body.hostname ?? "")}`, entityName);
226
+ }
227
+ /**
228
+ * The `owned` filter withholds a live resource; it never proves one absent
229
+ * (#1089). When the withheld resource is one a chant entity declares, record
230
+ * the hole — dropping it silently is what turned "exists, but foreign" into a
231
+ * proposed create. An undeclared live resource has no declared axis to
232
+ * misreport, so it is simply not returned.
233
+ */
234
+ const withheld = (entityName, detail) => {
235
+ const declaredType = entityName ? options.entities.get(entityName)?.entityType : undefined;
236
+ if (!entityName || !declaredType)
237
+ return;
238
+ unobserved[entityName] = { type: declaredType, reason: "filtered", detail };
239
+ };
240
+ /** Declared entities of one app whose breadth read never completed. */
241
+ const breadthEntitiesOf = (app) => {
242
+ const prefix = `${app} `;
243
+ const names = [];
244
+ for (const map of [volumeEntity, ipEntity, certEntity]) {
245
+ for (const [key, entityName] of map)
246
+ if (key.startsWith(prefix))
247
+ names.push(entityName);
248
+ }
249
+ return names;
250
+ };
251
+ // The app-boundary log fires at most once per call, and only when the `owned`
252
+ // filter reaches the metadata-less types (mirrors aws's degrade-to-log line).
253
+ let loggedBoundary = false;
254
+ const noteBoundaryInference = () => {
255
+ if (loggedBoundary || !owned)
256
+ return;
257
+ loggedBoundary = true;
258
+ console.warn("[fly] volumes/ip_assignments/certificates carry no per-resource ownership marker — ownership is inferred at the app boundary (#741/#743); only resources under a chant-managed app are reported owned");
259
+ };
260
+ for (const app of appsToQuery) {
261
+ // One machine list per app: it drives both the machine verdicts and the
262
+ // app-boundary decision (an app is chant-managed when any live machine is).
263
+ const machines = (await listMachines(ctx, app, http, signal)).filter((m) => !TERMINAL_STATES.has(m.state));
264
+ const appManaged = machines.some((m) => isChantOwned(m.config?.metadata));
265
+ // App resource. No per-resource marker; the app-boundary evidence is its own
266
+ // chant-owned machines. Absent that, ownership is left unset (unknown), so
267
+ // the change set never proposes deleting an app it can't prove is chant's.
268
+ const appRes = await http("GET", `${ctx.base}/v1/apps/${encodeURIComponent(app)}`, undefined, undefined, signal);
269
+ if (appRes.status === 200) {
270
+ const ownership = appManaged ? "owned" : undefined;
271
+ if (owned && ownership !== "owned") {
272
+ withheld(appEntityByName.get(app), "app has no chant-owned machine to prove ownership and --owned was requested");
273
+ }
274
+ else {
275
+ const entityName = appEntityByName.get(app) ?? app;
276
+ result[entityName] = {
277
+ type: typeOf(entityName, TYPE.app),
278
+ physicalId: app,
279
+ status: "deployed",
280
+ ownership,
281
+ attributes: { app },
282
+ };
283
+ }
284
+ }
285
+ // Machines: precise per-marker verdict.
286
+ for (const m of machines) {
287
+ const isOwned = isChantOwned(m.config?.metadata);
288
+ if (owned && !isOwned) {
289
+ // Withheld by the owned filter — live and foreign, which is not absent.
290
+ withheld(machineEntity.get(`${app} ${m.name}`), "live machine carries no chant ownership marker and --owned was requested");
291
+ continue;
292
+ }
293
+ const entityName = machineEntity.get(`${app} ${m.name}`) ?? m.name;
294
+ result[entityName] = {
295
+ type: typeOf(entityName, TYPE.machine),
296
+ physicalId: m.id,
297
+ status: m.state,
298
+ ownership: isOwned ? "owned" : "foreign",
299
+ // Marker identity (#1222): machines are the one fly type carrying the
300
+ // metadata channel, so only they surface stack/env. Apps and the
301
+ // boundary types (volumes/ips/certs) get their verdict inferred at the
302
+ // app boundary and carry no marker of their own — absent, not guessed.
303
+ marker: readOwnership(m.config?.metadata, FLY_METADATA_OWNERSHIP_KEYS),
304
+ // The release the Machine serves (#2736): `digest` is the identity
305
+ // attribute core's status join compares with the release ledger
306
+ // (LIVE_IDENTITY_ATTRIBUTES); `release` carries the rest of what its
307
+ // metadata records. The commit is reported inside `release`, not as
308
+ // the `gitSha` identity: a rollback serves an earlier commit under the
309
+ // digest the ledger records again, so only the digest is the join.
310
+ attributes: pruneUndefined({
311
+ app,
312
+ machineName: m.name,
313
+ instanceId: m.instance_id,
314
+ config: m.config,
315
+ ...releaseAttributes(m.config?.metadata),
316
+ }),
317
+ };
318
+ }
319
+ // ── Metadata-less breadth types (#741): app-boundary ownership. ──────────
320
+ // Under a chant-managed app they are owned; otherwise ownership is unknown
321
+ // and, under the `owned` filter, they are dropped (with a one-time note).
322
+ const boundaryOwnership = appManaged ? "owned" : undefined;
323
+ try {
324
+ for (const v of await listVolumes(ctx, app, http, signal)) {
325
+ if (owned && boundaryOwnership !== "owned") {
326
+ noteBoundaryInference();
327
+ withheld(volumeEntity.get(`${app} ${v.name}`), "volume sits under an app with no chant-owned machine and --owned was requested");
328
+ continue;
329
+ }
330
+ if (owned)
331
+ noteBoundaryInference();
332
+ const entityName = volumeEntity.get(`${app} ${v.name}`) ?? v.name;
333
+ result[entityName] = {
334
+ type: typeOf(entityName, TYPE.volume),
335
+ physicalId: v.id,
336
+ status: v.state ?? "present",
337
+ ownership: boundaryOwnership,
338
+ attributes: pruneUndefined({ app, volumeName: v.name }),
339
+ };
340
+ }
341
+ for (const ip of await listIps(ctx, app, http, signal)) {
342
+ const family = ipType(ip.shared, ip.ip);
343
+ if (owned && boundaryOwnership !== "owned") {
344
+ noteBoundaryInference();
345
+ withheld(ipEntity.get(`${app} ${family}`), "ip sits under an app with no chant-owned machine and --owned was requested");
346
+ continue;
347
+ }
348
+ if (owned)
349
+ noteBoundaryInference();
350
+ const entityName = ipEntity.get(`${app} ${family}`) ?? `${app}/${family}`;
351
+ result[entityName] = {
352
+ type: typeOf(entityName, TYPE.ip),
353
+ physicalId: ip.ip,
354
+ status: "present",
355
+ ownership: boundaryOwnership,
356
+ attributes: pruneUndefined({ app, family, address: ip.ip }),
357
+ };
358
+ }
359
+ for (const c of await listCerts(ctx, app, http, signal)) {
360
+ if (owned && boundaryOwnership !== "owned") {
361
+ noteBoundaryInference();
362
+ withheld(certEntity.get(`${app} ${c.hostname}`), "certificate sits under an app with no chant-owned machine and --owned was requested");
363
+ continue;
364
+ }
365
+ if (owned)
366
+ noteBoundaryInference();
367
+ const entityName = certEntity.get(`${app} ${c.hostname}`) ?? c.hostname;
368
+ result[entityName] = {
369
+ type: typeOf(entityName, TYPE.cert),
370
+ physicalId: c.hostname,
371
+ status: "present",
372
+ ownership: boundaryOwnership,
373
+ attributes: pruneUndefined({ app, hostname: c.hostname }),
374
+ };
375
+ }
376
+ }
377
+ catch (err) {
378
+ // A breadth endpoint that is unreachable on this target leaves the diff to
379
+ // machines + apps rather than failing the whole read — but the volumes,
380
+ // ips and certs it would have reported were never read, so they are holes
381
+ // rather than absences (#1089).
382
+ const detail = `flaps breadth read failed for app "${app}": ${err instanceof Error ? err.message : String(err)}`;
383
+ for (const entityName of breadthEntitiesOf(app)) {
384
+ if (result[entityName])
385
+ continue; // already read before the failure
386
+ const type = options.entities.get(entityName)?.entityType;
387
+ unobserved[entityName] = { ...(type ? { type } : {}), reason: "read-failed", detail };
388
+ }
389
+ }
390
+ }
391
+ // Present wins: an entity that was read is never also a hole.
392
+ for (const name of Object.keys(result))
393
+ delete unobserved[name];
394
+ return observation(result, unobserved);
395
+ }
396
+ /**
397
+ * Read-only plan (#767 §4): a `flyApply` dry-run. Reads live state via
398
+ * {@link describeResources}, promotes it to core's typed change set, then
399
+ * refines each declared+live machine's update/noop verdict with the applier's
400
+ * own `configEqual`, so the plan matches exactly what a subsequent `flyApply`
401
+ * would do — never mutating. `renderChangeSet` gives the human view.
402
+ *
403
+ * The core change set already computes create/delete/adopt/noop from
404
+ * declared-vs-live + ownership; the `configEqual` refinement adds the
405
+ * declared-vs-live *config* comparison for machines (core's own update signal is
406
+ * snapshot-relative, which a stateless dry-run has no snapshot for).
407
+ */
408
+ export async function flyPlan(options, http = defaultFlyHttp(), signal) {
409
+ const observed = normalizeObservation(await describeResources(options, http, signal));
410
+ const observedNow = observed.resources;
411
+ const declared = new Set(options.entities.keys());
412
+ const cs = buildChangeSet(options.environment, {
413
+ declared,
414
+ observedNow,
415
+ observedThen: options.observedThen,
416
+ // Entities flaps could not be asked about stay `unobserved` in the plan
417
+ // rather than being proposed as creates (#1089).
418
+ unobserved: observed.unobserved,
419
+ });
420
+ // Declared config per entity, for the machine update/noop refinement.
421
+ const declaredConfig = new Map();
422
+ try {
423
+ for (const [entityName, req] of Object.entries(parsePlan(options.buildOutput))) {
424
+ if (isMachineRequest(req))
425
+ declaredConfig.set(entityName, req.body.config);
426
+ }
427
+ }
428
+ catch {
429
+ // no plan to refine against
430
+ }
431
+ for (const entry of cs.entries) {
432
+ if (entry.action !== "noop")
433
+ continue;
434
+ const declaredCfg = declaredConfig.get(entry.name);
435
+ if (declaredCfg === undefined)
436
+ continue; // not a declared machine
437
+ const liveCfg = observedNow[entry.name]?.attributes?.config;
438
+ if (!configEqual(declaredCfg, liveCfg)) {
439
+ entry.action = "update";
440
+ }
441
+ }
442
+ return { changeSet: cs, rendered: renderChangeSet(cs) };
443
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Template detection for the fly lexicon.
3
+ *
4
+ * `chant import` parses a template file as JSON and asks each installed lexicon
5
+ * whether it recognizes the shape. fly's importable source is flaps JSON — the
6
+ * Machines API surface the #738 serializer emits and the applier POSTs. Three
7
+ * shapes are recognized:
8
+ *
9
+ * 1. The serializer plan: an object keyed by entity name whose values are
10
+ * `{ endpoint, method, body }` flaps requests (what #738 produces).
11
+ * 2. A machine, a machines listing, or an app-with-machines bundle: what
12
+ * `GET /v1/apps/{app}/machines` returns, or a single machine create body.
13
+ * 3. An app: a create body (`{ app_name }`) or the `GET /v1/apps/{app}` shape.
14
+ */
15
+ function isRecord(v) {
16
+ return typeof v === "object" && v !== null && !Array.isArray(v);
17
+ }
18
+ /** A single flaps request as the serializer emits it. */
19
+ function isFlapsRequest(v) {
20
+ if (!isRecord(v))
21
+ return false;
22
+ return typeof v.endpoint === "string" && v.endpoint.startsWith("/v1/apps") && "body" in v;
23
+ }
24
+ /** The serializer's whole output: entity name → flaps request. */
25
+ export function isSerializerPlan(v) {
26
+ if (!isRecord(v))
27
+ return false;
28
+ const values = Object.values(v);
29
+ return values.length > 0 && values.every(isFlapsRequest);
30
+ }
31
+ /** A live or create-body machine: carries a `config` object, or the id+state+region GET shape. */
32
+ export function isMachineObject(v) {
33
+ if (!isRecord(v))
34
+ return false;
35
+ if (isRecord(v.config))
36
+ return true;
37
+ return typeof v.id === "string" && typeof v.state === "string" && "region" in v;
38
+ }
39
+ /** An app: a create body (`app_name`) or the `GET /v1/apps/{app}` shape (`name` + machine_count/organization/status). */
40
+ export function isAppObject(v) {
41
+ if (!isRecord(v))
42
+ return false;
43
+ if (typeof v.app_name === "string")
44
+ return true;
45
+ return (typeof v.name === "string" &&
46
+ ("machine_count" in v || "organization" in v || typeof v.status === "string"));
47
+ }
48
+ export function detectTemplate(data) {
49
+ if (isSerializerPlan(data))
50
+ return true;
51
+ if (Array.isArray(data)) {
52
+ return data.length > 0 && data.some(isMachineObject);
53
+ }
54
+ if (isRecord(data)) {
55
+ if (Array.isArray(data.machines))
56
+ return true;
57
+ if (isMachineObject(data))
58
+ return true;
59
+ if (isAppObject(data))
60
+ return true;
61
+ }
62
+ return false;
63
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Live export for the fly lexicon — implements `LexiconPlugin.exportResources()`
3
+ * so `chant import --from <fly-env>` regenerates existing Fly apps and machines
4
+ * as chant TypeScript.
5
+ *
6
+ * Reads live flaps state and hands it to the pure `./import/live-export`
7
+ * helpers, which strip server-written fields to the authored shape and map it to
8
+ * import IR via `FlyParser`. Endpoint + auth reuse the applier verbatim
9
+ * (`resolveEndpoint` / `defaultFlyHttp` → FLY_FLAPS_BASE_URL / FLY_API_TOKEN),
10
+ * and the machine listing reuses the applier's `listMachines`, so export reads
11
+ * the same target `flyApply` writes and `describeResources` observes.
12
+ *
13
+ * Which apps are exported: an app named by the selector, else `FLY_APP_NAME`,
14
+ * else every app in the org (`GET /v1/apps?org_slug=<FLY_ORG>`). All flaps I/O
15
+ * lives here; cleaning and IR-building are pure in `./import/live-export`.
16
+ */
17
+ import { resolveEndpoint, defaultFlyHttp, listMachines, } from "./op/activities/fly-apply.js";
18
+ import { buildExportFromApp } from "./import/live-export.js";
19
+ const DEFAULT_ORG = "personal";
20
+ function parseJson(text) {
21
+ try {
22
+ return JSON.parse(text);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ function orgSlug() {
29
+ return process.env.FLY_ORG || process.env.FLY_ORG_SLUG || DEFAULT_ORG;
30
+ }
31
+ /** Resolve which apps to export: selector name → FLY_APP_NAME → org listing. */
32
+ async function resolveApps(ctx, selector, http, signal) {
33
+ if (selector?.name)
34
+ return [selector.name];
35
+ if (process.env.FLY_APP_NAME)
36
+ return [process.env.FLY_APP_NAME];
37
+ const res = await http("GET", `${ctx.base}/v1/apps?org_slug=${encodeURIComponent(orgSlug())}`, undefined, undefined, signal);
38
+ if (res.status >= 300)
39
+ return [];
40
+ const body = parseJson(res.text);
41
+ const apps = Array.isArray(body)
42
+ ? body
43
+ : body && typeof body === "object" && Array.isArray(body.apps)
44
+ ? body.apps
45
+ : [];
46
+ return apps
47
+ .map((a) => (a && typeof a === "object" ? a.name : undefined))
48
+ .filter((n) => typeof n === "string");
49
+ }
50
+ export async function exportResources(options, http = defaultFlyHttp(), signal) {
51
+ const ctx = { base: resolveEndpoint({}) };
52
+ const apps = await resolveApps(ctx, options.selector, http, signal);
53
+ if (apps.length === 0)
54
+ return { resources: [], parameters: [] };
55
+ // The app-boundary log fires at most once per call, only under `owned`, when
56
+ // the marker-less app type is reached (mirrors describe-resources).
57
+ let loggedBoundary = false;
58
+ const onBoundaryInference = () => {
59
+ if (loggedBoundary)
60
+ return;
61
+ loggedBoundary = true;
62
+ console.warn("[fly] apps carry no per-resource ownership marker — ownership is inferred at the app boundary (#741/#743); only apps with a chant-managed machine are exported under --owned");
63
+ };
64
+ const resources = [];
65
+ for (const app of apps) {
66
+ const appRes = await http("GET", `${ctx.base}/v1/apps/${encodeURIComponent(app)}`, undefined, undefined, signal);
67
+ const appBody = appRes.status === 200 ? parseJson(appRes.text) : undefined;
68
+ const appObj = appBody && typeof appBody === "object" && !Array.isArray(appBody)
69
+ ? appBody
70
+ : { name: app };
71
+ const machines = await listMachines(ctx, app, http, signal);
72
+ const ir = buildExportFromApp(appObj, machines, {
73
+ verbatim: options.verbatim,
74
+ selector: options.selector,
75
+ owned: options.owned,
76
+ onBoundaryInference,
77
+ });
78
+ resources.push(...ir.resources);
79
+ }
80
+ return { resources, parameters: [] };
81
+ }
@@ -0,0 +1,44 @@
1
+ // Code generated by chant generate. DO NOT EDIT.
2
+ import { createResource, createProperty } from "./runtime.js";
3
+ export const App = /* @__PURE__ */ createResource("Fly::Machines::App", "fly", { "id": "id", "internal_numeric_id": "internal_numeric_id", "machine_count": "machine_count", "network_cidr": "network_cidr", "organization": "organization", "status": "status", "volume_count": "volume_count" });
4
+ export const Certificate = /* @__PURE__ */ createResource("Fly::Machines::Certificate", "fly", { "acme_requested": "acme_requested", "certificates": "certificates", "configured": "configured", "dns_provider": "dns_provider", "dns_requirements": "dns_requirements", "rate_limited_until": "rate_limited_until", "status": "status", "validation": "validation", "validation_errors": "validation_errors" });
5
+ export const IPAddress = /* @__PURE__ */ createResource("Fly::Machines::IPAddress", "fly", { "created_at": "created_at", "egress": "egress", "ip": "ip", "shared": "shared" });
6
+ export const Machine = /* @__PURE__ */ createResource("Fly::Machines::Machine", "fly", { "checks": "checks", "cordoned": "cordoned", "created_at": "created_at", "events": "events", "host_features": "host_features", "host_status": "host_status", "id": "id", "image_ref": "image_ref", "incomplete_config": "incomplete_config", "instance_id": "instance_id", "lease": "lease", "nonce": "nonce", "private_ip": "private_ip", "state": "state", "updated_at": "updated_at", "version": "version" });
7
+ export const Secret = /* @__PURE__ */ createResource("Fly::Machines::Secret", "fly", { "created_at": "created_at", "digest": "digest", "updated_at": "updated_at" });
8
+ export const Volume = /* @__PURE__ */ createResource("Fly::Machines::Volume", "fly", { "attached_alloc_id": "attached_alloc_id", "attached_machine_id": "attached_machine_id", "block_size": "block_size", "blocks": "blocks", "blocks_avail": "blocks_avail", "blocks_free": "blocks_free", "bytes_total": "bytes_total", "bytes_used": "bytes_used", "created_at": "created_at", "host_features": "host_features", "host_status": "host_status", "id": "id", "required_host_features": "required_host_features", "state": "state", "type": "type", "zone": "zone" });
9
+ export const ContainerConfig = /* @__PURE__ */ createProperty("Fly::Machines::ContainerConfig", "fly");
10
+ export const ContainerDependency = /* @__PURE__ */ createProperty("Fly::Machines::ContainerDependency", "fly");
11
+ export const ContainerHealthcheck = /* @__PURE__ */ createProperty("Fly::Machines::ContainerHealthcheck", "fly");
12
+ export const DNSConfig = /* @__PURE__ */ createProperty("Fly::Machines::DNSConfig", "fly");
13
+ export const DnsForwardRule = /* @__PURE__ */ createProperty("Fly::Machines::DnsForwardRule", "fly");
14
+ export const DnsOption = /* @__PURE__ */ createProperty("Fly::Machines::DnsOption", "fly");
15
+ export const EnvFrom = /* @__PURE__ */ createProperty("Fly::Machines::EnvFrom", "fly");
16
+ export const ExecHealthcheck = /* @__PURE__ */ createProperty("Fly::Machines::ExecHealthcheck", "fly");
17
+ export const File = /* @__PURE__ */ createProperty("Fly::Machines::File", "fly");
18
+ export const HTTPHealthcheck = /* @__PURE__ */ createProperty("Fly::Machines::HTTPHealthcheck", "fly");
19
+ export const HTTPOptions = /* @__PURE__ */ createProperty("Fly::Machines::HTTPOptions", "fly");
20
+ export const HTTPResponseOptions = /* @__PURE__ */ createProperty("Fly::Machines::HTTPResponseOptions", "fly");
21
+ export const MachineCacheDrive = /* @__PURE__ */ createProperty("Fly::Machines::MachineCacheDrive", "fly");
22
+ export const MachineCheck = /* @__PURE__ */ createProperty("Fly::Machines::MachineCheck", "fly");
23
+ export const MachineConfig = /* @__PURE__ */ createProperty("Fly::Machines::MachineConfig", "fly");
24
+ export const MachineGuest = /* @__PURE__ */ createProperty("Fly::Machines::MachineGuest", "fly");
25
+ export const MachineHTTPHeader = /* @__PURE__ */ createProperty("Fly::Machines::MachineHTTPHeader", "fly");
26
+ export const MachineInit = /* @__PURE__ */ createProperty("Fly::Machines::MachineInit", "fly");
27
+ export const MachineMetrics = /* @__PURE__ */ createProperty("Fly::Machines::MachineMetrics", "fly");
28
+ export const MachineMount = /* @__PURE__ */ createProperty("Fly::Machines::MachineMount", "fly");
29
+ export const MachinePort = /* @__PURE__ */ createProperty("Fly::Machines::MachinePort", "fly");
30
+ export const MachineProcess = /* @__PURE__ */ createProperty("Fly::Machines::MachineProcess", "fly");
31
+ export const MachineRestart = /* @__PURE__ */ createProperty("Fly::Machines::MachineRestart", "fly");
32
+ export const MachineRootfs = /* @__PURE__ */ createProperty("Fly::Machines::MachineRootfs", "fly");
33
+ export const MachineSecret = /* @__PURE__ */ createProperty("Fly::Machines::MachineSecret", "fly");
34
+ export const MachineService = /* @__PURE__ */ createProperty("Fly::Machines::MachineService", "fly");
35
+ export const MachineServiceCheck = /* @__PURE__ */ createProperty("Fly::Machines::MachineServiceCheck", "fly");
36
+ export const MachineServiceConcurrency = /* @__PURE__ */ createProperty("Fly::Machines::MachineServiceConcurrency", "fly");
37
+ export const MachineSpot = /* @__PURE__ */ createProperty("Fly::Machines::MachineSpot", "fly");
38
+ export const ProxyProtoOptions = /* @__PURE__ */ createProperty("Fly::Machines::ProxyProtoOptions", "fly");
39
+ export const ReplayCache = /* @__PURE__ */ createProperty("Fly::Machines::ReplayCache", "fly");
40
+ export const Static = /* @__PURE__ */ createProperty("Fly::Machines::Static", "fly");
41
+ export const StopConfig = /* @__PURE__ */ createProperty("Fly::Machines::StopConfig", "fly");
42
+ export const TCPHealthcheck = /* @__PURE__ */ createProperty("Fly::Machines::TCPHealthcheck", "fly");
43
+ export const TLSOptions = /* @__PURE__ */ createProperty("Fly::Machines::TLSOptions", "fly");
44
+ // Re-exports for convenience
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Runtime factory constructors — re-exported from core.
3
+ */
4
+ export { createResource, createProperty } from "@intentius/chant/runtime";