mirror of
https://github.com/apple/container.git
synced 2026-07-15 14:07:04 +00:00
9a597eb6aa
This PR adds the support for the ARG instruction in the native builder parser, implements the support for different kinds of ARGs, and performs the substitution of ARG variables in the supported instructions. Resolves https://github.com/apple/container/issues/437. Some features are currently blocked and not included into this PR: - [Native builder: add the current target platform we're building use it to set automatic platform ARGs #522](https://github.com/apple/container/issues/522) - [Native builder: add the support for the BuildKit built-in ARGs #523](https://github.com/apple/container/issues/523) - [Native builder: ensure pre-defined ARGs are excluded from the output of the history #524](https://github.com/apple/container/issues/524) - [Native builder: add the support for stage references and ARG inheritance #525](https://github.com/apple/container/issues/525)
496 lines
17 KiB
Swift
496 lines
17 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import ContainerBuildIR
|
|
import Foundation
|
|
|
|
/// DockerfileParser parses a dockerfile to a BuildGraph.
|
|
public struct DockerfileParser: BuildParser {
|
|
public func parse(_ input: String) throws -> BuildGraph {
|
|
var instructions = [any DockerInstruction]()
|
|
let lines = input.components(separatedBy: .newlines)
|
|
var lineIndex = 0
|
|
while lineIndex < lines.count {
|
|
var line = lines[lineIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if line.isEmpty {
|
|
lineIndex += 1
|
|
continue
|
|
}
|
|
|
|
while lineIndex < lines.count && line.hasSuffix("\\") {
|
|
line = String(line.dropLast("\\".count))
|
|
let next = lineIndex + 1
|
|
if next < lines.count {
|
|
let nextLine = String(lines[next].trimmingCharacters(in: .whitespacesAndNewlines))
|
|
line.append(nextLine)
|
|
lineIndex += 1
|
|
}
|
|
}
|
|
|
|
var tokenizer = DockerfileTokenizer(line)
|
|
let tokens = try tokenizer.getTokens()
|
|
|
|
try instructions.append(tokensToDockerInstruction(tokens: tokens))
|
|
|
|
lineIndex += 1
|
|
}
|
|
let visitor = DockerInstructionVisitor()
|
|
return try visitor.buildGraph(from: instructions)
|
|
}
|
|
|
|
private func tokensToDockerInstruction(tokens: [Token]) throws -> any DockerInstruction {
|
|
guard case .stringLiteral(let value) = tokens.first else {
|
|
throw ParseError.missingInstruction
|
|
}
|
|
|
|
let instruction = DockerInstructionName(rawValue: value.lowercased())
|
|
|
|
switch instruction {
|
|
case .FROM:
|
|
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)
|
|
case .EXPOSE:
|
|
return try tokensToExposeInstruction(tokens: tokens)
|
|
case .ARG:
|
|
return try tokensToArgInstruction(tokens: tokens)
|
|
default:
|
|
throw ParseError.invalidInstruction(value)
|
|
}
|
|
}
|
|
|
|
private struct InstructionOpt {
|
|
let key: String
|
|
let value: String
|
|
}
|
|
|
|
private func parseInstructionOpts(start: Int, tokens: [Token]) throws -> (Int, [InstructionOpt]) {
|
|
var done = false
|
|
var opts: [InstructionOpt] = []
|
|
var index = start
|
|
|
|
while index < tokens.endIndex, !done {
|
|
guard case .stringLiteral(let raw) = tokens[index] else {
|
|
done = true
|
|
break
|
|
}
|
|
|
|
guard raw.hasPrefix("--") else {
|
|
done = true
|
|
break
|
|
}
|
|
|
|
let components = raw.split(separator: "=", maxSplits: 1)
|
|
if components.count == 2 {
|
|
opts.append(InstructionOpt(key: String(components[0]), value: String(components[1])))
|
|
} else {
|
|
// instruction options should ALWAYS have a value, so if we're at
|
|
// the end or the next value is a string list, that's invalid input
|
|
index += 1
|
|
guard index < tokens.endIndex else {
|
|
throw ParseError.invalidOption(raw)
|
|
}
|
|
|
|
guard case .stringLiteral(let value) = tokens[index] else {
|
|
throw ParseError.invalidOption(raw)
|
|
}
|
|
|
|
opts.append(InstructionOpt(key: raw, value: value))
|
|
}
|
|
index += 1
|
|
}
|
|
|
|
return (index, opts)
|
|
}
|
|
|
|
func tokensToFromInstruction(tokens: [Token]) throws -> FromInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
|
|
var stageName: String?
|
|
var platform: String?
|
|
var imageName: String?
|
|
|
|
// Step 1: Parse instruction options
|
|
let (newIndex, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens)
|
|
index = newIndex
|
|
|
|
for option in instructionOpts {
|
|
guard FromOptions(rawValue: String(option.key)) == .platform else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
if platform != nil {
|
|
throw ParseError.duplicateOptionSet(FromOptions.platform.rawValue)
|
|
}
|
|
platform = option.value
|
|
}
|
|
|
|
// Step 2: Parse image name
|
|
if index < tokens.endIndex {
|
|
guard case .stringLiteral(let value) = tokens[index] else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
imageName = value
|
|
index += 1
|
|
}
|
|
|
|
// Step 3 (optional): Parse stage name
|
|
if index < tokens.endIndex {
|
|
guard case .stringLiteral(let value) = tokens[index],
|
|
DockerKeyword(rawValue: value.lowercased()) == .AS
|
|
else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
index += 1
|
|
guard index < tokens.endIndex, case .stringLiteral(let name) = tokens[index] else {
|
|
throw ParseError.invalidSyntax
|
|
}
|
|
stageName = name
|
|
index += 1
|
|
}
|
|
|
|
guard let imageName = imageName else {
|
|
throw ParseError.invalidSyntax
|
|
}
|
|
|
|
// check for extra tokens
|
|
if index < tokens.endIndex {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
|
|
return try FromInstruction(image: imageName, platform: platform, stageName: stageName)
|
|
}
|
|
|
|
func tokensToRunInstruction(tokens: [Token]) throws -> RunInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
|
|
var rawMounts = [String]()
|
|
var network: String? = nil
|
|
|
|
// Step 1: Parse instruction options
|
|
let (lastOption, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens)
|
|
index = lastOption
|
|
|
|
for option in instructionOpts {
|
|
guard let runOpt = RunOptions(rawValue: option.key) else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
|
|
switch runOpt {
|
|
case .mount:
|
|
rawMounts.append(option.value)
|
|
case .network:
|
|
network = option.value
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
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
|
|
// if there's other content in the command already, the input was invalid
|
|
command = value
|
|
shell = false
|
|
index += 1
|
|
break
|
|
} else if case .stringLiteral(let value) = tokens[index] {
|
|
command.append(value)
|
|
} else {
|
|
break
|
|
}
|
|
index += 1
|
|
}
|
|
|
|
let cmd = shell ? Command.shell(command.joined(separator: " ")) : Command.exec(command)
|
|
return (index, cmd)
|
|
}
|
|
|
|
func tokensToCopyInstruction(tokens: [Token]) throws -> CopyInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
|
|
var from: String? = nil
|
|
var chmod: String? = nil
|
|
var chown: String? = nil
|
|
var link: String? = nil
|
|
|
|
// Step 1: Parse instruction options
|
|
let (newIndex, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens)
|
|
index = newIndex
|
|
|
|
for option in instructionOpts {
|
|
guard let copyOpt = CopyOptions(rawValue: option.key) else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
|
|
switch copyOpt {
|
|
case .from:
|
|
if from != nil {
|
|
throw ParseError.duplicateOptionSet(CopyOptions.from.rawValue)
|
|
}
|
|
from = option.value
|
|
case .chown:
|
|
if chown != nil {
|
|
throw ParseError.duplicateOptionSet(CopyOptions.chown.rawValue)
|
|
}
|
|
chown = option.value
|
|
case .chmod:
|
|
if chmod != nil {
|
|
throw ParseError.duplicateOptionSet(CopyOptions.chmod.rawValue)
|
|
}
|
|
chmod = option.value
|
|
case .link:
|
|
if link != nil {
|
|
throw ParseError.duplicateOptionSet(CopyOptions.link.rawValue)
|
|
}
|
|
link = option.value
|
|
}
|
|
}
|
|
|
|
// Step 2: Get all source paths and destination path
|
|
var sources: [String] = []
|
|
var destination: String?
|
|
while index < tokens.endIndex {
|
|
guard case .stringLiteral(let value) = tokens[index] else {
|
|
break
|
|
}
|
|
if index + 1 == tokens.endIndex {
|
|
// this is the last path provided, it must be the destination
|
|
destination = value
|
|
} else {
|
|
sources.append(value)
|
|
}
|
|
index += 1
|
|
}
|
|
|
|
// check for extra tokens
|
|
if index < tokens.endIndex {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
|
|
return try CopyInstruction(sources: sources, destination: destination, from: from, ownership: chown, permissions: chmod)
|
|
}
|
|
|
|
func tokensToCMDInstruction(tokens: [Token]) throws -> CMDInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
|
|
// 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)
|
|
}
|
|
|
|
func tokensToLabelInstruction(tokens: [Token]) throws -> LabelInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
|
|
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)
|
|
}
|
|
|
|
func tokensToExposeInstruction(tokens: [Token]) throws -> ExposeInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
|
|
var rawPorts: [String] = []
|
|
while index < tokens.endIndex {
|
|
guard case .stringLiteral(let port) = tokens[index] else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
rawPorts.append(port)
|
|
index += 1
|
|
}
|
|
|
|
guard !rawPorts.isEmpty else {
|
|
throw ParseError.missingRequiredField("port")
|
|
}
|
|
|
|
return ExposeInstruction(rawPorts)
|
|
}
|
|
|
|
func tokensToArgInstruction(tokens: [Token]) throws -> ArgInstruction {
|
|
var index = tokens.startIndex + 1 // skip the instruction
|
|
guard index < tokens.endIndex else {
|
|
throw ParseError.missingRequiredField("name")
|
|
}
|
|
|
|
// Collect all remaining tokens.
|
|
var argTokens: [String] = []
|
|
while index < tokens.endIndex {
|
|
guard case .stringLiteral(let tokenValue) = tokens[index] else {
|
|
throw ParseError.unexpectedValue
|
|
}
|
|
argTokens.append(tokenValue)
|
|
index += 1
|
|
}
|
|
|
|
let argSpec = argTokens.joined(separator: " ")
|
|
let argDefinitions = try parseMultipleArgs(from: argSpec)
|
|
return try ArgInstruction(args: argDefinitions)
|
|
}
|
|
|
|
private func parseMultipleArgs(from argSpec: String) throws -> [ArgDefinition] {
|
|
var definitions: [ArgDefinition] = []
|
|
var remaining = argSpec
|
|
|
|
while !remaining.isEmpty {
|
|
let (name, defaultValue, rest) = try parseNextArg(from: remaining)
|
|
let definition = ArgDefinition(name: name, defaultValue: defaultValue)
|
|
definitions.append(definition)
|
|
remaining = rest
|
|
}
|
|
|
|
guard !definitions.isEmpty else {
|
|
throw ParseError.missingRequiredField("name")
|
|
}
|
|
|
|
return definitions
|
|
}
|
|
|
|
private func parseNextArg(from input: String) throws -> (name: String, defaultValue: String?, remaining: String) {
|
|
let trimmed = input.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else {
|
|
throw ParseError.missingRequiredField("name")
|
|
}
|
|
|
|
// Find the name.
|
|
guard let nameEnd = trimmed.firstIndex(where: { $0.isWhitespace || $0 == "=" }) else {
|
|
return (name: trimmed, defaultValue: nil, remaining: "")
|
|
}
|
|
|
|
let name = String(trimmed[..<nameEnd])
|
|
guard !name.isEmpty else {
|
|
throw ParseError.missingRequiredField("name")
|
|
}
|
|
|
|
// Find the value.
|
|
let afterName = String(trimmed[nameEnd...]).trimmingCharacters(in: .whitespaces)
|
|
|
|
guard afterName.hasPrefix("=") else {
|
|
return (name: name, defaultValue: nil, remaining: afterName)
|
|
}
|
|
|
|
let afterEquals = String(afterName.dropFirst()).trimmingCharacters(in: .whitespaces)
|
|
guard !afterEquals.isEmpty else {
|
|
return (name: name, defaultValue: "", remaining: "")
|
|
}
|
|
|
|
let (value, remaining) = try parseArgValue(from: afterEquals)
|
|
let trimmedRemaining = remaining.trimmingCharacters(in: .whitespaces)
|
|
return (name: name, defaultValue: value, remaining: trimmedRemaining)
|
|
}
|
|
|
|
private func parseArgValue(from input: String) throws -> (value: String, remaining: String) {
|
|
if input.hasPrefix("\"") {
|
|
return try parseQuotedValue(from: input, quote: "\"")
|
|
} else if input.hasPrefix("'") {
|
|
return try parseQuotedValue(from: input, quote: "'")
|
|
} else {
|
|
return parseUnquotedValue(from: input)
|
|
}
|
|
}
|
|
|
|
private func parseUnquotedValue(from input: String) -> (value: String, remaining: String) {
|
|
guard let spaceIndex = input.firstIndex(where: { $0.isWhitespace }) else {
|
|
return (value: input, remaining: "")
|
|
}
|
|
|
|
let value = String(input[..<spaceIndex])
|
|
let remaining = String(input[spaceIndex...])
|
|
return (value: value, remaining: remaining)
|
|
}
|
|
|
|
private func parseQuotedValue(from input: String, quote: Character) throws -> (value: String, remaining: String) {
|
|
var pos = input.index(after: input.startIndex)
|
|
var value = ""
|
|
var escaped = false
|
|
|
|
while pos < input.endIndex {
|
|
let char = input[pos]
|
|
|
|
if !escaped && char == quote {
|
|
pos = input.index(after: pos)
|
|
let remaining = String(input[pos...])
|
|
return (value: value, remaining: remaining)
|
|
}
|
|
|
|
if escaped {
|
|
switch char {
|
|
case "\"", "'", "\\":
|
|
value.append(char)
|
|
default:
|
|
value.append("\\")
|
|
value.append(char)
|
|
}
|
|
escaped = false
|
|
} else if char == "\\" {
|
|
escaped = true
|
|
} else {
|
|
value.append(char)
|
|
}
|
|
|
|
pos = input.index(after: pos)
|
|
}
|
|
|
|
throw ParseError.invalidSyntax
|
|
}
|
|
}
|