@intentius/chant-lexicon-docker 0.124.45 → 0.124.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/export-resources.d.ts +21 -0
  2. package/dist/export-resources.d.ts.map +1 -0
  3. package/dist/export-resources.js +63 -0
  4. package/dist/import/adapter.d.ts +6 -2
  5. package/dist/import/adapter.d.ts.map +1 -1
  6. package/dist/import/adapter.js +11 -7
  7. package/dist/import/embedded.d.ts +27 -0
  8. package/dist/import/embedded.d.ts.map +1 -0
  9. package/dist/import/embedded.js +79 -0
  10. package/dist/import/engine.d.ts +39 -0
  11. package/dist/import/engine.d.ts.map +1 -0
  12. package/dist/import/engine.js +94 -0
  13. package/dist/import/generator.d.ts.map +1 -1
  14. package/dist/import/generator.js +41 -10
  15. package/dist/import/live-export.d.ts +142 -0
  16. package/dist/import/live-export.d.ts.map +1 -0
  17. package/dist/import/live-export.js +297 -0
  18. package/dist/import/parser.d.ts +9 -1
  19. package/dist/import/parser.d.ts.map +1 -1
  20. package/dist/import/parser.js +19 -2
  21. package/dist/import/testdata/tar.d.ts +3 -0
  22. package/dist/import/testdata/tar.d.ts.map +1 -0
  23. package/dist/import/testdata/tar.js +25 -0
  24. package/dist/integrity.json +2 -2
  25. package/dist/manifest.json +1 -1
  26. package/dist/plugin.d.ts.map +1 -1
  27. package/dist/plugin.js +5 -0
  28. package/package.json +3 -3
  29. package/src/export-resources.ts +90 -0
  30. package/src/import/adapter.ts +14 -9
  31. package/src/import/embedded-live.test.ts +248 -0
  32. package/src/import/embedded.ts +83 -0
  33. package/src/import/engine.ts +107 -0
  34. package/src/import/generator.ts +42 -10
  35. package/src/import/live-export.test.ts +271 -0
  36. package/src/import/live-export.ts +359 -0
  37. package/src/import/parser.ts +19 -2
  38. package/src/import/testdata/tar.ts +24 -0
  39. package/src/plugin.ts +6 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A `DockerOtelCollector`'s config through `chant import` (#3373, #4388): the
