@intentius/chant-lexicon-docker 0.124.21 → 0.124.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/generate.d.ts.map +1 -1
  3. package/dist/dockerfile-order.d.ts +13 -0
  4. package/dist/dockerfile-order.d.ts.map +1 -0
  5. package/dist/generated/index.d.ts +703 -6
  6. package/dist/generated/index.d.ts.map +1 -1
  7. package/dist/import/adapter.d.ts.map +1 -1
  8. package/dist/import/parser.d.ts +23 -0
  9. package/dist/import/parser.d.ts.map +1 -1
  10. package/dist/integrity.json +17 -8
  11. package/dist/lint/audit-catalog.d.ts.map +1 -1
  12. package/dist/lint/audit-lineage.d.ts.map +1 -1
  13. package/dist/lint/post-synth/add-url-checksum.d.ts +9 -0
  14. package/dist/lint/post-synth/add-url-checksum.d.ts.map +1 -0
  15. package/dist/lint/post-synth/apt-update-alone.d.ts +10 -0
  16. package/dist/lint/post-synth/apt-update-alone.d.ts.map +1 -0
  17. package/dist/lint/post-synth/cache-busting-copy.d.ts +11 -0
  18. package/dist/lint/post-synth/cache-busting-copy.d.ts.map +1 -0
  19. package/dist/lint/post-synth/copy-from-stage.d.ts +10 -0
  20. package/dist/lint/post-synth/copy-from-stage.d.ts.map +1 -0
  21. package/dist/lint/post-synth/docker-helpers.d.ts +35 -0
  22. package/dist/lint/post-synth/docker-helpers.d.ts.map +1 -1
  23. package/dist/lint/post-synth/index.d.ts.map +1 -1
  24. package/dist/lint/post-synth/no-root-user.d.ts +3 -2
  25. package/dist/lint/post-synth/no-root-user.d.ts.map +1 -1
  26. package/dist/lint/post-synth/relative-workdir.d.ts +10 -0
  27. package/dist/lint/post-synth/relative-workdir.d.ts.map +1 -0
  28. package/dist/lint/post-synth/repeated-cmd.d.ts +9 -0
  29. package/dist/lint/post-synth/repeated-cmd.d.ts.map +1 -0
  30. package/dist/lint/post-synth/root-after-user.d.ts +10 -0
  31. package/dist/lint/post-synth/root-after-user.d.ts.map +1 -0
  32. package/dist/lint/post-synth/unused-global-arg.d.ts +10 -0
  33. package/dist/lint/post-synth/unused-global-arg.d.ts.map +1 -0
  34. package/dist/lint/rules/index.d.ts +1 -0
  35. package/dist/lint/rules/index.d.ts.map +1 -1
  36. package/dist/lint/rules/per-kind-order.d.ts +15 -0
  37. package/dist/lint/rules/per-kind-order.d.ts.map +1 -0
  38. package/dist/manifest.json +1 -1
  39. package/dist/meta.json +444 -86
  40. package/dist/okf/index.md +10 -1
  41. package/dist/okf/rules/DKRD012.md +2 -2
  42. package/dist/okf/rules/DKRD013.md +11 -0
  43. package/dist/okf/rules/DKRD014.md +11 -0
  44. package/dist/okf/rules/DKRD015.md +11 -0
  45. package/dist/okf/rules/DKRD016.md +11 -0
  46. package/dist/okf/rules/DKRD017.md +11 -0
  47. package/dist/okf/rules/DKRD018.md +11 -0
  48. package/dist/okf/rules/DKRD019.md +11 -0
  49. package/dist/okf/rules/DKRD020.md +11 -0
  50. package/dist/okf/rules/DKRS002.md +15 -0
  51. package/dist/okf/types/DockerConfig.md +7 -5
  52. package/dist/okf/types/DockerSecret.md +8 -4
  53. package/dist/okf/types/Dockerfile.md +4 -1
  54. package/dist/okf/types/Network.md +10 -6
  55. package/dist/okf/types/Service.md +93 -15
  56. package/dist/okf/types/Volume.md +5 -5
  57. package/dist/plugin.d.ts.map +1 -1
  58. package/dist/rules/add-url-checksum.ts +38 -0
  59. package/dist/rules/apt-update-alone.ts +39 -0
  60. package/dist/rules/cache-busting-copy.ts +65 -0
  61. package/dist/rules/copy-from-stage.ts +54 -0
  62. package/dist/rules/docker-helpers.ts +80 -0
  63. package/dist/rules/no-root-user.ts +19 -17
  64. package/dist/rules/per-kind-order.ts +130 -0
  65. package/dist/rules/relative-workdir.ts +38 -0
  66. package/dist/rules/repeated-cmd.ts +35 -0
  67. package/dist/rules/root-after-user.ts +44 -0
  68. package/dist/rules/unused-global-arg.ts +38 -0
  69. package/dist/serializer.d.ts.map +1 -1
  70. package/dist/skills/chant-docker-patterns.md +93 -24
  71. package/dist/skills/chant-docker.md +43 -16
  72. package/dist/spec/parse-compose.d.ts +29 -2
  73. package/dist/spec/parse-compose.d.ts.map +1 -1
  74. package/dist/spec/parse-engine.d.ts +9 -0
  75. package/dist/spec/parse-engine.d.ts.map +1 -1
  76. package/dist/types/index.d.ts +344 -41
  77. package/package.json +3 -3
  78. package/src/codegen/__snapshots__/okf.test.ts.snap +278 -39
  79. package/src/codegen/docs.ts +14 -0
  80. package/src/codegen/generate.ts +128 -31
  81. package/src/codegen/okf.test.ts +1 -1
  82. package/src/dockerfile-order.ts +20 -0
  83. package/src/docs-examples.test.ts +141 -0
  84. package/src/generated/index.d.ts +344 -41
  85. package/src/generated/index.ts +396 -6
  86. package/src/generated/lexicon-docker.json +444 -86
  87. package/src/import/adapter.ts +8 -2
  88. package/src/import/generator.test.ts +4 -4
  89. package/src/import/generator.ts +53 -37
  90. package/src/import/parser.ts +82 -13
  91. package/src/import/roundtrip.test.ts +167 -2
  92. package/src/import/testdata/configs-secrets.yaml +15 -0
  93. package/src/lint/audit-catalog.ts +9 -1
  94. package/src/lint/audit-lineage.ts +14 -0
  95. package/src/lint/post-synth/add-url-checksum.ts +38 -0
  96. package/src/lint/post-synth/apt-update-alone.ts +39 -0
  97. package/src/lint/post-synth/cache-busting-copy.ts +65 -0
  98. package/src/lint/post-synth/copy-from-stage.ts +54 -0
  99. package/src/lint/post-synth/docker-helpers.ts +80 -0
  100. package/src/lint/post-synth/index.ts +16 -0
  101. package/src/lint/post-synth/no-root-user.ts +19 -17
  102. package/src/lint/post-synth/post-synth.test.ts +149 -0
  103. package/src/lint/post-synth/relative-workdir.ts +38 -0
  104. package/src/lint/post-synth/repeated-cmd.ts +35 -0
  105. package/src/lint/post-synth/root-after-user.ts +44 -0
  106. package/src/lint/post-synth/unused-global-arg.ts +38 -0
  107. package/src/lint/rules/index.ts +1 -0
  108. package/src/lint/rules/per-kind-order.ts +130 -0
  109. package/src/lint/rules/rules.test.ts +88 -0
  110. package/src/plugin.test.ts +4 -0
  111. package/src/plugin.ts +2 -1
  112. package/src/serializer.test.ts +112 -0
  113. package/src/serializer.ts +58 -11
  114. package/src/skills/chant-docker-patterns.md +93 -24
  115. package/src/skills/chant-docker.md +43 -16
  116. package/src/spec/parse-compose.ts +167 -95
  117. package/src/spec/parse-engine.ts +17 -5
  118. package/src/typecheck.e2e.test.ts +107 -0
  119. package/src/typed-props.test.ts +60 -0
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
6
+ import { splitDockerfileInstructions } from "../../import/parser";
6
7
 
