Skip to content

Commit fcfdc67

Browse files
authored
Merge pull request #29 from Automattic/add/buildkite-script-generation-tests
Add Buildkite Script Generation Tests
2 parents 8add752 + 1dda6d6 commit fcfdc67

11 files changed

+486
-34
lines changed

Package.resolved

Lines changed: 27 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: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ let package = Package(
1616
.package(url: "https://github.com/ebraraktas/swift-tqdm.git", from: "0.1.2"),
1717
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
1818
.package(name: "kcpassword", url: "https://github.com/jkmassel/kcpassword-swift.git", from: "1.0.0"),
19+
.package(url: "https://github.com/swiftpackages/DotEnv.git", from: "3.0.0"),
20+
.package(url: "https://github.com/apple/swift-tools-support-core", from: "0.2.5")
21+
1922
],
2023
targets: [
2124
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@@ -37,14 +40,23 @@ let package = Package(
3740
dependencies: [
3841
.product(name: "ArgumentParser", package: "swift-argument-parser"),
3942
.product(name: "SotoS3", package: "soto"),
43+
.product(name: "TSCBasic", package: "swift-tools-support-core")
4044
]
4145
),
4246
.testTarget(
4347
name: "libhostmgrTests",
44-
dependencies: ["libhostmgr"],
48+
dependencies: [
49+
"libhostmgr",
50+
.product(name: "DotEnv", package: "DotEnv"),
51+
],
4552
resources: [
4653
.copy("resources/configurations/0.6.0.json"),
4754
.copy("resources/configurations/defaults.json"),
55+
.copy("resources/buildkite-environment-variables-basic-expected-output.txt"),
56+
.copy("resources/buildkite-environment-variables-basic.env"),
57+
.copy("resources/buildkite-environment-variables-with-code-quotes.env"),
58+
.copy("resources/buildkite-commit-message-original.txt"),
59+
.copy("resources/buildkite-commit-message-expected.txt"),
4860
]
4961
),
5062
]

Sources/hostmgr/commands/generate/GenerateBuildkiteJobScript.swift

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,34 @@ struct GenerateBuildkiteJobScript: ParsableCommand {
1111
)
1212

1313
func run() throws {
14-
let path = try FileManager.default.createTemporaryFile(containing: generateBuildScript()).path
15-
print(path)
16-
}
14+
var scriptBuilder = BuildkiteScriptBuilder()
1715

18-
func generateBuildScript() throws -> String {
19-
let exports = try generateExports()
20-
.map { "export \($0.key)=\"\($0.value.escapingQuotes())\"" }
21-
.joined(separator: "\n")
16+
scriptBuilder.copyEnvironmentVariables(prefixedBy: "BUILDKITE_")
17+
scriptBuilder.addDependency(atPath: "~/.circ")
18+
scriptBuilder.addCommand("buildkite-agent bootstrap")
2219

23-
return [
24-
"source ~/.circ", // Need to source .circ first in order to set up the SSH session properly
25-
exports, // Declare all of our environment variables
26-
"buildkite-agent bootstrap" // Then let's go!
27-
].joined(separator: "\n")
28-
}
20+
// Manually specify the build path to keep them nice and clean in the output
21+
scriptBuilder.addEnvironmentVariable(
22+
named: "BUILDKITE_BUILD_PATH",
23+
value: "/usr/local/var/buildkite-agent/builds"
24+
)
2925

30-
func generateExports(
31-
from environment: [String: String] = ProcessInfo.processInfo.environment
32-
) throws -> [String: String] {
33-
let copyableExports = environment.filter { $0.key.starts(with: "BUILDKITE") }
26+
// Keep the agent name simple for better folder paths
27+
scriptBuilder.addEnvironmentVariable(named: "BUILDKITE_AGENT_NAME", value: "builder")
3428

35-
return [
36-
// Manually specify the build path to keep them nice and clean in the output
37-
"BUILDKITE_BUILD_PATH": "/usr/local/var/buildkite-agent/builds",
29+
// Required to convince `fastlane` that we're running in CI
30+
scriptBuilder.addEnvironmentVariable(named: "CI", value: "true")
3831

39-
// Keep the agent name simple for better folder paths
40-
"BUILDKITE_AGENT_NAME": "builder",
32+
// Used by the S3 Git Mirror plugin
33+
scriptBuilder.addEnvironmentVariable(
34+
named: "GIT_MIRROR_SERVER_ROOT",
35+
value: "http://\(try getIpAddress()):\( Configuration.shared.gitMirrorPort)"
36+
)
4137

42-
// Required to convince `fastlane` that we're running in CI
43-
"CI": "true",
38+
let scriptText = scriptBuilder.build()
4439

45-
// Used by the S3 Git Mirror plugin
46-
"GIT_MIRROR_SERVER_ROOT": "http://\(try getIpAddress()):\( Configuration.shared.gitMirrorPort)"
47-
]
48-
.merging(copyableExports, uniquingKeysWith: { lhs, _ in lhs })
49-
.compactMapValues { $0 }
40+
let path = try FileManager.default.createTemporaryFile(containing: scriptText).path
41+
print(path)
5042
}
5143

5244
// A somewhat hack-ey way to get the device's IP address, but it should continue

Sources/hostmgr/helpers/Foundation.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@ extension URL: ExpressibleByArgument {
3535
}
3636

