@intentius/chant-lexicon-docker 0.124.21 → 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
package/src/generated/index.ts
CHANGED
|
@@ -8,3 +8,120 @@ export const DockerConfig = createResource("Docker::Compose::Config", "docker",
|
|
|
8
8
|
export const DockerSecret = createResource("Docker::Compose::Secret", "docker", {});
|
|
9
9
|
|
|
10
10
|
export const Dockerfile = createResource("Docker::Dockerfile", "docker", {});
|
|
11
|
+
|
|
12
|
+
/** The Dockerfile instructions a step can name. FROM is the stage's own `from` field. */
|
|
13
|
+
export type DockerfileInstructionName = "arg" | "env" | "run" | "copy" | "add" | "workdir" | "user" | "expose" | "volume" | "label" | "entrypoint" | "cmd" | "healthcheck" | "shell" | "stopsignal" | "onbuild";
|
|
14
|
+
type DockerfileStepOf<K extends DockerfileInstructionName, V> = { [P in K]: V } & { [P in Exclude<DockerfileInstructionName, K>]?: never };
|
|
15
|
+
/**
|
|
16
|
+
* One Dockerfile instruction, named by its single key: `{ run: "npm ci" }` is `RUN npm ci`.
|
|
17
|
+
* A string[] value (run, cmd, entrypoint, shell) is written in JSON exec form.
|
|
18
|
+
*/
|
|
19
|
+
export type DockerfileStep =
|
|
20
|
+
/** ARG: Build-time argument */
|
|
21
|
+
| DockerfileStepOf<"arg", string>
|
|
22
|
+
/** ENV: Environment variable */
|
|
23
|
+
| DockerfileStepOf<"env", string>
|
|
24
|
+
/** RUN: Run a command during build */
|
|
25
|
+
| DockerfileStepOf<"run", string | string[]>
|
|
26
|
+
/** COPY: Copy files from build context */
|
|
27
|
+
| DockerfileStepOf<"copy", string>
|
|
28
|
+
/** ADD: Add files (supports URLs and archives) */
|
|
29
|
+
| DockerfileStepOf<"add", string>
|
|
30
|
+
/** WORKDIR: Set working directory */
|
|
31
|
+
| DockerfileStepOf<"workdir", string>
|
|
32
|
+
/** USER: Set user for subsequent instructions */
|
|
33
|
+
| DockerfileStepOf<"user", string>
|
|
34
|
+
/** EXPOSE: Document exposed ports */
|
|
35
|
+
| DockerfileStepOf<"expose", string | number>
|
|
36
|
+
/** VOLUME: Create mount points */
|
|
37
|
+
| DockerfileStepOf<"volume", string>
|
|
38
|
+
/** LABEL: Add metadata labels */
|
|
39
|
+
| DockerfileStepOf<"label", string>
|
|
40
|
+
/** ENTRYPOINT: Set the entrypoint executable */
|
|
41
|
+
| DockerfileStepOf<"entrypoint", string | string[]>
|
|
42
|
+
/** CMD: Default command arguments */
|
|
43
|
+
| DockerfileStepOf<"cmd", string | string[]>
|
|
44
|
+
/** HEALTHCHECK: Health check instruction */
|
|
45
|
+
| DockerfileStepOf<"healthcheck", string>
|
|
46
|
+
/** SHELL: Set the shell for shell-form commands (JSON array form) */
|
|
47
|
+
| DockerfileStepOf<"shell", string | string[]>
|
|
48
|
+
/** STOPSIGNAL: Signal sent to stop the container */
|
|
49
|
+
| DockerfileStepOf<"stopsignal", string>
|
|
50
|
+
/** ONBUILD: Instruction to run when the image is used as a base */
|
|
51
|
+
| DockerfileStepOf<"onbuild", string>;
|
|
52
|
+
/**
|
|
53
|
+
* One build stage: FROM, then its instructions.
|
|
54
|
+
* Without `steps`, the per-kind fields are written in a fixed order: ARG, ENV, WORKDIR, USER, RUN, COPY, ADD, EXPOSE, VOLUME, LABEL, ENTRYPOINT, CMD, HEALTHCHECK.
|
|
55
|
+
*/
|
|
56
|
+
export interface DockerfileStage {
|
|
57
|
+
from: string;
|
|
58
|
+
as?: string;
|
|
59
|
+
/** Instructions after FROM, written exactly in this order. Can't be combined with the per-kind fields. */
|
|
60
|
+
steps?: DockerfileStep[];
|
|
61
|
+
/** ARG as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
62
|
+
arg?: string[];
|
|
63
|
+
/** ENV as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
64
|
+
env?: string[];
|
|
65
|
+
/** RUN as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
66
|
+
run?: string[];
|
|
67
|
+
/** COPY as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
68
|
+
copy?: string[];
|
|
69
|
+
/** ADD as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
70
|
+
add?: string[];
|
|
71
|
+
/** WORKDIR as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
72
|
+
workdir?: string;
|
|
73
|
+
/** USER as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
74
|
+
user?: string;
|
|
75
|
+
/** EXPOSE as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
76
|
+
expose?: string[];
|
|
77
|
+
/** VOLUME as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
78
|
+
volume?: string[];
|
|
79
|
+
/** LABEL as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
80
|
+
label?: string[];
|
|
81
|
+
/** ENTRYPOINT as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
82
|
+
entrypoint?: string;
|
|
83
|
+
/** CMD as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
84
|
+
cmd?: string;
|
|
85
|
+
/** HEALTHCHECK as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
86
|
+
healthcheck?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A Dockerfile: `stages` for a multi-stage build, or `from` and the stage fields for one stage.
|
|
90
|
+
* Without `steps`, the per-kind fields are written in a fixed order: ARG, ENV, WORKDIR, USER, RUN, COPY, ADD, EXPOSE, VOLUME, LABEL, ENTRYPOINT, CMD, HEALTHCHECK.
|
|
91
|
+
*/
|
|
92
|
+
export interface DockerfileProps {
|
|
93
|
+
name?: string;
|
|
94
|
+
/** ARG instructions written before the first FROM, e.g. `["NODE_VERSION=20"]`. */
|
|
95
|
+
globalArgs?: string[];
|
|
96
|
+
from?: string;
|
|
97
|
+
as?: string;
|
|
98
|
+
stages?: DockerfileStage[];
|
|
99
|
+
/** Instructions after FROM, written exactly in this order. Can't be combined with the per-kind fields. */
|
|
100
|
+
steps?: DockerfileStep[];
|
|
101
|
+
/** ARG as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
102
|
+
arg?: string[];
|
|
103
|
+
/** ENV as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
104
|
+
env?: string[];
|
|
105
|
+
/** RUN as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
106
|
+
run?: string[];
|
|
107
|
+
/** COPY as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
108
|
+
copy?: string[];
|
|
109
|
+
/** ADD as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
110
|
+
add?: string[];
|
|
111
|
+
/** WORKDIR as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
112
|
+
workdir?: string;
|
|
113
|
+
/** USER as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
114
|
+
user?: string;
|
|
115
|
+
/** EXPOSE as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
116
|
+
expose?: string[];
|
|
117
|
+
/** VOLUME as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
118
|
+
volume?: string[];
|
|
119
|
+
/** LABEL as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
120
|
+
label?: string[];
|
|
121
|
+
/** ENTRYPOINT as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
122
|
+
entrypoint?: string;
|
|
123
|
+
/** CMD as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
124
|
+
cmd?: string;
|
|
125
|
+
/** HEALTHCHECK as a per-kind field, written in the fixed order. Use `steps` to choose the order. */
|
|
126
|
+
healthcheck?: string;
|
|
127
|
+
}
|
|
@@ -220,6 +220,12 @@
|
|
|
220
220
|
},
|
|
221
221
|
"healthcheck": {
|
|
222
222
|
"description": "Health check instruction"
|
|
223
|
+
},
|
|
224
|
+
"globalArgs": {
|
|
225
|
+
"description": "ARG instructions written before the first FROM"
|
|
226
|
+
},
|
|
227
|
+
"steps": {
|
|
228
|
+
"description": "Instructions after FROM, written in the order given; one instruction per step"
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
}
|
package/src/import/adapter.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { TemplateIR, TemplateParser, ResourceIR } from "@intentius/chant/import/parser";
|
|
15
15
|
import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
16
|
-
import { DockerParser } from "./parser";
|
|
16
|
+
import { DockerParser, DockerfileParser, looksLikeDockerfile } from "./parser";
|
|
17
17
|
import type { DockerIR, DockerfileStage } from "./parser";
|
|
18
18
|
import { DockerGenerator } from "./generator";
|
|
19
19
|
|
|
@@ -23,7 +23,7 @@ function dockerIrToResource(entity: DockerIR): ResourceIR {
|
|
|
23
23
|
return {
|
|
24
24
|
logicalId: entity.name,
|
|
25
25
|
type: entity.kind,
|
|
26
|
-
properties: { stages: entity.stages },
|
|
26
|
+
properties: { ...(entity.globalArgs ? { globalArgs: entity.globalArgs } : {}), stages: entity.stages },
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
return {
|
|
@@ -41,6 +41,7 @@ function resourceToDockerIr(resource: ResourceIR): DockerIR {
|
|
|
41
41
|
return {
|
|
42
42
|
kind: "dockerfile",
|
|
43
43
|
name,
|
|
44
|
+
...(Array.isArray(resource.properties.globalArgs) ? { globalArgs: resource.properties.globalArgs as string[] } : {}),
|
|
44
45
|
stages: (resource.properties.stages as DockerfileStage[] | undefined) ?? [],
|
|
45
46
|
};
|
|
46
47
|
case "service":
|
|
@@ -66,6 +67,11 @@ function resourceToDockerIr(resource: ResourceIR): DockerIR {
|
|
|
66
67
|
*/
|
|
67
68
|
export class DockerTemplateParser implements TemplateParser {
|
|
68
69
|
parse(content: string): TemplateIR {
|
|
70
|
+
// A Dockerfile has no name of its own; it imports as `app`
|
|
71
|
+
// (Dockerfile.app). Rename the export to choose the file name.
|
|
72
|
+
if (looksLikeDockerfile(content)) {
|
|
73
|
+
return { resources: [dockerIrToResource(new DockerfileParser().parse("app", content))], parameters: [] };
|
|
74
|
+
}
|
|
69
75
|
const { entities } = new DockerParser().parse(content);
|
|
70
76
|
return {
|
|
71
77
|
resources: entities.map(dockerIrToResource),
|
package/src/import/generator.ts
CHANGED
|
@@ -44,7 +44,7 @@ export class DockerGenerator {
|
|
|
44
44
|
break;
|
|
45
45
|
case "dockerfile":
|
|
46
46
|
imports.add("Dockerfile");
|
|
47
|
-
lines.push(generateDockerfile(entity));
|
|
47
|
+
lines.push(generateDockerfile(entity, warnings));
|
|
48
48
|
break;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -82,43 +82,59 @@ function generateSecret(sec: SecretIR): string {
|
|
|
82
82
|
return `export const ${sanitizeName(sec.name)} = new Secret(${hasProps ? JSON.stringify(sec.props) : "{}"});`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return `export const ${sanitizeName(df.name)} = new Dockerfile(${propsStr});`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Single-stage: flat props (preserved behaviour)
|
|
94
|
-
const stage = df.stages[0];
|
|
95
|
-
const props: Record<string, unknown> = {};
|
|
96
|
-
if (stage) props.from = stage.from;
|
|
97
|
-
|
|
98
|
-
// Group instructions by type
|
|
99
|
-
const grouped: Record<string, string[]> = {};
|
|
100
|
-
for (const { instruction, value } of stage?.instructions ?? []) {
|
|
101
|
-
const key = instruction.toLowerCase();
|
|
102
|
-
if (!grouped[key]) grouped[key] = [];
|
|
103
|
-
grouped[key].push(value);
|
|
104
|
-
}
|
|
85
|
+
/** The instructions a step can name; anything else (MAINTAINER) is left as a comment. */
|
|
86
|
+
const STEP_KEYS = new Set([
|
|
87
|
+
"arg", "env", "workdir", "user", "run", "copy", "add", "expose", "volume",
|
|
88
|
+
"label", "entrypoint", "cmd", "healthcheck", "shell", "stopsignal", "onbuild",
|
|
89
|
+
]);
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A Dockerfile always imports as `steps`, one per instruction in source
|
|
93
|
+
* order, so building the import gives the same instructions in the same
|
|
94
|
+
* order (the per-kind fields would reorder them).
|
|
95
|
+
*/
|
|
96
|
+
function generateDockerfile(df: DockerfileIR, warnings: string[]): string {
|
|
97
|
+
const out: string[] = [`export const ${sanitizeName(df.name)} = new Dockerfile({`];
|
|
98
|
+
if (df.globalArgs?.length) out.push(` globalArgs: [${df.globalArgs.map((a) => JSON.stringify(a)).join(", ")}],`);
|
|
99
|
+
|
|
100
|
+
const steps = (stage: DockerfileIR["stages"][number], indent: string): void => {
|
|
101
|
+
out.push(`${indent}steps: [`);
|
|
102
|
+
for (const { instruction, value } of stage.instructions) {
|
|
103
|
+
const key = instruction.toLowerCase();
|
|
104
|
+
if (STEP_KEYS.has(key)) {
|
|
105
|
+
out.push(`${indent} { ${key}: ${quote(value)} },`);
|
|
106
|
+
} else {
|
|
107
|
+
warnings.push(`Dockerfile ${df.name}: ${instruction} has no step and was left as a comment`);
|
|
108
|
+
out.push(`${indent} // ${instruction} ${value.replace(/\n/g, " ")}`);
|
|
109
|
+
}
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
out.push(`${indent}],`);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (df.stages.length === 1) {
|
|
115
|
+
const stage = df.stages[0]!;
|
|
116
|
+
out.push(` from: ${JSON.stringify(stage.from)},`);
|
|
117
|
+
if (stage.as) out.push(` as: ${JSON.stringify(stage.as)},`);
|
|
118
|
+
steps(stage, " ");
|
|
119
|
+
} else {
|
|
120
|
+
out.push(` stages: [`);
|
|
121
|
+
for (const stage of df.stages) {
|
|
122
|
+
out.push(` {`);
|
|
123
|
+
out.push(` from: ${JSON.stringify(stage.from)},`);
|
|
124
|
+
if (stage.as) out.push(` as: ${JSON.stringify(stage.as)},`);
|
|
125
|
+
steps(stage, " ");
|
|
126
|
+
out.push(` },`);
|
|
117
127
|
}
|
|
128
|
+
out.push(` ],`);
|
|
118
129
|
}
|
|
130
|
+
out.push(`});`);
|
|
131
|
+
return out.join("\n");
|
|
132
|
+
}
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
/** A string literal, single-quoted when that avoids escaping double quotes (exec form: `'["node", "a.js"]'`). */
|
|
135
|
+
function quote(value: string): string {
|
|
136
|
+
if (value.includes('"') && !/['\\\n]/.test(value)) return `'${value}'`;
|
|
137
|
+
return JSON.stringify(value);
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
function sanitizeName(name: string): string {
|
package/src/import/parser.ts
CHANGED
|
@@ -38,12 +38,19 @@ export interface SecretIR {
|
|
|
38
38
|
export interface DockerfileStage {
|
|
39
39
|
from: string;
|
|
40
40
|
as?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The stage's instructions after FROM, in source order. `instruction` is
|
|
43
|
+
* the upper-case keyword; `value` is the rest of the instruction as
|
|
44
|
+
* written, line continuations and heredoc bodies included.
|
|
45
|
+
*/
|
|
41
46
|
instructions: Array<{ instruction: string; value: string }>;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export interface DockerfileIR {
|
|
45
50
|
kind: "dockerfile";
|
|
46
51
|
name: string;
|
|
52
|
+
/** ARG values declared before the first FROM. */
|
|
53
|
+
globalArgs?: string[];
|
|
47
54
|
stages: DockerfileStage[];
|
|
48
55
|
}
|
|
49
56
|
|
|
@@ -154,37 +161,99 @@ export class DockerParser {
|
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
/** One logical Dockerfile instruction: its upper-case keyword and the rest as written. */
|
|
165
|
+
export interface DockerfileInstruction {
|
|
166
|
+
instruction: string;
|
|
167
|
+
value: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Split Dockerfile content into logical instructions, in order. Comment
|
|
172
|
+
* and blank lines are dropped (also inside a continued instruction, as
|
|
173
|
+
* Docker does). A line ending in a backslash continues onto the next line,
|
|
174
|
+
* and a heredoc (`RUN <<EOF` ... `EOF`) runs to its terminator; both are
|
|
175
|
+
* kept in the value exactly as written, so writing the value back gives
|
|
176
|
+
* the same instruction.
|
|
177
|
+
*/
|
|
178
|
+
export function splitDockerfileInstructions(content: string): DockerfileInstruction[] {
|
|
179
|
+
const out: DockerfileInstruction[] = [];
|
|
180
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
181
|
+
let i = 0;
|
|
182
|
+
while (i < lines.length) {
|
|
183
|
+
const first = lines[i]!;
|
|
184
|
+
i++;
|
|
185
|
+
const trimmed = first.trim();
|
|
186
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
187
|
+
|
|
188
|
+
const parts = [first.trimStart()];
|
|
189
|
+
let last = first;
|
|
190
|
+
while (/\\\s*$/.test(last) && i < lines.length) {
|
|
191
|
+
const next = lines[i]!;
|
|
192
|
+
i++;
|
|
193
|
+
// Docker skips comment and empty lines inside a continued instruction.
|
|
194
|
+
if (!next.trim() || next.trim().startsWith("#")) continue;
|
|
195
|
+
parts.push(next);
|
|
196
|
+
last = next;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Heredocs: <<EOF, <<-EOF, <<"EOF", <<'EOF' (possibly several).
|
|
200
|
+
const joined = parts.join("\n");
|
|
201
|
+
const terminators = [...joined.matchAll(/<<(-?)(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g)].map((m) => ({ dash: m[1] === "-", word: m[3]! }));
|
|
202
|
+
for (const t of terminators) {
|
|
203
|
+
while (i < lines.length) {
|
|
204
|
+
const body = lines[i]!;
|
|
205
|
+
i++;
|
|
206
|
+
parts.push(body);
|
|
207
|
+
if ((t.dash ? body.replace(/^\t+/, "") : body) === t.word) break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const text = parts.join("\n").trimEnd();
|
|
212
|
+
const match = text.match(/^([A-Za-z]+)(?:\s+([\s\S]*))?$/);
|
|
213
|
+
if (!match) continue;
|
|
214
|
+
out.push({ instruction: match[1]!.toUpperCase(), value: (match[2] ?? "").trimStart() });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
/**
|
|
158
220
|
* Parser for Dockerfile content.
|
|
159
221
|
*/
|
|
160
222
|
export class DockerfileParser {
|
|
161
223
|
parse(name: string, content: string): DockerfileIR {
|
|
162
224
|
const stages: DockerfileStage[] = [];
|
|
225
|
+
const globalArgs: string[] = [];
|
|
163
226
|
let current: DockerfileStage | null = null;
|
|
164
227
|
|
|
165
|
-
for (const
|
|
166
|
-
const trimmed = line.trim();
|
|
167
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
168
|
-
|
|
169
|
-
const match = trimmed.match(/^([A-Z]+)\s+([\s\S]+)$/);
|
|
170
|
-
if (!match) continue;
|
|
171
|
-
|
|
172
|
-
const [, instruction, value] = match;
|
|
173
|
-
|
|
228
|
+
for (const { instruction, value } of splitDockerfileInstructions(content)) {
|
|
174
229
|
if (instruction === "FROM") {
|
|
175
|
-
// Parse "FROM image AS stagename"
|
|
230
|
+
// Parse "FROM [--platform=...] image AS stagename"
|
|
176
231
|
const asMatch = value.match(/^(.+?)\s+[Aa][Ss]\s+(\S+)$/);
|
|
177
232
|
if (asMatch) {
|
|
178
|
-
current = { from: asMatch[1]
|
|
233
|
+
current = { from: asMatch[1]!.trim(), as: asMatch[2]!.trim(), instructions: [] };
|
|
179
234
|
} else {
|
|
180
235
|
current = { from: value.trim(), instructions: [] };
|
|
181
236
|
}
|
|
182
237
|
stages.push(current);
|
|
183
238
|
} else if (current) {
|
|
184
|
-
current.instructions.push({ instruction, value
|
|
239
|
+
current.instructions.push({ instruction, value });
|
|
240
|
+
} else if (instruction === "ARG") {
|
|
241
|
+
globalArgs.push(value);
|
|
185
242
|
}
|
|
186
243
|
}
|
|
187
244
|
|
|
188
|
-
return { kind: "dockerfile", name, stages };
|
|
245
|
+
return { kind: "dockerfile", name, ...(globalArgs.length > 0 ? { globalArgs } : {}), stages };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Whether content reads as a Dockerfile: its first instruction is FROM or ARG, or it starts with a parser directive. */
|
|
250
|
+
export function looksLikeDockerfile(content: string): boolean {
|
|
251
|
+
for (const line of content.split("\n")) {
|
|
252
|
+
const t = line.trim();
|
|
253
|
+
if (!t) continue;
|
|
254
|
+
if (/^#\s*(syntax|escape|check)\s*=/i.test(t)) return true;
|
|
255
|
+
if (t.startsWith("#")) continue;
|
|
256
|
+
return /^(FROM|ARG)\s/i.test(t);
|
|
189
257
|
}
|
|
258
|
+
return false;
|
|
190
259
|
}
|
|
@@ -3,6 +3,24 @@ import { readFileSync } from "fs";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { DockerParser, DockerfileParser } from "./parser";
|
|
5
5
|
import { DockerGenerator } from "./generator";
|
|
6
|
+
import { DockerTemplateGenerator, DockerTemplateParser } from "./adapter";
|
|
7
|
+
import { Dockerfile } from "../generated/index";
|
|
8
|
+
import { dockerSerializer } from "../serializer";
|
|
9
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dockerfile -> TypeScript -> Dockerfile. The generated source is run (its
|
|
13
|
+
* import line dropped, its exports collected) and the entity it declares is
|
|
14
|
+
* serialized again.
|
|
15
|
+
*/
|
|
16
|
+
function roundTrip(content: string, source?: string): { source: string; dockerfile: string } {
|
|
17
|
+
const src = source ?? new DockerGenerator().generate([new DockerfileParser().parse("app", content)]).source;
|
|
18
|
+
const body = src.replace(/^import .*$/m, "").replace(/export const (\w+) =/g, "out.$1 =");
|
|
19
|
+
const out: Record<string, Declarable> = {};
|
|
20
|
+
new Function("Dockerfile", "out", body)(Dockerfile, out);
|
|
21
|
+
const result = dockerSerializer.serialize(new Map(Object.entries(out))) as { files: Record<string, string> };
|
|
22
|
+
return { source: src, dockerfile: Object.values(result.files)[0]! };
|
|
23
|
+
}
|
|
6
24
|
|
|
7
25
|
const testdata = (file: string) =>
|
|
8
26
|
readFileSync(join(import.meta.dirname, "testdata", file), "utf8");
|
|
@@ -46,4 +64,91 @@ describe("roundtrip: parse → generate", () => {
|
|
|
46
64
|
expect(source).toContain("node:20-alpine");
|
|
47
65
|
expect(source).toContain("nginx:1.25");
|
|
48
66
|
});
|
|
67
|
+
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("roundtrip: Dockerfile → TypeScript → Dockerfile keeps the instruction order", () => {
|
|
71
|
+
test("interleaved COPY and RUN", () => {
|
|
72
|
+
const content = [
|
|
73
|
+
"FROM node:20-alpine",
|
|
74
|
+
"WORKDIR /app",
|
|
75
|
+
"COPY package*.json ./",
|
|
76
|
+
"RUN npm ci",
|
|
77
|
+
"COPY . .",
|
|
78
|
+
"RUN npm run build",
|
|
79
|
+
'CMD ["node", "dist/index.js"]',
|
|
80
|
+
"",
|
|
81
|
+
].join("\n");
|
|
82
|
+
const { source, dockerfile } = roundTrip(content);
|
|
83
|
+
expect(source).toContain("steps: [");
|
|
84
|
+
expect(dockerfile).toBe(content);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("USER after RUN stays after it", () => {
|
|
88
|
+
const content = [
|
|
89
|
+
"FROM debian:12-slim",
|
|
90
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends curl",
|
|
91
|
+
"RUN useradd --system app",
|
|
92
|
+
"USER app",
|
|
93
|
+
"RUN whoami",
|
|
94
|
+
"",
|
|
95
|
+
].join("\n");
|
|
96
|
+
expect(roundTrip(content).dockerfile).toBe(content);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("multi-stage, ARG before FROM, SHELL, STOPSIGNAL, ONBUILD and repeated WORKDIR", () => {
|
|
100
|
+
const content = [
|
|
101
|
+
"ARG NODE_VERSION=20",
|
|
102
|
+
"FROM node:${NODE_VERSION}-alpine AS build",
|
|
103
|
+
'SHELL ["/bin/sh", "-c"]',
|
|
104
|
+
"WORKDIR /src",
|
|
105
|
+
"COPY . .",
|
|
106
|
+
"WORKDIR /src/app",
|
|
107
|
+
"RUN npm run build",
|
|
108
|
+
"FROM nginx:1.27-alpine",
|
|
109
|
+
"COPY --from=build /src/app/dist /usr/share/nginx/html",
|
|
110
|
+
"ONBUILD COPY . /usr/share/nginx/html",
|
|
111
|
+
"STOPSIGNAL SIGQUIT",
|
|
112
|
+
"",
|
|
113
|
+
].join("\n");
|
|
114
|
+
const { source, dockerfile } = roundTrip(content);
|
|
115
|
+
expect(source).toContain('globalArgs: ["NODE_VERSION=20"]');
|
|
116
|
+
expect(dockerfile).toBe(content);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("line continuations and heredocs are kept as written", () => {
|
|
120
|
+
const content = [
|
|
121
|
+
"# syntax=docker/dockerfile:1",
|
|
122
|
+
"FROM debian:12-slim",
|
|
123
|
+
"RUN apt-get update \\",
|
|
124
|
+
" # a comment Docker drops",
|
|
125
|
+
" && apt-get install -y --no-install-recommends curl",
|
|
126
|
+
"RUN <<EOF",
|
|
127
|
+
"set -e",
|
|
128
|
+
"echo hello > /hello",
|
|
129
|
+
"EOF",
|
|
130
|
+
"USER nobody",
|
|
131
|
+
"",
|
|
132
|
+
].join("\n");
|
|
133
|
+
const { dockerfile } = roundTrip(content);
|
|
134
|
+
expect(dockerfile).toBe([
|
|
135
|
+
"FROM debian:12-slim",
|
|
136
|
+
"RUN apt-get update \\",
|
|
137
|
+
" && apt-get install -y --no-install-recommends curl",
|
|
138
|
+
"RUN <<EOF",
|
|
139
|
+
"set -e",
|
|
140
|
+
"echo hello > /hello",
|
|
141
|
+
"EOF",
|
|
142
|
+
"USER nobody",
|
|
143
|
+
"",
|
|
144
|
+
].join("\n"));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("chant import of a Dockerfile goes through the template adapter", () => {
|
|
148
|
+
const content = "FROM alpine:3.20\nCOPY run.sh /run.sh\nRUN chmod +x /run.sh\nUSER nobody\n";
|
|
149
|
+
const ir = new DockerTemplateParser().parse(content);
|
|
150
|
+
const [file] = new DockerTemplateGenerator().generate(ir);
|
|
151
|
+
expect(file!.content).toContain("export const app = new Dockerfile({");
|
|
152
|
+
expect(roundTrip(content, file!.content).dockerfile).toBe(content);
|
|
153
|
+
});
|
|
49
154
|
});
|
|
@@ -13,7 +13,15 @@ export const dockerAuditCatalog: Record<string, RuleMeta> = {
|
|
|
13
13
|
DKRD003: auditRule("DKRD003", "merge-worthy", "guidance", "Service exposes SSH (port 22)", "Don't expose SSH from a container; use exec/ephemeral access instead.", { authority: [DOCKER_SEC] }),
|
|
14
14
|
DKRD010: auditRule("DKRD010", "report-only", "guidance", "apt-get install without --no-install-recommends", "Add --no-install-recommends to keep images small.", { category: "best-practice" }),
|
|
15
15
|
DKRD011: auditRule("DKRD011", "report-only", "guidance", "ADD used where COPY would do", "Prefer COPY unless fetching a URL or extracting an archive.", { category: "best-practice" }),
|
|
16
|
-
DKRD012: auditRule("DKRD012", "merge-worthy", "guidance", "
|
|
16
|
+
DKRD012: auditRule("DKRD012", "merge-worthy", "guidance", "Container runs as root: the final stage has no USER, or ends on USER root", "Add a non-root USER instruction to the final stage, after the root setup steps.", { authority: [DOCKER_SEC] }),
|
|
17
|
+
DKRD013: auditRule("DKRD013", "report-only", "guidance", "Whole build context copied before a dependency install", "Copy the dependency manifests, run the install, then copy the rest of the source.", { category: "efficiency" }),
|
|
18
|
+
DKRD014: auditRule("DKRD014", "report-only", "guidance", "Root-only RUN after a non-root USER", "Run package installs and user/ownership changes before switching USER.", { category: "correctness" }),
|
|
19
|
+
DKRD015: auditRule("DKRD015", "report-only", "guidance", "More than one CMD or ENTRYPOINT in a stage", "Keep one CMD and one ENTRYPOINT per stage; only the last takes effect.", { category: "correctness" }),
|
|
20
|
+
DKRD016: auditRule("DKRD016", "report-only", "guidance", "COPY --from names no earlier stage", "Name a stage defined earlier in the Dockerfile, or a tagged image.", { category: "correctness" }),
|
|
21
|
+
DKRD017: auditRule("DKRD017", "report-only", "guidance", "ARG before FROM is never used", "Use the ARG in a FROM line, declare it again in the stage that needs it, or remove it.", { category: "best-practice" }),
|
|
22
|
+
DKRD018: auditRule("DKRD018", "report-only", "guidance", "Relative WORKDIR", "Use an absolute WORKDIR path.", { category: "best-practice" }),
|
|
23
|
+
DKRD019: auditRule("DKRD019", "report-only", "guidance", "ADD of a remote URL without --checksum", "Add --checksum=sha256:<digest> to ADD of an http(s) URL.", { category: "security" }),
|
|
24
|
+
DKRD020: auditRule("DKRD020", "report-only", "guidance", "apt-get update in its own RUN", "Run apt-get update and apt-get install in the same RUN.", { category: "correctness" }),
|
|
17
25
|
};
|
|
18
26
|
|
|
19
27
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
@@ -30,5 +30,19 @@ export const dockerAuditLineage: Record<string, Lineage[]> = {
|
|
|
30
30
|
{ tool: "checkov", rule: "CKV_DOCKER_3", url: "https://www.checkov.io/5.Policy%20Index/dockerfile.html", relation: "equivalent" },
|
|
31
31
|
{ tool: "kics", rule: "missing_user_instruction", url: "https://docs.kics.io/latest/queries/dockerfile-queries/fd54f200-402c-4333-a5a4-36ef6709af2f/", relation: "equivalent" },
|
|
32
32
|
{ tool: "dockle", rule: "CIS-DI-0001", url: "https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#cis-di-0001", relation: "overlaps" },
|
|
33
|
+
{ tool: "hadolint", rule: "DL3002", url: "https://github.com/hadolint/hadolint/wiki/DL3002", relation: "overlaps" },
|
|
34
|
+
],
|
|
35
|
+
DKRD015: [
|
|
36
|
+
{ tool: "hadolint", rule: "DL4003", url: "https://github.com/hadolint/hadolint/wiki/DL4003", relation: "overlaps" },
|
|
37
|
+
{ tool: "hadolint", rule: "DL4004", url: "https://github.com/hadolint/hadolint/wiki/DL4004", relation: "overlaps" },
|
|
38
|
+
],
|
|
39
|
+
DKRD016: [
|
|
40
|
+
{ tool: "hadolint", rule: "DL3022", url: "https://github.com/hadolint/hadolint/wiki/DL3022", relation: "overlaps" },
|
|
41
|
+
],
|
|
42
|
+
DKRD018: [
|
|
43
|
+
{ tool: "hadolint", rule: "DL3000", url: "https://github.com/hadolint/hadolint/wiki/DL3000", relation: "overlaps" },
|
|
44
|
+
],
|
|
45
|
+
DKRD020: [
|
|
46
|
+
{ tool: "checkov", rule: "CKV_DOCKER_5", url: "https://www.checkov.io/5.Policy%20Index/dockerfile.html", relation: "overlaps" },
|
|
33
47
|
],
|
|
34
48
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD019: ADD of a remote URL without --checksum
|
|
3
|
+
*
|
|
4
|
+
* ADD fetches an http(s) URL at build time and trusts whatever comes back.
|
|
5
|
+
* `ADD --checksum=sha256:...` makes the build fail when the content changes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { eachDockerfile, stageLabel } from "./docker-helpers";
|
|
10
|
+
|
|
11
|
+
export const dkrd019: PostSynthCheck = {
|
|
12
|
+
id: "DKRD019",
|
|
13
|
+
description: "ADD fetches a remote URL without --checksum",
|
|
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 instr of stage.instructions) {
|
|
21
|
+
if (instr.instruction !== "ADD") continue;
|
|
22
|
+
const words = instr.value.split(/\s+/);
|
|
23
|
+
if (words.some((w) => w.startsWith("--checksum="))) continue;
|
|
24
|
+
const url = words.find((w) => /^https?:\/\//i.test(w));
|
|
25
|
+
if (!url) continue;
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
checkId: "DKRD019",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
message: `${fileName} ${stageLabel(stage)}: ADD ${url} has no --checksum, so the build accepts whatever the URL serves. Add --checksum=sha256:<digest>.`,
|
|
30
|
+
lexicon: "docker",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DKRD020: apt-get update in its own RUN
|
|
3
|
+
*
|
|
4
|
+
* A RUN that only runs `apt-get update` is cached as its own layer, so a
|
|
5
|
+
* later `apt-get install` in another RUN can install from a stale package
|
|
6
|
+
* index. Update and install in the same RUN.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { commandText, eachDockerfile, stageLabel } from "./docker-helpers";
|
|
11
|
+
|
|
12
|
+
const UPDATE = /\bapt(-get)?\s+update\b/;
|
|
13
|
+
const INSTALL = /\bapt(-get)?\s+(install|upgrade|dist-upgrade)\b/;
|
|
14
|
+
|
|
15
|
+
export const dkrd020: PostSynthCheck = {
|
|
16
|
+
id: "DKRD020",
|
|
17
|
+
description: "apt-get update in a different RUN from apt-get install — the install can use a stale package index",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const { fileName, parsed } of eachDockerfile(ctx.outputs)) {
|
|
23
|
+
for (const stage of parsed.stages) {
|
|
24
|
+
const runs = stage.instructions.filter((i) => i.instruction === "RUN");
|
|
25
|
+
const updateAt = runs.findIndex((r) => UPDATE.test(commandText(r)) && !INSTALL.test(commandText(r)));
|
|
26
|
+
if (updateAt === -1) continue;
|
|
27
|
+
if (!runs.slice(updateAt + 1).some((r) => INSTALL.test(commandText(r)))) continue;
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "DKRD020",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `${fileName} ${stageLabel(stage)}: apt-get update runs in its own RUN, and a later RUN installs from that cached index. Run update and install in one RUN: apt-get update && apt-get install -y --no-install-recommends ...`,
|
|
32
|
+
lexicon: "docker",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|