7
8
  export interface ParsedService {
8
9
  name: string;
@@ -112,3 +113,82 @@ export function extractDockerfiles(output: unknown): Map<string, string> {
112
113
  }
113
114
  return files;
114
115
  }
116
+
117
+ // ── Dockerfile structure ──────────────────────────────────────────
118
+
119
+ export interface DockerfileInstr {
120
+ /** Upper-case keyword, e.g. "RUN". */
121
+ instruction: string;
122
+ /** The rest of the instruction as written. */
123
+ value: string;
124
+ }
125
+
126
+ export interface ParsedStage {
127
+ /** 0-based position among the stages. */
128
+ index: number;
129
+ /** The FROM image (or earlier stage) without `--platform` and `AS`. */
130
+ from: string;
131
+ as?: string;
132
+ instructions: DockerfileInstr[];
133
+ }
134
+
135
+ export interface ParsedDockerfile {
136
+ /** ARG instructions before the first FROM. */
137
+ globalArgs: DockerfileInstr[];
138
+ stages: ParsedStage[];
139
+ }
140
+
141
+ /** A Dockerfile's global ARGs and stages, each stage's instructions in order. */
142
+ export function parseDockerfileStages(content: string): ParsedDockerfile {
143
+ const out: ParsedDockerfile = { globalArgs: [], stages: [] };
144
+ for (const instr of splitDockerfileInstructions(content)) {
145
+ if (instr.instruction === "FROM") {
146
+ const words = instr.value.split(/\s+/).filter((w) => !w.startsWith("--"));
147
+ const asAt = words.findIndex((w) => w.toLowerCase() === "as");
148
+ out.stages.push({
149
+ index: out.stages.length,
150
+ from: words[0] ?? "",
151
+ ...(asAt > 0 && words[asAt + 1] ? { as: words[asAt + 1] } : {}),
152
+ instructions: [],
153
+ });
154
+ } else if (out.stages.length > 0) {
155
+ out.stages[out.stages.length - 1]!.instructions.push(instr);
156
+ } else if (instr.instruction === "ARG") {
157
+ out.globalArgs.push(instr);
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** How a stage is named in a message: `stage "build"` or `stage 2`. */
164
+ export function stageLabel(stage: ParsedStage): string {
165
+ return stage.as ? `stage "${stage.as}"` : `stage ${stage.index + 1}`;
166
+ }
167
+
168
+ /** The first line of an instruction, for a message. */
169
+ export function instrText(instr: DockerfileInstr): string {
170
+ const first = instr.value.split("\n")[0]!.replace(/\s*\\$/, "");
171
+ return `${instr.instruction} ${first}`;
172
+ }
173
+
174
+ /** Whether a USER value is root (by name or uid 0, with or without a group). */
175
+ export function isRootUser(value: string): boolean {
176
+ const user = value.trim().split(":")[0]!;
177
+ return user === "root" || user === "0";
178
+ }
179
+
180
+ /** Every generated Dockerfile in a build's outputs, parsed. */
181
+ export function eachDockerfile(outputs: Map<string, unknown>): Array<{ fileName: string; content: string; parsed: ParsedDockerfile }> {
182
+ const out: Array<{ fileName: string; content: string; parsed: ParsedDockerfile }> = [];
183
+ for (const [, output] of outputs) {
184
+ for (const [fileName, content] of extractDockerfiles(output)) {
185
+ out.push({ fileName, content, parsed: parseDockerfileStages(content) });
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /** A RUN value with its line continuations joined, for matching commands. */
192
+ export function commandText(instr: DockerfileInstr): string {
193
+ return instr.value.replace(/\\[ \t]*\n/g, " ");
194
+ }
@@ -1,34 +1,36 @@
1
1
  /**
2
2
  * DKRD012: No Root User
3
3
  *
4
- * Warns when a Dockerfile has no USER instruction,
5
- * meaning the container runs as root by default.
4
+ * Warns when the container would run as root: the final stage has no USER
5
+ * instruction, or its last USER is root. USER in an earlier stage does not
6
+ * carry into the final image.
6
7
  */
7
8
 
8
9
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
- import { extractDockerfiles } from "./docker-helpers";
10
+ import { eachDockerfile, isRootUser, stageLabel } from "./docker-helpers";
10
11
 
11
12
  export const dkrd012: PostSynthCheck = {
12
13
  id: "DKRD012",
13
- description: "Dockerfile has no USER instruction — container runs as root",
14
+ description: "Dockerfile's final stage has no USER instruction, or ends on USER root — container runs as root",
14
15
 
15
16
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
17
  const diagnostics: PostSynthDiagnostic[] = [];
17
18
 
18
- for (const [_outputName, output] of ctx.outputs) {
19
- const dockerfiles = extractDockerfiles(output);
20
-
21
- for (const [fileName, content] of dockerfiles) {
22
- const hasUser = /^USER\s+/m.test(content);
23
- if (!hasUser) {
24
- diagnostics.push({
25
- checkId: "DKRD012",
26
- severity: "warning",
27
- message: `${fileName}: No USER instruction found. The container will run as root. Add a USER instruction to improve security.`,
28
- lexicon: "docker",
29
- });
30
- }
19
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
20
+ const final = parsed.stages[parsed.stages.length - 1];
21
+ if (!final) continue;
22
+ const users = final.instructions.filter((i) => i.instruction === "USER");
23
+ const last = users[users.length - 1];
24
+ let message: string | undefined;
25
+ if (!last) {
26
+ const anyUser = parsed.stages.some((s) => s.instructions.some((i) => i.instruction === "USER"));
27
+ message = anyUser
28
+ ? `${fileName}: The final ${stageLabel(final)} has no USER instruction; a USER in an earlier stage does not carry over. The container will run as root. Add a USER step to the final stage.`
29
+ : `${fileName}: No USER instruction found. The container will run as root. Add a USER instruction to improve security.`;
30
+ } else if (isRootUser(last.value)) {
31
+ message = `${fileName}: The final ${stageLabel(final)} ends on USER ${last.value.trim()}, so the container runs as root. Switch to an unprivileged USER after the root setup steps.`;
31
32
  }
33
+ if (message) diagnostics.push({ checkId: "DKRD012", severity: "warning", message, lexicon: "docker" });
32
34
  }
33
35
 
34
36
  return diagnostics;
@@ -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
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * DKRD018: Relative WORKDIR
3
+ *
4
+ * A relative WORKDIR resolves against whatever directory the base image
5
+ * left, which can change with the image. Use an absolute path, at least for
6
+ * the first WORKDIR of a stage.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { eachDockerfile, stageLabel } from "./docker-helpers";
11
+
12
+ export const dkrd018: PostSynthCheck = {
13
+ id: "DKRD018",
14
+ description: "Relative WORKDIR before any absolute WORKDIR in the stage",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
20
+ for (const stage of parsed.stages) {
21
+ for (const instr of stage.instructions) {
22
+ if (instr.instruction !== "WORKDIR") continue;
23
+ const dir = instr.value.trim().replace(/^["']|["']$/g, "");
24
+ if (dir.startsWith("/") || dir.startsWith("$") || /^[A-Za-z]:[\\/]/.test(dir)) break;
25
+ diagnostics.push({
26
+ checkId: "DKRD018",
27
+ severity: "warning",
28
+ message: `${fileName} ${stageLabel(stage)}: WORKDIR ${dir} is relative to the base image's working directory. Use an absolute path.`,
29
+ lexicon: "docker",
30
+ });
31
+ break;
32
+ }
33
+ }
34
+ }
35
+
36
+ return diagnostics;
37
+ },
38
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * DKRD015: More than one CMD or ENTRYPOINT in a stage
3
+ *
4
+ * Only the last CMD and the last ENTRYPOINT of a stage take effect; the
5
+ * earlier ones are silently ignored.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { eachDockerfile, instrText, stageLabel } from "./docker-helpers";
10
+
11
+ export const dkrd015: PostSynthCheck = {
12
+ id: "DKRD015",
13
+ description: "More than one CMD or ENTRYPOINT in a stage — only the last one takes effect",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
19
+ for (const stage of parsed.stages) {
20
+ for (const kw of ["CMD", "ENTRYPOINT"]) {
21
+ const all = stage.instructions.filter((i) => i.instruction === kw);
22
+ if (all.length < 2) continue;
23
+ diagnostics.push({
24
+ checkId: "DKRD015",
25
+ severity: "warning",
26
+ message: `${fileName} ${stageLabel(stage)}: ${all.length} ${kw} instructions; only the last (${instrText(all[all.length - 1]!)}) takes effect. Remove the others.`,
27
+ lexicon: "docker",
28
+ });
29
+ }
30
+ }
31
+ }
32
+
33
+ return diagnostics;
34
+ },
35
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * DKRD014: Root-only command after a non-root USER
3
+ *
4
+ * A RUN that installs packages or manages users and ownership (apt-get,
5
+ * apk add, useradd, chown, ...) fails or misbehaves after USER switches to
6
+ * an unprivileged user. Run root setup first, then switch USER.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { commandText, eachDockerfile, instrText, isRootUser, stageLabel } from "./docker-helpers";
11
+
12
+ const ROOT_ONLY = /(^|[\s;&|(])(apt-get\s+(install|update|upgrade|dist-upgrade|remove|purge)|apt\s+(install|update|upgrade)|apk\s+(add|update|upgrade|del)|yum\s+(install|update)|dnf\s+(install|update|upgrade)|microdnf\s+install|zypper\s+(install|in|refresh)|useradd|adduser|groupadd|addgroup|usermod|chown)\b/;
13
+
14
+ export const dkrd014: PostSynthCheck = {
15
+ id: "DKRD014",
16
+ description: "RUN needs root but comes after USER switched to a non-root user",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
22
+ for (const stage of parsed.stages) {
23
+ let user: string | undefined;
24
+ for (const instr of stage.instructions) {
25
+ if (instr.instruction === "USER") {
26
+ user = instr.value.trim();
27
+ continue;
28
+ }
29
+ if (instr.instruction !== "RUN" || user === undefined || isRootUser(user)) continue;
30
+ const cmd = commandText(instr);
31
+ if (/\bsudo\b/.test(cmd) || !ROOT_ONLY.test(cmd)) continue;
32
+ diagnostics.push({
33
+ checkId: "DKRD014",
34
+ severity: "warning",
35
+ message: `${fileName} ${stageLabel(stage)}: ${instrText(instr)} needs root but runs as USER ${user}. Move it before the USER step, or switch to USER root for it.`,
36
+ lexicon: "docker",
37
+ });
38
+ }
39
+ }
40
+ }
41
+
42
+ return diagnostics;
43
+ },
44
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * DKRD017: ARG before FROM that nothing uses
3
+ *
4
+ * An ARG declared before the first FROM is only visible to FROM lines, and
5
+ * to stages that declare it again (`ARG NAME`). One that neither uses does
6
+ * nothing.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { eachDockerfile } from "./docker-helpers";
11
+
12
+ export const dkrd017: PostSynthCheck = {
13
+ id: "DKRD017",
14
+ description: "ARG before the first FROM is not used by any FROM or stage",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const { fileName, content, parsed } of eachDockerfile(ctx.outputs)) {
20
+ const fromLines = content.split("\n").filter((l) => /^\s*FROM\s/i.test(l)).join("\n");
21
+ for (const arg of parsed.globalArgs) {
22
+ const name = arg.value.trim().split("=")[0]!.trim();
23
+ if (!name) continue;
24
+ const used = new RegExp(`\\$\\{?${name}\\b`).test(fromLines);
25
+ const redeclared = parsed.stages.some((s) => s.instructions.some((i) => i.instruction === "ARG" && i.value.trim().split("=")[0]!.trim() === name));
26
+ if (used || redeclared) continue;
27
+ diagnostics.push({
28
+ checkId: "DKRD017",
29
+ severity: "warning",
30
+ message: `${fileName}: ARG ${name} is declared before FROM but no FROM uses it and no stage declares it again with ARG ${name}. Use it, declare it in the stage that needs it, or remove it.`,
31
+ lexicon: "docker",
32
+ });
33
+ }
34
+ }
35
+
36
+ return diagnostics;
37
+ },
38
+ };
@@ -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;AA6SlG,eAAO,MAAM,gBAAgB,EAAE,UAqE9B,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;AA4VlG,eAAO,MAAM,gBAAgB,EAAE,UAqE9B,CAAC"}
@@ -5,7 +5,7 @@ Common Docker Compose and Dockerfile patterns using `@intentius/chant-lexicon-do
5
5
  ## Database service with volume
6
6
 
7
7
  ```typescript
8
- import { Service, Volume } from "@intentius/chant-lexicon-docker";
8
+ import { Service, Volume, env } from "@intentius/chant-lexicon-docker";
9
9
 
10
10
  export const postgres = new Service({
11
11
  image: "postgres:16-alpine",
@@ -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
- workdir: "/app",
63
- copy: ["package*.json ./"],
64
- run: ["npm ci --only=production"],
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
- workdir: "/app",
70
- copy: ["--from=deps /app/node_modules ./node_modules", ". ."],
71
- run: ["npm run build"],
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
- workdir: "/app",
76
- copy: [
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 with virtualenv
96
+ ## Python service
89
97
 
90
98
  ```typescript
91
99
  export const pythonApp = new Dockerfile({
92
100
  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",
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
@@ -138,7 +207,7 @@ export const app = new Service({
138
207
  ## Environment interpolation patterns
139
208
 
140
209
  ```typescript
141
- import { env } from "@intentius/chant-lexicon-docker";
210
+ import { Service, env } from "@intentius/chant-lexicon-docker";
142
211
 
143
212
  // Dev vs prod toggle:
144
213
  export const api = new Service({