3737
extension String {
38-
func escapingQuotes() -> Self {
39-
self.replacingOccurrences(of: "\"", with: "\\\"")
40-
}
41-
4238
var expandingTildeInPath: String {
4339
return NSString(string: self).expandingTildeInPath
4440
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import Foundation
2+
import TSCBasic
3+
4+
public struct BuildkiteScriptBuilder {
5+
6+
/// A list of files to run `source` against prior to executing the rest of the script.
7+
var dependencies = [String]()
8+
9+
/// A list of key/value pairs representing environment variables that should be defined at the start of the script.
10+
var environmentVariables: [String: Value] = [:]
11+
12+
/// A list of commands to run in the script.
13+
var commands = [Command]()
14+
15+
public init() {
16+
self.environmentVariables["BUILDKITE"] = Value(wrapping: "true")
17+
}
18+
19+
/// Add another dependency to the build script.
20+
///
21+
/// Each dependency will be placed in a `source $DEPENDENCY` block at the top of the emitted build script
22+
public mutating func addDependency(atPath path: String) {
23+
self.dependencies.append(path)
24+
}
25+
26+
/// Add an environment variable pair to the build script.
27+
///
28+
/// If there's an existing environment variable with the same name, it will be overwritten.
29+
public mutating func addEnvironmentVariable(named key: String, value: String) {
30+
self.environmentVariables[key] = Value(wrapping: value)
31+
}
32+
33+
/// Copy environment variables from the existing environment into the build script based on their prefix.
34+
public mutating func copyEnvironmentVariables(
35+
prefixedBy prefix: String,
36+
from environment: [String: String] = ProcessInfo.processInfo.environment
37+
) {
38+
environment
39+
.filter { $0.key.starts(with: prefix) }
40+
.forEach { key, value in
41+
environmentVariables[key] = Value(wrapping: value)
42+
}
43+
}
44+
45+
/// Add a line to the build script.
46+
///
47+
/// This typically takes the form of a single command (like `cp foo bar`).
48+
public mutating func addCommand(_ command: String, _ arguments: String...) {
49+
self.commands.append(Command(command: command, arguments: arguments))
50+
}
51+
52+
/// Compile the build script into a single string
53+
public func build() -> String {
54+
return [
55+
dependencies
56+
.map(convertDependencyToSource)
57+
.joined(separator: "\n"),
58+
environmentVariables
59+
.sorted { $0.0 < $1.0 }
60+
.filter { !$0.value.rawValue.isEmpty }
61+
.map(convertEnvironmentVariableToExport)
62+
.joined(separator: "\n"),
63+
commands
64+
.map(escapeCommand)
65+
.joined(separator: "\n")
66+
].joined(separator: "\n")
67+
}
68+
69+
/// Helper that takes a path like `~/.bashrc` and make it into a bash `source` command.
70+
///
71+
/// Escapes spaces in paths automatically.
72+
///
73+
/// Example:
74+
///
75+
/// ```
76+
/// # Given a path of `~/.bashrc`:
77+
/// source ~/.bashrc
78+
/// ```
79+
func convertDependencyToSource(_ path: String) -> String {
80+
"source \(path.escapingSpaces)".trimmingWhitespace
81+
}
82+
83+
/// Helper that takes an environment variable key/value pair to an `export` statement.
84+
///
85+
/// Automatically quote-wraps the value and escapes quotes as needed.
86+
///
87+
/// Example:
88+
///
89+
/// ```
90+
/// # Given `foo:bar`
91+
/// export foo="bar"
92+
/// ```
93+
func convertEnvironmentVariableToExport(_ pair: (String, Value)) -> String {
94+
return "export \(pair.0)=\"\(pair.1.escapedRepresentation)\"".trimmingWhitespace
95+
}
96+
97+
/// Helper that wraps command escape logic for shorthand use in a `map` statement.
98+
func escapeCommand(_ command: Command) -> String {
99+
command.escapedText
100+
}
101+
102+
/// An object representing the `value` in an environment variable's key/value pair.
103+
///
104+
/// Mostly just a way to organize escaping
105+
struct Value: Equatable {
106+
let rawValue: String
107+
108+
init(wrapping: String) {
109+
self.rawValue = wrapping
110+
}
111+
112+
/// An version of this value suitable for placement in a shell script
113+
var escapedRepresentation: String {
114+
rawValue
115+
.escapingCodeQuotes
116+
.escapingDoubleQuotes
117+
}
118+
}
119+
120+
/// An object representing one command in a shell script
121+
struct Command {
122+
/// The underlying command. If you want to handle escaping yourself, put the entire command here.
123+
let command: String
124+
125+
/// The arguments for the command.
126+
let arguments: [String]
127+
128+
init(command: String, arguments: [String] = []) {
129+
self.command = command
130+
self.arguments = arguments
131+
}
132+
133+
init(_ command: String, _ arguments: String...) {
134+
self.init(command: command, arguments: arguments)
135+
}
136+
137+
/// The escaped command text, sutible for placement in a shell script
138+
var escapedText: String {
139+
"\(command) \(escapedArguments.joined(separator: " "))".trimmingWhitespace
140+
}
141+
142+
/// A helper to print only the escaped arguments
143+
var escapedArguments: [String] {
144+
arguments.map { $0.spm_shellEscaped() }
145+
}
146+
}
147+
}
148+
149+
extension String {
150+
var escapingSpaces: String {
151+
replacingOccurrences(of: " ", with: "\\ ")
152+
}
153+
154+
var escapingCodeQuotes: String {
155+
replacingOccurrences(of: "`", with: "\\`")
156+
}
157+
158+
var escapingDoubleQuotes: String {
159+
replacingOccurrences(of: "\"", with: "\\\"")
160+
}
161+
162+
var trimmingWhitespace: String {
163+
trimmingCharacters(in: .whitespacesAndNewlines)
164+
}
165+
}

0 commit comments

Comments
 (0)