Native Builder: Add parser support for CMD and LABEL instructions (#448)

This PR adds support for CMD and LABEL instructions in the native
builder's parser.

This also changes how options are tokenized. Options now include the raw
string so that when constructing a command for instructions like CMD and
RUN, we can use the exact user input without having to add logic in the
tokenizer to know when we're parsing a command verses other options,
etc.

Closes https://github.com/apple/container/issues/428 and
https://github.com/apple/container/issues/429

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
This commit is contained in:
Kathryn Baldauf
2025-08-05 14:34:34 -07:00
committed by GitHub
parent f2130a9604
commit 8adb215522
11 changed files with 477 additions and 92 deletions
@@ -179,8 +179,20 @@ public final class GraphBuilder {
network: NetworkMode = .default,
) throws -> Self {
let cmd = shell ? Command.shell(command) : Command.exec(command.split(separator: " ").map(String.init))
let envVars = env.map { (key: $0.key, value: EnvironmentValue.literal($0.value)) }
return try runWithCmd(cmd, shell: shell, env: env, workdir: workdir, user: user, mounts: mounts, network: network)
}
@discardableResult
public func runWithCmd(
_ cmd: Command,
shell: Bool = true,
env: [String: String] = [:],
workdir: String? = nil,
user: User? = nil,
mounts: [Mount] = [],
network: NetworkMode = .default,
) throws -> Self {
let envVars = env.map { (key: $0.key, value: EnvironmentValue.literal($0.value)) }
let operation = ExecOperation(
command: cmd,
environment: Environment(envVars),
@@ -280,6 +292,14 @@ public final class GraphBuilder {
return try add(operation)
}
@discardableResult
public func labelBatch(labels: [String: String]) throws -> Self {
let operation = MetadataOperation(
action: .setLabelBatch(labels)
)
return try add(operation)
}
/// Expose port
@discardableResult
public func expose(_ port: Int, protocolType: PortSpec.NetworkProtocol = .tcp) throws -> Self {
@@ -20,6 +20,8 @@ protocol InstructionVisitor {
func visit(_ from: FromInstruction) throws
func visit(_ run: RunInstruction) throws
func visit(_ copy: CopyInstruction) throws
func visit(_ cmd: CMDInstruction) throws
func visit(_ label: LabelInstruction) throws
}
/// DockerInstructionVisitor visits each provided DockerInstruction and builds a
@@ -114,7 +116,7 @@ extension DockerInstructionVisitor {
mounts.append(graphMount)
}
try graphBuilder.run(run.command, shell: run.shell, mounts: mounts)
try graphBuilder.runWithCmd(run.command, mounts: mounts)
}
func visit(_ copy: CopyInstruction) throws {
@@ -136,4 +138,12 @@ extension DockerInstructionVisitor {
}
try graphBuilder.copyFromContext(paths: copy.sources, to: copy.destination, chown: copy.chown, chmod: copy.chmod)
}
func visit(_ cmd: CMDInstruction) throws {
try graphBuilder.cmd(cmd.command)
}
func visit(_ label: LabelInstruction) throws {
try graphBuilder.labelBatch(labels: label.labels)
}
}
@@ -63,6 +63,12 @@ public struct DockerfileParser: BuildParser {
return try tokensToFromInstruction(tokens: tokens)
case .RUN:
return try tokensToRunInstruction(tokens: tokens)
case .COPY:
return try tokensToCopyInstruction(tokens: tokens)
case .CMD:
return try tokensToCMDInstruction(tokens: tokens)
case .LABEL:
return try tokensToLabelInstruction(tokens: tokens)
default:
throw ParseError.invalidInstruction(value)
}
@@ -78,13 +84,13 @@ public struct DockerfileParser: BuildParser {
// Step 1: parse options
while index < tokens.endIndex {
guard case .option(let key, let value) = tokens[index] else {
guard case .option(let option) = tokens[index] else {
break
}
guard FromOptions(rawValue: key) == .platform else {
guard FromOptions(rawValue: option.key) == .platform else {
throw ParseError.unexpectedValue
}
platform = value
platform = option.value
index += 1
}
@@ -133,29 +139,42 @@ public struct DockerfileParser: BuildParser {
// Step 1: parse options
while index < tokens.endIndex {
guard case .option(let key, let value) = tokens[index] else {
guard case .option(let option) = tokens[index] else {
break
}
guard let option = RunOptions(rawValue: key) else {
guard let runOpt = RunOptions(rawValue: option.key) else {
throw ParseError.unexpectedValue
}
switch option {
switch runOpt {
case .mount:
rawMounts.append(value)
rawMounts.append(option.value)
case .network:
network = value
network = option.value
default:
throw ParseError.unexpectedValue
}
index += 1
}
// Step 2: parse run command
let (newIndex, cmd) = getCommand(start: index, tokens: tokens)
index = newIndex
// check for extra tokens
if index < tokens.endIndex {
throw ParseError.unexpectedValue
}
return try RunInstruction(command: cmd, rawMounts: rawMounts, network: network)
}
private func getCommand(start: Int, tokens: [Token]) -> (index: Int, cmd: Command) {
var command = [String]()
var shell = true
// Step 2: parse run command and if we're using shell or exec form
var index = start
while index < tokens.endIndex {
if case .stringList(let value) = tokens[index], command.isEmpty {
// when using the exec form, there should only be a single list for the command
@@ -166,18 +185,16 @@ public struct DockerfileParser: BuildParser {
break
} else if case .stringLiteral(let value) = tokens[index] {
command.append(value)
} else if case .option(let option) = tokens[index] {
command.append(option.raw)
} else {
break
}
index += 1
}
// check for extra tokens
if index < tokens.endIndex {
throw ParseError.unexpectedValue
}
return try RunInstruction(command: command, shell: shell, rawMounts: rawMounts, network: network)
let cmd = shell ? Command.shell(command.joined(separator: " ")) : Command.exec(command)
return (index, cmd)
}
internal func tokensToCopyInstruction(tokens: [Token]) throws -> CopyInstruction {
@@ -191,35 +208,35 @@ public struct DockerfileParser: BuildParser {
// Step 1: parse options
while index < tokens.endIndex {
guard case .option(let key, let value) = tokens[index] else {
guard case .option(let option) = tokens[index] else {
break
}
guard let option = CopyOptions(rawValue: key) else {
guard let copyOpt = CopyOptions(rawValue: option.key) else {
throw ParseError.unexpectedValue
}
switch option {
switch copyOpt {
case .from:
if from != nil {
throw ParseError.duplicateOptionSet(CopyOptions.from.rawValue)
}
from = value
from = option.value
case .chown:
if chown != nil {
throw ParseError.duplicateOptionSet(CopyOptions.chown.rawValue)
}
chown = value
chown = option.value
case .chmod:
if chmod != nil {
throw ParseError.duplicateOptionSet(CopyOptions.chmod.rawValue)
}
chmod = value
chmod = option.value
case .link:
if link != nil {
throw ParseError.duplicateOptionSet(CopyOptions.link.rawValue)
}
link = value
link = option.value
default:
throw ParseError.unexpectedValue
}
@@ -250,4 +267,48 @@ public struct DockerfileParser: BuildParser {
return try CopyInstruction(sources: sources, destination: destination, from: from, ownership: chown, permissions: chmod)
}
internal func tokensToCMDInstruction(tokens: [Token]) throws -> CMDInstruction {
var index = tokens.startIndex
index += 1
// get the command
let (newIndex, cmd) = getCommand(start: index, tokens: tokens)
index = newIndex
// check for extra tokens
if index < tokens.endIndex {
throw ParseError.unexpectedValue
}
return CMDInstruction(command: cmd)
}
internal func tokensToLabelInstruction(tokens: [Token]) throws -> LabelInstruction {
var index = tokens.startIndex
index += 1
var labels: [String: String] = [:]
while index < tokens.endIndex {
guard case .stringLiteral(let option) = tokens[index] else {
break
}
let components = option.split(separator: "=", maxSplits: 1)
guard components.count == 2 else {
throw ParseError.unexpectedValue
}
let key = String(components[0])
guard labels[key] == nil else {
throw ParseError.duplicateOptionSet(key)
}
labels[key] = String(components[1])
index += 1
}
// check for extra tokens
if index < tokens.endIndex {
throw ParseError.unexpectedValue
}
return LabelInstruction(labels: labels)
}
}
@@ -135,10 +135,11 @@ struct DockerfileTokenizer {
let valueStart = position
parseWord()
let rawValue = input[valueStart..<position]
return .option(String(rawWord), String(rawValue))
let raw = input[wordStart..<position]
return .option(Option(key: String(rawWord), value: String(rawValue), raw: String(raw)))
}
// split by equal
let optionComponents = rawWord.split(separator: "=", maxSplits: 1)
return .option(String(optionComponents[0]), String(optionComponents[1]))
return .option(Option(key: String(optionComponents[0]), value: String(optionComponents[1]), raw: String(rawWord)))
}
}
@@ -23,7 +23,7 @@ enum CopyOptions: String {
case link = "--link"
}
struct CopyInstruction: DockerInstruction, Equatable {
struct CopyInstruction: DockerInstruction {
let sources: [String]
let destination: String
let from: String?
@@ -19,7 +19,7 @@ import ContainerizationOCI
/// DockerInstruction represents a single docker instruction with its given options
/// and arguments. Instructions are "visited" to add to a build graph.
protocol DockerInstruction {
protocol DockerInstruction: Sendable, Equatable {
func accept(_ visitor: DockerInstructionVisitor) throws
}
@@ -27,7 +27,7 @@ enum FromOptions: String {
case platform = "--platform"
}
struct FromInstruction: DockerInstruction, Equatable {
struct FromInstruction: DockerInstruction {
let image: ImageReference
let platform: Platform?
let stageName: String?
@@ -55,6 +55,9 @@ struct FromInstruction: DockerInstruction, Equatable {
enum DockerInstructionName: String {
case FROM = "from"
case RUN = "run"
case COPY = "copy"
case CMD = "cmd"
case LABEL = "label"
}
/// DockerKeyword defines words that are used as keywords within a line of a dockerfile
@@ -62,3 +65,18 @@ enum DockerInstructionName: String {
enum DockerKeyword: String {
case AS = "as"
}
struct CMDInstruction: DockerInstruction {
let command: Command
func accept(_ visitor: DockerInstructionVisitor) throws {
try visitor.visit(self)
}
}
struct LabelInstruction: DockerInstruction {
let labels: [String: String]
func accept(_ visitor: DockerInstructionVisitor) throws {
try visitor.visit(self)
}
}
@@ -257,21 +257,18 @@ struct RunMountOptions: Equatable {
/// RunInstruction represents a RUN instruction from a dockerfile
struct RunInstruction: DockerInstruction, Equatable {
let command: String
let shell: Bool
let command: Command
let mounts: [RunMount]
let network: NetworkMode
init() {
self.command = ""
self.shell = false
self.command = .shell("")
self.mounts = []
self.network = .default
}
init(command: [String], shell: Bool, rawMounts: [String], network: String?) throws {
self.command = command.joined(separator: " ")
self.shell = shell
init(command: Command, rawMounts: [String], network: String?) throws {
self.command = command
self.network = try RunInstruction.parseNetworkMode(mode: network)
var parsedMounts: [RunMount] = []
for m in rawMounts {
@@ -41,5 +41,17 @@ public enum ParseError: Error, Equatable {
public enum Token: Sendable, Equatable {
case stringLiteral(String)
case stringList([String])
case option(String, String)
case option(Option)
}
public struct Option: Sendable, Equatable {
let key: String
let value: String
let raw: String
init(key: String, value: String, raw: String) {
self.key = key
self.value = value
self.raw = raw
}
}
@@ -93,9 +93,140 @@ import Testing
try parser.parse(dockerfile)
}
}
@Test func testSimpleDockerfileWithCopy() throws {
let imageRef = ImageReference(parsing: "alpine:latest")
#expect(imageRef != nil, "Failed to parse image reference")
let dockerfile =
#"""
FROM alpine AS build-context
FROM alpine AS stage-two
COPY --from=build-context /test /test
"""#
let parser = DockerfileParser()
let actualGraph = try parser.parse(dockerfile)
#expect(!actualGraph.stages.isEmpty)
let stages = actualGraph.stages
#expect(stages.count == 2, "expected 2 stages, instead got \(stages.count)")
#expect(stages[0].name == "build-context", "expected stage name build-context, got \(stages[0].name)")
#expect(stages[1].name == "stage-two", "expected stage name stage-two, got \(stages[1].name)")
#expect(stages[1].nodes.count == 1, "expected 1 node, instead got \(stages[1].nodes.count)")
let node = stages[1].nodes[0]
#expect(node.operation is FilesystemOperation)
let copy = node.operation as! FilesystemOperation
#expect(copy.action == .copy)
}
@Test func testSimpleDockerfileRun() throws {
let imageRef = ImageReference(parsing: "alpine:latest")
#expect(imageRef != nil, "Failed to parse image reference")
let dockerfile =
#"""
FROM alpine:latest AS build
RUN ["ls", "-la"]
"""#
let parser = DockerfileParser()
let actualGraph = try parser.parse(dockerfile)
#expect(!actualGraph.stages.isEmpty)
let stages = actualGraph.stages
#expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)")
let stage = stages[0]
#expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)")
let run = stage.nodes[0].operation as! ExecOperation
#expect(run.command.displayString == "ls -la")
}
@Test func testSimpleDockerfileRunShell() throws {
let imageRef = ImageReference(parsing: "alpine:latest")
#expect(imageRef != nil, "Failed to parse image reference")
let dockerfile =
#"""
FROM alpine:latest AS build
RUN build.sh --verbose
"""#
let parser = DockerfileParser()
let actualGraph = try parser.parse(dockerfile)
#expect(!actualGraph.stages.isEmpty)
let stages = actualGraph.stages
#expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)")
let stage = stages[0]
#expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)")
let run = stage.nodes[0].operation as! ExecOperation
#expect(run.command.displayString == "build.sh --verbose")
}
@Test func testSimpleDockerfileLabel() throws {
let imageRef = ImageReference(parsing: "alpine:latest")
#expect(imageRef != nil, "Failed to parse image reference")
let dockerfile =
#"""
FROM alpine:latest AS build
LABEL label1=value1
"""#
let parser = DockerfileParser()
let actualGraph = try parser.parse(dockerfile)
#expect(!actualGraph.stages.isEmpty)
let stages = actualGraph.stages
#expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)")
let stage = stages[0]
#expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)")
let label = stage.nodes[0].operation as! MetadataOperation
#expect(label.action == .setLabelBatch(["label1": "value1"]))
}
@Test func testSimpleDockerfileCMD() throws {
let imageRef = ImageReference(parsing: "alpine:latest")
#expect(imageRef != nil, "Failed to parse image reference")
let dockerfile =
#"""
FROM alpine:latest AS build
CMD ["./test.sh", "--verbose"]
"""#
let parser = DockerfileParser()
let actualGraph = try parser.parse(dockerfile)
#expect(!actualGraph.stages.isEmpty)
let stages = actualGraph.stages
#expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)")
let stage = stages[0]
#expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)")
let cmd = stage.nodes[0].operation as! MetadataOperation
switch cmd.action {
case .setCmd(let command):
#expect(command.displayString == "./test.sh --verbose")
default:
Issue.record("expected .setCmd action type, instead got \(cmd.action)")
return
}
}
}
// tests for parsing RUN mount options
// tests for parsing options for the different instructions
extension ParserTest {
struct RunMountTestCase {
let rawMount: String
@@ -46,7 +46,7 @@ import Testing
input: "RUN --mount=type=cache /app",
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringLiteral("/app"),
]
),
@@ -54,7 +54,7 @@ import Testing
input: "RUN --network=default /app",
expectedTokens: [
.stringLiteral("RUN"),
.option("--network", "default"),
.option(Option(key: "--network", value: "default", raw: "--network=default")),
.stringLiteral("/app"),
]
),
@@ -62,8 +62,8 @@ import Testing
input: "RUN --mount=type=bind,target=/target --network=host build.sh",
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=bind,target=/target"),
.option("--network", "host"),
.option(Option(key: "--mount", value: "type=bind,target=/target", raw: "--mount=type=bind,target=/target")),
.option(Option(key: "--network", value: "host", raw: "--network=host")),
.stringLiteral("build.sh"),
]
),
@@ -71,7 +71,7 @@ import Testing
input: "RUN --mount type=cache /app",
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount type=cache")),
.stringLiteral("/app"),
]
),
@@ -82,9 +82,9 @@ import Testing
""",
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringLiteral("build.sh"),
.option("--input", "hello"),
.option(Option(key: "--input", value: "hello", raw: "--input hello")),
]
),
tokenizerTestInput(
@@ -94,7 +94,7 @@ import Testing
"""#,
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringList(["build.sh", "--input", "hello"]),
]
),
@@ -105,7 +105,7 @@ import Testing
"""#,
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringList(["build.sh", "--input", "hello"]),
]
),
@@ -116,7 +116,7 @@ import Testing
"""#,
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringLiteral("build.sh --input hello"),
]
),
@@ -128,7 +128,7 @@ import Testing
"""#,
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringLiteral("build.sh --input hello"),
]
),
@@ -139,7 +139,7 @@ import Testing
"""#,
expectedTokens: [
.stringLiteral("RUN"),
.option("--mount", "type=cache"),
.option(Option(key: "--mount", value: "type=cache", raw: "--mount=type=cache")),
.stringLiteral("build.sh --input hello"),
]
),
@@ -147,7 +147,7 @@ import Testing
input: "COPY --from=alpine src /dest",
expectedTokens: [
.stringLiteral("COPY"),
.option("--from", "alpine"),
.option(Option(key: "--from", value: "alpine", raw: "--from=alpine")),
.stringLiteral("src"),
.stringLiteral("/dest"),
]
@@ -157,7 +157,7 @@ import Testing
input: "COPY --from=alpine src src1 src2 src3 /dest",
expectedTokens: [
.stringLiteral("COPY"),
.option("--from", "alpine"),
.option(Option(key: "--from", value: "alpine", raw: "--from=alpine")),
.stringLiteral("src"),
.stringLiteral("src1"),
.stringLiteral("src2"),
@@ -169,7 +169,7 @@ import Testing
input: "COPY --chown=10:11 src /dest",
expectedTokens: [
.stringLiteral("COPY"),
.option("--chown", "10:11"),
.option(Option(key: "--chown", value: "10:11", raw: "--chown=10:11")),
.stringLiteral("src"),
.stringLiteral("/dest"),
]
@@ -178,7 +178,7 @@ import Testing
input: "COPY --chown=bin stuff.txt /stuffdest/",
expectedTokens: [
.stringLiteral("COPY"),
.option("--chown", "bin"),
.option(Option(key: "--chown", value: "bin", raw: "--chown=bin")),
.stringLiteral("stuff.txt"),
.stringLiteral("/stuffdest/"),
]
@@ -187,7 +187,7 @@ import Testing
input: "COPY --chown=1 source /destination",
expectedTokens: [
.stringLiteral("COPY"),
.option("--chown", "1"),
.option(Option(key: "--chown", value: "1", raw: "--chown=1")),
.stringLiteral("source"),
.stringLiteral("/destination"),
]
@@ -196,7 +196,7 @@ import Testing
input: "COPY --chmod=440 src /dest/",
expectedTokens: [
.stringLiteral("COPY"),
.option("--chmod", "440"),
.option(Option(key: "--chmod", value: "440", raw: "--chmod=440")),
.stringLiteral("src"),
.stringLiteral("/dest/"),
]
@@ -205,11 +205,52 @@ import Testing
input: "COPY --link=false src /dest/",
expectedTokens: [
.stringLiteral("COPY"),
.option("--link", "false"),
.option(Option(key: "--link", value: "false", raw: "--link=false")),
.stringLiteral("src"),
.stringLiteral("/dest/"),
]
),
tokenizerTestInput(
input: "LABEL key=value",
expectedTokens: [
.stringLiteral("LABEL"),
.stringLiteral("key=value"),
]
),
tokenizerTestInput(
input:
#"""
LABEL key=value anotherkey=anothervalue quoted="quotelabel"
"""#,
expectedTokens: [
.stringLiteral("LABEL"),
.stringLiteral("key=value"),
.stringLiteral("anotherkey=anothervalue"),
.stringLiteral("quoted=\"quotelabel\""),
]
),
tokenizerTestInput(
input:
#"""
CMD ["executable", "param1", "param2"]
"""#,
expectedTokens: [
.stringLiteral("CMD"),
.stringList(["executable", "param1", "param2"]),
]
),
tokenizerTestInput(
input:
#"""
CMD command param1 param2
"""#,
expectedTokens: [
.stringLiteral("CMD"),
.stringLiteral("command"),
.stringLiteral("param1"),
.stringLiteral("param2"),
]
),
]
@Test func testTokenization() throws {
@@ -235,9 +276,9 @@ import Testing
return true
}
struct TokenTest {
struct TokenTest: Sendable {
let tokens: [Token]
let expectedInstruction: DockerInstruction
let expectedInstruction: any DockerInstruction
}
@Test func tokenTranslationFrom() throws {
@@ -261,7 +302,7 @@ import Testing
TokenTest(
tokens: [
.stringLiteral("FROM"),
.option("--platform", "linux/arm64"),
.option(Option(key: "--platform", value: "linux/arm64", raw: "--platform=linux/arm64")),
.stringLiteral("alpine"),
],
expectedInstruction: try FromInstruction(image: "alpine", platform: "linux/arm64")
@@ -282,53 +323,45 @@ import Testing
@Test func testTokensToRunWithShellCommand() throws {
let tokens: [Token] = [
.stringLiteral("RUN"),
.option("--mount", "type=cache,target=/cache"),
.option(Option(key: "--mount", value: "type=cache,target=/cache", raw: "--mount=type=cache,target=/cache")),
.stringLiteral("build.sh --input hello"),
]
let parser = DockerfileParser()
let actual = try parser.tokensToRunInstruction(tokens: tokens)
#expect(actual.shell)
#expect(actual.command == "build.sh --input hello")
#expect(actual.command.displayString == "build.sh --input hello")
}
@Test func testTokensToRunWithoutShell() throws {
let command = ["build.sh", "--input", "hello"]
let tokens: [Token] = [
.stringLiteral("RUN"),
.option("--mount", "type=cache,target=/mytarget"),
.option(Option(key: "--mount", value: "type=cache,target=/mytarget", raw: "--mount=type=cache,target=/mytarget")),
.stringList(command),
]
let parser = DockerfileParser()
let actual = try parser.tokensToRunInstruction(tokens: tokens)
#expect(actual.shell == false)
#expect(actual.command == command.joined(separator: " "))
#expect(actual.command.displayString == command.joined(separator: " "))
}
static let extraTokensTests: [[Token]] = [
[
.stringLiteral("RUN"),
.option("--mount", "type=tmpfs,size=1000"),
.option(Option(key: "--mount", value: "type=tmpfs,size=1000", raw: "--mount=type=tmpfs,size=1000")),
.stringList(["build.sh", "--input", "hello"]),
.stringLiteral("extra"),
],
[
.stringLiteral("RUN"),
.option("--mount", "type=bind,target=/target"),
.option(Option(key: "--mount", value: "type=bind,target=/target", raw: "--mount=type=bind,target=/target")),
.stringLiteral("build.sh"),
.stringLiteral("--input"),
.stringLiteral("hello"),
.stringList(["extra"]),
],
[
.stringLiteral("RUN"),
.option("--mount", "type=bind,target=/target"),
.stringLiteral("build.sh"),
.option("key", "value"),
],
]
@Test("Parsing to run instruction fails when there's extra tokens", arguments: extraTokensTests)
@@ -344,7 +377,7 @@ import Testing
TokenTest(
tokens: [
.stringLiteral("COPY"),
.option("--link", "false"),
.option(Option(key: "--link", value: "false", raw: "--link=false")),
.stringLiteral("src"),
.stringLiteral("/dest/"),
],
@@ -356,7 +389,7 @@ import Testing
TokenTest(
tokens: [
.stringLiteral("COPY"),
.option("--chmod", "440"),
.option(Option(key: "--chmod", value: "440", raw: "--chmod=440")),
.stringLiteral("src"),
.stringLiteral("/dest"),
],
@@ -369,7 +402,7 @@ import Testing
TokenTest(
tokens: [
.stringLiteral("COPY"),
.option("--chown", "11:mygroup"),
.option(Option(key: "--chown", value: "11:mygroup", raw: "--chown 11:mygroup")),
.stringLiteral("source"),
.stringLiteral("destination"),
],
@@ -382,7 +415,7 @@ import Testing
TokenTest(
tokens: [
.stringLiteral("COPY"),
.option("--from", "alpine"),
.option(Option(key: "--from", value: "alpine", raw: "--from=alpine")),
.stringLiteral("src"),
.stringLiteral("src1"),
.stringLiteral("src2"),
@@ -398,7 +431,7 @@ import Testing
TokenTest(
tokens: [
.stringLiteral("COPY"),
.option("--from", "base"),
.option(Option(key: "--from", value: "base", raw: "--from base")),
.stringLiteral("Source"),
.stringLiteral("Dest"),
],
@@ -430,7 +463,7 @@ import Testing
[
// no sources
.stringLiteral("COPY"),
.option("--from", "alpine"),
.option(Option(key: "--from", value: "alpine", raw: "--from=alpine")),
],
]
@@ -441,4 +474,50 @@ import Testing
let _ = try parser.tokensToCopyInstruction(tokens: tokens)
}
}
static let cmdTokenTests: [TokenTest] = [
TokenTest(
tokens: [
.stringLiteral("CMD"),
.stringList(["executable", "param1", "param2"]),
],
expectedInstruction: CMDInstruction(command: .exec(["executable", "param1", "param2"]))
),
TokenTest(
tokens: [
.stringLiteral("CMD"),
.stringLiteral("command"),
.stringLiteral("param1"),
.stringLiteral("param2"),
], expectedInstruction: CMDInstruction(command: .shell("command param1 param2"))
),
]
@Test("Successful tokens to CMD Instruction conversion", arguments: cmdTokenTests)
func testTokensToCMDInstruction(_ testCase: TokenTest) throws {
let parser = DockerfileParser()
let actual = try parser.tokensToCMDInstruction(tokens: testCase.tokens)
guard let expected = testCase.expectedInstruction as? CMDInstruction else {
Issue.record("Instruction is not the correct type, \(testCase.expectedInstruction)")
return
}
#expect(actual == expected)
}
@Test func testTokensToLabelInstruction() throws {
let tokens: [Token] = [
.stringLiteral("LABEL"),
.stringLiteral("key=value"),
.stringLiteral("anotherkey=anothervalue"),
.stringLiteral("quoted=\"quotelabel\""),
]
let expectedLabels: [String: String] = [
"key": "value",
"anotherkey": "anothervalue",
"quoted": "\"quotelabel\"",
]
let expectedInstruction = LabelInstruction(labels: expectedLabels)
let actual = try DockerfileParser().tokensToLabelInstruction(tokens: tokens)
#expect(actual == expectedInstruction)
}
}
@@ -20,6 +20,8 @@ import Testing
@testable import ContainerBuildParser
/// VisitorTest covers testing that visiting instructions is successful and graphs
/// made from those instructions are structured as expected.
@Suite class VisitorTest {
@Test func simpleVisitFrom() throws {
let imageName = "alpine"
@@ -42,11 +44,10 @@ import Testing
@Test func simpleVisitRun() throws {
let from = try FromInstruction(image: "scratch")
let command = ["sh", "-c", "top"]
let command: Command = .exec(["sh", "-c", "top"])
let network = "default"
let run = try RunInstruction(
command: command,
shell: false,
rawMounts: [],
network: network
)
@@ -65,12 +66,8 @@ import Testing
#expect(node.operation is ExecOperation)
guard let exec = node.operation as? ExecOperation else {
Issue.record("expected ExecOperation, instead got \(node.operation)")
return
}
#expect(exec.command.arguments == command)
let exec = node.operation as! ExecOperation
#expect(exec.command == command)
#expect(exec.mounts.isEmpty)
#expect(exec.network == .default)
}
@@ -99,11 +96,9 @@ import Testing
#expect(stage.nodes.count == 1)
let node = stage.nodes[0]
guard let copyNode = node.operation as? FilesystemOperation else {
Issue.record("expected FilesystemOperation, instead got \(node.operation)")
return
}
#expect(node.operation is FilesystemOperation)
let copyNode = node.operation as! FilesystemOperation
#expect(copyNode.action == .copy)
let expectedSource = ContextSource(
@@ -118,4 +113,65 @@ import Testing
let expectedPerms: Permissions = .mode(777)
#expect(copyNode.fileMetadata.permissions == expectedPerms)
}
@Test func simpleVisitLabel() throws {
let from = try FromInstruction(image: "scratch")
let labels = [
"label1": "value1",
"label2": "label2",
]
let labelInst = LabelInstruction(labels: labels)
let visitor = DockerInstructionVisitor()
try visitor.visit(from)
try visitor.visit(labelInst)
let graph = try visitor.graphBuilder.build()
#expect(graph.stages.count == 1)
let stage = graph.stages[0]
#expect(stage.nodes.count == 1)
let node = stage.nodes[0]
#expect(node.operation is MetadataOperation)
let meta = node.operation as! MetadataOperation
switch meta.action {
case .setLabelBatch(let batch):
#expect(batch == labels)
default:
Issue.record("expected .setLabelBatch action type, instead got \(meta.action)")
return
}
}
@Test func simpleVisitCMD() throws {
let from = try FromInstruction(image: "scratch")
let rawCommand = Command.shell("./test.sh")
let cmd = CMDInstruction(command: rawCommand)
let visitor = DockerInstructionVisitor()
try visitor.visit(from)
try visitor.visit(cmd)
let graph = try visitor.graphBuilder.build()
#expect(graph.stages.count == 1)
let stage = graph.stages[0]
#expect(stage.nodes.count == 1)
let node = stage.nodes[0]
#expect(node.operation is MetadataOperation)
let meta = node.operation as! MetadataOperation
switch meta.action {
case .setCmd(let command):
#expect(command == rawCommand)
default:
Issue.record("expected .setCmd action type, instead got \(meta.action)")
return
}
}
}