@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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a Fly release's Machine config is kept, so a rollback can put it back
|
|
3
|
+
* (#2736, ws-056).
|
|
4
|
+
*
|
|
5
|
+
* The Machine's metadata says which release it serves and which one it
|
|
6
|
+
* replaced; the config that earlier release applied is kept on the
|
|
7
|
+
* `chant/lifecycle` branch beside the release ledger, one file per release at
|
|
8
|
+
* `<env>/fly/<app>/<machine>/<digest>.json`. It is written through core's
|
|
9
|
+
* ledger plumbing, so a workspace member's copy lands under its own prefix,
|
|
10
|
+
* and every checkout that fetches the branch can roll back.
|
|
11
|
+
*
|
|
12
|
+
* Not exported from the package entry point: it spawns git, which stays off
|
|
13
|
+
* the build path. The `fly-release` and `fly-rollback` capabilities import it.
|
|
14
|
+
*/
|
|
15
|
+
import { readBlobFromPath, writeBlobToPath } from "@intentius/chant/lifecycle/git";
|
|
16
|
+
const safe = (s) => s.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
17
|
+
/** The file a release's Machine config is kept in, under the environment's ledger directory. */
|
|
18
|
+
export function machineConfigFile(ref) {
|
|
19
|
+
return `fly/${safe(ref.app)}/${safe(ref.machine)}/${safe(ref.digest.replace(/^sha256:/, ""))}.json`;
|
|
20
|
+
}
|
|
21
|
+
/** The lifecycle-branch store for `environment`. */
|
|
22
|
+
export function lifecycleMachineConfigStore(environment, opts) {
|
|
23
|
+
return {
|
|
24
|
+
async read(ref) {
|
|
25
|
+
const content = await readBlobFromPath(environment, machineConfigFile(ref), opts);
|
|
26
|
+
return content === null ? undefined : JSON.parse(content);
|
|
27
|
+
},
|
|
28
|
+
async write(ref, config) {
|
|
29
|
+
await writeBlobToPath(environment, machineConfigFile(ref), `${JSON.stringify(config, null, 2)}\n`, `Fly release config ${ref.app}/${ref.machine} ${ref.digest}`, opts);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** An in-memory store, for tests and for a caller with nowhere to keep configs. */
|
|
34
|
+
export function memoryMachineConfigStore() {
|
|
35
|
+
const entries = new Map();
|
|
36
|
+
return {
|
|
37
|
+
entries,
|
|
38
|
+
async read(ref) {
|
|
39
|
+
const found = entries.get(machineConfigFile(ref));
|
|
40
|
+
return found ? structuredClone(found) : undefined;
|
|
41
|
+
},
|
|
42
|
+
async write(ref, config) {
|
|
43
|
+
entries.set(machineConfigFile(ref), structuredClone(config));
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fly (Machines API / "flaps") serializer.
|
|
3
|
+
*
|
|
4
|
+
* Turns declared `App`, `Machine`, `Volume`, `IPAddress`, `Certificate`, and
|
|
5
|
+
* `Secret` resources into the JSON create bodies the flaps REST API accepts, so
|
|
6
|
+
* #739's applier can POST them straight through and mudflaps (#740) can
|
|
7
|
+
* round-trip them.
|
|
8
|
+
*
|
|
9
|
+
* Output shape — a JSON object keyed by entity name. Each value is a single
|
|
10
|
+
* flaps request:
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* "<entityName>": {
|
|
14
|
+
* "endpoint": "/v1/apps", // or /v1/apps/{app}/machines
|
|
15
|
+
* "method": "POST",
|
|
16
|
+
* "body": { ... } // the create body
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Field names are taken from mudflaps' Go structs (the wire oracle), which win
|
|
21
|
+
* over the OpenAPI-generated TypeScript names:
|
|
22
|
+
* - App → CreateAppRequest `{ app_name, org_slug? }` (NOT `name`).
|
|
23
|
+
* - Machine→ CreateMachineRequest `{ name?, region?, config?, skip_launch? }`;
|
|
24
|
+
* `config` is the full MachineConfig. The owning app is a URL path segment,
|
|
25
|
+
* not a body field, so it lands in `endpoint`.
|
|
26
|
+
*
|
|
27
|
+
* D2: every serialized Machine carries the `managed-by: chant` ownership marker
|
|
28
|
+
* (plus the stack/env identity from `context.ownership`) merged into
|
|
29
|
+
* `config.metadata`, even when the user supplied no metadata.
|
|
30
|
+
*/
|
|
31
|
+
import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
32
|
+
import { ownershipEntries, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
33
|
+
import { walkValue } from "@intentius/chant/serializer-walker";
|
|
34
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
35
|
+
import { mergeResourceAttributes, telemetryEnvironment } from "@intentius/chant/telemetry-attribution";
|
|
36
|
+
import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership.js";
|
|
37
|
+
const APP_ENTITY_TYPE = "Fly::Machines::App";
|
|
38
|
+
const MACHINE_ENTITY_TYPE = "Fly::Machines::Machine";
|
|
39
|
+
const VOLUME_ENTITY_TYPE = "Fly::Machines::Volume";
|
|
40
|
+
const IP_ENTITY_TYPE = "Fly::Machines::IPAddress";
|
|
41
|
+
const CERTIFICATE_ENTITY_TYPE = "Fly::Machines::Certificate";
|
|
42
|
+
const SECRET_ENTITY_TYPE = "Fly::Machines::Secret";
|
|
43
|
+
/**
|
|
44
|
+
* Visitor for the generic serializer walker. Property declarables (MachineConfig,
|
|
45
|
+
* MachineGuest, MachineService, ...) are unwrapped to plain objects; resource
|
|
46
|
+
* references resolve to their logical name.
|
|
47
|
+
*/
|
|
48
|
+
function flyVisitor() {
|
|
49
|
+
return {
|
|
50
|
+
attrRef: (name) => name,
|
|
51
|
+
resourceRef: (name) => name,
|
|
52
|
+
propertyDeclarable: (entity, walk) => {
|
|
53
|
+
const props = isResourceDeclarable(entity) ? entity.props : undefined;
|
|
54
|
+
if (!props || typeof props !== "object")
|
|
55
|
+
return undefined;
|
|
56
|
+
const result = {};
|
|
57
|
+
for (const [key, value] of Object.entries(props)) {
|
|
58
|
+
if (value !== undefined)
|
|
59
|
+
result[key] = walk(value);
|
|
60
|
+
}
|
|
61
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function readProps(entity) {
|
|
66
|
+
const props = isResourceDeclarable(entity) ? entity.props : undefined;
|
|
67
|
+
return props && typeof props === "object" ? props : {};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Pseudo-parameter → environment variable mapping. flaps create bodies are
|
|
71
|
+
* plain JSON, so `Fly.Region` / `Fly.OrgSlug` / `Fly.AppName` (which the walker
|
|
72
|
+
* lowers to a `{ Ref: "Fly::..." }` marker) are resolved from the environment
|
|
73
|
+
* at build time, mirroring gcp's PSEUDO_ENV_MAP. The first set env var wins;
|
|
74
|
+
* absent all of them, the fallback keeps output valid offline (mudflaps).
|
|
75
|
+
*/
|
|
76
|
+
const PSEUDO_ENV_MAP = {
|
|
77
|
+
"Fly::Region": { envVars: ["FLY_REGION"], fallback: "iad" },
|
|
78
|
+
"Fly::OrgSlug": { envVars: ["FLY_ORG", "FLY_ORG_SLUG"], fallback: "personal" },
|
|
79
|
+
"Fly::AppName": { envVars: ["FLY_APP_NAME"], fallback: "app" },
|
|
80
|
+
};
|
|
81
|
+
function resolvePseudoRef(ref) {
|
|
82
|
+
const mapping = PSEUDO_ENV_MAP[ref];
|
|
83
|
+
if (!mapping)
|
|
84
|
+
return undefined;
|
|
85
|
+
for (const envVar of mapping.envVars) {
|
|
86
|
+
const value = process.env[envVar];
|
|
87
|
+
if (value)
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
return mapping.fallback;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Recursively replace pseudo-parameters with their resolved environment value.
|
|
94
|
+
* Handles both a raw `PseudoParameter` instance (any intrinsic carrying the
|
|
95
|
+
* marker, unwrapped via `toJSON()`) and the already-walked `{ Ref: "Fly::X" }`
|
|
96
|
+
* shape. Non-Fly `{ Ref }` shapes and every other value pass through untouched.
|
|
97
|
+
*/
|
|
98
|
+
function resolvePseudoParameters(value) {
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
return value.map(resolvePseudoParameters);
|
|
101
|
+
}
|
|
102
|
+
if (value && typeof value === "object") {
|
|
103
|
+
const record = value;
|
|
104
|
+
// Unwrap an intrinsic (e.g. a PseudoParameter assigned without walking) to
|
|
105
|
+
// the { Ref } envelope it serializes to, then resolve that.
|
|
106
|
+
if (INTRINSIC_MARKER in record && typeof record.toJSON === "function") {
|
|
107
|
+
return resolvePseudoParameters(record.toJSON());
|
|
108
|
+
}
|
|
109
|
+
const keys = Object.keys(record);
|
|
110
|
+
if (keys.length === 1 && keys[0] === "Ref" && typeof record.Ref === "string") {
|
|
111
|
+
const resolved = resolvePseudoRef(record.Ref);
|
|
112
|
+
if (resolved !== undefined)
|
|
113
|
+
return resolved;
|
|
114
|
+
}
|
|
115
|
+
const result = {};
|
|
116
|
+
for (const [key, val] of Object.entries(record)) {
|
|
117
|
+
result[key] = resolvePseudoParameters(val);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
/** The app_name flaps expects: an explicit `name` prop, else the entity name. */
|
|
124
|
+
function appName(entity, entityName) {
|
|
125
|
+
const name = readProps(entity).name;
|
|
126
|
+
return typeof name === "string" ? name : entityName;
|
|
127
|
+
}
|
|
128
|
+
// ── Telemetry attribution (#3060, D22) ────────────────────────────
|
|
129
|
+
/** The digest of an image reference pinned with `@sha256:...`, or undefined. */
|
|
130
|
+
function imageDigest(image) {
|
|
131
|
+
if (typeof image !== "string")
|
|
132
|
+
return undefined;
|
|
133
|
+
const at = image.lastIndexOf("@");
|
|
134
|
+
return at === -1 ? undefined : image.slice(at + 1);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Stamp one Machine's attributes as `OTEL_SERVICE_NAME` and
|
|
138
|
+
* `OTEL_RESOURCE_ATTRIBUTES` in its `config.env`, the env the Machine's
|
|
139
|
+
* process gets. `service` is the owning app's name on Fly, `decl` the
|
|
140
|
+
* Machine's declaration id. A value the Machine already sets is kept: its own
|
|
141
|
+
* `OTEL_SERVICE_NAME` wins, and its own resource attributes keep their keys
|
|
142
|
+
* while the missing ones are appended.
|
|
143
|
+
*
|
|
144
|
+
* Machine env values are literal strings, so there is no reference to the
|
|
145
|
+
* release attributes here: `fly-release` writes them into the value it
|
|
146
|
+
* deploys (#3061, ws-081).
|
|
147
|
+
*/
|
|
148
|
+
function stampTelemetry(config, service, decl, telemetry) {
|
|
149
|
+
const version = imageDigest(config.image);
|
|
150
|
+
const vars = telemetryEnvironment(telemetry, { service, decl, ...(version ? { version } : {}) });
|
|
151
|
+
const env = config.env && typeof config.env === "object" && !Array.isArray(config.env) ? { ...config.env } : {};
|
|
152
|
+
if (!("OTEL_SERVICE_NAME" in env))
|
|
153
|
+
env.OTEL_SERVICE_NAME = vars.OTEL_SERVICE_NAME;
|
|
154
|
+
const own = env.OTEL_RESOURCE_ATTRIBUTES;
|
|
155
|
+
if (own === undefined)
|
|
156
|
+
env.OTEL_RESOURCE_ATTRIBUTES = vars.OTEL_RESOURCE_ATTRIBUTES;
|
|
157
|
+
else if (typeof own === "string")
|
|
158
|
+
env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(own, vars.OTEL_RESOURCE_ATTRIBUTES);
|
|
159
|
+
config.env = env;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* fly flaps serializer.
|
|
163
|
+
*/
|
|
164
|
+
export const flySerializer = {
|
|
165
|
+
name: "fly",
|
|
166
|
+
rulePrefix: "FLY",
|
|
167
|
+
serialize(entities, _outputs, context) {
|
|
168
|
+
const entityNames = new Map();
|
|
169
|
+
for (const [name, entity] of entities)
|
|
170
|
+
entityNames.set(entity, name);
|
|
171
|
+
const visitor = flyVisitor();
|
|
172
|
+
// D2 ownership marker. `managed-by: chant` is always stamped; stack/env are
|
|
173
|
+
// added when the build threads an ownership marker through the context.
|
|
174
|
+
const ownershipMeta = context?.ownership
|
|
175
|
+
? ownershipEntries(FLY_METADATA_OWNERSHIP_KEYS, context.ownership)
|
|
176
|
+
: { [FLY_METADATA_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
|
|
177
|
+
// Resolve which app a machine belongs to (a URL path segment, not a body
|
|
178
|
+
// field). A machine may name its app explicitly via an `app` prop (a string
|
|
179
|
+
// or an `App` reference); otherwise, when the stack declares exactly one
|
|
180
|
+
// app, machines default to it. Failing both, a `{app}` placeholder is left
|
|
181
|
+
// for the applier to fill.
|
|
182
|
+
const apps = [];
|
|
183
|
+
for (const [name, entity] of entities) {
|
|
184
|
+
if (entity.entityType === APP_ENTITY_TYPE) {
|
|
185
|
+
apps.push([name, entity]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const soleApp = apps.length === 1 ? appName(apps[0][1], apps[0][0]) : undefined;
|
|
189
|
+
// The owning app of any app-scoped resource (machine, volume, ip, cert,
|
|
190
|
+
// secret): an explicit `app` prop (string or `App` reference), else the
|
|
191
|
+
// stack's sole app, else a `{app}` placeholder for the applier to fill.
|
|
192
|
+
const resolveOwningApp = (entity) => {
|
|
193
|
+
const app = readProps(entity).app;
|
|
194
|
+
if (typeof app === "string")
|
|
195
|
+
return app;
|
|
196
|
+
if (app && typeof app === "object" && "entityType" in app) {
|
|
197
|
+
const decl = app;
|
|
198
|
+
return appName(decl, entityNames.get(decl) ?? "{app}");
|
|
199
|
+
}
|
|
200
|
+
return soleApp ?? "{app}";
|
|
201
|
+
};
|
|
202
|
+
const requests = {};
|
|
203
|
+
for (const [name, entity] of entities) {
|
|
204
|
+
if (isPropertyDeclarable(entity))
|
|
205
|
+
continue;
|
|
206
|
+
const entityType = entity.entityType;
|
|
207
|
+
const props = readProps(entity);
|
|
208
|
+
if (entityType === APP_ENTITY_TYPE) {
|
|
209
|
+
const body = { app_name: appName(entity, name) };
|
|
210
|
+
if (props.org_slug !== undefined)
|
|
211
|
+
body.org_slug = props.org_slug;
|
|
212
|
+
if (props.network !== undefined)
|
|
213
|
+
body.network = props.network;
|
|
214
|
+
if (props.enable_subdomains !== undefined)
|
|
215
|
+
body.enable_subdomains = props.enable_subdomains;
|
|
216
|
+
requests[name] = { endpoint: "/v1/apps", method: "POST", body };
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (entityType === MACHINE_ENTITY_TYPE) {
|
|
220
|
+
const walkedConfig = props.config
|
|
221
|
+
? walkValue(props.config, entityNames, visitor)
|
|
222
|
+
: undefined;
|
|
223
|
+
const config = walkedConfig && typeof walkedConfig === "object"
|
|
224
|
+
? { ...walkedConfig }
|
|
225
|
+
: {};
|
|
226
|
+
// Merge the ownership marker into config.metadata. The marker wins over
|
|
227
|
+
// any colliding user key so ownership can never be silently unset.
|
|
228
|
+
const userMeta = config.metadata && typeof config.metadata === "object"
|
|
229
|
+
? config.metadata
|
|
230
|
+
: {};
|
|
231
|
+
config.metadata = { ...userMeta, ...ownershipMeta };
|
|
232
|
+
// Spread the create-body fields (name, region, skip_launch, ...) and
|
|
233
|
+
// override with the walked config. `app` is our association hint, not a
|
|
234
|
+
// flaps field, so it is dropped.
|
|
235
|
+
const body = {};
|
|
236
|
+
for (const [key, value] of Object.entries(props)) {
|
|
237
|
+
if (key === "config" || key === "app" || value === undefined)
|
|
238
|
+
continue;
|
|
239
|
+
body[key] = walkValue(value, entityNames, visitor);
|
|
240
|
+
}
|
|
241
|
+
body.config = config;
|
|
242
|
+
// Telemetry attribution (#3060): service.name is the app's name on
|
|
243
|
+
// Fly, or the Machine's export name when no app is resolved.
|
|
244
|
+
const owningApp = resolveOwningApp(entity);
|
|
245
|
+
if (context?.telemetry) {
|
|
246
|
+
stampTelemetry(config, owningApp === "{app}" ? name : owningApp, name, context.telemetry);
|
|
247
|
+
}
|
|
248
|
+
requests[name] = {
|
|
249
|
+
endpoint: `/v1/apps/${owningApp}/machines`,
|
|
250
|
+
method: "POST",
|
|
251
|
+
body,
|
|
252
|
+
};
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// ── App-scoped, metadata-less resources (#741, D2). ──────────────────
|
|
256
|
+
// Each spreads its scalar create-body props straight through; the `app`
|
|
257
|
+
// association hint is a URL segment, not a body field, so it is dropped.
|
|
258
|
+
// Ownership is at the app boundary, so none carry a metadata marker.
|
|
259
|
+
if (entityType === VOLUME_ENTITY_TYPE ||
|
|
260
|
+
entityType === IP_ENTITY_TYPE ||
|
|
261
|
+
entityType === CERTIFICATE_ENTITY_TYPE ||
|
|
262
|
+
entityType === SECRET_ENTITY_TYPE) {
|
|
263
|
+
const app = resolveOwningApp(entity);
|
|
264
|
+
if (entityType === SECRET_ENTITY_TYPE) {
|
|
265
|
+
// Apply-only (D7): name is the URL segment; `value` is the only body
|
|
266
|
+
// field. mudflaps returns just a digest, so this never enters a diff.
|
|
267
|
+
// A Secret with no value is set out of band. It is marked
|
|
268
|
+
// `mustExist`, and the applier checks for it instead of posting.
|
|
269
|
+
const secretName = typeof props.name === "string" ? props.name : name;
|
|
270
|
+
const endpoint = `/v1/apps/${app}/secrets/${encodeURIComponent(secretName)}`;
|
|
271
|
+
requests[name] =
|
|
272
|
+
props.value !== undefined
|
|
273
|
+
? { endpoint, method: "POST", body: { value: props.value }, applyOnly: true }
|
|
274
|
+
: { endpoint, method: "POST", body: {}, applyOnly: true, mustExist: true };
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// A Certificate is an ACME certificate. flaps creates those at
|
|
278
|
+
// `.../certificates/acme` and answers a bare POST to `.../certificates`
|
|
279
|
+
// with 404 (#3114).
|
|
280
|
+
const segment = entityType === VOLUME_ENTITY_TYPE
|
|
281
|
+
? "volumes"
|
|
282
|
+
: entityType === IP_ENTITY_TYPE
|
|
283
|
+
? "ip_assignments"
|
|
284
|
+
: "certificates/acme";
|
|
285
|
+
const body = {};
|
|
286
|
+
for (const [key, value] of Object.entries(props)) {
|
|
287
|
+
if (key === "app" || value === undefined)
|
|
288
|
+
continue;
|
|
289
|
+
body[key] = walkValue(value, entityNames, visitor);
|
|
290
|
+
}
|
|
291
|
+
requests[name] = { endpoint: `/v1/apps/${app}/${segment}`, method: "POST", body };
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Resolve pseudo-parameter markers (Fly.Region, Fly.OrgSlug, ...) to their
|
|
296
|
+
// environment value so create bodies carry plain strings, not `{ Ref }`.
|
|
297
|
+
return JSON.stringify(resolvePseudoParameters(requests), null, 2);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
4
|
+
/**
|
|
5
|
+
* Fly's published Machines API (flaps) OpenAPI 3.0.1 spec.
|
|
6
|
+
*
|
|
7
|
+
* This is an unversioned live endpoint: it always serves the current spec with
|
|
8
|
+
* no version or release tag to pin. There is nothing for the self-upgrade
|
|
9
|
+
* tooling (#685) to bump, so the plugin declares no `upstreamPin` — unlike gcp
|
|
10
|
+
* (`KCC_VERSION`) or k8s (`K8S_SCHEMA_VERSION`), which pin a released schema
|
|
11
|
+
* version. Re-running `generate` simply picks up whatever this URL currently
|
|
12
|
+
* serves.
|
|
13
|
+
*/
|
|
14
|
+
const SCHEMA_URL = "https://docs.machines.dev/openapi.json";
|
|
15
|
+
/**
|
|
16
|
+
* Cache under `~/.chant` (like the k8s lexicon) so CI's `~/.chant` schema
|
|
17
|
+
* cache covers it — a `docs.machines.dev` hiccup then falls back to the cache
|
|
18
|
+
* instead of failing the generate step.
|
|
19
|
+
*/
|
|
20
|
+
const CACHE_FILE = join(homedir(), ".chant", "fly-machines-openapi.json");
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the flaps OpenAPI spec and return it as a Map<typeName, Buffer>
|
|
23
|
+
* compatible with the generatePipeline fetchSchemas callback.
|
|
24
|
+
*
|
|
25
|
+
* The flaps spec is a single document, so we return a single entry keyed by
|
|
26
|
+
* "Fly::OpenAPI" — the parse step splits it into the curated resources and
|
|
27
|
+
* their reachable property types.
|
|
28
|
+
*/
|
|
29
|
+
export async function fetchSchemas(options) {
|
|
30
|
+
const raw = await fetchWithCache({ url: SCHEMA_URL, cacheFile: CACHE_FILE }, options?.force);
|
|
31
|
+
return new Map([["Fly::OpenAPI", raw]]);
|
|
32
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly Machines API OpenAPI 3.0.1 parser.
|
|
3
|
+
*
|
|
4
|
+
* The flaps spec has no resource marker and is mostly request/response DTOs,
|
|
5
|
+
* so we generate a curated set of resources rather than every schema. Each
|
|
6
|
+
* curated resource pairs a request schema (writable authoring surface) with a
|
|
7
|
+
* response schema (read-only attributes). The property types reachable from the
|
|
8
|
+
* request schemas — notably the fly.MachineConfig graph — are emitted as
|
|
9
|
+
* standalone property-type classes so `config` is fully typed.
|
|
10
|
+
*/
|
|
11
|
+
import { extractConstraints as coreExtractConstraints, primaryType, isEnumDefinition, } from "@intentius/chant/codegen/json-schema";
|
|
12
|
+
// ── Curated manifest ───────────────────────────────────────────────
|
|
13
|
+
/** The single service segment for all fly type names. */
|
|
14
|
+
const SERVICE = "Machines";
|
|
15
|
+
const RESOURCES = [
|
|
16
|
+
{ typeName: `Fly::${SERVICE}::App`, request: "CreateAppRequest", response: "App" },
|
|
17
|
+
{ typeName: `Fly::${SERVICE}::Machine`, request: "CreateMachineRequest", response: "Machine" },
|
|
18
|
+
{ typeName: `Fly::${SERVICE}::Volume`, request: "CreateVolumeRequest", response: "Volume" },
|
|
19
|
+
{ typeName: `Fly::${SERVICE}::IPAddress`, request: "assignIPRequest", response: "IPAssignment" },
|
|
20
|
+
{ typeName: `Fly::${SERVICE}::Certificate`, request: "createAcmeCertificateRequest", response: "CertificateDetail" },
|
|
21
|
+
{
|
|
22
|
+
typeName: `Fly::${SERVICE}::Secret`,
|
|
23
|
+
request: "SetAppSecretRequest",
|
|
24
|
+
response: "AppSecret",
|
|
25
|
+
pathProps: [
|
|
26
|
+
{
|
|
27
|
+
name: "name",
|
|
28
|
+
tsType: "string",
|
|
29
|
+
required: false,
|
|
30
|
+
description: "The secret's name on the app. Defaults to the declaration's export name. " +
|
|
31
|
+
"A Secret with no value is never sent: apply checks that the app already has it.",
|
|
32
|
+
constraints: {},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
const REF_PREFIX = "#/components/schemas/";
|
|
38
|
+
// ── Parser ─────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Parse the flaps OpenAPI spec into the curated resources and the property
|
|
41
|
+
* types reachable from their request schemas.
|
|
42
|
+
*/
|
|
43
|
+
export function parseFlyOpenAPI(data) {
|
|
44
|
+
const spec = JSON.parse(typeof data === "string" ? data : data.toString("utf-8"));
|
|
45
|
+
const schemas = spec.components?.schemas ?? {};
|
|
46
|
+
// Phase 1: discover the emitted property-type set — object schemas reachable
|
|
47
|
+
// (transitively) from the request schemas' properties. Enums are inlined, so
|
|
48
|
+
// they are never emitted as classes.
|
|
49
|
+
const emitted = collectPropertyTypes(schemas);
|
|
50
|
+
const className = (schemaName) => schemaToClassName(schemaName);
|
|
51
|
+
const resolve = (prop) => resolveType(prop, schemas, emitted);
|
|
52
|
+
const results = [];
|
|
53
|
+
// Phase 2: resources.
|
|
54
|
+
for (const rspec of RESOURCES) {
|
|
55
|
+
const req = schemas[rspec.request];
|
|
56
|
+
const res = schemas[rspec.response];
|
|
57
|
+
const reqProps = req?.properties ?? {};
|
|
58
|
+
const requiredSet = new Set(req?.required ?? []);
|
|
59
|
+
const properties = [];
|
|
60
|
+
for (const [name, prop] of Object.entries(reqProps)) {
|
|
61
|
+
properties.push({
|
|
62
|
+
name,
|
|
63
|
+
tsType: resolve(prop),
|
|
64
|
+
required: requiredSet.has(name),
|
|
65
|
+
description: prop.description,
|
|
66
|
+
constraints: coreExtractConstraints(prop),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const extra of rspec.pathProps ?? []) {
|
|
70
|
+
if (!(extra.name in reqProps))
|
|
71
|
+
properties.push(extra);
|
|
72
|
+
}
|
|
73
|
+
// Attributes = response props not present in the request schema.
|
|
74
|
+
const attributes = [];
|
|
75
|
+
for (const [name, prop] of Object.entries(res?.properties ?? {})) {
|
|
76
|
+
if (properties.some((p) => p.name === name))
|
|
77
|
+
continue;
|
|
78
|
+
attributes.push({ name, tsType: resolve(prop) });
|
|
79
|
+
}
|
|
80
|
+
results.push({
|
|
81
|
+
resource: { typeName: rspec.typeName, description: req?.description, properties, attributes },
|
|
82
|
+
propertyTypes: [],
|
|
83
|
+
enums: [],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Phase 3: property-type classes.
|
|
87
|
+
for (const schemaName of emitted) {
|
|
88
|
+
const def = schemas[schemaName];
|
|
89
|
+
const requiredSet = new Set(def.required ?? []);
|
|
90
|
+
const properties = [];
|
|
91
|
+
for (const [name, prop] of Object.entries(def.properties ?? {})) {
|
|
92
|
+
properties.push({
|
|
93
|
+
name,
|
|
94
|
+
tsType: resolve(prop),
|
|
95
|
+
required: requiredSet.has(name),
|
|
96
|
+
description: prop.description,
|
|
97
|
+
constraints: coreExtractConstraints(prop),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
results.push({
|
|
101
|
+
resource: {
|
|
102
|
+
typeName: `Fly::${SERVICE}::${className(schemaName)}`,
|
|
103
|
+
description: def.description,
|
|
104
|
+
properties,
|
|
105
|
+
attributes: [],
|
|
106
|
+
},
|
|
107
|
+
propertyTypes: [],
|
|
108
|
+
enums: [],
|
|
109
|
+
isProperty: true,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Walk the request schemas and collect the set of object schemas reachable
|
|
116
|
+
* through their properties (following $ref, allOf, arrays, and maps). Returns
|
|
117
|
+
* the schema names in insertion order, deduplicated across resources.
|
|
118
|
+
*/
|
|
119
|
+
function collectPropertyTypes(schemas) {
|
|
120
|
+
const emitted = new Set();
|
|
121
|
+
const queue = [];
|
|
122
|
+
const seedFrom = (node) => {
|
|
123
|
+
for (const ref of collectRefs(node)) {
|
|
124
|
+
const target = schemas[ref];
|
|
125
|
+
if (!target)
|
|
126
|
+
continue;
|
|
127
|
+
if (isObjectSchema(target) && !emitted.has(ref)) {
|
|
128
|
+
emitted.add(ref);
|
|
129
|
+
queue.push(ref);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
for (const spec of RESOURCES) {
|
|
134
|
+
seedFrom(schemas[spec.request]?.properties);
|
|
135
|
+
}
|
|
136
|
+
while (queue.length > 0) {
|
|
137
|
+
const name = queue.shift();
|
|
138
|
+
seedFrom(schemas[name]?.properties);
|
|
139
|
+
}
|
|
140
|
+
return [...emitted];
|
|
141
|
+
}
|
|
142
|
+
/** Collect every `#/components/schemas/X` ref name nested anywhere in a node. */
|
|
143
|
+
function collectRefs(node, acc = new Set()) {
|
|
144
|
+
if (!node || typeof node !== "object")
|
|
145
|
+
return acc;
|
|
146
|
+
if (Array.isArray(node)) {
|
|
147
|
+
for (const item of node)
|
|
148
|
+
collectRefs(item, acc);
|
|
149
|
+
return acc;
|
|
150
|
+
}
|
|
151
|
+
const obj = node;
|
|
152
|
+
const ref = obj.$ref;
|
|
153
|
+
if (typeof ref === "string" && ref.startsWith(REF_PREFIX))
|
|
154
|
+
acc.add(ref.slice(REF_PREFIX.length));
|
|
155
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
156
|
+
if (key === "$ref")
|
|
157
|
+
continue;
|
|
158
|
+
collectRefs(value, acc);
|
|
159
|
+
}
|
|
160
|
+
return acc;
|
|
161
|
+
}
|
|
162
|
+
/** An object schema with properties (not a pure enum). */
|
|
163
|
+
function isObjectSchema(def) {
|
|
164
|
+
return !!def.properties && Object.keys(def.properties).length > 0 && !isEnumDefinition(def);
|
|
165
|
+
}
|
|
166
|
+
// ── Type resolution ────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Resolve an OpenAPI schema node to its TypeScript type string. Emitted object
|
|
169
|
+
* schemas resolve to their class name; enums inline as string-literal unions;
|
|
170
|
+
* everything else falls back to primitives or `Record<string, any>`.
|
|
171
|
+
*/
|
|
172
|
+
function resolveType(prop, schemas, emitted) {
|
|
173
|
+
if (!prop)
|
|
174
|
+
return "any";
|
|
175
|
+
if (prop.$ref)
|
|
176
|
+
return resolveRefType(prop.$ref, schemas, emitted);
|
|
177
|
+
// allOf: [{ $ref }] — the OpenAPI idiom for "typed as this schema".
|
|
178
|
+
if (prop.allOf && prop.allOf.length > 0) {
|
|
179
|
+
const withRef = prop.allOf.find((s) => s.$ref);
|
|
180
|
+
if (withRef?.$ref)
|
|
181
|
+
return resolveRefType(withRef.$ref, schemas, emitted);
|
|
182
|
+
}
|
|
183
|
+
if (prop.enum && prop.enum.length > 0) {
|
|
184
|
+
return [...prop.enum].sort().map((v) => JSON.stringify(v)).join(" | ");
|
|
185
|
+
}
|
|
186
|
+
const pt = primaryType(prop.type);
|
|
187
|
+
switch (pt) {
|
|
188
|
+
case "string":
|
|
189
|
+
return "string";
|
|
190
|
+
case "integer":
|
|
191
|
+
case "number":
|
|
192
|
+
return "number";
|
|
193
|
+
case "boolean":
|
|
194
|
+
return "boolean";
|
|
195
|
+
case "array": {
|
|
196
|
+
if (prop.items) {
|
|
197
|
+
const itemType = resolveType(prop.items, schemas, emitted);
|
|
198
|
+
return itemType.includes(" | ") ? `(${itemType})[]` : `${itemType}[]`;
|
|
199
|
+
}
|
|
200
|
+
return "any[]";
|
|
201
|
+
}
|
|
202
|
+
case "object":
|
|
203
|
+
if (prop.additionalProperties && typeof prop.additionalProperties === "object") {
|
|
204
|
+
return `Record<string, ${resolveType(prop.additionalProperties, schemas, emitted)}>`;
|
|
205
|
+
}
|
|
206
|
+
return "Record<string, any>";
|
|
207
|
+
default:
|
|
208
|
+
return "any";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Resolve a `#/components/schemas/X` ref to a TypeScript type. */
|
|
212
|
+
function resolveRefType(ref, schemas, emitted) {
|
|
213
|
+
if (!ref.startsWith(REF_PREFIX))
|
|
214
|
+
return "any";
|
|
215
|
+
const name = ref.slice(REF_PREFIX.length);
|
|
216
|
+
const def = schemas[name];
|
|
217
|
+
if (!def)
|
|
218
|
+
return "any";
|
|
219
|
+
// Emitted object schemas reference their class by name.
|
|
220
|
+
if (emitted.includes(name))
|
|
221
|
+
return schemaToClassName(name);
|
|
222
|
+
// Enums inline as string-literal unions.
|
|
223
|
+
if (isEnumDefinition(def)) {
|
|
224
|
+
return [...(def.enum ?? [])].sort().map((v) => JSON.stringify(v)).join(" | ");
|
|
225
|
+
}
|
|
226
|
+
// Object schemas we did not emit (response-only shapes) loosen to a map.
|
|
227
|
+
if (def.properties)
|
|
228
|
+
return "Record<string, any>";
|
|
229
|
+
const pt = primaryType(def.type);
|
|
230
|
+
switch (pt) {
|
|
231
|
+
case "string":
|
|
232
|
+
return "string";
|
|
233
|
+
case "integer":
|
|
234
|
+
case "number":
|
|
235
|
+
return "number";
|
|
236
|
+
case "boolean":
|
|
237
|
+
return "boolean";
|
|
238
|
+
default:
|
|
239
|
+
return "any";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ── Naming helpers ─────────────────────────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* Convert a schema name into a PascalCase class-name segment.
|
|
245
|
+
* "fly.MachineConfig" → "MachineConfig", "fly.dnsOption" → "DnsOption".
|
|
246
|
+
*/
|
|
247
|
+
export function schemaToClassName(schemaName) {
|
|
248
|
+
const base = schemaName.slice(schemaName.lastIndexOf(".") + 1);
|
|
249
|
+
return base.charAt(0).toUpperCase() + base.slice(1);
|
|
250
|
+
}
|
|
251
|
+
/** Extract short name: "Fly::Machines::Machine" → "Machine". */
|
|
252
|
+
export function flyShortName(typeName) {
|
|
253
|
+
const parts = typeName.split("::");
|
|
254
|
+
return parts[parts.length - 1];
|
|
255
|
+
}
|
|
256
|
+
/** Extract service name: "Fly::Machines::Machine" → "Machines". */
|
|
257
|
+
export function flyServiceName(typeName) {
|
|
258
|
+
const parts = typeName.split("::");
|
|
259
|
+
return parts.length >= 2 ? parts[1] : SERVICE;
|
|
260
|
+
}
|