@intentius/chant-lexicon-docker 0.1.0
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/README.md +24 -0
- package/dist/integrity.json +19 -0
- package/dist/manifest.json +15 -0
- package/dist/meta.json +222 -0
- package/dist/rules/apt-no-recommends.ts +43 -0
- package/dist/rules/docker-helpers.ts +114 -0
- package/dist/rules/no-latest-image.ts +36 -0
- package/dist/rules/no-latest-tag.ts +63 -0
- package/dist/rules/no-root-user.ts +36 -0
- package/dist/rules/prefer-copy.ts +53 -0
- package/dist/rules/ssh-port-exposed.ts +68 -0
- package/dist/rules/unused-volume.ts +49 -0
- package/dist/skills/chant-docker-patterns.md +153 -0
- package/dist/skills/chant-docker.md +129 -0
- package/dist/types/index.d.ts +93 -0
- package/package.json +53 -0
- package/src/codegen/docs-cli.ts +10 -0
- package/src/codegen/docs.ts +12 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-compose.ts +21 -0
- package/src/codegen/generate-dockerfile.ts +21 -0
- package/src/codegen/generate.test.ts +105 -0
- package/src/codegen/generate.ts +158 -0
- package/src/codegen/naming.test.ts +81 -0
- package/src/codegen/naming.ts +54 -0
- package/src/codegen/package.ts +65 -0
- package/src/codegen/patches.ts +42 -0
- package/src/codegen/versions.ts +15 -0
- package/src/composites/index.ts +12 -0
- package/src/coverage.test.ts +33 -0
- package/src/coverage.ts +54 -0
- package/src/default-labels.test.ts +85 -0
- package/src/default-labels.ts +72 -0
- package/src/generated/index.d.ts +93 -0
- package/src/generated/index.ts +10 -0
- package/src/generated/lexicon-docker.json +222 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +133 -0
- package/src/import/generator.ts +127 -0
- package/src/import/parser.test.ts +137 -0
- package/src/import/parser.ts +190 -0
- package/src/import/roundtrip.test.ts +49 -0
- package/src/import/testdata/full.yaml +43 -0
- package/src/import/testdata/simple.yaml +9 -0
- package/src/import/testdata/webapp.yaml +41 -0
- package/src/index.ts +29 -0
- package/src/interpolation.test.ts +41 -0
- package/src/interpolation.ts +76 -0
- package/src/lint/post-synth/apt-no-recommends.ts +43 -0
- package/src/lint/post-synth/docker-helpers.ts +114 -0
- package/src/lint/post-synth/no-latest-image.ts +36 -0
- package/src/lint/post-synth/no-root-user.ts +36 -0
- package/src/lint/post-synth/post-synth.test.ts +181 -0
- package/src/lint/post-synth/prefer-copy.ts +53 -0
- package/src/lint/post-synth/ssh-port-exposed.ts +68 -0
- package/src/lint/post-synth/unused-volume.ts +49 -0
- package/src/lint/rules/data/deprecated-images.ts +28 -0
- package/src/lint/rules/data/known-base-images.ts +20 -0
- package/src/lint/rules/index.ts +5 -0
- package/src/lint/rules/no-latest-tag.ts +63 -0
- package/src/lint/rules/rules.test.ts +82 -0
- package/src/lsp/completions.test.ts +34 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +34 -0
- package/src/lsp/hover.ts +38 -0
- package/src/package-cli.ts +42 -0
- package/src/plugin.test.ts +117 -0
- package/src/plugin.ts +250 -0
- package/src/serializer.test.ts +294 -0
- package/src/serializer.ts +322 -0
- package/src/skills/chant-docker-patterns.md +153 -0
- package/src/skills/chant-docker.md +129 -0
- package/src/spec/fetch-compose.ts +35 -0
- package/src/spec/fetch-engine.ts +25 -0
- package/src/spec/parse-compose.ts +110 -0
- package/src/spec/parse-engine.ts +47 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +16 -0
- package/src/validate.ts +44 -0
- package/src/variables.test.ts +32 -0
- package/src/variables.ts +47 -0
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
|
|
12
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
13
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
14
|
+
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
15
|
+
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
16
|
+
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
17
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
18
|
+
import { emitYAML } from "@intentius/chant/yaml";
|
|
19
|
+
|
|
20
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const DEFAULT_LABELS_MARKER_KEY = Symbol.for("docker.defaultLabels");
|
|
23
|
+
const DEFAULT_ANNOTATIONS_MARKER_KEY = Symbol.for("docker.defaultAnnotations");
|
|
24
|
+
|
|
25
|
+
function isDefaultLabelsEntity(entity: Declarable): boolean {
|
|
26
|
+
return DEFAULT_LABELS_MARKER_KEY in entity;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isDefaultAnnotationsEntity(entity: Declarable): boolean {
|
|
30
|
+
return DEFAULT_ANNOTATIONS_MARKER_KEY in entity;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getProps(entity: Declarable): Record<string, unknown> {
|
|
34
|
+
if ("props" in entity && typeof entity.props === "object" && entity.props !== null) {
|
|
35
|
+
return entity.props as Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Intrinsic preprocessing ───────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function preprocessIntrinsics(value: unknown): unknown {
|
|
43
|
+
if (value === null || value === undefined) return value;
|
|
44
|
+
|
|
45
|
+
if (typeof value === "object" && INTRINSIC_MARKER in (value as object)) {
|
|
46
|
+
if ("toJSON" in (value as object) && typeof (value as { toJSON?: unknown }).toJSON === "function") {
|
|
47
|
+
return ((value as { toJSON(): unknown }).toJSON)();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof value === "object" && value !== null && "entityType" in (value as object)) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.map(preprocessIntrinsics);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value === "object") {
|
|
60
|
+
const result: Record<string, unknown> = {};
|
|
61
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
62
|
+
result[k] = preprocessIntrinsics(v);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Visitor ───────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function dockerVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
73
|
+
return {
|
|
74
|
+
attrRef: (name, _attr) => name,
|
|
75
|
+
// For Dockerfile references, emit the filename
|
|
76
|
+
resourceRef: (name) => `Dockerfile.${name}`,
|
|
77
|
+
propertyDeclarable: (entity, walk) => {
|
|
78
|
+
const props = getProps(entity);
|
|
79
|
+
const result: Record<string, unknown> = {};
|
|
80
|
+
for (const [key, value] of Object.entries(props)) {
|
|
81
|
+
if (value !== undefined) {
|
|
82
|
+
result[key] = walk(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toYAMLValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
|
|
91
|
+
const preprocessed = preprocessIntrinsics(value);
|
|
92
|
+
return walkValue(preprocessed, entityNames, dockerVisitor(entityNames));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Compose serialization ─────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function serializeCompose(
|
|
98
|
+
services: Map<string, Declarable>,
|
|
99
|
+
volumes: Map<string, Declarable>,
|
|
100
|
+
networks: Map<string, Declarable>,
|
|
101
|
+
configs: Map<string, Declarable>,
|
|
102
|
+
secrets: Map<string, Declarable>,
|
|
103
|
+
defaultLabels: Record<string, string>,
|
|
104
|
+
entityNames: Map<Declarable, string>,
|
|
105
|
+
): string {
|
|
106
|
+
const doc: Record<string, unknown> = {};
|
|
107
|
+
|
|
108
|
+
if (services.size > 0) {
|
|
109
|
+
const servicesSection: Record<string, unknown> = {};
|
|
110
|
+
for (const [name, entity] of services) {
|
|
111
|
+
const props = toYAMLValue(getProps(entity), entityNames) as Record<string, unknown>;
|
|
112
|
+
// Merge default labels into service labels
|
|
113
|
+
if (Object.keys(defaultLabels).length > 0) {
|
|
114
|
+
const existing = (props.labels as Record<string, string>) ?? {};
|
|
115
|
+
props.labels = { ...defaultLabels, ...existing };
|
|
116
|
+
}
|
|
117
|
+
// Remove undefined values
|
|
118
|
+
const cleaned: Record<string, unknown> = {};
|
|
119
|
+
for (const [k, v] of Object.entries(props)) {
|
|
120
|
+
if (v !== undefined && v !== null) cleaned[k] = v;
|
|
121
|
+
}
|
|
122
|
+
servicesSection[name] = cleaned;
|
|
123
|
+
}
|
|
124
|
+
doc.services = servicesSection;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (volumes.size > 0) {
|
|
128
|
+
const volumesSection: Record<string, unknown> = {};
|
|
129
|
+
for (const [name, entity] of volumes) {
|
|
130
|
+
const props = toYAMLValue(getProps(entity), entityNames) as Record<string, unknown>;
|
|
131
|
+
const cleaned: Record<string, unknown> = {};
|
|
132
|
+
for (const [k, v] of Object.entries(props)) {
|
|
133
|
+
if (v !== undefined && v !== null) cleaned[k] = v;
|
|
134
|
+
}
|
|
135
|
+
volumesSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
136
|
+
}
|
|
137
|
+
doc.volumes = volumesSection;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (networks.size > 0) {
|
|
141
|
+
const networksSection: Record<string, unknown> = {};
|
|
142
|
+
for (const [name, entity] of networks) {
|
|
143
|
+
const props = toYAMLValue(getProps(entity), entityNames) as Record<string, unknown>;
|
|
144
|
+
const cleaned: Record<string, unknown> = {};
|
|
145
|
+
for (const [k, v] of Object.entries(props)) {
|
|
146
|
+
if (v !== undefined && v !== null) cleaned[k] = v;
|
|
147
|
+
}
|
|
148
|
+
networksSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
149
|
+
}
|
|
150
|
+
doc.networks = networksSection;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (configs.size > 0) {
|
|
154
|
+
const configsSection: Record<string, unknown> = {};
|
|
155
|
+
for (const [name, entity] of configs) {
|
|
156
|
+
const props = toYAMLValue(getProps(entity), entityNames) as Record<string, unknown>;
|
|
157
|
+
const cleaned: Record<string, unknown> = {};
|
|
158
|
+
for (const [k, v] of Object.entries(props)) {
|
|
159
|
+
if (v !== undefined && v !== null) cleaned[k] = v;
|
|
160
|
+
}
|
|
161
|
+
configsSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
162
|
+
}
|
|
163
|
+
doc.configs = configsSection;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (secrets.size > 0) {
|
|
167
|
+
const secretsSection: Record<string, unknown> = {};
|
|
168
|
+
for (const [name, entity] of secrets) {
|
|
169
|
+
const props = toYAMLValue(getProps(entity), entityNames) as Record<string, unknown>;
|
|
170
|
+
const cleaned: Record<string, unknown> = {};
|
|
171
|
+
for (const [k, v] of Object.entries(props)) {
|
|
172
|
+
if (v !== undefined && v !== null) cleaned[k] = v;
|
|
173
|
+
}
|
|
174
|
+
secretsSection[name] = Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
175
|
+
}
|
|
176
|
+
doc.secrets = secretsSection;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return emitComposeDocument(doc);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function emitComposeDocument(doc: Record<string, unknown>): string {
|
|
183
|
+
if (Object.keys(doc).length === 0) return "\n";
|
|
184
|
+
|
|
185
|
+
const ORDER = ["services", "volumes", "networks", "configs", "secrets"];
|
|
186
|
+
const sections: string[] = [];
|
|
187
|
+
const emitted = new Set<string>();
|
|
188
|
+
|
|
189
|
+
for (const key of ORDER) {
|
|
190
|
+
if (key in doc && doc[key] !== undefined) {
|
|
191
|
+
emitted.add(key);
|
|
192
|
+
sections.push(`${key}:` + emitYAML(doc[key], 1));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
197
|
+
if (!emitted.has(key) && value !== undefined) {
|
|
198
|
+
sections.push(`${key}:` + emitYAML(value, 1));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return sections.join("\n\n") + "\n";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Dockerfile serialization ──────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
function serializeDockerfile(entity: Declarable): string {
|
|
208
|
+
const props = getProps(entity);
|
|
209
|
+
const lines: string[] = [];
|
|
210
|
+
|
|
211
|
+
// Multi-stage build via stages[]
|
|
212
|
+
if (Array.isArray(props.stages) && props.stages.length > 0) {
|
|
213
|
+
for (const stage of props.stages as Array<Record<string, unknown>>) {
|
|
214
|
+
emitStage(stage, lines);
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n") + "\n";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Single-stage build
|
|
220
|
+
emitStage(props, lines);
|
|
221
|
+
return lines.join("\n") + "\n";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function emitStage(stage: Record<string, unknown>, lines: string[]): void {
|
|
225
|
+
// FROM must be first
|
|
226
|
+
if (stage.from) {
|
|
227
|
+
const as = stage.as ? ` AS ${stage.as}` : "";
|
|
228
|
+
lines.push(`FROM ${stage.from}${as}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const INSTRUCTION_ORDER = [
|
|
232
|
+
"arg", "env", "workdir", "user", "run", "copy", "add",
|
|
233
|
+
"expose", "volume", "label", "entrypoint", "cmd", "healthcheck",
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const instr of INSTRUCTION_ORDER) {
|
|
237
|
+
const value = stage[instr];
|
|
238
|
+
if (value === undefined || value === null) continue;
|
|
239
|
+
|
|
240
|
+
const keyword = instr.toUpperCase();
|
|
241
|
+
|
|
242
|
+
if (Array.isArray(value)) {
|
|
243
|
+
for (const item of value) {
|
|
244
|
+
lines.push(`${keyword} ${item}`);
|
|
245
|
+
}
|
|
246
|
+
} else if (typeof value === "string") {
|
|
247
|
+
lines.push(`${keyword} ${value}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Serializer ────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export const dockerSerializer: Serializer = {
|
|
255
|
+
name: "docker",
|
|
256
|
+
rulePrefix: "DKR",
|
|
257
|
+
|
|
258
|
+
serialize(
|
|
259
|
+
entities: Map<string, Declarable>,
|
|
260
|
+
_outputs?: LexiconOutput[],
|
|
261
|
+
): string | SerializerResult {
|
|
262
|
+
const entityNames = new Map<Declarable, string>();
|
|
263
|
+
for (const [name, entity] of entities) {
|
|
264
|
+
entityNames.set(entity, name);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const services = new Map<string, Declarable>();
|
|
268
|
+
const volumes = new Map<string, Declarable>();
|
|
269
|
+
const networks = new Map<string, Declarable>();
|
|
270
|
+
const configs = new Map<string, Declarable>();
|
|
271
|
+
const secrets = new Map<string, Declarable>();
|
|
272
|
+
const dockerfiles = new Map<string, Declarable>();
|
|
273
|
+
let defaultLabels: Record<string, string> = {};
|
|
274
|
+
|
|
275
|
+
for (const [name, entity] of entities) {
|
|
276
|
+
// Skip property-kind entities
|
|
277
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
278
|
+
|
|
279
|
+
// Skip default labels/annotations markers — collect instead
|
|
280
|
+
if (isDefaultLabelsEntity(entity)) {
|
|
281
|
+
const props = getProps(entity);
|
|
282
|
+
defaultLabels = { ...defaultLabels, ...(props.labels as Record<string, string> ?? {}) };
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (isDefaultAnnotationsEntity(entity)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const et = (entity as Record<string, unknown>).entityType as string;
|
|
290
|
+
|
|
291
|
+
if (et === "Docker::Compose::Service") {
|
|
292
|
+
services.set(name, entity);
|
|
293
|
+
} else if (et === "Docker::Compose::Volume") {
|
|
294
|
+
volumes.set(name, entity);
|
|
295
|
+
} else if (et === "Docker::Compose::Network") {
|
|
296
|
+
networks.set(name, entity);
|
|
297
|
+
} else if (et === "Docker::Compose::Config") {
|
|
298
|
+
configs.set(name, entity);
|
|
299
|
+
} else if (et === "Docker::Compose::Secret") {
|
|
300
|
+
secrets.set(name, entity);
|
|
301
|
+
} else if (et === "Docker::Dockerfile") {
|
|
302
|
+
dockerfiles.set(name, entity);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const composeYaml = serializeCompose(
|
|
307
|
+
services, volumes, networks, configs, secrets,
|
|
308
|
+
defaultLabels, entityNames,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
if (dockerfiles.size === 0) {
|
|
312
|
+
return composeYaml;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const files: Record<string, string> = {};
|
|
316
|
+
for (const [name, entity] of dockerfiles) {
|
|
317
|
+
files[`Dockerfile.${name}`] = serializeDockerfile(entity);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { primary: composeYaml, files };
|
|
321
|
+
},
|
|
322
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# chant-docker-patterns
|
|
2
|
+
|
|
3
|
+
Common Docker Compose and Dockerfile patterns using `@intentius/chant-lexicon-docker`.
|
|
4
|
+
|
|
5
|
+
## Database service with volume
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { Service, Volume } from "@intentius/chant-lexicon-docker";
|
|
9
|
+
|
|
10
|
+
export const postgres = new Service({
|
|
11
|
+
image: "postgres:16-alpine",
|
|
12
|
+
environment: {
|
|
13
|
+
POSTGRES_DB: "myapp",
|
|
14
|
+
POSTGRES_USER: "myapp",
|
|
15
|
+
POSTGRES_PASSWORD: env("POSTGRES_PASSWORD", { required: true }),
|
|
16
|
+
},
|
|
17
|
+
volumes: ["pgdata:/var/lib/postgresql/data"],
|
|
18
|
+
restart: "unless-stopped",
|
|
19
|
+
healthcheck: {
|
|
20
|
+
test: ["CMD-SHELL", "pg_isready -U myapp"],
|
|
21
|
+
interval: "10s",
|
|
22
|
+
timeout: "5s",
|
|
23
|
+
retries: 5,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const pgdata = new Volume({});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Redis cache
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
export const redis = new Service({
|
|
34
|
+
image: "redis:7-alpine",
|
|
35
|
+
volumes: ["redisdata:/data"],
|
|
36
|
+
command: "redis-server --appendonly yes",
|
|
37
|
+
restart: "unless-stopped",
|
|
38
|
+
});
|
|
39
|
+
export const redisdata = new Volume({});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Reverse proxy (nginx)
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
export const nginx = new Service({
|
|
46
|
+
image: "nginx:1.25-alpine",
|
|
47
|
+
ports: ["80:80", "443:443"],
|
|
48
|
+
volumes: ["./nginx.conf:/etc/nginx/nginx.conf:ro", "./certs:/etc/ssl/certs:ro"],
|
|
49
|
+
depends_on: ["api"],
|
|
50
|
+
restart: "unless-stopped",
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Multi-stage Node.js Dockerfile
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
export const nodeApp = new Dockerfile({
|
|
58
|
+
stages: [
|
|
59
|
+
{
|
|
60
|
+
from: "node:20-alpine",
|
|
61
|
+
as: "deps",
|
|
62
|
+
workdir: "/app",
|
|
63
|
+
copy: ["package*.json ./"],
|
|
64
|
+
run: ["npm ci --only=production"],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
from: "node:20-alpine",
|
|
68
|
+
as: "build",
|
|
69
|
+
workdir: "/app",
|
|
70
|
+
copy: ["--from=deps /app/node_modules ./node_modules", ". ."],
|
|
71
|
+
run: ["npm run build"],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
from: "node:20-alpine",
|
|
75
|
+
workdir: "/app",
|
|
76
|
+
copy: [
|
|
77
|
+
"--from=deps /app/node_modules ./node_modules",
|
|
78
|
+
"--from=build /app/dist ./dist",
|
|
79
|
+
],
|
|
80
|
+
user: "node",
|
|
81
|
+
expose: ["3000"],
|
|
82
|
+
cmd: `["node", "dist/index.js"]`,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Python service with virtualenv
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
export const pythonApp = new Dockerfile({
|
|
92
|
+
from: "python:3.12-slim",
|
|
93
|
+
workdir: "/app",
|
|
94
|
+
env: ["PYTHONUNBUFFERED=1", "PYTHONDONTWRITEBYTECODE=1"],
|
|
95
|
+
copy: ["requirements.txt ."],
|
|
96
|
+
run: [
|
|
97
|
+
"pip install --no-cache-dir --no-deps -r requirements.txt",
|
|
98
|
+
],
|
|
99
|
+
copy: [". ."],
|
|
100
|
+
user: "1000:1000",
|
|
101
|
+
cmd: `["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]`,
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Network isolation
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import { Service, Network } from "@intentius/chant-lexicon-docker";
|
|
109
|
+
|
|
110
|
+
export const internal = new Network({ driver: "bridge", internal: true });
|
|
111
|
+
export const external = new Network({ driver: "bridge" });
|
|
112
|
+
|
|
113
|
+
export const api = new Service({
|
|
114
|
+
image: "myapi:1.0",
|
|
115
|
+
networks: ["external", "internal"],
|
|
116
|
+
});
|
|
117
|
+
export const db = new Service({
|
|
118
|
+
image: "postgres:16-alpine",
|
|
119
|
+
networks: ["internal"], // only reachable via internal network
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Secrets and configs
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { Service, DockerSecret, DockerConfig } from "@intentius/chant-lexicon-docker";
|
|
127
|
+
|
|
128
|
+
export const dbPassword = new DockerSecret({ file: "./secrets/db-password.txt" });
|
|
129
|
+
export const appConfig = new DockerConfig({ file: "./config/app.yaml" });
|
|
130
|
+
|
|
131
|
+
export const app = new Service({
|
|
132
|
+
image: "myapp:1.0",
|
|
133
|
+
secrets: ["dbPassword"],
|
|
134
|
+
configs: ["appConfig"],
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Environment interpolation patterns
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import { env } from "@intentius/chant-lexicon-docker";
|
|
142
|
+
|
|
143
|
+
// Dev vs prod toggle:
|
|
144
|
+
export const api = new Service({
|
|
145
|
+
image: env("API_IMAGE", { default: "myapp:latest" }),
|
|
146
|
+
environment: {
|
|
147
|
+
LOG_LEVEL: env("LOG_LEVEL", { default: "info" }),
|
|
148
|
+
WORKERS: env("WORKERS", { default: "4" }),
|
|
149
|
+
SECRET_KEY: env("SECRET_KEY", { required: true }),
|
|
150
|
+
SENTRY_DSN: env("SENTRY_DSN", { default: "" }),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
```
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# chant-docker
|
|
2
|
+
|
|
3
|
+
You are helping a developer define Docker Compose services and Dockerfiles using the `@intentius/chant-lexicon-docker` library.
|
|
4
|
+
|
|
5
|
+
## Core types
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { Service, Volume, Network, Dockerfile, env, defaultLabels } from "@intentius/chant-lexicon-docker";
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Service — docker-compose.yml services:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
export const api = new Service({
|
|
15
|
+
image: "myapp:1.0",
|
|
16
|
+
ports: ["8080:8080"],
|
|
17
|
+
environment: {
|
|
18
|
+
NODE_ENV: "production",
|
|
19
|
+
DB_URL: env("DATABASE_URL", { required: true }),
|
|
20
|
+
},
|
|
21
|
+
depends_on: ["db"],
|
|
22
|
+
restart: "unless-stopped",
|
|
23
|
+
healthcheck: {
|
|
24
|
+
test: ["CMD", "curl", "-f", "http://localhost:8080/health"],
|
|
25
|
+
interval: "30s",
|
|
26
|
+
timeout: "10s",
|
|
27
|
+
retries: 3,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Volume — top-level named volume:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
export const pgdata = new Volume({});
|
|
36
|
+
// With driver options:
|
|
37
|
+
export const nfsdata = new Volume({ driver: "local", driver_opts: { type: "nfs", o: "addr=..." } });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Dockerfile — generates Dockerfile.{name}:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// Single-stage:
|
|
44
|
+
export const builder = new Dockerfile({
|
|
45
|
+
from: "node:20-alpine",
|
|
46
|
+
workdir: "/app",
|
|
47
|
+
copy: ["package*.json ./"],
|
|
48
|
+
run: ["npm ci --only=production"],
|
|
49
|
+
user: "node",
|
|
50
|
+
cmd: `["node", "dist/index.js"]`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Multi-stage build:
|
|
54
|
+
export const app = new Dockerfile({
|
|
55
|
+
stages: [
|
|
56
|
+
{
|
|
57
|
+
from: "node:20-alpine",
|
|
58
|
+
as: "build",
|
|
59
|
+
workdir: "/app",
|
|
60
|
+
copy: ["package*.json ./"],
|
|
61
|
+
run: ["npm ci", "npm run build"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
from: "node:20-alpine",
|
|
65
|
+
workdir: "/app",
|
|
66
|
+
copy: ["--from=build /app/dist ./dist", "--from=build /app/node_modules ./node_modules"],
|
|
67
|
+
user: "node",
|
|
68
|
+
cmd: `["node", "dist/index.js"]`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Variable interpolation with env():
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { env } from "@intentius/chant-lexicon-docker";
|
|
78
|
+
|
|
79
|
+
// ${VAR} — required, no default:
|
|
80
|
+
env("DATABASE_URL")
|
|
81
|
+
|
|
82
|
+
// ${VAR:-default} — use default if unset:
|
|
83
|
+
env("LOG_LEVEL", { default: "info" })
|
|
84
|
+
|
|
85
|
+
// ${VAR:?message} — fail with error if unset:
|
|
86
|
+
env("API_SECRET", { required: true })
|
|
87
|
+
env("API_SECRET", { errorMessage: "API_SECRET must be set in .env" })
|
|
88
|
+
|
|
89
|
+
// ${VAR:+value} — substitute value if VAR is set:
|
|
90
|
+
env("DEBUG", { ifSet: "verbose" })
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Default labels (injected into all services):
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
export const labels = defaultLabels({
|
|
97
|
+
"com.example.team": "platform",
|
|
98
|
+
"com.example.managed-by": "chant",
|
|
99
|
+
"com.example.version": "1.0.0",
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Service → Dockerfile cross-reference:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// The serializer emits "Dockerfile.builder" as the filename.
|
|
107
|
+
// Reference it in the service build config:
|
|
108
|
+
export const api = new Service({
|
|
109
|
+
build: {
|
|
110
|
+
context: ".",
|
|
111
|
+
dockerfile: "Dockerfile.builder", // matches the Dockerfile entity name
|
|
112
|
+
},
|
|
113
|
+
ports: ["8080:8080"],
|
|
114
|
+
});
|
|
115
|
+
export const builder = new Dockerfile({ from: "node:20-alpine", ... });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Output structure
|
|
119
|
+
|
|
120
|
+
- All Compose entities → `docker-compose.yml`
|
|
121
|
+
- Each Dockerfile entity → `Dockerfile.{name}`
|
|
122
|
+
|
|
123
|
+
## Key rules
|
|
124
|
+
|
|
125
|
+
- `DKRS001`: Never use `:latest` image tags in source — use explicit versions
|
|
126
|
+
- `DKRD001`: Post-synth: no `:latest` images in generated YAML
|
|
127
|
+
- `DKRD002`: Post-synth: no unused named volumes
|
|
128
|
+
- `DKRD003`: Post-synth: SSH port 22 not exposed externally
|
|
129
|
+
- `DKRD010–012`: Dockerfile best practices (apt recommends, COPY vs ADD, USER)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch and cache the Compose Spec JSON Schema.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
8
|
+
import { COMPOSE_SPEC_URL, COMPOSE_SPEC_CACHE } from "../codegen/versions";
|
|
9
|
+
|
|
10
|
+
export function getCachePath(): string {
|
|
11
|
+
return join(homedir(), ".chant", COMPOSE_SPEC_CACHE);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the Compose Spec JSON Schema, with local caching.
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchComposeSpec(force?: boolean): Promise<Buffer> {
|
|
18
|
+
return fetchWithCache(
|
|
19
|
+
{
|
|
20
|
+
url: COMPOSE_SPEC_URL,
|
|
21
|
+
cacheFile: getCachePath(),
|
|
22
|
+
},
|
|
23
|
+
force,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch compose spec as a Map keyed by entity type — for generatePipeline.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchComposeSchemas(force?: boolean): Promise<Map<string, Buffer>> {
|
|
31
|
+
const data = await fetchComposeSpec(force);
|
|
32
|
+
const schemas = new Map<string, Buffer>();
|
|
33
|
+
schemas.set("Docker::Compose::Service", data);
|
|
34
|
+
return schemas;
|
|
35
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch and cache the Docker Engine API OpenAPI spec.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
8
|
+
import { ENGINE_API_URL, ENGINE_API_CACHE } from "../codegen/versions";
|
|
9
|
+
|
|
10
|
+
export function getCachePath(): string {
|
|
11
|
+
return join(homedir(), ".chant", ENGINE_API_CACHE);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the Docker Engine API swagger YAML, with local caching.
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchEngineApi(force?: boolean): Promise<Buffer> {
|
|
18
|
+
return fetchWithCache(
|
|
19
|
+
{
|
|
20
|
+
url: ENGINE_API_URL,
|
|
21
|
+
cacheFile: getCachePath(),
|
|
22
|
+
},
|
|
23
|
+
force,
|
|
24
|
+
);
|
|
25
|
+
}
|