Skip to content

Commit 7a19fdb

Browse files
committed
Initial operational capability
Provide a Swift Package that contains an executable, gitlab-fusion, that is capable of fulfilling the role of a GitLab Runner custom executor. Specifically this custom executor works with VMware Fusion.
1 parent fdfdc28 commit 7a19fdb

13 files changed

+904
-2
lines changed

Package.resolved

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@ import PackageDescription
55

66
let package = Package(
77
name: "gitlab-fusion",
8+
platforms: [
9+
.macOS(.v10_13),
10+
],
811
dependencies: [
912
// Dependencies declare other packages that this package depends on.
1013
// .package(url: /* package url */, from: "1.0.0"),
14+
.package(url: "https://github.com/apple/swift-argument-parser", from: "0.3.0"),
15+
.package(url: "https://github.com/mxcl/Path.swift.git", from: "1.0.0"),
16+
.package(name: "Environment", url: "https://github.com/wlisac/environment.git", from: "0.11.1"),
17+
.package(url: "https://github.com/jakeheis/Shout", from: "0.5.5"),
1118
],
1219
targets: [
1320
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
1421
// Targets can depend on other targets in this package, and on products in packages this package depends on.
1522
.target(
1623
name: "gitlab-fusion",
17-
dependencies: []),
24+
dependencies: [
25+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
26+
.product(name: "Environment", package: "Environment"),
27+
.product(name: "Path", package: "Path.swift"),
28+
.product(name: "Shout", package: "Shout"),
29+
]
30+
),
1831
.testTarget(
1932
name: "gitlab-fusionTests",
2033
dependencies: ["gitlab-fusion"]),
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
14+
private let log = OSLog(subsystem: subsystem, category: "cleanup")
15+
16+
private let discussion = """
17+
The cleanup subcommand is responsible for stopping the cloned VMware Fusion
18+
guest.
19+
20+
https://docs.gitlab.com/runner/executors/custom.html#cleanup
21+
"""
22+
23+
/// The cleanup subcommand is responsible for stopping the cloned VMware Fusion
24+
/// guest.
25+
///
26+
/// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#run
27+
struct Cleanup: ParsableCommand {
28+
static let configuration = CommandConfiguration(
29+
abstract: "This subcommand should be called by the cleanup_exec stage.",
30+
discussion: discussion
31+
)
32+
33+
@OptionGroup()
34+
var options: StageOptions
35+
36+
// MARK: - Virtual Machine runtime specific arguments
37+
38+
@Argument(help: "Fully qualified path to the base VMware Fusion guest.")
39+
var baseVMPath: Path
40+
41+
// MARK: - Cleanup Steps
42+
43+
func run() throws {
44+
os_log("Cleanup stage is starting.", log: log, type: .info)
45+
46+
os_log("The base VMware Fusion guest is %{public}@", log: log, type: .info, baseVMPath.string)
47+
let base = VirtualMachine(image: baseVMPath, executable: options.vmrunPath)
48+
49+
/// The name of VMware Fusion guest created by the clone operation
50+
let clonedGuestName = "\(base.name)-runner-\(ciRunnerId)-concurrent-\(ciConcurrentProjectId)"
51+
52+
/// The path of the VMware Fusion guest created by the clone operation
53+
let clonedGuestPath = options.vmImagesPath
54+
.join("\(clonedGuestName).vmwarevm")
55+
.join("\(clonedGuestName).vmx")
56+
57+
os_log("The cloned VMware Fusion guest is %{public}@", log: log, type: .info, clonedGuestPath.string)
58+
let clone = VirtualMachine(image: clonedGuestPath, executable: options.vmrunPath)
59+
60+
try clone.stop()
61+
}
62+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
let encoder = JSONEncoder()
76+
encoder.keyEncodingStrategy = .convertToSnakeCase
77+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
78+
let json = try encoder.encode(config)
79+
80+
if let string = String(data: json, encoding: .utf8) {
81+
os_log("%{public}@", log: log, type: .info, string)
82+
} else {
83+
os_log("The encoded data was not a valid UTF-8 string.", log: log, type: .error)
84+
}
85+
86+
FileHandle.standardOutput.write(json)
87+
}
88+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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 Shout
14+
15+
private let log = OSLog(subsystem: subsystem, category: "prepare")
16+
17+
private let discussion = """
18+
The prepare subcommand is responsible for creating the clean and isolated build
19+
environment that the job will use.
20+
21+
To achieve the goal of a clean and isolated build environment this command must
22+
be provided the path to a base VMware Guest. The prepare subcommand will then
23+
create a snapshot on base VMware Guest (if necessary) and then make a linked
24+
clone of the snapshot (if necessary).
25+
26+
The linked clone will also have a snapshot created. This snapshots will
27+
represent the clean base state of any job. Finally, the subcommand will restore
28+
from the snapshot and start the cloned VMware Guest.
29+
30+
Once the guest is started. The subcommand will wait for the guest to boot and
31+
provide its IP address via the VMware Guest Additions. Before signaling that
32+
the guest is working the prepare subcommand will also ensure that the SSH
33+
server is responding and that the supplied credentials work.
34+
35+
https://docs.gitlab.com/runner/executors/custom.html#prepare
36+
"""
37+
38+
/// The prepare stage is responsible for creating the clean and isolated build
39+
/// environment that the job will use.
40+
///
41+
/// - SeeAlso: https://docs.gitlab.com/runner/executors/custom.html#prepare
42+
struct Prepare: ParsableCommand {
43+
static let configuration = CommandConfiguration(
44+
abstract: "This subcommand should be called by the prepare_exec stage.",
45+
discussion: discussion
46+
)
47+
48+
@OptionGroup()
49+
var options: StageOptions
50+
51+
// MARK: - Virtual Machine runtime specific arguments
52+
53+
@Argument(help: "Fully qualified path to the base VMware Fusion guest.")
54+
var baseVMPath: Path
55+
56+
@Flag(help: "Determines if the VMware Fusion guest is started interactively.")
57+
var isGUI = false
58+
59+
// MARK: - Secure Shell (SSH) specific arguments
60+
61+
@Option(help: "User used to authenticate as over SSH to the VMware Fusion guest.")
62+
var sshUsername = "buildbot"
63+
64+
@Option(help: "Password used to authenticate as over SSH to the VMware Fusion guest.")
65+
var sshPassword = "Time2Build"
66+
67+
// MARK: - Validating the command-line input
68+
69+
func validate() throws {
70+
guard options.vmrunPath.isExecutable else {
71+
os_log("%{public}@ is not executable.", log: log, type: .error, options.vmrunPath.string)
72+
throw GitlabRunnerError.systemFailure
73+
}
74+
75+
guard options.vmImagesPath.exists, options.vmImagesPath.isWritable else {
76+
os_log("%{public}@ does not exist.", log: log, type: .error, options.vmImagesPath.string)
77+
throw GitlabRunnerError.systemFailure
78+
}
79+
}
80+
81+
// MARK: - Prepare steps
82+
83+
func run() throws {
84+
os_log("Prepare stage is starting.", log: log, type: .info)
85+
86+
os_log("The base VMware Fusion guest is %{public}@", log: log, type: .debug, baseVMPath.string)
87+
let base = VirtualMachine(image: baseVMPath, executable: options.vmrunPath)
88+
89+
/// The name of VMware Fusion guest created by the clone operation
90+
let clonedGuestName = "\(base.name)-runner-\(ciRunnerId)-concurrent-\(ciConcurrentProjectId)"
91+
92+
// Check if the snapshot exists (creating it if necessary)
93+
let baseVMSnapshotName = "base-snapshot-\(clonedGuestName)"
94+
if !base.snapshots.contains(baseVMSnapshotName) {
95+
FileHandle.standardOutput
96+
.write(line: "Creating snapshot \"\(baseVMSnapshotName)\" in base guest \"\(base.name)\"...")
97+
try base.snapshot(baseVMSnapshotName)
98+
}
99+
100+
/// The path of the VMware Fusion guest created by the clone operation
101+
let clonedGuestPath = options.vmImagesPath
102+
.join("\(clonedGuestName).vmwarevm")
103+
.join("\(clonedGuestName).vmx")
104+
105+
// Check if the VM image exists
106+
let clone: VirtualMachine
107+
if !clonedGuestPath.exists {
108+
FileHandle.standardOutput
109+
.write(line: "Cloning from snapshot \"\(baseVMSnapshotName)\" in base guest \"\(base.name)\" to \"\(clonedGuestName)\"...")
110+
clone = try base.clone(to: clonedGuestPath, named: clonedGuestName, linkedTo: baseVMSnapshotName)
111+
} else {
112+
clone = VirtualMachine(image: clonedGuestPath, executable: options.vmrunPath)
113+
}
114+
115+
/// The name of the snapshot to create on linked clone
116+
let cloneGuestSnapshotName = clonedGuestName
117+
118+
// Check if the snapshot exists
119+
if clone.snapshots.contains(cloneGuestSnapshotName) {
120+
FileHandle.standardOutput
121+
.write(line: "Restoring guest \"\(clonedGuestName)\" from snapshot \"\(cloneGuestSnapshotName)\"...")
122+
try clone.revert(to: cloneGuestSnapshotName)
123+
} else {
124+
FileHandle.standardOutput
125+
.write(line: "Creating snapshot \"\(cloneGuestSnapshotName)\" in guest \"\(clonedGuestName)\"...")
126+
try clone.snapshot(cloneGuestSnapshotName)
127+
}
128+
129+
FileHandle.standardOutput.write(line: "Starting guest \"\(clonedGuestName)\"...")
130+
try clone.start(hasGUI: isGUI)
131+
132+
FileHandle.standardOutput.write(line: "Waiting for guest \"\(clonedGuestName)\" to become responsive...")
133+
guard let ip = clone.ip else {
134+
throw GitlabRunnerError.systemFailure
135+
}
136+
137+
// Wait for ssh to become available
138+
for i in 1...60 {
139+
guard i != 60 else {
140+
// 'Waited 60 seconds for sshd to start, exiting...'
141+
throw GitlabRunnerError.systemFailure
142+
}
143+
144+
// TODO: Encapsulate this for timeout purposes
145+
let ssh = try SSH(host: ip)
146+
try ssh.authenticate(username: sshUsername, password: sshPassword)
147+
let exitCode = try ssh.execute("echo -n 2>&1")
148+
149+
if exitCode == 0 {
150+
return
151+
}
152+
153+
sleep(60)
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)