@intentius/chant-lexicon-fly 0.124.31 → 0.124.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +143 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +38 -0
- package/dist/codegen/generate-typescript.js +45 -0
- package/dist/codegen/generate.js +82 -0
- package/dist/codegen/naming.js +33 -0
- package/dist/codegen/package.js +35 -0
- package/dist/components/capability-plugin.js +26 -0
- package/dist/components/fly-release.js +335 -0
- package/dist/components/index.js +10 -0
- package/dist/components/run-agent.js +133 -0
- package/dist/composites/catalog.js +176 -0
- package/dist/composites/fly-deploy.js +91 -0
- package/dist/composites/fly-otel-collector.js +66 -0
- package/dist/composites/fly-site.js +80 -0
- package/dist/coverage.js +29 -0
- package/dist/describe-resources.js +443 -0
- package/dist/detect.js +63 -0
- package/dist/export-resources.js +81 -0
- package/dist/generated/index.js +44 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/import/generator.js +208 -0
- package/dist/import/live-export.js +81 -0
- package/dist/import/parser.js +157 -0
- package/dist/index.js +40 -0
- package/dist/integrity.json +3 -3
- package/dist/lint/audit-catalog.js +28 -0
- package/dist/lint/audit-lineage.js +5 -0
- package/dist/lint/post-synth/fly-helpers.js +24 -0
- package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
- package/dist/lint/post-synth/index.js +6 -0
- package/dist/lint/rules/guest-sizing.js +92 -0
- package/dist/lint/rules/index.js +12 -0
- package/dist/lint/rules/no-secret-literals.js +58 -0
- package/dist/lint/rules/valid-region.js +52 -0
- package/dist/lsp/completions.js +12 -0
- package/dist/lsp/hover.js +12 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +113 -0
- package/dist/okf/types/Machine.md +1 -1
- package/dist/op/activities/box-services.js +88 -0
- package/dist/op/activities/emulator-images.js +32 -0
- package/dist/op/activities/flaps.js +34 -0
- package/dist/op/activities/fly-apply.js +911 -0
- package/dist/op/activities/fly-release-step.js +21 -0
- package/dist/op/activities/fly-rollback-step.js +22 -0
- package/dist/op/activities/index.js +53 -0
- package/dist/op/activities/machine-release.js +236 -0
- package/dist/op/activities/machines-contract.js +81 -0
- package/dist/op/activities/machines-fake.js +203 -0
- package/dist/op/activities/machines-local-cli.js +85 -0
- package/dist/op/activities/machines-local.js +395 -0
- package/dist/op/activities/progress.js +10 -0
- package/dist/op/activities/sprite-config.js +409 -0
- package/dist/op/activities/sprite-fs.js +131 -0
- package/dist/op/activities/sprite-service-converge.js +224 -0
- package/dist/op/activities/sprite-services.js +174 -0
- package/dist/op/activities/sprite-tasks.js +57 -0
- package/dist/op/activities/sprites-contract.js +87 -0
- package/dist/op/activities/sprites-emulator.js +72 -0
- package/dist/op/activities/sprites-fake.js +547 -0
- package/dist/op/activities/sprites.js +515 -0
- package/dist/op/activity-contracts.js +82 -0
- package/dist/op/builders.js +157 -0
- package/dist/ownership.js +29 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +298 -0
- package/dist/pseudo.js +26 -0
- package/dist/reference-catalog.js +37 -0
- package/dist/release-metadata.js +59 -0
- package/dist/release-store.js +46 -0
- package/dist/serializer.js +299 -0
- package/dist/spec/fetch.js +32 -0
- package/dist/spec/parse.js +260 -0
- package/dist/teardown.js +194 -0
- package/dist/types/index.d.ts +33 -2
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +33 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- 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
|