@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.
- package/dist/export-resources.d.ts +21 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/export-resources.js +63 -0
- package/dist/import/adapter.d.ts +6 -2
- package/dist/import/adapter.d.ts.map +1 -1
- package/dist/import/adapter.js +11 -7
- package/dist/import/embedded.d.ts +27 -0
- package/dist/import/embedded.d.ts.map +1 -0
- package/dist/import/embedded.js +79 -0
- package/dist/import/engine.d.ts +39 -0
- package/dist/import/engine.d.ts.map +1 -0
- package/dist/import/engine.js +94 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/generator.js +41 -10
- package/dist/import/live-export.d.ts +142 -0
- package/dist/import/live-export.d.ts.map +1 -0
- package/dist/import/live-export.js +297 -0
- package/dist/import/parser.d.ts +9 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/parser.js +19 -2
- package/dist/import/testdata/tar.d.ts +3 -0
- package/dist/import/testdata/tar.d.ts.map +1 -0
- package/dist/import/testdata/tar.js +25 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +5 -0
- package/package.json +3 -3
- package/src/export-resources.ts +90 -0
- package/src/import/adapter.ts +14 -9
- package/src/import/embedded-live.test.ts +248 -0
- package/src/import/embedded.ts +83 -0
- package/src/import/engine.ts +107 -0
- package/src/import/generator.ts +42 -10
- package/src/import/live-export.test.ts +271 -0
- package/src/import/live-export.ts +359 -0
- package/src/import/parser.ts +19 -2
- package/src/import/testdata/tar.ts +24 -0
- 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
|
+
}
|
package/dist/import/parser.d.ts
CHANGED
|
@@ -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
|
-
|
|
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":"AAAA;;;GAGG;
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAMH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAGnE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,YAAY,EAAE,KAAK,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC7D;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,YAAY,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,MAAM,MAAM,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,YAAY,CAAC;AAE7F,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,gGAAgG;AAChG,QAAA,MAAM,QAAQ;aACV,OAAO,EAAE,UAAU;aAAE,IAAI,EAAE,SAAS;aAAE,KAAK,EAAE,SAAS;;aACtD,OAAO,EAAE,SAAS;aAAE,IAAI,EAAE,QAAQ;aAAE,KAAK,EAAE,QAAQ;;aACnD,OAAO,EAAE,UAAU;aAAE,IAAI,EAAE,SAAS;aAAE,KAAK,EAAE,SAAS;;aACtD,OAAO,EAAE,SAAS;aAAE,IAAI,EAAE,QAAQ;aAAE,KAAK,EAAE,cAAc;;aACzD,OAAO,EAAE,SAAS;aAAE,IAAI,EAAE,QAAQ;aAAE,KAAK,EAAE,cAAc;EACnD,CAAC;AAEX,KAAK,WAAW,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAIrD;;;;;GAKG;AACH,wBAAgB,aAAa,IAAI,GAAG,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAe7D;AAsBD;;;GAGG;AACH,qBAAa,YAAY;IACvB;;;;OAIG;IACH,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,WAAW,CAG1D;IAED,gGAAgG;IAChG,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,WAAW,CA8B3F;CACF;AAED,0FAA0F;AAC1F,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAuCpF;AAED;;GAEG;AACH,qBAAa,gBAAgB;IAC3B,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,YAAY,CAuBjD;CACF;AAED,yHAAyH;AACzH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAS5D"}
|
package/dist/import/parser.js
CHANGED
|
@@ -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
|
-
|
|
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 @@
|
|
|
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
|
+
}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
27
|
+
"composite": "f77f0cd91cd6971d35930ca2372af4fe50b1ae33b3553099441433da33e347f9"
|
|
28
28
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/import/adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|