Skip to content

Commit 41d410f

Browse files
authored
chore: Adding FS sandboxed unit tests in CI (#6754)
1 parent fdb6e69 commit 41d410f

8 files changed

Lines changed: 204 additions & 98 deletions

File tree

.github/scripts/ci/no-network-exec.sh

Lines changed: 0 additions & 65 deletions
This file was deleted.

.github/scripts/ci/no-network.sb

Lines changed: 0 additions & 14 deletions
This file was deleted.

.github/scripts/ci/sandbox-exec.sh

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Run a command without the two side effects a unit test run should not have:
5+
#
6+
# - network reach beyond loopback
7+
# - writes outside the directories the run owns
8+
#
9+
# Written for `go test -exec`:
10+
#
11+
# go test -exec "$PWD/.github/scripts/ci/sandbox-exec.sh" ./...
12+
#
13+
# Loopback stays reachable so tests can stand up httptest servers, and writes
14+
# stay open in the temp dir, the Go caches and Terragrunt's user cache.
15+
#
16+
# How much of that holds depends on the platform:
17+
#
18+
# - macOS confines both through one seatbelt profile.
19+
# - Linux confines the network alone, through a network namespace.
20+
#
21+
# The --check flag confirms the sandbox is working properly.
22+
23+
CURDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24+
PROFILE="$CURDIR/sandbox.sb"
25+
26+
run_sandboxed() {
27+
case "$(uname -s)" in
28+
Darwin)
29+
# sandbox-exec(1) has been deprecated since macOS 10.8, but it is still
30+
# the only thing on macOS that applies a profile to an arbitrary command,
31+
# and this never ships anywhere but a contributor's machine. Restrictions
32+
# reach spawned processes because a child inherits its parent's sandbox,
33+
# per sandbox(7).
34+
exec /usr/bin/sandbox-exec \
35+
-D "TMP=$(cd "${TMPDIR:-/tmp}" && pwd -P)" \
36+
-D "GOCACHE=$(go env GOCACHE)" \
37+
-D "GOMODCACHE=$(go env GOMODCACHE)" \
38+
-D "TGCACHE=$HOME/Library/Caches/terragrunt" \
39+
-f "$PROFILE" \
40+
"$@"
41+
;;
42+
Linux)
43+
# A fresh network namespace starts with `lo` down, so bring it up before
44+
# handing over to the real command. See network_namespaces(7) for what
45+
# the namespace covers and user_namespaces(7) for what --map-root-user
46+
# buys an unprivileged caller.
47+
exec unshare --map-root-user --net -- \
48+
sh -c 'ip link set lo up && exec "$@"' sh "$@"
49+
;;
50+
*)
51+
echo "sandbox-exec.sh: no sandbox mechanism for $(uname -s)" >&2
52+
exit 1
53+
;;
54+
esac
55+
}
56+
57+
check_egress() {
58+
local script="$1"
59+
60+
if ! command -v curl >/dev/null 2>&1; then
61+
echo "sandbox-exec.sh: --check needs curl" >&2
62+
exit 1
63+
fi
64+
65+
if "$script" curl --silent --show-error --max-time 15 --output /dev/null https://example.com; then
66+
echo "sandbox-exec.sh: check failed, https://example.com is still reachable inside the sandbox" >&2
67+
exit 1
68+
fi
69+
70+
echo "sandbox-exec.sh: egress is blocked"
71+
}
72+
73+
check_writes() {
74+
local script="$1"
75+
76+
local allowed
77+
allowed="$(mktemp)"
78+
79+
if ! "$script" touch "$allowed"; then
80+
rm -f "$allowed"
81+
echo "sandbox-exec.sh: check failed, the temp dir is not writable inside the sandbox" >&2
82+
exit 1
83+
fi
84+
85+
rm -f "$allowed"
86+
87+
local refused="$CURDIR/.sandbox-check"
88+
89+
if "$script" touch "$refused" 2>/dev/null; then
90+
rm -f "$refused"
91+
echo "sandbox-exec.sh: check failed, the source tree is still writable inside the sandbox" >&2
92+
exit 1
93+
fi
94+
95+
echo "sandbox-exec.sh: writes are confined to the temp dir and the caches"
96+
}
97+
98+
run_check() {
99+
local script="${BASH_SOURCE[0]}"
100+
101+
if ! "$script" true; then
102+
echo "sandbox-exec.sh: check failed, the sandbox cannot run a command at all" >&2
103+
exit 1
104+
fi
105+
106+
check_egress "$script"
107+
108+
if [[ "$(uname -s)" != "Darwin" ]]; then
109+
echo "sandbox-exec.sh: writes are NOT confined on $(uname -s)"
110+
return
111+
fi
112+
113+
check_writes "$script"
114+
}
115+
116+
if [[ "${1:-}" == "--check" ]]; then
117+
run_check
118+
exit 0
119+
fi
120+
121+
run_sandboxed "$@"

