Skip to content

Commit 44f4470

Browse files
authored
chore: Adding exec sandboxed logic to seatbelt (#6760)
1 parent 6cacc3c commit 44f4470

9 files changed

Lines changed: 140 additions & 235 deletions

File tree

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
# Run a command without the two side effects a unit test run should not have:
4+
# Run a command without the side effects a unit test run should not have:
55
#
66
# - network reach beyond loopback
77
# - writes outside the directories the run owns
8+
# - execution of a real OpenTofu or Terraform binary
89
#
910
# Written for `go test -exec`:
1011
#
1112
# go test -exec "$PWD/.github/scripts/ci/sandbox-exec.sh" ./...
1213
#
1314
# 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+
# stay open in the temp dir, the Go caches and Terragrunt's user cache. A test
16+
# that needs a real toolchain belongs behind the `tf` build tag.
1517
#
1618
# How much of that holds depends on the platform:
1719
#
18-
# - macOS confines both through one seatbelt profile.
20+
# - macOS confines all of them through one seatbelt profile.
1921
# - Linux confines the network alone, through a network namespace.
2022
#
2123
# The --check flag confirms the sandbox is working properly.
@@ -95,6 +97,25 @@ check_writes() {
9597
echo "sandbox-exec.sh: writes are confined to the temp dir and the caches"
9698
}
9799

100+
check_tf_exec() {
101+
local script="$1"
102+
103+
local binary
104+
binary="$(command -v tofu || command -v terraform || true)"
105+
106+
if [[ -z "$binary" ]]; then
107+
echo "sandbox-exec.sh: no tofu or terraform on PATH, skipping the execution check"
108+
return
109+
fi
110+
111+
if "$script" "$binary" -version >/dev/null 2>&1; then
112+
echo "sandbox-exec.sh: check failed, $binary still runs inside the sandbox" >&2
113+
exit 1
114+
fi
115+
116+
echo "sandbox-exec.sh: toolchain execution is blocked"
117+
}
118+
98119
run_check() {
99120
local script="${BASH_SOURCE[0]}"
100121

@@ -106,11 +127,12 @@ run_check() {
106127
check_egress "$script"
107128

108129
if [[ "$(uname -s)" != "Darwin" ]]; then
109-
echo "sandbox-exec.sh: writes are NOT confined on $(uname -s)"
130+
echo "sandbox-exec.sh: writes and execution are NOT confined on $(uname -s)"
110131
return
111132
fi
112133

113134
check_writes "$script"
135+
check_tf_exec "$script"
114136
}
115137

116138
if [[ "${1:-}" == "--check" ]]; then

.github/scripts/ci/sandbox.sb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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.
1+
;; macOS seatbelt profile that blocks the side effects a unit test run should
2+
;; not have. It binds the process and everything it spawns.
33
;;
44
;; Apple documents sandbox-exec(1) and sandbox(7) but not the profile language
55
;; below. The working reference for the syntax is the set of profiles Apple
@@ -15,6 +15,11 @@
1515
;; source tree is not among them. A test that writes into testdata or a fixture
1616
;; fails here instead of leaving the change behind for the next run.
1717
;;
18+
;; Execution: no real OpenTofu or Terraform binary. The `tf` build tag marks
19+
;; the tests that need one, and the integration jobs run those against a real
20+
;; toolchain. A test that needs to stand in for the binary substitutes
21+
;; vexec.NewMemExec instead of putting a mock on PATH.
22+
;;
1823
;; Reads are untouched.
1924
(version 1)
2025
(allow default)
@@ -31,6 +36,8 @@
3136
(allow file-write* (subpath (param "GOMODCACHE")))
3237
(allow file-write* (subpath (param "TGCACHE")))
3338

39+
(deny process-exec (regex #"/(tofu|terraform)$"))
40+
3441
;; Terminal and null devices, which the test binary and anything it spawns write
3542
;; through for ordinary output.
3643
(allow file-write-data (literal "/dev/null"))

.github/workflows/sandboxed-test.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ on:
99
# the untagged suite. Both are worth catching here rather than as a flake on a day
1010
# the registry is slow.
1111
#
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.
12+
# Only the network half applies here. The wrapper confines writes and toolchain
13+
# execution through a seatbelt profile, which runs only on macOS, so this job
14+
# does not check either.
1415
jobs:
1516
test:
1617
name: Sandboxed Test

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,12 +337,13 @@ The convention we use for race tests is to prefix them with `WithRacing`. The Te
337337

338338
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-
A test can break that in two ways, and neither shows up as a test failure:
340+
A test can break that in ways that never show up as a test failure:
341341

342342
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.
343343
1. A test that writes outside its own temp directory leaves state behind for the next run.
344+
1. A test that runs a real OpenTofu or Terraform binary belongs behind the `tf` build tag, where the integration jobs give it a real toolchain. A test that only needs to stand in for the binary substitutes `vexec.NewMemExec`.
344345

345-
To check for both, run the suite through the sandbox wrapper:
346+
To check for all of them, run the suite through the sandbox wrapper:
346347

347348
```bash
348349
go test -exec "$PWD/.github/scripts/ci/sandbox-exec.sh" ./...
@@ -358,13 +359,13 @@ Before reading anything into a passing run, confirm the sandbox took effect. The
358359

359360
</Aside>
360361

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+
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. A test that needs to stand in for the toolchain substitutes `vexec.NewMemExec` rather than putting a mock binary on `PATH`. 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`.
362363

363364
How the sandbox is built, and how much it covers, differ by platform:
364365

365366
<Tabs syncKey="operating-systems">
366367
<TabItem label="Linux">
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+
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 and execution are not confined. Doing either needs bubblewrap or a Landlock wrapper, neither of which is wired up, so `--check` reports the network half alone.
368369

369370
This is the platform Continuous Integration runs on, so CI checks the network and nothing else.
370371

@@ -375,7 +376,7 @@ How the sandbox is built, and how much it covers, differ by platform:
375376
```
376377
</TabItem>
377378
<TabItem label="macOS">
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.
379+
Each test binary runs under the seatbelt profile at `.github/scripts/ci/sandbox.sb`, applied with `sandbox-exec`. That profile confines the network, writes and toolchain execution together.
379380

380381
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`.
381382
</TabItem>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build tf
2+
3+
package test_test
4+
5+
import (
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/gruntwork-io/terragrunt/test/helpers"
12+
)
13+
14+
func TestTFDependencyOutputSkipDependencyOutputsFlag(t *testing.T) {
15+
t.Parallel()
16+
17+
helpers.CleanupTerraformFolder(t, testFixtureGetOutput)
18+
19+
// The subtests all drive the same unit, so each one needs its own copy of the
20+
// fixture. Sharing a working directory makes them race for its state lock.
21+
noOutputPath := func(t *testing.T) string {
22+
t.Helper()
23+
24+
tmpEnvPath := helpers.CopyEnvironment(t, testFixtureGetOutput)
25+
26+
return filepath.Join(tmpEnvPath, testFixtureGetOutput, "integration", "skip-dependency-outputs")
27+
}
28+
29+
t.Run("plan without flag fails", func(t *testing.T) {
30+
t.Parallel()
31+
_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt plan --non-interactive --working-dir "+noOutputPath(t))
32+
require.ErrorContains(t, err, "resolving dependency \"app1\" outputs")
33+
})
34+
35+
t.Run("flag rejected without experiment", func(t *testing.T) {
36+
t.Parallel()
37+
38+
if helpers.IsExperimentMode(t) {
39+
t.Skip("Skipping: TG_EXPERIMENT_MODE forces the optional-dependency-outputs experiment on, so its disabled-state error can't be verified")
40+
}
41+
42+
_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt init --no-dependency-outputs --non-interactive --working-dir "+noOutputPath(t))
43+
require.ErrorContains(t, err, "--no-dependency-outputs requires the 'optional-dependency-outputs' experiment")
44+
})
45+
46+
for _, cmd := range []string{"init", "validate", "plan"} {
47+
t.Run(cmd+" succeeds with flag", func(t *testing.T) {
48+
t.Parallel()
49+
_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt "+cmd+" --experiment optional-dependency-outputs --no-dependency-outputs --non-interactive --working-dir "+noOutputPath(t))
50+
require.NoError(t, err)
51+
})
52+
}
53+
54+
for _, cmd := range []string{"init", "validate", "plan"} {
55+
t.Run("run --all "+cmd+" succeeds with flag", func(t *testing.T) {
56+
t.Parallel()
57+
_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run --all --experiment optional-dependency-outputs "+cmd+" --no-dependency-outputs --non-interactive --working-dir "+noOutputPath(t))
58+
require.NoError(t, err)
59+
})
60+
}
61+
}

test/integration_hooks_test.go

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
package test_test
22

33
import (
4-
"bytes"
5-
"encoding/json"
64
"fmt"
75
"path/filepath"
86
"testing"
97

10-
"github.com/gruntwork-io/terragrunt/internal/cli/commands/info/print"
118
"github.com/gruntwork-io/terragrunt/test/helpers"
129
"github.com/stretchr/testify/assert"
1310
"github.com/stretchr/testify/require"
@@ -69,36 +66,3 @@ func assertNoHookOutputFiles(t *testing.T, unitPaths ...string) {
6966
assert.NoFileExists(t, filepath.Join(unitPath, "error.out"))
7067
}
7168
}
72-
73-
func TestTerragruntInfo(t *testing.T) {
74-
t.Parallel()
75-
76-
helpers.CleanupTerraformFolder(t, testFixtureHooksInitOnceWithSourceNoBackendSuppressHookStdout)
77-
tmpEnvPath := helpers.CopyEnvironment(t, "fixtures/hooks/init-once")
78-
rootPath := filepath.Join(
79-
tmpEnvPath,
80-
testFixtureHooksInitOnceWithSourceNoBackendSuppressHookStdout,
81-
)
82-
83-
showStdout := bytes.Buffer{}
84-
showStderr := bytes.Buffer{}
85-
86-
err := helpers.RunTerragruntCommand(
87-
t,
88-
"terragrunt info print --non-interactive --working-dir "+rootPath,
89-
&showStdout,
90-
&showStderr,
91-
)
92-
require.NoError(t, err)
93-
94-
helpers.LogBufferContentsLineByLine(t, showStdout, "show stdout")
95-
96-
var dat print.InfoOutput
97-
98-
errUnmarshal := json.Unmarshal(showStdout.Bytes(), &dat)
99-
require.NoError(t, errUnmarshal)
100-
101-
assert.Equal(t, fmt.Sprintf("%s/%s", rootPath, helpers.TerragruntCache), dat.DownloadDir)
102-
assert.Equal(t, wrappedBinary(t.Context()), dat.TerraformBinary)
103-
assert.Empty(t, dat.IAMRole)
104-
}

test/integration_hooks_tf_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ package test_test
44

55
import (
66
"bytes"
7+
"encoding/json"
78
"fmt"
89
"os"
910
"path/filepath"
1011
"strings"
1112
"testing"
1213

14+
"github.com/gruntwork-io/terragrunt/internal/cli/commands/info/print"
1315
"github.com/gruntwork-io/terragrunt/test/helpers"
1416
"github.com/stretchr/testify/assert"
1517
"github.com/stretchr/testify/require"
@@ -649,3 +651,36 @@ func TestTFTerragruntHookExitCodeError(t *testing.T) {
649651
assert.Contains(t, output, `exited with non-zero exit code 2`)
650652
assert.Contains(t, output, "lint warning: something is wrong")
651653
}
654+
655+
func TestTFTerragruntInfo(t *testing.T) {
656+
t.Parallel()
657+
658+
helpers.CleanupTerraformFolder(t, testFixtureHooksInitOnceWithSourceNoBackendSuppressHookStdout)
659+
tmpEnvPath := helpers.CopyEnvironment(t, "fixtures/hooks/init-once")
660+
rootPath := filepath.Join(
661+
tmpEnvPath,
662+
testFixtureHooksInitOnceWithSourceNoBackendSuppressHookStdout,
663+
)
664+
665+
showStdout := bytes.Buffer{}
666+
showStderr := bytes.Buffer{}
667+
668+
err := helpers.RunTerragruntCommand(
669+
t,
670+
"terragrunt info print --non-interactive --working-dir "+rootPath,
671+
&showStdout,
672+
&showStderr,
673+
)
674+
require.NoError(t, err)
675+
676+
helpers.LogBufferContentsLineByLine(t, showStdout, "show stdout")
677+
678+
var dat print.InfoOutput
679+
680+
errUnmarshal := json.Unmarshal(showStdout.Bytes(), &dat)
681+
require.NoError(t, errUnmarshal)
682+
683+
assert.Equal(t, fmt.Sprintf("%s/%s", rootPath, helpers.TerragruntCache), dat.DownloadDir)
684+
assert.Equal(t, wrappedBinary(t.Context()), dat.TerraformBinary)
685+
assert.Empty(t, dat.IAMRole)
686+
}

0 commit comments

Comments
 (0)