Skip to content

Commit 96d693d

Browse files
authored
Merge pull request #28 from Automattic/simplify-exports
Simplify buildkite job script generation
2 parents 5e6ea48 + c5ca97d commit 96d693d

11 files changed

+484
-93
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 & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -11,101 +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-
}
17-
18-
func generateBuildScript() throws -> String {
19-
let exports = try generateExports()
20-
.map { "export \($0.key)=\"\($0.value)\"" }
21-
.joined(separator: "\n")
22-
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-
}
14+
var scriptBuilder = BuildkiteScriptBuilder()
2915

30-
// swiftlint:disable:next function_body_length
31-
func generateExports(
32-
from environment: [String: String] = ProcessInfo.processInfo.environment
33-
) throws -> [String: String] {
16+
scriptBuilder.addDependency(atPath: "~/.circ")
17+
scriptBuilder.copyEnvironmentVariables(prefixedBy: "BUILDKITE_")
18+
scriptBuilder.addCommand("buildkite-agent bootstrap")
3419

35-
// See https://buildkite.com/docs/pipelines/environment-variables for
36-
// and up-to-date list of environment variables that Buildkite exports
37-
let copyableExports = [
38-
"BUILDKITE",
39-
"BUILDKITE_AGENT_DEBUG",
40-
"BUILDKITE_AGENT_DEBUG_HTTP",
41-
"BUILDKITE_AGENT_ENDPOINT",
42-
"BUILDKITE_AGENT_INCLUDE_RETRIED_JOBS",
43-
"BUILDKITE_AGENT_META_DATA_QUEUE",
44-
"BUILDKITE_AGENT_NAME",
45-
"BUILDKITE_AGENT_NO_COLOR",
46-
"BUILDKITE_AGENT_PROFILE",
47-
"BUILDKITE_ARTIFACT_PATHS",
48-
"BUILDKITE_ARTIFACT_UPLOAD_DESTINATION",
49-
"BUILDKITE_BOOTSTRAP_PHASES",
50-
"BUILDKITE_BRANCH",
51-
"BUILDKITE_BUILD_NUMBER",
52-
"BUILDKITE_BUILD_URL",
53-
"BUILDKITE_CLEAN_CHECKOUT",
54-
"BUILDKITE_COMMIT",
55-
"BUILDKITE_GIT_CLONE_FLAGS",
56-
"BUILDKITE_HOOKS_PATH",
57-
"BUILDKITE_JOB_ID",
58-
"BUILDKITE_LABEL",
59-
"BUILDKITE_MESSAGE",
60-
"BUILDKITE_NO_HTTP2",
61-
"BUILDKITE_ORGANIZATION_SLUG",
62-
"BUILDKITE_PIPELINE_PROVIDER",
63-
"BUILDKITE_PIPELINE_SLUG",
64-
"BUILDKITE_PULL_REQUEST",
65-
"BUILDKITE_PULL_REQUEST_REPO",
66-
"BUILDKITE_REFSPEC",
67-
"BUILDKITE_REPO",
68-
"BUILDKITE_TAG",
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+
)
6925

70-
// These ones aren't printed as part of the default list – we're copying them so that `bootstrap` works
71-
"BUILDKITE_AGENT_ACCESS_TOKEN",
72-
"BUILDKITE_BUILD_ID",
73-
"BUILDKITE_PLUGINS_PATH"
26+
// Keep the agent name simple for better folder paths
27+
scriptBuilder.addEnvironmentVariable(named: "BUILDKITE_AGENT_NAME", value: "builder")
7428

75-
].reduce([String: String]()) { dictionary, key in
76-
var mutableDictionary = dictionary
77-
mutableDictionary[key] = environment[key]
78-
return mutableDictionary
79-
}
29+
// Required to convince `fastlane` that we're running in CI
30+
scriptBuilder.addEnvironmentVariable(named: "CI", value: "true")
8031

81-
return [
82-
// Manually specify the build path to keep them nice and clean in the output
83-
"BUILDKITE_BUILD_PATH": "/usr/local/var/buildkite-agent/builds",
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+
)
8437

85-
// Keep the agent name simple for better folder paths
86-
"BUILDKITE_AGENT_NAME": "builder",
38+
let scriptText = scriptBuilder.build()
8739

