@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.
- 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 +703 -6
- 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 +444 -86
- 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/DockerConfig.md +7 -5
- package/dist/okf/types/DockerSecret.md +8 -4
- package/dist/okf/types/Dockerfile.md +4 -1
- package/dist/okf/types/Network.md +10 -6
- package/dist/okf/types/Service.md +93 -15
- package/dist/okf/types/Volume.md +5 -5
- 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 +65 -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 +93 -24
- package/dist/skills/chant-docker.md +43 -16
- package/dist/spec/parse-compose.d.ts +29 -2
- package/dist/spec/parse-compose.d.ts.map +1 -1
- 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 +344 -41
- package/package.json +3 -3
- package/src/codegen/__snapshots__/okf.test.ts.snap +278 -39
- package/src/codegen/docs.ts +14 -0
- package/src/codegen/generate.ts +128 -31
- package/src/codegen/okf.test.ts +1 -1
- package/src/dockerfile-order.ts +20 -0
- package/src/docs-examples.test.ts +141 -0
- package/src/generated/index.d.ts +344 -41
- package/src/generated/index.ts +396 -6
- package/src/generated/lexicon-docker.json +444 -86
- package/src/import/adapter.ts +8 -2
- package/src/import/generator.test.ts +4 -4
- package/src/import/generator.ts +53 -37
- package/src/import/parser.ts +82 -13
- package/src/import/roundtrip.test.ts +167 -2
- package/src/import/testdata/configs-secrets.yaml +15 -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 +65 -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 +93 -24
- package/src/skills/chant-docker.md +43 -16
- package/src/spec/parse-compose.ts +167 -95
- package/src/spec/parse-engine.ts +17 -5
- package/src/typecheck.e2e.test.ts +107 -0
- package/src/typed-props.test.ts +60 -0
|
@@ -39,15 +39,21 @@ export const nfsdata = new Volume({ driver: "local", driver_opts: { type: "nfs",
|
|
|
39
39
|
|
|
40
40
|
## Dockerfile — generates Dockerfile.{name}:
|
|
41
41
|
|
|
42
|
+
Each stage is `from` plus `steps`. A step is an object with one key naming one instruction. Steps are written exactly in the order listed.
|
|
43
|
+
|
|
42
44
|
```typescript
|
|
43
|
-
// Single
|
|
44
|
-
export const
|
|
45
|
+
// Single stage. Manifests before source, so the install layer is cached; root setup before USER.
|
|
46
|
+
export const server = new Dockerfile({
|
|
45
47
|
from: "node:20-alpine",
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
steps: [
|
|
49
|
+
{ workdir: "/app" },
|
|
50
|
+
{ copy: "package.json package-lock.json ./" },
|
|
51
|
+
{ run: "npm ci --omit=dev" },
|
|
52
|
+
{ copy: "src/ ./src/" },
|
|
53
|
+
{ user: "node" },
|
|
54
|
+
{ expose: 3000 },
|
|
55
|
+
{ cmd: ["node", "src/index.js"] }, // a list is written in JSON exec form
|
|
56
|
+
],
|
|
51
57
|
});
|
|
52
58
|
|
|
53
59
|
// Multi-stage build:
|
|
@@ -56,21 +62,37 @@ export const app = new Dockerfile({
|
|
|
56
62
|
{
|
|
57
63
|
from: "node:20-alpine",
|
|
58
64
|
as: "build",
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
steps: [
|
|
66
|
+
{ workdir: "/app" },
|
|
67
|
+
{ copy: "package.json package-lock.json ./" },
|
|
68
|
+
{ run: "npm ci" },
|
|
69
|
+
{ copy: ". ." },
|
|
70
|
+
{ run: "npm run build" },
|
|
71
|
+
{ run: "npm prune --omit=dev" },
|
|
72
|
+
],
|
|
62
73
|
},
|
|
63
74
|
{
|
|
64
75
|
from: "node:20-alpine",
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
steps: [
|
|
77
|
+
{ workdir: "/app" },
|
|
78
|
+
{ copy: "--from=build /app/node_modules ./node_modules" },
|
|
79
|
+
{ copy: "--from=build /app/dist ./dist" },
|
|
80
|
+
{ user: "node" },
|
|
81
|
+
{ cmd: ["node", "dist/index.js"] },
|
|
82
|
+
],
|
|
69
83
|
},
|
|
70
84
|
],
|
|
71
85
|
});
|
|
72
86
|
```
|
|
73
87
|
|
|
88
|
+
Step keys: `arg`, `env`, `workdir`, `user`, `run`, `copy`, `add`, `expose`, `volume`, `label`, `entrypoint`, `cmd`, `healthcheck`, `shell`, `stopsignal`, `onbuild`. `from` and `as` are stage fields, and `globalArgs: ["NAME=value"]` writes `ARG` lines before the first `FROM`. `run`, `cmd`, `entrypoint` and `shell` take a string or a list (exec form); `expose` takes a number too.
|
|
89
|
+
|
|
90
|
+
Rules:
|
|
91
|
+
- Use `steps`. The older per-kind fields (`run: [...]`, `copy: [...]`, `user`) are written in a fixed order (ARG, ENV, WORKDIR, USER, RUN, COPY, ADD, EXPOSE, VOLUME, LABEL, ENTRYPOINT, CMD, HEALTHCHECK), not the order written, so every RUN lands before every COPY. Lint rule DKRS002 flags this, and `chant lint --fix` rewrites them as steps.
|
|
92
|
+
- A stage can't have both `steps` and per-kind fields, and a step can't name two instructions. Both are build errors.
|
|
93
|
+
- Props are type-checked: `Dockerfile` takes `DockerfileProps` and the Compose constructors take `ServiceProps`, `VolumeProps`, `NetworkProps`, `DockerConfigProps` and `DockerSecretProps`. A misspelled key or a wrong value type is a TypeScript error, so run `tsc` (or read the editor's errors) before `chant build`.
|
|
94
|
+
- To share stages or steps between Dockerfiles in different modules, export a `const` stage (`DockerfileStage`) or step list (`DockerfileStep[]`) and import it. List the stage in `stages`, or spread the steps: `steps: [...nodeRuntime, { cmd: ["node", "dist/server.js"] }]`. This is chant's equivalent of an INCLUDE. Spreading a function call's result is a lint error (EVL004); put parameterized Dockerfiles in a `Composite`.
|
|
95
|
+
|
|
74
96
|
## Variable interpolation with env():
|
|
75
97
|
|
|
76
98
|
```typescript
|
|
@@ -112,7 +134,10 @@ export const api = new Service({
|
|
|
112
134
|
},
|
|
113
135
|
ports: ["8080:8080"],
|
|
114
136
|
});
|
|
115
|
-
export const builder = new Dockerfile({
|
|
137
|
+
export const builder = new Dockerfile({
|
|
138
|
+
from: "node:20-alpine",
|
|
139
|
+
steps: [{ workdir: "/app" }, { copy: ". ." }, { run: "npm ci" }, { user: "node" }, { cmd: ["node", "index.js"] }],
|
|
140
|
+
});
|
|
116
141
|
```
|
|
117
142
|
|
|
118
143
|
## Output structure
|
|
@@ -126,4 +151,6 @@ export const builder = new Dockerfile({ from: "node:20-alpine", ... });
|
|
|
126
151
|
- `DKRD001`: Post-synth: no `:latest` images in generated YAML
|
|
127
152
|
- `DKRD002`: Post-synth: no unused named volumes
|
|
128
153
|
- `DKRD003`: Post-synth: SSH port 22 not exposed externally
|
|
129
|
-
- `
|
|
154
|
+
- `DKRS002`: Dockerfile per-kind fields declared in an order the fixed order changes; use `steps`
|
|
155
|
+
- `DKRD010–012`: Dockerfile best practices (apt recommends, COPY vs ADD, final stage USER)
|
|
156
|
+
- `DKRD013–020`: order-aware Dockerfile checks: COPY . before a dependency install, root-only RUN after USER, repeated CMD/ENTRYPOINT, COPY --from an undefined stage, unused ARG before FROM, relative WORKDIR, ADD of a URL without --checksum, apt-get update in its own RUN
|
|
@@ -22,102 +22,174 @@ export interface ComposeProperty {
|
|
|
22
22
|
enum?: string[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The TypeScript type of a Compose string slot. Compose interpolates every
|
|
27
|
+
* value, so `env()` fits where a string does; an entity (a service, network,
|
|
28
|
+
* volume, config, secret or Dockerfile) is written as its name.
|
|
29
|
+
*/
|
|
30
|
+
export const COMPOSE_STRING = "ComposeString";
|
|
31
|
+
|
|
32
|
+
interface JsonSchema {
|
|
33
|
+
$ref?: string;
|
|
34
|
+
type?: string | string[];
|
|
35
|
+
description?: string;
|
|
36
|
+
enum?: unknown[];
|
|
37
|
+
const?: unknown;
|
|
38
|
+
properties?: Record<string, JsonSchema>;
|
|
39
|
+
patternProperties?: Record<string, JsonSchema>;
|
|
40
|
+
additionalProperties?: boolean | JsonSchema;
|
|
41
|
+
required?: string[];
|
|
42
|
+
items?: JsonSchema;
|
|
43
|
+
oneOf?: JsonSchema[];
|
|
44
|
+
anyOf?: JsonSchema[];
|
|
45
|
+
allOf?: JsonSchema[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ENTITIES: Array<{ def: string; shortName: string; description: string }> = [
|
|
49
|
+
{ def: "service", shortName: "Service", description: "A containerized service definition in Docker Compose" },
|
|
50
|
+
{ def: "volume", shortName: "Volume", description: "A named volume definition in Docker Compose" },
|
|
51
|
+
{ def: "network", shortName: "Network", description: "A network definition in Docker Compose" },
|
|
52
|
+
{ def: "config", shortName: "Config", description: "A config definition in Docker Compose" },
|
|
53
|
+
{ def: "secret", shortName: "Secret", description: "A secret definition in Docker Compose" },
|
|
54
|
+
];
|
|
55
|
+
|
|
25
56
|
/**
|
|
26
57
|
* Parse the Compose Spec JSON Schema buffer into ParsedResult entries.
|
|
27
|
-
* Returns one entry per top-level Compose resource type
|
|
58
|
+
* Returns one entry per top-level Compose resource type, with a property for
|
|
59
|
+
* every field the spec defines on it (#3934). Each property's `type` is a
|
|
60
|
+
* TypeScript type converted from the schema; string slots use
|
|
61
|
+
* {@link COMPOSE_STRING}.
|
|
28
62
|
*/
|
|
29
|
-
export function parseComposeSpec(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
63
|
+
export function parseComposeSpec(data: Buffer): ComposeParseResult[] {
|
|
64
|
+
const spec = JSON.parse(data.toString("utf-8")) as { $defs?: Record<string, JsonSchema>; definitions?: Record<string, JsonSchema> };
|
|
65
|
+
const defs = spec.$defs ?? spec.definitions ?? {};
|
|
66
|
+
|
|
67
|
+
return ENTITIES.map(({ def, shortName, description }) => {
|
|
68
|
+
const schema = defs[def];
|
|
69
|
+
const required = new Set(schema?.required ?? []);
|
|
70
|
+
const properties: ComposeProperty[] = Object.entries(schema?.properties ?? {}).map(([name, prop]) => ({
|
|
71
|
+
name,
|
|
72
|
+
type: toTsType(prop, defs, new Set([def])),
|
|
73
|
+
...(prop.description ? { description: oneLine(prop.description) } : {}),
|
|
74
|
+
...(required.has(name) ? { required: true } : {}),
|
|
75
|
+
}));
|
|
76
|
+
return { typeName: `Docker::Compose::${shortName}`, shortName, description, isResource: true, properties };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function oneLine(text: string): string {
|
|
81
|
+
return text.replace(/\s+/g, " ").replace(/\*\//g, "* /").trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A property name as a TypeScript key: bare when it is an identifier. */
|
|
85
|
+
function key(name: string): string {
|
|
86
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Convert one JSON Schema node to a TypeScript type. `seen` guards $ref cycles. */
|
|
90
|
+
export function toTsType(schema: JsonSchema | boolean | undefined, defs: Record<string, JsonSchema>, seen: Set<string> = new Set()): string {
|
|
91
|
+
if (schema === undefined || schema === true) return "unknown";
|
|
92
|
+
if (schema === false) return "never";
|
|
93
|
+
|
|
94
|
+
if (schema.$ref) {
|
|
95
|
+
const name = schema.$ref.replace(/^#\/(\$defs|definitions)\//, "");
|
|
96
|
+
const target = defs[name];
|
|
97
|
+
if (!target || seen.has(name)) return "unknown";
|
|
98
|
+
return toTsType(target, defs, new Set([...seen, name]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const alts = schema.oneOf ?? schema.anyOf;
|
|
102
|
+
if (alts) {
|
|
103
|
+
// A oneOf beside a type or properties narrows them; the alternatives carry the shape.
|
|
104
|
+
return union(alts.map((a) => toTsType(a, defs, seen)));
|
|
105
|
+
}
|
|
106
|
+
if (schema.allOf) {
|
|
107
|
+
const parts = schema.allOf.map((a) => toTsType(a, defs, seen)).filter((t) => t !== "unknown");
|
|
108
|
+
return parts.length ? parts.map(paren).join(" & ") : "unknown";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (schema.const !== undefined) return JSON.stringify(schema.const);
|
|
112
|
+
if (schema.enum) return union(schema.enum.map((v) => JSON.stringify(v)));
|
|
113
|
+
|
|
114
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
|
|
115
|
+
if (types.length === 0) {
|
|
116
|
+
if (schema.properties || schema.patternProperties || schema.additionalProperties !== undefined) return objectType(schema, defs, seen);
|
|
117
|
+
if (schema.items) return `Array<${toTsType(schema.items, defs, seen)}>`;
|
|
118
|
+
return "unknown";
|
|
119
|
+
}
|
|
120
|
+
return union(
|
|
121
|
+
types.map((t) => {
|
|
122
|
+
switch (t) {
|
|
123
|
+
case "string":
|
|
124
|
+
return COMPOSE_STRING;
|
|
125
|
+
case "number":
|
|
126
|
+
case "integer":
|
|
127
|
+
return "number";
|
|
128
|
+
case "boolean":
|
|
129
|
+
return "boolean";
|
|
130
|
+
case "null":
|
|
131
|
+
return "null";
|
|
132
|
+
case "array":
|
|
133
|
+
return `Array<${schema.items ? toTsType(schema.items, defs, seen) : "unknown"}>`;
|
|
134
|
+
case "object":
|
|
135
|
+
return objectType(schema, defs, seen);
|
|
136
|
+
default:
|
|
137
|
+
return "unknown";
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function objectType(schema: JsonSchema, defs: Record<string, JsonSchema>, seen: Set<string>): string {
|
|
144
|
+
const props = Object.entries(schema.properties ?? {});
|
|
145
|
+
const patterns = Object.entries(schema.patternProperties ?? {});
|
|
146
|
+
const extensions = patterns.some(([p]) => p === "^x-");
|
|
147
|
+
const others = patterns.filter(([p]) => p !== "^x-");
|
|
148
|
+
const additional = schema.additionalProperties;
|
|
149
|
+
|
|
150
|
+
if (props.length === 0) {
|
|
151
|
+
const values = others.map(([, v]) => toTsType(v, defs, seen));
|
|
152
|
+
if (additional && additional !== true) values.push(toTsType(additional, defs, seen));
|
|
153
|
+
if (values.length) return `Record<string, ${union(values)}>`;
|
|
154
|
+
return additional === false && !extensions ? "Record<string, never>" : "Record<string, unknown>";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const required = new Set(schema.required ?? []);
|
|
158
|
+
const fields = props.map(([name, p]) => `${key(name)}${required.has(name) ? "" : "?"}: ${toTsType(p, defs, seen)}`);
|
|
159
|
+
if (extensions) fields.push("[key: `x-${string}`]: unknown");
|
|
160
|
+
// Free-form keys beside the named ones: the named ones keep their types.
|
|
161
|
+
if (others.length || (additional && additional !== true)) fields.push("[key: string]: unknown");
|
|
162
|
+
return `{ ${fields.join("; ")} }`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function paren(t: string): string {
|
|
166
|
+
return /[|&]/.test(t) && !/^[{(]/.test(t) ? `(${t})` : t;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function union(types: string[]): string {
|
|
170
|
+
const unique = [...new Set(types.flatMap(splitTop))];
|
|
171
|
+
if (unique.includes("unknown")) return "unknown";
|
|
172
|
+
return unique.join(" | ");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The members of a union type, split only at the top level. */
|
|
176
|
+
function splitTop(type: string): string[] {
|
|
177
|
+
const out: string[] = [];
|
|
178
|
+
let depth = 0;
|
|
179
|
+
let quote = false;
|
|
180
|
+
let start = 0;
|
|
181
|
+
for (let i = 0; i < type.length; i++) {
|
|
182
|
+
const c = type[i];
|
|
183
|
+
if (c === '"') quote = !quote;
|
|
184
|
+
if (quote) continue;
|
|
185
|
+
if (c === "{" || c === "(" || c === "<" || c === "[") depth++;
|
|
186
|
+
else if (c === "}" || c === ")" || c === ">" || c === "]") depth--;
|
|
187
|
+
else if (depth === 0 && type.startsWith(" | ", i)) {
|
|
188
|
+
out.push(type.slice(start, i));
|
|
189
|
+
start = i + 3;
|
|
190
|
+
i += 2;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
out.push(type.slice(start));
|
|
194
|
+
return out;
|
|
123
195
|
}
|
package/src/spec/parse-engine.ts
CHANGED
|
@@ -15,7 +15,16 @@ export interface DockerfileParseResult {
|
|
|
15
15
|
export interface DockerfileInstruction {
|
|
16
16
|
name: string;
|
|
17
17
|
description?: string;
|
|
18
|
-
multi?: boolean; // can appear multiple times
|
|
18
|
+
multi?: boolean; // can appear multiple times (as a per-kind field, an array)
|
|
19
|
+
/**
|
|
20
|
+
* Only available as a step (`{ shell: ... }` in `steps`), not as a per-kind
|
|
21
|
+
* field. The per-kind fields keep the set and fixed order they always had.
|
|
22
|
+
*/
|
|
23
|
+
stepOnly?: boolean;
|
|
24
|
+
/** As a step, the value may be a JSON exec-form array as well as a string. */
|
|
25
|
+
execForm?: boolean;
|
|
26
|
+
/** As a step, the value may be a number as well as a string. */
|
|
27
|
+
numeric?: boolean;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
|
@@ -31,17 +40,20 @@ export function parseEngineApi(_data: Buffer): DockerfileParseResult {
|
|
|
31
40
|
{ name: "from", description: "Base image (required first instruction)" },
|
|
32
41
|
{ name: "arg", description: "Build-time argument", multi: true },
|
|
33
42
|
{ name: "env", description: "Environment variable", multi: true },
|
|
34
|
-
{ name: "run", description: "Run a command during build", multi: true },
|
|
43
|
+
{ name: "run", description: "Run a command during build", multi: true, execForm: true },
|
|
35
44
|
{ name: "copy", description: "Copy files from build context", multi: true },
|
|
36
45
|
{ name: "add", description: "Add files (supports URLs and archives)", multi: true },
|
|
37
46
|
{ name: "workdir", description: "Set working directory" },
|
|
38
47
|
{ name: "user", description: "Set user for subsequent instructions" },
|
|
39
|
-
{ name: "expose", description: "Document exposed ports", multi: true },
|
|
48
|
+
{ name: "expose", description: "Document exposed ports", multi: true, numeric: true },
|
|
40
49
|
{ name: "volume", description: "Create mount points", multi: true },
|
|
41
50
|
{ name: "label", description: "Add metadata labels", multi: true },
|
|
42
|
-
{ name: "entrypoint", description: "Set the entrypoint executable" },
|
|
43
|
-
{ name: "cmd", description: "Default command arguments" },
|
|
51
|
+
{ name: "entrypoint", description: "Set the entrypoint executable", execForm: true },
|
|
52
|
+
{ name: "cmd", description: "Default command arguments", execForm: true },
|
|
44
53
|
{ name: "healthcheck", description: "Health check instruction" },
|
|
54
|
+
{ name: "shell", description: "Set the shell for shell-form commands (JSON array form)", stepOnly: true, execForm: true },
|
|
55
|
+
{ name: "stopsignal", description: "Signal sent to stop the container", stepOnly: true },
|
|
56
|
+
{ name: "onbuild", description: "Instruction to run when the image is used as a base", stepOnly: true },
|
|
45
57
|
],
|
|
46
58
|
};
|
|
47
59
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker TypeScript that the repo typecheck can't see type-checks against the
|
|
3
|
+
* lexicon (#3934): the examples in the docs and skills, and the source
|
|
4
|
+
* `chant import` writes for the compose fixtures. The constructors take their
|
|
5
|
+
* props types, so an example with a misspelled field or a wrong value type
|
|
6
|
+
* fails here rather than in a reader's editor. Compiling them takes longer
|
|
7
|
+
* than the unit-test budget allows, so this runs with the e2e tests.
|
|
8
|
+
*
|
|
9
|
+
* A snippet that leaves out its import line is checked as if it imported the
|
|
10
|
+
* lexicon names it uses; a titled block or a `// src/x.ts` module block is
|
|
11
|
+
* placed at that path so the page's relative imports resolve.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect } from "vitest";
|
|
15
|
+
import { readFileSync, readdirSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import * as ts from "typescript";
|
|
18
|
+
import * as lexicon from "./index";
|
|
19
|
+
import { DockerParser } from "./import/parser";
|
|
20
|
+
import { DockerGenerator } from "./import/generator";
|
|
21
|
+
|
|
22
|
+
const root = join(import.meta.dirname, "..");
|
|
23
|
+
const repoRoot = join(root, "..", "..");
|
|
24
|
+
const virtualRoot = join(root, ".typecheck");
|
|
25
|
+
|
|
26
|
+
interface Block {
|
|
27
|
+
lang: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
code: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function blocks(file: string): Block[] {
|
|
33
|
+
const text = readFileSync(join(root, file), "utf8");
|
|
34
|
+
return [...text.matchAll(/^```(\w+)([^\n]*)\n([\s\S]*?)^```$/gm)].map((m) => {
|
|
35
|
+
const title = m[2]!.match(/title="([^"]+)"/)?.[1];
|
|
36
|
+
return { lang: m[1]!, ...(title ? { title } : {}), code: m[3]! };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Type errors in in-memory files, compiled against the lexicon's source with the repo typecheck's options. */
|
|
41
|
+
function typeErrors(files: Map<string, string>): string[] {
|
|
42
|
+
const config = ts.readConfigFile(join(repoRoot, "tsconfig.typecheck.json"), ts.sys.readFile);
|
|
43
|
+
const { options } = ts.parseJsonConfigFileContent(config.config, ts.sys, repoRoot);
|
|
44
|
+
const checkOptions = { ...options, noEmit: true, types: [], skipLibCheck: true, declaration: false, declarationMap: false, sourceMap: false };
|
|
45
|
+
const host = ts.createCompilerHost(checkOptions);
|
|
46
|
+
const getSourceFile = host.getSourceFile.bind(host);
|
|
47
|
+
host.getSourceFile = (name, lang, ...rest) =>
|
|
48
|
+
files.has(name) ? ts.createSourceFile(name, files.get(name)!, lang) : getSourceFile(name, lang, ...rest);
|
|
49
|
+
const fileExists = host.fileExists.bind(host);
|
|
50
|
+
host.fileExists = (name) => files.has(name) || fileExists(name);
|
|
51
|
+
const directoryExists = host.directoryExists?.bind(host);
|
|
52
|
+
host.directoryExists = (dir) => [...files.keys()].some((f) => f.startsWith(`${dir}/`)) || (directoryExists?.(dir) ?? false);
|
|
53
|
+
const readFile = host.readFile.bind(host);
|
|
54
|
+
host.readFile = (name) => files.get(name) ?? readFile(name);
|
|
55
|
+
|
|
56
|
+
const program = ts.createProgram([...files.keys()], checkOptions, host);
|
|
57
|
+
// Only the in-memory files are checked; the lexicon itself is the repo typecheck's job.
|
|
58
|
+
return [...files.keys()]
|
|
59
|
+
.flatMap((f) => {
|
|
60
|
+
const sf = program.getSourceFile(f)!;
|
|
61
|
+
return [...program.getSyntacticDiagnostics(sf), ...program.getSemanticDiagnostics(sf)];
|
|
62
|
+
})
|
|
63
|
+
.map((d) => {
|
|
64
|
+
const { line } = d.file!.getLineAndCharacterOfPosition(d.start ?? 0);
|
|
65
|
+
return `${d.file!.fileName.slice(virtualRoot.length + 1)}:${line + 1}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("docker TypeScript outside the repo typecheck type-checks", () => {
|
|
70
|
+
test("every docker example in the docs and skills", () => {
|
|
71
|
+
const pages = [
|
|
72
|
+
...readdirSync(join(root, "docs/pages")).filter((f) => f.endsWith(".mdx")).map((f) => `docs/pages/${f}`),
|
|
73
|
+
...readdirSync(join(root, "src/skills")).filter((f) => f.endsWith(".md")).map((f) => `src/skills/${f}`),
|
|
74
|
+
];
|
|
75
|
+
const lexiconNames = Object.keys(lexicon);
|
|
76
|
+
const typeNames = ["DockerfileProps", "DockerfileStage", "DockerfileStep", "DockerfileInstructionName", "ServiceProps", "VolumeProps", "NetworkProps", "DockerConfigProps", "DockerSecretProps"];
|
|
77
|
+
const files = new Map<string, string>();
|
|
78
|
+
for (const page of pages) {
|
|
79
|
+
blocks(page).forEach((b, i) => {
|
|
80
|
+
if (b.lang !== "typescript") return;
|
|
81
|
+
const modulePath = b.title ?? b.code.match(/^\/\/ (src\/\S+\.ts)\s*$/m)?.[1];
|
|
82
|
+
if (!modulePath && !/@intentius\/chant-lexicon-docker|new (Dockerfile|Service|Volume|Network|DockerConfig|DockerSecret)\(/.test(b.code)) return;
|
|
83
|
+
// A snippet sits one directory below src/, so `../web/main` and `./shared/node` reach the page's modules.
|
|
84
|
+
const file = join(virtualRoot, page.replace(/[/.]/g, "_"), modulePath ?? `src/snippets/block-${i + 1}.ts`);
|
|
85
|
+
let code = b.code;
|
|
86
|
+
if (!code.includes("@intentius/chant-lexicon-docker")) {
|
|
87
|
+
const used = [...lexiconNames, ...typeNames].filter((n) => new RegExp(`\\b${n}\\b`).test(code));
|
|
88
|
+
if (used.length) code = `import { ${used.join(", ")} } from "@intentius/chant-lexicon-docker";\n${code}`;
|
|
89
|
+
}
|
|
90
|
+
files.set(file, `${code}\nexport {};\n`);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
expect(files.size).toBeGreaterThan(20);
|
|
94
|
+
expect(typeErrors(files)).toEqual([]);
|
|
95
|
+
}, 120_000);
|
|
96
|
+
|
|
97
|
+
test("what importing the compose fixtures writes", () => {
|
|
98
|
+
const testdata = join(root, "src/import/testdata");
|
|
99
|
+
const files = new Map<string, string>();
|
|
100
|
+
for (const fixture of readdirSync(testdata).filter((f) => f.endsWith(".yaml"))) {
|
|
101
|
+
const { source } = new DockerGenerator().generate(new DockerParser().parse(readFileSync(join(testdata, fixture), "utf8")).entities);
|
|
102
|
+
files.set(join(virtualRoot, "import", `${fixture.replace(/\.yaml$/, "")}.ts`), source);
|
|
103
|
+
}
|
|
104
|
+
expect(files.size).toBeGreaterThanOrEqual(4);
|
|
105
|
+
expect(typeErrors(files)).toEqual([]);
|
|
106
|
+
}, 120_000);
|
|
107
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The docker constructors take their props types (#3934). The repo typecheck
|
|
3
|
+
* (scripts/typecheck.ts) compiles this file, so each `@ts-expect-error` below
|
|
4
|
+
* fails it if the line it marks stops being a type error: a misspelled key or
|
|
5
|
+
* a wrong-typed field must not compile. The tests themselves only run the
|
|
6
|
+
* well-typed declarations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { Dockerfile, Service, Volume, Network, DockerConfig, DockerSecret, env } from "./index";
|
|
11
|
+
import type { DockerfileStep } from "./index";
|
|
12
|
+
|
|
13
|
+
describe("docker props are typed", () => {
|
|
14
|
+
test("well-typed props construct", () => {
|
|
15
|
+
const settings = new DockerConfig({ file: "./config/app.conf" });
|
|
16
|
+
const token = new DockerSecret({ file: "./secrets/token.txt" });
|
|
17
|
+
const data = new Volume({ driver: "local" });
|
|
18
|
+
const backend = new Network({ internal: true });
|
|
19
|
+
const app = new Dockerfile({ from: "node:20-alpine", steps: [{ workdir: "/app" }, { run: "npm ci" }] });
|
|
20
|
+
const api = new Service({
|
|
21
|
+
build: { context: ".", dockerfile: app },
|
|
22
|
+
image: env("API_IMAGE", { default: "api:latest" }),
|
|
23
|
+
ports: ["8080:8080", { target: 8080, published: "8080" }],
|
|
24
|
+
configs: [{ source: settings, target: "/etc/app.conf" }],
|
|
25
|
+
secrets: [token],
|
|
26
|
+
networks: [backend],
|
|
27
|
+
volumes: ["data:/var/lib/data"],
|
|
28
|
+
depends_on: { db: { condition: "service_healthy" } },
|
|
29
|
+
});
|
|
30
|
+
for (const entity of [settings, token, data, backend, app, api]) {
|
|
31
|
+
expect(entity).toHaveProperty("entityType");
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("misspelled keys and wrong-typed fields are type errors", () => {
|
|
36
|
+
// Only the type checker reads these; they are never constructed.
|
|
37
|
+
const declare = (): unknown[] => [
|
|
38
|
+
// @ts-expect-error `rnu` is not a Dockerfile instruction
|
|
39
|
+
new Dockerfile({ from: "alpine:3.20", steps: [{ rnu: "npm ci" }] }),
|
|
40
|
+
// @ts-expect-error RUN takes a string
|
|
41
|
+
new Dockerfile({ from: "alpine:3.20", steps: [{ run: 42 }] }),
|
|
42
|
+
// @ts-expect-error `form` is not a Dockerfile field
|
|
43
|
+
new Dockerfile({ form: "alpine:3.20" }),
|
|
44
|
+
// @ts-expect-error `imgae` is not a service field
|
|
45
|
+
new Service({ imgae: "nginx:1.27" }),
|
|
46
|
+
// @ts-expect-error `restart` is a string
|
|
47
|
+
new Service({ image: "nginx:1.27", restart: 3 }),
|
|
48
|
+
// @ts-expect-error `condition` is one of the wait conditions
|
|
49
|
+
new Service({ depends_on: { db: { condition: "healthy" } } }),
|
|
50
|
+
// @ts-expect-error `internal` is a boolean
|
|
51
|
+
new Network({ internal: [] }),
|
|
52
|
+
// @ts-expect-error `fiel` is not a config field
|
|
53
|
+
new DockerConfig({ fiel: "./app.conf" }),
|
|
54
|
+
];
|
|
55
|
+
// @ts-expect-error a step names one instruction it knows
|
|
56
|
+
const step: DockerfileStep = { from: "alpine" };
|
|
57
|
+
expect(typeof declare).toBe("function");
|
|
58
|
+
expect(step).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
});
|