├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Clibssh │ ├── module.modulemap │ └── shim.h ├── SecureShell │ ├── Channel.swift │ ├── Logging.swift │ ├── PrivateKey.swift │ ├── SecureShellError.swift │ └── Session.swift ├── VMwareFusion │ ├── VMwareFusion.swift │ ├── VirtualMachine.swift │ └── validate.swift └── gitlab-fusion │ ├── Extensions │ └── ErrorCode.swift │ ├── Stages │ ├── Cleanup.swift │ ├── Config.swift │ ├── Prepare.swift │ ├── Run.swift │ ├── SecureShellOptions.swift │ └── StageOptions.swift │ ├── Utilities │ ├── ConfigurationOutput.swift │ ├── FileHandle+StringWrite.swift │ ├── GitlabRunnerError.swift │ └── Path+ExpressibleByArgument.swift │ └── main.swift └── Tests ├── LinuxMain.swift ├── SecureShellTests ├── Fixtures │ ├── ed25519WithoutPassword │ ├── ed25519WithoutPassword.pub │ ├── emptyFile │ ├── rsaWithoutPassword │ └── rsaWithoutPassword.pub └── PrivateKeyTests.swift └── VMwareFusionTests ├── VMwareFusionTests.swift └── XCTestManifests.swift /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Continuous Integration (CI) 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | build: 12 | env: 13 | DEVELOPER_DIR: /Applications/Xcode_12.app/Contents/Developer 14 | runs-on: macos-10.15 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v1 18 | - name: Install libssh Dependency 19 | run: brew install libssh 20 | - name: Build (Debug) 21 | run: swift build 22 | - name: Build (Release) 23 | run: swift build -c release 24 | test: 25 | env: 26 | DEVELOPER_DIR: /Applications/Xcode_12.2.app/Contents/Developer 27 | runs-on: macos-10.15 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v1 31 | - name: Install libssh Dependency 32 | run: brew install libssh 33 | - name: Test 34 | run: swift test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | /.swiftpm 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ryan Lovelett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "environment", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/wlisac/environment.git", 7 | "state" : { 8 | "revision" : "a6158da25ac2294edb84ee60ad111c10c5687352", 9 | "version" : "0.11.1" 10 | } 11 | }, 12 | { 13 | "identity" : "path.swift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/mxcl/Path.swift.git", 16 | "state" : { 17 | "revision" : "9c6f807b0a76be0e27aecc908bc6f173400d839e", 18 | "version" : "1.4.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-argument-parser", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-argument-parser", 25 | "state" : { 26 | "revision" : "df9ee6676cd5b3bf5b330ec7568a5644f547201b", 27 | "version" : "1.1.3" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "gitlab-fusion", 8 | platforms: [ 9 | .macOS(.v10_13), 10 | ], 11 | dependencies: [ 12 | // Dependencies declare other packages that this package depends on. 13 | // .package(url: /* package url */, from: "1.0.0"), 14 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), 15 | .package(url: "https://github.com/mxcl/Path.swift.git", from: "1.0.0"), 16 | .package(url: "https://github.com/wlisac/environment.git", from: "0.11.1"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .systemLibrary( 22 | name: "Clibssh", 23 | pkgConfig: "libssh", 24 | providers: [ 25 | .brew(["libssh"]) 26 | ] 27 | ), 28 | .target( 29 | name: "SecureShell", 30 | dependencies: [ 31 | .target(name: "Clibssh"), 32 | .product(name: "Path", package: "Path.swift"), 33 | ] 34 | ), 35 | .testTarget( 36 | name: "SecureShellTests", 37 | dependencies: [ 38 | .target(name: "SecureShell"), 39 | .product(name: "Path", package: "Path.swift"), 40 | ], 41 | resources: [ 42 | .process("Fixtures"), 43 | ] 44 | ), 45 | .target( 46 | name: "VMwareFusion", 47 | dependencies: [ 48 | .product(name: "Path", package: "Path.swift"), 49 | ] 50 | ), 51 | .testTarget( 52 | name: "VMwareFusionTests", 53 | dependencies: [ 54 | .target(name: "VMwareFusion"), 55 | .product(name: "Path", package: "Path.swift"), 56 | ] 57 | ), 58 | .executableTarget( 59 | name: "gitlab-fusion", 60 | dependencies: [ 61 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 62 | .product(name: "Environment", package: "Environment"), 63 | .product(name: "Path", package: "Path.swift"), 64 | .target(name: "SecureShell"), 65 | .target(name: "VMwareFusion"), 66 | ] 67 | ), 68 | ] 69 | ) 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitlab-fusion 2 | 3 | ![Continuous Integration (CI)](https://github.com/RLovelett/gitlab-fusion/workflows/Continuous%20Integration%20(CI)/badge.svg) 4 | 5 | `gitlab-fusion` is a [custom executor](https://docs.gitlab.com/13.4/runner/executors/custom.html) 6 | for GitLab Runner. This custom executor allows for the creation of a clean build 7 | environment from a known base state for every job executed by CI. 8 | 9 | This custom executor integrates well with 10 | [Console on Mac](https://support.apple.com/guide/console/log-messages-cnsl1012/mac) 11 | and the rest of the Swift ecosystem in an effort to provide easy development, 12 | debugging and integration on macOS. 13 | 14 | > ***NOTE***: All guests MUST have the VMware Fusion guest additions installed 15 | > and expose an SSH server. 16 | 17 | ## Dependencies 18 | 19 | macOS 10.13.x (High Sierra) or later is required as a host operating system. 20 | VMware Fusion host operating system support matrix can be 21 | found in [KB: 2088571 - Supported host operating systems for VMware Fusion and VMware Fusion Pro](https://kb.vmware.com/s/article/2088571). 22 | Fusion 11.5.x is known to work with this custom executor this is why macOS 23 | 10.13.x (High Sierra) is the oldest supported host operating system. 24 | 25 | Additionally, `gitlab-fusion` has a runtime dependency on 26 | [libssh](https://www.libssh.org). There are multiple ways to install libssh but 27 | the recommended way is through [Homebrew](https://brew.sh). It is beyond the 28 | scope of this document to explain how to install libssh. Just ensure it is 29 | available on your runpath. 30 | 31 | ## Build 32 | 33 | * Debug: `swift build` 34 | * Release: `swift build -c release` 35 | 36 | ## Command Line Usage 37 | 38 | ``` 39 | gitlab-fusion [OPTIONS] 40 | ``` 41 | 42 | It is a goal that the tool itself has _adequate_ documentation in the help 43 | available at the point of usage. A good place to start is to simply run 44 | `gitlab-fusion --help` and see if the flag, option or argument is documented 45 | there. 46 | 47 | The `gitlab-fusion` tool provides 4 subcommands `config`, `prepare`, `run` and 48 | `cleanup`. These subcommands are designed to be called by the corresponding 49 | custom executor [stages](https://docs.gitlab.com/13.4/runner/executors/custom.html#stages) 50 | of the same names. 51 | 52 | Each subcommand accepts the configuration of the location of the VMware Fusion 53 | application and where the managed linked clones will be stored. 54 | 55 | * `--vmware-fusion`: Fully qualified path to the VMware Fusion application. 56 | (default: `/Applications/VMware Fusion.app`) 57 | 58 | * `--vm-images-path`: Fully qualified path to directory where cloned images are 59 | stored. (default: `$HOME/Virtual Machines.localized`) 60 | 61 | ### Config 62 | 63 | This subcommand should be called by the 64 | [config_exec](https://docs.gitlab.com/runner/executors/custom.html#config) 65 | stage. 66 | 67 | This subcommand generates a properly formatted JSON string and serializes it to 68 | STDOUT. The keys of the JSON string are determined by the GitLab Runner custom 69 | executor API. While the values of the JSON are determined by the options 70 | provided by this step. 71 | 72 | See `gitlab-runner config --help` for more detail on the options. 73 | 74 | ### Prepare 75 | 76 | This subcommand should be called by the 77 | [prepare_exec](https://docs.gitlab.com/runner/executors/custom.html#prepare) 78 | stage. 79 | 80 | This subcommand is responsible for creating the clean and isolated build 81 | environment that the job will use. 82 | 83 | To achieve the goal of a clean and isolated build environment this command must 84 | be provided the path to a base guest virtual machine. The `prepare` subcommand 85 | will then create a snapshot on base guest (if necessary) and then make a linked 86 | clone of the snapshot (if necessary). 87 | 88 | The linked clone will also have a snapshot created. This snapshots will 89 | represent the clean base state of any job. Finally, the subcommand will restore 90 | from the snapshot and start the cloned guest. 91 | 92 | Once the guest is started. The subcommand will wait for the guest to boot and 93 | provide its IP address via the VMware Guest Additions. Before signaling that 94 | the guest is working the prepare subcommand will also ensure that the SSH 95 | server is responding and that the supplied credentials work. 96 | 97 | See `gitlab-runner prepare --help` for more detail on the options and arguments. 98 | 99 | ### Run 100 | 101 | This subcommand should be called by the 102 | [run_exec](https://docs.gitlab.com/runner/executors/custom.html#run) stage. 103 | 104 | The run subcommand is responsible for executing the scripts provided by GitLab 105 | Runner in the prepared guest virtual machine. 106 | 107 | Provided that the `prepare` stage has already been performed this command is 108 | safe to call multiple times. 109 | 110 | See `gitlab-runner run --help` for more detail on the options and arguments. 111 | 112 | ### Cleanup 113 | 114 | This subcommand should be called by the 115 | [cleanup_exec](https://docs.gitlab.com/runner/executors/custom.html#cleanup) 116 | stage. 117 | 118 | The cleanup subcommand is responsible for stopping the cloned guest virtual 119 | machine. 120 | 121 | See `gitlab-runner cleanup --help` for more detail on the options and arguments. 122 | 123 | ## Example Integration with GitLab Runner 124 | 125 | It is well beyond the scope of this project to explain how to install and configure 126 | a GitLab Runner. There are existing guides on how to do that. Please follow one 127 | of them. 128 | 129 | * [Install GitLab Runner](https://docs.gitlab.com/runner/install/) 130 | * [Install GitLab Runner](https://docs.gitlab.com/runner/#install-gitlab-runner) 131 | * [Registering runner](https://docs.gitlab.com/runner/register/index.html) 132 | * [The Custom executor](https://docs.gitlab.com/runner/executors/custom.html) 133 | 134 | At a high level, to use this executor one must install GitLab Runner. Then 135 | register a custom executor with GitLab Runner. Then finally configure the custom 136 | executor to use `gitlab-fusion`. 137 | 138 | That final step is where `gitlab-runner` needs to be told where the base VMware 139 | guest is located and be provided appropriate SSH credentials for that guest. 140 | 141 | The excerpt below assumes that the `gitlab-runner` executable is located at 142 | `/Users/buildbot/gitlab-fusion/.build/release/gitlab-fusion`. Additionally that 143 | the base guest virtual machine is located at 144 | `/Users/buildbot/base-macOS-10.15.7-19H2-xcode-12.0.0.vmwarevm/base-macOS-10.15.7-19H2-xcode-12.0.0.vmx`. 145 | Your path is likely different and should be updated accordingly. 146 | 147 | All of the arguments available to the `config_args`, `prepare_args`, `run_args` 148 | and `cleanup_args` should be located in the respective help of each subcommand. 149 | 150 | ```toml 151 | ... 152 | 153 | [[runners]] 154 | ... 155 | [runners.custom] 156 | config_exec = "/Users/buildbot/gitlab-fusion/.build/release/gitlab-fusion" 157 | config_args = [ 158 | "config" 159 | ] 160 | 161 | prepare_exec = "/Users/buildbot/gitlab-fusion/.build/release/gitlab-fusion" 162 | prepare_args = [ 163 | "prepare", 164 | "--ssh-username", "buildbot", 165 | "--ssh-identity-file", "/Users/buildbot/Library/Application Support/me.lovelett.gitlab-fusion/id_ed25519", 166 | "/Users/buildbot/base-macOS-10.15.7-19H2-xcode-12.0.0.vmwarevm/base-macOS-10.15.7-19H2-xcode-12.0.0.vmx" 167 | ] 168 | 169 | run_exec = "/Users/buildbot/gitlab-fusion/.build/release/gitlab-fusion" 170 | run_args = [ 171 | "run", 172 | "--ssh-username", "buildbot", 173 | "--ssh-identity-file", "/Users/buildbot/Library/Application Support/me.lovelett.gitlab-fusion/id_ed25519", 174 | "/Users/buildbot/base-macOS-10.15.7-19H2-xcode-12.0.0.vmwarevm/base-macOS-10.15.7-19H2-xcode-12.0.0.vmx" 175 | ] 176 | 177 | cleanup_exec = "/Users/buildbot/gitlab-fusion/.build/release/gitlab-fusion" 178 | cleanup_args = [ 179 | "cleanup", 180 | "/Users/buildbot/base-macOS-10.15.7-19H2-xcode-12.0.0.vmwarevm/base-macOS-10.15.7-19H2-xcode-12.0.0.vmx" 181 | ] 182 | ``` 183 | -------------------------------------------------------------------------------- /Sources/Clibssh/module.modulemap: -------------------------------------------------------------------------------- 1 | // 2 | // module.modulemap 3 | // Clibssh 4 | // 5 | // Created by Ryan Lovelett on 10/5/20. 6 | // 7 | 8 | module Clibssh { 9 | header "shim.h" 10 | export * 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Clibssh/shim.h: -------------------------------------------------------------------------------- 1 | // 2 | // Header.h 3 | // Clibssh 4 | // 5 | // Created by Ryan Lovelett on 10/5/20. 6 | // 7 | 8 | #ifndef C_LIB_SSH_h 9 | #define C_LIB_SSH_h 10 | #include 11 | #include 12 | 13 | #endif /* C_LIB_SSH_h */ 14 | -------------------------------------------------------------------------------- /Sources/SecureShell/Channel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Channel.swift 3 | // SecureShell 4 | // 5 | // Created by Ryan Lovelett on 10/5/20. 6 | // 7 | 8 | import Clibssh 9 | import Foundation 10 | 11 | /// A `Channel` represents a sub-process of a single `Session`. 12 | /// 13 | /// More precisely, a `Channel` wraps `libssh`'s `ssh_channel` struct and 14 | /// manages the lifecycle of that struct. Additionally it provides Swift native 15 | /// wrapper helpers for a subset of the available `libssh` C APIs. 16 | public final class Channel { 17 | /// Reference to the `libssh` channel structure. 18 | private let channel: ssh_channel 19 | 20 | /// Pointer to the first element in channel read buffer. 21 | private let channelReadBufferPointer: UnsafeMutableRawPointer 22 | 23 | /// A data buffer to store read bytes from the channel. 24 | private let channelReadBuffer: UnsafeMutablePointer 25 | 26 | /// The read buffer is 4x the memory page size. 4 is an arbitrary choice. 27 | private let bufferSize = 4 * sysconf(_SC_PAGESIZE) 28 | 29 | /// This enum is meant to give a human readable name to the magic numbers 30 | /// of `0` and `1` that can be given to `libssh` read methods: 31 | /// `ssh_channel_read`, `ssh_channel_read_timeout`, 32 | /// `ssh_channel_read_nonblocking`. 33 | private enum Stream: CInt { 34 | case stdout = 0 35 | case stderr = 1 36 | } 37 | 38 | /// Allocate and open a channel suited for a shell, not TCP forwarding, for 39 | /// a given `Session`. 40 | /// 41 | /// - Parameter session: The session to open the channel in. 42 | /// - Throws: If the channel cannot be allocated or opened. 43 | init(_ session: ssh_session) throws { 44 | // Allocate a `ssh_channel` 45 | guard let channel = ssh_channel_new(session) else { 46 | throw SecureShellError(session, description: "A ssh_channel could not be allocated.") 47 | } 48 | 49 | // Open a session channel to run a shell command 50 | let returnCode = ssh_channel_open_session(channel) 51 | guard returnCode == SSH_OK else { 52 | // Must cleanup because `deinit` is not called when failable 53 | // initializers fail. Go figure. 54 | // https://www.jessesquires.com/blog/2020/10/08/swift-deinit-is-not-called-for-failable-initializers/ 55 | ssh_channel_free(channel) 56 | throw SecureShellError(session, description: "The channel could not open a session.") 57 | } 58 | self.channel = channel 59 | 60 | channelReadBuffer = UnsafeMutablePointer.allocate(capacity: Int(bufferSize)) 61 | channelReadBufferPointer = UnsafeMutableRawPointer(channelReadBuffer) 62 | } 63 | 64 | deinit { 65 | channelReadBuffer.deallocate() 66 | ssh_channel_close(channel) 67 | ssh_channel_free(channel) 68 | } 69 | 70 | /// Check if remote has sent an EOF. 71 | /// - Returns: `false` if there is no EOF. `true` otherwise. 72 | private var isEOF: Bool { 73 | let eof = ssh_channel_is_eof(channel) 74 | return (eof == 0) ? false : true 75 | } 76 | 77 | /// Check if the channel is open or not. 78 | /// - Returns: `false` if the channel is closed. `true` otherwise. 79 | private var isOpen: Bool { 80 | let open = ssh_channel_is_open(channel) 81 | return (open == 0) ? false : true 82 | } 83 | 84 | /// Check if the channel is closed or not. 85 | /// - Returns: `false` if the channel is opened. `true` otherwise. 86 | private var isClosed: Bool { 87 | let closed = ssh_channel_is_closed(channel) 88 | return (closed == 0) ? false : true 89 | } 90 | 91 | /// Get the exit status of the channel. This is the equivalent of the error 92 | /// code from the executed instruction. 93 | private var exitCode: CInt { 94 | ssh_channel_get_exit_status(channel) 95 | } 96 | 97 | /// Read data from either the standard stream (stdout) or an error stream 98 | /// (stderr). 99 | /// 100 | /// - Parameter stream: The stream to attempt to read data from. 101 | /// - Returns: `Data` if a 1 or more bytes are returned from the selected 102 | /// stream. If there is an error or no bytes are returned from stream then 103 | /// the method returns `nil`. 104 | private func read(stream: Stream = .stdout) -> Data? { 105 | let returnCode = ssh_channel_read_timeout( 106 | channel, 107 | channelReadBufferPointer, 108 | UInt32(bufferSize), 109 | stream.rawValue, 110 | 250 111 | ) 112 | 113 | guard returnCode != SSH_ERROR else { 114 | fatalError("Reading from \(stream)") 115 | } 116 | 117 | if returnCode > 0 { 118 | return Data(bytes: channelReadBufferPointer, count: Int(returnCode)) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | /// Run a shell command without an interactive shell. 125 | /// 126 | /// - Note: This is similar to `sh -c command`. 127 | /// - Parameters: 128 | /// - command: Command to execute on the remote. 129 | /// - stdout: File descriptor to write the data from the remote standard stream. 130 | /// - stderr: File descriptor to write the data from the remote error stream. 131 | /// - Returns: Error code from the executed instruction. 132 | public func execute(_ command: String, stdout: FileHandle? = nil, stderr: FileHandle? = nil) -> CInt { 133 | let returnCode = command.withCString { 134 | ssh_channel_request_exec(channel, $0) 135 | } 136 | 137 | guard returnCode == SSH_OK else { 138 | fatalError("The channel could not execute the command: \(command)") 139 | } 140 | 141 | while isOpen && !isEOF { 142 | if let buffer = read(stream: .stdout) { 143 | stdout?.write(buffer) 144 | } 145 | 146 | if let buffer = read(stream: .stderr) { 147 | stdout?.write(buffer) 148 | } 149 | } 150 | 151 | return exitCode 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Sources/SecureShell/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // SecureShell 4 | // 5 | // Created by Ryan Lovelett on 10/9/20. 6 | // 7 | 8 | import Clibssh 9 | import os.log 10 | 11 | let log = OSLog(subsystem: "me.lovelett.gitlab-fusion", category: "SecureShell") 12 | 13 | /// Handle logging messages provided by libssh. 14 | /// 15 | /// - Parameters: 16 | /// - priority: Priority of the log, the smaller being the more important. 17 | /// - function: The function name calling the the logging fucntions. 18 | /// - buffer: The actual message. 19 | /// - userdata: Userdata to be passed to the callback function. 20 | func sshLoggingCallback(priority: Int32, function: UnsafePointer?, buffer: UnsafePointer?, userdata: UnsafeMutableRawPointer?) { 21 | let message = buffer.map { String(cString: $0) } ?? "" 22 | switch priority { 23 | case SSH_LOG_WARN: 24 | // Show only warnings 25 | os_log("%{public}@", log: log, type: .error, message) 26 | case SSH_LOG_INFO: 27 | // Get some information what's going on 28 | os_log("%{public}@", log: log, type: .info, message) 29 | case SSH_LOG_DEBUG, SSH_LOG_TRACE: 30 | // Get detailed debuging information 31 | // Get trace output, packet information, 32 | os_log("%{public}@", log: log, type: .debug, message) 33 | default: 34 | os_log("%{public}@", log: log, type: .default, message) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/SecureShell/PrivateKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivateKey.swift 3 | // SecureShell 4 | // 5 | // Created by Ryan Lovelett on 10/5/20. 6 | // 7 | 8 | import Clibssh 9 | import Path 10 | 11 | /// Private key used with the SecureShell public-key infrastructure (PKI). 12 | final class PrivateKey: CustomStringConvertible { 13 | let key: ssh_key 14 | 15 | /// Create an instance from a key at a path. 16 | /// - Parameter path: Path to the private key. 17 | /// - Throws: `SecureShellError` if the private key cannot be loaded. 18 | init(contentsOfFile path: Path) throws { 19 | var file: ssh_key? 20 | let returnCode = path.string.withCString { body in 21 | ssh_pki_import_privkey_file(body, nil, nil, nil, &file) 22 | } 23 | guard returnCode == SSH_OK, let privateKey = file else { 24 | ssh_key_free(file) 25 | throw SecureShellError("Could not import private key at \(path)") 26 | } 27 | self.key = privateKey 28 | } 29 | 30 | deinit { 31 | ssh_key_free(key) 32 | } 33 | 34 | /// The type of the key (e.g., `ssh-rsa` or `ssh-ed25519`). 35 | var description: String { 36 | let type = ssh_key_type(key) 37 | let name = ssh_key_type_to_char(type) 38 | return name.map { String(cString: $0) } ?? "unknown" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SecureShell/SecureShellError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecureShellError.swift 3 | // SecureShell 4 | // 5 | // Created by Ryan Lovelett on 10/5/20. 6 | // 7 | 8 | import Clibssh 9 | import Foundation 10 | 11 | /// Capture error text from libssh and provide them in a idiomatic Swift 12 | /// structure. 13 | struct SecureShellError: LocalizedError { 14 | /// The error text from the last error. 15 | let libsshErrorText: String 16 | 17 | /// A description provided in addition to the libssh error text. 18 | let description: String 19 | 20 | /// Initialize a new error without libssh error text. 21 | /// 22 | /// - Parameter description: An error with no libssh text. 23 | init(_ description: String) { 24 | self.description = description 25 | self.libsshErrorText = "" 26 | } 27 | 28 | /// Initialize a new error from a session and proving context description. 29 | /// 30 | /// - Parameter session: Attempt to extract error text from this session. 31 | /// - Parameter description: A description provided in addition to the libssh error text. 32 | init(_ session: ssh_session, description: String) { 33 | self.init(UnsafeMutableRawPointer(session), description) 34 | } 35 | 36 | /// Initialize a new error from a session and proving context description. 37 | /// 38 | /// - Parameter session: Attempt to extract error text from this session. 39 | /// - Parameter description: A description provided in addition to the libssh error text. 40 | private init(_ session: UnsafeMutableRawPointer, _ description: String) { 41 | self.description = description 42 | if let error = ssh_get_error(session) { 43 | libsshErrorText = String(cString: error) 44 | } else { 45 | libsshErrorText = "Was not able to infer the error from \(session)." 46 | } 47 | } 48 | 49 | var errorDescription: String? { 50 | if libsshErrorText.isEmpty { 51 | return description 52 | } 53 | 54 | return "\(description) - \(libsshErrorText)" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SecureShell/Session.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session.swift 3 | // SecureShell 4 | // 5 | // Created by Ryan Lovelett on 10/5/20. 6 | // 7 | 8 | import Clibssh 9 | import Path 10 | 11 | /// A `Session` encapsulates the entire lifetime of the connection with a remote 12 | /// machine. A `Session` is responsible for connecting and authenticating the 13 | /// server and the user. 14 | public final class Session { 15 | /// A reference to the libssh session 16 | private let session: ssh_session 17 | 18 | /// Create a new session. 19 | /// 20 | /// - Warning: This may currently be unsafe to call multiple times because 21 | /// of the `ssh_set_log_callback`. Never really tested that. 22 | /// - Parameters: 23 | /// - host: The hostname or ip address to connect to. 24 | /// - port: The port to connect to. 25 | /// - username: The username for authentication. 26 | /// - Throws: `SecureShellError` if the session cannot be initialized. 27 | public init(host: String, port: UInt32 = 22, username: String) throws { 28 | ssh_set_log_callback(sshLoggingCallback) 29 | 30 | // Allocate a new `ssh_session` 31 | guard let newSession = ssh_new() else { 32 | throw SecureShellError("A ssh_session could not be allocated.") 33 | } 34 | session = newSession 35 | 36 | do { 37 | try set(host, for: SSH_OPTIONS_HOST, on: session) 38 | try set(port, for: SSH_OPTIONS_PORT, on: session) 39 | try set(SSH_LOG_DEBUG, for: SSH_OPTIONS_LOG_VERBOSITY, on: session) 40 | try set(username, for: SSH_OPTIONS_USER, on: session) 41 | try SecureShell.connect(using: session) 42 | } catch { 43 | // Must cleanup because `deinit` is not called when failable 44 | // initializers fail. Go figure. 45 | // https://www.jessesquires.com/blog/2020/10/08/swift-deinit-is-not-called-for-failable-initializers/ 46 | ssh_free(session) 47 | throw error 48 | } 49 | } 50 | 51 | deinit { 52 | ssh_disconnect(session) 53 | ssh_free(session) 54 | } 55 | 56 | /// Path to a file from which the identity (private key) for public key 57 | /// authentication is read. 58 | /// 59 | /// - Parameter path: Path to identity private key. 60 | /// - Throws: `SecureShellError` if authentication fails. 61 | public func authenticate(withIdentity path: Path) throws { 62 | let key = try PrivateKey(contentsOfFile: path) 63 | let returnCode = ssh_auth_e(rawValue: ssh_userauth_publickey(session, nil, key.key)) 64 | 65 | guard returnCode == SSH_AUTH_SUCCESS else { 66 | throw SecureShellError(session, description: "Could not authenticate with identity \(path).") 67 | } 68 | } 69 | 70 | /// Create a new `Channel` in the current session. 71 | /// - Throws: If the `Channel` could not be created. 72 | /// - Returns: A new `Channel` in the current session. 73 | public func openChannel() throws -> Channel { 74 | try Channel(session) 75 | } 76 | } 77 | 78 | // MARK:- Idiomatic Swift wrappers around libssh C functions 79 | 80 | /// Set options on the SSH session. 81 | /// 82 | /// This is a wrapper around `ssh_options_set` to allow more idiomatic Swift 83 | /// interaction with the libssh function. 84 | /// 85 | /// - Note: See `ssh_options_set` for documentation on all the available options. 86 | /// - Parameters: 87 | /// - string: The value to set for the specified option. 88 | /// - option: The option type to set. See `ssh_options_set` for documentation 89 | /// of available options. 90 | /// - session: The allocated SSH session to set the option on. 91 | /// - Throws: If the option could not be set. 92 | private func set(_ value: Value, for option: ssh_options_e, on session: ssh_session) throws { 93 | let returnValue = withUnsafePointer(to: value) { 94 | ssh_options_set(session, option, $0) 95 | } 96 | guard returnValue == SSH_OK else { 97 | throw SecureShellError(session, description: "Unable to set \(value) for \(option)") 98 | } 99 | } 100 | 101 | /// Set options on the SSH session. 102 | /// 103 | /// This is a wrapper around `ssh_options_set` to allow more idiomatic Swift 104 | /// interaction with the libssh function. 105 | /// 106 | /// - Note: See `ssh_options_set` for documentation on all the available options. 107 | /// - Parameters: 108 | /// - string: The value to set for the specified option. 109 | /// - option: The option type to set. See `ssh_options_set` for documentation 110 | /// of available options. 111 | /// - session: The allocated SSH session to set the option on. 112 | /// - Throws: If the option could not be set. 113 | private func set(_ string: String, for option: ssh_options_e, on session: ssh_session) throws { 114 | let returnValue = string.withCString { 115 | ssh_options_set(session, option, $0) 116 | } 117 | guard returnValue == SSH_OK else { 118 | throw SecureShellError(session, description: "Unable to set \(string) for \(option)") 119 | } 120 | } 121 | 122 | /// Attempt to connect to the remote SSH server. 123 | /// 124 | /// - Parameter session: The SSH session to connect. 125 | /// - Throws: `SecureShellError` if authentication fails. 126 | private func connect(using session: ssh_session) throws { 127 | let returnValue = ssh_connect(session) 128 | guard returnValue == SSH_OK else { 129 | throw SecureShellError(session, description: "Unable to connect to session") 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/VMwareFusion/VMwareFusion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VMwareFusion.swift 3 | // VMwareFusion 4 | // 5 | // Created by Ryan Lovelett on 10/9/20. 6 | // 7 | 8 | import Foundation 9 | import os.log 10 | import Path 11 | 12 | /// A type that manages interactions with VMware Fusion. Specifically it 13 | /// interacts with `vmrun` to be able to manage VMware Fusion guests. 14 | public struct VMwareFusion { 15 | 16 | public struct Error: Swift.Error, LocalizedError { 17 | private let exitCode: CInt 18 | private let stdout: String 19 | private let stderr: String 20 | 21 | init(exitCode: CInt, stdout: String, stderr: String) { 22 | self.exitCode = exitCode 23 | self.stdout = stdout 24 | self.stderr = stderr 25 | } 26 | 27 | public var errorDescription: String? { 28 | return stderr 29 | } 30 | } 31 | 32 | /// Fully qualified path to the VMware `vmrun` command. 33 | let executable: Path 34 | 35 | /// Create a new instance using the VMware Fusion application at the 36 | /// supplied `Path`. 37 | /// 38 | /// - Parameter vmwareFusion: Fully qualified path to the VMware Fusion 39 | /// application. Typically something like: `/Applications/VMware Fusion.app` 40 | public init(_ vmwareFusion: P) { 41 | executable = vmwareFusion.join("Contents").join("Public").join("vmrun") 42 | } 43 | 44 | /// Execute `vmrun` with the supplied arguments. 45 | /// 46 | /// This causes the program to run `vmrun` as a subprocess. This function 47 | /// monitors the output and termination status and reason for errors. If 48 | /// any errors are encountered they are reflected in the returned `Result`. 49 | /// Otherwise the `stdout` of the `vmrun` is returned unmodified. 50 | /// - Parameter arguments: The arguments to provide to `vmrun`. 51 | /// - Returns: A `Result` of the process run. 52 | /// - SeeAlso: https://developer.apple.com/documentation/foundation/process/1408983-arguments 53 | func vmrun(task: Executable = Process(), _ arguments: String...) -> Result { 54 | task.executableURL = executable.url 55 | task.arguments = arguments 56 | 57 | let stdout = Pipe() 58 | task.standardOutput = stdout 59 | 60 | let stderr = Pipe() 61 | task.standardError = stderr 62 | 63 | do { 64 | try task.run() 65 | } catch { 66 | os_log("%{public}@", log: log, type: .error, error as CVarArg) 67 | // This gets triggered if the executableURL does not exist or is not executable 68 | let message = "The supplied vmrun, \"\(executable)\", cannot be executed." 69 | let fusionError = Error(exitCode: 2, stdout: error.localizedDescription, stderr: message) 70 | return .failure(fusionError) 71 | } 72 | 73 | // Block until the task is finished. 74 | task.waitUntilExit() 75 | 76 | let out = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) 77 | let err = String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) 78 | let exitCode = task.terminationStatus 79 | 80 | switch task.terminationReason { 81 | case .exit where exitCode == ERR_SUCCESS: 82 | return .success(out) 83 | case .uncaughtSignal, .exit: 84 | return .failure(Error(exitCode: exitCode, stdout: out, stderr: err)) 85 | default: 86 | fatalError("An unknown case for `Process.TerminationReason` has been encountered. This is possible but unexpected.") 87 | } 88 | } 89 | } 90 | 91 | // MARK:- Protocols to help vmrun testability 92 | 93 | /// A type that allows this process/program to run another program as a 94 | /// subprocess and can monitor that program's execution. 95 | /// 96 | /// For the most part this type enables `VMWareFusion` to be testable on a 97 | /// machine without VMware Fusion being installed in it. 98 | protocol Executable: AnyObject { 99 | init() 100 | var executableURL: URL? { get set } 101 | var arguments: [String]? { get set } 102 | var standardOutput: Any? { get set } 103 | var standardError: Any? { get set } 104 | var terminationStatus: CInt { get } 105 | var terminationReason: Process.TerminationReason { get } 106 | func run() throws 107 | func waitUntilExit() 108 | } 109 | 110 | /// Ensure that Process conforms to the testable subprocess protocol 111 | extension Process: Executable { } 112 | -------------------------------------------------------------------------------- /Sources/VMwareFusion/VirtualMachine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VirtualMachine.swift 3 | // VMwareFusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import Foundation 9 | import os.log 10 | import Path 11 | 12 | /// Encapsulates the interactions with a given VMware Fusion guest. 13 | public struct VirtualMachine { 14 | private let path: Path 15 | private let fusion: VMwareFusion 16 | 17 | /// Create a new guest. 18 | /// 19 | /// - Parameters: 20 | /// - path: Fully qualified path to a VMware Fusion guest. 21 | /// - executable: Fully qualified path to VMware Fusion application. 22 | public init(image path: Path, executable: VMwareFusion) { 23 | self.path = path 24 | self.fusion = executable 25 | } 26 | 27 | /// Create a new guest. 28 | /// 29 | /// - Parameters: 30 | /// - path: Fully qualified path to a VMware Fusion guest. 31 | /// - fusion: An already create VMware Fusion application. 32 | private init(_ path: Path, _ fusion: VMwareFusion) { 33 | self.path = path 34 | self.fusion = fusion 35 | } 36 | 37 | /// The filename, without extension, of the provided VMware Fusion guest. 38 | public var name: String { 39 | path.basename(dropExtension: true) 40 | } 41 | 42 | /// Lists all snapshots in a virtual machine. 43 | public var snapshots: [String] { 44 | os_log("vmrun -T fusion listSnapshots %{public}@", log: log, type: .debug, path.string) 45 | let result = fusion.vmrun("-T", "fusion", "listSnapshots", path.string) 46 | switch result { 47 | case .success(let stdout): 48 | return Array(stdout.split(separator: "\n", omittingEmptySubsequences: true).map { String($0) }.dropFirst()) 49 | default: 50 | return [] 51 | } 52 | } 53 | 54 | /// Creates a snapshot of a virtual machine. Because Fusion supports 55 | /// multiple snapshots, you must provide the snapshot name. 56 | /// 57 | /// - Parameter name: Snapshot name 58 | /// - Throws: If the `vmrun` command fails. 59 | public func snapshot(_ name: String) throws { 60 | os_log("vmrun -T fusion snapshot %{public}@ %{public}@", log: log, type: .debug, path.string, name) 61 | let result = fusion.vmrun("-T", "fusion", "snapshot", path.string, name) 62 | switch result { 63 | case .success(let stdout): 64 | os_log("stdout: %{public}@", log: log, type: .debug, stdout) 65 | case .failure(let error): 66 | throw error 67 | } 68 | } 69 | 70 | /// Creates a copy of the virtual machine. 71 | /// 72 | /// - Parameters: 73 | /// - destination: Fully qualified path to the new Fusion virtual machine. 74 | /// - cloneName: The name of the new cloned virtual machine. 75 | /// - snapshot: The snapshot to base the clone from. 76 | /// - Throws: If the `vmrun` command fails. 77 | /// - Returns: A new `VirtualMachine` initialized the new clone machine. 78 | public func clone(to destination: Path, named cloneName: String, linkedTo snapshot: String) throws -> VirtualMachine { 79 | os_log("vmrun -T fusion clone %{public}@ %{public}@ linked -snapshot=%{public}@ -cloneName=%{public}@", log: log, type: .debug, path.string, destination.string, snapshot, cloneName) 80 | let result = fusion.vmrun("-T", "fusion", "clone", path.string, destination.string, "linked", "-snapshot=\(snapshot)", "-cloneName=\(cloneName)") 81 | switch result { 82 | case .success(let stdout): 83 | os_log("stdout: %{public}@", log: log, type: .debug, stdout) 84 | case .failure(let error): 85 | throw error 86 | } 87 | return VirtualMachine(destination, fusion) 88 | } 89 | 90 | /// Sets the virtual machine to its state at snapshot time. 91 | /// 92 | /// - Parameter snapshot: The name of the snapshot to revert to. 93 | /// - Throws: If the `vmrun` command fails. 94 | public func revert(to snapshot: String) throws { 95 | os_log("vmrun -T fusion revertToSnapshot %{public}@ %{public}@", log: log, type: .debug, path.string, snapshot) 96 | let result = fusion.vmrun("-T", "fusion", "revertToSnapshot", path.string, snapshot) 97 | switch result { 98 | case .success(let stdout): 99 | os_log("stdout: %{public}@", log: log, type: .debug, stdout) 100 | case .failure(let error): 101 | throw error 102 | } 103 | } 104 | 105 | /// Starts a virtual machine. 106 | /// 107 | /// If `true` is provided to the `hasGUI` option the machine starts 108 | /// interactively, which is displays the Fusion interface. If `false` is 109 | /// provided th e Fusion interface is suppressed. 110 | /// 111 | /// - Parameter hasGUI: Whether the virtual machine starts interactively or 112 | /// not. 113 | /// - Throws: If the `vmrun` command fails. 114 | public func start(hasGUI: Bool) throws { 115 | os_log("vmrun -T fusion start %{public}@ %{public}@", log: log, type: .debug, path.string, hasGUI ? "gui" : "nogui") 116 | let result = fusion.vmrun("-T", "fusion", "start", path.string, hasGUI ? "gui" : "nogui") 117 | switch result { 118 | case .success(let stdout): 119 | os_log("stdout: %{public}@", log: log, type: .debug, stdout) 120 | case .failure(let error): 121 | throw error 122 | } 123 | } 124 | 125 | /// Stops a virtual machine. 126 | /// 127 | /// - Throws: If the `vmrun` command fails. 128 | public func stop() throws { 129 | os_log("vmrun -T fusion stop %{public}@ hard", log: log, type: .debug, path.string) 130 | let result = fusion.vmrun("-T", "fusion", "stop", path.string, "hard") 131 | switch result { 132 | case .success(let stdout): 133 | os_log("stdout: %{public}@", log: log, type: .debug, stdout) 134 | case .failure(let error): 135 | throw error 136 | } 137 | } 138 | 139 | /// Retrieves the IP address of the guest. 140 | /// 141 | /// The IP address is not available until the virtual machine powers on and 142 | /// requires the guest additions to be running. 143 | public var ip: String? { 144 | os_log("vmrun -T fusion getGuestIPAddress %{public}@ -wait", log: log, type: .debug, path.string) 145 | let result = fusion.vmrun("-T", "fusion", "getGuestIPAddress", path.string, "-wait") 146 | switch result { 147 | case .success(let ip) where validate(ipAddress: ip.trimmingCharacters(in: .whitespacesAndNewlines)): 148 | return ip.trimmingCharacters(in: .whitespacesAndNewlines) 149 | default: 150 | return nil 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Sources/VMwareFusion/validate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // validate.swift 3 | // VMwareFusion 4 | // 5 | // Created by Ryan Lovelett on 10/9/20. 6 | // 7 | 8 | import Darwin 9 | import os.log 10 | 11 | let log = OSLog(subsystem: "me.lovelett.gitlab-fusion", category: "VMWareFusion") 12 | 13 | /// Check to see if a `String` is a valid IP address. 14 | /// 15 | /// Use `inet_pton` to validate if a provided string is either a valid IPv4 or 16 | /// IPv6 address. 17 | /// 18 | /// - SeeAlso: https://stackoverflow.com/a/37071903/247730 19 | /// - Parameter ipToValidate: `String` to validate. 20 | /// - Returns: `true` if it is a valid IP; `false` otherwise. 21 | func validate(ipAddress ipToValidate: String) -> Bool { 22 | var sin = sockaddr_in() 23 | var sin6 = sockaddr_in6() 24 | 25 | if ipToValidate.withCString({ inet_pton(AF_INET6, $0, &sin6.sin6_addr) }) == 1 { 26 | // IPv6 27 | return true 28 | } else if ipToValidate.withCString({ inet_pton(AF_INET, $0, &sin.sin_addr) }) == 1 { 29 | // IPv4 30 | return true 31 | } 32 | 33 | return false 34 | } 35 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Extensions/ErrorCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorCode.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 4/9/20. 6 | // 7 | 8 | import ArgumentParser 9 | 10 | extension ArgumentParser.ExitCode { 11 | init(_ error: GitlabRunnerError) { 12 | self.init(error.exitCode) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Stages/Cleanup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cleanup.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Foundation 11 | import os.log 12 | import Path 13 | import VMwareFusion 14 | 15 | private let log = OSLog(subsystem: subsystem, category: "cleanup") 16 | 17 | private let discussion = """ 18 | The cleanup subcommand is responsible for stopping the cloned VMware Fusion 19 | guest. 20 | 21 | https://docs.gitlab.com/runner/executors/custom.html#cleanup 22 | """ 23 | 24 | /// The cleanup subcommand is responsible for stopping the cloned VMware Fusion 25 | /// guest. 26 | /// 27 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#run 28 | struct Cleanup: ParsableCommand { 29 | static let configuration = CommandConfiguration( 30 | abstract: "This subcommand should be called by the cleanup_exec stage.", 31 | discussion: discussion 32 | ) 33 | 34 | @OptionGroup() 35 | var options: StageOptions 36 | 37 | // MARK: - Virtual Machine runtime specific arguments 38 | 39 | @Argument(help: "Fully qualified path to the base VMware Fusion guest.") 40 | var baseVMPath: Path 41 | 42 | // MARK: - Cleanup Steps 43 | 44 | func run() throws { 45 | os_log("Cleanup stage is starting.", log: log, type: .info) 46 | 47 | os_log("The base VMware Fusion guest is %{public}@", log: log, type: .info, baseVMPath.string) 48 | let base = VirtualMachine(image: baseVMPath, executable: options.vmwareFusion) 49 | 50 | /// The name of VMware Fusion guest created by the clone operation 51 | let clonedGuestName = "\(base.name)-\(ciServerHost)-runner-\(ciRunnerId)-concurrent-\(ciConcurrentProjectId)" 52 | 53 | /// The path of the VMware Fusion guest created by the clone operation 54 | let clonedGuestPath = options.vmImagesPath 55 | .join("\(clonedGuestName).vmwarevm") 56 | .join("\(clonedGuestName).vmx") 57 | 58 | os_log("The cloned VMware Fusion guest is %{public}@", log: log, type: .info, clonedGuestPath.string) 59 | let clone = VirtualMachine(image: clonedGuestPath, executable: options.vmwareFusion) 60 | 61 | do { 62 | try clone.stop() 63 | } catch { 64 | os_log("Could not stop the VMware Fusion guest.", log: log, type: .error) 65 | throw ExitCode(GitlabRunnerError.systemFailure) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Stages/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Config.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Foundation 11 | import os.log 12 | import Path 13 | 14 | private let log = OSLog(subsystem: subsystem, category: "config") 15 | 16 | private let discussion = """ 17 | This subcommand generates a properly formatted JSON string and serializes it to 18 | STDOUT. The keys and values of the JSON string are further documented in the 19 | custom executor documentation page. 20 | 21 | https://docs.gitlab.com/runner/executors/custom.html#config 22 | """ 23 | 24 | /// The configuration stage is used to configure settings used during execution 25 | /// of the VMware Fusion guest. 26 | /// 27 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#config 28 | struct Config: ParsableCommand { 29 | static let configuration = CommandConfiguration( 30 | abstract: "This subcommand should be called by the config_exec stage.", 31 | discussion: discussion 32 | ) 33 | 34 | @OptionGroup() 35 | var options: StageOptions 36 | 37 | @Option(help: "The base directory where the working directory of the job will be created in the VMware Fusion guest.") 38 | var buildsDir = Path.root.join("Users").join("buildbot").join("builds") 39 | .join("runner-\(ciRunnerId)") 40 | .join("concurrent-\(ciConcurrentProjectId)") 41 | .join(ciProjectPath) 42 | 43 | @Option(help: "The base directory where local cache will be stored in the VMware Fusion guest.") 44 | var cacheDir = Path.root.join("Users").join("buildbot").join("cache") 45 | .join("runner-\(ciRunnerId)") 46 | .join("concurrent-\(ciConcurrentProjectId)") 47 | .join(ciProjectPath) 48 | 49 | @Option(help: "Defines whether the environment is shared between concurrent job or not.") 50 | var buildsDirIsShared = false 51 | 52 | @Option(help: "The hostname to associate with job’s \"metadata\".") 53 | var hostname = ProcessInfo.processInfo.hostName 54 | 55 | func run() throws { 56 | os_log("Configuration stage is starting.", log: log, type: .info) 57 | 58 | for (index, argument) in ProcessInfo.processInfo.arguments.enumerated() { 59 | os_log("Argument %{public}d - %{public}@", log: log, type: .debug, index, argument) 60 | } 61 | 62 | for (variable, value) in ProcessInfo.processInfo.environment { 63 | os_log("%{public}@=%{public}@", log: log, type: .debug, variable, value) 64 | } 65 | 66 | let driver = ConfigurationOutput.Driver(options.vmwareFusionInfo) 67 | let config = ConfigurationOutput( 68 | buildsDir: buildsDir.string, 69 | cacheDir: cacheDir.string, 70 | isBuildsDirShared: buildsDirIsShared, 71 | hostname: hostname, 72 | driver: driver 73 | ) 74 | 75 | do { 76 | let encoder = JSONEncoder() 77 | encoder.keyEncodingStrategy = .convertToSnakeCase 78 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 79 | let json = try encoder.encode(config) 80 | 81 | if let string = String(data: json, encoding: .utf8) { 82 | os_log("%{public}@", log: log, type: .info, string) 83 | } else { 84 | os_log("The encoded data was not a valid UTF-8 string.", log: log, type: .error) 85 | } 86 | 87 | FileHandle.standardOutput.write(json) 88 | } catch { 89 | os_log("Could not encode JSON data.", log: log, type: .error) 90 | throw ExitCode(GitlabRunnerError.systemFailure) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Stages/Prepare.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Prepare.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Foundation 11 | import os.log 12 | import Path 13 | import SecureShell 14 | import VMwareFusion 15 | 16 | private let log = OSLog(subsystem: subsystem, category: "prepare") 17 | 18 | private let discussion = """ 19 | The prepare subcommand is responsible for creating the clean and isolated build 20 | environment that the job will use. 21 | 22 | To achieve the goal of a clean and isolated build environment this command must 23 | be provided the path to a base VMware Guest. The prepare subcommand will then 24 | create a snapshot on base VMware Guest (if necessary) and then make a linked 25 | clone of the snapshot (if necessary). 26 | 27 | The linked clone will also have a snapshot created. This snapshots will 28 | represent the clean base state of any job. Finally, the subcommand will restore 29 | from the snapshot and start the cloned VMware Guest. 30 | 31 | Once the guest is started. The subcommand will wait for the guest to boot and 32 | provide its IP address via the VMware Guest Additions. Before signaling that 33 | the guest is working the prepare subcommand will also ensure that the SSH 34 | server is responding and that the supplied credentials work. 35 | 36 | https://docs.gitlab.com/runner/executors/custom.html#prepare 37 | """ 38 | 39 | /// The prepare stage is responsible for creating the clean and isolated build 40 | /// environment that the job will use. 41 | /// 42 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#prepare 43 | struct Prepare: ParsableCommand { 44 | static let configuration = CommandConfiguration( 45 | abstract: "This subcommand should be called by the prepare_exec stage.", 46 | discussion: discussion 47 | ) 48 | 49 | @OptionGroup() 50 | var options: StageOptions 51 | 52 | // MARK: - Virtual Machine runtime specific arguments 53 | 54 | @Argument(help: "Fully qualified path to the base VMware Fusion guest.") 55 | var baseVMPath: Path 56 | 57 | @Flag(help: "Determines if the VMware Fusion guest is started interactively.") 58 | var isGUI = false 59 | 60 | // MARK: - Secure Shell (SSH) specific arguments 61 | 62 | @OptionGroup() 63 | var sshOptions: SecureShellOptions 64 | 65 | // MARK: - Validating the command-line input 66 | 67 | func validate() throws { 68 | guard options.vmImagesPath.exists, options.vmImagesPath.isWritable else { 69 | os_log("%{public}@ does not exist.", log: log, type: .error, options.vmImagesPath.string) 70 | throw GitlabRunnerError.systemFailure 71 | } 72 | } 73 | 74 | // MARK: - Prepare steps 75 | 76 | func run() throws { 77 | os_log("Prepare stage is starting.", log: log, type: .info) 78 | 79 | os_log("The base VMware Fusion guest is %{public}@", log: log, type: .debug, baseVMPath.string) 80 | let base = VirtualMachine(image: baseVMPath, executable: options.vmwareFusion) 81 | 82 | // Check if the snapshot exists (creating it if necessary) 83 | let rootSnapshotname = subsystem 84 | if !base.snapshots.contains(rootSnapshotname) { 85 | FileHandle.standardOutput 86 | .write(line: "Creating snapshot \"\(rootSnapshotname)\" in base guest \"\(base.name)\"...") 87 | try base.snapshot(rootSnapshotname) 88 | } 89 | 90 | // Check if the snapshot exists (creating it if necessary) 91 | let cloneBaseSnapshotname = "\(ciServerHost)-runner-\(ciRunnerId)-concurrent-\(ciConcurrentProjectId)" 92 | if !base.snapshots.contains(cloneBaseSnapshotname) { 93 | FileHandle.standardOutput 94 | .write(line: "Creating snapshot \"\(cloneBaseSnapshotname)\" in base guest \"\(base.name)\"...") 95 | // Ensure that the common base snapshot is used 96 | try base.revert(to: rootSnapshotname) 97 | try base.snapshot(cloneBaseSnapshotname) 98 | } 99 | 100 | /// The path of the VMware Fusion guest created by the clone operation 101 | let clonedGuestName = "\(base.name)-\(ciServerHost)-runner-\(ciRunnerId)-concurrent-\(ciConcurrentProjectId)" 102 | let clonedGuestPath = options.vmImagesPath 103 | .join("\(clonedGuestName).vmwarevm") 104 | .join("\(clonedGuestName).vmx") 105 | 106 | // Check if the VM image exists 107 | let clone: VirtualMachine 108 | if !clonedGuestPath.exists { 109 | FileHandle.standardOutput 110 | .write(line: "Cloning from snapshot \"\(cloneBaseSnapshotname)\" in base guest \"\(base.name)\" to \"\(clonedGuestName)\"...") 111 | clone = try base.clone(to: clonedGuestPath, named: clonedGuestName, linkedTo: cloneBaseSnapshotname) 112 | } else { 113 | clone = VirtualMachine(image: clonedGuestPath, executable: options.vmwareFusion) 114 | } 115 | 116 | /// The name of the snapshot to create on linked clone 117 | let cloneGuestSnapshotName = clonedGuestName 118 | 119 | // Check if the snapshot exists 120 | if clone.snapshots.contains(cloneGuestSnapshotName) { 121 | FileHandle.standardOutput 122 | .write(line: "Restoring guest \"\(clonedGuestName)\" from snapshot \"\(cloneGuestSnapshotName)\"...") 123 | try clone.revert(to: cloneGuestSnapshotName) 124 | } else { 125 | FileHandle.standardOutput 126 | .write(line: "Creating snapshot \"\(cloneGuestSnapshotName)\" in guest \"\(clonedGuestName)\"...") 127 | try clone.snapshot(cloneGuestSnapshotName) 128 | } 129 | 130 | FileHandle.standardOutput.write(line: "Starting guest \"\(clonedGuestName)\"...") 131 | try clone.start(hasGUI: isGUI) 132 | 133 | FileHandle.standardOutput.write(line: "Waiting for guest \"\(clonedGuestName)\" to become responsive...") 134 | guard let ip = clone.ip else { 135 | os_log("VMware Guest never resolved an IP address.", log: log, type: .error) 136 | throw ExitCode(GitlabRunnerError.systemFailure) 137 | } 138 | 139 | // Wait for ssh to become available 140 | for _ in 1...60 { 141 | // TODO: Retry if connection times out 142 | let session = try Session(host: ip, username: sshOptions.sshUsername) 143 | try session.authenticate(withIdentity: sshOptions.sshIdentityFile) 144 | let channel = try session.openChannel() 145 | let exitCode = channel.execute("echo -n 2>&1") 146 | 147 | if exitCode == 0 { 148 | return 149 | } 150 | 151 | sleep(60) 152 | } 153 | 154 | // TODO: Actually handle this case better 155 | // 'Waited 60 seconds for sshd to start, exiting...' 156 | os_log("VMware Guest never responded to SSH requests.", log: log, type: .error) 157 | throw ExitCode(GitlabRunnerError.systemFailure) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Stages/Run.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Run.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Foundation 11 | import os.log 12 | import Path 13 | import SecureShell 14 | import VMwareFusion 15 | 16 | private let log = OSLog(subsystem: subsystem, category: "run") 17 | 18 | private let discussion = """ 19 | The run subcommand is responsible for executing the scripts provided by GitLab 20 | Runner in the prepared VMware Fusion guest. 21 | 22 | Provided that the prepare stage has already been performed this command is safe 23 | to call multiple times. 24 | 25 | https://docs.gitlab.com/runner/executors/custom.html#run 26 | """ 27 | 28 | /// The run subcommand is responsible for executing the scripts provided by 29 | /// GitLab Runner in the prepared VMware Fusion guest. 30 | /// 31 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#run 32 | struct Run: ParsableCommand { 33 | static let configuration = CommandConfiguration( 34 | abstract: "This subcommand should be called by the run_exec stage.", 35 | discussion: discussion 36 | ) 37 | 38 | @OptionGroup() 39 | var options: StageOptions 40 | 41 | // MARK: - Secure Shell (SSH) specific arguments 42 | 43 | @OptionGroup() 44 | var sshOptions: SecureShellOptions 45 | 46 | // MARK: - Virtual Machine runtime specific arguments 47 | 48 | @Argument(help: "Fully qualified path to the base VMware Fusion guest.") 49 | var baseVMPath: Path 50 | 51 | // MARK: - GitLab Arguments 52 | 53 | @Argument(help: "The path to the script that GitLab Runner creates for the executor to run.") 54 | var scriptFile: Path 55 | 56 | @Argument(help: "The name of the sub-stage provided to the executor by the GitLab Runner.") 57 | var subStage: String 58 | 59 | // MARK: - Run steps 60 | 61 | func run() throws { 62 | os_log("Run stage %{public}@ is starting.", log: log, type: .info, subStage) 63 | 64 | os_log("The base VMware Fusion guest is %{public}@", log: log, type: .info, baseVMPath.string) 65 | let base = VirtualMachine(image: baseVMPath, executable: options.vmwareFusion) 66 | 67 | /// The name of VMware Fusion guest created by the clone operation 68 | let clonedGuestName = "\(base.name)-\(ciServerHost)-runner-\(ciRunnerId)-concurrent-\(ciConcurrentProjectId)" 69 | 70 | /// The path of the VMware Fusion guest created by the clone operation 71 | let clonedGuestPath = options.vmImagesPath 72 | .join("\(clonedGuestName).vmwarevm") 73 | .join("\(clonedGuestName).vmx") 74 | 75 | os_log("The cloned VMware Fusion guest is %{public}@", log: log, type: .info, clonedGuestPath.string) 76 | let clone = VirtualMachine(image: clonedGuestPath, executable: options.vmwareFusion) 77 | 78 | guard let ip = clone.ip else { 79 | os_log("VMware Guest never resolved an IP address.", log: log, type: .error) 80 | throw ExitCode(GitlabRunnerError.systemFailure) 81 | } 82 | 83 | let script = try String(contentsOf: scriptFile) 84 | os_log("Running script:\n%{public}@", log: log, type: .info, script) 85 | 86 | let session = try Session(host: ip, username: sshOptions.sshUsername) 87 | try session.authenticate(withIdentity: sshOptions.sshIdentityFile) 88 | let channel = try session.openChannel() 89 | let exitCode = channel.execute(script, stdout: FileHandle.standardOutput, stderr: FileHandle.standardError) 90 | 91 | if exitCode == 0 { 92 | os_log("Run stage %{public}@ returned %{public}d.", log: log, type: .info, subStage, exitCode) 93 | } else { 94 | os_log("Run stage %{public}@ returned %{public}d.", log: log, type: .error, subStage, exitCode) 95 | throw ExitCode(GitlabRunnerError.buildFailure) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Stages/SecureShellOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecureShellOptions.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 10/10/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Path 11 | import VMwareFusion 12 | 13 | /// Common arguments used by the individual stage subcommands. 14 | struct SecureShellOptions: ParsableArguments { 15 | @Option(help: "User used to authenticate as over SSH to the VMware Fusion guest.") 16 | var sshUsername = "buildbot" 17 | 18 | @Option(help: "Path to a file from which the identity (private key) for public key authentication is read.") 19 | var sshIdentityFile = Path.applicationSupport / subsystem / "id_ed25519" 20 | } 21 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Stages/StageOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageOptions.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 10/1/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Path 11 | import VMwareFusion 12 | 13 | /// Common arguments used by the individual stage subcommands. 14 | struct StageOptions: ParsableArguments { 15 | @Option(name: .customLong("vmware-fusion"), help: "Fully qualified path to the VMware Fusion application.") 16 | var vmwareFusionPath = Path.root.join("Applications").join("VMware Fusion.app") 17 | 18 | /// A type to invoke `vmrun` and interact with virtual machines. 19 | var vmwareFusion: VMwareFusion { 20 | VMwareFusion(vmwareFusionPath) 21 | } 22 | 23 | /// Fully qualified path to the VMware Fusion `Info.plist` file. 24 | var vmwareFusionInfo: Path { 25 | vmwareFusionPath.join("Contents").join("Info.plist") 26 | } 27 | 28 | @Option(help: "Fully qualified path to directory where cloned images are stored.") 29 | var vmImagesPath = Path.home.join("Virtual Machines.localized") 30 | } 31 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Utilities/ConfigurationOutput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigurationOutput.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import Foundation 9 | import Path 10 | 11 | /// A type used to decode a `Info.plist` file. 12 | private struct InfoPlist: Decodable { 13 | let CFBundleShortVersionString: String 14 | } 15 | 16 | /// A structure to be JSON encoded and provided as the output of the 17 | /// `config_exec` stage. 18 | /// 19 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#config 20 | struct ConfigurationOutput: Encodable { 21 | /// Information about the driver to be used in Gitlab Runner logging. 22 | struct Driver: Encodable { 23 | /// The user-defined name for the driver. Printed with the 24 | /// `Using custom executor...` line. If undefined, no information about 25 | /// driver is printed. 26 | let name: String 27 | 28 | /// The user-defined version for the drive. Printed with the 29 | /// `Using custom executor...` line. If undefined, only the name 30 | /// information is printed. 31 | let version: String 32 | 33 | /// Create version information about the driver by interrogating the 34 | /// VMware Fusion `Info.plist` provided. 35 | /// - Parameter infoPlist: A `Path` to the `Info.plist` to get version information from. 36 | init(_ infoPlist: Path) { 37 | let vmwareFusionVersion = { () -> String in 38 | let decoder = PropertyListDecoder() 39 | let data = try? Data(contentsOf: infoPlist) 40 | let plist = data.flatMap { try? decoder.decode(InfoPlist.self, from: $0) } 41 | return plist?.CFBundleShortVersionString ?? "unknown" 42 | }() 43 | 44 | name = "gitlab-fusion" 45 | version = "1.0.0-rc.1 — VMware Fusion \(vmwareFusionVersion)" 46 | } 47 | } 48 | 49 | /// The base directory where the working directory of the job will be created. 50 | let buildsDir: String 51 | 52 | /// The base directory where local cache will be stored. 53 | let cacheDir: String 54 | 55 | /// Defines whether the environment is shared between concurrent job or not. 56 | let isBuildsDirShared: Bool? 57 | 58 | /// The hostname to associate with job’s “metadata” stored by Runner. 59 | /// If undefined, the hostname is not set. 60 | let hostname: String? 61 | 62 | /// Information about the driver to be used in Gitlab Runner logging. 63 | let driver: Driver? 64 | } 65 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Utilities/FileHandle+StringWrite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileHandle+StringWrite.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 10/1/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FileHandle { 11 | func write(line string: String) { 12 | var copy = string.appending("\n") 13 | let data = copy.withUTF8(Data.init(buffer:)) 14 | self.write(data) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Utilities/GitlabRunnerError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitlabRunnerError.swift 3 | // 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import Environment 9 | import Foundation 10 | 11 | private let defaultBuildFailureExitCode: Int32 = 1 12 | private let defaultSystemFailureExitCode: Int32 = 2 13 | 14 | enum GitlabRunnerError: Error { 15 | /// GitLab Runner provides `BUILD_FAILURE_EXIT_CODE` environment variable 16 | /// which should be used by the executable as an exit code to inform GitLab 17 | /// Runner that there is a failure on the users job. If the executable exits 18 | /// with the code from `BUILD_FAILURE_EXIT_CODE`, the build is marked as a 19 | /// failure appropriately in GitLab CI. 20 | /// 21 | /// If the script that the user defines inside of `.gitlab-ci.yml` file 22 | /// exits with a non-zero code, run_exec should exit with 23 | /// `BUILD_FAILURE_EXIT_CODE` value. 24 | /// 25 | /// - Note: From observation `BUILD_FAILURE_EXIT_CODE` is typically equal to 26 | /// `1`. Therefore, that will be the default error code for this. 27 | /// 28 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#build-failure 29 | case buildFailure 30 | 31 | /// System failure can be communicated to GitLab Runner by exiting the 32 | /// process with the error code specified in the `SYSTEM_FAILURE_EXIT_CODE`. 33 | /// If this error code is returned, on certain stages GitLab Runner will 34 | /// retry the stage, if none of the retries are successful the job will be 35 | /// marked as failed. 36 | /// 37 | /// - Note: From observation `SYSTEM_FAILURE_EXIT_CODE` is typically equal 38 | /// to `2`. Therefore, that will be the default error code for this. 39 | /// 40 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#system-failure 41 | case systemFailure 42 | 43 | case vmrunError(String) 44 | 45 | var exitCode: Int32 { 46 | switch self { 47 | case .buildFailure: 48 | return Environment.BUILD_FAILURE_EXIT_CODE ?? defaultBuildFailureExitCode 49 | case .systemFailure, .vmrunError(_): 50 | return Environment.SYSTEM_FAILURE_EXIT_CODE ?? defaultSystemFailureExitCode 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/Utilities/Path+ExpressibleByArgument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Path+ExpressibleByArgument.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Path 10 | 11 | extension Path: ExpressibleByArgument { 12 | 13 | public init?(argument: String) { 14 | self.init(argument) 15 | } 16 | 17 | public var defaultValueDescription: String { 18 | return self.string 19 | } 20 | 21 | public static var defaultCompletionKind: CompletionKind { 22 | .file() 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Sources/gitlab-fusion/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // gitlab-fusion 4 | // 5 | // Created by Ryan Lovelett on 9/27/20. 6 | // 7 | 8 | import ArgumentParser 9 | import Environment 10 | import Foundation 11 | 12 | let subsystem = "me.lovelett.gitlab-fusion" 13 | 14 | private let discussion = """ 15 | The gitlab-fusion executor allows for the creation of a clean build environment 16 | for every job executed by CI. Any guest that VMware Fusion supports should be 17 | supported by this executor. 18 | 19 | All guests should have the VMware Fusion guest additions installed and expose 20 | an SSH server. 21 | 22 | For information about a custom executors lifecycle and how to configure GitLab 23 | to use this executor see: https://docs.gitlab.com/runner/executors/custom.html. 24 | """ 25 | 26 | /// The unique ID of runner being used. 27 | /// 28 | /// This is pulled from the environment variable `CUSTOM_ENV_CI_RUNNER_ID`. If 29 | /// that variable is unset then the value defaults to `0`. 30 | /// 31 | /// The `CUSTOM_ENV_CI_RUNNER_ID` is a variation on the typical GitLab 32 | /// predefined environment variable `CI_RUNNER_ID`. The environment variables 33 | /// provided by GitLab are prefixed with `CUSTOM_ENV_` to prevent conflicts with 34 | /// system environment variables. 35 | /// 36 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#stages 37 | /// - SeeAlso: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html 38 | let ciRunnerId = Environment.CUSTOM_ENV_CI_RUNNER_ID ?? 0 39 | 40 | /// Unique ID of build execution within a single executor and project. 41 | /// 42 | /// This is pulled from the environment variable 43 | /// `CUSTOM_ENV_CI_CONCURRENT_PROJECT_ID`. If that variable is unset then the 44 | /// value defaults to `0`. 45 | /// 46 | /// The `CUSTOM_ENV_CI_CONCURRENT_PROJECT_ID` is a variation on the typical 47 | /// GitLab predefined environment variable `CI_CONCURRENT_PROJECT_ID`. The 48 | /// environment variables provided by GitLab are prefixed with `CUSTOM_ENV_` to 49 | /// prevent conflicts with system environment variables. 50 | /// 51 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#stages 52 | /// - SeeAlso: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html 53 | let ciConcurrentProjectId = Environment.CUSTOM_ENV_CI_CONCURRENT_PROJECT_ID ?? 0 54 | 55 | /// The namespace with project name. 56 | /// 57 | /// This is pulled from the environment variable 58 | /// `CUSTOM_ENV_CI_PROJECT_PATH`. If that variable is unset then the value 59 | /// defaults to an empty string (e.g., `""`). 60 | /// 61 | /// The `CUSTOM_ENV_CI_PROJECT_PATH` is a variation on the typical GitLab 62 | /// predefined environment variable `CI_PROJECT_PATH`. The environment variables 63 | /// provided by GitLab are prefixed with `CUSTOM_ENV_` to prevent conflicts with 64 | /// system environment variables. 65 | /// 66 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#stages 67 | /// - SeeAlso: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html 68 | let ciProjectPath = Environment.CUSTOM_ENV_CI_PROJECT_PATH ?? "" 69 | 70 | /// Host component of the GitLab instance URL, without protocol and port 71 | /// (like `gitlab.example.com`). 72 | /// 73 | /// This is pulled from the environment variable 74 | /// `CUSTOM_ENV_CI_SERVER_HOST`. If that variable is unset then the value 75 | /// defaults to `me.lovelett.gitlab-fusion`. 76 | /// 77 | /// The `CUSTOM_ENV_CI_SERVER_HOST` is a variation on the typical GitLab 78 | /// predefined environment variable `CI_SERVER_HOST`. The environment variables 79 | /// provided by GitLab are prefixed with `CUSTOM_ENV_` to prevent conflicts with 80 | /// system environment variables. 81 | /// 82 | /// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#stages 83 | /// - SeeAlso: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html 84 | let ciServerHost = Environment.CUSTOM_ENV_CI_SERVER_HOST ?? subsystem 85 | 86 | /// Collects the command-line options that were passed to `gitlab-fusion` and 87 | /// dispatches them to the appropriate subcommands (executor stages). 88 | struct GitlabFusion: ParsableCommand { 89 | static var configuration = CommandConfiguration( 90 | abstract: "A custom GitLab Runner executor to enable running jobs inside VMware Fusion.", 91 | discussion: discussion, 92 | subcommands: [ 93 | Config.self, 94 | Prepare.self, 95 | Run.self, 96 | Cleanup.self 97 | ], 98 | defaultSubcommand: Run.self 99 | ) 100 | } 101 | 102 | GitlabFusion.main() 103 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import VMwareFusionTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += VMwareFusionTests.__allTests() 7 | 8 | XCTMain(tests) 9 | -------------------------------------------------------------------------------- /Tests/SecureShellTests/Fixtures/ed25519WithoutPassword: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACDuYamDhcrjukTnYHhKH9vR+4w786CBI+BTHiVi5Rx/uwAAAJianUMxmp1D 4 | MQAAAAtzc2gtZWQyNTUxOQAAACDuYamDhcrjukTnYHhKH9vR+4w786CBI+BTHiVi5Rx/uw 5 | AAAEDnc9ytgpASKJ+EjUy8fuw4GP6CpI9I/QFEQZnUmatjx+5hqYOFyuO6ROdgeEof29H7 6 | jDvzoIEj4FMeJWLlHH+7AAAAEFNlY3VyZVNoZWxsVGVzdHMBAgMEBQ== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /Tests/SecureShellTests/Fixtures/ed25519WithoutPassword.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO5hqYOFyuO6ROdgeEof29H7jDvzoIEj4FMeJWLlHH+7 SecureShellTests 2 | -------------------------------------------------------------------------------- /Tests/SecureShellTests/Fixtures/emptyFile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RLovelett/gitlab-fusion/e71f47d275a1fba9e4551d06c44d4753622e0149/Tests/SecureShellTests/Fixtures/emptyFile -------------------------------------------------------------------------------- /Tests/SecureShellTests/Fixtures/rsaWithoutPassword: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | 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 49 | -----END OPENSSH PRIVATE KEY----- 50 | -------------------------------------------------------------------------------- /Tests/SecureShellTests/Fixtures/rsaWithoutPassword.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCglQmB8xyPQJjq+lK6FEluz38dxd6vEKh4U6Ry6qfeWcDgm8w0ppQMOCq7hlLmLIxspwzD6qXyTLbsgKqEu0j13cbsoEPAEhJuGlhegVgSC8OAkG2xlXHpWB+SK9bICkOkZzty57HHJ5uShHirx+pqV4H9r5aUE6o25KToXW1ekym/gFK8Du02Qd92V9mhnHf8rEo74wgBhFyRasst6XsIAAuh6JMajko2Ot5JfcUqaydROGOGG5NTbgdQWQoSvUdBvoCn3vM+yqPLT+pQVTu8WOveeQCksIqH0FzuuO/tYX3AL94KAaalAnCBg2ZpTPiw0PAlESYOzqnS6jyBEcNCEVuEwEbP24OVKmxWe3kX1AOkgQBwBDcrN278EqCMWFlBHdhoGfpLiyygzrVz1Aey6z/2JuMHs5Pe5XhecgWL6Hn5QqEweEKbHlALwofYZ0WLi5Z6UeWm9hLheYBM1HdiVsCctneERHpRAawnnfV2dxjroYTHmziwlWJ8pixgo/oRNLz3SxUEpwLvZ74KEHTzK3D9X8av14lOkEJLaH7XIULBS7GGqXEl48rpmguP8aLiRv5McKR0pVSzhuZkBNdhpobJ8CwGJpFVrPnWqkx6zPdHDUNgLMzEWo2YTw9JdMLvsIruqZZ/r+7GZifU6yxtf6SUJ9ZfxhA6DLOu9f4o3w== SecureShellTests 2 | -------------------------------------------------------------------------------- /Tests/SecureShellTests/PrivateKeyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivateKeyTests.swift 3 | // SecureShellTests 4 | // 5 | // Created by Ryan Lovelett on 10/10/20. 6 | // 7 | 8 | import class Foundation.Bundle 9 | import Path 10 | @testable import SecureShell 11 | import XCTest 12 | 13 | final class PrivateKeyTests: XCTestCase { 14 | var idRsaWithoutPassword: Path { 15 | Bundle.module.path(forResource: "rsaWithoutPassword", ofType: nil)! 16 | } 17 | 18 | var idED25519WithoutPassword: Path { 19 | Bundle.module.path(forResource: "ed25519WithoutPassword", ofType: nil)! 20 | } 21 | 22 | var emptyFile: Path { 23 | Bundle.module.path(forResource: "emptyFile", ofType: nil)! 24 | } 25 | 26 | func testReadingPrivateKeyWithoutPassword() throws { 27 | let a = try PrivateKey(contentsOfFile: idRsaWithoutPassword) 28 | XCTAssertEqual(a.description, "ssh-rsa") 29 | let b = try PrivateKey(contentsOfFile: idED25519WithoutPassword) 30 | XCTAssertEqual(b.description, "ssh-ed25519") 31 | } 32 | 33 | func testReadingEmptyFile() throws { 34 | XCTAssertThrowsError(try PrivateKey(contentsOfFile: emptyFile)) { error in 35 | XCTAssertTrue(error is SecureShellError, "Unexpected error type: \(type(of: error))") 36 | XCTAssertTrue(error.localizedDescription.starts(with: "Could not import private key at")) 37 | } 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /Tests/VMwareFusionTests/VMwareFusionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VMwareFusionTests.swift 3 | // VMwareFusionTests 4 | // 5 | // Created by Ryan Lovelett on 10/9/20. 6 | // 7 | 8 | import Foundation 9 | @testable import VMwareFusion 10 | import Path 11 | import XCTest 12 | 13 | // VMWareFusion is it's own target/module to work around SR-1393 14 | 15 | final class VMwareFusionTests: XCTestCase { 16 | func testMissingExecutable() throws { 17 | let fusion = VMwareFusion(Path.root/"a") 18 | XCTAssertThrowsError(try fusion.vmrun("foo").get()) { error in 19 | XCTAssertTrue(error is VMwareFusion.Error, "Unexpected error type: \(type(of: error))") 20 | XCTAssertEqual(error.localizedDescription, "The supplied vmrun, \"/a/Contents/Public/vmrun\", cannot be executed.") 21 | } 22 | } 23 | 24 | func testNonZeroExit() throws { 25 | let fusion = VMwareFusion(Path.home) 26 | XCTAssertThrowsError(try fusion.vmrun(task: NonZeroExit(), "foo").get()) { error in 27 | XCTAssertTrue(error is VMwareFusion.Error, "Unexpected error type: \(type(of: error))") 28 | XCTAssertEqual(error.localizedDescription, "Matter tells space-time how to curve") 29 | } 30 | } 31 | 32 | func testHappyPath() throws { 33 | let fusion = VMwareFusion(Path.home) 34 | let result = fusion.vmrun(task: HappyPath(), "foo") 35 | XCTAssertEqual(try result.get(), "🥳") 36 | } 37 | } 38 | 39 | // MARK:- Conformances to Executable for mocking in tests 40 | 41 | private final class NonZeroExit: Executable { 42 | init() {} 43 | var executableURL: URL? 44 | var arguments: [String]? 45 | var standardOutput: Any? { 46 | get { nil } 47 | set(newStdout) { 48 | if let pipe = newStdout as? Pipe { 49 | let data = "Space-time tells matter how to move".data(using: .utf8)! 50 | pipe.fileHandleForWriting.write(data) 51 | try! pipe.fileHandleForWriting.close() 52 | } 53 | } 54 | } 55 | var standardError: Any? { 56 | get { nil } 57 | set(newStderr) { 58 | if let pipe = newStderr as? Pipe { 59 | let data = "Matter tells space-time how to curve".data(using: .utf8)! 60 | pipe.fileHandleForWriting.write(data) 61 | try! pipe.fileHandleForWriting.close() 62 | } 63 | } 64 | } 65 | var terminationStatus: CInt { 2 } 66 | var terminationReason: Process.TerminationReason { .exit } 67 | func run() throws { } 68 | func waitUntilExit() { } 69 | } 70 | 71 | private final class HappyPath: Executable { 72 | init() {} 73 | var executableURL: URL? 74 | var arguments: [String]? 75 | var standardOutput: Any? { 76 | get { nil } 77 | set(newStdout) { 78 | if let pipe = newStdout as? Pipe { 79 | let data = "🥳".data(using: .utf8)! 80 | pipe.fileHandleForWriting.write(data) 81 | try! pipe.fileHandleForWriting.close() 82 | } 83 | } 84 | } 85 | var standardError: Any? { 86 | get { nil } 87 | set(newStderr) { 88 | if let pipe = newStderr as? Pipe { 89 | try! pipe.fileHandleForWriting.close() 90 | } 91 | } 92 | } 93 | var terminationStatus: CInt { 0 } 94 | var terminationReason: Process.TerminationReason { .exit } 95 | func run() throws { } 96 | func waitUntilExit() { } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/VMwareFusionTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension VMwareFusionTests { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__VMwareFusionTests = [ 9 | ("testHappyPath", testHappyPath), 10 | ("testMissingExecutable", testMissingExecutable), 11 | ("testNonZeroExit", testNonZeroExit), 12 | ] 13 | } 14 | 15 | public func __allTests() -> [XCTestCaseEntry] { 16 | return [ 17 | testCase(VMwareFusionTests.__allTests__VMwareFusionTests), 18 | ] 19 | } 20 | #endif 21 | --------------------------------------------------------------------------------