diff --git a/Sources/Container-Compose/Application.swift b/Sources/Container-Compose/Application.swift index a986d48..ec9d2dc 100644 --- a/Sources/Container-Compose/Application.swift +++ b/Sources/Container-Compose/Application.swift @@ -30,6 +30,7 @@ public struct Main: AsyncParsableCommand { subcommands: [ ComposeUp.self, ComposeDown.self, + ComposeBuild.self, Version.self ]) diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift new file mode 100644 index 0000000..39fc8ab --- /dev/null +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -0,0 +1,166 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose 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. +//===----------------------------------------------------------------------===// + +// +// ComposeBuild.swift +// Container-Compose +// +// Created by Luke Parkin on 04/20/26. +// + +import ArgumentParser +import ContainerCommands +import ContainerAPIClient +import ContainerizationExtras +import Foundation +import Yams + +public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "build", + abstract: "Build images from a compose file without starting containers" + ) + + @Argument(help: "Services to build (builds all if omitted)") + var services: [String] = [] + + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") + var composeFilename: String? + + @Flag(name: .long, help: "Do not use cache when building") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var logging: Flags.Logging + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + + private var cwdURL: URL { URL(fileURLWithPath: cwd) } + + private static let supportedComposeFilenames = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + ] + + private var composePath: String { + if let composeFilename { + return resolvedPath(for: composeFilename, relativeTo: cwdURL) + } + for filename in Self.supportedComposeFilenames { + let candidate = cwdURL.appending(path: filename).path + if FileManager.default.fileExists(atPath: candidate) { + return candidate + } + } + return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path + } + + private var composeDirectory: String { + URL(fileURLWithPath: composePath).deletingLastPathComponent().path + } + + private var envFilePath: String { + let envFile = process.envFile.first ?? ".env" + return resolvedPath(for: envFile, relativeTo: cwdURL) + } + + public mutating func run() async throws { + guard let yamlData = FileManager.default.contents(atPath: composePath) else { + let dir = URL(fileURLWithPath: composePath).deletingLastPathComponent().path + throw YamlError.composeFileNotFound(dir) + } + + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + let environmentVariables = loadEnvFile(path: envFilePath) + + let projectName: String + if let name = dockerCompose.name { + projectName = name + } else { + projectName = deriveProjectName(cwd: cwd) + } + + var servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in + guard let service, service.build != nil else { return nil } + return (name, service) + } + + if !services.isEmpty { + servicesToBuild = servicesToBuild.filter { services.contains($0.serviceName) } + } + + if servicesToBuild.isEmpty { + print("No services with a 'build' configuration found.") + return + } + + print("Building services") + for (serviceName, service) in servicesToBuild { + try await buildService(service.build!, for: service, serviceName: serviceName, projectName: projectName, environmentVariables: environmentVariables) + } + print("Build complete") + } + + private func buildService( + _ buildConfig: Build, + for service: Service, + serviceName: String, + projectName: String, + environmentVariables: [String: String] + ) async throws { + let imageTag = service.image ?? "\(serviceName):latest" + + var commands = [URL(fileURLWithPath: buildConfig.context, relativeTo: URL(fileURLWithPath: composeDirectory)).path] + + for (key, value) in buildConfig.args ?? [:] { + commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"]) + } + + commands.append(contentsOf: [ + "--file", URL(fileURLWithPath: buildConfig.dockerfile ?? "Dockerfile", relativeTo: URL(fileURLWithPath: composeDirectory)).path, + "--tag", imageTag, + ]) + + if noCache { + commands.append("--no-cache") + } + + let split = service.platform?.split(separator: "/") + let os = String(split?.first ?? "linux") + let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") + commands.append(contentsOf: ["--os", os, "--arch", arch]) + + let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" + commands.append(contentsOf: ["--cpus", "\(cpuCount)", "--memory", memoryLimit]) + + print("\n----------------------------------------") + print("Building \(serviceName) -> \(imageTag)") + let buildCommand = try Application.BuildCommand.parse(commands + logging.passThroughCommands()) + try buildCommand.validate() + try await buildCommand.run() + print("Built \(serviceName) successfully.") + print("----------------------------------------") + } +} diff --git a/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift b/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift new file mode 100644 index 0000000..7e7e8e1 --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift @@ -0,0 +1,158 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose 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 Testing +import Foundation +import ContainerCommands +import ContainerAPIClient +import TestHelpers +@testable import ContainerComposeCore + +@Suite("Compose Build Tests", .containerDependent, .serialized) +struct ComposeBuildTests { + + // MARK: - Helpers + + private func writeBuildProject(yaml: String, dockerfile: String = "FROM alpine:latest") throws -> DockerComposeYamlFiles.TemporaryProject { + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + let dockerfilePath = project.base.appending(path: "Dockerfile").path(percentEncoded: false) + try dockerfile.write(toFile: dockerfilePath, atomically: false, encoding: .utf8) + return project + } + + private func imageExists(named tag: String) async throws -> Bool { + let images = try await ClientImage.list() + return images.contains { $0.description.reference.hasSuffix(tag) } + } + + // MARK: - Tests + + @Test("Build produces an image in the local store") + func buildProducesImageInLocalStore() async throws { + let yaml = """ + services: + simple: + build: + context: . + dockerfile: Dockerfile + """ + + let project = try writeBuildProject(yaml: yaml) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + ]) + try await composeBuild.run() + + #expect(try await imageExists(named: "simple:latest")) + } + + @Test("Build uses explicit image tag from compose file") + func buildUsesExplicitImageTag() async throws { + let yaml = """ + services: + app: + image: compose-build-test-tagged:latest + build: + context: . + dockerfile: Dockerfile + """ + + let project = try writeBuildProject(yaml: yaml) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + ]) + try await composeBuild.run() + + #expect(try await imageExists(named: "compose-build-test-tagged:latest")) + } + + @Test("Build with service filter only builds specified service") + func buildWithServiceFilterOnlyBuildsSpecifiedService() async throws { + let yaml = """ + services: + included: + build: + context: . + dockerfile: Dockerfile + excluded: + build: + context: . + dockerfile: Dockerfile + """ + + let project = try writeBuildProject(yaml: yaml) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + "included", + ]) + try await composeBuild.run() + + #expect(try await imageExists(named: "included:latest")) + #expect(try await !imageExists(named: "excluded:latest")) + } + + @Test("Build passes build args to Dockerfile") + func buildPassesBuildArgsToDockerfile() async throws { + let dockerfile = """ + ARG BUILD_VERSION=unset + FROM alpine:latest + LABEL build.version=$BUILD_VERSION + """ + + let yaml = """ + services: + app: + image: compose-build-test-args:latest + build: + context: . + dockerfile: Dockerfile + args: + BUILD_VERSION: "1.2.3" + """ + + let project = try writeBuildProject(yaml: yaml, dockerfile: dockerfile) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + ]) + try await composeBuild.run() + + #expect(try await imageExists(named: "compose-build-test-args:latest")) + } + + @Test("Build with no buildable services prints message and exits cleanly") + func buildWithNoBuildableServicesExitsCleanly() async throws { + let yaml = """ + services: + cache: + image: redis:alpine + db: + image: postgres:14 + """ + + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeBuild = try ComposeBuild.parse([ + "--cwd", project.base.path(percentEncoded: false), + ]) + + // Should complete without throwing even though nothing is built + try await composeBuild.run() + } +} diff --git a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift new file mode 100644 index 0000000..208d6a9 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift @@ -0,0 +1,168 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +@Suite("Compose Build Parsing Tests") +struct ComposeBuildParsingTests { + + @Test("Services with build config are selected for building") + func servicesWithBuildConfigAreSelected() throws { + let yaml = """ + services: + app: + build: + context: . + dockerfile: Dockerfile + cache: + image: redis:alpine + """ + + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + + let buildable = compose.services.compactMap { name, service -> String? in + guard let service, service.build != nil else { return nil } + return name + } + + #expect(buildable == ["app"]) + } + + @Test("Services without build config are excluded") + func servicesWithoutBuildConfigAreExcluded() throws { + let yaml = """ + services: + web: + image: nginx:alpine + db: + image: postgres:14 + """ + + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + + let buildable = compose.services.compactMap { name, service -> String? in + guard let service, service.build != nil else { return nil } + return name + } + + #expect(buildable.isEmpty) + } + + @Test("Mixed compose file — only build services are selected") + func mixedComposeFileOnlyBuildServicesSelected() throws { + let yaml = """ + services: + app: + build: + context: ./app + worker: + build: + context: ./worker + db: + image: postgres:14 + cache: + image: redis:alpine + """ + + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + + let buildable = compose.services.compactMap { name, service -> String? in + guard let service, service.build != nil else { return nil } + return name + }.sorted() + + #expect(buildable == ["app", "worker"]) + } + + @Test("Image tag defaults to serviceName:latest when image field is absent") + func imageTagDefaultsToServiceNameLatest() throws { + let yaml = """ + services: + myservice: + build: + context: . + """ + + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + let service = try #require(compose.services["myservice"] ?? nil) + + let tag = service.image ?? "myservice:latest" + #expect(tag == "myservice:latest") + } + + @Test("Explicit image field is used as the build tag") + func explicitImageFieldIsUsedAsBuildTag() throws { + let yaml = """ + services: + app: + image: myorg/myapp:v1.2.3 + build: + context: . + """ + + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + let service = try #require(compose.services["app"] ?? nil) + + let tag = service.image ?? "app:latest" + #expect(tag == "myorg/myapp:v1.2.3") + } + + @Test("Build args are passed through from compose file") + func buildArgsArePassedThrough() throws { + let yaml = """ + services: + app: + build: + context: . + args: + NODE_VERSION: "20" + ENV: production + """ + + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + let build = try #require(compose.services["app"]??.build) + + #expect(build.args?["NODE_VERSION"] == "20") + #expect(build.args?["ENV"] == "production") + } + + @Test("ComposeBuild command parses --no-cache flag") + func composeBuildCommandParsesNoCacheFlag() throws { + let cmd = try ComposeBuild.parse(["--no-cache"]) + #expect(cmd.noCache == true) + } + + @Test("ComposeBuild command defaults no-cache to false") + func composeBuildCommandDefaultsNoCacheToFalse() throws { + let cmd = try ComposeBuild.parse([]) + #expect(cmd.noCache == false) + } + + @Test("ComposeBuild command accepts service name arguments") + func composeBuildCommandAcceptsServiceNameArguments() throws { + let cmd = try ComposeBuild.parse(["app", "worker"]) + #expect(cmd.services == ["app", "worker"]) + } + + @Test("ComposeBuild command accepts -f flag for compose file") + func composeBuildCommandAcceptsFileFlag() throws { + let cmd = try ComposeBuild.parse(["-f", "my-compose.yaml"]) + #expect(cmd.composeFilename == "my-compose.yaml") + } +}