.github/scripts/ci/sandbox.sb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
;; macOS seatbelt profile that blocks the two side effects a unit test run
2+
;; should not have. It binds the process and everything it spawns.
3+
;;
4+
;; Apple documents sandbox-exec(1) and sandbox(7) but not the profile language
5+
;; below. The working reference for the syntax is the set of profiles Apple
6+
;; ships in /System/Library/Sandbox/Profiles.
7+
;;
8+
;; Network: nothing beyond loopback. Loopback stays open so tests can stand up
9+
;; httptest servers. Unix domain sockets stay open too, because the platform
10+
;; reaches system daemons (mDNSResponder, notifyd) through them. Name resolution
11+
;; still succeeds as a result. Connecting to what it resolves does not.
12+
;;
13+
;; Writes: nothing outside the directories a run owns, which are its temp dir,
14+
;; the Go build and module caches, and Terragrunt's user cache. The checked-out
15+
;; source tree is not among them. A test that writes into testdata or a fixture
16+
;; fails here instead of leaving the change behind for the next run.
17+
;;
18+
;; Reads are untouched.
19+
(version 1)
20+
(allow default)
21+
22+
(deny network*)
23+
(allow network-bind (local ip "localhost:*"))
24+
(allow network-inbound (local ip "localhost:*"))
25+
(allow network-outbound (remote ip "localhost:*"))
26+
(allow network-outbound (remote unix-socket))
27+
28+
(deny file-write*)
29+
(allow file-write* (subpath (param "TMP")))
30+
(allow file-write* (subpath (param "GOCACHE")))
31+
(allow file-write* (subpath (param "GOMODCACHE")))
32+
(allow file-write* (subpath (param "TGCACHE")))
33+
34+
;; Terminal and null devices, which the test binary and anything it spawns write
35+
;; through for ordinary output.
36+
(allow file-write-data (literal "/dev/null"))
37+
(allow file-write-data (literal "/dev/dtracehelper"))
38+
(allow file-write* (regex #"^/dev/tty"))
39+
(allow file-write* (regex #"^/dev/pty"))
40+
(allow file-write* (regex #"^/dev/ptmx"))

.github/workflows/sandboxed-test.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ on:
88
# either exercises an unvirtualized code path or is an integration test hiding in
99
# the untagged suite. Both are worth catching here rather than as a flake on a day
1010
# the registry is slow.
11+
#
12+
# Only the network half applies here. The wrapper confines writes through a
13+
# seatbelt profile, which runs only on macOS, so this job does not check them.
1114
jobs:
1215
test:
1316
name: Sandboxed Test
@@ -64,10 +67,10 @@ jobs:
6467
run: go build ./... && go mod download
6568
shell: bash
6669

67-
- name: Verify the sandbox blocks egress
68-
run: ./.github/scripts/ci/no-network-exec.sh --check
70+
- name: Verify the sandbox is in force
71+
run: ./.github/scripts/ci/sandbox-exec.sh --check
6972
shell: bash
7073

7174
- name: Run Tests
72-
run: go test -exec "$PWD/.github/scripts/ci/no-network-exec.sh" -timeout 45m ./...
75+
run: go test -exec "$PWD/.github/scripts/ci/sandbox-exec.sh" -timeout 45m ./...
7376
shell: bash

docs/src/content/docs/05-community/01-contributing.mdx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -335,25 +335,38 @@ The convention we use for race tests is to prefix them with `WithRacing`. The Te
335335

336336
#### Sandboxed tests
337337

338-
Terragrunt routes its side effects through a virtualized environment, so tests can drive filesystem, subprocess and HTTP behavior without touching the real thing. A test that still reaches the network either exercises a code path that bypasses that environment or is an integration test sitting in the untagged suite. Either way, it makes the unit suite slower and flakier. Every run pays for the round trip, and a slow registry or a rate-limited git host turns into a red build that has nothing to do with the change under review.
338+
Terragrunt routes its side effects through a virtualized environment, so tests can drive filesystem, subprocess and HTTP behavior without touching the real thing.
339339

340-
To check for that, run the suite with every socket except loopback closed:
340+
A test can break that in two ways, and neither shows up as a test failure:
341+
342+
1. A test that reaches the network either exercises a code path that bypasses the virtualized environment or is an integration test sitting in the untagged suite.
343+
1. A test that writes outside its own temp directory leaves state behind for the next run.
344+
345+
To check for both, run the suite through the sandbox wrapper:
341346

342347
```bash
343-
go test -exec "$PWD/.github/scripts/ci/no-network-exec.sh" ./...
348+
go test -exec "$PWD/.github/scripts/ci/sandbox-exec.sh" ./...
344349
```
345350

346-
Before reading anything into a passing run, confirm the sandbox took effect:
351+
<Aside type="tip">
352+
353+
Before reading anything into a passing run, confirm the sandbox took effect. The output names what this platform confines:
347354

348355
```bash
349-
./.github/scripts/ci/no-network-exec.sh --check
356+
./.github/scripts/ci/sandbox-exec.sh --check
350357
```
351358

352-
Loopback stays reachable, so tests that stand up an `httptest` server keep working. Compilation happens outside the sandbox, so module downloads are unaffected. How the sandbox is built differs by platform:
359+
</Aside>
360+
361+
Loopback stays reachable, so tests that stand up an `httptest` server keep working. Writes stay open in the temp directory, the Go caches and Terragrunt's user cache. Compilation happens outside the sandbox, so module downloads are unaffected. Reads are untouched, so none of this says anything about a test picking up `~/.gitconfig` or `~/.terraformrc`.
362+
363+
How the sandbox is built, and how much it covers, differ by platform:
353364

354365
<Tabs syncKey="operating-systems">
355366
<TabItem label="Linux">
356-
Each test binary runs in its own network namespace, created with `unshare`.
367+
Each test binary runs in its own network namespace, created with `unshare`. The `network_namespaces(7)` and `user_namespaces(7)` man pages cover what that isolates. Writes are not confined. Doing that needs bubblewrap or a Landlock wrapper, neither of which is wired up, so `--check` reports the network half alone.
368+
369+
This is the platform Continuous Integration runs on, so CI checks the network and nothing else.
357370

358371
Ubuntu 24.04 and later confine unprivileged user namespaces with AppArmor, which stops `unshare` from creating that namespace. If the check reports that the sandbox cannot run a command at all, lift the restriction:
359372

@@ -362,9 +375,9 @@ Loopback stays reachable, so tests that stand up an `httptest` server keep worki
362375
```
363376
</TabItem>
364377
<TabItem label="macOS">
365-
Each test binary runs under the seatbelt profile at `.github/scripts/ci/no-network.sb`, applied with `sandbox-exec`.
378+
Each test binary runs under the seatbelt profile at `.github/scripts/ci/sandbox.sb`, applied with `sandbox-exec`. That profile confines the network and writes together.
366379

367-
Nothing to set up. `sandbox-exec` ships with macOS.
380+
Nothing to set up. `sandbox-exec` ships with macOS. Apple documents `sandbox-exec(1)` and `sandbox(7)` but not the profile language, so the profiles under `/System/Library/Sandbox/Profiles` are the working reference for changing `sandbox.sb`.
368381
</TabItem>
369382
</Tabs>
370383

internal/services/catalog/module/repo_test.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,16 @@ func TestFindModules(t *testing.T) {
5858
for _, tc := range testCases {
5959
t.Run(tc.repoPath, func(t *testing.T) {
6060
t.Parallel()
61-
// Unfortunately, we are unable to commit the `.git` directory. We have to temporarily rename it while running the tests.
62-
os.Rename(filepath.Join(tc.repoPath, "gitdir"), filepath.Join(tc.repoPath, ".git"))
63-
defer os.Rename(
64-
filepath.Join(tc.repoPath, ".git"),
65-
filepath.Join(tc.repoPath, "gitdir"),
66-
)
61+
62+
// The fixture carries its git metadata as `gitdir`, because a real
63+
// `.git` directory cannot be committed. Copying first keeps that
64+
// rename out of the checked-out tree.
65+
repoDir := filepath.Join(t.TempDir(), "repo")
66+
require.NoError(t, os.CopyFS(repoDir, os.DirFS(tc.repoPath)))
67+
require.NoError(t, os.Rename(
68+
filepath.Join(repoDir, "gitdir"),
69+
filepath.Join(repoDir, ".git"),
70+
))
6771

6872
ctx := t.Context()
6973

@@ -74,7 +78,7 @@ func TestFindModules(t *testing.T) {
7478
ctx,
7579
logger.CreateLogger(),
7680
v,
77-
&module.RepoOpts{CloneURL: tc.repoPath},
81+
&module.RepoOpts{CloneURL: repoDir},
7882
)
7983
require.NoError(t, err)
8084

test/helpers/package.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,10 @@ func RunTerragruntValidateInputs(
13201320
) {
13211321
t.Helper()
13221322

1323+
// Terragrunt writes a .terragrunt-cache into whatever directory it runs in,
1324+
// so this runs against a copy rather than the fixture in the checked-out tree.
1325+
moduleDir = filepath.Join(CopyEnvironment(t, moduleDir), moduleDir)
1326+
13231327
maybeNested := filepath.Join(moduleDir, "module")
13241328
if vfs.Exists(vfs.NewOSFS(), maybeNested) {
13251329
// Nested module test case with included file, so run terragrunt from the nested module.

0 commit comments

Comments
 (0)