@intentius/chant-lexicon-docker 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.
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,297 @@
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
+ import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
36
+ import { DockerParser } from "./parser.js";
37
+ import { dockerResultToTemplate } from "./adapter.js";
38
+ export const PROJECT_LABEL = "com.docker.compose.project";
39
+ export const SERVICE_LABEL = "com.docker.compose.service";
40
+ const NUMBER_LABEL = "com.docker.compose.container-number";
41
+ const ONEOFF_LABEL = "com.docker.compose.oneoff";
42
+ const DEPENDS_LABEL = "com.docker.compose.depends_on";
43
+ function same(a, b) {
44
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
45
+ }
46
+ /**
47
+ * The containers to export, one per service: compose-labelled for `project`,
48
+ * not one-off, and the lowest container number when a service is scaled.
49
+ * `summaries` is what `GET /containers/json` returns.
50
+ */
51
+ export function serviceContainers(project, summaries) {
52
+ const best = new Map();
53
+ for (const s of summaries) {
54
+ const labels = s.Labels ?? {};
55
+ if (labels[PROJECT_LABEL] !== project || !s.Id)
56
+ continue;
57
+ if ((labels[ONEOFF_LABEL] ?? "").toLowerCase() === "true")
58
+ continue;
59
+ const service = labels[SERVICE_LABEL];
60
+ if (!service)
61
+ continue;
62
+ const n = Number(labels[NUMBER_LABEL] ?? "1") || 1;
63
+ const prev = best.get(service);
64
+ if (!prev || n < prev.n)
65
+ best.set(service, { id: s.Id, n });
66
+ }
67
+ return new Map([...best].sort(([a], [b]) => a.localeCompare(b)).map(([service, { id }]) => [service, id]));
68
+ }
69
+ function under(path, dir) {
70
+ const d = dir.endsWith("/") ? dir : `${dir}/`;
71
+ return path.startsWith(d);
72
+ }
73
+ /**
74
+ * The paths a container's command names that may be config files: an
75
+ * absolute path with a file extension, alone or as a `--flag=` value (an
76
+ * otel `file:` URI included). A path inside a volume, or inside a directory
77
+ * bind mount, belongs to that mount and is left out.
78
+ */
79
+ export function configCandidates(container) {
80
+ const args = [...(container.Config?.Entrypoint ?? []), ...(container.Config?.Cmd ?? [])];
81
+ const mounts = container.Mounts ?? [];
82
+ const out = [];
83
+ for (const arg of args) {
84
+ let value = arg.startsWith("-") && arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : arg;
85
+ if (value.startsWith("file:"))
86
+ value = value.slice("file:".length);
87
+ if (!/^\/[^\s,;:]+$/.test(value))
88
+ continue;
89
+ const base = value.split("/").pop();
90
+ if (!/\.[A-Za-z0-9]+$/.test(base))
91
+ continue;
92
+ if (mounts.some((m) => m.Destination && under(value, m.Destination)))
93
+ continue;
94
+ if (!out.includes(value))
95
+ out.push(value);
96
+ }
97
+ return out;
98
+ }
99
+ function stripProjectPrefix(project, name) {
100
+ return name.startsWith(`${project}_`) ? name.slice(project.length + 1) : undefined;
101
+ }
102
+ function envMap(env) {
103
+ const out = {};
104
+ for (const e of env) {
105
+ const i = e.indexOf("=");
106
+ if (i === -1)
107
+ out[e] = null;
108
+ else
109
+ out[e.slice(0, i)] = e.slice(i + 1);
110
+ }
111
+ return out;
112
+ }
113
+ function duration(ns) {
114
+ if (!ns)
115
+ return undefined;
116
+ const ms = ns / 1e6;
117
+ return ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
118
+ }
119
+ function portList(bindings) {
120
+ const out = [];
121
+ for (const [key, binds] of Object.entries(bindings ?? {})) {
122
+ const [port, proto] = key.split("/");
123
+ const suffix = proto && proto !== "tcp" ? `/${proto}` : "";
124
+ if (!binds || binds.length === 0) {
125
+ out.push(`${port}${suffix}`);
126
+ continue;
127
+ }
128
+ for (const b of binds) {
129
+ const ip = b.HostIp && b.HostIp !== "0.0.0.0" && b.HostIp !== "::" ? `${b.HostIp}:` : "";
130
+ out.push(b.HostPort ? `${ip}${b.HostPort}:${port}${suffix}` : `${port}${suffix}`);
131
+ }
132
+ }
133
+ // The Engine may list `::` and `0.0.0.0` bindings of one port separately.
134
+ return [...new Set(out)];
135
+ }
136
+ function dependsOn(label) {
137
+ if (!label)
138
+ return undefined;
139
+ const entries = label
140
+ .split(",")
141
+ .map((e) => e.split(":"))
142
+ .filter(([name]) => !!name);
143
+ if (entries.length === 0)
144
+ return undefined;
145
+ if (entries.every(([, condition, restart]) => (condition ?? "service_started") === "service_started" && restart !== "true")) {
146
+ return entries.map(([name]) => name);
147
+ }
148
+ return Object.fromEntries(entries.map(([name, condition, restart]) => [name, { condition: condition || "service_started", ...(restart === "true" ? { restart: true } : {}) }]));
149
+ }
150
+ /** The compose service a container was created from, and the config files it mounts. */
151
+ function serviceFromContainer(project, live, verbatim) {
152
+ const c = live.container.Config ?? {};
153
+ const img = live.image?.Config ?? {};
154
+ const keep = (live, image) => verbatim || !same(live, image);
155
+ const service = {};
156
+ if (c.Image)
157
+ service.image = c.Image;
158
+ if (c.Entrypoint && c.Entrypoint.length > 0 && keep(c.Entrypoint, img.Entrypoint))
159
+ service.entrypoint = c.Entrypoint;
160
+ if (c.Cmd && c.Cmd.length > 0 && keep(c.Cmd, img.Cmd))
161
+ service.command = c.Cmd;
162
+ const imageEnv = new Set(img.Env ?? []);
163
+ const env = (c.Env ?? []).filter((e) => verbatim || !imageEnv.has(e));
164
+ if (env.length > 0)
165
+ service.environment = envMap(env);
166
+ const labels = {};
167
+ for (const [k, v] of Object.entries(c.Labels ?? {})) {
168
+ if (k.startsWith("com.docker.compose."))
169
+ continue;
170
+ if (!verbatim && img.Labels?.[k] === v)
171
+ continue;
172
+ labels[k] = v;
173
+ }
174
+ if (Object.keys(labels).length > 0)
175
+ service.labels = labels;
176
+ if (c.WorkingDir && keep(c.WorkingDir, img.WorkingDir ?? ""))
177
+ service.working_dir = c.WorkingDir;
178
+ if (c.User && keep(c.User, img.User ?? ""))
179
+ service.user = c.User;
180
+ if (c.Healthcheck?.Test && c.Healthcheck.Test.length > 0 && keep(c.Healthcheck, img.Healthcheck)) {
181
+ const h = c.Healthcheck;
182
+ service.healthcheck = {
183
+ test: h.Test,
184
+ ...(duration(h.Interval) ? { interval: duration(h.Interval) } : {}),
185
+ ...(duration(h.Timeout) ? { timeout: duration(h.Timeout) } : {}),
186
+ ...(h.Retries ? { retries: h.Retries } : {}),
187
+ ...(duration(h.StartPeriod) ? { start_period: duration(h.StartPeriod) } : {}),
188
+ };
189
+ }
190
+ const ports = portList(live.container.HostConfig?.PortBindings);
191
+ if (ports.length > 0)
192
+ service.ports = ports;
193
+ const restart = live.container.HostConfig?.RestartPolicy;
194
+ if (restart?.Name && restart.Name !== "no") {
195
+ service.restart = restart.Name === "on-failure" && restart.MaximumRetryCount ? `on-failure:${restart.MaximumRetryCount}` : restart.Name;
196
+ }
197
+ const deps = dependsOn(c.Labels?.[DEPENDS_LABEL]);
198
+ if (deps !== undefined)
199
+ service.depends_on = deps;
200
+ // Mounts: a config read at a mount's destination is that config, not a volume.
201
+ const volumes = [];
202
+ const mountList = [];
203
+ for (const m of live.container.Mounts ?? []) {
204
+ if (!m.Destination || live.files[m.Destination] !== undefined)
205
+ continue;
206
+ const ro = m.RW === false ? ":ro" : "";
207
+ if (m.Type === "volume" && m.Name) {
208
+ const key = stripProjectPrefix(project, m.Name);
209
+ if (key) {
210
+ volumes.push(key);
211
+ mountList.push(`${key}:${m.Destination}${ro}`);
212
+ }
213
+ else if (/^[0-9a-f]{64}$/.test(m.Name)) {
214
+ mountList.push(m.Destination);
215
+ }
216
+ else {
217
+ volumes.push(m.Name);
218
+ mountList.push(`${m.Name}:${m.Destination}${ro}`);
219
+ }
220
+ }
221
+ else if (m.Type === "bind" && m.Source) {
222
+ mountList.push(`${m.Source}:${m.Destination}${ro}`);
223
+ }
224
+ }
225
+ if (mountList.length > 0)
226
+ service.volumes = mountList;
227
+ const networks = [];
228
+ for (const net of Object.keys(live.container.NetworkSettings?.Networks ?? {})) {
229
+ networks.push(stripProjectPrefix(project, net) ?? net);
230
+ }
231
+ const declaredNets = networks.filter((n) => n !== "default");
232
+ if (declaredNets.length > 0)
233
+ service.networks = networks;
234
+ const configs = Object.entries(live.files).map(([target, content]) => {
235
+ const base = target.split("/").pop().replace(/\.[^.]*$/, "");
236
+ return { name: `${live.name}-${base}`, target, content };
237
+ });
238
+ if (configs.length > 0)
239
+ service.configs = configs.map(({ name, target }) => ({ source: name, target }));
240
+ return { service, configs, volumes, networks: declaredNets };
241
+ }
242
+ /**
243
+ * The compose document a project's services were created from. External
244
+ * volumes and networks (not prefixed with the project name) are declared
245
+ * `external`.
246
+ */
247
+ export function composeFromLive(project, services, opts = {}) {
248
+ const doc = { services: {}, configs: {}, volumes: {}, networks: {} };
249
+ for (const live of services) {
250
+ const { service, configs, volumes, networks } = serviceFromContainer(project, live, opts.verbatim ?? false);
251
+ doc.services[live.name] = service;
252
+ for (const cfg of configs)
253
+ doc.configs[cfg.name] = { content: cfg.content };
254
+ for (const v of volumes) {
255
+ const owned = (live.container.Mounts ?? []).some((m) => m.Name === `${project}_${v}`);
256
+ doc.volumes[v] = owned ? {} : { external: true };
257
+ }
258
+ for (const n of networks) {
259
+ const owned = Object.keys(live.container.NetworkSettings?.Networks ?? {}).includes(`${project}_${n}`);
260
+ doc.networks[n] = owned ? {} : { external: true };
261
+ }
262
+ }
263
+ const out = { services: doc.services };
264
+ for (const section of ["volumes", "networks", "configs"]) {
265
+ if (Object.keys(doc[section]).length > 0)
266
+ out[section] = doc[section];
267
+ }
268
+ return out;
269
+ }
270
+ /** Whether a container carries chant's label marker. */
271
+ export function isChantOwned(container) {
272
+ return hasOwnershipMarker(container.Config?.Labels ?? undefined, LABEL_OWNERSHIP_KEYS);
273
+ }
274
+ /**
275
+ * Build export IR from one compose project's services, through
276
+ * {@link DockerParser} as the single compose → IR mapping. `owned` keeps only
277
+ * services carrying chant's marker; `selector` narrows by IR type (`service`,
278
+ * `config`, …) and compose key. `reparse(context)` parses the same document
279
+ * again with a `ParseContext`, so `chant import --from` hands a collector
280
+ * config to the otel importer, as a file import does (#3373).
281
+ */
282
+ export function buildExportFromProject(project, services, opts = {}) {
283
+ const kept = opts.owned ? services.filter((s) => isChantOwned(s.container)) : services;
284
+ if (kept.length === 0)
285
+ return { resources: [], parameters: [] };
286
+ const doc = composeFromLive(project, kept, { verbatim: opts.verbatim });
287
+ const parse = (context) => select(dockerResultToTemplate(new DockerParser().parseDocument(doc, context)), opts.selector);
288
+ return { ...parse(), reparse: (context) => parse(context) };
289
+ }
290
+ function select(ir, selector) {
291
+ if (!selector || (selector.type === undefined && selector.name === undefined))
292
+ return ir;
293
+ return {
294
+ ...ir,
295
+ resources: ir.resources.filter((r) => (selector.type === undefined || r.type === selector.type) && (selector.name === undefined || r.logicalId === selector.name)),
296
+ };
297
+ }
@@ -2,6 +2,7 @@
2
2
  * Docker Compose + Dockerfile parser — converts existing YAML/Dockerfiles
