@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,271 @@
1
+ /**
2
+ * docker live export (#4388): a compose project read from a fake Engine API
3
+ * is mapped back to the compose document it was created from, and
4
+ * `chant import --from` regenerates its services.
5
+ */
6
+
7
+ import { describe, expect, test, vi } from "vitest";
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
9
+ import { join, resolve } from "path";
10
+ import { liveImportFromPlugins } from "@intentius/chant/cli/commands/import";
11
+ import type { LexiconPlugin } from "@intentius/chant/lexicon";
12
+ import { dockerPlugin } from "../plugin";
13
+ import { exportResources, projectFor } from "../export-resources";
14
+ import { engineAddress, readTar, singleFileText, type DockerEngine } from "./engine";
15
+ import {
16
+ composeFromLive,
17
+ configCandidates,
18
+ serviceContainers,
19
+ type ContainerInspect,
20
+ type ImageInspect,
21
+ type LiveService,
22
+ } from "./live-export";
23
+ import { tarOf } from "./testdata/tar";
24
+
25
+ const pkgDir = resolve(import.meta.dirname, "../..");
26
+
27
+ const compose = (project: string, service: string, extra: Record<string, string> = {}) => ({
28
+ "com.docker.compose.project": project,
29
+ "com.docker.compose.service": service,
30
+ "com.docker.compose.container-number": "1",
31
+ "com.docker.compose.oneoff": "False",
32
+ ...extra,
33
+ });
34
+
35
+ const PG_IMAGE: ImageInspect = {
36
+ Config: {
37
+ Cmd: ["postgres"],
38
+ Entrypoint: ["docker-entrypoint.sh"],
39
+ Env: ["PATH=/usr/bin", "PGDATA=/var/lib/postgresql/data"],
40
+ Labels: null,
41
+ },
42
+ };
43
+
44
+ const db: ContainerInspect = {
45
+ Id: "db1",
46
+ Config: {
47
+ Image: "postgres:16-alpine",
48
+ Cmd: ["postgres"],
49
+ Entrypoint: ["docker-entrypoint.sh"],
50
+ Env: ["POSTGRES_DB=app", "PATH=/usr/bin", "PGDATA=/var/lib/postgresql/data"],
51
+ Labels: compose("shop", "db", { "app.kubernetes.io/managed-by": "chant" }),
52
+ Healthcheck: { Test: ["CMD", "pg_isready"], Interval: 5e9, Timeout: 2e9, Retries: 5 },
53
+ },
54
+ HostConfig: { PortBindings: {}, RestartPolicy: { Name: "no" } },
55
+ Mounts: [{ Type: "volume", Name: "shop_pgdata", Destination: "/var/lib/postgresql/data", RW: true }],
56
+ NetworkSettings: { Networks: { shop_default: {} } },
57
+ };
58
+
59
+ const API_IMAGE: ImageInspect = { Config: { Cmd: ["node", "server.js"], Env: ["NODE_ENV=production"], WorkingDir: "/app" } };
60
+
61
+ const api: ContainerInspect = {
62
+ Id: "api1",
63
+ Config: {
64
+ Image: "shop/api:1.0",
65
+ Cmd: ["node", "server.js", "--settings=/etc/api/settings.json"],
66
+ Env: ["NODE_ENV=production", "DATABASE_URL=postgres://db/app"],
67
+ Labels: compose("shop", "api", { "com.docker.compose.depends_on": "db:service_healthy:false", team: "web" }),
68
+ WorkingDir: "/app",
69
+ },
70
+ HostConfig: {
71
+ PortBindings: { "8080/tcp": [{ HostIp: "", HostPort: "8080" }, { HostIp: "::", HostPort: "8080" }], "9000/udp": [{ HostIp: "127.0.0.1", HostPort: "9000" }] },
72
+ RestartPolicy: { Name: "on-failure", MaximumRetryCount: 3 },
73
+ },
74
+ Mounts: [
75
+ { Type: "bind", Source: "/srv/shop/static", Destination: "/app/static", RW: false },
76
+ { Type: "volume", Name: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Destination: "/tmp/cache", RW: true },
77
+ { Type: "volume", Name: "shared-logs", Destination: "/var/log/api", RW: true },
78
+ ],
79
+ NetworkSettings: { Networks: { shop_default: {}, shop_backend: {} } },
80
+ };
81
+
82
+ const SETTINGS = '{ "feature": true }\n';
83
+
84
+ function fakeEngine(): { engine: DockerEngine; paths: string[] } {
85
+ const paths: string[] = [];
86
+ const ok = (body: unknown) => ({ status: 200, body: Buffer.from(JSON.stringify(body)) });
87
+ const engine: DockerEngine = async (path) => {
88
+ paths.push(path);
89
+ const url = new URL(`http://docker${path}`);
90
+ if (url.pathname === "/containers/json") {
91
+ return ok([
92
+ { Id: "api1", Labels: api.Config!.Labels },
93
+ { Id: "db1", Labels: db.Config!.Labels },
94
+ { Id: "api2", Labels: { ...api.Config!.Labels!, "com.docker.compose.container-number": "2" } },
95
+ { Id: "run1", Labels: { ...api.Config!.Labels!, "com.docker.compose.oneoff": "True" } },
96
+ ]);
97
+ }
98
+ if (url.pathname === "/containers/api1/json") return ok(api);
99
+ if (url.pathname === "/containers/db1/json") return ok(db);
100
+ if (url.pathname === `/images/${encodeURIComponent("shop/api:1.0")}/json`) return ok(API_IMAGE);
101
+ if (url.pathname === "/images/postgres:16-alpine/json") return ok(PG_IMAGE);
102
+ if (url.pathname === "/containers/api1/archive" && url.searchParams.get("path") === "/etc/api/settings.json") {
103
+ return { status: 200, body: tarOf(["settings.json", SETTINGS]) };
104
+ }
105
+ return { status: 404, body: Buffer.from('{"message":"no such thing"}') };
106
+ };
107
+ return { engine, paths };
108
+ }
109
+
110
+ describe("serviceContainers", () => {
111
+ test("one container per service, the lowest number, no one-off runs, only the project's", () => {
112
+ const got = serviceContainers("shop", [
113
+ { Id: "b", Labels: compose("shop", "api", { "com.docker.compose.container-number": "2" }) },
114
+ { Id: "a", Labels: compose("shop", "api") },
115
+ { Id: "r", Labels: compose("shop", "api", { "com.docker.compose.oneoff": "True" }) },
116
+ { Id: "x", Labels: compose("other", "web") },
117
+ { Id: "n", Labels: null },
118
+ ]);
119
+ expect([...got]).toEqual([["api", "a"]]);
120
+ });
121
+ });
122
+
123
+ describe("configCandidates", () => {
124
+ test("absolute file paths the command names, outside every mount", () => {
125
+ expect(
126
+ configCandidates({
127
+ Config: {
128
+ Entrypoint: ["/otelcol-contrib"],
129
+ Cmd: ["--config=/etc/otel/config.yaml", "--set", "x=1", "file:/etc/b.yml", "/data/in.csv", "/usr/bin/run", "--config=/etc/otel/config.yaml"],
130
+ },
131
+ Mounts: [{ Type: "volume", Name: "v", Destination: "/data" }],
132
+ }),
133
+ ).toEqual(["/etc/otel/config.yaml", "/etc/b.yml"]);
134
+ });
135
+ });
136
+
137
+ describe("composeFromLive", () => {
138
+ const live = (name: string, container: ContainerInspect, image: ImageInspect, files: Record<string, string> = {}): LiveService => ({
139
+ name,
140
+ container,
141
+ image,
142
+ files,
143
+ });
144
+
145
+ test("drops what the image supplies and what compose writes, and keeps what was declared", () => {
146
+ const doc = composeFromLive("shop", [live("api", api, API_IMAGE, { "/etc/api/settings.json": SETTINGS }), live("db", db, PG_IMAGE)]);
147
+ expect(doc).toEqual({
148
+ services: {
149
+ api: {
150
+ image: "shop/api:1.0",
151
+ command: ["node", "server.js", "--settings=/etc/api/settings.json"],
152
+ environment: { DATABASE_URL: "postgres://db/app" },
153
+ labels: { team: "web" },
154
+ ports: ["8080:8080", "127.0.0.1:9000:9000/udp"],
155
+ restart: "on-failure:3",
156
+ depends_on: { db: { condition: "service_healthy" } },
157
+ volumes: ["/srv/shop/static:/app/static:ro", "/tmp/cache", "shared-logs:/var/log/api"],
158
+ networks: ["default", "backend"],
159
+ configs: [{ source: "api-settings", target: "/etc/api/settings.json" }],
160
+ },
161
+ db: {
162
+ image: "postgres:16-alpine",
163
+ environment: { POSTGRES_DB: "app" },
164
+ labels: { "app.kubernetes.io/managed-by": "chant" },
165
+ healthcheck: { test: ["CMD", "pg_isready"], interval: "5s", timeout: "2s", retries: 5 },
166
+ volumes: ["pgdata:/var/lib/postgresql/data"],
167
+ },
168
+ },
169
+ volumes: { "shared-logs": { external: true }, pgdata: {} },
170
+ networks: { backend: {} },
171
+ configs: { "api-settings": { content: SETTINGS } },
172
+ });
173
+ });
174
+
175
+ test("verbatim keeps what the image supplies, never compose's labels", () => {
176
+ const doc = composeFromLive("shop", [live("db", db, PG_IMAGE)], { verbatim: true });
177
+ const svc = (doc.services as Record<string, Record<string, unknown>>).db!;
178
+ expect(svc.command).toEqual(["postgres"]);
179
+ expect(svc.entrypoint).toEqual(["docker-entrypoint.sh"]);
180
+ expect(svc.environment).toEqual({ POSTGRES_DB: "app", PATH: "/usr/bin", PGDATA: "/var/lib/postgresql/data" });
181
+ expect(svc.labels).toEqual({ "app.kubernetes.io/managed-by": "chant" });
182
+ });
183
+ });
184
+
185
+ describe("exportResources against a fake Engine", () => {
186
+ test("reads the project's services and the config files their commands name", async () => {
187
+ const { engine, paths } = fakeEngine();
188
+ const ir = await exportResources({ environment: "shop" }, engine);
189
+ expect(ir.resources.map((r) => `${r.type}/${r.logicalId}`)).toEqual([
190
+ "service/api",
191
+ "service/db",
192
+ "volume/shared-logs",
193
+ "volume/pgdata",
194
+ "network/backend",
195
+ "config/api-settings",
196
+ ]);
197
+ expect(paths.filter((p) => p.includes("/archive"))).toEqual([`/containers/api1/archive?path=${encodeURIComponent("/etc/api/settings.json")}`]);
198
+ expect(paths.some((p) => p.includes("api2") || p.includes("run1"))).toBe(false);
199
+ });
200
+
201
+ test("owned keeps only services with chant's marker, and says why", async () => {
202
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
203
+ try {
204
+ const { engine, paths } = fakeEngine();
205
+ const ir = await exportResources({ environment: "shop", owned: true }, engine);
206
+ expect(ir.resources.map((r) => `${r.type}/${r.logicalId}`)).toEqual(["service/db", "volume/pgdata"]);
207
+ expect(paths.some((p) => p.includes("/archive"))).toBe(false);
208
+ expect(warn).toHaveBeenCalledTimes(1);
209
+ } finally {
210
+ warn.mockRestore();
211
+ }
212
+ });
213
+
214
+ test("the selector narrows by type and name", async () => {
215
+ const { engine } = fakeEngine();
216
+ const ir = await exportResources({ environment: "shop", selector: { type: "service", name: "db" } }, engine);
217
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["db"]);
218
+ });
219
+
220
+ test("COMPOSE_PROJECT_NAME names the project, else the environment", () => {
221
+ const prev = process.env.COMPOSE_PROJECT_NAME;
222
+ try {
223
+ delete process.env.COMPOSE_PROJECT_NAME;
224
+ expect(projectFor("staging")).toBe("staging");
225
+ process.env.COMPOSE_PROJECT_NAME = "shop";
226
+ expect(projectFor("staging")).toBe("shop");
227
+ } finally {
228
+ if (prev === undefined) delete process.env.COMPOSE_PROJECT_NAME;
229
+ else process.env.COMPOSE_PROJECT_NAME = prev;
230
+ }
231
+ });
232
+
233
+ test("a failed listing is an error, not an empty project", async () => {
234
+ const engine: DockerEngine = async () => ({ status: 500, body: Buffer.from("daemon down") });
235
+ await expect(exportResources({ environment: "shop" }, engine)).rejects.toThrow(/compose project shop failed \(500\): daemon down/);
236
+ });
237
+
238
+ test("chant import --from regenerates the project's services", async () => {
239
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
240
+ try {
241
+ writeFileSync(join(dir, "package.json"), '{ "name": "docker-live", "private": true, "type": "module" }\n');
242
+ const { engine } = fakeEngine();
243
+ const plugin: LexiconPlugin = { ...dockerPlugin, exportResources: (options) => exportResources(options, engine) };
244
+ const result = await liveImportFromPlugins([plugin], { environment: "shop", output: join(dir, "src"), lexicon: "docker", force: true });
245
+ expect(result.error).toBeUndefined();
246
+ expect(result.success).toBe(true);
247
+ const main = result.generatedFiles.map((f) => readFileSync(join(dir, "src", f), "utf-8")).join("\n");
248
+ expect(main).toContain("export const api = new Service(");
249
+ expect(main).toContain('image: "postgres:16-alpine"');
250
+ expect(main).toContain('composeKey("api-settings", new DockerConfig(');
251
+ expect(main).toContain(JSON.stringify(SETTINGS));
252
+ } finally {
253
+ rmSync(dir, { recursive: true, force: true });
254
+ }
255
+ });
256
+ });
257
+
258
+ describe("engine", () => {
259
+ test("DOCKER_HOST: unix socket, plain tcp, else the default socket", () => {
260
+ expect(engineAddress("unix:///run/user/1000/docker.sock")).toEqual({ socketPath: "/run/user/1000/docker.sock" });
261
+ expect(engineAddress("tcp://10.0.0.5:2375")).toEqual({ host: "10.0.0.5", port: 2375 });
262
+ expect(engineAddress("")).toEqual({ socketPath: "/var/run/docker.sock" });
263
+ expect(() => engineAddress("ssh://me@host")).toThrow(/not a unix:\/\/ or tcp:\/\//);
264
+ });
265
+
266
+ test("an archive of one text file gives its text; a directory, several files or bytes do not", () => {
267
+ expect(singleFileText(tarOf(["config.yaml", "a: 1\n"]))).toBe("a: 1\n");
268
+ expect(singleFileText(tarOf(["conf.d/", null], ["conf.d/a.yaml", "a: 1\n"]))).toBeUndefined();
269
+ expect(readTar(tarOf(["x", "y".repeat(700)]))[0]!.data.length).toBe(700);
270
+ });
271
+ });
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Pure export helpers for the docker lexicon (#4388): turn what the Engine API
3
+ * returns for a compose project's containers back into the compose document a
4
+ * user would have written, and map it to import IR through `DockerParser`. All
5
+ * Engine I/O stays in the caller (`../export-resources`).
6
+ *
7
+ * A compose project is the set of containers labelled
8
+ * `com.docker.compose.project=<name>`; each service is the container labelled
9
+ * `com.docker.compose.service`, the lowest `container-number` when scaled.
10
+ * One-off containers (`compose run`) are skipped.
11
+ *
12
+ * Configs. Outside swarm, a top-level config is not an Engine object. A
13
+ * `content` config is copied into the container when it is created, and a
14
+ * `file` config is bind-mounted; neither is named anywhere the Engine keeps.
15
+ * What the container does keep is its command, so the files a service is
16
+ * told to read (`--config=/etc/otelcol-contrib/config.yaml`) are the
17
+ * candidates, and the caller reads each through
18
+ * `GET /containers/{id}/archive?path=…`, which works for both forms and for a
19
+ * remote Engine whose host paths are out of reach. A candidate that reads back
20
+ * as one text file becomes a `content` config mounted at that path, named
21
+ * `<service>-<file name>` (the original key is not recoverable). A candidate
22
+ * inside a volume or a directory bind mount belongs to that mount and is not
23
+ * read.
24
+ *
25
+ * The strip. By default fields the image supplies (its command, entrypoint,
26
+ * environment, labels, working dir and user) are dropped, so a service reaches
27
+ * the shape it was declared in; `verbatim` keeps them. Compose's own
28
+ * `com.docker.compose.*` labels are always dropped: compose writes them, and a
29
+ * declaration that set them would fight it.
30
+ *
31
+ * Ownership. The docker serializer stamps no marker, so under `owned` only
32
+ * services carrying the label marker (`app.kubernetes.io/managed-by: chant`,
33
+ * set with `defaultLabels`) are exported, and the caller logs that once.
34
+ */
35
+
36
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
37
+ import type { ParseContext, TemplateIR } from "@intentius/chant/import/parser";
38
+ import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
39
+ import { DockerParser } from "./parser";
40
+ import { dockerResultToTemplate } from "./adapter";
41
+
42
+ export const PROJECT_LABEL = "com.docker.compose.project";
43
+ export const SERVICE_LABEL = "com.docker.compose.service";
44
+ const NUMBER_LABEL = "com.docker.compose.container-number";
45
+ const ONEOFF_LABEL = "com.docker.compose.oneoff";
46
+ const DEPENDS_LABEL = "com.docker.compose.depends_on";
47
+
48
+ type Json = Record<string, unknown>;
49
+
50
+ /** A container's or an image's healthcheck, durations in nanoseconds. */
51
+ export interface HealthcheckInspect {
52
+ Test?: string[];
53
+ Interval?: number;
54
+ Timeout?: number;
55
+ Retries?: number;
56
+ StartPeriod?: number;
57
+ }
58
+
59
+ /** What `GET /containers/{id}/json` returns, as far as export reads it. */
60
+ export interface ContainerInspect {
61
+ Id?: string;
62
+ Name?: string;
63
+ Config?: {
64
+ Image?: string;
65
+ Cmd?: string[] | null;
66
+ Entrypoint?: string[] | null;
67
+ Env?: string[] | null;
68
+ Labels?: Record<string, string> | null;
69
+ WorkingDir?: string;
70
+ User?: string;
71
+ Healthcheck?: HealthcheckInspect | null;
72
+ };
73
+ HostConfig?: {
74
+ PortBindings?: Record<string, Array<{ HostIp?: string; HostPort?: string }> | null> | null;
75
+ RestartPolicy?: { Name?: string; MaximumRetryCount?: number } | null;
76
+ };
77
+ Mounts?: Array<{ Type?: string; Name?: string; Source?: string; Destination?: string; RW?: boolean }>;
78
+ NetworkSettings?: { Networks?: Record<string, unknown> | null };
79
+ }
80
+
81
+ /** What `GET /images/{name}/json` returns, as far as export reads it. */
82
+ export interface ImageInspect {
83
+ Config?: {
84
+ Cmd?: string[] | null;
85
+ Entrypoint?: string[] | null;
86
+ Env?: string[] | null;
87
+ Labels?: Record<string, string> | null;
88
+ WorkingDir?: string;
89
+ User?: string;
90
+ Healthcheck?: HealthcheckInspect | null;
91
+ };
92
+ }
93
+
94
+ /** One service as read from the Engine: its container, its image, and the config files read out of it. */
95
+ export interface LiveService {
96
+ name: string;
97
+ container: ContainerInspect;
98
+ image?: ImageInspect;
99
+ /** Config target path → the text read from the container. */
100
+ files: Record<string, string>;
101
+ }
102
+
103
+ function same(a: unknown, b: unknown): boolean {
104
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
105
+ }
106
+
107
+ /**
108
+ * The containers to export, one per service: compose-labelled for `project`,
109
+ * not one-off, and the lowest container number when a service is scaled.
110
+ * `summaries` is what `GET /containers/json` returns.
111
+ */
112
+ export function serviceContainers(project: string, summaries: Array<{ Id?: string; Labels?: Record<string, string> | null }>): Map<string, string> {
113
+ const best = new Map<string, { id: string; n: number }>();
114
+ for (const s of summaries) {
115
+ const labels = s.Labels ?? {};
116
+ if (labels[PROJECT_LABEL] !== project || !s.Id) continue;
117
+ if ((labels[ONEOFF_LABEL] ?? "").toLowerCase() === "true") continue;
118
+ const service = labels[SERVICE_LABEL];
119
+ if (!service) continue;
120
+ const n = Number(labels[NUMBER_LABEL] ?? "1") || 1;
121
+ const prev = best.get(service);
122
+ if (!prev || n < prev.n) best.set(service, { id: s.Id, n });
123
+ }
124
+ return new Map([...best].sort(([a], [b]) => a.localeCompare(b)).map(([service, { id }]) => [service, id]));
125
+ }
126
+
127
+ function under(path: string, dir: string): boolean {
128
+ const d = dir.endsWith("/") ? dir : `${dir}/`;
129
+ return path.startsWith(d);
130
+ }
131
+
132
+ /**
133
+ * The paths a container's command names that may be config files: an
134
+ * absolute path with a file extension, alone or as a `--flag=` value (an
135
+ * otel `file:` URI included). A path inside a volume, or inside a directory
136
+ * bind mount, belongs to that mount and is left out.
137
+ */
138
+ export function configCandidates(container: ContainerInspect): string[] {
139
+ const args = [...(container.Config?.Entrypoint ?? []), ...(container.Config?.Cmd ?? [])];
140
+ const mounts = container.Mounts ?? [];
141
+ const out: string[] = [];
142
+ for (const arg of args) {
143
+ let value = arg.startsWith("-") && arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : arg;
144
+ if (value.startsWith("file:")) value = value.slice("file:".length);
145
+ if (!/^\/[^\s,;:]+$/.test(value)) continue;
146
+ const base = value.split("/").pop()!;
147
+ if (!/\.[A-Za-z0-9]+$/.test(base)) continue;
148
+ if (mounts.some((m) => m.Destination && under(value, m.Destination))) continue;
149
+ if (!out.includes(value)) out.push(value);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function stripProjectPrefix(project: string, name: string): string | undefined {
155
+ return name.startsWith(`${project}_`) ? name.slice(project.length + 1) : undefined;
156
+ }
157
+
158
+ function envMap(env: string[]): Json {
159
+ const out: Json = {};
160
+ for (const e of env) {
161
+ const i = e.indexOf("=");
162
+ if (i === -1) out[e] = null;
163
+ else out[e.slice(0, i)] = e.slice(i + 1);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ function duration(ns: number | undefined): string | undefined {
169
+ if (!ns) return undefined;
170
+ const ms = ns / 1e6;
171
+ return ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
172
+ }
173
+
174
+ function portList(bindings: NonNullable<ContainerInspect["HostConfig"]>["PortBindings"]): string[] {
175
+ const out: string[] = [];
176
+ for (const [key, binds] of Object.entries(bindings ?? {})) {
177
+ const [port, proto] = key.split("/");
178
+ const suffix = proto && proto !== "tcp" ? `/${proto}` : "";
179
+ if (!binds || binds.length === 0) {
180
+ out.push(`${port}${suffix}`);
181
+ continue;
182
+ }
183
+ for (const b of binds) {
184
+ const ip = b.HostIp && b.HostIp !== "0.0.0.0" && b.HostIp !== "::" ? `${b.HostIp}:` : "";
185
+ out.push(b.HostPort ? `${ip}${b.HostPort}:${port}${suffix}` : `${port}${suffix}`);
186
+ }
187
+ }
188
+ // The Engine may list `::` and `0.0.0.0` bindings of one port separately.
189
+ return [...new Set(out)];
190
+ }
191
+
192
+ function dependsOn(label: string | undefined): unknown {
193
+ if (!label) return undefined;
194
+ const entries = label
195
+ .split(",")
196
+ .map((e) => e.split(":"))
197
+ .filter(([name]) => !!name);
198
+ if (entries.length === 0) return undefined;
199
+ if (entries.every(([, condition, restart]) => (condition ?? "service_started") === "service_started" && restart !== "true")) {
200
+ return entries.map(([name]) => name);
201
+ }
202
+ return Object.fromEntries(
203
+ entries.map(([name, condition, restart]) => [name, { condition: condition || "service_started", ...(restart === "true" ? { restart: true } : {}) }]),
204
+ );
205
+ }
206
+
207
+ /** The compose service a container was created from, and the config files it mounts. */
208
+ function serviceFromContainer(
209
+ project: string,
210
+ live: LiveService,
211
+ verbatim: boolean,
212
+ ): { service: Json; configs: Array<{ name: string; target: string; content: string }>; volumes: string[]; networks: string[] } {
213
+ const c = live.container.Config ?? {};
214
+ const img = live.image?.Config ?? {};
215
+ const keep = (live: unknown, image: unknown) => verbatim || !same(live, image);
216
+ const service: Json = {};
217
+
218
+ if (c.Image) service.image = c.Image;
219
+ if (c.Entrypoint && c.Entrypoint.length > 0 && keep(c.Entrypoint, img.Entrypoint)) service.entrypoint = c.Entrypoint;
220
+ if (c.Cmd && c.Cmd.length > 0 && keep(c.Cmd, img.Cmd)) service.command = c.Cmd;
221
+
222
+ const imageEnv = new Set(img.Env ?? []);
223
+ const env = (c.Env ?? []).filter((e) => verbatim || !imageEnv.has(e));
224
+ if (env.length > 0) service.environment = envMap(env);
225
+
226
+ const labels: Record<string, string> = {};
227
+ for (const [k, v] of Object.entries(c.Labels ?? {})) {
228
+ if (k.startsWith("com.docker.compose.")) continue;
229
+ if (!verbatim && img.Labels?.[k] === v) continue;
230
+ labels[k] = v;
231
+ }
232
+ if (Object.keys(labels).length > 0) service.labels = labels;
233
+
234
+ if (c.WorkingDir && keep(c.WorkingDir, img.WorkingDir ?? "")) service.working_dir = c.WorkingDir;
235
+ if (c.User && keep(c.User, img.User ?? "")) service.user = c.User;
236
+
237
+ if (c.Healthcheck?.Test && c.Healthcheck.Test.length > 0 && keep(c.Healthcheck, img.Healthcheck)) {
238
+ const h = c.Healthcheck;
239
+ service.healthcheck = {
240
+ test: h.Test,
241
+ ...(duration(h.Interval) ? { interval: duration(h.Interval) } : {}),
242
+ ...(duration(h.Timeout) ? { timeout: duration(h.Timeout) } : {}),
243
+ ...(h.Retries ? { retries: h.Retries } : {}),
244
+ ...(duration(h.StartPeriod) ? { start_period: duration(h.StartPeriod) } : {}),
245
+ };
246
+ }
247
+
248
+ const ports = portList(live.container.HostConfig?.PortBindings);
249
+ if (ports.length > 0) service.ports = ports;
250
+
251
+ const restart = live.container.HostConfig?.RestartPolicy;
252
+ if (restart?.Name && restart.Name !== "no") {
253
+ service.restart = restart.Name === "on-failure" && restart.MaximumRetryCount ? `on-failure:${restart.MaximumRetryCount}` : restart.Name;
254
+ }
255
+
256
+ const deps = dependsOn(c.Labels?.[DEPENDS_LABEL]);
257
+ if (deps !== undefined) service.depends_on = deps;
258
+
259
+ // Mounts: a config read at a mount's destination is that config, not a volume.
260
+ const volumes: string[] = [];
261
+ const mountList: string[] = [];
262
+ for (const m of live.container.Mounts ?? []) {
263
+ if (!m.Destination || live.files[m.Destination] !== undefined) continue;
264
+ const ro = m.RW === false ? ":ro" : "";
265
+ if (m.Type === "volume" && m.Name) {
266
+ const key = stripProjectPrefix(project, m.Name);
267
+ if (key) {
268
+ volumes.push(key);
269
+ mountList.push(`${key}:${m.Destination}${ro}`);
270
+ } else if (/^[0-9a-f]{64}$/.test(m.Name)) {
271
+ mountList.push(m.Destination);
272
+ } else {
273
+ volumes.push(m.Name);
274
+ mountList.push(`${m.Name}:${m.Destination}${ro}`);
275
+ }
276
+ } else if (m.Type === "bind" && m.Source) {
277
+ mountList.push(`${m.Source}:${m.Destination}${ro}`);
278
+ }
279
+ }
280
+ if (mountList.length > 0) service.volumes = mountList;
281
+
282
+ const networks: string[] = [];
283
+ for (const net of Object.keys(live.container.NetworkSettings?.Networks ?? {})) {
284
+ networks.push(stripProjectPrefix(project, net) ?? net);
285
+ }
286
+ const declaredNets = networks.filter((n) => n !== "default");
287
+ if (declaredNets.length > 0) service.networks = networks;
288
+
289
+ const configs = Object.entries(live.files).map(([target, content]) => {
290
+ const base = target.split("/").pop()!.replace(/\.[^.]*$/, "");
291
+ return { name: `${live.name}-${base}`, target, content };
292
+ });
293
+ if (configs.length > 0) service.configs = configs.map(({ name, target }) => ({ source: name, target }));
294
+
295
+ return { service, configs, volumes, networks: declaredNets };
296
+ }
297
+
298
+ /**
299
+ * The compose document a project's services were created from. External
300
+ * volumes and networks (not prefixed with the project name) are declared
301
+ * `external`.
302
+ */
303
+ export function composeFromLive(project: string, services: LiveService[], opts: { verbatim?: boolean } = {}): Json {
304
+ const doc: { services: Json; configs: Json; volumes: Json; networks: Json } = { services: {}, configs: {}, volumes: {}, networks: {} };
305
+ for (const live of services) {
306
+ const { service, configs, volumes, networks } = serviceFromContainer(project, live, opts.verbatim ?? false);
307
+ doc.services[live.name] = service;
308
+ for (const cfg of configs) doc.configs[cfg.name] = { content: cfg.content };
309
+ for (const v of volumes) {
310
+ const owned = (live.container.Mounts ?? []).some((m) => m.Name === `${project}_${v}`);
311
+ doc.volumes[v] = owned ? {} : { external: true };
312
+ }
313
+ for (const n of networks) {
314
+ const owned = Object.keys(live.container.NetworkSettings?.Networks ?? {}).includes(`${project}_${n}`);
315
+ doc.networks[n] = owned ? {} : { external: true };
316
+ }
317
+ }
318
+ const out: Json = { services: doc.services };
319
+ for (const section of ["volumes", "networks", "configs"] as const) {
320
+ if (Object.keys(doc[section]).length > 0) out[section] = doc[section];
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /** Whether a container carries chant's label marker. */
326
+ export function isChantOwned(container: ContainerInspect): boolean {
327
+ return hasOwnershipMarker(container.Config?.Labels ?? undefined, LABEL_OWNERSHIP_KEYS);
328
+ }
329
+
330
+ /**
331
+ * Build export IR from one compose project's services, through
332
+ * {@link DockerParser} as the single compose → IR mapping. `owned` keeps only
333
+ * services carrying chant's marker; `selector` narrows by IR type (`service`,
334
+ * `config`, …) and compose key. `reparse(context)` parses the same document
335
+ * again with a `ParseContext`, so `chant import --from` hands a collector
336
+ * config to the otel importer, as a file import does (#3373).
337
+ */
338
+ export function buildExportFromProject(
339
+ project: string,
340
+ services: LiveService[],
341
+ opts: { verbatim?: boolean; selector?: ResourceSelector; owned?: boolean } = {},
342
+ ): ExportedTemplate {
343
+ const kept = opts.owned ? services.filter((s) => isChantOwned(s.container)) : services;
344
+ if (kept.length === 0) return { resources: [], parameters: [] };
345
+ const doc = composeFromLive(project, kept, { verbatim: opts.verbatim });
346
+ const parse = (context?: ParseContext): TemplateIR =>
347
+ select(dockerResultToTemplate(new DockerParser().parseDocument(doc, context)), opts.selector);
348
+ return { ...parse(), reparse: (context) => parse(context) };
349
+ }
350
+
351
+ function select(ir: TemplateIR, selector: ResourceSelector | undefined): TemplateIR {
352
+ if (!selector || (selector.type === undefined && selector.name === undefined)) return ir;
353
+ return {
354
+ ...ir,
355
+ resources: ir.resources.filter(
356
+ (r) => (selector.type === undefined || r.type === selector.type) && (selector.name === undefined || r.logicalId === selector.name),
357
+ ),
358
+ };
359
+ }
@@ -7,6 +7,8 @@ import { existsSync, readFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { ParseContext } from "@intentius/chant/import/parser";
11
+ import { delegateEmbedded } from "./embedded";
10
12
 
11
13
  export interface ServiceIR {
12
14
  kind: "service";
@@ -125,12 +127,20 @@ function extractProps(
125
127
  * Converts YAML to an IR consumable by the TypeScript generator.
126
128
  */
127
129
  export class DockerParser {
128
- parse(content: string): ParseResult {
130
+ /**
131
+ * With `context.embedded` (`chant import`), a config's `content` another
132
+ * lexicon owns (a collector config) is handed to it (see ./embedded.ts).
133
+ * Without it, every config is kept as written.
134
+ */
135
+ parse(content: string, context?: ParseContext): ParseResult {
129
136
  if (!content.trim()) return { entities: [], warnings: [] };
137
+ return this.parseDocument(parseYAML(content) as Record<string, unknown>, context);
138
+ }
130
139
 
140
+ /** The same as {@link parse}, over a compose document already read (live export builds one). */
141
+ parseDocument(doc: Record<string, unknown> | undefined, context?: ParseContext): ParseResult {
131
142
  const entities: DockerIR[] = [];
132
143
  const warnings: string[] = [];
133
- const doc = parseYAML(content) as Record<string, unknown>;
134
144
  const fields = composeFields();
135
145
 
136
146
  const known = new Set<string>(SECTIONS.map((s) => s.section));
@@ -150,6 +160,13 @@ export class DockerParser {
150
160
  }
151
161
  }
152
162
 
163
+ if (context?.embedded) {
164
+ const configs = new Map(entities.filter((e): e is ConfigIR => e.kind === "config").map((e) => [e.name, e.props]));
165
+ const services = doc?.services;
166
+ delegateEmbedded(configs, services && typeof services === "object" && !Array.isArray(services) ? (services as Record<string, unknown>) : undefined, context.embedded);
167
+ for (const e of entities) if (e.kind === "config") e.props = configs.get(e.name)!;
168
+ }
169
+
153
170
  return { entities, warnings };
154
171
  }
155
172
  }