diff --git a/Sources/ContainerBuild/BuildFile.swift b/Sources/ContainerBuild/BuildFile.swift new file mode 100644 index 00000000..cfe1a24e --- /dev/null +++ b/Sources/ContainerBuild/BuildFile.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// 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 Foundation +import Logging + +public struct BuildFile { + /// Tries to resolve either a Dockerfile or Containerfile relative to contextDir. + /// Checks for Dockerfile, then falls back to Containerfile. + public static func resolvePath(contextDir: String, log: Logger? = nil) throws -> String? { + // Check for Dockerfile then Containerfile in context directory + let dockerfilePath = URL(filePath: contextDir).appendingPathComponent("Dockerfile").path + let containerfilePath = URL(filePath: contextDir).appendingPathComponent("Containerfile").path + + let dockerfileExists = FileManager.default.fileExists(atPath: dockerfilePath) + let containerfileExists = FileManager.default.fileExists(atPath: containerfilePath) + + if dockerfileExists && containerfileExists { + log?.info("Detected both Dockerfile and Containerfile, choosing Dockerfile") + return dockerfilePath + } + + if dockerfileExists { + return dockerfilePath + } + + if containerfileExists { + return containerfilePath + } + + return nil + } +} diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 2a326f9d..c4dc2407 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -32,7 +32,7 @@ extension Application { public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "build" - config.abstract = "Build an image from a Dockerfile" + config.abstract = "Build an image from a Dockerfile or Containerfile" config._superCommandName = "container" config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) return config @@ -64,7 +64,7 @@ extension Application { var cpus: Int64 = 2 @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) - var file: String = "Dockerfile" + var file: String? @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) var label: [String] = [] @@ -186,7 +186,23 @@ extension Application { throw ValidationError("builder is not running") } - let dockerfile = try Data(contentsOf: URL(filePath: file)) + let buildFilePath: String + if let file = self.file { + buildFilePath = file + } else { + guard + let resolvedPath = try BuildFile.resolvePath( + contextDir: self.contextDir, + log: log + ) + else { + throw ValidationError("failed to find Dockerfile or Containerfile in the context directory \(self.contextDir)") + } + buildFilePath = resolvedPath + } + + let buildFileData = try Data(contentsOf: URL(filePath: buildFilePath)) + let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) let exportPath = systemHealth.appRoot .appendingPathComponent(Application.BuilderCommand.builderResourceDir) @@ -264,7 +280,7 @@ extension Application { contentStore: RemoteContentStoreClient(), buildArgs: buildArg, contextDir: contextDir, - dockerfile: dockerfile, + dockerfile: buildFileData, labels: label, noCache: noCache, platforms: [Platform](platforms), @@ -351,9 +367,7 @@ extension Application { } public func validate() throws { - guard FileManager.default.fileExists(atPath: file) else { - throw ValidationError("Dockerfile does not exist at path: \(file)") - } + // NOTE: We'll "validate" the Dockerfile later. guard FileManager.default.fileExists(atPath: contextDir) else { throw ValidationError("context dir does not exist \(contextDir)") } diff --git a/Tests/ContainerBuildTests/BuildFile.swift b/Tests/ContainerBuildTests/BuildFile.swift new file mode 100644 index 00000000..5f5f17ff --- /dev/null +++ b/Tests/ContainerBuildTests/BuildFile.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// 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 Foundation +import Logging +import Testing + +@testable import ContainerBuild + +@Suite class BuildFileResolvePathTests { + private var baseTempURL: URL + private let fileManager = FileManager.default + + init() throws { + self.baseTempURL = URL.temporaryDirectory + .appendingPathComponent("BuildFileTests-\(UUID().uuidString)") + try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) + } + + deinit { + try? fileManager.removeItem(at: baseTempURL) + } + + private func createFile(at url: URL, content: String = "") throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + let created = fileManager.createFile( + atPath: url.path, + contents: content.data(using: .utf8), + attributes: nil + ) + try #require(created) + } + + @Test func testResolvePathFindsDockerfile() throws { + let contextDir = baseTempURL.path + let dockerfilePath = baseTempURL.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM alpine") + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == dockerfilePath.path) + } + + @Test func testResolvePathFindsContainerfile() throws { + let contextDir = baseTempURL.path + let containerfilePath = baseTempURL.appendingPathComponent("Containerfile") + try createFile(at: containerfilePath, content: "FROM alpine") + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == containerfilePath.path) + } + + @Test func testResolvePathPrefersDockerfileWhenBothExist() throws { + let contextDir = baseTempURL.path + let dockerfilePath = baseTempURL.appendingPathComponent("Dockerfile") + let containerfilePath = baseTempURL.appendingPathComponent("Containerfile") + try createFile(at: dockerfilePath, content: "FROM alpine") + try createFile(at: containerfilePath, content: "FROM ubuntu") + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == dockerfilePath.path) + } + + @Test func testResolvePathReturnsNilWhenNoFilesExist() throws { + let contextDir = baseTempURL.path + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == nil) + } + + @Test func testResolvePathWithEmptyDirectory() throws { + let emptyDir = baseTempURL.appendingPathComponent("empty") + try fileManager.createDirectory(at: emptyDir, withIntermediateDirectories: true, attributes: nil) + + let result = try BuildFile.resolvePath(contextDir: emptyDir.path) + + #expect(result == nil) + } + + @Test func testResolvePathWithNestedContextDirectory() throws { + let nestedDir = baseTempURL.appendingPathComponent("project/build") + try fileManager.createDirectory(at: nestedDir, withIntermediateDirectories: true, attributes: nil) + let dockerfilePath = nestedDir.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM node") + + let result = try BuildFile.resolvePath(contextDir: nestedDir.path) + + #expect(result == dockerfilePath.path) + } + + @Test func testResolvePathWithRelativeContextDirectory() throws { + let nestedDir = baseTempURL.appendingPathComponent("project") + try fileManager.createDirectory(at: nestedDir, withIntermediateDirectories: true, attributes: nil) + let dockerfilePath = nestedDir.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM python") + + // Test with the absolute path + let result = try BuildFile.resolvePath(contextDir: nestedDir.path) + + #expect(result == dockerfilePath.path) + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index da695236..e8f13349 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -89,7 +89,9 @@ container run -e NODE_ENV=production --cpus 2 --memory 1G node:18 ### `container build` -Builds an OCI image from a local build context. It reads a Dockerfile (default `Dockerfile`) and produces an image tagged with `-t` option. The build runs in isolation using BuildKit, and resource limits may be set for the build process itself. +Builds an OCI image from a local build context. It reads a Dockerfile (default `Dockerfile`) or Containerfile and produces an image tagged with `-t` option. The build runs in isolation using BuildKit, and resource limits may be set for the build process itself. + +When no `-f/--file` is specified, the build command will look for `Dockerfile` first, then fall back to `Containerfile` if `Dockerfile` is not found. **Usage** @@ -106,7 +108,7 @@ container build [OPTIONS] [CONTEXT-DIR] * `-a, --arch `: Add the architecture type to the build * `--build-arg `: Set build-time variables * `-c, --cpus `: Number of CPUs to allocate to the builder container (default: 2) -* `-f, --file `: Path to Dockerfile (default: Dockerfile) +* `-f, --file `: Path to Dockerfile * `-l, --label `: Set a label * `-m, --memory `: Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix (default: 2048MB) * `--no-cache`: Do not use cache