3
3
  * to an intermediate representation (IR) for TypeScript code generation.
4
4
  */
5
+ import type { ParseContext } from "@intentius/chant/import/parser";
5
6
  export interface ServiceIR {
6
7
  kind: "service";
7
8
  name: string;
@@ -87,7 +88,14 @@ export declare function composeFields(): Map<ComposeKind, Set<string>>;
87
88
  * Converts YAML to an IR consumable by the TypeScript generator.
88
89
  */
89
90
  export declare class DockerParser {
90
- parse(content: string): ParseResult;
91
+ /**
92
+ * With `context.embedded` (`chant import`), a config's `content` another
93
+ * lexicon owns (a collector config) is handed to it (see ./embedded.ts).
94
+ * Without it, every config is kept as written.
95
+ */
96
+ parse(content: string, context?: ParseContext): ParseResult;
97
+ /** The same as {@link parse}, over a compose document already read (live export builds one). */
98
+ parseDocument(doc: Record<string, unknown> | undefined, context?: ParseContext): ParseResult;
91
99
  }
92
100
  /** One logical Dockerfile instruction: its upper-case keyword and the rest as written. */
93
101
  export interface DockerfileInstruction {
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"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"}
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseYAML } from "@intentius/chant/yaml";
9
+ import { delegateEmbedded } from "./embedded.js";
9
10
  /** The compose sections import reads, with the kind each holds and its entry in the lexicon. */
