@intentius/chant-lexicon-docker 0.124.31 → 0.124.32
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-cli.js +7 -0
- package/dist/codegen/docs.js +163 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-compose.js +16 -0
- package/dist/codegen/generate-dockerfile.js +16 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/generate.js +212 -0
- package/dist/codegen/naming.js +45 -0
- package/dist/codegen/package.js +40 -0
- package/dist/codegen/patches.js +31 -0
- package/dist/codegen/versions.js +41 -0
- package/dist/compose-key.d.ts +19 -0
- package/dist/compose-key.d.ts.map +1 -0
- package/dist/compose-key.js +26 -0
- package/dist/composites/catalog.js +113 -0
- package/dist/composites/docker-otel-collector.js +43 -0
- package/dist/composites/docker-web-service.js +51 -0
- package/dist/composites/index.js +6 -0
- package/dist/coverage.js +35 -0
- package/dist/default-labels.js +52 -0
- package/dist/detect.js +8 -0
- package/dist/dockerfile-order.js +16 -0
- package/dist/generated/index.d.ts +10 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +8 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/import/adapter.d.ts.map +1 -1
- package/dist/import/adapter.js +86 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/generator.js +159 -0
- package/dist/import/parser.d.ts +31 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/parser.js +181 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -0
- package/dist/integrity.json +3 -3
- package/dist/interpolation.js +54 -0
- package/dist/lint/audit-catalog.js +25 -0
- package/dist/lint/audit-lineage.js +39 -0
- package/dist/lint/post-synth/add-url-checksum.js +35 -0
- package/dist/lint/post-synth/apt-no-recommends.js +34 -0
- package/dist/lint/post-synth/apt-update-alone.js +34 -0
- package/dist/lint/post-synth/cache-busting-copy.js +61 -0
- package/dist/lint/post-synth/copy-from-stage.js +53 -0
- package/dist/lint/post-synth/docker-helpers.js +148 -0
- package/dist/lint/post-synth/index.js +30 -0
- package/dist/lint/post-synth/no-latest-image.js +30 -0
- package/dist/lint/post-synth/no-root-user.js +35 -0
- package/dist/lint/post-synth/prefer-copy.js +43 -0
- package/dist/lint/post-synth/relative-workdir.js +34 -0
- package/dist/lint/post-synth/repeated-cmd.js +30 -0
- package/dist/lint/post-synth/root-after-user.js +39 -0
- package/dist/lint/post-synth/ssh-port-exposed.js +58 -0
- package/dist/lint/post-synth/unused-global-arg.js +34 -0
- package/dist/lint/post-synth/unused-volume.js +41 -0
- package/dist/lint/rules/data/deprecated-images.js +26 -0
- package/dist/lint/rules/data/known-base-images.js +18 -0
- package/dist/lint/rules/index.js +5 -0
- package/dist/lint/rules/no-latest-tag.js +57 -0
- package/dist/lint/rules/per-kind-order.js +129 -0
- package/dist/list-artifacts.js +133 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +229 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +390 -0
- package/dist/spec/fetch-compose.js +28 -0
- package/dist/spec/fetch-engine.js +19 -0
- package/dist/spec/parse-compose.js +160 -0
- package/dist/spec/parse-engine.js +36 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +37 -0
- package/dist/variables.js +42 -0
- package/package.json +8 -8
- package/src/codegen/generate.test.ts +10 -0
- package/src/codegen/generate.ts +3 -0
- package/src/codegen/package.ts +2 -2
- package/src/compose-key.ts +31 -0
- package/src/generated/index.d.ts +10 -0
- package/src/generated/index.ts +10 -0
- package/src/import/adapter.ts +2 -1
- package/src/import/generator.test.ts +5 -4
- package/src/import/generator.ts +75 -48
- package/src/import/parser.ts +58 -65
- package/src/import/roundtrip.test.ts +46 -3
- package/src/import/testdata/names-and-fields.yaml +44 -0
- package/src/index.ts +3 -0
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
- package/src/serializer.test.ts +31 -0
- package/src/serializer.ts +15 -2
package/src/import/generator.ts
CHANGED
|
@@ -19,33 +19,26 @@ export class DockerGenerator {
|
|
|
19
19
|
const warnings: string[] = [];
|
|
20
20
|
const imports = new Set<string>();
|
|
21
21
|
const lines: string[] = [];
|
|
22
|
+
const names = new ExportNames();
|
|
22
23
|
|
|
23
24
|
for (const entity of entities) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
case "secret":
|
|
42
|
-
imports.add("DockerSecret");
|
|
43
|
-
lines.push(generateSecret(entity));
|
|
44
|
-
break;
|
|
45
|
-
case "dockerfile":
|
|
46
|
-
imports.add("Dockerfile");
|
|
47
|
-
lines.push(generateDockerfile(entity, warnings));
|
|
48
|
-
break;
|
|
25
|
+
if (entity.kind === "dockerfile") {
|
|
26
|
+
imports.add("Dockerfile");
|
|
27
|
+
lines.push(generateDockerfile(entity, warnings));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const ctor = CONSTRUCTORS[entity.kind];
|
|
31
|
+
imports.add(ctor);
|
|
32
|
+
const exportName = names.take(entity.name, entity.kind);
|
|
33
|
+
const expr = `new ${ctor}(${propsLiteral(entity)})`;
|
|
34
|
+
// An export name that differs from the compose key would rename the
|
|
35
|
+
// key on build, and every reference to it by name would dangle, so
|
|
36
|
+
// the key is set explicitly (#3937).
|
|
37
|
+
if (exportName === entity.name) {
|
|
38
|
+
lines.push(`export const ${exportName} = ${expr};`);
|
|
39
|
+
} else {
|
|
40
|
+
imports.add("composeKey");
|
|
41
|
+
lines.push(`export const ${exportName} = composeKey(${JSON.stringify(entity.name)}, ${expr});`);
|
|
49
42
|
}
|
|
50
43
|
}
|
|
51
44
|
|
|
@@ -56,30 +49,56 @@ export class DockerGenerator {
|
|
|
56
49
|
}
|
|
57
50
|
}
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return `export const ${sanitizeName(net.name)} = new Network(${hasProps ? JSON.stringify(net.props) : "{}"});`;
|
|
52
|
+
const CONSTRUCTORS = {
|
|
53
|
+
service: "Service",
|
|
54
|
+
volume: "Volume",
|
|
55
|
+
network: "Network",
|
|
56
|
+
config: "DockerConfig",
|
|
57
|
+
secret: "DockerSecret",
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
function propsLiteral(entity: ServiceIR | VolumeIR | NetworkIR | ConfigIR | SecretIR): string {
|
|
61
|
+
if (entity.kind === "service") {
|
|
62
|
+
return JSON.stringify(entity.props, null, 2).replace(/"([a-z_][a-z0-9_]*)":/g, "$1:");
|
|
63
|
+
}
|
|
64
|
+
return Object.keys(entity.props).length > 0 ? JSON.stringify(entity.props) : "{}";
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
67
|
+
/** Words an export cannot be named, and the names the generated file imports. */
|
|
68
|
+
const RESERVED = new Set([
|
|
69
|
+
"break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do",
|
|
70
|
+
"else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in",
|
|
71
|
+
"instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof",
|
|
72
|
+
"var", "void", "while", "with", "yield", "let", "static", "implements", "interface", "package",
|
|
73
|
+
"private", "protected", "public", "await", "arguments", "eval", "undefined", "NaN", "Infinity",
|
|
74
|
+
"Service", "Volume", "Network", "DockerConfig", "DockerSecret", "Dockerfile", "composeKey",
|
|
75
|
+
]);
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Export names for compose keys: identifiers, unique within the file. A key
|
|
79
|
+
* shared by two kinds (a service and a volume both named `db`) gives the
|
|
80
|
+
* second one a kind suffix (`dbVolume`).
|
|
81
|
+
*/
|
|
82
|
+
class ExportNames {
|
|
83
|
+
private readonly used = new Set<string>();
|
|
84
|
+
|
|
85
|
+
take(key: string, kind: string): string {
|
|
86
|
+
const base = sanitizeName(key);
|
|
87
|
+
const suffixed = `${base}${kind[0]!.toUpperCase()}${kind.slice(1)}`;
|
|
88
|
+
for (const c of [base, suffixed]) {
|
|
89
|
+
if (!RESERVED.has(c) && !this.used.has(c)) {
|
|
90
|
+
this.used.add(c);
|
|
91
|
+
return c;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (let i = 2; ; i++) {
|
|
95
|
+
const c = `${suffixed}${i}`;
|
|
96
|
+
if (!this.used.has(c)) {
|
|
97
|
+
this.used.add(c);
|
|
98
|
+
return c;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
/** The instructions a step can name; anything else (MAINTAINER) is left as a comment. */
|
|
@@ -137,7 +156,15 @@ function quote(value: string): string {
|
|
|
137
156
|
return JSON.stringify(value);
|
|
138
157
|
}
|
|
139
158
|
|
|
159
|
+
/**
|
|
160
|
+
* A TypeScript identifier for a compose key: kebab, snake and dotted keys
|
|
161
|
+
* become camelCase, other characters are dropped, and a leading digit gets
|
|
162
|
+
* an underscore.
|
|
163
|
+
*/
|
|
140
164
|
function sanitizeName(name: string): string {
|
|
141
|
-
|
|
142
|
-
|
|
165
|
+
let id = name.replace(/[^A-Za-z0-9$]+([A-Za-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
166
|
+
id = id.replace(/[^A-Za-z0-9_$]/g, "");
|
|
167
|
+
if (id === "") id = "entity";
|
|
168
|
+
if (/^[0-9]/.test(id)) id = `_${id}`;
|
|
169
|
+
return id;
|
|
143
170
|
}
|
package/src/import/parser.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* to an intermediate representation (IR) for TypeScript code generation.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
7
10
|
|
|
8
11
|
export interface ServiceIR {
|
|
@@ -61,26 +64,58 @@ export interface ParseResult {
|
|
|
61
64
|
warnings: string[];
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
/** The compose sections import reads, with the kind each holds and its entry in the lexicon. */
|
|
68
|
+
const SECTIONS = [
|
|
69
|
+
{ section: "services", kind: "service", entry: "Service" },
|
|
70
|
+
{ section: "volumes", kind: "volume", entry: "Volume" },
|
|
71
|
+
{ section: "networks", kind: "network", entry: "Network" },
|
|
72
|
+
{ section: "configs", kind: "config", entry: "DockerConfig" },
|
|
73
|
+
{ section: "secrets", kind: "secret", entry: "DockerSecret" },
|
|
68
74
|
] as const;
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
const NETWORK_PROPS = ["driver", "external", "attachable", "labels"] as const;
|
|
72
|
-
const CONFIG_PROPS = ["file", "external"] as const;
|
|
73
|
-
const SECRET_PROPS = ["file", "external"] as const;
|
|
76
|
+
type ComposeKind = (typeof SECTIONS)[number]["kind"];
|
|
74
77
|
|
|
78
|
+
let fieldsCache: Map<ComposeKind, Set<string>> | undefined;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The fields the Compose spec defines for each kind, read from the generated
|
|
82
|
+
* lexicon (`lexicon-docker.json`, or `dist/meta.json` in a package built
|
|
83
|
+
* without it). The props types come from the same spec (#3934), so import
|
|
84
|
+
* keeps exactly what the types accept (#3937).
|
|
85
|
+
*/
|
|
86
|
+
export function composeFields(): Map<ComposeKind, Set<string>> {
|
|
87
|
+
if (fieldsCache) return fieldsCache;
|
|
88
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
89
|
+
const candidates = [join(here, "..", "generated", "lexicon-docker.json"), join(here, "..", "..", "dist", "meta.json")];
|
|
90
|
+
const path = candidates.find((c) => existsSync(c));
|
|
91
|
+
if (!path) {
|
|
92
|
+
throw new Error(`docker import: the generated lexicon is missing (tried ${candidates.join(", ")}); run npm run generate in lexicons/docker`);
|
|
93
|
+
}
|
|
94
|
+
const lexicon = JSON.parse(readFileSync(path, "utf-8")) as Record<string, { properties?: Record<string, unknown> }>;
|
|
95
|
+
const fields = new Map<ComposeKind, Set<string>>();
|
|
96
|
+
for (const { kind, entry } of SECTIONS) {
|
|
97
|
+
fields.set(kind, new Set(Object.keys(lexicon[entry]?.properties ?? {})));
|
|
98
|
+
}
|
|
99
|
+
fieldsCache = fields;
|
|
100
|
+
return fields;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Keep every field the Compose spec defines for the kind, and `x-`
|
|
105
|
+
* extension fields. Anything else is dropped and named in `warnings`.
|
|
106
|
+
*/
|
|
75
107
|
function extractProps(
|
|
76
108
|
raw: unknown,
|
|
77
|
-
allowed:
|
|
109
|
+
allowed: Set<string>,
|
|
110
|
+
where: string,
|
|
111
|
+
kind: string,
|
|
112
|
+
warnings: string[],
|
|
78
113
|
): Record<string, unknown> {
|
|
79
114
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
80
|
-
const obj = raw as Record<string, unknown>;
|
|
81
115
|
const props: Record<string, unknown> = {};
|
|
82
|
-
for (const key of
|
|
83
|
-
if (key
|
|
116
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
117
|
+
if (allowed.has(key) || key.startsWith("x-")) props[key] = value;
|
|
118
|
+
else warnings.push(`${where}: "${key}" is not a Compose ${kind} field and was dropped`);
|
|
84
119
|
}
|
|
85
120
|
return props;
|
|
86
121
|
}
|
|
@@ -96,64 +131,22 @@ export class DockerParser {
|
|
|
96
131
|
const entities: DockerIR[] = [];
|
|
97
132
|
const warnings: string[] = [];
|
|
98
133
|
const doc = parseYAML(content) as Record<string, unknown>;
|
|
134
|
+
const fields = composeFields();
|
|
99
135
|
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
for (const [name, raw] of Object.entries(services as Record<string, unknown>)) {
|
|
104
|
-
entities.push({
|
|
105
|
-
kind: "service",
|
|
106
|
-
name,
|
|
107
|
-
props: extractProps(raw, SERVICE_PROPS),
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// volumes
|
|
113
|
-
const volumes = doc["volumes"];
|
|
114
|
-
if (volumes && typeof volumes === "object" && !Array.isArray(volumes)) {
|
|
115
|
-
for (const [name, raw] of Object.entries(volumes as Record<string, unknown>)) {
|
|
116
|
-
entities.push({
|
|
117
|
-
kind: "volume",
|
|
118
|
-
name,
|
|
119
|
-
props: extractProps(raw, VOLUME_PROPS),
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// networks
|
|
125
|
-
const networks = doc["networks"];
|
|
126
|
-
if (networks && typeof networks === "object" && !Array.isArray(networks)) {
|
|
127
|
-
for (const [name, raw] of Object.entries(networks as Record<string, unknown>)) {
|
|
128
|
-
entities.push({
|
|
129
|
-
kind: "network",
|
|
130
|
-
name,
|
|
131
|
-
props: extractProps(raw, NETWORK_PROPS),
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// configs
|
|
137
|
-
const configs = doc["configs"];
|
|
138
|
-
if (configs && typeof configs === "object" && !Array.isArray(configs)) {
|
|
139
|
-
for (const [name, raw] of Object.entries(configs as Record<string, unknown>)) {
|
|
140
|
-
entities.push({
|
|
141
|
-
kind: "config",
|
|
142
|
-
name,
|
|
143
|
-
props: extractProps(raw, CONFIG_PROPS),
|
|
144
|
-
});
|
|
145
|
-
}
|
|
136
|
+
const known = new Set<string>(SECTIONS.map((s) => s.section));
|
|
137
|
+
for (const key of Object.keys(doc ?? {})) {
|
|
138
|
+
if (!known.has(key)) warnings.push(`"${key}" at the top level is not imported`);
|
|
146
139
|
}
|
|
147
140
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for (const [name, raw] of Object.entries(
|
|
141
|
+
for (const { section, kind } of SECTIONS) {
|
|
142
|
+
const entries = doc?.[section];
|
|
143
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
|
|
144
|
+
for (const [name, raw] of Object.entries(entries as Record<string, unknown>)) {
|
|
152
145
|
entities.push({
|
|
153
|
-
kind
|
|
146
|
+
kind,
|
|
154
147
|
name,
|
|
155
|
-
props: extractProps(raw,
|
|
156
|
-
});
|
|
148
|
+
props: extractProps(raw, fields.get(kind)!, `${section}.${name}`, kind, warnings),
|
|
149
|
+
} as DockerIR);
|
|
157
150
|
}
|
|
158
151
|
}
|
|
159
152
|
|
|
@@ -178,8 +178,6 @@ function buildImport(source: string): string {
|
|
|
178
178
|
|
|
179
179
|
describe("roundtrip: compose → TypeScript → compose", () => {
|
|
180
180
|
test("configs and secrets import as DockerConfig and DockerSecret and build the same file", () => {
|
|
181
|
-
// Names without hyphens: the generator camel-cases export names, which renames a
|
|
182
|
-
// hyphenated key (db-password becomes dbPassword, #3937).
|
|
183
181
|
const content = testdata("configs-secrets.yaml");
|
|
184
182
|
const { entities } = new DockerParser().parse(content);
|
|
185
183
|
const { source } = new DockerGenerator().generate(entities);
|
|
@@ -188,6 +186,50 @@ describe("roundtrip: compose → TypeScript → compose", () => {
|
|
|
188
186
|
expect(source).toContain("new DockerSecret(");
|
|
189
187
|
expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
|
|
190
188
|
});
|
|
189
|
+
|
|
190
|
+
test("full.yaml builds the same file: every field kept, hyphenated keys kept (#3937)", () => {
|
|
191
|
+
const content = testdata("full.yaml");
|
|
192
|
+
const { entities, warnings } = new DockerParser().parse(content);
|
|
193
|
+
expect(warnings).toEqual([]);
|
|
194
|
+
const { source } = new DockerGenerator().generate(entities);
|
|
195
|
+
expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("fields outside the old fixed list, x- extensions, and keys that are not identifiers survive (#3937)", () => {
|
|
199
|
+
const content = testdata("names-and-fields.yaml");
|
|
200
|
+
const { entities, warnings } = new DockerParser().parse(content);
|
|
201
|
+
expect(warnings).toEqual([]);
|
|
202
|
+
const { source } = new DockerGenerator().generate(entities);
|
|
203
|
+
expect(parseYAML(buildImport(source))).toEqual(parseYAML(content));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("through the chant import adapter", () => {
|
|
207
|
+
const content = testdata("names-and-fields.yaml");
|
|
208
|
+
const [file] = new DockerTemplateGenerator().generate(new DockerTemplateParser().parse(content));
|
|
209
|
+
expect(parseYAML(buildImport(file!.content))).toEqual(parseYAML(content));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("compose import warns on what it cannot keep (#3937)", () => {
|
|
214
|
+
test("a field the Compose spec does not define is dropped with a warning", () => {
|
|
215
|
+
const content = "services:\n api:\n image: nginx\n not_a_field: 1\nnetworks:\n n:\n bogus: true\n";
|
|
216
|
+
const { entities, warnings } = new DockerParser().parse(content);
|
|
217
|
+
expect(entities.find((e) => e.name === "api")).toMatchObject({ props: { image: "nginx" } });
|
|
218
|
+
expect(warnings).toEqual([
|
|
219
|
+
'services.api: "not_a_field" is not a Compose service field and was dropped',
|
|
220
|
+
'networks.n: "bogus" is not a Compose network field and was dropped',
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("a top-level section import does not carry is named", () => {
|
|
225
|
+
const { warnings } = new DockerParser().parse("name: proj\nservices:\n a:\n image: x\n");
|
|
226
|
+
expect(warnings).toEqual(['"name" at the top level is not imported']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("the adapter hands parser warnings to chant import", () => {
|
|
230
|
+
const ir = new DockerTemplateParser().parse("services:\n api:\n image: nginx\n not_a_field: 1\n");
|
|
231
|
+
expect(ir.warnings).toEqual(['services.api: "not_a_field" is not a Compose service field and was dropped']);
|
|
232
|
+
});
|
|
191
233
|
});
|
|
192
234
|
|
|
193
235
|
describe("every name the importers emit is exported by the lexicon", () => {
|
|
@@ -198,7 +240,8 @@ describe("every name the importers emit is exported by the lexicon", () => {
|
|
|
198
240
|
];
|
|
199
241
|
expect(new Set(entities.map((e) => e.kind))).toEqual(new Set(["service", "volume", "network", "config", "secret", "dockerfile"]));
|
|
200
242
|
const names = importedNames(new DockerGenerator().generate(entities).source);
|
|
201
|
-
|
|
243
|
+
// The six constructors, and composeKey for full.yaml's hyphenated keys.
|
|
244
|
+
expect(names.length).toBe(7);
|
|
202
245
|
for (const name of names) {
|
|
203
246
|
expect(typeof (lexicon as Record<string, unknown>)[name], name).toBe("function");
|
|
204
247
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
services:
|
|
2
|
+
web-app:
|
|
3
|
+
image: myapp:latest
|
|
4
|
+
working_dir: /srv
|
|
5
|
+
user: "1000:1000"
|
|
6
|
+
env_file:
|
|
7
|
+
- .env
|
|
8
|
+
stop_grace_period: 30s
|
|
9
|
+
x-team: platform
|
|
10
|
+
depends_on:
|
|
11
|
+
- db_primary
|
|
12
|
+
volumes:
|
|
13
|
+
- "app-data:/data"
|
|
14
|
+
networks:
|
|
15
|
+
- back-tier
|
|
16
|
+
secrets:
|
|
17
|
+
- db-password
|
|
18
|
+
configs:
|
|
19
|
+
- nginx.conf
|
|
20
|
+
db_primary:
|
|
21
|
+
image: postgres:16
|
|
22
|
+
db:
|
|
23
|
+
image: postgres:16
|
|
24
|
+
volumes:
|
|
25
|
+
- "db:/var/lib/postgresql/data"
|
|
26
|
+
volumes:
|
|
27
|
+
app-data:
|
|
28
|
+
name: shared-app-data
|
|
29
|
+
db:
|
|
30
|
+
networks:
|
|
31
|
+
back-tier:
|
|
32
|
+
internal: true
|
|
33
|
+
driver_opts:
|
|
34
|
+
com.docker.network.bridge.name: br-back
|
|
35
|
+
configs:
|
|
36
|
+
nginx.conf:
|
|
37
|
+
content: |
|
|
38
|
+
server {}
|
|
39
|
+
name: nginx-conf
|
|
40
|
+
labels:
|
|
41
|
+
tier: edge
|
|
42
|
+
secrets:
|
|
43
|
+
db-password:
|
|
44
|
+
environment: DB_PASSWORD
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,9 @@ export { defaultLabels, defaultAnnotations, isDefaultLabels, isDefaultAnnotation
|
|
|
17
17
|
export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-labels";
|
|
18
18
|
export type { DefaultLabels, DefaultAnnotations } from "./default-labels";
|
|
19
19
|
|
|
20
|
+
// Compose keys that are not identifiers (#3937)
|
|
21
|
+
export { composeKey, getComposeKey, COMPOSE_KEY } from "./compose-key";
|
|
22
|
+
|
|
20
23
|
// Generated entities — populated by `npm run generate`
|
|
21
24
|
export * from "./generated/index";
|
|
22
25
|
|
package/src/lsp/completions.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
|
+
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
2
3
|
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
3
4
|
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
-
const require = createRequire(import.meta.url);
|
|
5
|
+
const require = createRequire(moduleSourceUrl(import.meta.url));
|
|
5
6
|
|
|
6
7
|
let cachedIndex: LexiconIndex | null = null;
|
|
7
8
|
|
package/src/lsp/hover.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
|
+
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
2
3
|
import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
3
4
|
import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
-
const require = createRequire(import.meta.url);
|
|
5
|
+
const require = createRequire(moduleSourceUrl(import.meta.url));
|
|
5
6
|
|
|
6
7
|
let cachedIndex: LexiconIndex | null = null;
|
|
7
8
|
|
package/src/serializer.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { dockerSerializer } from "./serializer";
|
|
3
|
+
import { composeKey } from "./compose-key";
|
|
3
4
|
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
4
5
|
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
5
6
|
|
|
@@ -489,3 +490,33 @@ describe("telemetry attribution", () => {
|
|
|
489
490
|
);
|
|
490
491
|
});
|
|
491
492
|
});
|
|
493
|
+
|
|
494
|
+
describe("composeKey (#3937)", () => {
|
|
495
|
+
test("the entity takes the key, and a reference to it serializes as the key", () => {
|
|
496
|
+
const data = composeKey("app-data", new MockVolume({ driver: "local" }));
|
|
497
|
+
const out = dockerSerializer.serialize(new Map<string, Declarable>([
|
|
498
|
+
["web", composeKey("web-app", new MockService({ image: "nginx", volumes: [data] }))],
|
|
499
|
+
["appData", data],
|
|
500
|
+
])) as string;
|
|
501
|
+
expect(out).toContain(" web-app:\n");
|
|
502
|
+
expect(out).toContain(" app-data:\n");
|
|
503
|
+
expect(out).toContain(" - app-data\n");
|
|
504
|
+
expect(out).not.toContain("appData");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("two entities of one kind with the same key is an error", () => {
|
|
508
|
+
expect(() => dockerSerializer.serialize(new Map<string, Declarable>([
|
|
509
|
+
["db", new MockService({ image: "postgres" })],
|
|
510
|
+
["primary", composeKey("db", new MockService({ image: "postgres" }))],
|
|
511
|
+
]))).toThrow('docker: db and primary both take the compose key "db"');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("a service and a volume may share a key", () => {
|
|
515
|
+
const out = dockerSerializer.serialize(new Map<string, Declarable>([
|
|
516
|
+
["db", new MockService({ image: "postgres" })],
|
|
517
|
+
["dbVolume", composeKey("db", new MockVolume({}))],
|
|
518
|
+
])) as string;
|
|
519
|
+
expect(out).toContain("services:\n db:\n");
|
|
520
|
+
expect(out).toContain("volumes:\n db:");
|
|
521
|
+
});
|
|
522
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
|
17
17
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
18
18
|
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
19
19
|
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
20
|
+
import { getComposeKey } from "./compose-key";
|
|
20
21
|
import { EXEC_FORM_STEPS, PER_KIND_ORDER, STEP_INSTRUCTIONS } from "./dockerfile-order";
|
|
21
22
|
|
|
22
23
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
@@ -368,9 +369,20 @@ export const dockerSerializer: Serializer = {
|
|
|
368
369
|
_outputs?: LexiconOutput[],
|
|
369
370
|
context?: SerializeContext,
|
|
370
371
|
): string | SerializerResult {
|
|
372
|
+
// A compose entity's key is its export name unless composeKey set one
|
|
373
|
+
// (#3937); references to it serialize as the same key. Two entities of
|
|
374
|
+
// one kind cannot share a key.
|
|
371
375
|
const entityNames = new Map<Declarable, string>();
|
|
376
|
+
const taken = new Map<string, { name: string; entity: Declarable }>();
|
|
372
377
|
for (const [name, entity] of entities) {
|
|
373
|
-
|
|
378
|
+
const key = getComposeKey(entity) ?? name;
|
|
379
|
+
entityNames.set(entity, key);
|
|
380
|
+
const slot = `${(entity as unknown as Record<string, unknown>).entityType}\0${key}`;
|
|
381
|
+
const other = taken.get(slot);
|
|
382
|
+
if (other !== undefined && other.entity !== entity) {
|
|
383
|
+
throw new Error(`docker: ${other.name} and ${name} both take the compose key "${key}"`);
|
|
384
|
+
}
|
|
385
|
+
taken.set(slot, { name, entity });
|
|
374
386
|
}
|
|
375
387
|
|
|
376
388
|
const services = new Map<string, Declarable>();
|
|
@@ -381,7 +393,8 @@ export const dockerSerializer: Serializer = {
|
|
|
381
393
|
const dockerfiles = new Map<string, Declarable>();
|
|
382
394
|
let defaultLabels: Record<string, string> = {};
|
|
383
395
|
|
|
384
|
-
for (const [
|
|
396
|
+
for (const [exportName, entity] of entities) {
|
|
397
|
+
const name = entityNames.get(entity) ?? exportName;
|
|
385
398
|
// Skip property-kind entities
|
|
386
399
|
if (isPropertyDeclarable(entity)) continue;
|
|
387
400
|
|