@intentius/chant-lexicon-docker 0.124.31 → 0.124.33
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/codegen/docs-cli.js +7 -0
- package/dist/codegen/docs.js +163 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-compose.js +16 -0
- package/dist/codegen/generate-dockerfile.js +16 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/generate.js +212 -0
- package/dist/codegen/naming.js +45 -0
- package/dist/codegen/package.js +40 -0
- package/dist/codegen/patches.js +31 -0
- package/dist/codegen/versions.js +41 -0
- package/dist/compose-key.d.ts +19 -0
- package/dist/compose-key.d.ts.map +1 -0
- package/dist/compose-key.js +26 -0
- package/dist/composites/catalog.js +113 -0
- package/dist/composites/docker-otel-collector.js +43 -0
- package/dist/composites/docker-web-service.js +51 -0
- package/dist/composites/index.js +6 -0
- package/dist/coverage.js +35 -0
- package/dist/default-labels.js +52 -0
- package/dist/detect.js +8 -0
- package/dist/dockerfile-order.js +16 -0
- package/dist/generated/index.d.ts +10 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +8 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/import/adapter.d.ts.map +1 -1
- package/dist/import/adapter.js +86 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/generator.js +159 -0
- package/dist/import/parser.d.ts +31 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/parser.js +181 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -0
- package/dist/integrity.json +3 -3
- package/dist/interpolation.js +54 -0
- package/dist/lint/audit-catalog.js +25 -0
- package/dist/lint/audit-lineage.js +39 -0
- package/dist/lint/post-synth/add-url-checksum.js +35 -0
- package/dist/lint/post-synth/apt-no-recommends.js +34 -0
- package/dist/lint/post-synth/apt-update-alone.js +34 -0
- package/dist/lint/post-synth/cache-busting-copy.js +61 -0
- package/dist/lint/post-synth/copy-from-stage.js +53 -0
- package/dist/lint/post-synth/docker-helpers.js +148 -0
- package/dist/lint/post-synth/index.js +30 -0
- package/dist/lint/post-synth/no-latest-image.js +30 -0
- package/dist/lint/post-synth/no-root-user.js +35 -0
- package/dist/lint/post-synth/prefer-copy.js +43 -0
- package/dist/lint/post-synth/relative-workdir.js +34 -0
- package/dist/lint/post-synth/repeated-cmd.js +30 -0
- package/dist/lint/post-synth/root-after-user.js +39 -0
- package/dist/lint/post-synth/ssh-port-exposed.js +58 -0
- package/dist/lint/post-synth/unused-global-arg.js +34 -0
- package/dist/lint/post-synth/unused-volume.js +41 -0
- package/dist/lint/rules/data/deprecated-images.js +26 -0
- package/dist/lint/rules/data/known-base-images.js +18 -0
- package/dist/lint/rules/index.js +5 -0
- package/dist/lint/rules/no-latest-tag.js +57 -0
- package/dist/lint/rules/per-kind-order.js +129 -0
- package/dist/list-artifacts.js +133 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +229 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +390 -0
- package/dist/spec/fetch-compose.js +28 -0
- package/dist/spec/fetch-engine.js +19 -0
- package/dist/spec/parse-compose.js +160 -0
- package/dist/spec/parse-engine.js +36 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +37 -0
- package/dist/variables.js +42 -0
- package/package.json +8 -8
- package/src/codegen/generate.test.ts +10 -0
- package/src/codegen/generate.ts +3 -0
- package/src/codegen/package.ts +2 -2
- package/src/compose-key.ts +31 -0
- package/src/generated/index.d.ts +10 -0
- package/src/generated/index.ts +10 -0
- package/src/import/adapter.ts +2 -1
- package/src/import/generator.test.ts +5 -4
- package/src/import/generator.ts +75 -48
- package/src/import/parser.ts +58 -65
- package/src/import/roundtrip.test.ts +46 -3
- package/src/import/testdata/names-and-fields.yaml +44 -0
- package/src/index.ts +3 -0
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
- package/src/serializer.test.ts +31 -0
- package/src/serializer.ts +15 -2
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker lexicon plugin.
|
|
3
|
+
*
|
|
4
|
+
* Provides serializer, lint rules, post-synth checks, intrinsics, LSP support,
|
|
5
|
+
* and code generation for Docker Compose and Dockerfile resources.
|
|
6
|
+
*/
|
|
7
|
+
import { compositeCatalog } from "./composites/catalog.js";
|
|
8
|
+
import { detectTemplate } from "./detect.js";
|
|
9
|
+
import { postSynthChecks as postSynthCheckList } from "./lint/post-synth/index.js";
|
|
10
|
+
import { dockerAuditCatalog } from "./lint/audit-catalog.js";
|
|
11
|
+
import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers";
|
|
12
|
+
import { dockerSerializer } from "./serializer.js";
|
|
13
|
+
import { noLatestTagRule } from "./lint/rules/no-latest-tag.js";
|
|
14
|
+
import { perKindOrderRule } from "./lint/rules/per-kind-order.js";
|
|
15
|
+
import { dockerCompletions } from "./lsp/completions.js";
|
|
16
|
+
import { dockerHover } from "./lsp/hover.js";
|
|
17
|
+
import { DockerTemplateParser, DockerTemplateGenerator } from "./import/adapter.js";
|
|
18
|
+
export const dockerPlugin = {
|
|
19
|
+
name: "docker",
|
|
20
|
+
auditCatalog: () => dockerAuditCatalog,
|
|
21
|
+
// Self-upgrade: the moby/moby tag embedded in the Engine API spec URL + its upstream (#685).
|
|
22
|
+
upstreamPin: {
|
|
23
|
+
file: "src/codegen/versions.ts",
|
|
24
|
+
pattern: /moby\/moby\/([^/]+)\/api\/swagger\.yaml/,
|
|
25
|
+
replace: (v, line) => line.replace(/moby\/moby\/[^/]+\/api\/swagger\.yaml/, `moby/moby/${v}/api/swagger.yaml`),
|
|
26
|
+
upstream: { owner: "moby", repo: "moby", kind: "releases" },
|
|
27
|
+
},
|
|
28
|
+
serializer: dockerSerializer,
|
|
29
|
+
lintRules() {
|
|
30
|
+
return [noLatestTagRule, perKindOrderRule];
|
|
31
|
+
},
|
|
32
|
+
postSynthChecks() {
|
|
33
|
+
return postSynthCheckList;
|
|
34
|
+
},
|
|
35
|
+
intrinsics() {
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
name: "env",
|
|
39
|
+
description: "Docker Compose variable interpolation — ${VAR}, ${VAR:-default}, ${VAR:?error}",
|
|
40
|
+
outputKey: "env",
|
|
41
|
+
isTag: false,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
},
|
|
45
|
+
initTemplates(template) {
|
|
46
|
+
if (template === "webapp") {
|
|
47
|
+
return {
|
|
48
|
+
src: {
|
|
49
|
+
"compose.ts": `import { Service, Volume } from "@intentius/chant-lexicon-docker";
|
|
50
|
+
|
|
51
|
+
export const db = new Service({
|
|
52
|
+
image: "postgres:16-alpine",
|
|
53
|
+
environment: {
|
|
54
|
+
POSTGRES_DB: "myapp",
|
|
55
|
+
POSTGRES_USER: "myapp",
|
|
56
|
+
POSTGRES_PASSWORD: "secret",
|
|
57
|
+
},
|
|
58
|
+
volumes: ["pgdata:/var/lib/postgresql/data"],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const pgdata = new Volume({});
|
|
62
|
+
|
|
63
|
+
export const api = new Service({
|
|
64
|
+
image: "myapp:1.0",
|
|
65
|
+
ports: ["8080:8080"],
|
|
66
|
+
depends_on: ["db"],
|
|
67
|
+
environment: {
|
|
68
|
+
DATABASE_URL: "postgresql://myapp:secret@db:5432/myapp",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
`,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Default template
|
|
76
|
+
return {
|
|
77
|
+
src: {
|
|
78
|
+
"compose.ts": `import { Service } from "@intentius/chant-lexicon-docker";
|
|
79
|
+
|
|
80
|
+
export const api = new Service({
|
|
81
|
+
image: "myapp:1.0",
|
|
82
|
+
ports: ["8080:8080"],
|
|
83
|
+
restart: "unless-stopped",
|
|
84
|
+
});
|
|
85
|
+
`,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
detectTemplate,
|
|
90
|
+
completionProvider(ctx) {
|
|
91
|
+
return dockerCompletions(ctx);
|
|
92
|
+
},
|
|
93
|
+
hoverProvider(ctx) {
|
|
94
|
+
return dockerHover(ctx);
|
|
95
|
+
},
|
|
96
|
+
templateParser() {
|
|
97
|
+
return new DockerTemplateParser();
|
|
98
|
+
},
|
|
99
|
+
templateGenerator() {
|
|
100
|
+
return new DockerTemplateGenerator();
|
|
101
|
+
},
|
|
102
|
+
async generate(options) {
|
|
103
|
+
const { generate, writeGeneratedFiles } = await import("./codegen/generate.js");
|
|
104
|
+
const { dirname: pathDirname } = await import("path");
|
|
105
|
+
const { fileURLToPath: urlToPath } = await import("url");
|
|
106
|
+
const result = await generate({ verbose: options?.verbose ?? true });
|
|
107
|
+
const pkgDir = pathDirname(pathDirname(urlToPath(import.meta.url)));
|
|
108
|
+
writeGeneratedFiles(result, pkgDir);
|
|
109
|
+
console.error(`Generated ${result.resources} entities, ${result.properties} property types, ${result.enums} enums`);
|
|
110
|
+
if (result.warnings.length > 0) {
|
|
111
|
+
console.error(`${result.warnings.length} warnings`);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async validate(options) {
|
|
115
|
+
const { validate } = await import("./validate.js");
|
|
116
|
+
const { printValidationResult } = await import("@intentius/chant/codegen/validate");
|
|
117
|
+
const result = await validate();
|
|
118
|
+
printValidationResult(result);
|
|
119
|
+
},
|
|
120
|
+
async coverage(options) {
|
|
121
|
+
const { analyzeDockerCoverage } = await import("./coverage.js");
|
|
122
|
+
await analyzeDockerCoverage({
|
|
123
|
+
verbose: options?.verbose,
|
|
124
|
+
minOverall: options?.minOverall,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
async package(options) {
|
|
128
|
+
const { packageLexicon } = await import("./codegen/package.js");
|
|
129
|
+
const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
|
|
130
|
+
const { join: pathJoin, dirname: pathDirname } = await import("path");
|
|
131
|
+
const { fileURLToPath: urlToPath } = await import("url");
|
|
132
|
+
const { spec, stats } = await packageLexicon({ verbose: options?.verbose, force: options?.force });
|
|
133
|
+
const pkgDir = pathDirname(pathDirname(urlToPath(import.meta.url)));
|
|
134
|
+
const distDir = pathJoin(pkgDir, "dist");
|
|
135
|
+
writeBundleSpec(spec, distDir);
|
|
136
|
+
console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
137
|
+
},
|
|
138
|
+
composites() {
|
|
139
|
+
return compositeCatalog;
|
|
140
|
+
},
|
|
141
|
+
mcpTools() {
|
|
142
|
+
return [createDiffTool(dockerSerializer, "Compare current build output against previous output for Docker Compose", "docker")];
|
|
143
|
+
},
|
|
144
|
+
mcpResources() {
|
|
145
|
+
return [
|
|
146
|
+
createCatalogResource(import.meta.url, "Docker Entity Catalog", "JSON list of all supported Docker entity types", "lexicon-docker.json", "docker"),
|
|
147
|
+
{
|
|
148
|
+
uri: "examples/basic-app",
|
|
149
|
+
name: "Basic App Example",
|
|
150
|
+
description: "A basic Docker Compose application with a service and volume",
|
|
151
|
+
mimeType: "text/typescript",
|
|
152
|
+
async handler() {
|
|
153
|
+
return `import { Service, Volume } from "@intentius/chant-lexicon-docker";
|
|
154
|
+
|
|
155
|
+
export const db = new Service({
|
|
156
|
+
image: "postgres:16-alpine",
|
|
157
|
+
environment: {
|
|
158
|
+
POSTGRES_DB: "myapp",
|
|
159
|
+
POSTGRES_USER: "myapp",
|
|
160
|
+
POSTGRES_PASSWORD: "secret",
|
|
161
|
+
},
|
|
162
|
+
volumes: ["pgdata:/var/lib/postgresql/data"],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export const pgdata = new Volume({});
|
|
166
|
+
|
|
167
|
+
export const api = new Service({
|
|
168
|
+
image: "myapp:1.0",
|
|
169
|
+
ports: ["8080:8080"],
|
|
170
|
+
depends_on: ["db"],
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
},
|
|
177
|
+
async docs(options) {
|
|
178
|
+
const { generateDocs } = await import("./codegen/docs.js");
|
|
179
|
+
await generateDocs(options);
|
|
180
|
+
},
|
|
181
|
+
skills: createSkillsLoader(import.meta.url, [
|
|
182
|
+
{
|
|
183
|
+
file: "chant-docker.md",
|
|
184
|
+
name: "chant-docker",
|
|
185
|
+
description: "Docker Compose service/volume/network lifecycle — build, serialize, deploy",
|
|
186
|
+
triggers: [
|
|
187
|
+
{ type: "file-pattern", value: "**/*.compose.ts" },
|
|
188
|
+
{ type: "file-pattern", value: "**/docker-compose.ts" },
|
|
189
|
+
{ type: "context", value: "docker compose" },
|
|
190
|
+
{ type: "context", value: "docker service" },
|
|
191
|
+
{ type: "context", value: "dockerfile" },
|
|
192
|
+
{ type: "context", value: "container" },
|
|
193
|
+
],
|
|
194
|
+
parameters: [],
|
|
195
|
+
examples: [
|
|
196
|
+
{
|
|
197
|
+
title: "Basic service with database",
|
|
198
|
+
description: "Create a web service and PostgreSQL database",
|
|
199
|
+
input: "Create a Node.js API with a PostgreSQL database",
|
|
200
|
+
output: `new Service({ image: "myapi:1.0", depends_on: ["db"] })`,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
file: "chant-docker-patterns.md",
|
|
206
|
+
name: "chant-docker-patterns",
|
|
207
|
+
description: "Docker Compose patterns — databases, caches, networks, multi-stage builds",
|
|
208
|
+
triggers: [
|
|
209
|
+
{ type: "context", value: "docker compose" },
|
|
210
|
+
{ type: "context", value: "multi-stage" },
|
|
211
|
+
{ type: "context", value: "dockerfile pattern" },
|
|
212
|
+
{ type: "context", value: "docker network" },
|
|
213
|
+
{ type: "context", value: "docker volume" },
|
|
214
|
+
],
|
|
215
|
+
parameters: [],
|
|
216
|
+
examples: [
|
|
217
|
+
{
|
|
218
|
+
title: "Multi-stage Dockerfile",
|
|
219
|
+
input: "Create a multi-stage Dockerfile for Node.js",
|
|
220
|
+
output: `new Dockerfile({ stages: [{ from: "node:20-alpine", as: "build", ... }] })`,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
]),
|
|
225
|
+
async listArtifacts(options) {
|
|
226
|
+
const { listArtifacts } = await import("./list-artifacts.js");
|
|
227
|
+
return listArtifacts(options);
|
|
228
|
+
},
|
|
229
|
+
};
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAoB,UAAU,EAAoB,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAoB,UAAU,EAAoB,MAAM,6BAA6B,CAAC;AA6VlG,eAAO,MAAM,gBAAgB,EAAE,UAiF9B,CAAC"}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker lexicon serializer.
|
|
3
|
+
*
|
|
4
|
+
* Splits entities into two output domains:
|
|
5
|
+
* - Docker::Compose::* → docker-compose.yml (primary output)
|
|
6
|
+
* - Docker::Dockerfile → Dockerfile.{name} (per-file entries in SerializerResult)
|
|
7
|
+
*
|
|
8
|
+
* Default labels (DEFAULT_LABELS_MARKER) are merged into every service's
|
|
9
|
+
* labels map but are not emitted as standalone documents.
|
|
10
|
+
*/
|
|
11
|
+
import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
12
|
+
import { mergeResourceAttributes, RELEASE_ATTRIBUTES_VARIABLE, telemetryEnvironment } from "@intentius/chant/telemetry-attribution";
|
|
13
|
+
import { walkValue } from "@intentius/chant/serializer-walker";
|
|
14
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
15
|
+
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
16
|
+
import { getComposeKey } from "./compose-key.js";
|
|
17
|
+
import { EXEC_FORM_STEPS, PER_KIND_ORDER, STEP_INSTRUCTIONS } from "./dockerfile-order.js";
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
19
|
+
const DEFAULT_LABELS_MARKER_KEY = Symbol.for("docker.defaultLabels");
|
|
20
|
+
const DEFAULT_ANNOTATIONS_MARKER_KEY = Symbol.for("docker.defaultAnnotations");
|
|
21
|
+
function isDefaultLabelsEntity(entity) {
|
|
22
|
+
return DEFAULT_LABELS_MARKER_KEY in entity;
|
|
23
|
+
}
|
|
24
|
+
function isDefaultAnnotationsEntity(entity) {
|
|
25
|
+
return DEFAULT_ANNOTATIONS_MARKER_KEY in entity;
|
|
26
|
+
}
|
|
27
|
+
function getProps(entity) {
|
|
28
|
+
if (isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null) {
|
|
29
|
+
return entity.props;
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
// ── Intrinsic preprocessing ───────────────────────────────────────
|
|
34
|
+
function preprocessIntrinsics(value) {
|
|
35
|
+
if (value === null || value === undefined)
|
|
36
|
+
return value;
|
|
37
|
+
if (typeof value === "object" && INTRINSIC_MARKER in value) {
|
|
38
|
+
if ("toJSON" in value && typeof value.toJSON === "function") {
|
|
39
|
+
return (value.toJSON)();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "object" && value !== null && "entityType" in value) {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
return value.map(preprocessIntrinsics);
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === "object") {
|
|
49
|
+
const result = {};
|
|
50
|
+
for (const [k, v] of Object.entries(value)) {
|
|
51
|
+
result[k] = preprocessIntrinsics(v);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
// ── Visitor ───────────────────────────────────────────────────────
|
|
58
|
+
function dockerVisitor(entityNames) {
|
|
59
|
+
const dockerfiles = new Set();
|
|
60
|
+
for (const [entity, name] of entityNames) {
|
|
61
|
+
if (entity.entityType === "Docker::Dockerfile")
|
|
62
|
+
dockerfiles.add(name);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
attrRef: (name, _attr) => name,
|
|
66
|
+
// A Dockerfile reference is its filename. Any other entity (a config, a
|
|
67
|
+
// secret, a volume) is referenced by its key in the compose file.
|
|
68
|
+
resourceRef: (name) => (dockerfiles.has(name) ? `Dockerfile.${name}` : name),
|
|
69
|
+
propertyDeclarable: (entity, walk) => {
|
|
70
|
+
const props = getProps(entity);
|
|
71
|
+
const result = {};
|
|
72
|
+
for (const [key, value] of Object.entries(props)) {
|
|
73
|
+
if (value !== undefined) {
|
|
74
|
+
result[key] = walk(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function toYAMLValue(value, entityNames) {
|
|
82
|
+
const preprocessed = preprocessIntrinsics(value);
|
|
83
|
+
return walkValue(preprocessed, entityNames, dockerVisitor(entityNames));
|
|
84
|
+
}
|
|
85
|
+
// ── Telemetry attribution (#2558, D22) ────────────────────────────
|
|
86
|
+
/** The digest of an image reference pinned with `@sha256:...`, or undefined. */
|
|
87
|
+
function imageDigest(image) {
|
|
88
|
+
if (typeof image !== "string")
|
|
89
|
+
return undefined;
|
|
90
|
+
const at = image.lastIndexOf("@");
|
|
91
|
+
return at === -1 ? undefined : image.slice(at + 1);
|
|
92
|
+
}
|
|
93
|
+
/** Compose's reference to the release attributes a deploy exports (#3061, ws-081), empty when unset. */
|
|
94
|
+
const RELEASE_REF = `\${${RELEASE_ATTRIBUTES_VARIABLE}:-}`;
|
|
95
|
+
/** A resource-attributes value that ends with the release reference, once. */
|
|
96
|
+
function withReleaseRef(value) {
|
|
97
|
+
return value.includes(RELEASE_REF) ? value : `${value}${RELEASE_REF}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Stamp one service's attributes as `OTEL_SERVICE_NAME` and
|
|
101
|
+
* `OTEL_RESOURCE_ATTRIBUTES` in its environment. A value the service already
|
|
102
|
+
* sets is kept: its own `OTEL_SERVICE_NAME` wins, and its own resource
|
|
103
|
+
* attributes keep their keys while the missing ones are appended.
|
|
104
|
+
*
|
|
105
|
+
* `OTEL_RESOURCE_ATTRIBUTES` ends with `${CHANT_RELEASE_ATTRIBUTES:-}`, which
|
|
106
|
+
* Compose fills from the environment of `docker compose up`: a release deploy
|
|
107
|
+
* exports `service.version` and `vcs.ref.head.revision` there (#3061,
|
|
108
|
+
* ws-081), and any other `up` leaves it empty, so this output does not change
|
|
109
|
+
* with a release.
|
|
110
|
+
*/
|
|
111
|
+
function stampTelemetry(service, name, telemetry) {
|
|
112
|
+
const version = imageDigest(service.image);
|
|
113
|
+
const vars = telemetryEnvironment(telemetry, { service: name, decl: name, ...(version ? { version } : {}) });
|
|
114
|
+
const env = service.environment;
|
|
115
|
+
if (Array.isArray(env)) {
|
|
116
|
+
const entries = env.map(String);
|
|
117
|
+
const has = (key) => entries.findIndex((e) => e === key || e.startsWith(`${key}=`));
|
|
118
|
+
if (has("OTEL_SERVICE_NAME") === -1)
|
|
119
|
+
entries.push(`OTEL_SERVICE_NAME=${vars.OTEL_SERVICE_NAME}`);
|
|
120
|
+
const at = has("OTEL_RESOURCE_ATTRIBUTES");
|
|
121
|
+
if (at === -1)
|
|
122
|
+
entries.push(`OTEL_RESOURCE_ATTRIBUTES=${withReleaseRef(vars.OTEL_RESOURCE_ATTRIBUTES)}`);
|
|
123
|
+
else if (entries[at].includes("=")) {
|
|
124
|
+
entries[at] = `OTEL_RESOURCE_ATTRIBUTES=${withReleaseRef(mergeResourceAttributes(entries[at].slice(entries[at].indexOf("=") + 1), vars.OTEL_RESOURCE_ATTRIBUTES))}`;
|
|
125
|
+
}
|
|
126
|
+
service.environment = entries;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const map = env && typeof env === "object" ? { ...env } : {};
|
|
130
|
+
if (!("OTEL_SERVICE_NAME" in map))
|
|
131
|
+
map.OTEL_SERVICE_NAME = vars.OTEL_SERVICE_NAME;
|
|
132
|
+
const own = map.OTEL_RESOURCE_ATTRIBUTES;
|
|
133
|
+
if (own === undefined)
|
|
134
|
+
map.OTEL_RESOURCE_ATTRIBUTES = withReleaseRef(vars.OTEL_RESOURCE_ATTRIBUTES);
|
|
135
|
+
else if (typeof own === "string")
|
|
136
|
+
map.OTEL_RESOURCE_ATTRIBUTES = withReleaseRef(mergeResourceAttributes(own, vars.OTEL_RESOURCE_ATTRIBUTES));
|
|
137
|
+
service.environment = map;
|
|
138
|
+
}
|
|
139
|
+
// ── Compose serialization ─────────────────────────────────────────
|
|
140
|
+
function serializeCompose(services, volumes, networks, configs, secrets, defaultLabels, entityNames, telemetry) {
|
|
141
|
+
const doc = {};
|
|
142
|
+
if (services.size > 0) {
|
|
143
|
+
const servicesSection = {};
|
|
144
|
+
for (const [name, entity] of services) {
|
|
145
|
+
const props = toYAMLValue(getProps(entity), entityNames);
|
|
146
|
+
// Merge default labels into service labels
|
|
147
|
+
if (Object.keys(defaultLabels).length > 0) {
|
|
148
|
+
const existing = props.labels ?? {};
|
|
149
|
+
props.labels = { ...defaultLabels, ...existing };
|
|
150
|
+
}
|
|
151
|
+
// Remove undefined values
|
|
152
|
+
const cleaned = {};
|
|
153
|
+
for (const [k, v] of Object.entries(props)) {
|
|
154
|
+
if (v !== undefined && v !== null)
|
|
155
|
+
cleaned[k] = v;
|
|
156
|
+
}
|
|
157
|
+
if (telemetry)
|
|
158
|
+
stampTelemetry(cleaned, name, telemetry);
|
|
159
|
+
servicesSection[name] = cleaned;
|
|
160
|
+
}
|
|
161
|
+
doc.services = servicesSection;
|
|
162
|
+
}
|
|
163
|
+
if (volumes.size > 0) {
|
|
164
|
+
const volumesSection = {};
|
|
165
|
+
for (const [name, entity] of volumes) {
|
|
166
|
+
const props = toYAMLValue(getProps(entity), entityNames);
|
|
167
|
+
const cleaned = {};
|
|
168
|
+
for (const [k, v] of Object.entries(props)) {
|
|
169
|
+
if (v !== undefined && v !== null)
|
|
170
|
+
cleaned[k] = v;
|
|
171
|
+
}
|
|
172
|
+
volumesSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
173
|
+
}
|
|
174
|
+
doc.volumes = volumesSection;
|
|
175
|
+
}
|
|
176
|
+
if (networks.size > 0) {
|
|
177
|
+
const networksSection = {};
|
|
178
|
+
for (const [name, entity] of networks) {
|
|
179
|
+
const props = toYAMLValue(getProps(entity), entityNames);
|
|
180
|
+
const cleaned = {};
|
|
181
|
+
for (const [k, v] of Object.entries(props)) {
|
|
182
|
+
if (v !== undefined && v !== null)
|
|
183
|
+
cleaned[k] = v;
|
|
184
|
+
}
|
|
185
|
+
networksSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
186
|
+
}
|
|
187
|
+
doc.networks = networksSection;
|
|
188
|
+
}
|
|
189
|
+
if (configs.size > 0) {
|
|
190
|
+
const configsSection = {};
|
|
191
|
+
for (const [name, entity] of configs) {
|
|
192
|
+
const props = toYAMLValue(getProps(entity), entityNames);
|
|
193
|
+
const cleaned = {};
|
|
194
|
+
for (const [k, v] of Object.entries(props)) {
|
|
195
|
+
if (v !== undefined && v !== null)
|
|
196
|
+
cleaned[k] = v;
|
|
197
|
+
}
|
|
198
|
+
configsSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
199
|
+
}
|
|
200
|
+
doc.configs = configsSection;
|
|
201
|
+
}
|
|
202
|
+
if (secrets.size > 0) {
|
|
203
|
+
const secretsSection = {};
|
|
204
|
+
for (const [name, entity] of secrets) {
|
|
205
|
+
const props = toYAMLValue(getProps(entity), entityNames);
|
|
206
|
+
const cleaned = {};
|
|
207
|
+
for (const [k, v] of Object.entries(props)) {
|
|
208
|
+
if (v !== undefined && v !== null)
|
|
209
|
+
cleaned[k] = v;
|
|
210
|
+
}
|
|
211
|
+
secretsSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
212
|
+
}
|
|
213
|
+
doc.secrets = secretsSection;
|
|
214
|
+
}
|
|
215
|
+
return emitComposeDocument(doc);
|
|
216
|
+
}
|
|
217
|
+
function emitComposeDocument(doc) {
|
|
218
|
+
if (Object.keys(doc).length === 0)
|
|
219
|
+
return "\n";
|
|
220
|
+
const ORDER = ["services", "volumes", "networks", "configs", "secrets"];
|
|
221
|
+
const sections = [];
|
|
222
|
+
const emitted = new Set();
|
|
223
|
+
for (const key of ORDER) {
|
|
224
|
+
if (key in doc && doc[key] !== undefined) {
|
|
225
|
+
emitted.add(key);
|
|
226
|
+
sections.push(emitYAMLEntry(key, doc[key]));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
230
|
+
if (!emitted.has(key) && value !== undefined) {
|
|
231
|
+
sections.push(emitYAMLEntry(key, value));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return sections.join("\n\n") + "\n";
|
|
235
|
+
}
|
|
236
|
+
// ── Dockerfile serialization ──────────────────────────────────────
|
|
237
|
+
function serializeDockerfile(name, entity) {
|
|
238
|
+
const props = getProps(entity);
|
|
239
|
+
const lines = [];
|
|
240
|
+
// ARGs before the first FROM: global build args a FROM line can use.
|
|
241
|
+
if (props.globalArgs !== undefined && props.globalArgs !== null) {
|
|
242
|
+
if (!Array.isArray(props.globalArgs)) {
|
|
243
|
+
throw new Error(`Dockerfile.${name}: globalArgs must be a list of ARG values, like ["NODE_VERSION=20"]`);
|
|
244
|
+
}
|
|
245
|
+
for (const arg of props.globalArgs)
|
|
246
|
+
lines.push(`ARG ${arg}`);
|
|
247
|
+
}
|
|
248
|
+
// Multi-stage build via stages[]
|
|
249
|
+
if (Array.isArray(props.stages) && props.stages.length > 0) {
|
|
250
|
+
if (props.steps !== undefined) {
|
|
251
|
+
throw new Error(`Dockerfile.${name}: steps belongs on each stage when stages is set, not on the Dockerfile`);
|
|
252
|
+
}
|
|
253
|
+
props.stages.forEach((stage, i) => {
|
|
254
|
+
emitStage(stage, lines, `Dockerfile.${name} stage ${stage.as ? `"${stage.as}"` : i + 1}`);
|
|
255
|
+
});
|
|
256
|
+
return lines.join("\n") + "\n";
|
|
257
|
+
}
|
|
258
|
+
// Single-stage build
|
|
259
|
+
emitStage(props, lines, `Dockerfile.${name}`);
|
|
260
|
+
return lines.join("\n") + "\n";
|
|
261
|
+
}
|
|
262
|
+
function emitStage(stage, lines, where) {
|
|
263
|
+
// FROM must be first
|
|
264
|
+
if (stage.from) {
|
|
265
|
+
const as = stage.as ? ` AS ${stage.as}` : "";
|
|
266
|
+
lines.push(`FROM ${stage.from}${as}`);
|
|
267
|
+
}
|
|
268
|
+
if (stage.steps !== undefined && stage.steps !== null) {
|
|
269
|
+
const perKind = PER_KIND_ORDER.filter((k) => stage[k] !== undefined && stage[k] !== null);
|
|
270
|
+
if (perKind.length > 0) {
|
|
271
|
+
throw new Error(`${where}: steps can't be combined with the per-kind fields (${perKind.join(", ")}). Move them into steps, in the order they should run.`);
|
|
272
|
+
}
|
|
273
|
+
if (!Array.isArray(stage.steps))
|
|
274
|
+
throw new Error(`${where}: steps must be a list`);
|
|
275
|
+
stage.steps.forEach((step, i) => lines.push(emitStep(step, `${where} step ${i + 1}`)));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// No steps: the per-kind fields, in the fixed order they have always had.
|
|
279
|
+
for (const instr of PER_KIND_ORDER) {
|
|
280
|
+
const value = stage[instr];
|
|
281
|
+
if (value === undefined || value === null)
|
|
282
|
+
continue;
|
|
283
|
+
const keyword = instr.toUpperCase();
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
for (const item of value) {
|
|
286
|
+
lines.push(`${keyword} ${item}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (typeof value === "string") {
|
|
290
|
+
lines.push(`${keyword} ${value}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** One step, `{ run: "npm ci" }`, as its Dockerfile line, `RUN npm ci`. */
|
|
295
|
+
function emitStep(step, where) {
|
|
296
|
+
if (typeof step !== "object" || step === null || Array.isArray(step)) {
|
|
297
|
+
throw new Error(`${where}: a step is an object naming one instruction, like { run: "npm ci" }`);
|
|
298
|
+
}
|
|
299
|
+
const keys = Object.keys(step).filter((k) => step[k] !== undefined);
|
|
300
|
+
if (keys.length !== 1) {
|
|
301
|
+
throw new Error(`${where}: a step names exactly one instruction, found ${keys.length === 0 ? "none" : keys.join(", ")}. Split it into one step per instruction.`);
|
|
302
|
+
}
|
|
303
|
+
const key = keys[0];
|
|
304
|
+
if (!STEP_INSTRUCTIONS.has(key)) {
|
|
305
|
+
throw new Error(`${where}: "${key}" is not a Dockerfile instruction a step can name (FROM is the stage's from field)`);
|
|
306
|
+
}
|
|
307
|
+
const value = step[key];
|
|
308
|
+
const keyword = key.toUpperCase();
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
if (!EXEC_FORM_STEPS.has(key)) {
|
|
311
|
+
throw new Error(`${where}: ${keyword} takes a string; only RUN, CMD, ENTRYPOINT and SHELL take an exec-form array`);
|
|
312
|
+
}
|
|
313
|
+
return `${keyword} ${JSON.stringify(value.map(String))}`;
|
|
314
|
+
}
|
|
315
|
+
if (typeof value === "string" || typeof value === "number")
|
|
316
|
+
return `${keyword} ${value}`;
|
|
317
|
+
throw new Error(`${where}: ${keyword} needs a string value`);
|
|
318
|
+
}
|
|
319
|
+
// ── Serializer ────────────────────────────────────────────────────
|
|
320
|
+
export const dockerSerializer = {
|
|
321
|
+
name: "docker",
|
|
322
|
+
rulePrefix: "DKR",
|
|
323
|
+
serialize(entities, _outputs, context) {
|
|
324
|
+
// A compose entity's key is its export name unless composeKey set one
|
|
325
|
+
// (#3937); references to it serialize as the same key. Two entities of
|
|
326
|
+
// one kind cannot share a key.
|
|
327
|
+
const entityNames = new Map();
|
|
328
|
+
const taken = new Map();
|
|
329
|
+
for (const [name, entity] of entities) {
|
|
330
|
+
const key = getComposeKey(entity) ?? name;
|
|
331
|
+
entityNames.set(entity, key);
|
|
332
|
+
const slot = `${entity.entityType}\0${key}`;
|
|
333
|
+
const other = taken.get(slot);
|
|
334
|
+
if (other !== undefined && other.entity !== entity) {
|
|
335
|
+
throw new Error(`docker: ${other.name} and ${name} both take the compose key "${key}"`);
|
|
336
|
+
}
|
|
337
|
+
taken.set(slot, { name, entity });
|
|
338
|
+
}
|
|
339
|
+
const services = new Map();
|
|
340
|
+
const volumes = new Map();
|
|
341
|
+
const networks = new Map();
|
|
342
|
+
const configs = new Map();
|
|
343
|
+
const secrets = new Map();
|
|
344
|
+
const dockerfiles = new Map();
|
|
345
|
+
let defaultLabels = {};
|
|
346
|
+
for (const [exportName, entity] of entities) {
|
|
347
|
+
const name = entityNames.get(entity) ?? exportName;
|
|
348
|
+
// Skip property-kind entities
|
|
349
|
+
if (isPropertyDeclarable(entity))
|
|
350
|
+
continue;
|
|
351
|
+
// Skip default labels/annotations markers — collect instead
|
|
352
|
+
if (isDefaultLabelsEntity(entity)) {
|
|
353
|
+
const props = getProps(entity);
|
|
354
|
+
defaultLabels = { ...defaultLabels, ...(props.labels ?? {}) };
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (isDefaultAnnotationsEntity(entity)) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const et = entity.entityType;
|
|
361
|
+
if (et === "Docker::Compose::Service") {
|
|
362
|
+
services.set(name, entity);
|
|
363
|
+
}
|
|
364
|
+
else if (et === "Docker::Compose::Volume") {
|
|
365
|
+
volumes.set(name, entity);
|
|
366
|
+
}
|
|
367
|
+
else if (et === "Docker::Compose::Network") {
|
|
368
|
+
networks.set(name, entity);
|
|
369
|
+
}
|
|
370
|
+
else if (et === "Docker::Compose::Config") {
|
|
371
|
+
configs.set(name, entity);
|
|
372
|
+
}
|
|
373
|
+
else if (et === "Docker::Compose::Secret") {
|
|
374
|
+
secrets.set(name, entity);
|
|
375
|
+
}
|
|
376
|
+
else if (et === "Docker::Dockerfile") {
|
|
377
|
+
dockerfiles.set(name, entity);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const composeYaml = serializeCompose(services, volumes, networks, configs, secrets, defaultLabels, entityNames, context?.telemetry);
|
|
381
|
+
if (dockerfiles.size === 0) {
|
|
382
|
+
return composeYaml;
|
|
383
|
+
}
|
|
384
|
+
const files = {};
|
|
385
|
+
for (const [name, entity] of dockerfiles) {
|
|
386
|
+
files[`Dockerfile.${name}`] = serializeDockerfile(name, entity);
|
|
387
|
+
}
|
|
388
|
+
return { primary: composeYaml, files };
|
|
389
|
+
},
|
|
390
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch and cache the Compose Spec JSON Schema.
|
|
3
|
+
*/
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
7
|
+
import { COMPOSE_SPEC_URL, COMPOSE_SPEC_CACHE } from "../codegen/versions.js";
|
|
8
|
+
export function getCachePath() {
|
|
9
|
+
return join(homedir(), ".chant", COMPOSE_SPEC_CACHE);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fetch the Compose Spec JSON Schema, with local caching.
|
|
13
|
+
*/
|
|
14
|
+
export async function fetchComposeSpec(force) {
|
|
15
|
+
return fetchWithCache({
|
|
16
|
+
url: COMPOSE_SPEC_URL,
|
|
17
|
+
cacheFile: getCachePath(),
|
|
18
|
+
}, force);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fetch compose spec as a Map keyed by entity type — for generatePipeline.
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchComposeSchemas(force) {
|
|
24
|
+
const data = await fetchComposeSpec(force);
|
|
25
|
+
const schemas = new Map();
|
|
26
|
+
schemas.set("Docker::Compose::Service", data);
|
|
27
|
+
return schemas;
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch and cache the Docker Engine API OpenAPI spec.
|
|
3
|
+
*/
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
7
|
+
import { ENGINE_API_URL, ENGINE_API_CACHE } from "../codegen/versions.js";
|
|
8
|
+
export function getCachePath() {
|
|
9
|
+
return join(homedir(), ".chant", ENGINE_API_CACHE);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fetch the Docker Engine API swagger YAML, with local caching.
|
|
13
|
+
*/
|
|
14
|
+
export async function fetchEngineApi(force) {
|
|
15
|
+
return fetchWithCache({
|
|
16
|
+
url: ENGINE_API_URL,
|
|
17
|
+
cacheFile: getCachePath(),
|
|
18
|
+
}, force);
|
|
19
|
+
}
|