@alchemy.run/docker 2.0.0-beta.85 → 2.0.0-beta.86
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/README.md +16 -0
- package/lib/Bootstrap.d.ts.map +1 -1
- package/lib/Bootstrap.js +2 -1
- package/lib/Bootstrap.js.map +1 -1
- package/lib/BuildCache.d.ts +87 -0
- package/lib/BuildCache.d.ts.map +1 -0
- package/lib/BuildCache.js +39 -0
- package/lib/BuildCache.js.map +1 -0
- package/lib/BuildHash.d.ts +9 -1
- package/lib/BuildHash.d.ts.map +1 -1
- package/lib/BuildHash.js +51 -19
- package/lib/BuildHash.js.map +1 -1
- package/lib/BuildPlan.d.ts +202 -0
- package/lib/BuildPlan.d.ts.map +1 -0
- package/lib/BuildPlan.js +514 -0
- package/lib/BuildPlan.js.map +1 -0
- package/lib/Container.d.ts +24 -1
- package/lib/Container.d.ts.map +1 -1
- package/lib/Container.js +14 -0
- package/lib/Container.js.map +1 -1
- package/lib/Context.d.ts +20 -1
- package/lib/Context.d.ts.map +1 -1
- package/lib/Context.js +15 -0
- package/lib/Context.js.map +1 -1
- package/lib/Docker.d.ts +33 -0
- package/lib/Docker.d.ts.map +1 -1
- package/lib/Docker.js +23 -2
- package/lib/Docker.js.map +1 -1
- package/lib/Dockerfile.d.ts +0 -2
- package/lib/Dockerfile.d.ts.map +1 -1
- package/lib/Dockerfile.js.map +1 -1
- package/lib/DockerfileParser.d.ts +125 -0
- package/lib/DockerfileParser.d.ts.map +1 -0
- package/lib/DockerfileParser.js +1040 -0
- package/lib/DockerfileParser.js.map +1 -0
- package/lib/HealthcheckCommand.d.ts.map +1 -1
- package/lib/HealthcheckCommand.js.map +1 -1
- package/lib/Image.d.ts +40 -12
- package/lib/Image.d.ts.map +1 -1
- package/lib/Image.js +41 -9
- package/lib/Image.js.map +1 -1
- package/lib/ImageArchive.d.ts +41 -0
- package/lib/ImageArchive.d.ts.map +1 -0
- package/lib/ImageArchive.js +54 -0
- package/lib/ImageArchive.js.map +1 -0
- package/lib/ImageSource.d.ts +100 -11
- package/lib/ImageSource.d.ts.map +1 -1
- package/lib/ImageSource.js +219 -73
- package/lib/ImageSource.js.map +1 -1
- package/lib/Network.d.ts +175 -3
- package/lib/Network.d.ts.map +1 -1
- package/lib/Network.js +296 -8
- package/lib/Network.js.map +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js.map +1 -1
- package/lib/Registry.d.ts.map +1 -1
- package/lib/Registry.js.map +1 -1
- package/lib/RegistryError.d.ts.map +1 -1
- package/lib/RegistryError.js.map +1 -1
- package/lib/RemoteImage.d.ts.map +1 -1
- package/lib/RemoteImage.js.map +1 -1
- package/lib/Service.d.ts.map +1 -1
- package/lib/Service.js.map +1 -1
- package/lib/ServiceImage.d.ts.map +1 -1
- package/lib/ServiceImage.js.map +1 -1
- package/lib/ShellWord.d.ts +28 -0
- package/lib/ShellWord.d.ts.map +1 -0
- package/lib/ShellWord.js +301 -0
- package/lib/ShellWord.js.map +1 -0
- package/lib/Swarm.d.ts.map +1 -1
- package/lib/Swarm.js.map +1 -1
- package/lib/Volume.d.ts +24 -1
- package/lib/Volume.d.ts.map +1 -1
- package/lib/Volume.js +16 -0
- package/lib/Volume.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- package/package.json +9 -9
- package/src/Bootstrap.ts +2 -0
- package/src/BuildCache.ts +111 -0
- package/src/BuildHash.ts +73 -24
- package/src/BuildPlan.ts +758 -0
- package/src/Container.ts +31 -1
- package/src/Context.ts +27 -1
- package/src/Docker.ts +56 -2
- package/src/Dockerfile.ts +0 -2
- package/src/DockerfileParser.ts +1152 -0
- package/src/Image.ts +45 -14
- package/src/ImageArchive.ts +77 -0
- package/src/ImageSource.ts +322 -103
- package/src/Network.ts +432 -10
- package/src/ShellWord.ts +326 -0
- package/src/Volume.ts +33 -1
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Result from "effect/Result";
|
|
4
|
+
import { processWord, processWords } from "./ShellWord.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Dockerfile parsed into stages and instructions, with the line each one
|
|
8
|
+
* starts on. Builders that are not Docker (E2B, Modal, Daytona) lower this
|
|
9
|
+
* structure into their own build steps instead of running `docker build`.
|
|
10
|
+
*
|
|
11
|
+
* The parser is a port of BuildKit's (`frontend/dockerfile/parser` and the
|
|
12
|
+
* checks in `instructions`), and is tested against it: parser directives,
|
|
13
|
+
* line continuations, heredocs, exec and shell forms, `--flag[=value]`
|
|
14
|
+
* options, Docker's quoting, and the files BuildKit rejects. It does not
|
|
15
|
+
* expand variables: lowering decides whether a builder expands them or
|
|
16
|
+
* Alchemy does.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The Dockerfile could not be parsed. */
|
|
20
|
+
export class DockerfileSyntaxError extends Data.TaggedError("DockerfileSyntaxError")<{
|
|
21
|
+
readonly message: string;
|
|
22
|
+
/** 1-based line the error was found on. */
|
|
23
|
+
readonly line: number;
|
|
24
|
+
}> {}
|
|
25
|
+
|
|
26
|
+
/** A `--name` or `--name=value` option on an instruction. */
|
|
27
|
+
export interface Flag {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly value: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A heredoc attached to an instruction (`RUN <<EOF … EOF`). */
|
|
33
|
+
export interface Heredoc {
|
|
34
|
+
/** The delimiter word, e.g. `EOF`. */
|
|
35
|
+
readonly name: string;
|
|
36
|
+
/** The body, without the closing delimiter line. */
|
|
37
|
+
readonly content: string;
|
|
38
|
+
/** `<<-`: leading tabs were stripped from the body. */
|
|
39
|
+
readonly stripTabs: boolean;
|
|
40
|
+
/** A quoted delimiter (`<<"EOF"`): the body is not expanded. */
|
|
41
|
+
readonly quoted: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The exec form (`["bun", "index.mjs"]`) or the shell form (`bun index.mjs`). */
|
|
45
|
+
export type Command =
|
|
46
|
+
| { readonly form: "exec"; readonly argv: ReadonlyArray<string> }
|
|
47
|
+
| { readonly form: "shell"; readonly script: string };
|
|
48
|
+
|
|
49
|
+
interface InstructionBase {
|
|
50
|
+
/** 1-based line the instruction starts on. */
|
|
51
|
+
readonly line: number;
|
|
52
|
+
/** The instruction as written: the joined logical line, plus heredoc bodies. */
|
|
53
|
+
readonly raw: string;
|
|
54
|
+
readonly flags: ReadonlyArray<Flag>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RunInstruction extends InstructionBase {
|
|
58
|
+
readonly _tag: "RUN";
|
|
59
|
+
readonly command: Command;
|
|
60
|
+
readonly heredocs: ReadonlyArray<Heredoc>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CopyInstruction extends InstructionBase {
|
|
64
|
+
readonly _tag: "COPY" | "ADD";
|
|
65
|
+
/** Context-relative sources, URLs (ADD), or `<<NAME` heredoc references. */
|
|
66
|
+
readonly sources: ReadonlyArray<string>;
|
|
67
|
+
readonly destination: string;
|
|
68
|
+
readonly heredocs: ReadonlyArray<Heredoc>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface EnvInstruction extends InstructionBase {
|
|
72
|
+
readonly _tag: "ENV";
|
|
73
|
+
readonly vars: ReadonlyArray<readonly [name: string, value: string]>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ArgInstruction extends InstructionBase {
|
|
77
|
+
readonly _tag: "ARG";
|
|
78
|
+
readonly vars: ReadonlyArray<readonly [name: string, defaultValue: string | undefined]>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ValueInstruction extends InstructionBase {
|
|
82
|
+
readonly _tag: "WORKDIR" | "USER" | "STOPSIGNAL";
|
|
83
|
+
readonly value: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CommandInstruction extends InstructionBase {
|
|
87
|
+
readonly _tag: "CMD" | "ENTRYPOINT" | "SHELL";
|
|
88
|
+
readonly command: Command;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OtherInstruction extends InstructionBase {
|
|
92
|
+
readonly _tag: "EXPOSE" | "LABEL" | "HEALTHCHECK" | "VOLUME" | "ONBUILD" | "MAINTAINER";
|
|
93
|
+
/** Everything after the instruction name and its flags. */
|
|
94
|
+
readonly args: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type Instruction =
|
|
98
|
+
| RunInstruction
|
|
99
|
+
| CopyInstruction
|
|
100
|
+
| EnvInstruction
|
|
101
|
+
| ArgInstruction
|
|
102
|
+
| ValueInstruction
|
|
103
|
+
| CommandInstruction
|
|
104
|
+
| OtherInstruction;
|
|
105
|
+
|
|
106
|
+
export type InstructionName = Instruction["_tag"] | "FROM";
|
|
107
|
+
|
|
108
|
+
/** One `FROM` and the instructions after it. */
|
|
109
|
+
export interface Stage {
|
|
110
|
+
/** 1-based line of the `FROM`. */
|
|
111
|
+
readonly line: number;
|
|
112
|
+
/** The `FROM` line as written. */
|
|
113
|
+
readonly raw: string;
|
|
114
|
+
/** The base: an image reference, an earlier stage's name, or `scratch`. */
|
|
115
|
+
readonly image: string;
|
|
116
|
+
/** `FROM … AS name`. */
|
|
117
|
+
readonly name: string | undefined;
|
|
118
|
+
readonly flags: ReadonlyArray<Flag>;
|
|
119
|
+
readonly instructions: ReadonlyArray<Instruction>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ParsedDockerfile {
|
|
123
|
+
/** Parser directives, e.g. `{ syntax: "docker/dockerfile:1", escape: "\\" }`. */
|
|
124
|
+
readonly directives: Readonly<Record<string, string>>;
|
|
125
|
+
/** `ARG`s declared before the first `FROM`. */
|
|
126
|
+
readonly globalArgs: ReadonlyArray<ArgInstruction>;
|
|
127
|
+
readonly stages: ReadonlyArray<Stage>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Each instruction's flags, as BuildKit's instruction layer declares them. */
|
|
131
|
+
const FLAGS: Partial<Record<InstructionName, Record<string, "bool" | "string" | "strings">>> = {
|
|
132
|
+
FROM: { platform: "string" },
|
|
133
|
+
RUN: { mount: "strings", network: "string", security: "string", device: "strings" },
|
|
134
|
+
COPY: {
|
|
135
|
+
chown: "string",
|
|
136
|
+
from: "string",
|
|
137
|
+
chmod: "string",
|
|
138
|
+
link: "bool",
|
|
139
|
+
exclude: "strings",
|
|
140
|
+
parents: "bool",
|
|
141
|
+
},
|
|
142
|
+
ADD: {
|
|
143
|
+
chown: "string",
|
|
144
|
+
chmod: "string",
|
|
145
|
+
link: "bool",
|
|
146
|
+
"keep-git-dir": "bool",
|
|
147
|
+
checksum: "string",
|
|
148
|
+
unpack: "bool",
|
|
149
|
+
exclude: "strings",
|
|
150
|
+
},
|
|
151
|
+
HEALTHCHECK: {
|
|
152
|
+
interval: "string",
|
|
153
|
+
timeout: "string",
|
|
154
|
+
"start-period": "string",
|
|
155
|
+
"start-interval": "string",
|
|
156
|
+
retries: "string",
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const KNOWN = new Set<InstructionName>([
|
|
161
|
+
"FROM",
|
|
162
|
+
"RUN",
|
|
163
|
+
"COPY",
|
|
164
|
+
"ADD",
|
|
165
|
+
"ENV",
|
|
166
|
+
"ARG",
|
|
167
|
+
"WORKDIR",
|
|
168
|
+
"USER",
|
|
169
|
+
"STOPSIGNAL",
|
|
170
|
+
"CMD",
|
|
171
|
+
"ENTRYPOINT",
|
|
172
|
+
"SHELL",
|
|
173
|
+
"EXPOSE",
|
|
174
|
+
"LABEL",
|
|
175
|
+
"HEALTHCHECK",
|
|
176
|
+
"VOLUME",
|
|
177
|
+
"ONBUILD",
|
|
178
|
+
"MAINTAINER",
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
const HEREDOC_CAPABLE = new Set<string>(["RUN", "COPY", "ADD"]);
|
|
182
|
+
const DIRECTIVES = new Set(["syntax", "escape", "check"]);
|
|
183
|
+
const WHITESPACE = /[\t\v\f\r ]+/;
|
|
184
|
+
const HEREDOC_WORD = /^(\d*)<<(-?)\s*([^<]*)$/;
|
|
185
|
+
const VALID_STAGE_NAME = /^[a-z][a-z0-9-_.]*$/;
|
|
186
|
+
|
|
187
|
+
const fail = (message: string, line: number) =>
|
|
188
|
+
Effect.fail(new DockerfileSyntaxError({ message, line }));
|
|
189
|
+
|
|
190
|
+
/** `JSON.parse`, or `undefined` when the text is not JSON. */
|
|
191
|
+
const jsonOf = (text: string): unknown => {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(text);
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const isSpace = (ch: string) => /\s/u.test(ch);
|
|
200
|
+
|
|
201
|
+
/** The instruction word and the rest of the line. */
|
|
202
|
+
const splitInstruction = (text: string): [string, string] => {
|
|
203
|
+
const trimmed = text.trim();
|
|
204
|
+
const at = trimmed.search(WHITESPACE);
|
|
205
|
+
return at === -1
|
|
206
|
+
? [trimmed, ""]
|
|
207
|
+
: [trimmed.slice(0, at), trimmed.slice(at).replace(WHITESPACE, "")];
|
|
208
|
+
};
|
|
209
|
+
const trimNewline = (line: string) => line.replace(/[\r\n]+$/, "");
|
|
210
|
+
const trimLeadingWhitespace = (line: string) => line.replace(/^\s+/u, "");
|
|
211
|
+
const isComment = (line: string) => trimLeadingWhitespace(line).startsWith("#");
|
|
212
|
+
const regexEscape = (s: string) => s.replace(/[\\^$.*+?()[\]{}|`]/g, "\\$&");
|
|
213
|
+
|
|
214
|
+
/** `[^\x]\x[ \t]*$` or a line of only `\x`: a continuation, unless the escape is escaped. */
|
|
215
|
+
const continuation = (escape: string) => {
|
|
216
|
+
const e = regexEscape(escape);
|
|
217
|
+
return new RegExp(`([^${e}])${e}[ \\t]*$|^${e}[ \\t]*$`);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* BuildKit's flag splitter: `--name[=value]` words before the arguments,
|
|
222
|
+
* quotes and escapes resolved. Like BuildKit it walks bytes and writes each
|
|
223
|
+
* byte back as a character, so a non-ASCII flag value comes out
|
|
224
|
+
* double-encoded (`µ` becomes `µ`), as `docker build` reads it.
|
|
225
|
+
*/
|
|
226
|
+
const extractFlags = (line: string, escape: string): { rest: string; flags: string[] } => {
|
|
227
|
+
const bytes = new TextEncoder().encode(line);
|
|
228
|
+
const at = (pos: number) => String.fromCharCode(bytes[pos]!);
|
|
229
|
+
const restFrom = (pos: number) => new TextDecoder().decode(bytes.slice(pos));
|
|
230
|
+
const words: string[] = [];
|
|
231
|
+
let phase: "spaces" | "word" | "quote" = "spaces";
|
|
232
|
+
let word = "";
|
|
233
|
+
let quote = "";
|
|
234
|
+
let blankOk = false;
|
|
235
|
+
for (let pos = 0; pos <= bytes.length; pos++) {
|
|
236
|
+
const ch = pos < bytes.length ? at(pos) : "";
|
|
237
|
+
if (phase === "spaces") {
|
|
238
|
+
if (pos === bytes.length) break;
|
|
239
|
+
if (isSpace(ch)) continue;
|
|
240
|
+
if (ch !== "-" || pos + 1 === bytes.length || at(pos + 1) !== "-") {
|
|
241
|
+
return { rest: restFrom(pos), flags: words };
|
|
242
|
+
}
|
|
243
|
+
phase = "word";
|
|
244
|
+
}
|
|
245
|
+
if (pos === bytes.length) {
|
|
246
|
+
if (word !== "--" && (blankOk || word.length > 0)) words.push(word);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (phase === "word") {
|
|
250
|
+
if (isSpace(ch)) {
|
|
251
|
+
phase = "spaces";
|
|
252
|
+
if (word === "--") return { rest: restFrom(pos), flags: words };
|
|
253
|
+
if (blankOk || word.length > 0) words.push(word);
|
|
254
|
+
word = "";
|
|
255
|
+
blankOk = false;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (ch === "'" || ch === '"') {
|
|
259
|
+
quote = ch;
|
|
260
|
+
blankOk = true;
|
|
261
|
+
phase = "quote";
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (ch === escape) {
|
|
265
|
+
if (pos + 1 === bytes.length) continue;
|
|
266
|
+
pos++;
|
|
267
|
+
word += at(pos);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
word += ch;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// quote
|
|
274
|
+
if (ch === quote) {
|
|
275
|
+
phase = "word";
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (ch === escape) {
|
|
279
|
+
if (pos + 1 === bytes.length) {
|
|
280
|
+
phase = "word";
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
pos++;
|
|
284
|
+
word += at(pos);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
word += ch;
|
|
288
|
+
}
|
|
289
|
+
return { rest: "", flags: words };
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** BuildKit's `parseWords`: split on unquoted whitespace, keeping quotes and escapes. */
|
|
293
|
+
const parseWords = (rest: string, escape: string): string[] => {
|
|
294
|
+
const words: string[] = [];
|
|
295
|
+
let phase: "spaces" | "word" | "quote" = "spaces";
|
|
296
|
+
let quote = "";
|
|
297
|
+
let blankOk = false;
|
|
298
|
+
let word = "";
|
|
299
|
+
const chars = Array.from(rest);
|
|
300
|
+
for (let pos = 0; pos <= chars.length; pos++) {
|
|
301
|
+
let ch = pos < chars.length ? chars[pos]! : "";
|
|
302
|
+
if (phase === "spaces") {
|
|
303
|
+
if (pos === chars.length) break;
|
|
304
|
+
if (isSpace(ch)) continue;
|
|
305
|
+
phase = "word";
|
|
306
|
+
}
|
|
307
|
+
if (pos === chars.length) {
|
|
308
|
+
if (blankOk || word.length > 0) words.push(word);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
if (phase === "word") {
|
|
312
|
+
if (isSpace(ch)) {
|
|
313
|
+
phase = "spaces";
|
|
314
|
+
if (blankOk || word.length > 0) words.push(word);
|
|
315
|
+
word = "";
|
|
316
|
+
blankOk = false;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (ch === "'" || ch === '"') {
|
|
320
|
+
quote = ch;
|
|
321
|
+
blankOk = true;
|
|
322
|
+
phase = "quote";
|
|
323
|
+
}
|
|
324
|
+
if (ch === escape) {
|
|
325
|
+
if (pos + 1 === chars.length) continue;
|
|
326
|
+
word += ch;
|
|
327
|
+
pos++;
|
|
328
|
+
ch = chars[pos]!;
|
|
329
|
+
}
|
|
330
|
+
word += ch;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (ch === quote) phase = "word";
|
|
334
|
+
if (ch === escape && quote !== "'") {
|
|
335
|
+
if (pos + 1 === chars.length) {
|
|
336
|
+
phase = "word";
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
pos++;
|
|
340
|
+
word += ch;
|
|
341
|
+
ch = chars[pos]!;
|
|
342
|
+
}
|
|
343
|
+
word += ch;
|
|
344
|
+
}
|
|
345
|
+
return words;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** A JSON array of strings; `undefined` when the text is not a JSON array. */
|
|
349
|
+
const parseJson = Effect.fn(function* (rest: string, line: number) {
|
|
350
|
+
const trimmed = trimLeadingWhitespace(rest);
|
|
351
|
+
const value = trimmed.startsWith("[") ? jsonOf(trimmed) : undefined;
|
|
352
|
+
if (!Array.isArray(value)) return undefined;
|
|
353
|
+
if (!value.every((item): item is string => typeof item === "string")) {
|
|
354
|
+
return yield* fail(
|
|
355
|
+
"when using JSON array syntax, arrays must be comprised of strings only",
|
|
356
|
+
line,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return value as string[];
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const commandOf = Effect.fn(function* (rest: string, line: number) {
|
|
363
|
+
const argv = rest === "" ? undefined : yield* parseJson(rest, line);
|
|
364
|
+
return (argv === undefined ? { form: "shell", script: rest } : { form: "exec", argv }) as Command;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
interface Line {
|
|
368
|
+
readonly start: number;
|
|
369
|
+
readonly end: number;
|
|
370
|
+
/** The logical line: continuations joined, comments dropped. */
|
|
371
|
+
readonly text: string;
|
|
372
|
+
readonly raw: string;
|
|
373
|
+
readonly heredocs: ReadonlyArray<Heredoc>;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** BuildKit's `ParseHeredoc`: `<<NAME`, `<<-NAME`, `3<<"NAME"`; heredocs always lex with `\\`. */
|
|
377
|
+
const heredocOf = Effect.fn(function* (word: string, line: number) {
|
|
378
|
+
const match = HEREDOC_WORD.exec(word);
|
|
379
|
+
const rest = match?.[3] ?? "";
|
|
380
|
+
if (match === null || rest.length === 0) return undefined;
|
|
381
|
+
const plain = processWords(rest, { escape: "\\" });
|
|
382
|
+
if (Result.isFailure(plain)) return yield* fail(plain.failure.message, line);
|
|
383
|
+
if (plain.success.length !== 1) return undefined;
|
|
384
|
+
const raw = processWords(rest, { escape: "\\", rawQuotes: true });
|
|
385
|
+
if (Result.isFailure(raw)) return yield* fail(raw.failure.message, line);
|
|
386
|
+
const quotes = (s: string) => (s.match(/['"]/g) ?? []).length;
|
|
387
|
+
const name = plain.success[0]!;
|
|
388
|
+
// A quote anywhere in the delimiter turns expansion off.
|
|
389
|
+
return {
|
|
390
|
+
name,
|
|
391
|
+
stripTabs: match[2] === "-",
|
|
392
|
+
quoted: quotes(name) !== quotes(raw.success[0] ?? ""),
|
|
393
|
+
} satisfies Omit<Heredoc, "content">;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const heredocsOf = Effect.fn(function* (text: string, line: number) {
|
|
397
|
+
const words = processWords(text, { escape: "\\", rawQuotes: true, rawEscapes: true });
|
|
398
|
+
const heredocs: Array<Omit<Heredoc, "content">> = [];
|
|
399
|
+
for (const word of Result.isFailure(words) ? [] : words.success) {
|
|
400
|
+
const heredoc = yield* heredocOf(word, line);
|
|
401
|
+
if (heredoc !== undefined) heredocs.push(heredoc);
|
|
402
|
+
}
|
|
403
|
+
return heredocs;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/** `RUN`, `COPY` and `ADD` (also as an `ONBUILD` trigger) take heredocs, except in JSON form. */
|
|
407
|
+
const canContainHeredoc = (text: string, escape: string) => {
|
|
408
|
+
let [name, rest] = splitInstruction(text);
|
|
409
|
+
if (name.toUpperCase() === "ONBUILD") [name, rest] = splitInstruction(rest);
|
|
410
|
+
if (!HEREDOC_CAPABLE.has(name.toUpperCase())) return false;
|
|
411
|
+
const args = extractFlags(rest, escape).rest.trim();
|
|
412
|
+
return !(args.startsWith("[") && Array.isArray(jsonOf(args)));
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const readLines = Effect.fn(function* (text: string) {
|
|
416
|
+
// Lines keep their endings: heredoc bodies keep `\r\n`, as BuildKit does.
|
|
417
|
+
const physical = text.split(/(?<=\n)/);
|
|
418
|
+
if (physical.length > 0) physical[0] = physical[0]!.replace(/^\uFEFF/, "");
|
|
419
|
+
// BuildKit reads lines with a 64 KiB scanner: a longer line fails the file.
|
|
420
|
+
const encoder = new TextEncoder();
|
|
421
|
+
const tooLong = physical.findIndex((line) => encoder.encode(trimNewline(line)).length >= 65536);
|
|
422
|
+
if (tooLong !== -1) {
|
|
423
|
+
return yield* fail("dockerfile line greater than max allowed size of 65535", tooLong + 1);
|
|
424
|
+
}
|
|
425
|
+
const directives: Record<string, string> = {};
|
|
426
|
+
let escape = "\\";
|
|
427
|
+
let continued = continuation(escape);
|
|
428
|
+
let directivesDone = false;
|
|
429
|
+
let lineNo = 0;
|
|
430
|
+
|
|
431
|
+
// Parser directives: `# key=value` for syntax, escape and check, before
|
|
432
|
+
// any other line. An unknown key, a comment or a blank line ends them.
|
|
433
|
+
const directive = Effect.fn(function* (token: string) {
|
|
434
|
+
if (directivesDone) return;
|
|
435
|
+
const match = token.startsWith("#")
|
|
436
|
+
? /^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*(.+?)\s*$/.exec(trimLeadingWhitespace(token.slice(1)))
|
|
437
|
+
: null;
|
|
438
|
+
const key = match?.[1]?.toLowerCase();
|
|
439
|
+
if (match === null || key === undefined || !DIRECTIVES.has(key)) {
|
|
440
|
+
directivesDone = true;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (key in directives) {
|
|
444
|
+
return yield* fail(`only one ${key} parser directive can be used`, lineNo + 1);
|
|
445
|
+
}
|
|
446
|
+
const value = match[2]!;
|
|
447
|
+
if (key === "escape") {
|
|
448
|
+
if (value !== "`" && value !== "\\") {
|
|
449
|
+
return yield* fail(`invalid escape token '${value}' does not match \` or \\`, lineNo + 1);
|
|
450
|
+
}
|
|
451
|
+
escape = value;
|
|
452
|
+
continued = continuation(escape);
|
|
453
|
+
}
|
|
454
|
+
directives[key] = value;
|
|
455
|
+
});
|
|
456
|
+
const processLine = Effect.fn(function* (line: string, stripLeft: boolean) {
|
|
457
|
+
let token = trimNewline(line);
|
|
458
|
+
if (stripLeft) token = trimLeadingWhitespace(token);
|
|
459
|
+
yield* directive(token);
|
|
460
|
+
return isComment(token) ? "" : token;
|
|
461
|
+
});
|
|
462
|
+
const trimContinuation = (line: string): [string, boolean] =>
|
|
463
|
+
continued.test(line) ? [line.replace(continued, "$1"), false] : [line, true];
|
|
464
|
+
|
|
465
|
+
const lines: Line[] = [];
|
|
466
|
+
let cursor = 0;
|
|
467
|
+
while (cursor < physical.length) {
|
|
468
|
+
const first = physical[cursor++]!;
|
|
469
|
+
const firstToken = yield* processLine(first, true);
|
|
470
|
+
lineNo++;
|
|
471
|
+
const start = lineNo;
|
|
472
|
+
let [joined, ended] = trimContinuation(firstToken);
|
|
473
|
+
if (ended && joined.length === 0) continue;
|
|
474
|
+
const raw = [trimNewline(first)];
|
|
475
|
+
// Comment and blank lines inside a continuation are skipped; a
|
|
476
|
+
// continued line keeps its leading whitespace.
|
|
477
|
+
while (!ended && cursor < physical.length) {
|
|
478
|
+
const next = physical[cursor++]!;
|
|
479
|
+
raw.push(trimNewline(next));
|
|
480
|
+
const token = yield* processLine(next, false);
|
|
481
|
+
lineNo++;
|
|
482
|
+
if (isComment(next) || trimLeadingWhitespace(token).length === 0) continue;
|
|
483
|
+
let part: string;
|
|
484
|
+
[part, ended] = trimContinuation(token);
|
|
485
|
+
joined += part;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const heredocs: Heredoc[] = [];
|
|
489
|
+
if (joined.includes("<<") && canContainHeredoc(joined, escape)) {
|
|
490
|
+
for (const heredoc of yield* heredocsOf(joined, start)) {
|
|
491
|
+
let content = "";
|
|
492
|
+
let terminated = false;
|
|
493
|
+
while (cursor < physical.length) {
|
|
494
|
+
const body = physical[cursor++]!;
|
|
495
|
+
lineNo++;
|
|
496
|
+
raw.push(trimNewline(body));
|
|
497
|
+
const candidate = trimNewline(body);
|
|
498
|
+
if ((heredoc.stripTabs ? candidate.replace(/^\t+/, "") : candidate) === heredoc.name) {
|
|
499
|
+
terminated = true;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
content += body;
|
|
503
|
+
}
|
|
504
|
+
if (!terminated) return yield* fail("unterminated heredoc", start);
|
|
505
|
+
heredocs.push({
|
|
506
|
+
...heredoc,
|
|
507
|
+
content: heredoc.stripTabs ? content.replace(/^\t+/gm, "") : content,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
lines.push({ start, end: lineNo, text: joined, raw: raw.join("\n"), heredocs });
|
|
512
|
+
}
|
|
513
|
+
return { directives, escape, lines };
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
/** BuildKit's word processing, with no variable expanded. */
|
|
517
|
+
const wordOf = (word: string, escape: string, line: number) => {
|
|
518
|
+
const result = processWord(word, { escape });
|
|
519
|
+
return Result.isFailure(result)
|
|
520
|
+
? fail(result.failure.message, line)
|
|
521
|
+
: Effect.succeed(result.success);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
/** Checks raw `--name[=value]` words against the instruction's declared flags. */
|
|
525
|
+
const validateFlags = Effect.fn(function* (
|
|
526
|
+
name: InstructionName,
|
|
527
|
+
raw: ReadonlyArray<string>,
|
|
528
|
+
line: number,
|
|
529
|
+
) {
|
|
530
|
+
const declared = FLAGS[name] ?? {};
|
|
531
|
+
const used = new Set<string>();
|
|
532
|
+
const flags: Flag[] = [];
|
|
533
|
+
for (const arg of raw) {
|
|
534
|
+
if (!arg.startsWith("--")) return yield* fail(`arg should start with -- : ${arg}`, line);
|
|
535
|
+
const eq = arg.indexOf("=");
|
|
536
|
+
const flagName = eq === -1 ? arg : arg.slice(0, eq);
|
|
537
|
+
const value = eq === -1 ? undefined : arg.slice(eq + 1);
|
|
538
|
+
const key = flagName.slice(2);
|
|
539
|
+
const type = Object.hasOwn(declared, key) ? declared[key] : undefined;
|
|
540
|
+
if (type === undefined) return yield* fail(`unknown flag: ${flagName}`, line);
|
|
541
|
+
if (used.has(key) && type !== "strings") {
|
|
542
|
+
return yield* fail(`duplicate flag specified: ${flagName}`, line);
|
|
543
|
+
}
|
|
544
|
+
used.add(key);
|
|
545
|
+
if (type === "bool") {
|
|
546
|
+
if (value === "") return yield* fail(`missing a value on flag: ${flagName}`, line);
|
|
547
|
+
if (value !== undefined && !["true", "false"].includes(value.toLowerCase())) {
|
|
548
|
+
return yield* fail(`expecting boolean value for flag ${flagName}, not: ${value}`, line);
|
|
549
|
+
}
|
|
550
|
+
} else if (value === undefined) {
|
|
551
|
+
return yield* fail(`missing a value on flag: ${flagName}`, line);
|
|
552
|
+
}
|
|
553
|
+
flags.push({ name: key, value });
|
|
554
|
+
}
|
|
555
|
+
return flags;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/** `ENV`/`LABEL` pairs: `k=v …`, or the legacy `k value with spaces`. */
|
|
559
|
+
const nameValues = Effect.fn(function* (rest: string, key: string, escape: string, line: number) {
|
|
560
|
+
const words = parseWords(rest, escape);
|
|
561
|
+
if (words.length === 0) return [];
|
|
562
|
+
if (!words[0]!.includes("=")) {
|
|
563
|
+
const at = rest.search(WHITESPACE);
|
|
564
|
+
if (at === -1) return yield* fail(`${key} must have two arguments`, line);
|
|
565
|
+
return [[rest.slice(0, at), rest.slice(at).replace(WHITESPACE, "")] as const];
|
|
566
|
+
}
|
|
567
|
+
const pairs: Array<readonly [string, string]> = [];
|
|
568
|
+
for (const word of words) {
|
|
569
|
+
const index = word.indexOf("=");
|
|
570
|
+
if (index === -1) {
|
|
571
|
+
return yield* fail(
|
|
572
|
+
`Syntax error - can't find = in "${word}". Must be of the form: name=value`,
|
|
573
|
+
line,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
pairs.push([word.slice(0, index), word.slice(index + 1)]);
|
|
577
|
+
}
|
|
578
|
+
return pairs;
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const whitespaceList = (rest: string) => (rest === "" ? [] : rest.split(WHITESPACE));
|
|
582
|
+
|
|
583
|
+
/** Go's `strconv.ParseBool`. */
|
|
584
|
+
const goBool = (value: string): boolean | undefined =>
|
|
585
|
+
["1", "t", "T", "TRUE", "true", "True"].includes(value)
|
|
586
|
+
? true
|
|
587
|
+
: ["0", "f", "F", "FALSE", "false", "False"].includes(value)
|
|
588
|
+
? false
|
|
589
|
+
: undefined;
|
|
590
|
+
|
|
591
|
+
/** Go's `strconv.ParseUint(value, base, 32)`. */
|
|
592
|
+
const goUint32 = (value: string, base: 8 | 10) =>
|
|
593
|
+
(base === 8 ? /^[0-7]+$/ : /^[0-9]+$/).test(value) && parseInt(value, base) <= 0xffffffff;
|
|
594
|
+
|
|
595
|
+
/** Go's `strconv.ParseInt(value, 10, 32)`. */
|
|
596
|
+
const goInt32 = (value: string): number | undefined => {
|
|
597
|
+
if (!/^[+-]?[0-9]+$/.test(value)) return undefined;
|
|
598
|
+
const n = Number(value);
|
|
599
|
+
return n >= -0x80000000 && n <= 0x7fffffff ? n : undefined;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const DURATION_UNITS: Record<string, number> = {
|
|
603
|
+
ns: 1,
|
|
604
|
+
us: 1e3,
|
|
605
|
+
µs: 1e3,
|
|
606
|
+
μs: 1e3,
|
|
607
|
+
ms: 1e6,
|
|
608
|
+
s: 1e9,
|
|
609
|
+
m: 60e9,
|
|
610
|
+
h: 3600e9,
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
/** Go's `time.ParseDuration`, in nanoseconds. */
|
|
614
|
+
const goDuration = (value: string): number | undefined => {
|
|
615
|
+
let rest = value;
|
|
616
|
+
let sign = 1;
|
|
617
|
+
if (rest.startsWith("-") || rest.startsWith("+")) {
|
|
618
|
+
if (rest[0] === "-") sign = -1;
|
|
619
|
+
rest = rest.slice(1);
|
|
620
|
+
}
|
|
621
|
+
if (rest === "0") return 0;
|
|
622
|
+
if (rest === "") return undefined;
|
|
623
|
+
let total = 0;
|
|
624
|
+
while (rest !== "") {
|
|
625
|
+
const match = /^([0-9]*(?:\.[0-9]*)?)([^0-9.]+)/u.exec(rest);
|
|
626
|
+
if (match === null || match[1] === "" || match[1] === ".") return undefined;
|
|
627
|
+
const unit = DURATION_UNITS[match[2]!];
|
|
628
|
+
if (unit === undefined) return undefined;
|
|
629
|
+
total += Number(match[1]) * unit;
|
|
630
|
+
rest = rest.slice(match[0].length);
|
|
631
|
+
}
|
|
632
|
+
return sign * total;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
/** go-units `RAMInBytes`: `64m`, `1.5GiB`, `512 kb`. */
|
|
636
|
+
const ramInBytes = (value: string): boolean => {
|
|
637
|
+
const sep = Math.max(...Array.from("01234567890. ", (c) => value.lastIndexOf(c)));
|
|
638
|
+
if (sep === -1) return false;
|
|
639
|
+
const num = value[sep] === " " ? value.slice(0, sep) : value.slice(0, sep + 1);
|
|
640
|
+
const suffix = value.slice(sep + 1).toLowerCase();
|
|
641
|
+
if (!/^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/.test(num)) return false;
|
|
642
|
+
if (Number(num) < 0) return false;
|
|
643
|
+
if (suffix === "") return true;
|
|
644
|
+
if (suffix.length > 3) return false;
|
|
645
|
+
if (suffix[0] === "b") return suffix.length === 1;
|
|
646
|
+
if (!"kmgtp".includes(suffix[0]!)) return false;
|
|
647
|
+
return (
|
|
648
|
+
suffix.length === 1 || (suffix.length === 2 ? suffix[1] === "b" : suffix.slice(1) === "ib")
|
|
649
|
+
);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
/** `go-csvvalue` `Fields`: comma-separated, `"quoted"` with `""` escapes; `undefined` on a bad quote or empty input. */
|
|
653
|
+
const csvFields = (input: string): string[] | undefined => {
|
|
654
|
+
let line = input.replace(/\r?\n$/, "");
|
|
655
|
+
if (line === "") return undefined;
|
|
656
|
+
const fields: string[] = [];
|
|
657
|
+
for (;;) {
|
|
658
|
+
if (!line.startsWith('"')) {
|
|
659
|
+
const comma = line.indexOf(",");
|
|
660
|
+
const field = comma === -1 ? line : line.slice(0, comma);
|
|
661
|
+
if (field.includes('"')) return undefined;
|
|
662
|
+
fields.push(field);
|
|
663
|
+
if (comma === -1) return fields;
|
|
664
|
+
line = line.slice(comma + 1);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
line = line.slice(1);
|
|
668
|
+
let field = "";
|
|
669
|
+
for (;;) {
|
|
670
|
+
const quote = line.indexOf('"');
|
|
671
|
+
if (quote === -1) return undefined;
|
|
672
|
+
field += line.slice(0, quote);
|
|
673
|
+
line = line.slice(quote + 1);
|
|
674
|
+
if (line.startsWith('"')) {
|
|
675
|
+
field += '"';
|
|
676
|
+
line = line.slice(1);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (line.startsWith(",")) {
|
|
680
|
+
fields.push(field);
|
|
681
|
+
line = line.slice(1);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
if (line === "") {
|
|
685
|
+
fields.push(field);
|
|
686
|
+
return fields;
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const MOUNT_TYPES = ["bind", "cache", "tmpfs", "secret", "ssh"];
|
|
694
|
+
const SHARING_MODES = ["shared", "private", "locked"];
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* `RUN --mount`, checked as BuildKit does: when parsing (only `from` may
|
|
698
|
+
* not use a variable), then when docker build expands it.
|
|
699
|
+
*/
|
|
700
|
+
const checkMount = Effect.fn(function* (value: string, escape: string, line: number) {
|
|
701
|
+
const fields = csvFields(value);
|
|
702
|
+
if (fields === undefined) return yield* fail("failed to parse csv mounts", line);
|
|
703
|
+
for (const field of fields) {
|
|
704
|
+
const eq = field.indexOf("=");
|
|
705
|
+
if (eq === -1 || field.slice(0, eq).toLowerCase() !== "from") continue;
|
|
706
|
+
const v = field.slice(eq + 1);
|
|
707
|
+
const dollar = v.indexOf("$");
|
|
708
|
+
if (dollar !== -1 && dollar !== v.length - 1) {
|
|
709
|
+
return yield* fail(
|
|
710
|
+
"'from' doesn't support variable expansion, define alias stage instead",
|
|
711
|
+
line,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
let type = "bind";
|
|
716
|
+
let from = "";
|
|
717
|
+
let source = "";
|
|
718
|
+
let target = "";
|
|
719
|
+
let id = "";
|
|
720
|
+
let sharing = "";
|
|
721
|
+
const fileInfoKeys: string[] = [];
|
|
722
|
+
for (const field of fields) {
|
|
723
|
+
const eq = field.indexOf("=");
|
|
724
|
+
const key = (eq === -1 ? field : field.slice(0, eq)).toLowerCase();
|
|
725
|
+
if (eq === -1) {
|
|
726
|
+
if (["readonly", "ro", "readwrite", "rw"].includes(key)) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (key === "required") {
|
|
730
|
+
if (type === "secret" || type === "ssh") continue;
|
|
731
|
+
return yield* fail(`unexpected key '${key}' for mount type '${type}'`, line);
|
|
732
|
+
}
|
|
733
|
+
return yield* fail(`invalid field '${field}' must be a key=value pair`, line);
|
|
734
|
+
}
|
|
735
|
+
const v = yield* wordOf(field.slice(eq + 1), escape, line);
|
|
736
|
+
const invalid = () => fail(`invalid value for ${key}: ${v}`, line);
|
|
737
|
+
switch (key) {
|
|
738
|
+
case "type":
|
|
739
|
+
if (!MOUNT_TYPES.includes(v.toLowerCase())) {
|
|
740
|
+
return yield* fail(`unsupported mount type "${v}"`, line);
|
|
741
|
+
}
|
|
742
|
+
type = v.toLowerCase();
|
|
743
|
+
break;
|
|
744
|
+
case "from":
|
|
745
|
+
from = v;
|
|
746
|
+
break;
|
|
747
|
+
case "source":
|
|
748
|
+
case "src":
|
|
749
|
+
source = v;
|
|
750
|
+
break;
|
|
751
|
+
case "target":
|
|
752
|
+
case "dst":
|
|
753
|
+
case "destination":
|
|
754
|
+
target = v;
|
|
755
|
+
break;
|
|
756
|
+
case "readonly":
|
|
757
|
+
case "ro":
|
|
758
|
+
case "readwrite":
|
|
759
|
+
case "rw":
|
|
760
|
+
if (goBool(v) === undefined) return yield* invalid();
|
|
761
|
+
break;
|
|
762
|
+
case "required":
|
|
763
|
+
if (type !== "secret" && type !== "ssh") {
|
|
764
|
+
return yield* fail(`unexpected key '${key}' for mount type '${type}'`, line);
|
|
765
|
+
}
|
|
766
|
+
if (goBool(v) === undefined) return yield* invalid();
|
|
767
|
+
break;
|
|
768
|
+
case "size":
|
|
769
|
+
if (type !== "tmpfs") {
|
|
770
|
+
return yield* fail(`unexpected key '${key}' for mount type '${type}'`, line);
|
|
771
|
+
}
|
|
772
|
+
if (!ramInBytes(v)) return yield* invalid();
|
|
773
|
+
break;
|
|
774
|
+
case "id":
|
|
775
|
+
id = v;
|
|
776
|
+
break;
|
|
777
|
+
case "sharing":
|
|
778
|
+
if (!SHARING_MODES.includes(v.toLowerCase())) {
|
|
779
|
+
return yield* fail(`unsupported sharing value "${v}"`, line);
|
|
780
|
+
}
|
|
781
|
+
sharing = v.toLowerCase();
|
|
782
|
+
break;
|
|
783
|
+
case "mode":
|
|
784
|
+
if (!goUint32(v, 8)) return yield* fail(`invalid value ${v} for mode`, line);
|
|
785
|
+
fileInfoKeys.push("mode");
|
|
786
|
+
break;
|
|
787
|
+
case "uid":
|
|
788
|
+
case "gid":
|
|
789
|
+
if (!goUint32(v, 10)) return yield* fail(`invalid value ${v} for ${key}`, line);
|
|
790
|
+
fileInfoKeys.push(key);
|
|
791
|
+
break;
|
|
792
|
+
case "env":
|
|
793
|
+
break;
|
|
794
|
+
default:
|
|
795
|
+
return yield* fail(`unexpected key '${key}' in '${field}'`, line);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const fileInfo = type === "secret" || type === "ssh" || type === "cache";
|
|
799
|
+
for (const key of ["mode", "uid", "gid"]) {
|
|
800
|
+
if (!fileInfo && fileInfoKeys.includes(key)) {
|
|
801
|
+
return yield* fail(`${key} not allowed for "${type}" type mounts`, line);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (type === "secret") {
|
|
805
|
+
if (from !== "") return yield* fail("secret mount should not have a from", line);
|
|
806
|
+
if (sharing !== "") return yield* fail("secret mount should not define sharing", line);
|
|
807
|
+
if (source === "" && target === "" && id === "") {
|
|
808
|
+
return yield* fail("invalid secret mount. one of source, target required", line);
|
|
809
|
+
}
|
|
810
|
+
if (source !== "" && id !== "") return yield* fail("both source and id can't be set", line);
|
|
811
|
+
}
|
|
812
|
+
if (sharing !== "" && type !== "cache") {
|
|
813
|
+
return yield* fail(`invalid cache sharing set for ${type} mount`, line);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
/** `RUN --device`: `name[,required[=bool]]`. */
|
|
818
|
+
const checkDevice = Effect.fn(function* (value: string, line: number) {
|
|
819
|
+
const fields = csvFields(value);
|
|
820
|
+
if (fields === undefined) return yield* fail("failed to parse csv devices", line);
|
|
821
|
+
let name = "";
|
|
822
|
+
for (const field of fields) {
|
|
823
|
+
const eq = field.indexOf("=");
|
|
824
|
+
const key = (eq === -1 ? field : field.slice(0, eq)).toLowerCase();
|
|
825
|
+
if (eq === -1) {
|
|
826
|
+
if (key === "required") continue;
|
|
827
|
+
if (name === "") {
|
|
828
|
+
name = field;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
return yield* fail(`invalid field '${field}' must be a key=value pair`, line);
|
|
832
|
+
}
|
|
833
|
+
const v = field.slice(eq + 1);
|
|
834
|
+
if (key === "name") {
|
|
835
|
+
if (name !== "") return yield* fail(`device name already set to ${name}`, line);
|
|
836
|
+
name = v;
|
|
837
|
+
} else if (key === "required") {
|
|
838
|
+
if (goBool(v) === undefined) return yield* fail(`invalid value for ${key}: ${v}`, line);
|
|
839
|
+
} else if (name === "") {
|
|
840
|
+
name = field;
|
|
841
|
+
} else {
|
|
842
|
+
return yield* fail(`unexpected key '${key}' in '${field}'`, line);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const checkRunFlags = Effect.fn(function* (
|
|
848
|
+
flags: ReadonlyArray<Flag>,
|
|
849
|
+
escape: string,
|
|
850
|
+
line: number,
|
|
851
|
+
) {
|
|
852
|
+
for (const flag of flags) {
|
|
853
|
+
const value = flag.value ?? "";
|
|
854
|
+
if (flag.name === "network" && !["default", "none", "host"].includes(value)) {
|
|
855
|
+
return yield* fail(`invalid network mode "${value}"`, line);
|
|
856
|
+
}
|
|
857
|
+
if (flag.name === "security" && !["insecure", "sandbox"].includes(value)) {
|
|
858
|
+
return yield* fail(`security "${value}" is not valid`, line);
|
|
859
|
+
}
|
|
860
|
+
if (flag.name === "device") yield* checkDevice(value, line);
|
|
861
|
+
if (flag.name === "mount") yield* checkMount(value, escape, line);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
const checkHealthcheckFlags = Effect.fn(function* (flags: ReadonlyArray<Flag>, line: number) {
|
|
866
|
+
for (const flag of flags) {
|
|
867
|
+
const value = flag.value ?? "";
|
|
868
|
+
if (value === "") continue;
|
|
869
|
+
if (flag.name === "retries") {
|
|
870
|
+
const retries = goInt32(value);
|
|
871
|
+
if (retries === undefined) return yield* fail(`invalid --retries value ${value}`, line);
|
|
872
|
+
if (retries < 0) return yield* fail(`--retries cannot be negative (${retries})`, line);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const duration = goDuration(value);
|
|
876
|
+
if (duration === undefined) return yield* fail(`time: invalid duration "${value}"`, line);
|
|
877
|
+
if (duration !== 0 && duration < 1e6) {
|
|
878
|
+
return yield* fail(`Interval "${flag.name}" cannot be less than 1ms`, line);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* What BuildKit's parser (before the instruction checks) rejects in an
|
|
885
|
+
* instruction: a JSON array of non-strings, and malformed `ENV`/`LABEL`
|
|
886
|
+
* pairs. All an `ONBUILD` trigger is checked for.
|
|
887
|
+
*/
|
|
888
|
+
const checkSyntax = Effect.fn(function* (text: string, escape: string, line: number) {
|
|
889
|
+
const [word, after] = splitInstruction(text);
|
|
890
|
+
const name = word.toUpperCase();
|
|
891
|
+
const rest = (after === "" ? "" : extractFlags(after, escape).rest).trim();
|
|
892
|
+
if (["RUN", "CMD", "ENTRYPOINT", "SHELL", "COPY", "ADD", "VOLUME"].includes(name)) {
|
|
893
|
+
if (rest !== "") yield* parseJson(rest, line);
|
|
894
|
+
} else if (name === "HEALTHCHECK") {
|
|
895
|
+
const command = splitInstruction(rest)[1];
|
|
896
|
+
if (command !== "") yield* parseJson(command, line);
|
|
897
|
+
} else if (name === "ENV" || name === "LABEL") {
|
|
898
|
+
yield* nameValues(rest, name, escape, line);
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
/** Every word processes; returns them processed. */
|
|
903
|
+
const wordsOf = Effect.fn(function* (words: ReadonlyArray<string>, escape: string, line: number) {
|
|
904
|
+
const out: string[] = [];
|
|
905
|
+
for (const word of words) out.push(yield* wordOf(word, escape, line));
|
|
906
|
+
return out;
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const isEmptyCommand = (command: Command) =>
|
|
910
|
+
command.form === "shell" ? command.script === "" : command.argv.length === 0;
|
|
911
|
+
|
|
912
|
+
type Parsed =
|
|
913
|
+
| { readonly stage: Omit<Stage, "instructions"> }
|
|
914
|
+
| { readonly instruction: Instruction };
|
|
915
|
+
|
|
916
|
+
const instructionOf = (
|
|
917
|
+
logical: Line,
|
|
918
|
+
text: string,
|
|
919
|
+
escape: string,
|
|
920
|
+
): Effect.Effect<Parsed, DockerfileSyntaxError> =>
|
|
921
|
+
Effect.gen(function* () {
|
|
922
|
+
const line = logical.start;
|
|
923
|
+
const [word, after] = splitInstruction(text);
|
|
924
|
+
const name = word.toUpperCase() as InstructionName;
|
|
925
|
+
if (!KNOWN.has(name)) return yield* fail(`unknown instruction: ${word}`, line);
|
|
926
|
+
const extracted = after === "" ? { rest: "", flags: [] } : extractFlags(after, escape);
|
|
927
|
+
const rest = extracted.rest.trim();
|
|
928
|
+
const raw = logical.raw;
|
|
929
|
+
// HEALTHCHECK NONE declares no flags and never checks them.
|
|
930
|
+
const flags =
|
|
931
|
+
name === "HEALTHCHECK" && /^none(\s|$)/i.test(rest)
|
|
932
|
+
? extracted.flags.map((raw) => {
|
|
933
|
+
const eq = raw.indexOf("=");
|
|
934
|
+
return eq === -1
|
|
935
|
+
? { name: raw.slice(2), value: undefined }
|
|
936
|
+
: { name: raw.slice(2, eq), value: raw.slice(eq + 1) };
|
|
937
|
+
})
|
|
938
|
+
: yield* validateFlags(name, extracted.flags, line);
|
|
939
|
+
const base = { line, raw, flags };
|
|
940
|
+
|
|
941
|
+
switch (name) {
|
|
942
|
+
case "FROM": {
|
|
943
|
+
const args = whitespaceList(rest);
|
|
944
|
+
let stageName: string | undefined;
|
|
945
|
+
if (args.length === 3 && args[1]!.toLowerCase() === "as") {
|
|
946
|
+
stageName = args[2]!.toLowerCase();
|
|
947
|
+
if (!VALID_STAGE_NAME.test(stageName)) {
|
|
948
|
+
return yield* fail(
|
|
949
|
+
`invalid name for build stage: "${args[2]}", name can't start with a number or contain symbols`,
|
|
950
|
+
line,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
} else if (args.length !== 1) {
|
|
954
|
+
return yield* fail("FROM requires either one or three arguments", line);
|
|
955
|
+
}
|
|
956
|
+
const image = yield* wordOf(args[0]!, escape, line);
|
|
957
|
+
return { stage: { line, raw, image, name: stageName, flags } };
|
|
958
|
+
}
|
|
959
|
+
case "RUN":
|
|
960
|
+
yield* checkRunFlags(flags, escape, line);
|
|
961
|
+
return {
|
|
962
|
+
instruction: {
|
|
963
|
+
...base,
|
|
964
|
+
_tag: name,
|
|
965
|
+
command: yield* commandOf(rest, line),
|
|
966
|
+
heredocs: logical.heredocs,
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
case "CMD":
|
|
970
|
+
case "ENTRYPOINT":
|
|
971
|
+
return { instruction: { ...base, _tag: name, command: yield* commandOf(rest, line) } };
|
|
972
|
+
case "SHELL": {
|
|
973
|
+
const command = yield* commandOf(rest, line);
|
|
974
|
+
if (isEmptyCommand(command))
|
|
975
|
+
return yield* fail("SHELL requires at least one argument", line);
|
|
976
|
+
if (command.form !== "exec") {
|
|
977
|
+
return yield* fail("SHELL requires the arguments to be in JSON form", line);
|
|
978
|
+
}
|
|
979
|
+
return { instruction: { ...base, _tag: name, command } };
|
|
980
|
+
}
|
|
981
|
+
case "COPY":
|
|
982
|
+
case "ADD": {
|
|
983
|
+
const json = yield* parseJson(rest, line);
|
|
984
|
+
const args = json ?? whitespaceList(rest);
|
|
985
|
+
if (args.length < 2) {
|
|
986
|
+
return yield* fail(
|
|
987
|
+
`${name} requires at least two arguments, but only one was provided. Destination could not be determined`,
|
|
988
|
+
line,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
if (json === undefined && (yield* heredocOf(args.at(-1)!, line)) !== undefined) {
|
|
992
|
+
return yield* fail(`${name} cannot accept a heredoc as a destination`, line);
|
|
993
|
+
}
|
|
994
|
+
// docker build expands chown, chmod and checksum, and every source
|
|
995
|
+
// and the destination, the JSON form's included.
|
|
996
|
+
yield* wordsOf(
|
|
997
|
+
flags
|
|
998
|
+
.filter((f) => ["chown", "chmod", "checksum"].includes(f.name))
|
|
999
|
+
.map((f) => f.value ?? ""),
|
|
1000
|
+
escape,
|
|
1001
|
+
line,
|
|
1002
|
+
);
|
|
1003
|
+
const words: string[] = [];
|
|
1004
|
+
for (const arg of args) {
|
|
1005
|
+
words.push(
|
|
1006
|
+
json === undefined && arg.startsWith("<<") ? arg : yield* wordOf(arg, escape, line),
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
instruction: {
|
|
1011
|
+
...base,
|
|
1012
|
+
_tag: name,
|
|
1013
|
+
sources: words.slice(0, -1),
|
|
1014
|
+
destination: words.at(-1)!,
|
|
1015
|
+
heredocs: logical.heredocs,
|
|
1016
|
+
},
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
case "ENV": {
|
|
1020
|
+
const pairs = yield* nameValues(rest, "ENV", escape, line);
|
|
1021
|
+
if (pairs.length === 0) return yield* fail("ENV requires at least one argument", line);
|
|
1022
|
+
const vars: Array<readonly [string, string]> = [];
|
|
1023
|
+
for (const [key, value] of pairs) {
|
|
1024
|
+
if (key.length === 0) return yield* fail("ENV names can not be blank", line);
|
|
1025
|
+
vars.push([yield* wordOf(key, escape, line), yield* wordOf(value, escape, line)]);
|
|
1026
|
+
}
|
|
1027
|
+
return { instruction: { ...base, _tag: name, vars } };
|
|
1028
|
+
}
|
|
1029
|
+
case "ARG": {
|
|
1030
|
+
const words = parseWords(rest, escape);
|
|
1031
|
+
if (words.length === 0) return yield* fail("ARG requires at least one argument", line);
|
|
1032
|
+
const vars: Array<readonly [string, string | undefined]> = [];
|
|
1033
|
+
for (const word of words) {
|
|
1034
|
+
const index = word.indexOf("=");
|
|
1035
|
+
if (index === 0) return yield* fail("ARG names can not be blank", line);
|
|
1036
|
+
vars.push(
|
|
1037
|
+
index === -1
|
|
1038
|
+
? [yield* wordOf(word, escape, line), undefined]
|
|
1039
|
+
: [
|
|
1040
|
+
yield* wordOf(word.slice(0, index), escape, line),
|
|
1041
|
+
yield* wordOf(word.slice(index + 1), escape, line),
|
|
1042
|
+
],
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
return { instruction: { ...base, _tag: name, vars } };
|
|
1046
|
+
}
|
|
1047
|
+
case "WORKDIR":
|
|
1048
|
+
case "USER":
|
|
1049
|
+
case "STOPSIGNAL":
|
|
1050
|
+
if (rest === "") return yield* fail(`${name} requires exactly one argument`, line);
|
|
1051
|
+
return { instruction: { ...base, _tag: name, value: yield* wordOf(rest, escape, line) } };
|
|
1052
|
+
case "MAINTAINER":
|
|
1053
|
+
if (rest === "") return yield* fail("MAINTAINER requires exactly one argument", line);
|
|
1054
|
+
return { instruction: { ...base, _tag: name, args: rest } };
|
|
1055
|
+
case "EXPOSE":
|
|
1056
|
+
case "VOLUME": {
|
|
1057
|
+
const args =
|
|
1058
|
+
name === "VOLUME"
|
|
1059
|
+
? ((yield* parseJson(rest, line)) ?? whitespaceList(rest))
|
|
1060
|
+
: whitespaceList(rest);
|
|
1061
|
+
if (args.length === 0) return yield* fail(`${name} requires at least one argument`, line);
|
|
1062
|
+
if (name === "VOLUME") {
|
|
1063
|
+
if (args.some((volume) => volume.trim() === "")) {
|
|
1064
|
+
return yield* fail("VOLUME specified can not be an empty string", line);
|
|
1065
|
+
}
|
|
1066
|
+
yield* wordsOf(args, escape, line);
|
|
1067
|
+
}
|
|
1068
|
+
return { instruction: { ...base, _tag: name, args: rest } };
|
|
1069
|
+
}
|
|
1070
|
+
case "LABEL": {
|
|
1071
|
+
const pairs = yield* nameValues(rest, "LABEL", escape, line);
|
|
1072
|
+
if (pairs.length === 0) return yield* fail("LABEL requires at least one argument", line);
|
|
1073
|
+
if (pairs.some(([key]) => key.length === 0)) {
|
|
1074
|
+
return yield* fail("LABEL names can not be blank", line);
|
|
1075
|
+
}
|
|
1076
|
+
yield* wordsOf(pairs.flat(), escape, line);
|
|
1077
|
+
return { instruction: { ...base, _tag: name, args: rest } };
|
|
1078
|
+
}
|
|
1079
|
+
case "HEALTHCHECK": {
|
|
1080
|
+
if (rest === "") return yield* fail("HEALTHCHECK requires at least one argument", line);
|
|
1081
|
+
const [type, command] = splitInstruction(rest);
|
|
1082
|
+
if (type.toUpperCase() === "NONE") {
|
|
1083
|
+
if (command !== "") return yield* fail("HEALTHCHECK NONE takes no arguments", line);
|
|
1084
|
+
} else if (type.toUpperCase() !== "CMD") {
|
|
1085
|
+
return yield* fail(`Unknown type "${type.toUpperCase()}" in HEALTHCHECK (try CMD)`, line);
|
|
1086
|
+
} else if (isEmptyCommand(yield* commandOf(command, line))) {
|
|
1087
|
+
return yield* fail("Missing command after HEALTHCHECK CMD", line);
|
|
1088
|
+
} else {
|
|
1089
|
+
yield* checkHealthcheckFlags(flags, line);
|
|
1090
|
+
}
|
|
1091
|
+
return { instruction: { ...base, _tag: name, args: rest } };
|
|
1092
|
+
}
|
|
1093
|
+
case "ONBUILD": {
|
|
1094
|
+
if (rest === "") return yield* fail("ONBUILD requires at least one argument", line);
|
|
1095
|
+
const trigger = splitInstruction(rest)[0].toUpperCase();
|
|
1096
|
+
if (trigger === "ONBUILD") {
|
|
1097
|
+
return yield* fail("Chaining ONBUILD via `ONBUILD ONBUILD` isn't allowed", line);
|
|
1098
|
+
}
|
|
1099
|
+
if (trigger === "MAINTAINER" || trigger === "FROM") {
|
|
1100
|
+
return yield* fail(`${trigger} isn't allowed as an ONBUILD trigger`, line);
|
|
1101
|
+
}
|
|
1102
|
+
// BuildKit parses the trigger, but checks it only when a child image runs it.
|
|
1103
|
+
yield* checkSyntax(rest, escape, line);
|
|
1104
|
+
return { instruction: { ...base, _tag: name, args: rest } };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Parse Dockerfile text into stages and instructions, as BuildKit reads it:
|
|
1111
|
+
* the same directives, continuations, heredocs, word splitting and quoting,
|
|
1112
|
+
* and the same files rejected. Values have their quotes and escapes
|
|
1113
|
+
* resolved; variables are left as written for the builder to expand.
|
|
1114
|
+
*/
|
|
1115
|
+
export const parse = (text: string): Effect.Effect<ParsedDockerfile, DockerfileSyntaxError> =>
|
|
1116
|
+
Effect.gen(function* () {
|
|
1117
|
+
const { directives, escape, lines } = yield* readLines(text);
|
|
1118
|
+
if (lines.length === 0) return yield* fail("file with no instructions", 1);
|
|
1119
|
+
const globalArgs: ArgInstruction[] = [];
|
|
1120
|
+
const stages: Array<Omit<Stage, "instructions"> & { instructions: Instruction[] }> = [];
|
|
1121
|
+
for (const logical of lines) {
|
|
1122
|
+
const parsed = yield* instructionOf(logical, logical.text, escape);
|
|
1123
|
+
if ("stage" in parsed) {
|
|
1124
|
+
stages.push({ ...parsed.stage, instructions: [] });
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const stage = stages.at(-1);
|
|
1128
|
+
if (stage !== undefined) {
|
|
1129
|
+
stage.instructions.push(parsed.instruction);
|
|
1130
|
+
} else if (parsed.instruction._tag === "ARG") {
|
|
1131
|
+
globalArgs.push(parsed.instruction);
|
|
1132
|
+
} else {
|
|
1133
|
+
return yield* fail("no build stage in current context", logical.start);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (stages.length === 0) return yield* fail("no build stage in current context", 1);
|
|
1137
|
+
return { directives, globalArgs, stages } satisfies ParsedDockerfile;
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Render a parsed Dockerfile back to text, one instruction per entry, each
|
|
1142
|
+
* exactly as written (continuations and heredoc bodies included).
|
|
1143
|
+
*/
|
|
1144
|
+
export const renderLines = (dockerfile: ParsedDockerfile): string[] => [
|
|
1145
|
+
...Object.entries(dockerfile.directives).map(([key, value]) => `# ${key}=${value}`),
|
|
1146
|
+
...dockerfile.globalArgs.map((arg) => arg.raw),
|
|
1147
|
+
...dockerfile.stages.flatMap((stage) => [stage.raw, ...stage.instructions.map((i) => i.raw)]),
|
|
1148
|
+
];
|
|
1149
|
+
|
|
1150
|
+
/** Render a parsed Dockerfile back to text. */
|
|
1151
|
+
export const render = (dockerfile: ParsedDockerfile): string =>
|
|
1152
|
+
`${renderLines(dockerfile).join("\n")}\n`;
|