88-
// Required to convince `fastlane` that we're running in CI
89-
"CI": "true",
90-
91-
// We need to escape double slashes in the command / script, otherwise we can't pass it via command line
92-
"BUILDKITE_COMMAND": environment["BUILDKITE_COMMAND"]?.escapingQuotes(),
93-
"BUILDKITE_SCRIPT_PATH": environment["BUILDKITE_SCRIPT_PATH"]?.escapingQuotes(),
94-
95-
// We need to escape double slashes in the Buildkite Plugins JSON,
96-
// otherwise it causes an inscrutable error like:
97-
//
98-
// ```
99-
// Error: Failed to parse a plugin definition: invalid character 'g'
100-
// looking for beginning of object key string
101-
// ```
102-
"BUILDKITE_PLUGINS": environment["BUILDKITE_PLUGINS"]?.escapingQuotes(),
103-
104-
// Used by the S3 Git Mirror plugin
105-
"GIT_MIRROR_SERVER_ROOT": "http://\(try getIpAddress()):\( Configuration.shared.gitMirrorPort)"
106-
]
107-
.merging(copyableExports, uniquingKeysWith: { lhs, _ in lhs })
108-
.compactMapValues { $0 }
40+
let path = try FileManager.default.createTemporaryFile(containing: scriptText).path
41+
print(path)
10942
}
11043

11144
// 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: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
for (key, value) in environment where key.starts(with: prefix) {
39+
environmentVariables[key] = Value(wrapping: value)
40+
}
41+
}
42+
43+
/// Add a line to the build script.
44+
///
45+
/// This typically takes the form of a single command (like `cp foo bar`).
46+
public mutating func addCommand(_ command: String, _ arguments: String...) {
47+
self.commands.append(Command(command: command, arguments: arguments))
48+
}
49+
50+
/// Compile the build script into a single string
51+
public func build() -> String {
52+
return [
53+
dependencies
54+
.map(convertDependencyToSource)
55+
.joined(separator: "\n"),
56+
environmentVariables
57+
.sorted { $0.0 < $1.0 }
58+
.filter { !$0.value.rawValue.isEmpty }
59+
.map(convertEnvironmentVariableToExport)
60+
.joined(separator: "\n"),
61+
commands
62+
.map(escapeCommand)
63+
.joined(separator: "\n")
64+
].joined(separator: "\n")
65+
}
66+
67+
/// Helper that takes a path like `~/.bashrc` and make it into a bash `source` command.
68+
///
69+
/// Escapes spaces in paths automatically.
70+
///
71+
/// Example:
72+
///
73+
/// ```
74+
/// # Given a path of `~/.bashrc`:
75+
/// source ~/.bashrc
76+
/// ```
77+
func convertDependencyToSource(_ path: String) -> String {
78+
"source \(path.escapingSpaces)".trimmingWhitespace
79+
}
80+
81+
/// Helper that takes an environment variable key/value pair to an `export` statement.
82+
///
83+
/// Automatically quote-wraps the value and escapes quotes as needed.
84+
///
85+
/// Example:
86+
///
87+
/// ```
88+
/// # Given `foo:bar`
89+
/// export foo="bar"
90+
/// ```
91+
func convertEnvironmentVariableToExport(_ pair: (String, Value)) -> String {
92+
return "export \(pair.0)=\"\(pair.1.escapedRepresentation)\"".trimmingWhitespace
93+
}
94+
95+
/// Helper that wraps command escape logic for shorthand use in a `map` statement.
96+
func escapeCommand(_ command: Command) -> String {
97+
command.escapedText
98+
}
99+
100+
/// An object representing the `value` in an environment variable's key/value pair.
101+
///
102+
/// Mostly just a way to organize escaping
103+
struct Value: Equatable {
104+
let rawValue: String
105+
106+
init(wrapping: String) {
107+
self.rawValue = wrapping
108+
}
109+
110+
/// An version of this value suitable for placement in a shell script
111+
var escapedRepresentation: String {
112+
rawValue
113+
.escapingCodeQuotes
114+
.escapingDoubleQuotes
115+
}
116+
}
117+
118+
/// An object representing one command in a shell script
119+
struct Command {
120+
/// The underlying command. If you want to handle escaping yourself, put the entire command here.
121+
let command: String
122+
123+
/// The arguments for the command.
124+
let arguments: [String]
125+
126+
init(command: String, arguments: [String] = []) {
127+
self.command = command
128+
self.arguments = arguments
129+
}
130+
131+
init(_ command: String, _ arguments: String...) {
132+
self.init(command: command, arguments: arguments)
133+
}
134+
135+
/// The escaped command text, sutible for placement in a shell script
136+
var escapedText: String {
137+
"\(command) \(escapedArguments.joined(separator: " "))".trimmingWhitespace
138+
}
139+
140+
/// A helper to print only the escaped arguments
141+
var escapedArguments: [String] {
142+
arguments.map { $0.spm_shellEscaped() }
143+
}
144+
}
145+
}
146+
147+
extension String {
148+
var escapingSpaces: String {
149+
replacingOccurrences(of: " ", with: "\\ ")
150+
}
151+
152+
var escapingCodeQuotes: String {
153+
replacingOccurrences(of: "`", with: "\\`")
154+
}
155+
156+
var escapingDoubleQuotes: String {
157+
replacingOccurrences(of: "\"", with: "\\\"")
158+
}
159+
160+
var trimmingWhitespace: String {
161+
trimmingCharacters(in: .whitespacesAndNewlines)
162+
}
163+
}

0 commit comments

Comments
 (0)