diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f873954 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +.git diff --git a/.gitignore b/.gitignore index 02c0875..e12d0df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /.build /Packages /*.xcodeproj +**/*/xcuserdata/* \ No newline at end of file diff --git a/.swift-version b/.swift-version index c4e41f9..ef425ca 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -4.0.3 +5.2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2e45574 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: generic +dist: bionic +sudo: required +osx_image: xcode11.4 +os: + - linux + - osx +env: + - SWIFT_VERSION=5.1 + - SWIFT_VERSION=5.2.1 +install: + - if [ "$TRAVIS_OS_NAME" = "linux" ] || [ "$SWIFT_VERSION" = "5.1" ]; then eval "$(curl -sL https://swiftenv.fuller.li/en/latest/install.sh)"; fi; +script: + - set -o pipefail + - swift test --filter TravisClientTests.JSONTests + diff --git a/Internals.md b/Internals.md new file mode 100644 index 0000000..8aa2d37 --- /dev/null +++ b/Internals.md @@ -0,0 +1,31 @@ +## Docker Labels + +[Docker Guide](https://docs.docker.com/config/labels-custom-metadata/) + +our prefix is com.swiftdockercli + +LABEL com.swiftdockercli.action="test"/"build" +LABEL com.swiftdockercli.folder="name-of-your-project" +* "com.swiftdockercli.action"= - images created from the test command +* "com.swiftdockercli.build" - images created from the build command + +LABEL "com.example.vendor"="ACME Incorporated" +LABEL com.example.label-with-value="foo" + +Identifying test images created with `swift-docker` = `docker images --filter "com.swiftdockercli.action=bar"` + +## Docker Tags + +https://docs.docker.com/engine/reference/commandline/tag/ + +## Ideas + +* `swift docker test -s 4.1` +* `swift docker test -s 4.0` +* `swift docker test --configuration release` +* `swift docker test --swift 4.0 --configuration release` +* `swift docker test --image swiftdocker/swift:latest` +* `swift docker build --tag my-tag` +* `swift docker run ./build/hello --interactive` +* `swift docker run --daemon/--background` +* `swift docker test --log docker.log` diff --git a/Package.resolved b/Package.resolved index 71fc0b2..5733c35 100644 --- a/Package.resolved +++ b/Package.resolved @@ -2,39 +2,21 @@ "object": { "pins": [ { - "package": "Commander", - "repositoryURL": "https://github.com/kylef/Commander", + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "e5b50ad7b2e91eeb828393e89b03577b16be7db9", - "version": "0.8.0" + "revision": "9f04d1ff1afbccd02279338a2c91e5f27c45e93a", + "version": "0.0.5" } }, { - "package": "Rainbow", - "repositoryURL": "https://github.com/onevcat/Rainbow", + "package": "swift-tools-support-core", + "repositoryURL": "https://github.com/apple/swift-tools-support-core", "state": { "branch": null, - "revision": "f69961599ad524251d677fbec9e4bac57385d6fc", - "version": "3.1.1" - } - }, - { - "package": "ShellOut", - "repositoryURL": "https://github.com/iainsmith/ShellOut", - "state": { - "branch": null, - "revision": "46c6382a0531a448841c0fa6fc58fcf75f216c56", - "version": "2.2.0" - } - }, - { - "package": "Spectre", - "repositoryURL": "https://github.com/kylef/Spectre.git", - "state": { - "branch": null, - "revision": "e34d5687e1e9d865e3527dd58bc2f7464ef6d936", - "version": "0.8.0" + "revision": "ae8ccef3274e38eb5ae3189c357883510da74a01", + "version": "0.1.1" } } ] diff --git a/Package.swift b/Package.swift index d1b1714..04699ec 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,40 @@ -// swift-tools-version:4.0 +// swift-tools-version:5.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "SwiftDockerCLI", - products: [ - .executable(name: "swift-docker", targets: ["SwiftDocker"]), - ], - dependencies: [ - .package(url: "https://github.com/iainsmith/ShellOut", from: "2.2.0"), - .package(url: "https://github.com/kylef/Commander", from: "0.8.0"), - .package(url: "https://github.com/onevcat/Rainbow", from: "3.1.1"), - ], - targets: [ - .target( - name: "SwiftDocker", - dependencies: ["SwiftDockerLib"]), - .target( - name: "SwiftDockerLib", - dependencies: ["ShellOut", "Commander", "Rainbow"]), - .testTarget( - name: "SwiftDockerLibTests", - dependencies: ["SwiftDockerLib"] - ), - ] + name: "swift-docker-cli", + platforms: [.macOS(.v10_14)], + products: [ + .executable(name: "swift-docker", targets: ["SwiftDocker"]), + .library(name: "SwiftDocker", targets: ["SwiftDocker"]), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-tools-support-core", + .upToNextMinor(from: "0.1.1") + ), + .package( + url: "https://github.com/apple/swift-argument-parser", + .upToNextMinor(from: "0.0.5") + ), + ], + targets: [ + .target( + name: "SwiftDocker", + dependencies: ["SwiftDockerLib"] + ), + .target( + name: "SwiftDockerLib", + dependencies: [ + .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .testTarget( + name: "SwiftDockerLibTests", + dependencies: ["SwiftDockerLib"] + ), + ] ) diff --git a/README.md b/README.md index 4adf426..aa22232 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,56 @@ # swift-docker -Test your swift package is Linux compatible using one command. `swift docker test` +A command line tool for building & testing your swift package in a docker container. +* [Quick start](#quick-start-for-macOS) +* [Features](#Features) +* [Installation](#Install-swift-docker) +* [Usage](#Usage) +* [Docker Labels](#docker-labels) + +## Quick start for macOS + +```sh +brew install iainsmith/formulae/swift-docker # Install swift docker +git clone https://github.com/jpsim/Yams.git # Clone an example package +cd Yams && swift test # Run the tests on your machine +swift docker test # Run the tests in a container +swift docker test --swift 5.1 # Check if the tests pass on swift 5.1 +swift docker cleanup # Delete the docker image you just created +swift docker write-dockerfile # Write a ./Dockerfile to the repo +``` + +## Features + +* [x] Test swift packages in one command `swift docker test` +* [x] Use custom images - `swift docker test --image vapor/swift:latest` +* [x] Build a docker image for your project - `swift docker build` +* [x] Quickly free up space - `swift docker cleanup` +* [x] Create a dockerfile for your project +* [ ] Prevent duplicate builds +* [ ] Automatically create a .dockerignore file +* [ ] Support multistage slim builds +* [ ] Log output to a file +* [ ] cmake build for running on Windows + ## Install swift-docker + +Install with Homebrew ```sh -brew tap iainsmith/formulae -brew install swift-docker +brew install iainsmith/formulae/swift-docker ```
-or from source +Install from source
-> git clone https://github.com/iainsmith/swift-docker.git
+> git clone https://github.com/iainsmith/swift-docker-cli.git
 > cd swift-docker
-> swift build -c release -Xswiftc -static-stdlib
-# copy the binary to somewhere in your path. 
-> cp ./.build/x86_64-apple-macosx10.10/release/swift-docker ~/bin
+> swift build -c release --disable-sandbox
+# copy the binary to somewhere in your path.
+> cp ./.build/release/swift-docker ~/bin
 

@@ -29,50 +61,61 @@ And install docker if you don't have it already * Download the [Docker Mac App](https://www.docker.com/docker-mac). -* Alternatively install via homebrew `brew install docker` +* Or alternatively install via homebrew `brew cask install docker` ## Usage -Run the Tests +```bash +OVERVIEW: Build and test your swift packages in docker -```sh -# Against the latest version of swift -swift docker test +Simple commands for working with the official swift docker images +https://hub.docker.com/_/swift -# Against a swift version -swift docker test --swift 4.0 +examples: -# Using a specific image -swift docker test --image ibmcom/swift-ubuntu:4.1 +swift docker test #test the package in the current directory +swift docker test --swift 5.1 # test your package against swift:5.1 +swift docker test --path ~/code/my-package # test a package in a directory +swift docker build --swift 5.2.2 --tag username/package:1.0 +swift docker write-dockerfile --swift 5.2.2-slim +swift docker cleanup # Remove all images created with swift docker test -# Run tests and save dockerfile -swift docker test --image ibmcom/swift-ubuntu:4.1 --write-dockerfile -``` +USAGE: swift-docker -Save the default dockerfile to ./Dockerfile +OPTIONS: +-h, --help Show help information. -```sh -swift docker write-dockerfile +SUBCOMMANDS: +test Test your swift package in a docker container. +build Build you swift package in a docker container. +cleanup Remove temporary docker images. +write-dockerfile Write a dockerfile to disk. ``` -Cleanup docker images generated by swift-docker +## Docker labels + +Each docker image created by `swift-docker` is tagged with two labels. ``` -swift docker cleanup +LABEL com.swiftdockercli.action="test/build" +LABEL com.swiftdockercli.folder="your-project-name" ``` -## Credits - -swift-docker is built on top of +Running `docker ps -a --filter label=com.swiftdockercli.action=test` will list all containers created by swift-docker test +Running `docker images --filter label=com.swiftdockercli.action=test` will list all images created by swift-docker test -* [ShellOut](https://github.com/JohnSundell/ShellOut) (from John Sundell) -* [Commander](https://github.com/kylef/commander) (from Kyle Fuller) -* [Rainbow](https://github.com/onevcat/Rainbow) (from 王巍 Wei Wang) +This is how `swift docker cleanup` looks up images to delete. ## Contributing -If you have suggestions for new commands, features or bug fixes. Please raise an issue or open a PR. +If you have suggestions for new commands, features or bug fixes. Please raise an issue or open a PR. -If you find this tool useful in your workflow let me know on twitter [@_iains]() +If you find this tool useful in your workflow let me know on twitter [@_iains](https://twitter.com/_iains) + +## Credits + +swift-docker is built on top of +* [swift-tools-support-core](https://github.com/apple/swift-tools-support-core) +* [swift-argument-parser](https://github.com/apple/swift-argument-parser) diff --git a/Sources/SwiftDocker/main.swift b/Sources/SwiftDocker/main.swift index eb0fb46..884b3c9 100644 --- a/Sources/SwiftDocker/main.swift +++ b/Sources/SwiftDocker/main.swift @@ -1,40 +1,3 @@ -import Commander -import Foundation import SwiftDockerLib -Group() { - let version = Option("swift", - default: "4.1", - flag: "s", - description: "The swift version to test against. e.g 4.0") - // Ideally we could represent this as an optional. - let image = Option("image", - default: "", - flag: "i", - description: "(Optional) The docker image to test against. e.g swiftdocker/swift:4.0") - - let writeDockerfile = Flag("write-dockerfile", - default: false, - flag: "w", - description: "Write the dockerfile to the current directory") - - /// swift docker test -s 4.1 - /// swift docker test -s 4.0 - /// swift docker test -s 4.0 -w - /// swift docker test --swift 4.0 --write-dockerfile - /// swift docker test --image swiftdocker/swift:latest - $0.command("test", - version, image, writeDockerfile, - description: "Build and test the SPM package") { version, image, shouldWriteToLocalDir in - try runDockerTests(version: version, image: image, writeDockerFile: shouldWriteToLocalDir) - } - /// swift docker test cleanup - $0.command("cleanup", description: "Remove docker images created with swift docker") { - try runDockerRemoveImages() - } - - /// swift docker test write-dockerfile - $0.command("write-dockerfile", version, description: "Write the default dockerfile to ./Dockerfile") { version in - try writeDefaultDockerFile(version: version) - } -}.run() +SwiftDockerCLI.main() diff --git a/Sources/SwiftDockerLib/ArgumentParserExtensions.swift b/Sources/SwiftDockerLib/ArgumentParserExtensions.swift new file mode 100644 index 0000000..818044b --- /dev/null +++ b/Sources/SwiftDockerLib/ArgumentParserExtensions.swift @@ -0,0 +1,14 @@ +import protocol ArgumentParser.ExpressibleByArgument +import class Foundation.NSString +import struct Foundation.URL + +extension URL: ExpressibleByArgument { + public init?(argument: String) { + let expanded = NSString(string: argument).expandingTildeInPath + self = URL(fileURLWithPath: expanded) + } + + public var defaultValueDescription: String { + self.relativeString + } +} diff --git a/Sources/SwiftDockerLib/BuildCommand.swift b/Sources/SwiftDockerLib/BuildCommand.swift new file mode 100644 index 0000000..f83c710 --- /dev/null +++ b/Sources/SwiftDockerLib/BuildCommand.swift @@ -0,0 +1,97 @@ +import ArgumentParser +import Foundation +import TSCBasic + +struct BuildCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "build", + abstract: "Build you swift package in a docker container.", + discussion: """ + Examples: + + swift docker build --swift 5.2.2 --tag username/package:1.0 + swift docker build --image vapor/ubuntu:latst --tag username/package:1.0 + + Docker Labels: + + All docker images created from this command will have LABEL com.swiftdockercli.action=build and will not be deleted by running `swift docker cleanup` + """, + shouldDisplay: true + ) + + @Option(name: .shortAndLong, help: "The tag for this image. e.g name/project:latest") + var tag: String + + @OptionGroup() + var options: CLIOptions + + func run() throws { + // Docker build context is relative to the directory docker is being called from. + // https://github.com/moby/moby/issues/4592 + // For testing we use the swift-tools-support InMemoryFileSystem which fatalErrors + // on calls to changeCurrentWorkingDirectory in 0.1.1 + try localFileSystem.changeCurrentWorkingDirectory(to: options.absolutePath) + try BuildCommandRunner(tag: tag, options: options).run(action: .build) + } +} + +struct BuildCommandRunner { + private let options: CLIOptions + private let filesystem: FileSystem + private let outputDestinaton: OutputDestination + private let shell: ShellProtocol.Type + private let withTemporaryFileClosure: TemporaryFileFunction + private let tag: String + + init( + tag: String, + options: CLIOptions, + fileSystem: FileSystem = localFileSystem, + output: OutputDestination = TerminalController(stream: stdoutStream) ?? stdoutStream, + shell: ShellProtocol.Type = ShellRunner.self, + withTemporaryFile: TemporaryFileFunction? = nil + ) { + self.tag = tag + self.options = options + self.shell = shell + filesystem = fileSystem + outputDestinaton = output + withTemporaryFileClosure = withTemporaryFile ?? { dir, prefix, suffix, delete, body in + try TSCBasic.withTemporaryFile(dir: dir, prefix: prefix, suffix: suffix, deleteOnClose: delete) { tempfile in try body(tempfile.path) } + } + } + + func withTemporaryFile( + dir: AbsolutePath? = nil, + prefix: String = "TemporaryFile", + suffix: String = "", + deleteOnClose: Bool = true, _ + body: (AbsolutePath) throws -> Void + ) throws { + try withTemporaryFileClosure(dir, prefix, suffix, deleteOnClose, body) + } + + func run(action: Dockerfile.ActionLabel) throws { + _ = try withTemporaryFile(prefix: "Dockerfile") { (file) -> Void in + let dockerfileBody = Dockerfile.makeMinimalDockerFile( + image: options.baseImage.fullName, + directory: options.projectName, + action: action + ) + + try filesystem.writeFileContents(file, bytes: ByteString(encodingAsUTF8: dockerfileBody), atomically: true) + if options.verbose { outputDestinaton.writeLine("Created temporary Dockerfile at \(file.pathString)") } + + let buildCommand = DockerCommands.dockerBuild(tag: tag, dockerFilePath: file.pathString) + outputDestinaton.writeLine("-> docker build") + let buildOutput = try shell.runWithStreamingOutput( + buildCommand, + controller: outputDestinaton, + redirection: DockerOutputRewriter.self, + isVerbose: options.verbose + ) + + if buildOutput.exitStatus == .terminated(code: 1) { throw DockerError.failedToRunCommand(buildCommand) } + } + } +} diff --git a/Sources/SwiftDockerLib/CLI.swift b/Sources/SwiftDockerLib/CLI.swift new file mode 100644 index 0000000..b816bf4 --- /dev/null +++ b/Sources/SwiftDockerLib/CLI.swift @@ -0,0 +1,23 @@ +import ArgumentParser + +public struct SwiftDockerCLI: ParsableCommand { + public static var configuration: CommandConfiguration = CommandConfiguration( + commandName: "swift-docker", + abstract: "A simple workflow for building & testing swift packages with docker", + discussion: """ + Run swift docker --help for subcommand details + Reference - Offiical docker images: https://hub.docker.com/_/swift + + Examples: + + \(DocExamples.testCommand.indentLines(by: 2)) + swift docker build --swift 5.2.2 --tag username/package:1.0 + swift docker write-dockerfile --swift 5.2.2 + swift docker cleanup # Remove all images created with swift docker test + """, + shouldDisplay: true, + subcommands: [TestCommand.self, BuildCommand.self, CleanupCommand.self, WriteDockerfileCommand.self] + ) + + public init() {} +} diff --git a/Sources/SwiftDockerLib/CLIOptions.swift b/Sources/SwiftDockerLib/CLIOptions.swift new file mode 100644 index 0000000..b08d3d9 --- /dev/null +++ b/Sources/SwiftDockerLib/CLIOptions.swift @@ -0,0 +1,55 @@ +import ArgumentParser +import Foundation +import TSCBasic + +public struct CLIOptions: ParsableArguments { + @Option(name: .shortAndLong, default: "latest", help: "swift tag found at https://hub.docker.com/_/swift \n e.g latest, 5.2, 5.2.2-slim") + var swift: String + + @Option(name: .shortAndLong, help: "a custom docker image to use as the base image\n e.g vapor/ubuntu:bionic") + var image: String? + + @Option(name: [.customShort("p"), .customLong("path")], default: URL(fileURLWithPath: "."), help: "a path to the swift package if not using the current directory") + var url: URL + + @Flag(name: .shortAndLong, help: "Increase the level of output") + var verbose: Bool + + @Flag(name: .customLong("skip-validation"), help: .hidden) + var skipValidation: Bool + + var absolutePath: AbsolutePath { + try! AbsolutePath(validating: url.path) + } + + var baseImage: DockerTag { + DockerTag(version: swift, image: image)! + } + + var projectName: String { + absolutePath.basename + } + + var defaultDockerfilePath: AbsolutePath { + absolutePath.appending(component: "Dockerfile") + } + + public init() {} + + public func validate() throws { + if skipValidation { return } + + if swift != "latest", image != nil { + throw ValidationError("--swift and --image are exclusive options") + } + + if url.pathComponents.contains("DerivedData") { + throw ValidationError("Running from Xcode without an explicit --path") + } + + let packageSwift = url.appendingPathComponent("Package").appendingPathExtension("swift") + if !localFileSystem.exists(packageSwift) { + throw ValidationError("No Package.swift file found in \(url.path)") + } + } +} diff --git a/Sources/SwiftDockerLib/CleanupCommand.swift b/Sources/SwiftDockerLib/CleanupCommand.swift new file mode 100644 index 0000000..57d80a6 --- /dev/null +++ b/Sources/SwiftDockerLib/CleanupCommand.swift @@ -0,0 +1,74 @@ +import ArgumentParser +import var TSCBasic.stdoutStream +import class TSCBasic.TerminalController + +struct CleanupCommand: ParsableCommand { + @Flag(name: .shortAndLong) + var verbose: Bool + + @Flag(name: .shortAndLong) + var force: Bool + + static let configuration = CommandConfiguration( + commandName: "cleanup", + abstract: "Remove temporary docker images.", + discussion: """ + All swift-docker DOCKERFILEs are tagged with the following labels + + LABEL \(Dockerfile.ActionLabel.label)=\(Dockerfile.ActionLabel.buildForTesting.rawValue)/\(Dockerfile.ActionLabel.build.rawValue) + LABEL \(Dockerfile.FolderLabel.label)=name-of-folder + + You can list all test images created using + + docker images --filter "label=\(Dockerfile.filter(for: .buildForTesting))" + """ + ) + + func run() throws { + try CleanupCommandRunner(verbose: verbose, force: force).run() + } +} + +struct CleanupCommandRunner { + private let terminal: OutputDestination + private let shell: ShellProtocol.Type + private let verbose: Bool + private let force: Bool + + init( + verbose: Bool, + force: Bool = false, + terminal: OutputDestination = TerminalController(stream: stdoutStream) ?? stdoutStream, + shell: ShellProtocol.Type = ShellRunner.self + ) { + self.verbose = verbose + self.force = force + self.terminal = terminal + self.shell = shell + } + + func run() throws { + let fetchTestImagesCommand = DockerCommands.fetchImageIdentifiers(filter: Dockerfile.filter(for: .buildForTesting)) + let fetchImageResult = try shell.run(fetchTestImagesCommand, outputDestination: nil, isVerbose: verbose) + if fetchImageResult.exitStatus != .terminated(code: 0) { + throw DockerError.failedToRunCommand(fetchTestImagesCommand) + } + + let imageIdentifiers = try fetchImageResult.utf8Output().split { $0.isNewline } + if imageIdentifiers.isEmpty { + terminal.writeLine("No images to delete") + return + } + + let deleteCommand = DockerCommands.deleteImages(identifiers: imageIdentifiers.joined(separator: " "), force: force) + let deleteResult = try shell.run(deleteCommand, outputDestination: nil, isVerbose: verbose) + if deleteResult.exitStatus != .terminated(code: 0) { + terminal.writeLine(try deleteResult.utf8stderrOutput()) + } + if verbose { + terminal.writeLine(try deleteResult.utf8Output()) + } else { + terminal.writeLine("Deleted \(imageIdentifiers.count) images") + } + } +} diff --git a/Sources/SwiftDockerLib/Constants.swift b/Sources/SwiftDockerLib/Constants.swift deleted file mode 100644 index eeb1bb1..0000000 --- a/Sources/SwiftDockerLib/Constants.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -let dockerImagePrefix = "spm-test-" - -let tempDockerFilePathComponent = "temp-dockerfile" -fileprivate let defaultDockerPathComponent = "/Dockerfile" - -func makeDefaultImage(forVersion version: String) -> String { - return "swiftdocker/swift:\(version)" -} - -func makeMinimalDockerFile(image: String, directory directoryName: String) -> String { - let directory = directoryName.replacingOccurrences(of: " ", with: "\\ ").lowercased() - - return """ - FROM \(image) - COPY . /\(directory) - WORKDIR /\(directory) - RUN swift build - """ -} - -func makeDockerTag(forDirectoryName directory: String, version: String) -> String { - let tagEscapedDirectory = directory.replacingOccurrences(of: " ", with: "").lowercased() - let dockerTag = dockerImagePrefix + tagEscapedDirectory + "-" + version - return dockerTag -} - -let defaultDockerFilePath = FileManager.default.currentDirectoryPath.appending(defaultDockerPathComponent) - -extension FileManager { - var currentDirectoryName: String { - return currentDirectoryPath.components(separatedBy: "/").last! - } -} diff --git a/Sources/SwiftDockerLib/DocExamples.swift b/Sources/SwiftDockerLib/DocExamples.swift new file mode 100644 index 0000000..e28879a --- /dev/null +++ b/Sources/SwiftDockerLib/DocExamples.swift @@ -0,0 +1,7 @@ +enum DocExamples { + static let testCommand = """ + swift docker test # Using swift:latest run swift build && swift test + swift docker test --swift 5.1 # Run tests in the swift:5.1 image + swift docker test --path ~/my-package # Specify the package directory + """ +} diff --git a/Sources/SwiftDockerLib/DockerCommands.swift b/Sources/SwiftDockerLib/DockerCommands.swift index 2157889..8f559df 100644 --- a/Sources/SwiftDockerLib/DockerCommands.swift +++ b/Sources/SwiftDockerLib/DockerCommands.swift @@ -1,67 +1,22 @@ -import Foundation -import ShellOut - -// MARK: Run & Delete operations - -func cleanup(path: String, fileManager: FileManager, silent: Bool = false) { - if silent == false { printTitle("Removing temporary Dockerfile") } - try? fileManager.removeItem(atPath: path) -} - -func runDockerTests(image: DockerImage, writeDockerFile shouldSaveFile: Bool) throws { - let fileManager = FileManager.default - let tempDockerFilePath = NSTemporaryDirectory().appending(tempDockerFilePathComponent) - - do { - cleanup(path: tempDockerFilePath, fileManager: fileManager, silent: true) - - let directoryName = fileManager.currentDirectoryName - let minimalDockerfile = makeMinimalDockerFile(image: image.imageName, directory: directoryName) - - printTitle("Creating temporary Dockerfile at \(tempDockerFilePath)") - printBody(minimalDockerfile) - try minimalDockerfile.write(toFile: tempDockerFilePath, atomically: true, encoding: .utf8) - - let dockerTag = makeDockerTag(forDirectoryName: directoryName, version: image.imageName) - - try runDockerBuild(tag: dockerTag, dockerFilePath: tempDockerFilePath) - try runDockerSwiftTest(tag: dockerTag, remove: true) - - cleanup(path: tempDockerFilePath, fileManager: fileManager) - - if shouldSaveFile { - try minimalDockerfile.write(toFile: defaultDockerFilePath, atomically: true, encoding: .utf8) - } - } catch { - cleanup(path: tempDockerFilePath, fileManager: fileManager, silent: true) - printError(error.localizedDescription) - } -} - -public func runDockerTests(version: String, image: String, writeDockerFile shouldSaveFile: Bool) throws { - guard let image = DockerImage(version: version, image: image) else { fatalError() } - try runDockerTests(image: image, writeDockerFile: shouldSaveFile) -} - -// MARK: Shellout wrappers - -public func runDockerRemoveImages() throws { - let startsWithTestPrefix = "^" + dockerImagePrefix - let remove = ShellOutCommand.dockerRemoveImages(matchingPattern: startsWithTestPrefix) - try runAndLog(remove, prefix: "Removing images") -} - -public func writeDefaultDockerFile(version: String) throws { - let file = makeMinimalDockerFile(image: makeDefaultImage(forVersion: version), directory: FileManager.default.currentDirectoryName) - try file.write(toFile: defaultDockerFilePath, atomically: true, encoding: .utf8) -} - -func runDockerSwiftTest(tag: String, remove: Bool) throws { - let testCMD = ShellOutCommand.dockerRun(tag: tag, remove: remove, command: "swift test") - try runAndLog(testCMD, prefix: "Running swift test") -} - -func runDockerBuild(tag: String, dockerFilePath: String) throws { - let buildCmd = ShellOutCommand.dockerBuild(tag: tag, dockerFile: dockerFilePath) - try runAndLog(buildCmd, prefix: "Building docker image") +enum DockerCommands { + static func dockerBuild(tag: String, dockerFilePath: String) -> String { + let file = dockerFilePath == "." ? dockerFilePath : "--file \(dockerFilePath)" + let dockerBuild = "docker build -t \(tag.lowercased()) \(file) ." + return dockerBuild + } + + static func dockerRun(tag: String, remove: Bool, command: String) -> String { + let removeTag = remove ? "--rm" : "" + let dockerRun = "docker run \(removeTag) \(tag.lowercased()) \(command)" + return dockerRun + } + + static func fetchImageIdentifiers(filter: String) -> String { + "docker images --filter \(filter) --quiet" + } + + static func deleteImages(identifiers: String, force: Bool) -> String { + let forceFlag = force ? "--force" : "" + return "docker rmi \(identifiers) \(forceFlag)" + } } diff --git a/Sources/SwiftDockerLib/DockerHub.swift b/Sources/SwiftDockerLib/DockerHub.swift new file mode 100644 index 0000000..186e2aa --- /dev/null +++ b/Sources/SwiftDockerLib/DockerHub.swift @@ -0,0 +1,3 @@ +enum DockerHub { + static let reservedDockerID = "swiftdockercli" +} diff --git a/Sources/SwiftDockerLib/DockerImage.swift b/Sources/SwiftDockerLib/DockerImage.swift deleted file mode 100644 index 76b029c..0000000 --- a/Sources/SwiftDockerLib/DockerImage.swift +++ /dev/null @@ -1,27 +0,0 @@ -/// A docker image represented either as a full image or a swift version. -enum DockerImage { - case image(String) - case swiftVersion(String) - - /// Image has a higher precedance than version - init?(version: String?, image: String?) { - if let fullImage = image, fullImage.isEmpty == false { - self = .image(fullImage) - return - } - - if let version = version { - self = .swiftVersion(version) - return - } - - return nil - } - - var imageName: String { - switch self { - case let .image(fullImage): return fullImage - case let .swiftVersion(version): return makeDefaultImage(forVersion: version) - } - } -} diff --git a/Sources/SwiftDockerLib/DockerOutputRewriter.swift b/Sources/SwiftDockerLib/DockerOutputRewriter.swift new file mode 100644 index 0000000..f303312 --- /dev/null +++ b/Sources/SwiftDockerLib/DockerOutputRewriter.swift @@ -0,0 +1,44 @@ +import TSCBasic + +protocol OutputRewriter { + static func make(controller: TerminalController) -> TSCBasic.Process.OutputRedirection +} + +enum DockerOutputRewriter: OutputRewriter { + static func make(controller: TerminalController) -> TSCBasic.Process.OutputRedirection { + .stream(stdout: { stdBytes in + guard let string = String(bytes: stdBytes, encoding: .utf8) else { return } + string.split { $0.isNewline }.forEach { (substring: Substring) in + if substring.hasPrefix("Step ") { + controller.write(substring.indent(by: 2)) + controller.endLine() + } + return + } + }, stderr: { errorBytes in + guard let string = String(bytes: errorBytes, encoding: .utf8) else { return } + string.split(separator: "\n").forEach { substring in + controller.write(String(substring), inColor: .red) + controller.endLine() + } + }) + } +} + +enum VerboseOutputRedirection: OutputRewriter { + static func make(controller: TerminalController) -> TSCBasic.Process.OutputRedirection { + .stream(stdout: { stdBytes in + guard let string = String(bytes: stdBytes, encoding: .utf8) else { return } + string.split { $0.isNewline }.forEach { (substring: Substring) in + controller.write(substring.indent(by: 2)) + controller.endLine() + } + }, stderr: { errorBytes in + guard let string = String(bytes: errorBytes, encoding: .utf8) else { return } + string.split(separator: "\n").forEach { substring in + controller.write(String(substring), inColor: .red) + controller.endLine() + } + }) + } +} diff --git a/Sources/SwiftDockerLib/DockerTag.swift b/Sources/SwiftDockerLib/DockerTag.swift new file mode 100644 index 0000000..15f8867 --- /dev/null +++ b/Sources/SwiftDockerLib/DockerTag.swift @@ -0,0 +1,27 @@ +/// A docker image represented either as a full image or a swift version. +enum DockerTag: Equatable { + case image(String) + case officialSwiftVersion(String) + + /// Image has a higher precedance than version + init?(version: String?, image: String?) { + if let fullImage = image, fullImage.isEmpty == false { + self = .image(fullImage) + return + } + + if let version = version { + self = .officialSwiftVersion(version) + return + } + + return nil + } + + var fullName: String { + switch self { + case let .image(fullImage): return fullImage + case let .officialSwiftVersion(version): return "swift:\(version)" + } + } +} diff --git a/Sources/SwiftDockerLib/Dockerfile.swift b/Sources/SwiftDockerLib/Dockerfile.swift new file mode 100644 index 0000000..769b583 --- /dev/null +++ b/Sources/SwiftDockerLib/Dockerfile.swift @@ -0,0 +1,33 @@ +enum Dockerfile { + enum ActionLabel: String { + case buildForTesting = "test" + case build + + static let label = "com.\(DockerHub.reservedDockerID).action" + } + + enum FolderLabel { + static let label = "com.\(DockerHub.reservedDockerID).folder" + } + + static func makeMinimalDockerFile( + image: String, + directory directoryName: String, + action: ActionLabel + ) -> String { + let directory = directoryName.replacingOccurrences(of: " ", with: "\\ ").lowercased() + + return """ + FROM \(image) + LABEL \(ActionLabel.label)="\(action.rawValue)" + LABEL \(FolderLabel.label)="\(directory)" + COPY . /\(directory) + WORKDIR /\(directory) + RUN swift build\n + """ + } + + static func filter(for action: ActionLabel) -> String { + "label=\(ActionLabel.label)=\(action.rawValue)" + } +} diff --git a/Sources/SwiftDockerLib/Errors.swift b/Sources/SwiftDockerLib/Errors.swift new file mode 100644 index 0000000..5b4c959 --- /dev/null +++ b/Sources/SwiftDockerLib/Errors.swift @@ -0,0 +1,11 @@ +public struct DockerError: Error { + public let message: String + + init(_ message: String) { + self.message = message + } + + static func failedToRunCommand(_ cmd: String) -> DockerError { + DockerError("failed to run \(cmd)") + } +} diff --git a/Sources/SwiftDockerLib/FileSystemExtensions.swift b/Sources/SwiftDockerLib/FileSystemExtensions.swift new file mode 100644 index 0000000..b510ddc --- /dev/null +++ b/Sources/SwiftDockerLib/FileSystemExtensions.swift @@ -0,0 +1,9 @@ +import struct Foundation.URL +import struct TSCBasic.AbsolutePath +import protocol TSCBasic.FileSystem + +extension FileSystem { + func exists(_ url: URL) -> Bool { + exists(try! AbsolutePath(validating: url.path)) + } +} diff --git a/Sources/SwiftDockerLib/Indent.swift b/Sources/SwiftDockerLib/Indent.swift new file mode 100644 index 0000000..5d3ad5e --- /dev/null +++ b/Sources/SwiftDockerLib/Indent.swift @@ -0,0 +1,9 @@ +extension StringProtocol { + func indentLines(by spaces: Int) -> String { + split { $0.isNewline }.map { $0.indent(by: spaces) }.joined(separator: "\n") + } + + func indent(by spaces: Int) -> String { + repeatElement(" ", count: spaces).joined(separator: "") + self + } +} diff --git a/Sources/SwiftDockerLib/Logging.swift b/Sources/SwiftDockerLib/Logging.swift deleted file mode 100644 index 127ce82..0000000 --- a/Sources/SwiftDockerLib/Logging.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation -import Rainbow -import ShellOut - -func runAndLog(_ cmd: ShellOutCommand, prefix: String) throws { - printTitle("\(prefix): \(cmd.string)") - try shellOut(to: cmd, outputHandle: bodyHandle, errorHandle: errorHandle) -} - -func printTitle(_ string: String) { - print(string.bold.lightGreen) -} - -func printBody(_ string: String) { - print(string.italic.lightCyan) -} - -func printError(_ string: String) { - print(string.lightRed) -} - -// MARK: Handles -class ColorfulHandle: Handle { - let print: (String) -> Void - - init(print: @escaping (String) -> Void) { - self.print = print - } - - func handle(data: Data) { - guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .newlines), output.isEmpty == false else { return } - print(output) - } -} - -let bodyHandle = ColorfulHandle(print: printBody) -let errorHandle = ColorfulHandle(print: printError) diff --git a/Sources/SwiftDockerLib/OutputDestination.swift b/Sources/SwiftDockerLib/OutputDestination.swift new file mode 100644 index 0000000..c6b7f55 --- /dev/null +++ b/Sources/SwiftDockerLib/OutputDestination.swift @@ -0,0 +1,19 @@ +import class TSCBasic.TerminalController +import class TSCBasic.ThreadSafeOutputByteStream + +protocol OutputDestination { + func writeLine(_ string: String) +} + +extension TerminalController: OutputDestination { + func writeLine(_ string: String) { + write(string) + endLine() + } +} + +extension ThreadSafeOutputByteStream: OutputDestination { + func writeLine(_ string: String) { + write(string) + } +} diff --git a/Sources/SwiftDockerLib/ShellOut+Docker.swift b/Sources/SwiftDockerLib/ShellOut+Docker.swift deleted file mode 100644 index 547ba07..0000000 --- a/Sources/SwiftDockerLib/ShellOut+Docker.swift +++ /dev/null @@ -1,24 +0,0 @@ -import ShellOut - -extension ShellOutCommand { - static func dockerBuildCurrentDirectory(tag: String) -> ShellOutCommand { - return dockerBuild(tag: tag, dockerFile: ".") - } - - static func dockerBuild(tag: String, dockerFile: String) -> ShellOutCommand { - let file = dockerFile == "." ? dockerFile : "--file \(dockerFile)" - let dockerBuild = "docker build -t \(tag) . \(file)" - return ShellOutCommand(string: dockerBuild) - } - - static func dockerRun(tag: String, remove: Bool, command: String) -> ShellOutCommand { - let removeTag = remove ? "--rm" : "" - let dockerRun = "docker run \(removeTag) \(tag) \(command)" - return ShellOutCommand(string: dockerRun) - } - - static func dockerRemoveImages(matchingPattern pattern: String) -> ShellOutCommand { - let removeImages = "docker images -a | grep \"\(pattern)\" | awk '{print $3}' | xargs docker rmi" - return ShellOutCommand(string: removeImages) - } -} diff --git a/Sources/SwiftDockerLib/ShellRunner.swift b/Sources/SwiftDockerLib/ShellRunner.swift new file mode 100644 index 0000000..140c64e --- /dev/null +++ b/Sources/SwiftDockerLib/ShellRunner.swift @@ -0,0 +1,70 @@ +import Foundation +import class TSCBasic.Process +import struct TSCBasic.ProcessResult +import class TSCBasic.TerminalController + +protocol ShellProtocol { + @discardableResult + static func runWithStreamingOutput( + _ cmd: String, + controller: OutputDestination, + redirection: OutputRewriter.Type, + isVerbose: Bool + ) throws -> ProcessResult + + @discardableResult + static func run(_ cmd: String, outputDestination: OutputDestination?, isVerbose: Bool) throws -> ProcessResult +} + +enum ShellRunner: ShellProtocol { + @discardableResult + static func runWithStreamingOutput( + _ cmd: String, + controller: OutputDestination, + redirection: OutputRewriter.Type, + isVerbose: Bool + ) throws -> ProcessResult { + if let controller = controller as? TerminalController { + if isVerbose { + return try runStreamingOutput(cmd, controller: controller, + redirection: VerboseOutputRedirection.self, isVerbose: isVerbose) + } + return try runStreamingOutput(cmd, controller: controller, redirection: redirection, isVerbose: isVerbose) + } else { + return try run(cmd, outputDestination: controller, isVerbose: isVerbose) + } + } + + @discardableResult + static func runStreamingOutput( + _ cmd: String, + controller: TerminalController, + redirection: OutputRewriter.Type, + isVerbose: Bool + ) throws -> ProcessResult { + let cmds = cmd.split(separator: " ").map { String($0) } + let process = Process( + arguments: cmds, + outputRedirection: redirection.make(controller: controller), + verbose: isVerbose, + startNewProcessGroup: true + ) + try process.launch() + return try process.waitUntilExit() + } + + @discardableResult + static func run(_ cmd: String, outputDestination: OutputDestination?, isVerbose: Bool) throws -> ProcessResult { + let cmds = cmd.split(separator: " ").map { String($0) } + let process = Process(arguments: cmds, verbose: isVerbose, startNewProcessGroup: true) + try process.launch() + + let result = try process.waitUntilExit() + if outputDestination != nil { + let output: String = (try? result.utf8Output()) ?? "" + outputDestination?.writeLine(output) + } + + return result + } +} diff --git a/Sources/SwiftDockerLib/SquareBracketsLineRewriter.swift b/Sources/SwiftDockerLib/SquareBracketsLineRewriter.swift new file mode 100644 index 0000000..b38ada9 --- /dev/null +++ b/Sources/SwiftDockerLib/SquareBracketsLineRewriter.swift @@ -0,0 +1,33 @@ +import Foundation +import TSCBasic + +enum SquareBracketsLineRewriter: OutputRewriter { + static func make(controller: TerminalController) -> TSCBasic.Process.OutputRedirection { + .stream(stdout: { stdBytes in + var needsEndLine = false + guard let string = String(bytes: stdBytes, encoding: .utf8) else { return } + string.split { $0.isNewline }.forEach { substring in + let isInlineTotal = substring.hasPrefix("[") + if isInlineTotal { + controller.clearLine() + needsEndLine = true + } else if needsEndLine { + controller.endLine() + needsEndLine = false + } + + controller.write(substring.indent(by: 2)) + + if !isInlineTotal { + controller.endLine() + } + } + }, stderr: { errorBytes in + guard let string = String(bytes: errorBytes, encoding: .utf8) else { return } + string.split(separator: "\n").forEach { substring in + controller.write(String(substring), inColor: .red) + controller.endLine() + } + }) + } +} diff --git a/Sources/SwiftDockerLib/TemporaryFile.swift b/Sources/SwiftDockerLib/TemporaryFile.swift new file mode 100644 index 0000000..568bbc2 --- /dev/null +++ b/Sources/SwiftDockerLib/TemporaryFile.swift @@ -0,0 +1,3 @@ +import struct TSCBasic.AbsolutePath + +typealias TemporaryFileFunction = (AbsolutePath?, String, String, Bool, (AbsolutePath) throws -> Void) throws -> Void diff --git a/Sources/SwiftDockerLib/TestCommand.swift b/Sources/SwiftDockerLib/TestCommand.swift new file mode 100644 index 0000000..9fe68e5 --- /dev/null +++ b/Sources/SwiftDockerLib/TestCommand.swift @@ -0,0 +1,108 @@ +import ArgumentParser +import Foundation +import TSCBasic + +public struct TestCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "test", + abstract: "Test your swift package in a docker container.", + discussion: """ + Defaults to testing the current directory using the official swift:latest docker image + + Examples: + + \(DocExamples.testCommand.indentLines(by: 2)) + swift docker test --image vapor/ubuntu/bionic + + Docker Labels: + + All docker images created from this command will have LABEL com.swiftdockercli.action=test so they can easily be deleted with `swift docker cleanup` + """, + shouldDisplay: true + ) + + @OptionGroup() + var options: CLIOptions + + public func run() throws { + // Docker build context is relative to the directory docker is being called from. + // https://github.com/moby/moby/issues/4592 + // For testing we use the swift-tools-support InMemoryFileSystem which fatalErrors + // on calls to changeCurrentWorkingDirectory in 0.1.1 + try localFileSystem.changeCurrentWorkingDirectory(to: options.absolutePath) + try TestCommandRunner(options: options).run() + } + + public init() {} + + public init(options: CLIOptions) { + self.options = options + } +} + +/// You must run this command from the options.absolutePath directory. +struct TestCommandRunner { + private var options: CLIOptions + private let filesystem: FileSystem + private let outputDestinaton: OutputDestination + private let shell: ShellProtocol.Type + private let withTemporaryFileClosure: TemporaryFileFunction + private let computeGitSHA: () -> String + + func run() throws { + let uniqueHash = computeGitSHA() // TODO: + let tagName = "swift-docker/\(options.projectName.lowercased()):\(uniqueHash)" + + try BuildCommandRunner( + tag: tagName, options: options, fileSystem: filesystem, + output: outputDestinaton, shell: shell, + withTemporaryFile: withTemporaryFileClosure + ).run(action: .buildForTesting) + + let testCommand = DockerCommands.dockerRun(tag: tagName, remove: true, command: "swift test") + outputDestinaton.writeLine("-> swift test") + try shell.runWithStreamingOutput( + testCommand, + controller: outputDestinaton, + redirection: SquareBracketsLineRewriter.self, + isVerbose: options.verbose + ) + } + + init(options: CLIOptions) { + self.init( + options: options, + fileSystem: localFileSystem, + output: TerminalController(stream: stdoutStream) ?? stdoutStream, + shell: ShellRunner.self + ) + } + + init( + options: CLIOptions, + fileSystem: FileSystem = localFileSystem, + output: OutputDestination = TerminalController(stream: stdoutStream) ?? stdoutStream, + shell: ShellProtocol.Type = ShellRunner.self, + withTemporaryFile: TemporaryFileFunction? = nil, + computeGitSHA: (() -> String)? = nil + ) { + self.options = options + filesystem = fileSystem + outputDestinaton = output + self.shell = shell + withTemporaryFileClosure = withTemporaryFile ?? { dir, prefix, suffix, delete, body in + try TSCBasic.withTemporaryFile(dir: dir, prefix: prefix, suffix: suffix, deleteOnClose: delete) { tempfile in try body(tempfile.path) } + } + self.computeGitSHA = computeGitSHA ?? { String(NSUUID().uuidString.prefix(6)) } + } + + func withTemporaryFile( + dir: AbsolutePath? = nil, + prefix: String = "TemporaryFile", + suffix: String = "", + deleteOnClose: Bool = true, _ + body: (AbsolutePath) throws -> Void + ) throws { + try withTemporaryFileClosure(dir, prefix, suffix, deleteOnClose, body) + } +} diff --git a/Sources/SwiftDockerLib/WriteDockerfileCommand.swift b/Sources/SwiftDockerLib/WriteDockerfileCommand.swift new file mode 100644 index 0000000..85d20cc --- /dev/null +++ b/Sources/SwiftDockerLib/WriteDockerfileCommand.swift @@ -0,0 +1,57 @@ +import ArgumentParser +import struct TSCBasic.ByteString +import protocol TSCBasic.FileSystem +import var TSCBasic.localFileSystem +import var TSCBasic.stdoutStream +import class TSCBasic.TerminalController + +struct WriteDockerfileCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "write-dockerfile", + abstract: "Write a dockerfile to disk.", + discussion: """ + swift docker write-dockerfile # Save to ./Dockerfile using swift:latest + swift docker write-dockerfile --swift 5.1 --path Dockerfile.test # Save to ./Dockerfile.test using swift:5.1 + """ + ) + + @OptionGroup() + var options: CLIOptions + + func run() throws { + try WriteDockerfileCommandRunner(options: self.options).run() + } +} + +struct WriteDockerfileCommandRunner { + private var options: CLIOptions + private let filesystem: FileSystem + private let outputDestinaton: OutputDestination + + func run() throws { + let dockerfileBody = Dockerfile.makeMinimalDockerFile( + image: options.baseImage.fullName, + directory: options.projectName, + action: .build + ) + + try filesystem.writeFileContents( + options.defaultDockerfilePath, + bytes: ByteString(encodingAsUTF8: dockerfileBody), + atomically: true + ) + + outputDestinaton.writeLine("Saved dockerfile to \(options.defaultDockerfilePath.prettyPath())") + if options.verbose { outputDestinaton.writeLine(dockerfileBody) } + } + + init( + options: CLIOptions, + filesystem: FileSystem = localFileSystem, + outputDestinaton: OutputDestination = TerminalController(stream: stdoutStream) ?? stdoutStream + ) { + self.options = options + self.filesystem = filesystem + self.outputDestinaton = outputDestinaton + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index d40af19..0d3c851 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,6 +1,8 @@ -@testable import SwiftDockerLibTests import XCTest -XCTMain([ - testCase(DockerImageTests.allTests), -]) +import SwiftDockerLibTests + +var tests = [XCTestCaseEntry]() +tests += SwiftDockerLibTests.__allTests() + +XCTMain(tests) diff --git a/Tests/SwiftDockerLibTests/BuildCommandUnitTests.swift b/Tests/SwiftDockerLibTests/BuildCommandUnitTests.swift new file mode 100644 index 0000000..e61b792 --- /dev/null +++ b/Tests/SwiftDockerLibTests/BuildCommandUnitTests.swift @@ -0,0 +1,47 @@ +import Foundation +@testable import SwiftDockerLib +@testable import TSCBasic +import XCTest + +class TestCommandUnitTests: XCTestCase { + func testSuccessfullBuildAndTest() throws { + let options = try CLIOptions.parse(["--swift", "5.2", "--path", "/hello/my-project", "-v", "--skip-validation"]) + let fileSystem = InMemoryFileSystem() + try fileSystem.createDirectory(AbsolutePath("/tmp")) + let output = MockOutput() + let shell = MockShell.self + shell.clear() + let runner = TestCommandRunner( + options: options, + fileSystem: fileSystem, + output: output, + shell: shell, + withTemporaryFile: mockWithTemporaryFile, + computeGitSHA: { "4E3FF7" } + ) + + try runner.run() + XCTAssertEqual(output.lines, [ + "Created temporary Dockerfile at /tmp/Dockerfile", + "-> docker build", + "-> swift test", + ]) + + XCTAssertEqual(shell.commands, [ + "docker build -t swift-docker/my-project:4e3ff7 --file /tmp/Dockerfile .", + "docker run --rm swift-docker/my-project:4e3ff7 swift test", + ]) + + let dockerString = try fileSystem.readFileContents(AbsolutePath("/tmp/Dockerfile")).cString + XCTAssertEqual(try fileSystem.getDirectoryContents(AbsolutePath("/tmp")), ["Dockerfile"]) + XCTAssertEqual(dockerString, """ + FROM swift:5.2 + LABEL com.swiftdockercli.action="test" + LABEL com.swiftdockercli.folder="my-project" + COPY . /my-project + WORKDIR /my-project + RUN swift build + + """) + } +} diff --git a/Tests/SwiftDockerLibTests/CLIOptionTests.swift b/Tests/SwiftDockerLibTests/CLIOptionTests.swift new file mode 100644 index 0000000..276b021 --- /dev/null +++ b/Tests/SwiftDockerLibTests/CLIOptionTests.swift @@ -0,0 +1,66 @@ +import ArgumentParser +import Foundation +@testable import SwiftDockerLib +import TSCBasic +import XCTest + +extension CLIOptions { + static func parseWithoutValidation(_ args: [String]? = nil) throws -> CLIOptions { + let fullArgs = args ?? [] + return try parse(fullArgs + ["--skip-validation"]) + } +} + +class CLIOptionTests: XCTestCase { + func testDefaultDockerTag() throws { + let options = try CLIOptions.parseWithoutValidation() + XCTAssertEqual(options.baseImage, DockerTag.officialSwiftVersion("latest")) + } + + func testCustomSwiftVersion() throws { + let options = try CLIOptions.parseWithoutValidation(["--swift", "5.1"]) + XCTAssertEqual(options.baseImage, DockerTag.officialSwiftVersion("5.1")) + } + + func testCustomImage() throws { + let options = try CLIOptions.parseWithoutValidation(["--image", "vapor/ubuntu:latest"]) + XCTAssertEqual(options.baseImage, DockerTag.image("vapor/ubuntu:latest")) + } + + func testCustomAbsolutePath() throws { + let options = try CLIOptions.parseWithoutValidation(["--path", "/tmp/hello/world"]) + XCTAssertEqual(options.absolutePath, AbsolutePath("/tmp/hello/world")) + } + + func testCustomTildaPath() throws { + let options = try CLIOptions.parseWithoutValidation(["--path", "~/hello/world"]) + XCTAssertEqual(options.absolutePath, AbsolutePath("hello/world", relativeTo: localFileSystem.homeDirectory)) + } + + func testRelativePath() throws { + let options = try CLIOptions.parseWithoutValidation(["--path", "../../hello/world"]) + let currentDir = localFileSystem.currentWorkingDirectory! + XCTAssertEqual(options.absolutePath, AbsolutePath("hello/world", relativeTo: currentDir.parentDirectory.parentDirectory)) + } + + func testProjectName() throws { + let options = try CLIOptions.parseWithoutValidation(["--path", "../../hello/world"]) + XCTAssertEqual(options.projectName, "world") + } + + func testValidatesSwiftAndImageAreExclusive() { + let invalidArgs = ["--image", "vapor/ubuntu:latest", "--swift", "5.1"] + XCTAssertThrowsError(try CLIOptions.parse(invalidArgs), "--image and --swift were not exclusive") + } + + func testDirectoryMustContainAPackageSwiftFile() throws { + try withTemporaryDirectory { dir -> Void in + try localFileSystem.writeFileContents(dir.appending(component: "Package.swift"), bytes: ByteString()) + XCTAssertNoThrow(try CLIOptions.parse(["--path", dir.pathString])) + } + + try withTemporaryDirectory { (dir) -> Void in + XCTAssertThrowsError(try CLIOptions.parse(["--path", dir.pathString])) + } + } +} diff --git a/Tests/SwiftDockerLibTests/CleanupCommandUnitTests.swift b/Tests/SwiftDockerLibTests/CleanupCommandUnitTests.swift new file mode 100644 index 0000000..45a6241 --- /dev/null +++ b/Tests/SwiftDockerLibTests/CleanupCommandUnitTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import SwiftDockerLib +@testable import TSCBasic +import XCTest + +class CleanupCommandUnitTests: XCTestCase { + func testNoImagesToCleanup() throws { + let output = MockOutput() + let shell = MockShell.self + shell.clear() + let runner = CleanupCommandRunner(verbose: true, terminal: output, shell: shell) + try runner.run() + XCTAssertEqual(output.lines, [ + "No images to delete", + ]) + + XCTAssertEqual(shell.commands, [ + "docker images --filter label=com.swiftdockercli.action=test --quiet", + ]) + } + + func testDeletingOneImage() throws { + let output = MockOutput() + let shell = MockShell.self + shell.clear() + let runner = CleanupCommandRunner(verbose: true, terminal: output, shell: shell) + shell.nextStandardOut = ["123456", "Deleted image 123456"] + try runner.run() + XCTAssertEqual(output.lines, [ + "Deleted image 123456", + ]) + + XCTAssertEqual(shell.commands, [ + "docker images --filter label=com.swiftdockercli.action=test --quiet", + "docker rmi 123456 ", + ]) + } + + func testForceDeletingOneImage() throws { + let output = MockOutput() + let shell = MockShell.self + shell.clear() + let runner = CleanupCommandRunner(verbose: true, force: true, terminal: output, shell: shell) + shell.nextStandardOut = ["123456", "Deleted image 123456"] + try runner.run() + XCTAssertEqual(output.lines, [ + "Deleted image 123456", + ]) + + XCTAssertEqual(shell.commands, [ + "docker images --filter label=com.swiftdockercli.action=test --quiet", + "docker rmi 123456 --force", + ]) + } +} diff --git a/Tests/SwiftDockerLibTests/DockerCommandTests.swift b/Tests/SwiftDockerLibTests/DockerCommandTests.swift index 6af161f..49adce7 100644 --- a/Tests/SwiftDockerLibTests/DockerCommandTests.swift +++ b/Tests/SwiftDockerLibTests/DockerCommandTests.swift @@ -2,44 +2,44 @@ import XCTest enum TestError: Error { - case failed + case failed } class DockerImageTests: XCTestCase { - func testInitailizedWithVersion() throws { - let version = "4.1" - guard let destination = DockerImage(version: version, image: nil) else { throw TestError.failed } - guard case let .swiftVersion(finalVersion) = destination else { throw TestError.failed } - XCTAssertEqual(finalVersion, "4.1") - } + func testInitailizedWithVersion() throws { + let version = "4.1" + guard let destination = DockerTag(version: version, image: nil) else { throw TestError.failed } + guard case let .officialSwiftVersion(finalVersion) = destination else { throw TestError.failed } + XCTAssertEqual(finalVersion, "4.1") + } - func testInitailizedWithImage() throws { - let image = "swift:4.1" - guard let destination = DockerImage(version: nil, image: image) else { throw TestError.failed } - guard case let .image(fullImage) = destination else { throw TestError.failed } - XCTAssertEqual(fullImage, "swift:4.1") - } + func testInitailizedWithImage() throws { + let image = "swift:4.1" + guard let destination = DockerTag(version: nil, image: image) else { throw TestError.failed } + guard case let .image(fullImage) = destination else { throw TestError.failed } + XCTAssertEqual(fullImage, "swift:4.1") + } - func testInitailizedWithEmptyImage() throws { - let image = "" - let version = "4.0" - guard let destination = DockerImage(version: version, image: image) else { throw TestError.failed } - guard case let .swiftVersion(finalVersion) = destination else { throw TestError.failed } - XCTAssertEqual(finalVersion, "4.0") - } + func testInitailizedWithEmptyImage() throws { + let image = "" + let version = "4.0" + guard let destination = DockerTag(version: version, image: image) else { throw TestError.failed } + guard case let .officialSwiftVersion(finalVersion) = destination else { throw TestError.failed } + XCTAssertEqual(finalVersion, "4.0") + } - func testInitailizationPrecedence() throws { - let version = "4.1" - let image = "swift:4.1" - guard let destination = DockerImage(version: version, image: image) else { throw TestError.failed } - guard case let .image(fullImage) = destination else { throw TestError.failed } - XCTAssertEqual(fullImage, "swift:4.1") - } + func testInitailizationPrecedence() throws { + let version = "4.1" + let image = "swift:4.1" + guard let destination = DockerTag(version: version, image: image) else { throw TestError.failed } + guard case let .image(fullImage) = destination else { throw TestError.failed } + XCTAssertEqual(fullImage, "swift:4.1") + } - static var allTests = [ - ("testInitailizedWithVersion", DockerImageTests.testInitailizedWithVersion), - ("testInitailizedWithImage", DockerImageTests.testInitailizedWithImage), - ("testInitailizedWithEmptyImage", DockerImageTests.testInitailizedWithEmptyImage), - ("testInitailizationPrecedence", DockerImageTests.testInitailizationPrecedence), - ] + static var allTests = [ + ("testInitailizedWithVersion", DockerImageTests.testInitailizedWithVersion), + ("testInitailizedWithImage", DockerImageTests.testInitailizedWithImage), + ("testInitailizedWithEmptyImage", DockerImageTests.testInitailizedWithEmptyImage), + ("testInitailizationPrecedence", DockerImageTests.testInitailizationPrecedence), + ] } diff --git a/Tests/SwiftDockerLibTests/Mocks.swift b/Tests/SwiftDockerLibTests/Mocks.swift new file mode 100644 index 0000000..de1f0d9 --- /dev/null +++ b/Tests/SwiftDockerLibTests/Mocks.swift @@ -0,0 +1,48 @@ +@testable import SwiftDockerLib +@testable import TSCBasic + +class MockShell: ShellProtocol { + static var commands = [String]() + static var nextStandardOut: [String] = [] + + static func runWithStreamingOutput(_ cmd: String, controller _: OutputDestination, redirection _: OutputRewriter.Type, isVerbose _: Bool) throws -> ProcessResult { + commands.append(cmd) + let arguments = cmd.split { $0.isNewline }.map { String($0) } + let byteArray = nextStandardOut.first.flatMap { ByteString(encodingAsUTF8: $0)._bytes } ?? [] + let result = ProcessResult(arguments: arguments, environment: [:], exitStatus: .terminated(code: 0), output: .success(byteArray), stderrOutput: .success([])) + nextStandardOut = Array(nextStandardOut.dropFirst()) + return result + } + + static func run(_ cmd: String, outputDestination _: OutputDestination?, isVerbose _: Bool) throws -> ProcessResult { + commands.append(cmd) + let arguments = cmd.split { $0.isNewline }.map { String($0) } + let byteArray = nextStandardOut.first.flatMap { ByteString(encodingAsUTF8: $0)._bytes } ?? [] + let result = ProcessResult(arguments: arguments, environment: [:], exitStatus: .terminated(code: 0), output: .success(byteArray), stderrOutput: .success([])) + nextStandardOut = Array(nextStandardOut.dropFirst()) + return result + } + + static func clear() { + Self.commands = [] + } +} + +class MockOutput: OutputDestination { + var lines = [String]() + func writeLine(_ string: String) { + lines.append(string) + } +} + +func mockWithTemporaryFile( + dir _: AbsolutePath? = nil, + prefix: String = "TemporaryFile", + suffix _: String = "", + deleteOnClose _: Bool = true, _ + body: (AbsolutePath) throws -> Void +) throws { + let tmp = AbsolutePath("/tmp") + let tmpFile = tmp.appending(component: prefix) + try body(tmpFile) +} diff --git a/Tests/SwiftDockerLibTests/TestCommandUnitTests.swift b/Tests/SwiftDockerLibTests/TestCommandUnitTests.swift new file mode 100644 index 0000000..03009c6 --- /dev/null +++ b/Tests/SwiftDockerLibTests/TestCommandUnitTests.swift @@ -0,0 +1,45 @@ +import Foundation +@testable import SwiftDockerLib +@testable import TSCBasic +import XCTest + +class BuiildCommandUnitTests: XCTestCase { + func testSuccessfullBuild() throws { + let options = try CLIOptions.parse(["--swift", "5.2", "--path", "/hello/my-Project", "-v", "--skip-validation"]) + let fileSystem = InMemoryFileSystem() + try fileSystem.createDirectory(AbsolutePath("/tmp")) + let output = MockOutput() + let shell = MockShell.self + shell.clear() + let runner = BuildCommandRunner( + tag: "iain/DocKer:my-tag", + options: options, + fileSystem: fileSystem, + output: output, + shell: shell, + withTemporaryFile: mockWithTemporaryFile + ) + + try runner.run(action: .build) + XCTAssertEqual(output.lines, [ + "Created temporary Dockerfile at /tmp/Dockerfile", + "-> docker build", + ]) + + XCTAssertEqual(shell.commands, [ + "docker build -t iain/docker:my-tag --file /tmp/Dockerfile .", + ]) + + let dockerString = try fileSystem.readFileContents(AbsolutePath("/tmp/Dockerfile")).cString + XCTAssertEqual(try fileSystem.getDirectoryContents(AbsolutePath("/tmp")), ["Dockerfile"]) + XCTAssertEqual(dockerString, """ + FROM swift:5.2 + LABEL com.swiftdockercli.action="build" + LABEL com.swiftdockercli.folder="my-project" + COPY . /my-project + WORKDIR /my-project + RUN swift build + + """) + } +} diff --git a/Tests/SwiftDockerLibTests/URLArgumentTests.swift b/Tests/SwiftDockerLibTests/URLArgumentTests.swift new file mode 100644 index 0000000..e924ae8 --- /dev/null +++ b/Tests/SwiftDockerLibTests/URLArgumentTests.swift @@ -0,0 +1,11 @@ +import ArgumentParser +import Foundation +import XCTest + +class URLArgumentTests: XCTestCase { + func testRelativePathResolution() throws { + let path = URL(argument: "~/Code")!.path + let expectedPath = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Code").path + XCTAssertEqual(path, expectedPath) + } +} diff --git a/Tests/SwiftDockerLibTests/XCTestManifests.swift b/Tests/SwiftDockerLibTests/XCTestManifests.swift new file mode 100644 index 0000000..487c193 --- /dev/null +++ b/Tests/SwiftDockerLibTests/XCTestManifests.swift @@ -0,0 +1,81 @@ +#if !canImport(ObjectiveC) +import XCTest + +extension BuiildCommandUnitTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__BuiildCommandUnitTests = [ + ("testSuccessfullBuild", testSuccessfullBuild), + ] +} + +extension CLIOptionTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CLIOptionTests = [ + ("testCustomAbsolutePath", testCustomAbsolutePath), + ("testCustomImage", testCustomImage), + ("testCustomSwiftVersion", testCustomSwiftVersion), + ("testCustomTildaPath", testCustomTildaPath), + ("testDefaultDockerTag", testDefaultDockerTag), + ("testDirectoryMustContainAPackageSwiftFile", testDirectoryMustContainAPackageSwiftFile), + ("testProjectName", testProjectName), + ("testRelativePath", testRelativePath), + ("testValidatesSwiftAndImageAreExclusive", testValidatesSwiftAndImageAreExclusive), + ] +} + +extension CleanupCommandUnitTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CleanupCommandUnitTests = [ + ("testDeletingOneImage", testDeletingOneImage), + ("testForceDeletingOneImage", testForceDeletingOneImage), + ("testNoImagesToCleanup", testNoImagesToCleanup), + ] +} + +extension DockerImageTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__DockerImageTests = [ + ("testInitailizationPrecedence", testInitailizationPrecedence), + ("testInitailizedWithEmptyImage", testInitailizedWithEmptyImage), + ("testInitailizedWithImage", testInitailizedWithImage), + ("testInitailizedWithVersion", testInitailizedWithVersion), + ] +} + +extension TestCommandUnitTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__TestCommandUnitTests = [ + ("testSuccessfullBuildAndTest", testSuccessfullBuildAndTest), + ] +} + +extension URLArgumentTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__URLArgumentTests = [ + ("testRelativePathResolution", testRelativePathResolution), + ] +} + +public func __allTests() -> [XCTestCaseEntry] { + return [ + testCase(BuiildCommandUnitTests.__allTests__BuiildCommandUnitTests), + testCase(CLIOptionTests.__allTests__CLIOptionTests), + testCase(CleanupCommandUnitTests.__allTests__CleanupCommandUnitTests), + testCase(DockerImageTests.__allTests__DockerImageTests), + testCase(TestCommandUnitTests.__allTests__TestCommandUnitTests), + testCase(URLArgumentTests.__allTests__URLArgumentTests), + ] +} +#endif