3
+ * config's `content` is handed to the otel importer, and a live import
4
+ * (`chant import --from`, against a fake Engine API) gives the same otel
5
+ * source a file import of the build's `docker-compose.yml` gives, and both
6
+ * give what `chant import config.yaml` 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 { dockerPlugin } from "../plugin";
21
+ import { dockerSerializer } from "../serializer";
22
+ import { exportResources } from "../export-resources";
23
+ import type { DockerEngine } from "./engine";
24
+ import { DockerParser } from "./parser";
25
+ import { tarOf } from "./testdata/tar";
26
+
27
+ type Json = Record<string, unknown>;
28
+
29
+ const pkgDir = resolve(import.meta.dirname, "../..");
30
+
31
+ const SOURCE = `import { DockerOtelCollector } from "@intentius/chant-lexicon-docker";
32
+ import { OtlpHttpExporter } from "@intentius/chant-lexicon-otel";
33
+
34
+ export const otel = DockerOtelCollector({
35
+ exporters: [new OtlpHttpExporter({ endpoint: "https://otlp.example.com" })],
36
+ });
37
+ `;
38
+
39
+ function walk(dir: string, root: string, out: Record<string, string>): void {
40
+ for (const entry of readdirSync(dir)) {
41
+ const path = join(dir, entry);
42
+ if (statSync(path).isDirectory()) walk(path, root, out);
43
+ else out[relative(root, path)] = readFileSync(path, "utf-8");
44
+ }
45
+ }
46
+
47
+ function project(): { dir: string; srcDir: string } {
48
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
49
+ writeFileSync(join(dir, "package.json"), '{ "name": "docker-embedded", "private": true, "type": "module" }\n');
50
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["docker", "otel"] };\n');
51
+ const srcDir = join(dir, "src");
52
+ mkdirSync(srcDir);
53
+ return { dir, srcDir };
54
+ }
55
+
56
+ function files(srcDir: string): Record<string, string> {
57
+ const out: Record<string, string> = {};
58
+ walk(srcDir, srcDir, out);
59
+ return out;
60
+ }
61
+
62
+ function composeOf(out: unknown): string {
63
+ if (typeof out === "string") return out;
64
+ const o = out as { primary?: string; files?: Record<string, string> };
65
+ if (typeof o.primary === "string") return o.primary;
66
+ const yml = Object.entries(o.files ?? {}).find(([p]) => /compose/.test(p));
67
+ if (!yml) throw new Error("no compose file in the build");
68
+ return yml[1];
69
+ }
70
+
71
+ /** The build's compose document for `source`. */
72
+ async function compose(source: string): Promise<{ text: string; doc: Json }> {
73
+ const { dir, srcDir } = project();
74
+ try {
75
+ writeFileSync(join(srcDir, "main.ts"), source);
76
+ const result = await build(srcDir, [dockerSerializer, otelSerializer]);
77
+ expect(result.errors).toEqual([]);
78
+ const text = composeOf(result.outputs.get("docker"));
79
+ return { text, doc: load(text) as Json };
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ }
84
+
85
+ /** The collector's service key and its config text. */
86
+ function collector(doc: Json): { service: string; config: string } {
87
+ const services = doc.services as Record<string, Json>;
88
+ const [service, svc] = Object.entries(services).find(([, s]) => Array.isArray(s.configs))!;
89
+ const source = (svc.configs as Json[])[0]!.source as string;
90
+ return { service, config: (doc.configs as Record<string, Json>)[source]!.content as string };
91
+ }
92
+
93
+ /** `chant import config.yaml`: the otel modules for the config alone. */
94
+ function standalone(text: string): Record<string, string> {
95
+ return Object.fromEntries(new OtelCollectorGenerator().generate(new OtelCollectorParser().parse(text)).map((f) => [f.path, f.content]));
96
+ }
97
+
98
+ /** The modules under `dir/`, keyed by their path inside it. */
99
+ function under(all: Record<string, string>, dir: string): Record<string, string> {
100
+ return Object.fromEntries(Object.entries(all).filter(([p]) => p.startsWith(`${dir}/`)).map(([p, c]) => [p.slice(dir.length + 1), c]));
101
+ }
102
+
103
+ /**
104
+ * A fake Engine holding the collector's container as compose creates it: the
105
+ * service's command, ports and restart policy, compose's labels, and the
106
+ * config's content in the container at its target.
107
+ */
108
+ function engine(project: string, service: string, svc: Json, configText: string): DockerEngine {
109
+ const target = ((svc.configs as Json[])[0]!.target as string) ?? COLLECTOR_CONFIG_PATH;
110
+ const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {};
111
+ for (const p of (svc.ports as string[]) ?? []) {
112
+ const [host, rest] = p.split(":");
113
+ const [port, proto] = rest!.split("/");
114
+ portBindings[`${port}/${proto ?? "tcp"}`] = [{ HostIp: "", HostPort: host! }];
115
+ }
116
+ const labels = {
117
+ "com.docker.compose.project": project,
118
+ "com.docker.compose.service": service,
119
+ "com.docker.compose.container-number": "1",
120
+ "com.docker.compose.oneoff": "False",
121
+ "com.docker.compose.config-hash": "abc",
122
+ };
123
+ const inspect = {
124
+ Id: "c1",
125
+ Name: `/${project}-${service}-1`,
126
+ Config: {
127
+ Image: svc.image,
128
+ Cmd: svc.command,
129
+ Entrypoint: ["/otelcol-contrib"],
130
+ Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
131
+ Labels: { ...labels, "org.opencontainers.image.title": "otelcol-contrib" },
132
+ WorkingDir: "",
133
+ User: "10001",
134
+ },
135
+ HostConfig: { PortBindings: portBindings, RestartPolicy: { Name: svc.restart, MaximumRetryCount: 0 } },
136
+ Mounts: [],
137
+ NetworkSettings: { Networks: { [`${project}_default`]: {} } },
138
+ };
139
+ const image = {
140
+ Config: {
141
+ Cmd: ["--config", "/etc/otelcol-contrib/config.yaml"],
142
+ Entrypoint: ["/otelcol-contrib"],
143
+ Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
144
+ Labels: { "org.opencontainers.image.title": "otelcol-contrib" },
145
+ User: "10001",
146
+ },
147
+ };
148
+ const ok = (body: unknown) => ({ status: 200, body: Buffer.from(JSON.stringify(body)) });
149
+ return async (path) => {
150
+ const url = new URL(`http://docker${path}`);
151
+ if (url.pathname === "/containers/json") {
152
+ const filters = JSON.parse(url.searchParams.get("filters") ?? "{}") as { label?: string[] };
153
+ expect(filters.label).toEqual([`com.docker.compose.project=${project}`]);
154
+ return ok([{ Id: "c1", Labels: labels }]);
155
+ }
156
+ if (url.pathname === "/containers/c1/json") return ok(inspect);
157
+ if (url.pathname.startsWith("/images/")) return ok(image);
158
+ if (url.pathname === "/containers/c1/archive") {
159
+ if (url.searchParams.get("path") !== target) return { status: 404, body: Buffer.from('{"message":"not found"}') };
160
+ return { status: 200, body: tarOf([target.split("/").pop()!, configText]) };
161
+ }
162
+ return { status: 404, body: Buffer.alloc(0) };
163
+ };
164
+ }
165
+
166
+ describe("DockerOtelCollector through chant import (#3373, #4388)", () => {
167
+ test("a live import gives the otel source a file import gives, and builds back to the same config", async () => {
168
+ const { text: composeText, doc } = await compose(SOURCE);
169
+ const { service, config } = collector(doc);
170
+ const expected = standalone(config);
171
+ expect(Object.keys(expected).length).toBeGreaterThan(0);
172
+
173
+ const fileProject = project();
174
+ const liveProject = project();
175
+ try {
176
+ // File import of the build's docker-compose.yml.
177
+ const templatePath = join(fileProject.dir, "docker-compose.yml");
178
+ writeFileSync(templatePath, composeText);
179
+ const fileResult = await importCommand({ templatePath, output: fileProject.srcDir, lexicon: "docker" });
180
+ expect(fileResult.error).toBeUndefined();
181
+ expect(fileResult.warnings.filter((w) => w.includes("kept as written"))).toEqual([]);
182
+ const fromFile = files(fileProject.srcDir);
183
+
184
+ // Live import from a fake Engine.
185
+ const svc = (doc.services as Record<string, Json>)[service]!;
186
+ const plugin: LexiconPlugin = { ...dockerPlugin, exportResources: (options) => exportResources(options, engine("test", service, svc, config)) };
187
+ const liveResult = await liveImportFromPlugins([plugin, otelPlugin], {
188
+ environment: "test",
189
+ output: liveProject.srcDir,
190
+ lexicon: "docker",
191
+ force: true,
192
+ });
193
+ expect(liveResult.error).toBeUndefined();
194
+ expect(liveResult.success).toBe(true);
195
+ expect(liveResult.warnings.filter((w) => w.includes("kept as written"))).toEqual([]);
196
+ const fromLive = files(liveProject.srcDir);
197
+
198
+ const dir = service.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
199
+ expect(under(fromLive, dir)).toEqual(expected);
200
+ expect(under(fromFile, dir)).toEqual(expected);
201
+ expect(fromLive["main.ts"]).toContain('import { collectorYaml } from "@intentius/chant-lexicon-otel";');
202
+ expect(fromLive["main.ts"]).toContain("content: collectorYaml(");
203
+
204
+ // The imported source builds back to the same service and config.
205
+ const result = await build(liveProject.srcDir, [dockerSerializer, otelSerializer]);
206
+ expect(result.errors).toEqual([]);
207
+ const rebuilt = { doc: load(composeOf(result.outputs.get("docker"))) as Json };
208
+ const after = collector(rebuilt.doc);
209
+ expect(load(after.config)).toEqual(load(config));
210
+ const before = (doc.services as Record<string, Json>)[service]!;
211
+ const back = (rebuilt.doc.services as Record<string, Json>)[after.service]!;
212
+ expect(back.image).toBe(before.image);
213
+ expect(back.command).toEqual(before.command);
214
+ expect(back.ports).toEqual(before.ports);
215
+ expect(back.restart).toBe(before.restart);
216
+ expect((back.configs as Json[])[0]!.target).toBe(COLLECTOR_CONFIG_PATH);
217
+ } finally {
218
+ rmSync(fileProject.dir, { recursive: true, force: true });
219
+ rmSync(liveProject.dir, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ test("without a context the export keeps the content as read, and offers a reparse", async () => {
224
+ const { doc } = await compose(SOURCE);
225
+ const { service, config } = collector(doc);
226
+ const svc = (doc.services as Record<string, Json>)[service]!;
227
+ const ir = await exportResources({ environment: "test" }, engine("test", service, svc, config));
228
+ const cfg = ir.resources.find((r) => r.type === "config")!;
229
+ expect(cfg.properties.content).toBe(config);
230
+ expect(typeof ir.reparse).toBe("function");
231
+ });
232
+
233
+ test("only a config's content that is a document is offered", () => {
234
+ const probe = new EmbeddedImports([], { quiet: true });
235
+ const compose = [
236
+ "services:",
237
+ " app:",
238
+ " image: x",
239
+ " configs: [{ source: motd, target: /etc/motd }, { source: settings, target: /etc/app.yaml }, { source: fromfile }]",
240
+ "configs:",
241
+ " motd: { content: \"hello there\" }",
242
+ " settings: { content: \"a: 1\\n\" }",
243
+ " fromfile: { file: ./x.yaml }",
244
+ ].join("\n");
245
+ new DockerParser().parse(compose, { embedded: probe });
246
+ expect(probe.offered.map((c) => [c.location, c.directory])).toEqual([["config settings content", "app"]]);
247
+ });
248
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Where a Compose file embeds another lexicon's content, for `chant import`
3
+ * (#3373, #4388).
4
+ *
5
+ * A top-level config with `content` is a file Compose writes into each
6
+ * container that mounts it. The parser offers that text to core's
7
+ * embedded-content resolver, as k8s offers a ConfigMap value (#2962) and fly
8
+ * a machine file. `DockerOtelCollector` carries its collector config this way,
9
+ * at the otel lexicon's `COLLECTOR_CONFIG_PATH`, so a file or live import of
10
+ * the project 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 } 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
+ const COLLECTOR = { lexicon: "otel", what: "an OpenTelemetry Collector config" } as const;
25
+
26
+ function hintFor(targets: string[], doc: unknown): EmbeddedContent["expectedOwner"] {
27
+ if (targets.includes(COLLECTOR_CONFIG_PATH)) return COLLECTOR;
28
+ if (isRecord(doc) && isRecord(doc.service) && isRecord(doc.service.pipelines)) return COLLECTOR;
29
+ return undefined;
30
+ }
31
+
32
+ /** Each service's mounts of top-level configs: config name, then the services and targets that mount it. */
33
+ function mountsByConfig(services: Record<string, unknown> | undefined): Map<string, Array<{ service: string; target: string }>> {
34
+ const out = new Map<string, Array<{ service: string; target: string }>>();
35
+ for (const [service, raw] of Object.entries(services ?? {})) {
36
+ if (!isRecord(raw) || !Array.isArray(raw.configs)) continue;
37
+ for (const c of raw.configs) {
38
+ const source = typeof c === "string" ? c : isRecord(c) && typeof c.source === "string" ? c.source : undefined;
39
+ if (!source) continue;
40
+ const target = isRecord(c) && typeof c.target === "string" ? c.target : `/${source}`;
41
+ const list = out.get(source) ?? [];
42
+ list.push({ service, target });
43
+ out.set(source, list);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Offer each top-level config's `content` to `embedded`, replacing the value
51
+ * an owner imports with its reference. `configs` maps config name to its IR
52
+ * properties and is changed in place with fresh copies; `services` is the
53
+ * compose document's services section, read for where each config is mounted.
54
+ *
55
+ * The owner's modules go in a directory named after the one service that
56
+ * mounts the config, else after the config, so a file import and a live
57
+ * import (which cannot recover the config's key) write them to the same place.
58
+ */
59
+ export function delegateEmbedded(
60
+ configs: Map<string, Record<string, unknown>>,
61
+ services: Record<string, unknown> | undefined,
62
+ embedded: EmbeddedContentResolver,
63
+ ): void {
64
+ const mounts = mountsByConfig(services);
65
+ for (const [name, props] of configs) {
66
+ const text = props.content;
67
+ if (typeof text !== "string") continue;
68
+ const doc = embeddedDocument(text);
69
+ if (!isRecord(doc)) continue;
70
+ const users = mounts.get(name) ?? [];
71
+ const owners = new Set(users.map((u) => u.service));
72
+ const ref = embedded.resolve({
73
+ host: "docker",
74
+ hostType: "config",
75
+ location: `config ${name} content`,
76
+ directory: owners.size === 1 ? [...owners][0]! : name,
77
+ text,
78
+ document: doc,
79
+ expectedOwner: hintFor(users.map((u) => u.target), doc),
80
+ });
81
+ if (ref) configs.set(name, { ...props, content: ref });
82
+ }
83
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A read-only Docker Engine API client for live export (#4388), and the tar
3
+ * reader for `GET /containers/{id}/archive`.
4
+ *
5
+ * The client is a single function, `(path) => { status, body }`, so tests hand
6
+ * export a fake Engine and never need a daemon. The default one speaks HTTP
7
+ * over the socket `DOCKER_HOST` names (`unix://…`, or plain `tcp://host:port`),
8
+ * else `/var/run/docker.sock`, with the unversioned API paths the daemon
9
+ * serves at its own version.
10
+ */
11
+
12
+ import { request } from "node:http";
13
+
14
+ /** One GET against the Engine API. `path` starts with `/`. */
15
+ export type DockerEngine = (path: string, signal?: AbortSignal) => Promise<{ status: number; body: Buffer }>;
16
+
17
+ const DEFAULT_SOCKET = "/var/run/docker.sock";
18
+
19
+ /** Where `DOCKER_HOST` points: a unix socket path, or a host and port. */
20
+ export function engineAddress(dockerHost = process.env.DOCKER_HOST): { socketPath: string } | { host: string; port: number } {
21
+ if (!dockerHost) return { socketPath: DEFAULT_SOCKET };
22
+ if (dockerHost.startsWith("unix://")) return { socketPath: dockerHost.slice("unix://".length) };
23
+ if (dockerHost.startsWith("tcp://")) {
24
+ if (process.env.DOCKER_TLS_VERIFY) {
25
+ throw new Error("docker live export: DOCKER_TLS_VERIFY is set, and a TLS Engine endpoint is not supported; use a unix socket or a plain tcp:// endpoint");
26
+ }
27
+ const url = new URL(`http://${dockerHost.slice("tcp://".length)}`);
28
+ return { host: url.hostname, port: Number(url.port || 2375) };
29
+ }
30
+ throw new Error(`docker live export: DOCKER_HOST ${dockerHost} is not a unix:// or tcp:// endpoint`);
31
+ }
32
+
33
+ /** The Engine at `DOCKER_HOST` (or the default socket). */
34
+ export function defaultDockerEngine(dockerHost?: string): DockerEngine {
35
+ const address = engineAddress(dockerHost);
36
+ return (path, signal) =>
37
+ new Promise((resolve, reject) => {
38
+ const req = request({ ...address, path, method: "GET", headers: { Host: "docker" }, signal }, (res) => {
39
+ const chunks: Buffer[] = [];
40
+ res.on("data", (c: Buffer) => chunks.push(c));
41
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks) }));
42
+ res.on("error", reject);
43
+ });
44
+ req.on("error", reject);
45
+ req.end();
46
+ });
47
+ }
48
+
49
+ /** One entry of a tar stream. `type` is the ustar typeflag (`0` file, `5` directory, …). */
50
+ export interface TarEntry {
51
+ name: string;
52
+ type: string;
53
+ data: Buffer;
54
+ }
55
+
56
+ function field(block: Buffer, start: number, length: number): string {
57
+ const raw = block.subarray(start, start + length);
58
+ const end = raw.indexOf(0);
59
+ return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
60
+ }
61
+
62
+ /** The entries of an uncompressed tar stream. PAX and GNU long-name records are applied to the entry after them. */
63
+ export function readTar(tar: Buffer): TarEntry[] {
64
+ const out: TarEntry[] = [];
65
+ let offset = 0;
66
+ let longName: string | undefined;
67
+ while (offset + 512 <= tar.length) {
68
+ const header = tar.subarray(offset, offset + 512);
69
+ if (header.every((b) => b === 0)) break;
70
+ const size = parseInt(field(header, 124, 12).trim() || "0", 8);
71
+ const type = field(header, 156, 1) || "0";
72
+ const prefix = field(header, 345, 155);
73
+ let name = prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100);
74
+ const data = tar.subarray(offset + 512, offset + 512 + size);
75
+ offset += 512 + Math.ceil(size / 512) * 512;
76
+
77
+ if (type === "x") {
78
+ const path = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString("utf8"));
79
+ if (path) longName = path[1];
80
+ continue;
81
+ }
82
+ if (type === "L") {
83
+ longName = data.toString("utf8").replace(/\0+$/, "");
84
+ continue;
85
+ }
86
+ if (type === "g") continue;
87
+ if (longName !== undefined) {
88
+ name = longName;
89
+ longName = undefined;
90
+ }
91
+ out.push({ name, type: type === "\0" ? "0" : type, data: Buffer.from(data) });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * The text of the one regular file an archive holds, or undefined when it
98
+ * holds anything else (a directory, a link, several entries) or the bytes
99
+ * are not UTF-8 text.
100
+ */
101
+ export function singleFileText(tar: Buffer): string | undefined {
102
+ const entries = readTar(tar);
103
+ if (entries.length !== 1 || entries[0]!.type !== "0") return undefined;
104
+ const bytes = entries[0]!.data;
105
+ const text = bytes.toString("utf8");
106
+ return Buffer.from(text, "utf8").equals(bytes) ? text : undefined;
107
+ }
@@ -4,6 +4,7 @@
4
4
  * Converts parsed IR to valid chant TypeScript source code.
5
5
  */
6
6
 
7
+ import { EmbeddedImportScope, isEmbeddedReference, renderEmbeddedReference, type EmbeddedReference } from "@intentius/chant/import/embedded";
7
8
  import type { DockerIR, ServiceIR, VolumeIR, NetworkIR, ConfigIR, SecretIR, DockerfileIR } from "./parser";
8
9
 
9
10
  export interface GenerateResult {
@@ -21,16 +22,21 @@ export class DockerGenerator {
21
22
  const lines: string[] = [];
22
23
  const names = new ExportNames();
23
24
 
24
- for (const entity of entities) {
25
+ // Export names first, so the imports an embedded reference needs (#3373)
26
+ // never take one.
27
+ const exportNames = entities.map((e) => (e.kind === "dockerfile" ? sanitizeName(e.name) : names.take(e.name, e.kind)));
28
+ const scope = new EmbeddedImportScope("", [...RESERVED, ...exportNames]);
29
+
30
+ entities.forEach((entity, i) => {
25
31
  if (entity.kind === "dockerfile") {
26
32
  imports.add("Dockerfile");
27
33
  lines.push(generateDockerfile(entity, warnings));
28
- continue;
34
+ return;
29
35
  }
30
36
  const ctor = CONSTRUCTORS[entity.kind];
31
37
  imports.add(ctor);
32
- const exportName = names.take(entity.name, entity.kind);
33
- const expr = `new ${ctor}(${propsLiteral(entity)})`;
38
+ const exportName = exportNames[i]!;
39
+ const expr = `new ${ctor}(${propsLiteral(entity, scope)})`;
34
40
  // An export name that differs from the compose key would rename the
35
41
  // key on build, and every reference to it by name would dangle, so
36
42
  // the key is set explicitly (#3937).
@@ -40,10 +46,10 @@ export class DockerGenerator {
40
46
  imports.add("composeKey");
41
47
  lines.push(`export const ${exportName} = composeKey(${JSON.stringify(entity.name)}, ${expr});`);
42
48
  }
43
- }
49
+ });
44
50
 
45
51
  const importLine = `import { ${[...imports].sort().join(", ")} } from "@intentius/chant-lexicon-docker";`;
46
- const source = [importLine, "", ...lines].join("\n");
52
+ const source = [importLine, ...scope.lines(), "", ...lines].join("\n");
47
53
 
48
54
  return { source, warnings };
49
55
  }
@@ -57,11 +63,37 @@ const CONSTRUCTORS = {
57
63
  secret: "DockerSecret",
58
64
  } as const;
59
65
 
60
- function propsLiteral(entity: ServiceIR | VolumeIR | NetworkIR | ConfigIR | SecretIR): string {
61
- if (entity.kind === "service") {
62
- return JSON.stringify(entity.props, null, 2).replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
66
+ function propsLiteral(entity: ServiceIR | VolumeIR | NetworkIR | ConfigIR | SecretIR, scope: EmbeddedImportScope): string {
67
+ // Embedded references (a config's `content` another lexicon imported) are
68
+ // rendered as expressions in place of a placeholder string.
69
+ const refs: EmbeddedReference[] = [];
70
+ const props = withPlaceholders(entity.props, refs) as Record<string, unknown>;
71
+ let out: string;
72
+ if (entity.kind === "service" || refs.length > 0) {
73
+ out = JSON.stringify(props, null, 2).replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
74
+ } else {
75
+ out = Object.keys(props).length > 0 ? JSON.stringify(props) : "{}";
76
+ }
77
+ refs.forEach((ref, i) => {
78
+ out = out.replace(JSON.stringify(placeholder(i)), () => renderEmbeddedReference(ref, scope, 2));
79
+ });
80
+ return out;
81
+ }
82
+
83
+ function placeholder(i: number): string {
84
+ return `\u0000chant-embedded-${i}\u0000`;
85
+ }
86
+
87
+ function withPlaceholders(value: unknown, refs: EmbeddedReference[]): unknown {
88
+ if (isEmbeddedReference(value)) {
89
+ refs.push(value);
90
+ return placeholder(refs.length - 1);
91
+ }
92
+ if (Array.isArray(value)) return value.map((v) => withPlaceholders(v, refs));
93
+ if (value && typeof value === "object") {
94
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, withPlaceholders(v, refs)]));
63
95
  }
64
- return Object.keys(entity.props).length > 0 ? JSON.stringify(entity.props) : "{}";
96
+ return value;
65
97
  }
66
98
 
67
99
  /** Words an export cannot be named, and the names the generated file imports. */