Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Sources/NativeBuilder/ContainerBuildDemo/Examples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public enum IRExample {
.copyFromContext(paths: ["package.json", "src/"], to: "/app/")
.run("npm install")
.env("NODE_ENV", "production")
.expose(3000)
.expose([PortSpec(port: 3000)])
.cmd(Command.exec(["node", "src/index.js"]))
}
}
Expand Down Expand Up @@ -80,7 +80,7 @@ public enum IRExample {
.copyFromStage(.named("frontend-builder"), paths: ["/frontend/dist"], to: "/app/static")
.copyFromStage(.named("backend-builder"), paths: ["/backend/server"], to: "/app/server")
.run("chmod +x /app/server")
.expose(8080)
.expose([PortSpec(port: 8080)])
.cmd(.exec(["/app/server"]))
}
}
Expand Down Expand Up @@ -210,7 +210,7 @@ public enum IRExample {
),
BuildNode(
operation: MetadataOperation(
action: .expose(port: PortSpec(port: 8000))
action: .expose(ports: [PortSpec(port: 8000)])
)
),
BuildNode(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,11 @@ public struct MetadataOperationExecutor: OperationExecutor {
}
}

case .expose(let port):
context.updateImageConfig { config in
config.exposedPorts.insert(port.stringValue)
case .expose(let ports):
for p in ports {
context.updateImageConfig { config in
config.exposedPorts.insert(p.stringValue)
}
}

case .setHealthcheck(let healthcheck):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ extension ReportContext {
return "LABEL \(labels.map { "\($0.key)=\($0.value)" }.joined(separator: " "))"
case .declareArg(let name, let defaultValue):
return "ARG \(name)\(defaultValue.map { "=\($0)" } ?? "")"
case .expose(let port):
return "EXPOSE \(port.stringValue)"
case .expose(let ports):
return "EXPOSE \(ports.map{$0.stringValue}.joined(separator: " "))"
case .setStopSignal(let signal):
return "STOPSIGNAL \(signal)"
case .setHealthcheck(let hc):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,9 @@ public final class GraphBuilder {

/// Expose port
@discardableResult
public func expose(_ port: Int, protocolType: PortSpec.NetworkProtocol = .tcp) throws -> Self {
public func expose(_ ports: [PortSpec]) throws -> Self {
let operation = MetadataOperation(
action: .expose(port: PortSpec(port: port, protocol: protocolType))
action: .expose(ports: ports)
)
return try add(operation)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ public enum MetadataAction: Sendable {
/// Define build argument (ARG)
case declareArg(name: String, defaultValue: String?)

/// Set exposed port (EXPOSE)
case expose(port: PortSpec)
/// Set exposed ports (EXPOSE)
case expose(ports: [PortSpec])

/// Set working directory (WORKDIR)
case setWorkdir(path: String)
Expand Down Expand Up @@ -116,10 +116,10 @@ public struct PortSpec: Hashable, Sendable {
}

/// Port number or range start
public let port: Int
public let port: UInt16

/// Range end (if range)
public let endPort: Int?
public let endPort: UInt16?

/// Protocol
public let `protocol`: NetworkProtocol
Expand All @@ -128,8 +128,8 @@ public struct PortSpec: Hashable, Sendable {
public let description: String?

public init(
port: Int,
endPort: Int? = nil,
port: UInt16,
endPort: UInt16? = nil,
protocol: NetworkProtocol = .tcp,
description: String? = nil
) {
Expand Down Expand Up @@ -267,9 +267,9 @@ extension MetadataAction: Hashable, Equatable {
hasher.combine(4)
hasher.combine(name)
hasher.combine(defaultValue)
case .expose(let port):
case .expose(let ports):
hasher.combine(5)
hasher.combine(port)
hasher.combine(ports)
case .setWorkdir(let path):
hasher.combine(6)
hasher.combine(path)
Expand Down Expand Up @@ -313,7 +313,7 @@ extension MetadataAction: Codable {
case labels
case name
case defaultValue
case port
case ports
case path
case user
case command
Expand Down Expand Up @@ -350,9 +350,9 @@ extension MetadataAction: Codable {
try container.encode("declareArg", forKey: .type)
try container.encode(name, forKey: .name)
try container.encode(defaultValue, forKey: .defaultValue)
case .expose(let port):
case .expose(let ports):
try container.encode("expose", forKey: .type)
try container.encode(port, forKey: .port)
try container.encode(ports, forKey: .ports)
case .setWorkdir(let path):
try container.encode("setWorkdir", forKey: .type)
try container.encode(path, forKey: .path)
Expand Down Expand Up @@ -407,8 +407,8 @@ extension MetadataAction: Codable {
let defaultValue = try container.decodeIfPresent(String.self, forKey: .defaultValue)
self = .declareArg(name: name, defaultValue: defaultValue)
case "expose":
let port = try container.decode(PortSpec.self, forKey: .port)
self = .expose(port: port)
let ports = try container.decode([PortSpec].self, forKey: .ports)
self = .expose(ports: ports)
case "setWorkdir":
let path = try container.decode(String.self, forKey: .path)
self = .setWorkdir(path: path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ protocol InstructionVisitor {
func visit(_ copy: CopyInstruction) throws
func visit(_ cmd: CMDInstruction) throws
func visit(_ label: LabelInstruction) throws
func visit(_ expose: ExposeInstruction) throws
}

/// DockerInstructionVisitor visits each provided DockerInstruction and builds a
Expand Down Expand Up @@ -146,4 +147,8 @@ extension DockerInstructionVisitor {
func visit(_ label: LabelInstruction) throws {
try graphBuilder.labelBatch(labels: label.labels)
}

func visit(_ expose: ExposeInstruction) throws {
try graphBuilder.expose(expose.ports)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Foundation
/// DockerfileParser parses a dockerfile to a BuildGraph.
public struct DockerfileParser: BuildParser {
public func parse(_ input: String) throws -> BuildGraph {
var instructions = [DockerInstruction]()
var instructions = [any DockerInstruction]()
let lines = input.components(separatedBy: .newlines)
var lineIndex = 0
while lineIndex < lines.count {
Expand Down Expand Up @@ -51,7 +51,7 @@ public struct DockerfileParser: BuildParser {
return try visitor.buildGraph(from: instructions)
}

private func tokensToDockerInstruction(tokens: [Token]) throws -> DockerInstruction {
private func tokensToDockerInstruction(tokens: [Token]) throws -> any DockerInstruction {
guard case .stringLiteral(let value) = tokens.first else {
throw ParseError.missingInstruction
}
Expand All @@ -69,6 +69,8 @@ public struct DockerfileParser: BuildParser {
return try tokensToCMDInstruction(tokens: tokens)
case .LABEL:
return try tokensToLabelInstruction(tokens: tokens)
case .EXPOSE:
return try tokensToExposeInstruction(tokens: tokens)
default:
throw ParseError.invalidInstruction(value)
}
Expand Down Expand Up @@ -119,8 +121,7 @@ public struct DockerfileParser: BuildParser {
}

internal func tokensToFromInstruction(tokens: [Token]) throws -> FromInstruction {
var index = tokens.startIndex
index += 1 // skip the instruction
var index = tokens.startIndex + 1 // skip the instruction

var stageName: String?
var platform: String?
Expand Down Expand Up @@ -177,8 +178,7 @@ public struct DockerfileParser: BuildParser {
}

internal func tokensToRunInstruction(tokens: [Token]) throws -> RunInstruction {
var index = tokens.startIndex
index += 1 // skip the instruction
var index = tokens.startIndex + 1 // skip the instruction

var rawMounts = [String]()
var network: String? = nil
Expand Down Expand Up @@ -240,8 +240,7 @@ public struct DockerfileParser: BuildParser {
}

internal func tokensToCopyInstruction(tokens: [Token]) throws -> CopyInstruction {
var index = tokens.startIndex
index += 1 // skip the instruction
var index = tokens.startIndex + 1 // skip the instruction

var from: String? = nil
var chmod: String? = nil
Expand Down Expand Up @@ -308,8 +307,7 @@ public struct DockerfileParser: BuildParser {
}

internal func tokensToCMDInstruction(tokens: [Token]) throws -> CMDInstruction {
var index = tokens.startIndex
index += 1
var index = tokens.startIndex + 1 // skip the instruction

// get the command
let (newIndex, cmd) = getCommand(start: index, tokens: tokens)
Expand All @@ -324,8 +322,7 @@ public struct DockerfileParser: BuildParser {
}

internal func tokensToLabelInstruction(tokens: [Token]) throws -> LabelInstruction {
var index = tokens.startIndex
index += 1
var index = tokens.startIndex + 1 // skip the instruction

var labels: [String: String] = [:]
while index < tokens.endIndex {
Expand All @@ -351,4 +348,23 @@ public struct DockerfileParser: BuildParser {

return LabelInstruction(labels: labels)
}

internal 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 try ExposeInstruction(rawPorts)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ enum DockerInstructionName: String {
case COPY = "copy"
case CMD = "cmd"
case LABEL = "label"
case EXPOSE = "expose"
}

/// DockerKeyword defines words that are used as keywords within a line of a dockerfile
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
// 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

struct ExposeInstruction: DockerInstruction {
let ports: [PortSpec]

init(_ rawPorts: [String]) throws {
self.ports = try rawPorts.map(parsePort)
}

internal init(ports: [PortSpec]) {
self.ports = ports
}

func accept(_ visitor: DockerInstructionVisitor) throws {
try visitor.visit(self)
}
}

func parsePort(_ p: String) throws -> PortSpec {
let parts = p.split(separator: "/", maxSplits: 1).map(String.init)
guard let rangePart = parts.first, !rangePart.isEmpty else {
throw ParseError.invalidOption(p)
}

// parse the port range
let range = rangePart.split(separator: "-", maxSplits: 1)
guard let port = UInt16(range[0]), port != 0 else {
throw ParseError.invalidOption(p)
}

// parse the end of the range if it exists
let end = range.count == 2 ? UInt16(range[1]) : nil
if range.count == 2, end == nil {
throw ParseError.invalidOption(p)
}

if end != nil, end == 0 {
throw ParseError.invalidOption(p)
}

// parse the protocol if one was specified
let protocolType: PortSpec.NetworkProtocol = try {
if parts.count == 2 {
guard let proto = PortSpec.NetworkProtocol(rawValue: String(parts[1]).lowercased()) else {
throw ParseError.invalidOption(p)
}
return proto
}
return .tcp
}()

return PortSpec(port: port, endPort: end, protocol: protocolType)
}
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ struct AnalysisTests {
.run("npm ci --only=production && npm cache clean --force") // Good: cleanup
.copyFromContext(paths: ["src/"], to: "./src/")
.user(.uid(1000)) // Good: non-root user
.expose(3000)
.expose([PortSpec(port: 3000)])
.entrypoint(.exec(["dumb-init", "node", "src/index.js"]))
}

Expand Down Expand Up @@ -598,7 +598,7 @@ struct AnalysisTests {
.run("apk add --no-cache ca-certificates && rm -rf /var/cache/apk/*") // Good: cleanup
.copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/")
.user(.uid(65534)) // Good: nobody user
.expose(8080)
.expose([PortSpec(port: 8080)])
.entrypoint(.exec(["/usr/local/bin/app"]))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ struct BasicTests {
.copyFromContext(paths: ["package.json", "src/"], to: "/app/")
.run("npm install")
.env("NODE_ENV", "production")
.expose(3000)
.expose([PortSpec(port: 3000)])
.cmd(Command.exec(["node", "src/index.js"]))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ struct DependencyAnalysisTests {
.stage(from: nginxRef)
.copyFromStage(.named("assets"), paths: ["/app/dist"], to: "/usr/share/nginx/html")
.copyFromContext(paths: ["nginx.conf"], to: "/etc/nginx/nginx.conf")
.expose(80)
.expose([PortSpec(port: 80)])
}

let analyzer = DependencyAnalyzer()
Expand Down
Loading