10
11
  const SECTIONS = [
11
12
  { section: "services", kind: "service", entry: "Service" },
@@ -59,12 +60,20 @@ function extractProps(raw, allowed, where, kind, warnings) {
59
60
  * Converts YAML to an IR consumable by the TypeScript generator.
60
61
  */
61
62
  export class DockerParser {
62
- parse(content) {
63
+ /**
64
+ * With `context.embedded` (`chant import`), a config's `content` another
65
+ * lexicon owns (a collector config) is handed to it (see ./embedded.ts).
66
+ * Without it, every config is kept as written.
67
+ */
68
+ parse(content, context) {
63
69
  if (!content.trim())
64
70
  return { entities: [], warnings: [] };
71
+ return this.parseDocument(parseYAML(content), context);
72
+ }
73
+ /** The same as {@link parse}, over a compose document already read (live export builds one). */
74
+ parseDocument(doc, context) {
65
75
  const entities = [];
66
76
  const warnings = [];
67
- const doc = parseYAML(content);
68
77
  const fields = composeFields();
69
78
  const known = new Set(SECTIONS.map((s) => s.section));
70
79
  for (const key of Object.keys(doc ?? {})) {
@@ -83,6 +92,14 @@ export class DockerParser {
83
92
  });
84
93
  }
85
94
  }
95
+ if (context?.embedded) {
96
+ const configs = new Map(entities.filter((e) => e.kind === "config").map((e) => [e.name, e.props]));
97
+ const services = doc?.services;
98
+ delegateEmbedded(configs, services && typeof services === "object" && !Array.isArray(services) ? services : undefined, context.embedded);
99
+ for (const e of entities)
100
+ if (e.kind === "config")
101
+ e.props = configs.get(e.name);
102
+ }
86
103
  return { entities, warnings };
87
104
  }
88
105
  }
@@ -0,0 +1,3 @@
1
+ /** A ustar archive of regular files and directories, as `GET /containers/{id}/archive` returns one (test fixture). */
2
+ export declare function tarOf(...entries: Array<[name: string, content: string | null]>): Buffer;
3
+ //# sourceMappingURL=tar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tar.d.ts","sourceRoot":"","sources":["../../../src/import/testdata/tar.ts"],"names":[],"mappings":"AAAA,sHAAsH;AACtH,wBAAgB,KAAK,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,GAAG,MAAM,CAsBvF"}
@@ -0,0 +1,25 @@
1
+ /** A ustar archive of regular files and directories, as `GET /containers/{id}/archive` returns one (test fixture). */
2
+ export function tarOf(...entries) {
3
+ const blocks = [];
4
+ for (const [name, content] of entries) {
5
+ const data = content === null ? Buffer.alloc(0) : Buffer.from(content, "utf8");
6
+ const header = Buffer.alloc(512);
7
+ header.write(name, 0, 100, "utf8");
8
+ header.write("0000644\0", 100, 8, "ascii");
9
+ header.write("0000000\0", 108, 8, "ascii");
10
+ header.write("0000000\0", 116, 8, "ascii");
11
+ header.write(`${data.length.toString(8).padStart(11, "0")}\0`, 124, 12, "ascii");
12
+ header.write("00000000000\0", 136, 12, "ascii");
13
+ header.write(content === null ? "5" : "0", 156, 1, "ascii");
14
+ header.write("ustar\0", 257, 6, "ascii");
15
+ header.write("00", 263, 2, "ascii");
16
+ header.fill(" ", 148, 156);
17
+ let sum = 0;
18
+ for (const b of header)
19
+ sum += b;
20
+ header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii");
21
+ blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
22
+ }
23
+ blocks.push(Buffer.alloc(1024));
24
+ return Buffer.concat(blocks);
25
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "5a484236f7c739cb2a78374e2585ec115b9c6544a030bfa20520cf23bffe1ad4",
4
+ "manifest.json": "ae7e2a89319b195c4a7015127f4d5c58be08e79c505c0aa691217bc55b51f7ff",
5
5
  "meta.json": "b566d5cb84a87836a861dc5840c229577e860c5427123192810b7839f764a9ee",
6
6
  "types/index.d.ts": "256644d2bffa2845900cf86984cc7dde3d9e79da5994ab13a22b0b83edf7f62a",
7
7
  "rules/no-latest-tag.ts": "2281be0457c02e782fbafe015f65ff5fd2b008ef0f879226e9b548a3ea65399b",
@@ -24,5 +24,5 @@
24
24
  "skills/chant-docker.md": "d499e18525f2cfd7ecc5b5be4a4c52c6931ebdef8d70f872d53279e53bed6f7e",
25
25
  "skills/chant-docker-patterns.md": "655f437c833796e0d520bbe08740a4226d48fcec4912b0438672ed5a299897e7"
26
26
  },
27
- "composite": "3b3069dbc3c7dc9eb8446348ba84227fcd63d48a0686325f87a7623f5ba08097"
27
+ "composite": "f77f0cd91cd6971d35930ca2372af4fe50b1ae33b3553099441433da33e347f9"
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docker",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Docker",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAe7F,eAAO,MAAM,YAAY,EAAE,aA8O1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAe7F,eAAO,MAAM,YAAY,EAAE,aAoP1B,CAAC"}
package/dist/plugin.js CHANGED
@@ -222,6 +222,11 @@ export const api = new Service({
222
222
  ],
223
223
  },
224
224
  ]),
