Skip to content

Commit fcf18dc

Browse files
daymxnncooke3
andauthored
infra(all): Introduce generic script for integration tests (#15415)
Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com>
1 parent 0e5a4e0 commit fcf18dc

File tree

8 files changed

+547
-0
lines changed

8 files changed

+547
-0
lines changed

scripts/repo.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
3+
# Copyright 2025 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
# USAGE: ./repo.sh <subcommand> [args...]
18+
#
19+
# EXAMPLE: ./repo.sh tests decrypt --json ./scripts/secrets/AI.json
20+
#
21+
# Wraps around the local "repo" swift package, and facilitates calls to it.
22+
# The main purpose of this is to make calling "repo" easier, as you typically
23+
# need to call "swift run" with the package path.
24+
25+
set -euo pipefail
26+
27+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28+
29+
if [[ $# -eq 0 ]]; then
30+
cat 1>&2 <<EOF
31+
OVERVIEW: Small script for running repo commands.
32+
33+
Repo commands live under the scripts/repo swift package.
34+
35+
USAGE: $0 <subcommand> [args...]
36+
EOF
37+
exit 1
38+
fi
39+
40+
swift run --package-path "${ROOT}/repo" "$@"

scripts/repo/Package.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// swift-tools-version:6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
/*
5+
* Copyright 2025 Google LLC
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import PackageDescription
21+
22+
/// Package containing CLI executables for our larger scripts that are a bit harder to follow in
23+
/// bash form, or that need more advanced flag/optional requirements.
24+
let package = Package(
25+
name: "RepoScripts",
26+
platforms: [.macOS(.v15)],
27+
products: [
28+
.executable(name: "tests", targets: ["Tests"]),
29+
],
30+
dependencies: [
31+
.package(url: "https://github.com/apple/swift-argument-parser", exact: "1.6.2"),
32+
.package(url: "https://github.com/apple/swift-log", exact: "1.6.2"),
33+
],
34+
targets: [
35+
.executableTarget(
36+
name: "Tests",
37+
dependencies: [
38+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
39+
.product(name: "Logging", package: "swift-log"),
40+
.byName(name: "Util"),
41+
]
42+
),
43+
.target(
44+
name: "Util",
45+
dependencies: [
46+
.product(name: "Logging", package: "swift-log"),
47+
]
48+
),
49+
]
50+
)

scripts/repo/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Firebase Apple repo commands
2+
3+
This project includes commands that are too long and complicated to properly
4+
maintain in a bash script, or that have unique option/flag constraints that
5+
are better represented in a swift project.
6+
7+
## Tests
8+
9+
Commands for interacting with integration tests in the repo.
10+
11+
```sh
12+
./scripts/repo.sh tests --help
13+
```
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import ArgumentParser
18+
import Foundation
19+
import Logging
20+
import Util
21+
22+
extension Tests {
23+
/// Command for decrypting the secret files needed for a test run.
24+
struct Decrypt: ParsableCommand {
25+
nonisolated(unsafe) static var configuration = CommandConfiguration(
26+
abstract: "Decrypt the secret files for a test run.",
27+
usage: """
28+
tests decrypt [--json] [--overwrite] [<json-file>]
29+
tests decrypt [--password <password>] [--overwrite] [<secret-files> ...]
30+
31+
tests decrypt --json secret_files.json
32+
tests decrypt --json --overwrite secret_files.json
33+
tests decrypt --password "super_secret" \\
34+
scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info.plist \\
35+
scripts/gha-encrypted/FirebaseAI/TestApp-GoogleService-Info-Spark.plist.gpg:FirebaseAI/Tests/TestApp/Resources/GoogleService-Info-Spark.plist
36+
""",
37+
discussion: """
38+
The happy path usage is saving the secret passphrase in the environment variable \
39+
'secrets_passphrase', and passing a json file to the command. Although, you can also \
40+
pass everything inline via options.
41+
42+
When using a json file, it's expected that the json file is an array of json elements \
43+
in the format of:
44+
{ encrypted: <path-to-encrypted-file>, destination: <where-to-output-decrypted-file> }
45+
""",
46+
)
47+
48+
@Argument(
49+
help: """
50+
An array of secret files to decrypt. \
51+
The files should be in the format "encrypted:destination", where "encrypted" is a path to \
52+
the encrypted file and "destination" is a path to where the decrypted file should be saved.
53+
"""
54+
)
55+
var secretFiles: [String] = []
56+
57+
@Option(
58+
help: """
59+
The secret to use when decrypting the files. \
60+
Defaults to the environment variable 'secrets_passphrase'.
61+
"""
62+
)
63+
var password: String = ""
64+
65+
@Flag(help: "Overwrite existing decrypted secret files.")
66+
var overwrite: Bool = false
67+
68+
@Flag(
69+
help: """
70+
Use a json file of secret file mappings instead. \
71+
When this flag is enabled, <secret-files> should be a single json file.
72+
"""
73+
)
74+
var json: Bool = false
75+
76+
/// The parsed version of ``secretFiles``.
77+
///
78+
/// Only populated after `validate()` runs.
79+
var files: [SecretFile] = []
80+
81+
static let log = Logger(label: "Tests::Decrypt")
82+
private var log: Logger { Decrypt.log }
83+
84+
mutating func validate() throws {
85+
try validatePassword()
86+
87+
if json {
88+
try validateJSON()
89+
} else {
90+
try validateFileString()
91+
}
92+
93+
if !overwrite {
94+
log.info("Overwrite is disabled, so we're skipping generation for existing files.")
95+
files = files.filter { file in
96+
let keep = !FileManager.default.fileExists(atPath: file.destination)
97+
if !keep {
98+
log.debug(
99+
"Skipping generation for existing file",
100+
metadata: ["destination": "\(file.destination)"]
101+
)
102+
}
103+
return keep
104+
}
105+
}
106+
107+
for file in files {
108+
guard FileManager.default.fileExists(atPath: file.encrypted) else {
109+
throw ValidationError("Encrypted secret file does not exist: \(file.encrypted)")
110+
}
111+
}
112+
}
113+
114+
private mutating func validatePassword() throws {
115+
if password.isEmpty {
116+
// when a password isn't provided, try to load one from the environment variable
117+
guard
118+
let secrets_passphrase = ProcessInfo.processInfo.environment["secrets_passphrase"]
119+
else {
120+
throw ValidationError(
121+
"Either provide a passphrase via the password option or set the environvment variable 'secrets_passphrase' to the passphrase."
122+
)
123+
}
124+
password = secrets_passphrase
125+
}
126+
}
127+
128+
private mutating func validateJSON() throws {
129+
guard let jsonPath = secretFiles.first else {
130+
throw ValidationError("Missing path to json file for secret files")
131+
}
132+
133+
let fileURL = URL(
134+
filePath: jsonPath, directoryHint: .notDirectory,
135+
relativeTo: URL.currentDirectory()
136+
)
137+
138+
files = try SecretFile.parseArrayFrom(file: fileURL)
139+
guard !files.isEmpty else {
140+
throw ValidationError("Missing secret files in json file: \(jsonPath)")
141+
}
142+
}
143+
144+
private mutating func validateFileString() throws {
145+
guard !secretFiles.isEmpty else {
146+
throw ValidationError("Missing paths to secret files")
147+
}
148+
for string in secretFiles {
149+
try files.append(SecretFile(string: string))
150+
}
151+
}
152+
153+
mutating func run() throws {
154+
log.info("Decrypting files...")
155+
156+
for file in files {
157+
let gpg = Process("gpg", inheritEnvironment: true)
158+
let result = try gpg.runWithSignals([
159+
"--quiet",
160+
"--batch",
161+
"--yes",
162+
"--decrypt",
163+
"--passphrase=\(password)",
164+
"--output",
165+
file.destination,
166+
file.encrypted,
167+
])
168+
169+
guard result == 0 else {
170+
log.error("Failed to decrypt file", metadata: ["file": "\(file.encrypted)"])
171+
throw ExitCode(result)
172+
}
173+
174+
log.debug(
175+
"File encrypted",
176+
metadata: ["file": "\(file.encrypted)", "destination": "\(file.destination)"]
177+
)
178+
}
179+
180+
log.info("Files decrypted")
181+
}
182+
}
183+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import ArgumentParser
18+
import Foundation
19+
20+
/// A representation of a secret file, which should be decrypted for an integration test.
21+
struct SecretFile: Codable {
22+
/// A relative path to the encrypted file.
23+
let encrypted: String
24+
25+
/// A relative path to where the decrypted file should be output to.
26+
let destination: String
27+
}
28+
29+
extension SecretFile {
30+
/// Parses a `SecretFile` from a string.
31+
///
32+
/// The string should be in the format of "encrypted:destination".
33+
/// If it's not, then a `ValidationError`will be thrown.
34+
///
35+
/// - Parameters:
36+
/// - string: A string in the format of "encrypted:destination".
37+
init(string: String) throws {
38+
let splits = string.split(separator: ":")
39+
guard splits.count == 2 else {
40+
throw ValidationError(
41+
"Invalid secret file format. Format should be \"encrypted:destination\". Cause: \(string)"
42+
)
43+
}
44+
encrypted = String(splits[0])
45+
destination = String(splits[1])
46+
}
47+
48+
/// Parses an array of `SecretFile` from a JSON file.
49+
///
50+
/// It's expected that the secrets are encoded in the JSON file in the format of:
51+
/// ```json
52+
/// [
53+
/// {
54+
/// "encrypted": "path-to-encrypted-file",
55+
/// "destination": "where-to-output-decrypted-file"
56+
/// }
57+
/// ]
58+
/// ```
59+
///
60+
/// - Parameters:
61+
/// - file: The URL of a JSON file which contains an array of `SecretFile`,
62+
/// encoded as JSON.
63+
static func parseArrayFrom(file: URL) throws -> [SecretFile] {
64+
do {
65+
let data = try Data(contentsOf: file)
66+
return try JSONDecoder().decode([SecretFile].self, from: data)
67+
} catch {
68+
throw ValidationError(
69+
"Failed to load secret files from json file. Cause: \(error.localizedDescription)"
70+
)
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)