@intentius/chant-lexicon-fly 0.124.46 → 0.124.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -111,7 +111,7 @@ The agent-facing entry point is the `chant-fly` skill: a short operational playb
111
111
  | ------------------------------------------------------------------------------------------ | ------------------------------------- |
112
112
  | [@intentius/chant](https://www.npmjs.com/package/@intentius/chant) | Core type system, CLI, build pipeline |
113
113
  | [@intentius/chant-lexicon-aws](https://www.npmjs.com/package/@intentius/chant-lexicon-aws) | AWS CloudFormation lexicon |
114
- | [@intentius/chant-lexicon-gcp](https://www.npmjs.com/package/@intentius/chant-lexicon-gcp) | GCP Deployment Manager lexicon |
114
+ | [@intentius/chant-lexicon-gcp](https://www.npmjs.com/package/@intentius/chant-lexicon-gcp) | GCP Config Connector lexicon |
115
115
 
116
116
  ## License
117
117
 
@@ -1 +1 @@
1
- {"version":3,"file":"export-resources.d.ts","sourceRoot":"","sources":["../src/export-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAIL,KAAK,OAAO,EAEb,MAAM,2BAA2B,CAAC;AA8CnC,wBAAsB,eAAe,CACnC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,EACD,IAAI,GAAE,OAA0B,EAChC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,gBAAgB,CAAC,CA2C3B"}
1
+ {"version":3,"file":"export-resources.d.ts","sourceRoot":"","sources":["../src/export-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAIL,KAAK,OAAO,EAEb,MAAM,2BAA2B,CAAC;AA8CnC,wBAAsB,eAAe,CACnC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,EACD,IAAI,GAAE,OAA0B,EAChC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,gBAAgB,CAAC,CAoD3B"}
@@ -61,7 +61,7 @@ export async function exportResources(options, http = defaultFlyHttp(), signal)
61
61
  loggedBoundary = true;
62
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
63
  };
64
- const resources = [];
64
+ const parts = [];
65
65
  for (const app of apps) {
66
66
  const appRes = await http("GET", `${ctx.base}/v1/apps/${encodeURIComponent(app)}`, undefined, undefined, signal);
67
67
  const appBody = appRes.status === 200 ? parseJson(appRes.text) : undefined;
@@ -75,7 +75,16 @@ export async function exportResources(options, http = defaultFlyHttp(), signal)
75
75
  owned: options.owned,
76
76
  onBoundaryInference,
77
77
  });
78
- resources.push(...ir.resources);
78
+ parts.push(ir);
79
79
  }
80
- return { resources, parameters: [] };
80
+ // Each app's export re-parses its own bundle; together they re-parse the
81
+ // whole export, so embedded collector configs reach the otel importer (#3373).
82
+ return {
83
+ resources: parts.flatMap((p) => p.resources),
84
+ parameters: [],
85
+ reparse: (context) => ({
86
+ resources: parts.flatMap((p) => (p.reparse ? p.reparse(context) : p).resources),
87
+ parameters: [],
88
+ }),
89
+ };
81
90
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Where a Fly Machine embeds another lexicon's content, for `chant import`
3
+ * (#3373).
4
+ *
5
+ * A machine's `config.files[]` entry with a `raw_value` is a file written into
6
+ * the machine, base64 in flaps. The parser decodes each one and offers the text
7
+ * to core's embedded-content resolver, as k8s offers a ConfigMap value
8
+ * (#2962). `FlyOtelCollector` writes its collector config this way, at the otel
9
+ * lexicon's `COLLECTOR_CONFIG_PATH`, so a live or file import of that machine
10
+ * gives the same otel source `chant import config.yaml` gives.
11
+ *
12
+ * Which lexicon imports the content is decided by core at run time; the hint
13
+ * below only names the owner a warning suggests when none is installed.
14
+ */
15
+ import type { EmbeddedContentResolver, EmbeddedReference } from "@intentius/chant/import/embedded";
16
+ /** The UTF-8 text a base64 `raw_value` holds, or undefined when it is not base64 of text. */
17
+ export declare function decodeRawValue(raw: string): string | undefined;
18
+ /**
19
+ * The marker the parser leaves on a file whose `raw_value` an owner imports:
20
+ * the generator renders it as the base64 of the reference's text.
21
+ */
22
+ export interface EmbeddedRawValue {
23
+ readonly $base64: EmbeddedReference;
24
+ }
25
+ export declare function isEmbeddedRawValue(v: unknown): v is EmbeddedRawValue;
26
+ /**
27
+ * Offer each `config.files[].raw_value` of one machine to `embedded`, replacing
28
+ * the value an owner imports with an {@link EmbeddedRawValue}. `properties` is
29
+ * the machine's IR properties, changed in place with fresh copies on the way
30
+ * down.
31
+ */
32
+ export declare function delegateEmbedded(machineName: string, properties: Record<string, unknown>, embedded: EmbeddedContentResolver): void;
33
+ //# sourceMappingURL=embedded.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedded.d.ts","sourceRoot":"","sources":["../../src/import/embedded.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAmB,uBAAuB,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAQpH,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM9D;AAUD;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;CACrC;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,gBAAgB,CAEpE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,QAAQ,EAAE,uBAAuB,GAChC,IAAI,CA2BN"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Where a Fly Machine embeds another lexicon's content, for `chant import`
3
+ * (#3373).
4
+ *
5
+ * A machine's `config.files[]` entry with a `raw_value` is a file written into
6
+ * the machine, base64 in flaps. The parser decodes each one and offers the text
7
+ * to core's embedded-content resolver, as k8s offers a ConfigMap value
8
+ * (#2962). `FlyOtelCollector` writes its collector config this way, at the otel
9
+ * lexicon's `COLLECTOR_CONFIG_PATH`, so a live or file import of that machine
10
+ * gives the same otel source `chant import config.yaml` gives.
11
+ *
12
+ * Which lexicon imports the content is decided by core at run time; the hint
13
+ * below only names the owner a warning suggests when none is installed.
14
+ */
15
+ import { embeddedDocument } from "@intentius/chant/import/embedded";
16
+ import { COLLECTOR_CONFIG_PATH } from "@intentius/chant-lexicon-otel";
17
+ function isRecord(v) {
18
+ return typeof v === "object" && v !== null && !Array.isArray(v);
19
+ }
20
+ /** The UTF-8 text a base64 `raw_value` holds, or undefined when it is not base64 of text. */
21
+ export function decodeRawValue(raw) {
22
+ if (!/^[A-Za-z0-9+/\s]*={0,2}\s*$/.test(raw))
23
+ return undefined;
24
+ const bytes = Buffer.from(raw, "base64");
25
+ const text = bytes.toString("utf8");
26
+ // A round trip that changes the bytes means the file is not UTF-8 text.
27
+ return Buffer.from(text, "utf8").equals(bytes) ? text : undefined;
28
+ }
29
+ function hintFor(guestPath, doc) {
30
+ if (guestPath === COLLECTOR_CONFIG_PATH)
31
+ return { lexicon: "otel", what: "an OpenTelemetry Collector config" };
32
+ if (isRecord(doc) && isRecord(doc.service) && isRecord(doc.service.pipelines)) {
33
+ return { lexicon: "otel", what: "an OpenTelemetry Collector config" };
34
+ }
35
+ return undefined;
36
+ }
37
+ export function isEmbeddedRawValue(v) {
38
+ return isRecord(v) && isRecord(v.$base64);
39
+ }
40
+ /**
41
+ * Offer each `config.files[].raw_value` of one machine to `embedded`, replacing
42
+ * the value an owner imports with an {@link EmbeddedRawValue}. `properties` is
43
+ * the machine's IR properties, changed in place with fresh copies on the way
44
+ * down.
45
+ */
46
+ export function delegateEmbedded(machineName, properties, embedded) {
47
+ const config = properties.config;
48
+ if (!isRecord(config) || !Array.isArray(config.files))
49
+ return;
50
+ const files = config.files;
51
+ let changed = false;
52
+ const next = files.map((file, i) => {
53
+ if (!isRecord(file) || typeof file.raw_value !== "string")
54
+ return file;
55
+ const text = decodeRawValue(file.raw_value);
56
+ if (text === undefined)
57
+ return file;
58
+ const doc = embeddedDocument(text);
59
+ if (!isRecord(doc))
60
+ return file;
61
+ const guestPath = typeof file.guest_path === "string" ? file.guest_path : `files[${i}]`;
62
+ const base = guestPath.split("/").pop().replace(/\.[^.]*$/, "");
63
+ const ref = embedded.resolve({
64
+ host: "fly",
65
+ hostType: "Fly::Machines::Machine",
66
+ location: `Machine ${machineName} file ${guestPath}`,
67
+ directory: files.length === 1 ? machineName : `${machineName}-${base}`,
68
+ text,
69
+ document: doc,
70
+ expectedOwner: hintFor(guestPath, doc),
71
+ });
72
+ if (!ref)
73
+ return file;
74
+ changed = true;
75
+ return { ...file, raw_value: { $base64: ref } };
76
+ });
77
+ if (changed)
78
+ properties.config = { ...config, files: next };
79
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/import/generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC5F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAuFjE,qBAAa,YAAa,YAAW,mBAAmB;IACtD,QAAQ,CAAC,EAAE,EAAE,UAAU,GAAG,aAAa,EAAE,CAwBxC;CACF"}
1
+ {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/import/generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC5F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AA+FjE,qBAAa,YAAa,YAAW,mBAAmB;IACtD,QAAQ,CAAC,EAAE,EAAE,UAAU,GAAG,aAAa,EAAE,CAqCxC;CACF"}
@@ -13,6 +13,8 @@
13
13
  * the same authoring surface `chant init` scaffolds and the serializer walks back
14
14
  * to the identical flaps body.
15
15
  */
16
+ import { EmbeddedImportScope, isEmbeddedReference, renderEmbeddedReference } from "@intentius/chant/import/embedded";
17
+ import { isEmbeddedRawValue } from "./embedded.js";
16
18
  /**
17
19
  * `<OwnerClass> → { <field>: { kind, cls } }` for every field whose declared
18
20
  * type is a fly property declarable (a class, an array of one, or a
@@ -95,20 +97,36 @@ export class FlyGenerator {
95
97
  generate(ir) {
96
98
  const imports = new Set();
97
99
  const bodyLines = [];
100
+ // Names an embedded reference's imports must not take: the module's own
101
+ // declarations and every fly class it could import.
102
+ const classNames = new Set(["App", "Machine", ...Object.keys(NESTED_DECLARABLES)]);
103
+ for (const fields of Object.values(NESTED_DECLARABLES))
104
+ for (const n of Object.values(fields))
105
+ classNames.add(n.cls);
106
+ for (const r of ir.resources) {
107
+ const parts = r.type.split("::");
108
+ classNames.add(parts.length >= 3 ? parts[2] : r.type);
109
+ }
110
+ const ctx = {
111
+ imports,
112
+ scope: new EmbeddedImportScope("", [...classNames, "Buffer", ...ir.resources.map((r) => camelCase(r.logicalId))]),
113
+ };
98
114
  for (const resource of ir.resources) {
99
115
  const parts = resource.type.split("::");
100
116
  const className = parts.length >= 3 ? parts[2] : resource.type;
101
117
  imports.add(className);
102
118
  const varName = camelCase(resource.logicalId);
103
- const props = formatProps(resource.properties, 0, className, imports);
119
+ const props = formatProps(resource.properties, 0, className, ctx);
104
120
  bodyLines.push(`export const ${varName} = new ${className}(${props});`);
105
121
  bodyLines.push("");
106
122
  }
107
123
  const lines = [];
108
124
  if (imports.size > 0) {
109
125
  lines.push(`import { ${[...imports].sort().join(", ")} } from "@intentius/chant-lexicon-fly";`);
110
- lines.push("");
111
126
  }
127
+ lines.push(...ctx.scope.lines());
128
+ if (lines.length > 0)
129
+ lines.push("");
112
130
  lines.push(...bodyLines);
113
131
  return [{ path: "main.ts", content: lines.join("\n") + "\n" }];
114
132
  }
@@ -123,7 +141,7 @@ function camelCase(str) {
123
141
  * type is a nested declarable are wrapped in their constructor; everything else
124
142
  * is emitted as a plain literal.
125
143
  */
126
- function formatProps(props, indent, ownerClass, imports) {
144
+ function formatProps(props, indent, ownerClass, ctx) {
127
145
  const entries = Object.entries(props);
128
146
  if (entries.length === 0)
129
147
  return "{}";
@@ -133,20 +151,20 @@ function formatProps(props, indent, ownerClass, imports) {
133
151
  const lines = entries.map(([key, value]) => {
134
152
  const spec = nested?.[key];
135
153
  const rendered = spec !== undefined
136
- ? formatNested(value, indent + 1, spec, imports)
137
- : formatValue(value, indent + 1);
154
+ ? formatNested(value, indent + 1, spec, ctx)
155
+ : formatValue(value, indent + 1, ctx);
138
156
  return `${pad}${key}: ${rendered},`;
139
157
  });
140
158
  return `{\n${lines.join("\n")}\n${closePad}}`;
141
159
  }
142
160
  /** Wrap a nested-declarable value in its constructor per the field's kind. */
143
- function formatNested(value, indent, spec, imports) {
161
+ function formatNested(value, indent, spec, ctx) {
144
162
  const construct = (obj, at) => {
145
163
  if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
146
- return formatValue(obj, at);
164
+ return formatValue(obj, at, ctx);
147
165
  }
148
- imports.add(spec.cls);
149
- return `new ${spec.cls}(${formatProps(obj, at, spec.cls, imports)})`;
166
+ ctx.imports.add(spec.cls);
167
+ return `new ${spec.cls}(${formatProps(obj, at, spec.cls, ctx)})`;
150
168
  };
151
169
  if (spec.kind === "single") {
152
170
  return construct(value, indent);
@@ -161,7 +179,7 @@ function formatNested(value, indent, spec, imports) {
161
179
  }
162
180
  // record: a plain object whose values are wrapped in the constructor.
163
181
  if (!value || typeof value !== "object" || Array.isArray(value)) {
164
- return formatValue(value, indent);
182
+ return formatValue(value, indent, ctx);
165
183
  }
166
184
  const record = value;
167
185
  const recKeys = Object.entries(record);
@@ -172,9 +190,16 @@ function formatNested(value, indent, spec, imports) {
172
190
  const items = recKeys.map(([k, v]) => `${pad}${JSON.stringify(k)}: ${construct(v, indent + 1)},`);
173
191
  return `{\n${items.join("\n")}\n${closePad}}`;
174
192
  }
175
- function formatValue(value, indent) {
193
+ function formatValue(value, indent, ctx) {
176
194
  if (value === null || value === undefined)
177
195
  return "undefined";
196
+ // A machine file another lexicon imported (#3373): flaps wants the file's
197
+ // text base64-encoded, so the reference's text is encoded at build time.
198
+ if (isEmbeddedRawValue(value)) {
199
+ return `Buffer.from(${renderEmbeddedReference(value.$base64, ctx.scope, indent * 2)}, "utf8").toString("base64")`;
200
+ }
201
+ if (isEmbeddedReference(value))
202
+ return renderEmbeddedReference(value, ctx.scope, indent * 2);
178
203
  if (typeof value === "string")
179
204
  return JSON.stringify(value);
180
205
  if (typeof value === "number" || typeof value === "boolean")
@@ -184,22 +209,22 @@ function formatValue(value, indent) {
184
209
  return "[]";
185
210
  const pad = " ".repeat(indent + 1);
186
211
  const closePad = " ".repeat(indent);
187
- const items = value.map((v) => `${pad}${formatValue(v, indent + 1)},`);
212
+ const items = value.map((v) => `${pad}${formatValue(v, indent + 1, ctx)},`);
188
213
  return `[\n${items.join("\n")}\n${closePad}]`;
189
214
  }
190
215
  if (typeof value === "object") {
191
- return formatPlainObject(value, indent);
216
+ return formatPlainObject(value, indent, ctx);
192
217
  }
193
218
  return String(value);
194
219
  }
195
220
  /** A plain object literal (no constructor wrapping) — for scalar-valued maps like env/metadata. */
196
- function formatPlainObject(obj, indent) {
221
+ function formatPlainObject(obj, indent, ctx) {
197
222
  const entries = Object.entries(obj);
198
223
  if (entries.length === 0)
199
224
  return "{}";
200
225
  const pad = " ".repeat(indent + 1);
201
226
  const closePad = " ".repeat(indent);
202
- const lines = entries.map(([key, value]) => `${pad}${objectKey(key)}: ${formatValue(value, indent + 1)},`);
227
+ const lines = entries.map(([key, value]) => `${pad}${objectKey(key)}: ${formatValue(value, indent + 1, ctx)},`);
203
228
  return `{\n${lines.join("\n")}\n${closePad}}`;
204
229
  }
205
230
  /** Emit a bare identifier key when it is a valid one, else a quoted key. */
@@ -1 +1 @@
1
- {"version":3,"file":"live-export.d.ts","sourceRoot":"","sources":["../../src/import/live-export.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAW7E,sGAAsG;AACtG,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIlG;AAED,iGAAiG;AACjG,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ1F;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACxC,QAAQ,EAAE,YAAY,EAAE,EACxB,IAAI,GAAE;IACJ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B,GACL,gBAAgB,CAqClB"}
1
+ {"version":3,"file":"live-export.d.ts","sourceRoot":"","sources":["../../src/import/live-export.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEnF,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAW7E,sGAAsG;AACtG,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIlG;AAED,iGAAiG;AACjG,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ1F;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACxC,QAAQ,EAAE,YAAY,EAAE,EACxB,IAAI,GAAE;IACJ,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B,GACL,gBAAgB,CA6BlB"}
@@ -68,8 +68,14 @@ export function buildExportFromApp(app, machines, opts = {}) {
68
68
  const cleanMachines = keptMachines.map((m) => stripMachineServerFields(m));
69
69
  const cleanApp = keptApp ? stripAppServerFields(keptApp) : keptApp;
70
70
  const bundle = { ...(cleanApp ?? {}), machines: cleanMachines };
71
- const ir = new FlyParser().parse(JSON.stringify(bundle));
72
- const selector = opts.selector;
71
+ const content = JSON.stringify(bundle);
72
+ // `reparse(context)` parses the same bundle again with a `ParseContext`, so
73
+ // `chant import --from` hands a collector config in a machine file to the
74
+ // otel importer, as file import does (#3373).
75
+ const parse = (context) => select(new FlyParser().parse(content, context), opts.selector);
76
+ return { ...parse(), reparse: (context) => parse(context) };
77
+ }
78
+ function select(ir, selector) {
73
79
  if (!selector || (selector.type === undefined && selector.name === undefined)) {
74
80
  return ir;
75
81
  }
@@ -18,7 +18,7 @@
18
18
  * shape. `logicalId` is the app/machine name (the generator camelCases it into a
19
19
  * variable).
20
20
  */
21
- import type { TemplateParser, TemplateIR } from "@intentius/chant/import/parser";
21
+ import type { TemplateParser, TemplateIR, ParseContext } from "@intentius/chant/import/parser";
22
22
  import { BaseValueParser } from "@intentius/chant/import/base-parser";
23
23
  export declare const APP_TYPE = "Fly::Machines::App";
24
24
  export declare const MACHINE_TYPE = "Fly::Machines::Machine";
@@ -28,7 +28,15 @@ export declare const MACHINE_SERVER_FIELDS: string[];
28
28
  export declare const APP_SERVER_FIELDS: string[];
29
29
  export declare class FlyParser extends BaseValueParser implements TemplateParser {
30
30
  protected dispatchIntrinsic(_key: string, _value: unknown, _obj: Record<string, unknown>): unknown | null;
31
- parse(input: string): TemplateIR;
31
+ /** Set for the length of one `parse` with a context (`chant import`). */
32
+ private embedded?;
33
+ /**
34
+ * With `context.embedded` (`chant import`), a machine file another lexicon
35
+ * owns (a collector config) is handed to it (see ./embedded.ts). Without
36
+ * it, every file is kept as written.
37
+ */
38
+ parse(input: string, context?: ParseContext): TemplateIR;
39
+ private parseInput;
32
40
  /** Map one serializer-plan request to a resource by its endpoint. */
33
41
  private resourceFromRequest;
34
42
  private looksLikeApp;
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EAEX,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAGtE,eAAO,MAAM,QAAQ,uBAAuB,CAAC;AAC7C,eAAO,MAAM,YAAY,2BAA2B,CAAC;AAErD,kFAAkF;AAClF,eAAO,MAAM,qBAAqB,UAajC,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,iBAAiB,UAO7B,CAAC;AAMF,qBAAa,SAAU,SAAQ,eAAgB,YAAW,cAAc;IACtE,SAAS,CAAC,iBAAiB,CACzB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,OAAO,EACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,OAAO,GAAG,IAAI,CAGhB;IAED,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CA+C/B;IAED,qEAAqE;IACrE,OAAO,CAAC,mBAAmB;IAkB3B,OAAO,CAAC,YAAY;IAIpB,kFAAkF;IAClF,OAAO,CAAC,WAAW;IAuBnB,yEAAyE;IACzE,OAAO,CAAC,eAAe;CAexB"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EAEV,YAAY,EACb,MAAM,gCAAgC,CAAC;AAExC,OAAO,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAItE,eAAO,MAAM,QAAQ,uBAAuB,CAAC;AAC7C,eAAO,MAAM,YAAY,2BAA2B,CAAC;AAErD,kFAAkF;AAClF,eAAO,MAAM,qBAAqB,UAajC,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,iBAAiB,UAO7B,CAAC;AAMF,qBAAa,SAAU,SAAQ,eAAgB,YAAW,cAAc;IACtE,SAAS,CAAC,iBAAiB,CACzB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,OAAO,EACf,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,OAAO,GAAG,IAAI,CAGhB;IAED,yEAAyE;IACzE,OAAO,CAAC,QAAQ,CAAC,CAA0B;IAE3C;;;;OAIG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,UAAU,CAOvD;IAED,OAAO,CAAC,UAAU;IAiDlB,qEAAqE;IACrE,OAAO,CAAC,mBAAmB;IAkB3B,OAAO,CAAC,YAAY;IAIpB,kFAAkF;IAClF,OAAO,CAAC,WAAW;IAuBnB,yEAAyE;IACzE,OAAO,CAAC,eAAe;CAgBxB"}
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { BaseValueParser } from "@intentius/chant/import/base-parser";
22
22
  import { isSerializerPlan } from "../detect.js";
23
+ import { delegateEmbedded } from "./embedded.js";
23
24
  export const APP_TYPE = "Fly::Machines::App";
24
25
  export const MACHINE_TYPE = "Fly::Machines::Machine";
25
26
  /** Server-written read-only machine fields — never part of the authored shape. */
@@ -54,7 +55,21 @@ export class FlyParser extends BaseValueParser {
54
55
  // flaps JSON has no intrinsic functions.
55
56
  return null;
56
57
  }
57
- parse(input) {
58
+ /**
59
+ * With `context.embedded` (`chant import`), a machine file another lexicon
60
+ * owns (a collector config) is handed to it (see ./embedded.ts). Without
61
+ * it, every file is kept as written.
62
+ */
63
+ parse(input, context) {
64
+ this.embedded = context?.embedded;
65
+ try {
66
+ return this.parseInput(input);
67
+ }
68
+ finally {
69
+ this.embedded = undefined;
70
+ }
71
+ }
72
+ parseInput(input) {
58
73
  const trimmed = input.trim();
59
74
  if (!trimmed)
60
75
  return { resources: [], parameters: [] };
@@ -152,6 +167,8 @@ export class FlyParser extends BaseValueParser {
152
167
  }
153
168
  // Always carry a name so the round-trip keeps the machine's identity.
154
169
  properties.name = name;
170
+ if (this.embedded)
171
+ delegateEmbedded(name, properties, this.embedded);
155
172
  return { logicalId: name, type: MACHINE_TYPE, properties };
156
173
  }
157
174
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "4b0145f698aa9822dd923ee79ba4d1172b38da78c27d14bb1d10e9b8cc836355",
4
+ "manifest.json": "1d4034e0f96564abe4799b41d43ad037e2e60f76aa7b23839ffd0cab965419ab",
5
5
  "meta.json": "273f2a523b66f08ca91642dba2592c44765ab59ddf7329998884a0b08c6c3f24",
6
- "types/index.d.ts": "ca9b4c777e58e7ce2132f5f024e7a1b3d3f2ba4305d935aab85f6523469aed1e",
6
+ "types/index.d.ts": "75c29d3ba9faa7c2792a0a25c51dc13df011906ffbd4c9e3ccc80023f5f32667",
7
7
  "rules/guest-sizing.ts": "8dd2db79a47ceab3d488f84f560aed1b1e4a66b4e36bf11f3baf6a8544d6e126",
8
8
  "rules/no-secret-literals.ts": "3854307bd0e8d7a1bf6c23051b460b90f3212baa8bb2f7d4324d0e4418a69982",
9
9
  "rules/valid-region.ts": "238afb252ffb59ef131d070a705a49a1ac87b98f6a86c029a69dc82200e1aabf",
@@ -15,5 +15,5 @@
15
15
  "skills/chant-fly-ops.md": "44102f1fdf611b9262c04457258990e44026e83f9e5b19ae86b69322f3a37620",
16
16
  "skills/chant-fly-sprites.md": "3d100680422151b58d082c3a7b30a286edfc52faa4887995c9ad5553873bab95"
17
17
  },
18
- "composite": "83977ae2cdcbb3d3a7a08049ce804386ab3f31bb2c1f4944c3f0bc62ad93637a"
18
+ "composite": "75b8156e5678665226ec6aa034581b27de887a070aa0a2769f204b0cd7bf493d"
19
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fly",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fly"
6
6
  }
@@ -10,8 +10,8 @@ resource_type: Fly::Machines::App
10
10
 
11
11
  ## Properties
12
12
 
13
- - `enable_subdomains` (`boolean`, optional)
13
+ - `enable_subdomains` (`boolean`, optional): Request a wildcard TLS certificate for *.svc.<app-name>.fly.dev. Defaults to false. Certificate provisioning is asynchronous; this does not create services or configure their routing.
14
14
  - `idempotency_key` (`string`, optional): When set, makes a retry of this exact request safe: a second create with the same key and the same name (or no name) returns the app the first request created, instead of erroring on a false name conflict or creating a duplicate. A second create with the same key but a *different* name is rejected outright; it won't silently hand back the first app.
15
- - `name` (`string`, optional)
16
- - `network` (`string`, optional)
17
- - `org_slug` (`string`, optional)
15
+ - `name` (`string`, optional): Globally unique name for the app. Required.
16
+ - `network` (`string`, optional): Name of the organization's private network to attach the app to. Creates the network if it does not exist. Omit, use an empty string, or use "default" to select the organization's default network.
17
+ - `org_slug` (`string`, optional): Slug of the organization that will own the app, or "personal" for your personal organization. Required.
@@ -4,6 +4,7 @@
4
4
 
5
5
  export declare class App {
6
6
  constructor(props: {
7
+ /** Request a wildcard TLS certificate for *.svc.<app-name>.fly.dev. Defaults to false. Certificate provisioning is asynchronous; this does not create services or configure their routing. */
7
8
  enable_subdomains?: boolean;
8
9
  /** When set, makes a retry of this exact request safe: a
9
10
  second create with the same key and the same name (or no name) returns
@@ -12,8 +13,11 @@ conflict or creating a duplicate. A second create with the same key
12
13
  but a *different* name is rejected outright; it won't silently
13
14
  hand back the first app. */
14
15
  idempotency_key?: string;
16
+ /** Globally unique name for the app. Required. */
15
17
  name?: string;
18
+ /** Name of the organization's private network to attach the app to. Creates the network if it does not exist. Omit, use an empty string, or use "default" to select the organization's default network. */
16
19
  network?: string;
20
+ /** Slug of the organization that will own the app, or "personal" for your personal organization. Required. */
17
21
  org_slug?: string;
18
22
  });
19
23
  readonly id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fly",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -58,7 +58,7 @@
58
58
  "build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json"
59
59
  },
60
60
  "dependencies": {
61
- "@intentius/chant-lexicon-otel": "^0.124.46",
61
+ "@intentius/chant-lexicon-otel": "^0.124.49",
62
62
  "ws": "^8.18.0"
63
63
  },
64
64
  "devDependencies": {
@@ -69,7 +69,7 @@
69
69
  "zod": "^4.3.6"
70
70
  },
71
71
  "peerDependencies": {
72
- "@intentius/chant": "^0.124.46",
72
+ "@intentius/chant": "^0.124.49",
73
73
  "zod": "^4.3.6"
74
74
  },
75
75
  "description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",
@@ -93,7 +93,7 @@ export async function exportResources(
93
93
  );
94
94
  };
95
95
 
96
- const resources: ExportedTemplate["resources"] = [];
96
+ const parts: ExportedTemplate[] = [];
97
97
  for (const app of apps) {
98
98
  const appRes = await http(
99
99
  "GET",
@@ -116,8 +116,17 @@ export async function exportResources(
116
116
  owned: options.owned,
117
117
  onBoundaryInference,
118
118
  });
119
- resources.push(...ir.resources);
119
+ parts.push(ir);
120
120
  }
121
121
 
122
- return { resources, parameters: [] };
122
+ // Each app's export re-parses its own bundle; together they re-parse the
123
+ // whole export, so embedded collector configs reach the otel importer (#3373).
124
+ return {
125
+ resources: parts.flatMap((p) => p.resources),
126
+ parameters: [],
127
+ reparse: (context) => ({
128
+ resources: parts.flatMap((p) => (p.reparse ? p.reparse(context) : p).resources),
129
+ parameters: [],
130
+ }),
131
+ };
123
132
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  export declare class App {
6
6
  constructor(props: {
7
+ /** Request a wildcard TLS certificate for *.svc.<app-name>.fly.dev. Defaults to false. Certificate provisioning is asynchronous; this does not create services or configure their routing. */
7
8
  enable_subdomains?: boolean;
8
9
  /** When set, makes a retry of this exact request safe: a
9
10
  second create with the same key and the same name (or no name) returns
@@ -12,8 +13,11 @@ conflict or creating a duplicate. A second create with the same key
12
13
  but a *different* name is rejected outright; it won't silently
13
14
  hand back the first app. */
14
15
  idempotency_key?: string;
16
+ /** Globally unique name for the app. Required. */
15
17
  name?: string;
18
+ /** Name of the organization's private network to attach the app to. Creates the network if it does not exist. Omit, use an empty string, or use "default" to select the organization's default network. */
16
19
  network?: string;
20
+ /** Slug of the organization that will own the app, or "personal" for your personal organization. Required. */
17
21
  org_slug?: string;
18
22
  });
19
23
  readonly id: string;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * A `FlyOtelCollector`'s config through `chant import` (#3373): the machine
3
+ * file at `COLLECTOR_CONFIG_PATH` is handed to the otel importer, and a live
4
+ * import (`chant import --from`) gives the same otel source a file import of
5
+ * the build's `fly.json` gives, and both give what `chant import config.yaml`
6
+ * gives for the config itself.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
11
+ import { join, relative, resolve } from "path";
12
+ import { load } from "js-yaml";
13
+ import { importCommand, liveImportFromPlugins } from "@intentius/chant/cli/commands/import";
14
+ import type { LexiconPlugin } from "@intentius/chant/lexicon";
15
+ import { build } from "@intentius/chant/build";
16
+ import { EmbeddedImports } from "@intentius/chant/import/embedded";
17
+ import { COLLECTOR_CONFIG_PATH, otelPlugin, otelSerializer } from "@intentius/chant-lexicon-otel";
18
+ import { OtelCollectorParser } from "@intentius/chant-lexicon-otel/import/parser";
19
+ import { OtelCollectorGenerator } from "@intentius/chant-lexicon-otel/import/generator";
20
+ import { flyPlugin } from "../plugin";
21
+ import { flySerializer } from "../serializer";
22
+ import { exportResources } from "../export-resources";
23
+ import type { FlyHttp } from "../op/activities/fly-apply";
24
+ import { FlyParser } from "./parser";
25
+ import { decodeRawValue } from "./embedded";
26
+
27
+ type Json = Record<string, unknown>;
28
+
29
+ const pkgDir = resolve(import.meta.dirname, "../..");
30
+
31
+ const SOURCE = `import { App, FlyOtelCollector } from "@intentius/chant-lexicon-fly";
32
+ import { OtlpHttpExporter } from "@intentius/chant-lexicon-otel";
33
+
34
+ export const app = new App({ name: "otel" });
35
+ export const collector = FlyOtelCollector({
36
+ region: "iad",
37
+ exporters: [new OtlpHttpExporter({ endpoint: "https://otlp.example.com" })],
38
+ });
39
+ `;
40
+
41
+ function walk(dir: string, root: string, out: Record<string, string>): void {
42
+ for (const entry of readdirSync(dir)) {
43
+ const path = join(dir, entry);
44
+ if (statSync(path).isDirectory()) walk(path, root, out);
45
+ else out[relative(root, path)] = readFileSync(path, "utf-8");
46
+ }
47
+ }
48
+
49
+ function project(): { dir: string; srcDir: string } {
50
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
51
+ writeFileSync(join(dir, "package.json"), '{ "name": "fly-embedded", "private": true, "type": "module" }\n');
52
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["fly", "otel"] };\n');
53
+ const srcDir = join(dir, "src");
54
+ mkdirSync(srcDir);
55
+ return { dir, srcDir };
56
+ }
57
+
58
+ function files(srcDir: string): Record<string, string> {
59
+ const out: Record<string, string> = {};
60
+ walk(srcDir, srcDir, out);
61
+ return out;
62
+ }
63
+
64
+ /** The build's flaps plan for a FlyOtelCollector. */
65
+ async function plan(): Promise<Record<string, { endpoint: string; body: Json }>> {
66
+ const { dir, srcDir } = project();
67
+ try {
68
+ writeFileSync(join(srcDir, "main.ts"), SOURCE);
69
+ const result = await build(srcDir, [flySerializer, otelSerializer]);
70
+ expect(result.errors).toEqual([]);
71
+ const out = result.outputs.get("fly");
72
+ return JSON.parse(typeof out === "string" ? out : (out as { primary: string }).primary);
73
+ } finally {
74
+ rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ function machineBody(p: Awaited<ReturnType<typeof plan>>): Json {
79
+ const req = Object.values(p).find((r) => /\/machines\/?$/.test(r.endpoint));
80
+ if (!req) throw new Error("no machine in the plan");
81
+ return req.body;
82
+ }
83
+
84
+ function configText(machine: Json): string {
85
+ const files = (machine.config as Json).files as Json[];
86
+ const file = files.find((f) => f.guest_path === COLLECTOR_CONFIG_PATH)!;
87
+ return decodeRawValue(file.raw_value as string)!;
88
+ }
89
+
90
+ /** `chant import config.yaml`: the otel modules for the config alone. */
91
+ function standalone(text: string): Record<string, string> {
92
+ return Object.fromEntries(new OtelCollectorGenerator().generate(new OtelCollectorParser().parse(text)).map((f) => [f.path, f.content]));
93
+ }
94
+
95
+ /** The modules under `dir/`, keyed by their path inside it. */
96
+ function under(all: Record<string, string>, dir: string): Record<string, string> {
97
+ return Object.fromEntries(Object.entries(all).filter(([p]) => p.startsWith(`${dir}/`)).map(([p, c]) => [p.slice(dir.length + 1), c]));
98
+ }
99
+
100
+ /** A fake flaps API holding one app and the plan's machine, with the fields a server adds. */
101
+ function flaps(machine: Json): FlyHttp {
102
+ const live = { ...machine, id: "m-1", state: "started", instance_id: "I1", private_ip: "fdaa::1" };
103
+ return async (_method, url) => {
104
+ if (/\/v1\/apps\/otel\/machines/.test(url)) return { status: 200, text: JSON.stringify([live]) };
105
+ if (/\/v1\/apps\/otel$/.test(url)) return { status: 200, text: JSON.stringify({ name: "otel", organization: { slug: "acme" } }) };
106
+ return { status: 404, text: "" };
107
+ };
108
+ }
109
+
110
+ describe("FlyOtelCollector through chant import (#3373)", () => {
111
+ test("a live import gives the otel source a file import gives, and builds back to the same config", async () => {
112
+ const p = await plan();
113
+ const machine = machineBody(p);
114
+ const text = configText(machine);
115
+ const name = machine.name as string;
116
+ const expected = standalone(text);
117
+ expect(Object.keys(expected).length).toBeGreaterThan(0);
118
+
119
+ // File import of the build's fly.json.
120
+ const fileProject = project();
121
+ // Live import from a fake flaps API.
122
+ const liveProject = project();
123
+ const prevApp = process.env.FLY_APP_NAME;
124
+ process.env.FLY_APP_NAME = "otel";
125
+ try {
126
+ const templatePath = join(fileProject.dir, "fly.json");
127
+ writeFileSync(templatePath, JSON.stringify(p));
128
+ const fileResult = await importCommand({ templatePath, output: fileProject.srcDir, lexicon: "fly" });
129
+ expect(fileResult.error).toBeUndefined();
130
+ expect(fileResult.warnings.filter((w) => w.includes("kept as written"))).toEqual([]);
131
+ const fromFile = files(fileProject.srcDir);
132
+
133
+ const plugin: LexiconPlugin = { ...flyPlugin, exportResources: (options) => exportResources(options, flaps(machine)) };
134
+ const liveResult = await liveImportFromPlugins([plugin, otelPlugin], {
135
+ environment: "test",
136
+ output: liveProject.srcDir,
137
+ lexicon: "fly",
138
+ force: true,
139
+ });
140
+ expect(liveResult.error).toBeUndefined();
141
+ expect(liveResult.success).toBe(true);
142
+ expect(liveResult.warnings.filter((w) => w.includes("kept as written"))).toEqual([]);
143
+ const fromLive = files(liveProject.srcDir);
144
+
145
+ expect(under(fromLive, name)).toEqual(expected);
146
+ expect(under(fromFile, name)).toEqual(expected);
147
+ expect(fromLive["main.ts"]).toContain('import { collectorYaml } from "@intentius/chant-lexicon-otel";');
148
+ expect(fromLive["main.ts"]).toContain("raw_value: Buffer.from(collectorYaml(");
149
+
150
+ // The imported source builds back to the config the machine holds.
151
+ const rebuilt = await build(liveProject.srcDir, [flySerializer, otelSerializer]);
152
+ expect(rebuilt.errors).toEqual([]);
153
+ const out = rebuilt.outputs.get("fly");
154
+ const after = machineBody(JSON.parse(typeof out === "string" ? out : (out as { primary: string }).primary));
155
+ expect(load(configText(after))).toEqual(load(text));
156
+ } finally {
157
+ if (prevApp === undefined) delete process.env.FLY_APP_NAME;
158
+ else process.env.FLY_APP_NAME = prevApp;
159
+ rmSync(fileProject.dir, { recursive: true, force: true });
160
+ rmSync(liveProject.dir, { recursive: true, force: true });
161
+ }
162
+ });
163
+
164
+ test("without a context the export keeps the file as read, and offers a reparse", async () => {
165
+ const machine = machineBody(await plan());
166
+ const prevApp = process.env.FLY_APP_NAME;
167
+ process.env.FLY_APP_NAME = "otel";
168
+ try {
169
+ const ir = await exportResources({ environment: "test" }, flaps(machine));
170
+ const m = ir.resources.find((r) => r.type === "Fly::Machines::Machine")!;
171
+ const file = ((m.properties.config as Json).files as Json[])[0];
172
+ expect(typeof file.raw_value).toBe("string");
173
+ expect(typeof ir.reparse).toBe("function");
174
+ } finally {
175
+ if (prevApp === undefined) delete process.env.FLY_APP_NAME;
176
+ else process.env.FLY_APP_NAME = prevApp;
177
+ }
178
+ });
179
+
180
+ test("a file that is not a document, or not text, is never offered", () => {
181
+ const probe = new EmbeddedImports([], { quiet: true });
182
+ const files = [
183
+ { guest_path: "/etc/motd", raw_value: Buffer.from("hello there\n").toString("base64") },
184
+ { guest_path: "/bin/blob", raw_value: Buffer.from([0xff, 0xfe, 0x00, 0x81]).toString("base64") },
185
+ { guest_path: "/etc/app.yaml", raw_value: Buffer.from("a: 1\n").toString("base64") },
186
+ ];
187
+ new FlyParser().parse(JSON.stringify({ name: "m", config: { image: "x", files } }), { embedded: probe });
188
+ expect(probe.offered.map((c) => c.location)).toEqual(["Machine m file /etc/app.yaml"]);
189
+ });
190
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Where a Fly Machine embeds another lexicon's content, for `chant import`
3
+ * (#3373).
4
+ *
5
+ * A machine's `config.files[]` entry with a `raw_value` is a file written into
6
+ * the machine, base64 in flaps. The parser decodes each one and offers the text
7
+ * to core's embedded-content resolver, as k8s offers a ConfigMap value
8
+ * (#2962). `FlyOtelCollector` writes its collector config this way, at the otel
9
+ * lexicon's `COLLECTOR_CONFIG_PATH`, so a live or file import of that machine
10
+ * gives the same otel source `chant import config.yaml` gives.
11
+ *
12
+ * Which lexicon imports the content is decided by core at run time; the hint
13
+ * below only names the owner a warning suggests when none is installed.
14
+ */
15
+
16
+ import type { EmbeddedContent, EmbeddedContentResolver, EmbeddedReference } from "@intentius/chant/import/embedded";
17
+ import { embeddedDocument } from "@intentius/chant/import/embedded";
18
+ import { COLLECTOR_CONFIG_PATH } from "@intentius/chant-lexicon-otel";
19
+
20
+ function isRecord(v: unknown): v is Record<string, unknown> {
21
+ return typeof v === "object" && v !== null && !Array.isArray(v);
22
+ }
23
+
24
+ /** The UTF-8 text a base64 `raw_value` holds, or undefined when it is not base64 of text. */
25
+ export function decodeRawValue(raw: string): string | undefined {
26
+ if (!/^[A-Za-z0-9+/\s]*={0,2}\s*$/.test(raw)) return undefined;
27
+ const bytes = Buffer.from(raw, "base64");
28
+ const text = bytes.toString("utf8");
29
+ // A round trip that changes the bytes means the file is not UTF-8 text.
30
+ return Buffer.from(text, "utf8").equals(bytes) ? text : undefined;
31
+ }
32
+
33
+ function hintFor(guestPath: string, doc: unknown): EmbeddedContent["expectedOwner"] {
34
+ if (guestPath === COLLECTOR_CONFIG_PATH) return { lexicon: "otel", what: "an OpenTelemetry Collector config" };
35
+ if (isRecord(doc) && isRecord(doc.service) && isRecord(doc.service.pipelines)) {
36
+ return { lexicon: "otel", what: "an OpenTelemetry Collector config" };
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * The marker the parser leaves on a file whose `raw_value` an owner imports:
43
+ * the generator renders it as the base64 of the reference's text.
44
+ */
45
+ export interface EmbeddedRawValue {
46
+ readonly $base64: EmbeddedReference;
47
+ }
48
+
49
+ export function isEmbeddedRawValue(v: unknown): v is EmbeddedRawValue {
50
+ return isRecord(v) && isRecord(v.$base64);
51
+ }
52
+
53
+ /**
54
+ * Offer each `config.files[].raw_value` of one machine to `embedded`, replacing
55
+ * the value an owner imports with an {@link EmbeddedRawValue}. `properties` is
56
+ * the machine's IR properties, changed in place with fresh copies on the way
57
+ * down.
58
+ */
59
+ export function delegateEmbedded(
60
+ machineName: string,
61
+ properties: Record<string, unknown>,
62
+ embedded: EmbeddedContentResolver,
63
+ ): void {
64
+ const config = properties.config;
65
+ if (!isRecord(config) || !Array.isArray(config.files)) return;
66
+ const files = config.files;
67
+ let changed = false;
68
+ const next = files.map((file, i) => {
69
+ if (!isRecord(file) || typeof file.raw_value !== "string") return file;
70
+ const text = decodeRawValue(file.raw_value);
71
+ if (text === undefined) return file;
72
+ const doc = embeddedDocument(text);
73
+ if (!isRecord(doc)) return file;
74
+ const guestPath = typeof file.guest_path === "string" ? file.guest_path : `files[${i}]`;
75
+ const base = guestPath.split("/").pop()!.replace(/\.[^.]*$/, "");
76
+ const ref = embedded.resolve({
77
+ host: "fly",
78
+ hostType: "Fly::Machines::Machine",
79
+ location: `Machine ${machineName} file ${guestPath}`,
80
+ directory: files.length === 1 ? machineName : `${machineName}-${base}`,
81
+ text,
82
+ document: doc,
83
+ expectedOwner: hintFor(guestPath, doc),
84
+ });
85
+ if (!ref) return file;
86
+ changed = true;
87
+ return { ...file, raw_value: { $base64: ref } satisfies EmbeddedRawValue };
88
+ });
89
+ if (changed) properties.config = { ...config, files: next };
90
+ }
@@ -16,6 +16,14 @@
16
16
 
17
17
  import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
18
18
  import type { TemplateIR } from "@intentius/chant/import/parser";
19
+ import { EmbeddedImportScope, isEmbeddedReference, renderEmbeddedReference } from "@intentius/chant/import/embedded";
20
+ import { isEmbeddedRawValue } from "./embedded";
21
+
22
+ /** What one module's rendering collects: the fly classes it uses, and the imports embedded references need. */
23
+ interface Ctx {
24
+ imports: Set<string>;
25
+ scope: EmbeddedImportScope;
26
+ }
19
27
 
20
28
  /**
21
29
  * How a nested field is wrapped: `single` → `new Cls({...})`, `array` → each
@@ -106,13 +114,25 @@ export class FlyGenerator implements TypeScriptGenerator {
106
114
  generate(ir: TemplateIR): GeneratedFile[] {
107
115
  const imports = new Set<string>();
108
116
  const bodyLines: string[] = [];
117
+ // Names an embedded reference's imports must not take: the module's own
118
+ // declarations and every fly class it could import.
119
+ const classNames = new Set(["App", "Machine", ...Object.keys(NESTED_DECLARABLES)]);
120
+ for (const fields of Object.values(NESTED_DECLARABLES)) for (const n of Object.values(fields)) classNames.add(n.cls);
121
+ for (const r of ir.resources) {
122
+ const parts = r.type.split("::");
123
+ classNames.add(parts.length >= 3 ? parts[2] : r.type);
124
+ }
125
+ const ctx: Ctx = {
126
+ imports,
127
+ scope: new EmbeddedImportScope("", [...classNames, "Buffer", ...ir.resources.map((r) => camelCase(r.logicalId))]),
128
+ };
109
129
 
110
130
  for (const resource of ir.resources) {
111
131
  const parts = resource.type.split("::");
112
132
  const className = parts.length >= 3 ? parts[2] : resource.type;
113
133
  imports.add(className);
114
134
  const varName = camelCase(resource.logicalId);
115
- const props = formatProps(resource.properties, 0, className, imports);
135
+ const props = formatProps(resource.properties, 0, className, ctx);
116
136
  bodyLines.push(`export const ${varName} = new ${className}(${props});`);
117
137
  bodyLines.push("");
118
138
  }
@@ -122,8 +142,9 @@ export class FlyGenerator implements TypeScriptGenerator {
122
142
  lines.push(
123
143
  `import { ${[...imports].sort().join(", ")} } from "@intentius/chant-lexicon-fly";`,
124
144
  );
125
- lines.push("");
126
145
  }
146
+ lines.push(...ctx.scope.lines());
147
+ if (lines.length > 0) lines.push("");
127
148
  lines.push(...bodyLines);
128
149
 
129
150
  return [{ path: "main.ts", content: lines.join("\n") + "\n" }];
@@ -145,7 +166,7 @@ function formatProps(
145
166
  props: Record<string, unknown>,
146
167
  indent: number,
147
168
  ownerClass: string,
148
- imports: Set<string>,
169
+ ctx: Ctx,
149
170
  ): string {
150
171
  const entries = Object.entries(props);
151
172
  if (entries.length === 0) return "{}";
@@ -158,8 +179,8 @@ function formatProps(
158
179
  const spec = nested?.[key];
159
180
  const rendered =
160
181
  spec !== undefined
161
- ? formatNested(value, indent + 1, spec, imports)
162
- : formatValue(value, indent + 1);
182
+ ? formatNested(value, indent + 1, spec, ctx)
183
+ : formatValue(value, indent + 1, ctx);
163
184
  return `${pad}${key}: ${rendered},`;
164
185
  });
165
186
 
@@ -171,14 +192,14 @@ function formatNested(
171
192
  value: unknown,
172
193
  indent: number,
173
194
  spec: Nested,
174
- imports: Set<string>,
195
+ ctx: Ctx,
175
196
  ): string {
176
197
  const construct = (obj: unknown, at: number): string => {
177
198
  if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
178
- return formatValue(obj, at);
199
+ return formatValue(obj, at, ctx);
179
200
  }
180
- imports.add(spec.cls);
181
- return `new ${spec.cls}(${formatProps(obj as Record<string, unknown>, at, spec.cls, imports)})`;
201
+ ctx.imports.add(spec.cls);
202
+ return `new ${spec.cls}(${formatProps(obj as Record<string, unknown>, at, spec.cls, ctx)})`;
182
203
  };
183
204
 
184
205
  if (spec.kind === "single") {
@@ -195,7 +216,7 @@ function formatNested(
195
216
 
196
217
  // record: a plain object whose values are wrapped in the constructor.
197
218
  if (!value || typeof value !== "object" || Array.isArray(value)) {
198
- return formatValue(value, indent);
219
+ return formatValue(value, indent, ctx);
199
220
  }
200
221
  const record = value as Record<string, unknown>;
201
222
  const recKeys = Object.entries(record);
@@ -206,8 +227,14 @@ function formatNested(
206
227
  return `{\n${items.join("\n")}\n${closePad}}`;
207
228
  }
208
229
 
209
- function formatValue(value: unknown, indent: number): string {
230
+ function formatValue(value: unknown, indent: number, ctx: Ctx): string {
210
231
  if (value === null || value === undefined) return "undefined";
232
+ // A machine file another lexicon imported (#3373): flaps wants the file's
233
+ // text base64-encoded, so the reference's text is encoded at build time.
234
+ if (isEmbeddedRawValue(value)) {
235
+ return `Buffer.from(${renderEmbeddedReference(value.$base64, ctx.scope, indent * 2)}, "utf8").toString("base64")`;
236
+ }
237
+ if (isEmbeddedReference(value)) return renderEmbeddedReference(value, ctx.scope, indent * 2);
211
238
  if (typeof value === "string") return JSON.stringify(value);
212
239
  if (typeof value === "number" || typeof value === "boolean") return String(value);
213
240
 
@@ -215,24 +242,24 @@ function formatValue(value: unknown, indent: number): string {
215
242
  if (value.length === 0) return "[]";
216
243
  const pad = " ".repeat(indent + 1);
217
244
  const closePad = " ".repeat(indent);
218
- const items = value.map((v) => `${pad}${formatValue(v, indent + 1)},`);
245
+ const items = value.map((v) => `${pad}${formatValue(v, indent + 1, ctx)},`);
219
246
  return `[\n${items.join("\n")}\n${closePad}]`;
220
247
  }
221
248
 
222
249
  if (typeof value === "object") {
223
- return formatPlainObject(value as Record<string, unknown>, indent);
250
+ return formatPlainObject(value as Record<string, unknown>, indent, ctx);
224
251
  }
225
252
 
226
253
  return String(value);
227
254
  }
228
255
 
229
256
  /** A plain object literal (no constructor wrapping) — for scalar-valued maps like env/metadata. */
230
- function formatPlainObject(obj: Record<string, unknown>, indent: number): string {
257
+ function formatPlainObject(obj: Record<string, unknown>, indent: number, ctx: Ctx): string {
231
258
  const entries = Object.entries(obj);
232
259
  if (entries.length === 0) return "{}";
233
260
  const pad = " ".repeat(indent + 1);
234
261
  const closePad = " ".repeat(indent);
235
- const lines = entries.map(([key, value]) => `${pad}${objectKey(key)}: ${formatValue(value, indent + 1)},`);
262
+ const lines = entries.map(([key, value]) => `${pad}${objectKey(key)}: ${formatValue(value, indent + 1, ctx)},`);
236
263
  return `{\n${lines.join("\n")}\n${closePad}}`;
237
264
  }
238
265
 
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
22
+ import type { ParseContext, TemplateIR } from "@intentius/chant/import/parser";
22
23
  import { isChantOwned, type FlapsMachine } from "../op/activities/fly-apply";
23
24
  import {
24
25
  FlyParser,
@@ -90,9 +91,15 @@ export function buildExportFromApp(
90
91
  const cleanApp = keptApp ? stripAppServerFields(keptApp) : keptApp;
91
92
 
92
93
  const bundle: Record<string, unknown> = { ...(cleanApp ?? {}), machines: cleanMachines };
93
- const ir = new FlyParser().parse(JSON.stringify(bundle));
94
+ const content = JSON.stringify(bundle);
95
+ // `reparse(context)` parses the same bundle again with a `ParseContext`, so
96
+ // `chant import --from` hands a collector config in a machine file to the
97
+ // otel importer, as file import does (#3373).
98
+ const parse = (context?: ParseContext): TemplateIR => select(new FlyParser().parse(content, context), opts.selector);
99
+ return { ...parse(), reparse: (context) => parse(context) };
100
+ }
94
101
 
95
- const selector = opts.selector;
102
+ function select(ir: TemplateIR, selector: ResourceSelector | undefined): TemplateIR {
96
103
  if (!selector || (selector.type === undefined && selector.name === undefined)) {
97
104
  return ir;
98
105
  }
@@ -23,9 +23,12 @@ import type {
23
23
  TemplateParser,
24
24
  TemplateIR,
25
25
  ResourceIR,
26
+ ParseContext,
26
27
  } from "@intentius/chant/import/parser";
28
+ import type { EmbeddedContentResolver } from "@intentius/chant/import/embedded";
27
29
  import { BaseValueParser } from "@intentius/chant/import/base-parser";
28
30
  import { isSerializerPlan } from "../detect";
31
+ import { delegateEmbedded } from "./embedded";
29
32
 
30
33
  export const APP_TYPE = "Fly::Machines::App";
31
34
  export const MACHINE_TYPE = "Fly::Machines::Machine";
@@ -70,7 +73,24 @@ export class FlyParser extends BaseValueParser implements TemplateParser {
70
73
  return null;
71
74
  }
72
75
 
73
- parse(input: string): TemplateIR {
76
+ /** Set for the length of one `parse` with a context (`chant import`). */
77
+ private embedded?: EmbeddedContentResolver;
78
+
79
+ /**
80
+ * With `context.embedded` (`chant import`), a machine file another lexicon
81
+ * owns (a collector config) is handed to it (see ./embedded.ts). Without
82
+ * it, every file is kept as written.
83
+ */
84
+ parse(input: string, context?: ParseContext): TemplateIR {
85
+ this.embedded = context?.embedded;
86
+ try {
87
+ return this.parseInput(input);
88
+ } finally {
89
+ this.embedded = undefined;
90
+ }
91
+ }
92
+
93
+ private parseInput(input: string): TemplateIR {
74
94
  const trimmed = input.trim();
75
95
  if (!trimmed) return { resources: [], parameters: [] };
76
96
 
@@ -179,6 +199,7 @@ export class FlyParser extends BaseValueParser implements TemplateParser {
179
199
  }
180
200
  // Always carry a name so the round-trip keeps the machine's identity.
181
201
  properties.name = name;
202
+ if (this.embedded) delegateEmbedded(name, properties, this.embedded);
182
203
 
183
204
  return { logicalId: name, type: MACHINE_TYPE, properties };
184
205
  }