@intentius/chant-lexicon-docker 0.124.20 → 0.124.22
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.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/dockerfile-order.d.ts +13 -0
- package/dist/dockerfile-order.d.ts.map +1 -0
- package/dist/generated/index.d.ts +121 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/adapter.d.ts.map +1 -1
- package/dist/import/parser.d.ts +23 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/integrity.json +17 -8
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/add-url-checksum.d.ts +9 -0
- package/dist/lint/post-synth/add-url-checksum.d.ts.map +1 -0
- package/dist/lint/post-synth/apt-update-alone.d.ts +10 -0
- package/dist/lint/post-synth/apt-update-alone.d.ts.map +1 -0
- package/dist/lint/post-synth/cache-busting-copy.d.ts +11 -0
- package/dist/lint/post-synth/cache-busting-copy.d.ts.map +1 -0
- package/dist/lint/post-synth/copy-from-stage.d.ts +10 -0
- package/dist/lint/post-synth/copy-from-stage.d.ts.map +1 -0
- package/dist/lint/post-synth/docker-helpers.d.ts +35 -0
- package/dist/lint/post-synth/docker-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/no-root-user.d.ts +3 -2
- package/dist/lint/post-synth/no-root-user.d.ts.map +1 -1
- package/dist/lint/post-synth/relative-workdir.d.ts +10 -0
- package/dist/lint/post-synth/relative-workdir.d.ts.map +1 -0
- package/dist/lint/post-synth/repeated-cmd.d.ts +9 -0
- package/dist/lint/post-synth/repeated-cmd.d.ts.map +1 -0
- package/dist/lint/post-synth/root-after-user.d.ts +10 -0
- package/dist/lint/post-synth/root-after-user.d.ts.map +1 -0
- package/dist/lint/post-synth/unused-global-arg.d.ts +10 -0
- package/dist/lint/post-synth/unused-global-arg.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +1 -0
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lint/rules/per-kind-order.d.ts +15 -0
- package/dist/lint/rules/per-kind-order.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +6 -0
- package/dist/okf/index.md +10 -1
- package/dist/okf/rules/DKRD012.md +2 -2
- package/dist/okf/rules/DKRD013.md +11 -0
- package/dist/okf/rules/DKRD014.md +11 -0
- package/dist/okf/rules/DKRD015.md +11 -0
- package/dist/okf/rules/DKRD016.md +11 -0
- package/dist/okf/rules/DKRD017.md +11 -0
- package/dist/okf/rules/DKRD018.md +11 -0
- package/dist/okf/rules/DKRD019.md +11 -0
- package/dist/okf/rules/DKRD020.md +11 -0
- package/dist/okf/rules/DKRS002.md +15 -0
- package/dist/okf/types/Dockerfile.md +4 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/add-url-checksum.ts +38 -0
- package/dist/rules/apt-update-alone.ts +39 -0
- package/dist/rules/cache-busting-copy.ts +64 -0
- package/dist/rules/copy-from-stage.ts +54 -0
- package/dist/rules/docker-helpers.ts +80 -0
- package/dist/rules/no-root-user.ts +19 -17
- package/dist/rules/per-kind-order.ts +130 -0
- package/dist/rules/relative-workdir.ts +38 -0
- package/dist/rules/repeated-cmd.ts +35 -0
- package/dist/rules/root-after-user.ts +44 -0
- package/dist/rules/unused-global-arg.ts +38 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-docker-patterns.md +91 -22
- package/dist/skills/chant-docker.md +42 -16
- package/dist/spec/parse-engine.d.ts +9 -0
- package/dist/spec/parse-engine.d.ts.map +1 -1
- package/dist/types/index.d.ts +81 -0
- package/package.json +3 -3
- package/src/codegen/__snapshots__/okf.test.ts.snap +155 -4
- package/src/codegen/docs.ts +14 -0
- package/src/codegen/generate.ts +77 -19
- package/src/dockerfile-order.ts +20 -0
- package/src/docs-examples.test.ts +141 -0
- package/src/generated/index.d.ts +81 -0
- package/src/generated/index.ts +117 -0
- package/src/generated/lexicon-docker.json +6 -0
- package/src/import/adapter.ts +8 -2
- package/src/import/generator.ts +49 -33
- package/src/import/parser.ts +82 -13
- package/src/import/roundtrip.test.ts +105 -0
- package/src/lint/audit-catalog.ts +9 -1
- package/src/lint/audit-lineage.ts +14 -0
- package/src/lint/post-synth/add-url-checksum.ts +38 -0
- package/src/lint/post-synth/apt-update-alone.ts +39 -0
- package/src/lint/post-synth/cache-busting-copy.ts +64 -0
- package/src/lint/post-synth/copy-from-stage.ts +54 -0
- package/src/lint/post-synth/docker-helpers.ts +80 -0
- package/src/lint/post-synth/index.ts +16 -0
- package/src/lint/post-synth/no-root-user.ts +19 -17
- package/src/lint/post-synth/post-synth.test.ts +149 -0
- package/src/lint/post-synth/relative-workdir.ts +38 -0
- package/src/lint/post-synth/repeated-cmd.ts +35 -0
- package/src/lint/post-synth/root-after-user.ts +44 -0
- package/src/lint/post-synth/unused-global-arg.ts +38 -0
- package/src/lint/rules/index.ts +1 -0
- package/src/lint/rules/per-kind-order.ts +130 -0
- package/src/lint/rules/rules.test.ts +88 -0
- package/src/plugin.test.ts +4 -0
- package/src/plugin.ts +2 -1
- package/src/serializer.test.ts +112 -0
- package/src/serializer.ts +58 -11
- package/src/skills/chant-docker-patterns.md +91 -22
- package/src/skills/chant-docker.md +42 -16
- package/src/spec/parse-engine.ts +17 -5
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRS002: Per-kind Dockerfile fields written in a different order
|
|
3
|
+
*
|
|
4
|
+
* A Dockerfile stage without `steps` writes its per-kind fields (`run`,
|
|
5
|
+
* `copy`, `user`, ...) in a fixed order: ARG, ENV, WORKDIR, USER, RUN, COPY,
|
|
6
|
+
* ADD, EXPOSE, VOLUME, LABEL, ENTRYPOINT, CMD, HEALTHCHECK. When the fields
|
|
7
|
+
* are declared in another order (`copy` before `run`, `run` before `user`)
|
|
8
|
+
* the generated Dockerfile does not run them in the order they read in.
|
|
9
|
+
*
|
|
10
|
+
* The fix rewrites the fields as `steps` in the fixed order, so the
|
|
11
|
+
* generated Dockerfile stays the same, with a comment to reorder them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LintRule, LintDiagnostic, LintContext, LintFix } from "@intentius/chant/lint/rule";
|
|
15
|
+
import { ts } from "@intentius/chant/lint/typescript";
|
|
16
|
+
import { PER_KIND_ORDER, type PerKindField } from "../../dockerfile-order";
|
|
17
|
+
|
|
18
|
+
const PER_KIND = new Set<string>(PER_KIND_ORDER);
|
|
19
|
+
/** Per-kind fields that take one string; the rest take a list. */
|
|
20
|
+
const SINGLE = new Set<string>(["workdir", "user", "entrypoint", "cmd", "healthcheck"]);
|
|
21
|
+
|
|
22
|
+
const FIX_NOTE = "// Written by the DKRS002 fix in the fixed order the per-kind fields used, so the output is unchanged. Reorder the steps as the build needs.";
|
|
23
|
+
|
|
24
|
+
function propName(p: ts.ObjectLiteralElementLike): string | undefined {
|
|
25
|
+
if (!(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p))) return undefined;
|
|
26
|
+
const n = p.name;
|
|
27
|
+
if (ts.isIdentifier(n) || ts.isStringLiteral(n)) return n.text;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isDockerfileNew(node: ts.Node): node is ts.NewExpression {
|
|
32
|
+
if (!ts.isNewExpression(node)) return false;
|
|
33
|
+
const e = node.expression;
|
|
34
|
+
return (ts.isIdentifier(e) && e.text === "Dockerfile") || (ts.isPropertyAccessExpression(e) && e.name.text === "Dockerfile");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The stage-shaped object literals of one `new Dockerfile({...})`. */
|
|
38
|
+
function stageLiterals(arg: ts.ObjectLiteralExpression): ts.ObjectLiteralExpression[] {
|
|
39
|
+
const stagesProp = arg.properties.find((p) => propName(p) === "stages");
|
|
40
|
+
if (!stagesProp) return [arg];
|
|
41
|
+
// With stages, the top-level per-kind fields are not written at all.
|
|
42
|
+
if (!ts.isPropertyAssignment(stagesProp) || !ts.isArrayLiteralExpression(stagesProp.initializer)) return [];
|
|
43
|
+
return stagesProp.initializer.elements.filter(ts.isObjectLiteralExpression);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `{ key: value }` step texts for one per-kind field, or undefined when its value can't be split. */
|
|
47
|
+
function stepTexts(sf: ts.SourceFile, key: string, p: ts.ObjectLiteralElementLike): string[] | undefined {
|
|
48
|
+
if (!ts.isPropertyAssignment(p)) return undefined;
|
|
49
|
+
const init = p.initializer;
|
|
50
|
+
if (SINGLE.has(key)) return [`{ ${key}: ${init.getText(sf)} }`];
|
|
51
|
+
if (!ts.isArrayLiteralExpression(init)) return undefined;
|
|
52
|
+
const out: string[] = [];
|
|
53
|
+
for (const el of init.elements) {
|
|
54
|
+
if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return undefined;
|
|
55
|
+
out.push(`{ ${key}: ${el.getText(sf)} }`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildFix(sf: ts.SourceFile, obj: ts.ObjectLiteralExpression, perKind: ts.ObjectLiteralElementLike[]): LintFix | undefined {
|
|
61
|
+
const byKey = new Map<string, ts.ObjectLiteralElementLike>();
|
|
62
|
+
for (const p of perKind) byKey.set(propName(p)!, p);
|
|
63
|
+
const steps: string[] = [];
|
|
64
|
+
for (const key of PER_KIND_ORDER) {
|
|
65
|
+
const p = byKey.get(key);
|
|
66
|
+
if (!p) continue;
|
|
67
|
+
const texts = stepTexts(sf, key, p);
|
|
68
|
+
if (!texts) return undefined;
|
|
69
|
+
steps.push(...texts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const first = perKind[0]!;
|
|
73
|
+
const last = perKind[perKind.length - 1]!;
|
|
74
|
+
const start = first.getStart(sf);
|
|
75
|
+
const end = last.getEnd();
|
|
76
|
+
const lineStart = sf.text.lastIndexOf("\n", start - 1) + 1;
|
|
77
|
+
const indent = /^[ \t]*/.exec(sf.text.slice(lineStart, start))![0];
|
|
78
|
+
// Other props between the first and last per-kind field (rare) are kept, ahead of steps.
|
|
79
|
+
const kept = obj.properties.filter((p) => p.getStart(sf) > start && p.getEnd() < end && !perKind.includes(p)).map((p) => p.getText(sf));
|
|
80
|
+
const stepsText = `${FIX_NOTE}\n${indent}steps: [\n${steps.map((s) => `${indent} ${s},`).join("\n")}\n${indent}]`;
|
|
81
|
+
return { range: [start, end], replacement: [...kept, stepsText].join(`,\n${indent}`), kind: "replace" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function check(context: LintContext): LintDiagnostic[] {
|
|
85
|
+
const { sourceFile: sf } = context;
|
|
86
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
87
|
+
|
|
88
|
+
const visit = (node: ts.Node): void => {
|
|
89
|
+
if (isDockerfileNew(node)) {
|
|
90
|
+
const arg = node.arguments?.[0];
|
|
91
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
92
|
+
for (const obj of stageLiterals(arg)) {
|
|
93
|
+
if (obj.properties.some((p) => propName(p) === "steps")) continue;
|
|
94
|
+
const perKind = obj.properties.filter((p) => PER_KIND.has(propName(p) ?? ""));
|
|
95
|
+
const declared = perKind.map((p) => propName(p)!);
|
|
96
|
+
const fixed = [...declared].sort((a, b) => PER_KIND_ORDER.indexOf(a as PerKindField) - PER_KIND_ORDER.indexOf(b as PerKindField));
|
|
97
|
+
if (declared.join() === fixed.join()) continue;
|
|
98
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(perKind[0]!.getStart(sf));
|
|
99
|
+
const fix = buildFix(sf, obj, perKind);
|
|
100
|
+
diagnostics.push({
|
|
101
|
+
file: sf.fileName,
|
|
102
|
+
line: line + 1,
|
|
103
|
+
column: character + 1,
|
|
104
|
+
ruleId: "DKRS002",
|
|
105
|
+
severity: "warning",
|
|
106
|
+
message:
|
|
107
|
+
`Dockerfile fields declared as ${declared.join(", ")} are written in the fixed order ${fixed.join(", ")}. ` +
|
|
108
|
+
`Use steps to write the instructions in the order the build needs${fix ? " (chant lint --fix rewrites them as steps)" : ""}.`,
|
|
109
|
+
...(fix ? { fix } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ts.forEachChild(node, visit);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
visit(sf);
|
|
118
|
+
return diagnostics;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const perKindOrderRule: LintRule = {
|
|
122
|
+
id: "DKRS002",
|
|
123
|
+
severity: "warning",
|
|
124
|
+
category: "correctness",
|
|
125
|
+
description: "Dockerfile per-kind fields are written in a fixed order that differs from the declared order — use steps",
|
|
126
|
+
check,
|
|
127
|
+
fix(context: LintContext): LintFix[] {
|
|
128
|
+
return check(context).flatMap((d) => (d.fix ? [d.fix] : []));
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { noLatestTagRule } from "./no-latest-tag";
|
|
3
|
+
import { perKindOrderRule } from "./per-kind-order";
|
|
4
|
+
import { dockerSerializer } from "../../serializer";
|
|
5
|
+
import { Dockerfile } from "../../generated/index";
|
|
6
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
3
7
|
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
4
8
|
import * as ts from "typescript";
|
|
5
9
|
|
|
@@ -80,3 +84,87 @@ describe("DKRS001: no-latest-tag", () => {
|
|
|
80
84
|
expect(diags).toHaveLength(0);
|
|
81
85
|
});
|
|
82
86
|
});
|
|
87
|
+
|
|
88
|
+
// ── DKRS002: per-kind fields in a different order ─────────────────
|
|
89
|
+
|
|
90
|
+
/** Apply a rule's fixes the way `chant lint --fix` does: last range first. */
|
|
91
|
+
function applyFixes(code: string, fixes: Array<{ range: [number, number]; replacement: string }>): string {
|
|
92
|
+
for (const f of [...fixes].sort((a, b) => b.range[0] - a.range[0])) {
|
|
93
|
+
code = code.slice(0, f.range[0]) + f.replacement + code.slice(f.range[1]);
|
|
94
|
+
}
|
|
95
|
+
return code;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Run a module's `export const x = new Dockerfile(...)` and serialize what it declares. */
|
|
99
|
+
function build(code: string): string {
|
|
100
|
+
const body = code.replace(/^\s*import .*$/m, "").replace(/export const (\w+) =/g, "out.$1 =");
|
|
101
|
+
const out: Record<string, Declarable> = {};
|
|
102
|
+
new Function("Dockerfile", "out", body)(Dockerfile, out);
|
|
103
|
+
const result = dockerSerializer.serialize(new Map(Object.entries(out))) as { files: Record<string, string> };
|
|
104
|
+
return Object.values(result.files).join("---\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("DKRS002: per-kind-order", () => {
|
|
108
|
+
const reordered = `import { Dockerfile } from "@intentius/chant-lexicon-docker";
|
|
109
|
+
export const app = new Dockerfile({
|
|
110
|
+
stages: [
|
|
111
|
+
{
|
|
112
|
+
from: "node:20-alpine",
|
|
113
|
+
as: "build",
|
|
114
|
+
workdir: "/app",
|
|
115
|
+
copy: ["package*.json ./", ". ."],
|
|
116
|
+
run: ["npm ci", "npm run build"],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
from: "node:20-alpine",
|
|
120
|
+
workdir: "/app",
|
|
121
|
+
user: "node",
|
|
122
|
+
cmd: "node dist/index.js",
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
test("flags a stage whose fields read in a different order than they are written", () => {
|
|
129
|
+
const diags = perKindOrderRule.check(makeContext(reordered));
|
|
130
|
+
expect(diags).toHaveLength(1);
|
|
131
|
+
expect(diags[0].ruleId).toBe("DKRS002");
|
|
132
|
+
expect(diags[0].line).toBe(7);
|
|
133
|
+
expect(diags[0].message).toContain("declared as workdir, copy, run are written in the fixed order workdir, run, copy");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("flags USER declared after RUN, on a single-stage Dockerfile", () => {
|
|
137
|
+
const diags = perKindOrderRule.check(makeContext(`new Dockerfile({ from: "debian:12", run: ["useradd app"], user: "app" });`));
|
|
138
|
+
expect(diags).toHaveLength(1);
|
|
139
|
+
expect(diags[0].message).toContain("run, user");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("does not flag fields already in the fixed order, steps, or other objects", () => {
|
|
143
|
+
expect(perKindOrderRule.check(makeContext(`new Dockerfile({ from: "a:1", workdir: "/app", user: "node", run: ["x"], copy: [". ."], cmd: "y" });`))).toHaveLength(0);
|
|
144
|
+
expect(perKindOrderRule.check(makeContext(`new Dockerfile({ from: "a:1", steps: [{ copy: ". ." }, { run: "x" }] });`))).toHaveLength(0);
|
|
145
|
+
expect(perKindOrderRule.check(makeContext(`const o = { copy: ["a"], run: ["b"] };`))).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("the fix rewrites the fields as steps and the output does not change", () => {
|
|
149
|
+
const fixes = perKindOrderRule.fix!(makeContext(reordered));
|
|
150
|
+
expect(fixes).toHaveLength(1);
|
|
151
|
+
const fixed = applyFixes(reordered, fixes);
|
|
152
|
+
expect(fixed).toContain(` // Written by the DKRS002 fix`);
|
|
153
|
+
expect(fixed).toContain(` steps: [
|
|
154
|
+
{ workdir: "/app" },
|
|
155
|
+
{ run: "npm ci" },
|
|
156
|
+
{ run: "npm run build" },
|
|
157
|
+
{ copy: "package*.json ./" },
|
|
158
|
+
{ copy: ". ." },
|
|
159
|
+
],
|
|
160
|
+
},`);
|
|
161
|
+
expect(build(fixed)).toBe(build(reordered));
|
|
162
|
+
expect(perKindOrderRule.check(makeContext(fixed))).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("no fix when a list value can't be split into steps", () => {
|
|
166
|
+
const diags = perKindOrderRule.check(makeContext(`new Dockerfile({ from: "a:1", copy: files, run: ["x"] });`));
|
|
167
|
+
expect(diags).toHaveLength(1);
|
|
168
|
+
expect(diags[0].fix).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/plugin.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ describe("dockerPlugin", () => {
|
|
|
17
17
|
|
|
18
18
|
const ruleIds = rules.map((r) => r.id);
|
|
19
19
|
expect(ruleIds).toContain("DKRS001");
|
|
20
|
+
expect(ruleIds).toContain("DKRS002");
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
test("provides post-synth checks", () => {
|
|
@@ -30,6 +31,9 @@ describe("dockerPlugin", () => {
|
|
|
30
31
|
expect(checkIds).toContain("DKRD010");
|
|
31
32
|
expect(checkIds).toContain("DKRD011");
|
|
32
33
|
expect(checkIds).toContain("DKRD012");
|
|
34
|
+
for (const id of ["DKRD013", "DKRD014", "DKRD015", "DKRD016", "DKRD017", "DKRD018", "DKRD019", "DKRD020"]) {
|
|
35
|
+
expect(checkIds).toContain(id);
|
|
36
|
+
}
|
|
33
37
|
});
|
|
34
38
|
|
|
35
39
|
test("provides intrinsics", () => {
|
package/src/plugin.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { join, dirname } from "path";
|
|
|
16
16
|
import { fileURLToPath } from "url";
|
|
17
17
|
import { dockerSerializer } from "./serializer";
|
|
18
18
|
import { noLatestTagRule } from "./lint/rules/no-latest-tag";
|
|
19
|
+
import { perKindOrderRule } from "./lint/rules/per-kind-order";
|
|
19
20
|
import { dockerCompletions } from "./lsp/completions";
|
|
20
21
|
import { dockerHover } from "./lsp/hover";
|
|
21
22
|
import { DockerTemplateParser, DockerTemplateGenerator } from "./import/adapter";
|
|
@@ -33,7 +34,7 @@ export const dockerPlugin: LexiconPlugin = {
|
|
|
33
34
|
serializer: dockerSerializer,
|
|
34
35
|
|
|
35
36
|
lintRules(): LintRule[] {
|
|
36
|
-
return [noLatestTagRule];
|
|
37
|
+
return [noLatestTagRule, perKindOrderRule];
|
|
37
38
|
},
|
|
38
39
|
|
|
39
40
|
postSynthChecks() {
|
package/src/serializer.test.ts
CHANGED
|
@@ -306,6 +306,118 @@ describe("dockerSerializer.serialize — multi-stage Dockerfile", () => {
|
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
// ── Dockerfile steps ──────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
describe("dockerSerializer.serialize — Dockerfile steps", () => {
|
|
312
|
+
const dockerfile = (props: Record<string, unknown>): string => {
|
|
313
|
+
const result = dockerSerializer.serialize(new Map<string, Declarable>([["app", new MockDockerfile(props)]]));
|
|
314
|
+
return (result as { files: Record<string, string> }).files["Dockerfile.app"]!;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
test("steps are written in the order given, interleaving COPY and RUN", () => {
|
|
318
|
+
expect(dockerfile({
|
|
319
|
+
from: "node:20-alpine",
|
|
320
|
+
steps: [
|
|
321
|
+
{ workdir: "/app" },
|
|
322
|
+
{ copy: "package*.json ./" },
|
|
323
|
+
{ run: "npm ci" },
|
|
324
|
+
{ copy: ". ." },
|
|
325
|
+
{ run: "npm run build" },
|
|
326
|
+
{ user: "node" },
|
|
327
|
+
{ cmd: ["node", "dist/index.js"] },
|
|
328
|
+
],
|
|
329
|
+
})).toBe([
|
|
330
|
+
"FROM node:20-alpine",
|
|
331
|
+
"WORKDIR /app",
|
|
332
|
+
"COPY package*.json ./",
|
|
333
|
+
"RUN npm ci",
|
|
334
|
+
"COPY . .",
|
|
335
|
+
"RUN npm run build",
|
|
336
|
+
"USER node",
|
|
337
|
+
'CMD ["node","dist/index.js"]',
|
|
338
|
+
"",
|
|
339
|
+
].join("\n"));
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("every instruction is available as a step, and ARG before FROM comes from globalArgs", () => {
|
|
343
|
+
expect(dockerfile({
|
|
344
|
+
globalArgs: ["NODE_VERSION=20"],
|
|
345
|
+
stages: [{
|
|
346
|
+
from: "node:${NODE_VERSION}-alpine",
|
|
347
|
+
as: "base",
|
|
348
|
+
steps: [
|
|
349
|
+
{ shell: ["/bin/sh", "-eo", "pipefail", "-c"] },
|
|
350
|
+
{ arg: "APP_ENV=production" },
|
|
351
|
+
{ env: "NODE_ENV=$APP_ENV" },
|
|
352
|
+
{ label: 'org.opencontainers.image.title="app"' },
|
|
353
|
+
{ workdir: "/app" },
|
|
354
|
+
{ add: "https://example.com/a.tgz /tmp/" },
|
|
355
|
+
{ volume: "/data" },
|
|
356
|
+
{ expose: 8080 },
|
|
357
|
+
{ stopsignal: "SIGTERM" },
|
|
358
|
+
{ healthcheck: "CMD wget -qO- http://localhost:8080/health || exit 1" },
|
|
359
|
+
{ onbuild: "COPY . /app" },
|
|
360
|
+
{ entrypoint: ["/sbin/tini", "--"] },
|
|
361
|
+
{ cmd: "node index.js" },
|
|
362
|
+
],
|
|
363
|
+
}],
|
|
364
|
+
})).toBe([
|
|
365
|
+
"ARG NODE_VERSION=20",
|
|
366
|
+
"FROM node:${NODE_VERSION}-alpine AS base",
|
|
367
|
+
'SHELL ["/bin/sh","-eo","pipefail","-c"]',
|
|
368
|
+
"ARG APP_ENV=production",
|
|
369
|
+
"ENV NODE_ENV=$APP_ENV",
|
|
370
|
+
'LABEL org.opencontainers.image.title="app"',
|
|
371
|
+
"WORKDIR /app",
|
|
372
|
+
"ADD https://example.com/a.tgz /tmp/",
|
|
373
|
+
"VOLUME /data",
|
|
374
|
+
"EXPOSE 8080",
|
|
375
|
+
"STOPSIGNAL SIGTERM",
|
|
376
|
+
"HEALTHCHECK CMD wget -qO- http://localhost:8080/health || exit 1",
|
|
377
|
+
"ONBUILD COPY . /app",
|
|
378
|
+
'ENTRYPOINT ["/sbin/tini","--"]',
|
|
379
|
+
"CMD node index.js",
|
|
380
|
+
"",
|
|
381
|
+
].join("\n"));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("per-kind fields keep their fixed order, byte for byte", () => {
|
|
385
|
+
expect(dockerfile({
|
|
386
|
+
from: "node:20-alpine",
|
|
387
|
+
cmd: "node index.js",
|
|
388
|
+
copy: [". ."],
|
|
389
|
+
run: ["npm ci"],
|
|
390
|
+
user: "node",
|
|
391
|
+
workdir: "/app",
|
|
392
|
+
})).toBe("FROM node:20-alpine\nWORKDIR /app\nUSER node\nRUN npm ci\nCOPY . .\nCMD node index.js\n");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a stage that mixes steps with per-kind fields is an error", () => {
|
|
396
|
+
expect(() => dockerfile({ from: "alpine:3.20", run: ["true"], steps: [{ user: "nobody" }] }))
|
|
397
|
+
.toThrow(/Dockerfile\.app: steps can't be combined with the per-kind fields \(run\)/);
|
|
398
|
+
expect(() => dockerfile({ stages: [{ from: "alpine:3.20", as: "b", copy: ["a b"], steps: [] }] }))
|
|
399
|
+
.toThrow(/stage "b": steps can't be combined/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("a step names exactly one known instruction", () => {
|
|
403
|
+
expect(() => dockerfile({ from: "alpine:3.20", steps: [{ run: "a", copy: "b" }] })).toThrow(/exactly one instruction, found run, copy/);
|
|
404
|
+
expect(() => dockerfile({ from: "alpine:3.20", steps: [{}] })).toThrow(/found none/);
|
|
405
|
+
expect(() => dockerfile({ from: "alpine:3.20", steps: [{ from: "x" }] })).toThrow(/"from" is not a Dockerfile instruction a step can name/);
|
|
406
|
+
expect(() => dockerfile({ from: "alpine:3.20", steps: [{ copy: ["a", "b"] }] })).toThrow(/COPY takes a string/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("steps on the Dockerfile itself with stages is an error", () => {
|
|
410
|
+
expect(() => dockerfile({ stages: [{ from: "alpine:3.20" }], steps: [{ run: "true" }] })).toThrow(/steps belongs on each stage/);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("stages and step lists are plain values that can be shared and spread", () => {
|
|
414
|
+
const install = [{ copy: "package*.json ./" }, { run: "npm ci" }];
|
|
415
|
+
const base = { from: "node:20-alpine", as: "deps", steps: [{ workdir: "/app" }, ...install] };
|
|
416
|
+
expect(dockerfile({ stages: [base, { from: "node:20-alpine", steps: [{ copy: "--from=deps /app/node_modules ./node_modules" }] }] }))
|
|
417
|
+
.toBe("FROM node:20-alpine AS deps\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nFROM node:20-alpine\nCOPY --from=deps /app/node_modules ./node_modules\n");
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
309
421
|
// ── Name generation ───────────────────────────────────────────────
|
|
310
422
|
|
|
311
423
|
describe("dockerSerializer.serialize — name gen", () => {
|
package/src/serializer.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
|
17
17
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
18
18
|
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
19
19
|
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
20
|
+
import { EXEC_FORM_STEPS, PER_KIND_ORDER, STEP_INSTRUCTIONS } from "./dockerfile-order";
|
|
20
21
|
|
|
21
22
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -265,36 +266,55 @@ function emitComposeDocument(doc: Record<string, unknown>): string {
|
|
|
265
266
|
|
|
266
267
|
// ── Dockerfile serialization ──────────────────────────────────────
|
|
267
268
|
|
|
268
|
-
function serializeDockerfile(entity: Declarable): string {
|
|
269
|
+
function serializeDockerfile(name: string, entity: Declarable): string {
|
|
269
270
|
const props = getProps(entity);
|
|
270
271
|
const lines: string[] = [];
|
|
271
272
|
|
|
273
|
+
// ARGs before the first FROM: global build args a FROM line can use.
|
|
274
|
+
if (props.globalArgs !== undefined && props.globalArgs !== null) {
|
|
275
|
+
if (!Array.isArray(props.globalArgs)) {
|
|
276
|
+
throw new Error(`Dockerfile.${name}: globalArgs must be a list of ARG values, like ["NODE_VERSION=20"]`);
|
|
277
|
+
}
|
|
278
|
+
for (const arg of props.globalArgs) lines.push(`ARG ${arg}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
272
281
|
// Multi-stage build via stages[]
|
|
273
282
|
if (Array.isArray(props.stages) && props.stages.length > 0) {
|
|
274
|
-
|
|
275
|
-
|
|
283
|
+
if (props.steps !== undefined) {
|
|
284
|
+
throw new Error(`Dockerfile.${name}: steps belongs on each stage when stages is set, not on the Dockerfile`);
|
|
276
285
|
}
|
|
286
|
+
(props.stages as Array<Record<string, unknown>>).forEach((stage, i) => {
|
|
287
|
+
emitStage(stage, lines, `Dockerfile.${name} stage ${stage.as ? `"${stage.as}"` : i + 1}`);
|
|
288
|
+
});
|
|
277
289
|
return lines.join("\n") + "\n";
|
|
278
290
|
}
|
|
279
291
|
|
|
280
292
|
// Single-stage build
|
|
281
|
-
emitStage(props, lines);
|
|
293
|
+
emitStage(props, lines, `Dockerfile.${name}`);
|
|
282
294
|
return lines.join("\n") + "\n";
|
|
283
295
|
}
|
|
284
296
|
|
|
285
|
-
function emitStage(stage: Record<string, unknown>, lines: string[]): void {
|
|
297
|
+
function emitStage(stage: Record<string, unknown>, lines: string[], where: string): void {
|
|
286
298
|
// FROM must be first
|
|
287
299
|
if (stage.from) {
|
|
288
300
|
const as = stage.as ? ` AS ${stage.as}` : "";
|
|
289
301
|
lines.push(`FROM ${stage.from}${as}`);
|
|
290
302
|
}
|
|
291
303
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
304
|
+
if (stage.steps !== undefined && stage.steps !== null) {
|
|
305
|
+
const perKind = PER_KIND_ORDER.filter((k) => stage[k] !== undefined && stage[k] !== null);
|
|
306
|
+
if (perKind.length > 0) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`${where}: steps can't be combined with the per-kind fields (${perKind.join(", ")}). Move them into steps, in the order they should run.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (!Array.isArray(stage.steps)) throw new Error(`${where}: steps must be a list`);
|
|
312
|
+
stage.steps.forEach((step, i) => lines.push(emitStep(step, `${where} step ${i + 1}`)));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
296
315
|
|
|
297
|
-
|
|
316
|
+
// No steps: the per-kind fields, in the fixed order they have always had.
|
|
317
|
+
for (const instr of PER_KIND_ORDER) {
|
|
298
318
|
const value = stage[instr];
|
|
299
319
|
if (value === undefined || value === null) continue;
|
|
300
320
|
|
|
@@ -310,6 +330,33 @@ function emitStage(stage: Record<string, unknown>, lines: string[]): void {
|
|
|
310
330
|
}
|
|
311
331
|
}
|
|
312
332
|
|
|
333
|
+
/** One step, `{ run: "npm ci" }`, as its Dockerfile line, `RUN npm ci`. */
|
|
334
|
+
function emitStep(step: unknown, where: string): string {
|
|
335
|
+
if (typeof step !== "object" || step === null || Array.isArray(step)) {
|
|
336
|
+
throw new Error(`${where}: a step is an object naming one instruction, like { run: "npm ci" }`);
|
|
337
|
+
}
|
|
338
|
+
const keys = Object.keys(step).filter((k) => (step as Record<string, unknown>)[k] !== undefined);
|
|
339
|
+
if (keys.length !== 1) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`${where}: a step names exactly one instruction, found ${keys.length === 0 ? "none" : keys.join(", ")}. Split it into one step per instruction.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const key = keys[0]!;
|
|
345
|
+
if (!STEP_INSTRUCTIONS.has(key)) {
|
|
346
|
+
throw new Error(`${where}: "${key}" is not a Dockerfile instruction a step can name (FROM is the stage's from field)`);
|
|
347
|
+
}
|
|
348
|
+
const value = (step as Record<string, unknown>)[key];
|
|
349
|
+
const keyword = key.toUpperCase();
|
|
350
|
+
if (Array.isArray(value)) {
|
|
351
|
+
if (!EXEC_FORM_STEPS.has(key)) {
|
|
352
|
+
throw new Error(`${where}: ${keyword} takes a string; only RUN, CMD, ENTRYPOINT and SHELL take an exec-form array`);
|
|
353
|
+
}
|
|
354
|
+
return `${keyword} ${JSON.stringify(value.map(String))}`;
|
|
355
|
+
}
|
|
356
|
+
if (typeof value === "string" || typeof value === "number") return `${keyword} ${value}`;
|
|
357
|
+
throw new Error(`${where}: ${keyword} needs a string value`);
|
|
358
|
+
}
|
|
359
|
+
|
|
313
360
|
// ── Serializer ────────────────────────────────────────────────────
|
|
314
361
|
|
|
315
362
|
export const dockerSerializer: Serializer = {
|
|
@@ -376,7 +423,7 @@ export const dockerSerializer: Serializer = {
|
|
|
376
423
|
|
|
377
424
|
const files: Record<string, string> = {};
|
|
378
425
|
for (const [name, entity] of dockerfiles) {
|
|
379
|
-
files[`Dockerfile.${name}`] = serializeDockerfile(entity);
|
|
426
|
+
files[`Dockerfile.${name}`] = serializeDockerfile(name, entity);
|
|
380
427
|
}
|
|
381
428
|
|
|
382
429
|
return { primary: composeYaml, files };
|
|
@@ -53,55 +53,124 @@ export const nginx = new Service({
|
|
|
53
53
|
|
|
54
54
|
## Multi-stage Node.js Dockerfile
|
|
55
55
|
|
|
56
|
+
Manifests before source so the install layer is cached; the runtime stage takes only what it needs and runs as `node`.
|
|
57
|
+
|
|
56
58
|
```typescript
|
|
57
59
|
export const nodeApp = new Dockerfile({
|
|
58
60
|
stages: [
|
|
59
61
|
{
|
|
60
62
|
from: "node:20-alpine",
|
|
61
63
|
as: "deps",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
steps: [
|
|
65
|
+
{ workdir: "/app" },
|
|
66
|
+
{ copy: "package.json package-lock.json ./" },
|
|
67
|
+
{ run: "npm ci --omit=dev" },
|
|
68
|
+
],
|
|
65
69
|
},
|
|
66
70
|
{
|
|
67
71
|
from: "node:20-alpine",
|
|
68
72
|
as: "build",
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
steps: [
|
|
74
|
+
{ workdir: "/app" },
|
|
75
|
+
{ copy: "package.json package-lock.json ./" },
|
|
76
|
+
{ run: "npm ci" },
|
|
77
|
+
{ copy: ". ." },
|
|
78
|
+
{ run: "npm run build" },
|
|
79
|
+
],
|
|
72
80
|
},
|
|
73
81
|
{
|
|
74
82
|
from: "node:20-alpine",
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"--from=deps /app/node_modules ./node_modules",
|
|
78
|
-
"--from=build /app/dist ./dist",
|
|
83
|
+
steps: [
|
|
84
|
+
{ workdir: "/app" },
|
|
85
|
+
{ copy: "--from=deps /app/node_modules ./node_modules" },
|
|
86
|
+
{ copy: "--from=build /app/dist ./dist" },
|
|
87
|
+
{ user: "node" },
|
|
88
|
+
{ expose: 3000 },
|
|
89
|
+
{ cmd: ["node", "dist/index.js"] },
|
|
79
90
|
],
|
|
80
|
-
user: "node",
|
|
81
|
-
expose: ["3000"],
|
|
82
|
-
cmd: `["node", "dist/index.js"]`,
|
|
83
91
|
},
|
|
84
92
|
],
|
|
85
93
|
});
|
|
86
94
|
```
|
|
87
95
|
|
|
88
|
-
## Python service
|
|
96
|
+
## Python service
|
|
89
97
|
|
|
90
98
|
```typescript
|
|
91
99
|
export const pythonApp = new Dockerfile({
|
|
92
100
|
from: "python:3.12-slim",
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
steps: [
|
|
102
|
+
{ workdir: "/app" },
|
|
103
|
+
{ env: "PYTHONUNBUFFERED=1" },
|
|
104
|
+
{ env: "PYTHONDONTWRITEBYTECODE=1" },
|
|
105
|
+
{ copy: "requirements.txt ." },
|
|
106
|
+
{ run: "pip install --no-cache-dir -r requirements.txt" },
|
|
107
|
+
{ copy: ". ." },
|
|
108
|
+
{ user: "1000:1000" },
|
|
109
|
+
{ cmd: ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] },
|
|
98
110
|
],
|
|
99
|
-
copy: [". ."],
|
|
100
|
-
user: "1000:1000",
|
|
101
|
-
cmd: `["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]`,
|
|
102
111
|
});
|
|
103
112
|
```
|
|
104
113
|
|
|
114
|
+
## Root setup, then USER
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
export const tool = new Dockerfile({
|
|
118
|
+
from: "debian:12-slim",
|
|
119
|
+
steps: [
|
|
120
|
+
{ run: "apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*" },
|
|
121
|
+
{ run: "useradd --system --create-home app" },
|
|
122
|
+
{ workdir: "/home/app" },
|
|
123
|
+
{ copy: "--chown=app:app bin/ ./bin/" },
|
|
124
|
+
{ user: "app" },
|
|
125
|
+
{ entrypoint: ["./bin/tool"] },
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Sharing stages and steps across modules
|
|
131
|
+
|
|
132
|
+
Export `const` stages and step lists from one module and import them into each Dockerfile. This is the chant answer to `INCLUDE`.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// src/shared/node.ts
|
|
136
|
+
import type { DockerfileStage, DockerfileStep } from "@intentius/chant-lexicon-docker";
|
|
137
|
+
|
|
138
|
+
export const nodeBuild: DockerfileStage = {
|
|
139
|
+
from: "node:20-alpine",
|
|
140
|
+
as: "build",
|
|
141
|
+
steps: [
|
|
142
|
+
{ workdir: "/app" },
|
|
143
|
+
{ copy: "package.json package-lock.json ./" },
|
|
144
|
+
{ run: "npm ci" },
|
|
145
|
+
{ copy: ". ." },
|
|
146
|
+
{ run: "npm run build" },
|
|
147
|
+
{ run: "npm prune --omit=dev" },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const nodeRuntime: DockerfileStep[] = [
|
|
152
|
+
{ workdir: "/app" },
|
|
153
|
+
{ copy: "--from=build /app/node_modules ./node_modules" },
|
|
154
|
+
{ copy: "--from=build /app/dist ./dist" },
|
|
155
|
+
{ user: "node" },
|
|
156
|
+
];
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// src/web.ts
|
|
161
|
+
import { Dockerfile, type DockerfileStage } from "@intentius/chant-lexicon-docker";
|
|
162
|
+
import { nodeBuild, nodeRuntime } from "./shared/node";
|
|
163
|
+
|
|
164
|
+
const runtime: DockerfileStage = {
|
|
165
|
+
from: "node:20-alpine",
|
|
166
|
+
steps: [...nodeRuntime, { expose: 3000 }, { cmd: ["node", "dist/server.js"] }],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const web = new Dockerfile({ stages: [nodeBuild, runtime] });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
For a shared Dockerfile that takes parameters, write a `Composite((props) => ({ image: new Dockerfile({...}) }))`; spreads are allowed inside it. The `dockerfile-sharing` example in the lexicon has all three patterns.
|
|
173
|
+
|
|
105
174
|
## Network isolation
|
|
106
175
|
|
|
107
176
|
```typescript
|