225
+ // Live export (#4388): a compose project's services, read from the Engine API.
226
+ async exportResources(options) {
227
+ const { exportResources } = await import("./export-resources.js");
228
+ return exportResources(options);
229
+ },
225
230
  async listArtifacts(options) {
226
231
  const { listArtifacts } = await import("./list-artifacts.js");
227
232
  return listArtifacts(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-docker",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "description": "Docker lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -63,13 +63,13 @@
63
63
  "build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json"
64
64
  },
65
65
  "dependencies": {
66
- "@intentius/chant-lexicon-otel": "^0.124.46"
66
+ "@intentius/chant-lexicon-otel": "^0.124.49"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@intentius/chant": "*",
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@intentius/chant": "^0.124.46"
73
+ "@intentius/chant": "^0.124.49"
74
74
  }
75
75
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Live export for the docker lexicon (#4388): implements
3
+ * `LexiconPlugin.exportResources()` so `chant import --from <env>` regenerates
4
+ * a compose project's services, and the configs they read, as chant
5
+ * TypeScript.
6
+ *
7
+ * Which project: `COMPOSE_PROJECT_NAME` when set, else the environment name.
8
+ * The containers are found by compose's `com.docker.compose.project` label.
9
+ * All Engine I/O lives here; mapping is pure in `./import/live-export`.
10
+ */
11
+
12
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
13
+ import { defaultDockerEngine, singleFileText, type DockerEngine } from "./import/engine";
14
+ import {
15
+ buildExportFromProject,
16
+ configCandidates,
17
+ isChantOwned,
18
+ PROJECT_LABEL,
19
+ serviceContainers,
20
+ type ContainerInspect,
21
+ type ImageInspect,
22
+ type LiveService,
23
+ } from "./import/live-export";
24
+
25
+ function json<T>(body: Buffer): T | undefined {
26
+ try {
27
+ return JSON.parse(body.toString("utf8")) as T;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ /** The compose project an environment exports. */
34
+ export function projectFor(environment: string): string {
35
+ return process.env.COMPOSE_PROJECT_NAME || environment;
36
+ }
37
+
38
+ export async function exportResources(
39
+ options: {
40
+ environment: string;
41
+ selector?: ResourceSelector;
42
+ owned?: boolean;
43
+ verbatim?: boolean;
44
+ },
45
+ engine: DockerEngine = defaultDockerEngine(),
46
+ signal?: AbortSignal,
47
+ ): Promise<ExportedTemplate> {
48
+ const project = projectFor(options.environment);
49
+ const filters = encodeURIComponent(JSON.stringify({ label: [`${PROJECT_LABEL}=${project}`] }));
50
+ const list = await engine(`/containers/json?all=1&filters=${filters}`, signal);
51
+ if (list.status !== 200) {
52
+ throw new Error(`docker live export: listing the containers of compose project ${project} failed (${list.status}): ${list.body.toString("utf8").trim()}`);
53
+ }
54
+ const containers = serviceContainers(project, json<Array<{ Id?: string; Labels?: Record<string, string> }>>(list.body) ?? []);
55
+
56
+ const services: LiveService[] = [];
57
+ const images = new Map<string, ImageInspect | undefined>();
58
+ for (const [name, id] of containers) {
59
+ const res = await engine(`/containers/${encodeURIComponent(id)}/json`, signal);
60
+ const container = res.status === 200 ? json<ContainerInspect>(res.body) : undefined;
61
+ if (!container) continue;
62
+
63
+ const ref = container.Config?.Image;
64
+ if (ref && !images.has(ref)) {
65
+ const img = await engine(`/images/${encodeURIComponent(ref)}/json`, signal);
66
+ images.set(ref, img.status === 200 ? json<ImageInspect>(img.body) : undefined);
67
+ }
68
+
69
+ // Config files are read only for the services that are exported.
70
+ const files: Record<string, string> = {};
71
+ if (!options.owned || isChantOwned(container)) {
72
+ for (const path of configCandidates(container)) {
73
+ const res = await engine(`/containers/${encodeURIComponent(id)}/archive?path=${encodeURIComponent(path)}`, signal);
74
+ if (res.status !== 200) continue;
75
+ const text = singleFileText(res.body);
76
+ if (text !== undefined) files[path] = text;
77
+ }
78
+ }
79
+
80
+ services.push({ name, container, image: ref ? images.get(ref) : undefined, files });
81
+ }
82
+
83
+ if (options.owned) {
84
+ console.warn(
85
+ "[docker] the docker serializer stamps no ownership marker; under --owned only services labelled app.kubernetes.io/managed-by=chant (defaultLabels) are exported",
86
+ );
87
+ }
88
+
89
+ return buildExportFromProject(project, services, options);
90
+ }
@@ -11,10 +11,10 @@
11
11
  * reverse the mapping in the generator so DockerGenerator can do the real work.
12
12
  */
13
13
 
14
- import type { TemplateIR, TemplateParser, ResourceIR } from "@intentius/chant/import/parser";
14
+ import type { TemplateIR, TemplateParser, ResourceIR, ParseContext } from "@intentius/chant/import/parser";
15
15
  import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
16
16
  import { DockerParser, DockerfileParser, looksLikeDockerfile } from "./parser";
17
- import type { DockerIR, DockerfileStage } from "./parser";
17
+ import type { DockerIR, DockerfileStage, ParseResult } from "./parser";
18
18
  import { DockerGenerator } from "./generator";
19
19
 
20
20
  /** Convert a DockerIR into the core ResourceIR shape. */
@@ -66,21 +66,26 @@ function resourceToDockerIr(resource: ResourceIR): DockerIR {
66
66
  * entities to a TemplateIR.
67
67
  */
68
68
  export class DockerTemplateParser implements TemplateParser {
69
- parse(content: string): TemplateIR {
69
+ /** With `context.embedded`, a config's `content` another lexicon owns is handed to it (#3373). */
70
+ parse(content: string, context?: ParseContext): TemplateIR {
70
71
  // A Dockerfile has no name of its own; it imports as `app`
71
72
  // (Dockerfile.app). Rename the export to choose the file name.
72
73
  if (looksLikeDockerfile(content)) {
73
74
  return { resources: [dockerIrToResource(new DockerfileParser().parse("app", content))], parameters: [] };
74
75
  }
75
- const { entities, warnings } = new DockerParser().parse(content);
76
- return {
77
- resources: entities.map(dockerIrToResource),
78
- parameters: [],
79
- ...(warnings.length > 0 ? { warnings } : {}),
80
- };
76
+ return dockerResultToTemplate(new DockerParser().parse(content, context));
81
77
  }
82
78
  }
83
79
 
80
+ /** A compose parse result as the core TemplateIR. */
81
+ export function dockerResultToTemplate({ entities, warnings }: ParseResult): TemplateIR {
82
+ return {
83
+ resources: entities.map(dockerIrToResource),
84
+ parameters: [],
85
+ ...(warnings.length > 0 ? { warnings } : {}),
86
+ };
87
+ }
88
+
84
89
  /**
85
90
  * TypeScriptGenerator adapter — converts a TemplateIR back into DockerIR[]
86
91
  * and delegates to DockerGenerator, returning a single generated file.