From f8a14e46ed87414971e2429d11df2cc2ed5cbdd0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 22 Feb 2025 00:33:52 +0000 Subject: [PATCH] Improved the Build subcommand to support the building of Docker images (#11) This PR contains the work done to support the building of Docker images in the `Build` subcommand in the executable target. So, for this purpose, the following task have been done: - added a basic boilerplate of the `docker-compose.yml` file; - fixed some issues found in the boilerplate of the `Dockerfile` file; - defined the `Randomable` protocol; - defined the `Artifact` enumeration; - updated the `BuildProjectTask` task to support building Docker images if required; - renamed the `BuildProjectTask` task as `BuildArtifactTask`. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/colibri/pulls/11 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- .../Sources/Commands/BuildCommand.swift | 4 +- .../Conformances.swift} | 1 + Executable/Sources/Options/BuildOptions.swift | 3 + Library/Resources/Files/Sources/DockerCompose | 7 +++ Library/Resources/Files/Sources/DockerFile | 4 +- .../Sources/Internal/Enumerations/File.swift | 2 + .../Internal/Protocols/Randomable.swift | 7 +++ .../Public/Enumerations/Artifact.swift | 16 +++++ Library/Sources/Public/Enumerations/IDE.swift | 8 +-- .../Public/Tasks/BuildArtifactTask.swift | 40 ++++++++++++ .../Public/Tasks/BuildProjectTask.swift | 29 --------- .../Internal/Enumerations/FileTests.swift | 4 ++ .../Internal/Protocols/RandomableTests.swift | 32 ++++++++++ .../Public/Tasks/BuildArtifactTaskTests.swift | 63 +++++++++++++++++++ .../Public/Tasks/BuildProjectTaskTests.swift | 42 ------------- 15 files changed, 181 insertions(+), 81 deletions(-) rename Executable/Sources/{Extensions/IDE+Conformances.swift => Definitions/Conformances.swift} (72%) create mode 100644 Library/Resources/Files/Sources/DockerCompose create mode 100644 Library/Sources/Internal/Protocols/Randomable.swift create mode 100644 Library/Sources/Public/Enumerations/Artifact.swift create mode 100644 Library/Sources/Public/Tasks/BuildArtifactTask.swift delete mode 100644 Library/Sources/Public/Tasks/BuildProjectTask.swift create mode 100644 Test/Sources/Cases/Internal/Protocols/RandomableTests.swift create mode 100644 Test/Sources/Cases/Public/Tasks/BuildArtifactTaskTests.swift delete mode 100644 Test/Sources/Cases/Public/Tasks/BuildProjectTaskTests.swift diff --git a/Executable/Sources/Commands/BuildCommand.swift b/Executable/Sources/Commands/BuildCommand.swift index be1fc08..64cf792 100644 --- a/Executable/Sources/Commands/BuildCommand.swift +++ b/Executable/Sources/Commands/BuildCommand.swift @@ -20,9 +20,9 @@ extension Colibri { mutating func run() async throws { let terminalService = TerminalService() - let buildProject = BuildProjectTask(terminalService: terminalService) + let buildArtifact = BuildArtifactTask(terminalService: terminalService) - try await buildProject(at: options.locationURL) + try await buildArtifact(options.artifact, at: options.locationURL) } } diff --git a/Executable/Sources/Extensions/IDE+Conformances.swift b/Executable/Sources/Definitions/Conformances.swift similarity index 72% rename from Executable/Sources/Extensions/IDE+Conformances.swift rename to Executable/Sources/Definitions/Conformances.swift index 413ea49..e9651c0 100644 --- a/Executable/Sources/Extensions/IDE+Conformances.swift +++ b/Executable/Sources/Definitions/Conformances.swift @@ -3,4 +3,5 @@ import ColibriLibrary // MARK: - ExpressibleByArgument +extension Artifact: ExpressibleByArgument {} extension IDE: ExpressibleByArgument {} diff --git a/Executable/Sources/Options/BuildOptions.swift b/Executable/Sources/Options/BuildOptions.swift index 9396639..009afdc 100644 --- a/Executable/Sources/Options/BuildOptions.swift +++ b/Executable/Sources/Options/BuildOptions.swift @@ -6,6 +6,9 @@ extension Colibri.Build { // MARK: Properties + @Option(name: .shortAndLong) + var artifact: Artifact = .executable + @Option(name: .shortAndLong) var location: String? diff --git a/Library/Resources/Files/Sources/DockerCompose b/Library/Resources/Files/Sources/DockerCompose new file mode 100644 index 0000000..8994020 --- /dev/null +++ b/Library/Resources/Files/Sources/DockerCompose @@ -0,0 +1,7 @@ +services: + app: + build: + context: . + port: + - 3000:8080 + command: ["--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Library/Resources/Files/Sources/DockerFile b/Library/Resources/Files/Sources/DockerFile index a20ddca..e6cf30a 100644 --- a/Library/Resources/Files/Sources/DockerFile +++ b/Library/Resources/Files/Sources/DockerFile @@ -1,7 +1,7 @@ # ================================ # Build image # ================================ -FROM swift:6.0-noble as build +FROM swift:6.0.3-noble AS build # Install OS updates RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ @@ -83,5 +83,5 @@ USER hummingbird:hummingbird EXPOSE 8080 # Start the Hummingbird service when the image is run, default to listening on 8080 in production environment -ENTRYPOINT ["./App] +ENTRYPOINT ["./App"] CMD ["--hostname", "0.0.0.0", "--port", "8080"] diff --git a/Library/Sources/Internal/Enumerations/File.swift b/Library/Sources/Internal/Enumerations/File.swift index 50e68f0..402b534 100644 --- a/Library/Sources/Internal/Enumerations/File.swift +++ b/Library/Sources/Internal/Enumerations/File.swift @@ -2,6 +2,7 @@ enum File: String { case appArguments = "AppArguments" case appBuilder = "AppBuilder" case appOptions = "AppOptions" + case dockerCompose = "DockerCompose" case dockerFile = "DockerFile" case dockerIgnore = "DockerIgnore" case environment = "Environment" @@ -24,6 +25,7 @@ extension File { case .appArguments: "AppArguments.swift" case .appBuilder: "AppBuilder.swift" case .appOptions: "AppOptions.swift" + case .dockerCompose: "docker-compose.yml" case .dockerFile: "Dockerfile" case .dockerIgnore: ".dockerignore" case .environment: "Environment+Properties.swift" diff --git a/Library/Sources/Internal/Protocols/Randomable.swift b/Library/Sources/Internal/Protocols/Randomable.swift new file mode 100644 index 0000000..5b2f5c5 --- /dev/null +++ b/Library/Sources/Internal/Protocols/Randomable.swift @@ -0,0 +1,7 @@ +protocol Randomable: CaseIterable { + + // MARK: Functions + + static func random() -> Self + +} diff --git a/Library/Sources/Public/Enumerations/Artifact.swift b/Library/Sources/Public/Enumerations/Artifact.swift new file mode 100644 index 0000000..cdffbd2 --- /dev/null +++ b/Library/Sources/Public/Enumerations/Artifact.swift @@ -0,0 +1,16 @@ +public enum Artifact: String { + case executable + case image +} + +// MARK: - Randomable + +extension Artifact: Randomable { + + // MARK: Functions + + static func random() -> Artifact { + .allCases.randomElement() ?? .executable + } + +} diff --git a/Library/Sources/Public/Enumerations/IDE.swift b/Library/Sources/Public/Enumerations/IDE.swift index 2c530ac..75db91e 100644 --- a/Library/Sources/Public/Enumerations/IDE.swift +++ b/Library/Sources/Public/Enumerations/IDE.swift @@ -3,9 +3,9 @@ public enum IDE: String { case xcode } -// MARK: - Extension +// MARK: - Randomable -extension IDE { +extension IDE: Randomable { // MARK: Functions @@ -14,7 +14,3 @@ extension IDE { } } - -// MARK: - CaseIterable - -extension IDE: CaseIterable {} diff --git a/Library/Sources/Public/Tasks/BuildArtifactTask.swift b/Library/Sources/Public/Tasks/BuildArtifactTask.swift new file mode 100644 index 0000000..c10969e --- /dev/null +++ b/Library/Sources/Public/Tasks/BuildArtifactTask.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct BuildArtifactTask { + + // MARK: Properties + + private let terminalService: TerminalServicing + + // MARK: Initialisers + + public init(terminalService: TerminalServicing) { + self.terminalService = terminalService + } + + // MARK: Functions + + public func callAsFunction(_ artifact: Artifact, at location: URL? = nil) async throws (TerminalServiceError) { + let executableURL: URL = switch artifact { + case .executable: .init(at: "/usr/bin/swift") + case .image: .init(at: "/usr/local/bin/docker") + } + + var arguments: [String] = switch artifact { + case .executable: ["build"] + case .image: ["compose", "build"] + } + + if let location { + if case .executable = artifact { + arguments.append(contentsOf: ["--package-path", location.pathString]) + } else if case .image = artifact { + arguments.insert(contentsOf: ["--project-directory", location.pathString], at: 1) + } + } + + try await terminalService.run(executableURL, arguments: arguments) + } + +} + diff --git a/Library/Sources/Public/Tasks/BuildProjectTask.swift b/Library/Sources/Public/Tasks/BuildProjectTask.swift deleted file mode 100644 index 282c7cb..0000000 --- a/Library/Sources/Public/Tasks/BuildProjectTask.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -public struct BuildProjectTask { - - // MARK: Properties - - private let terminalService: TerminalServicing - - // MARK: Initialisers - - public init(terminalService: TerminalServicing) { - self.terminalService = terminalService - } - - // MARK: Functions - - public func callAsFunction(at location: URL? = nil) async throws (TerminalServiceError) { - let executableURL = URL(at: "/usr/bin/swift") - - var arguments: [String] = ["build"] - - if let location { - arguments.append(contentsOf: ["--package-path", location.pathString]) - } - - try await terminalService.run(executableURL, arguments: arguments) - } - -} diff --git a/Test/Sources/Cases/Internal/Enumerations/FileTests.swift b/Test/Sources/Cases/Internal/Enumerations/FileTests.swift index 0de6680..800d822 100644 --- a/Test/Sources/Cases/Internal/Enumerations/FileTests.swift +++ b/Test/Sources/Cases/Internal/Enumerations/FileTests.swift @@ -56,6 +56,7 @@ private extension FileTests { "AppArguments.swift", "AppBuilder.swift", "AppOptions.swift", + "docker-compose.yml", "Dockerfile", ".dockerignore", "Environment+Properties.swift", @@ -70,6 +71,7 @@ private extension FileTests { "Library/Sources/Public/AppArguments.swift", "Library/Sources/Public/AppBuilder.swift", "App/Sources/AppOptions.swift", + "docker-compose.yml", "Dockerfile", ".dockerignore", "Library/Sources/Internal/Environment+Properties.swift", @@ -86,6 +88,7 @@ private extension FileTests { .app, .root, .root, + .root, .libraryInternal, .root, .root, @@ -100,6 +103,7 @@ private extension FileTests { "Resources/Files/Sources/App", "Resources/Files/Sources", "Resources/Files/Sources", + "Resources/Files/Sources", "Resources/Files/Sources/Library", "Resources/Files/Sources", "Resources/Files/Sources", diff --git a/Test/Sources/Cases/Internal/Protocols/RandomableTests.swift b/Test/Sources/Cases/Internal/Protocols/RandomableTests.swift new file mode 100644 index 0000000..07912c5 --- /dev/null +++ b/Test/Sources/Cases/Internal/Protocols/RandomableTests.swift @@ -0,0 +1,32 @@ +import Testing + +@testable import ColibriLibrary + +struct RandomableTest { + + @Test func random() { + // GIVEN + let allCases = TestRandomable.allCases + + // WHEN + let random = TestRandomable.random() + + // THEN + #expect(allCases.contains(random)) + } + +} + +// MARK: - Enumerations + +enum TestRandomable: Randomable { + case someCase + case someOtherCase + + // MARK: Functions + + static func random() -> TestRandomable { + .allCases.randomElement() ?? .someCase + } + +} diff --git a/Test/Sources/Cases/Public/Tasks/BuildArtifactTaskTests.swift b/Test/Sources/Cases/Public/Tasks/BuildArtifactTaskTests.swift new file mode 100644 index 0000000..a8e2752 --- /dev/null +++ b/Test/Sources/Cases/Public/Tasks/BuildArtifactTaskTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import ColibriLibrary + +struct BuildArtifactTaskTests { + + @Test(arguments: [nil, URL.someCurrentFolder]) + func taskForExecutable(at location: URL?) async throws { + // GIVEN + let terminalService = TerminalServiceSpy() + let task = BuildArtifactTask(terminalService: terminalService) + + // WHEN + try await task(.executable, at: location) + + // THEN + let executableURL = URL(at: "/usr/bin/swift") + let arguments = if let location { + ["build", "--package-path", location.pathString] + } else { + ["build"] + } + + #expect(terminalService.actions.count == 1) + #expect(terminalService.actions[0] == .ran(executableURL, arguments)) + } + + @Test(arguments: [nil, URL.someCurrentFolder]) + func taskForImage(at location: URL?) async throws { + // GIVEN + let terminalService = TerminalServiceSpy() + let task = BuildArtifactTask(terminalService: terminalService) + + // WHEN + try await task(.image, at: location) + + // THEN + let executableURL = URL(at: "/usr/local/bin/docker") + let arguments = if let location { + ["compose", "--project-directory", location.pathString, "build"] + } else { + ["compose", "build"] + } + + #expect(terminalService.actions.count == 1) + #expect(terminalService.actions[0] == .ran(executableURL, arguments)) + } + + @Test(arguments: [nil, URL.someCurrentFolder], [TerminalServiceError.unexpected, .output(""), .captured("")]) + func taskForArtifact(at location: URL?, throws error: TerminalServiceError) async throws { + // GIVEN + let terminalService = TerminalServiceMock(action: .error(error)) + let task = BuildArtifactTask(terminalService: terminalService) + + // WHEN + // THEN + await #expect(throws: error) { + try await task(.random(), at: location) + } + } + +} diff --git a/Test/Sources/Cases/Public/Tasks/BuildProjectTaskTests.swift b/Test/Sources/Cases/Public/Tasks/BuildProjectTaskTests.swift deleted file mode 100644 index 0c8049f..0000000 --- a/Test/Sources/Cases/Public/Tasks/BuildProjectTaskTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import Testing - -@testable import ColibriLibrary - -struct BuildProjectTaskTests { - - @Test(arguments: [nil, URL.someCurrentFolder]) - func task(at location: URL?) async throws { - // GIVEN - let terminalService = TerminalServiceSpy() - let task = BuildProjectTask(terminalService: terminalService) - - // WHEN - try await task(at: location) - - // THEN - let executableURL = URL(at: "/usr/bin/swift") - let arguments = if let location { - ["build", "--package-path", location.pathString] - } else { - ["build"] - } - - #expect(terminalService.actions.count == 1) - #expect(terminalService.actions[0] == .ran(executableURL, arguments)) - } - - @Test(arguments: [nil, URL.someCurrentFolder], [TerminalServiceError.unexpected, .output(""), .captured("")]) - func task(at location: URL?, throws error: TerminalServiceError) async throws { - // GIVEN - let terminalService = TerminalServiceMock(action: .error(error)) - let task = BuildProjectTask(terminalService: terminalService) - - // WHEN - // THEN - await #expect(throws: error) { - try await task(at: location) - } - } - -}