From 0668cc36021c133afaf9434c7f581b7ec34d1950 Mon Sep 17 00:00:00 2001 From: Ian Liu <37123262+Ianyliu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:20:19 -0400 Subject: [PATCH 1/2] Make Docker Terraform initialization configurable --- .../workflows/docker-terraform-version.yml | 3 + Dockerfile | 3 +- docker-entrypoint.sh | 28 ++-- docker-terraform-init.sh | 82 ++++++++++ tests/test_docker_init.py | 150 ++++++++++++++++++ 5 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 docker-terraform-init.sh create mode 100644 tests/test_docker_init.py diff --git a/.github/workflows/docker-terraform-version.yml b/.github/workflows/docker-terraform-version.yml index 97ee1cd..a1a15bd 100644 --- a/.github/workflows/docker-terraform-version.yml +++ b/.github/workflows/docker-terraform-version.yml @@ -38,6 +38,9 @@ jobs: - name: Check out repository uses: actions/checkout@v6 + - name: Test Terraform initialization policy + run: python -m unittest tests/test_docker_init.py -v + - name: Build Docker image run: | build_args=() diff --git a/Dockerfile b/Dockerfile index 2a7a6e9..dc017fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ RUN apk add --no-cache graphviz ttf-freefont git \ COPY --from=terraform /bin/terraform /bin/terraform COPY ./docker-entrypoint.sh /bin/docker-entrypoint.sh -RUN chmod +x /bin/docker-entrypoint.sh \ +COPY ./docker-terraform-init.sh /bin/docker-terraform-init.sh +RUN chmod +x /bin/docker-entrypoint.sh /bin/docker-terraform-init.sh \ && terraform version \ && dot -V diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3899876..cf549bf 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,10 +6,6 @@ if [ -n "${1}" ] && [ "${1}" != "blast-radius" ]; then set -- blast-radius "$@" fi -# Assert CLI args are overwritten, otherwise set them to preferred defaults. -export TF_CLI_ARGS_get=${TF_CLI_ARGS_get:-'-update'} -export TF_CLI_ARGS_init=${TF_CLI_ARGS_init:-'-input=false'} - # Inside the container # Need to create the upper and work dirs inside a tmpfs. # Otherwise OverlayFS complains about AUFS folders. @@ -24,20 +20,22 @@ mount -t overlay overlay -o lowerdir=/data,upperdir=/tmp/overlay/upper,workdir=/ # change to the overlayFS cd /data-rw -# Is Terraform already initialized? Ensure modules are all downloaded. -[ -d '.terraform' ] && terraform get - -# Initialize Terraform with the version embedded in this Docker image. -if [ -n "$CHDIR" ] && [ -d "$CHDIR" ]; then - echo "Initializing Terraform in directory: $CHDIR" - terraform -chdir="$CHDIR" init +/bin/docker-terraform-init.sh /data-rw + +if [ -n "${CHDIR:-}" ]; then + case "$CHDIR" in + /*) + config_dir=$CHDIR + ;; + *) + config_dir=/data-rw/$CHDIR + ;; + esac else - echo "Initializing Terraform in directory: /data-rw" - terraform init + config_dir=/data-rw fi -# it's possible that we're in a sub-directory. leave. -cd /data-rw +cd "$config_dir" # Let's go! exec "$@" diff --git a/docker-terraform-init.sh b/docker-terraform-init.sh new file mode 100644 index 0000000..b93f421 --- /dev/null +++ b/docker-terraform-init.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +workspace=${1:?usage: docker-terraform-init.sh WORKSPACE} +mode=${BLAST_RADIUS_TERRAFORM_INIT:-auto} + +case "$mode" in + auto|always|never) + ;; + *) + echo "Invalid BLAST_RADIUS_TERRAFORM_INIT value '$mode'; expected auto, always, or never." >&2 + exit 64 + ;; +esac + +if [ -n "${CHDIR:-}" ]; then + case "$CHDIR" in + /*) + config_dir=$CHDIR + ;; + *) + config_dir=$workspace/$CHDIR + ;; + esac +else + config_dir=$workspace +fi + +if [ ! -d "$config_dir" ]; then + echo "Terraform configuration directory does not exist: $config_dir" >&2 + exit 66 +fi + +if [ -n "${TF_DATA_DIR:-}" ]; then + case "$TF_DATA_DIR" in + /*) + cache_dir=$TF_DATA_DIR + ;; + *) + cache_dir=$config_dir/$TF_DATA_DIR + ;; + esac +else + cache_dir=$config_dir/.terraform +fi + +has_configuration=false +for terraform_file in "$config_dir"/*.tf; do + if [ -f "$terraform_file" ]; then + has_configuration=true + break + fi +done + +has_cache=false +if [ -d "$cache_dir" ] && find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then + has_cache=true +fi + +should_initialize=false +case "$mode" in + always) + should_initialize=true + ;; + never) + echo "Skipping Terraform init because BLAST_RADIUS_TERRAFORM_INIT=never." + ;; + auto) + if [ "$has_configuration" = false ]; then + echo "Skipping Terraform init because no .tf files were found in $config_dir." + elif [ "$has_cache" = true ]; then + echo "Skipping Terraform init because cached Terraform data exists at $cache_dir." + else + should_initialize=true + fi + ;; +esac + +if [ "$should_initialize" = true ]; then + echo "Initializing Terraform in directory: $config_dir" + terraform -chdir="$config_dir" init -backend=false -input=false +fi diff --git a/tests/test_docker_init.py b/tests/test_docker_init.py new file mode 100644 index 0000000..ba86e3d --- /dev/null +++ b/tests/test_docker_init.py @@ -0,0 +1,150 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +INIT_SCRIPT = ROOT / "docker-terraform-init.sh" + + +@unittest.skipIf(os.name == "nt", "the container initialization script is POSIX shell") +class DockerTerraformInitTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.root = Path(self.temporary_directory.name) + self.workspace = self.root / "workspace" + self.workspace.mkdir() + self.log = self.root / "terraform.log" + + mock_bin = self.root / "bin" + mock_bin.mkdir() + terraform = mock_bin / "terraform" + terraform.write_text( + '#!/bin/sh\nprintf "%s\\n" "$*" >> "$MOCK_TERRAFORM_LOG"\n', + encoding="utf-8", + ) + terraform.chmod(0o755) + + self.environment = os.environ.copy() + self.environment["PATH"] = ( + str(mock_bin) + os.pathsep + self.environment["PATH"] + ) + self.environment["MOCK_TERRAFORM_LOG"] = str(self.log) + for name in ( + "BLAST_RADIUS_TERRAFORM_INIT", + "CHDIR", + "TF_DATA_DIR", + ): + self.environment.pop(name, None) + + def run_init(self, **environment): + process_environment = self.environment.copy() + process_environment.update(environment) + return subprocess.run( + ["/bin/sh", str(INIT_SCRIPT), str(self.workspace)], + text=True, + capture_output=True, + check=False, + env=process_environment, + ) + + def terraform_calls(self): + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + def add_configuration(self, directory=None): + config_dir = directory or self.workspace + (config_dir / "main.tf").write_text( + 'resource "terraform_data" "example" {}\n', + encoding="utf-8", + ) + + def test_auto_initializes_an_uncached_configuration(self): + self.add_configuration() + + result = self.run_init() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + self.terraform_calls(), + [ + f"-chdir={self.workspace} init -backend=false -input=false", + ], + ) + self.assertNotIn("terraform get", result.stdout) + + def test_auto_reuses_a_nonempty_private_module_cache(self): + self.add_configuration() + cached_module = self.workspace / ".terraform" / "modules" / "private" + cached_module.mkdir(parents=True) + (cached_module / "main.tf").write_text("", encoding="utf-8") + + result = self.run_init() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.terraform_calls(), []) + self.assertIn("cached Terraform data exists", result.stdout) + + def test_auto_skips_dot_only_startup(self): + result = self.run_init() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.terraform_calls(), []) + self.assertIn("no .tf files were found", result.stdout) + + def test_auto_respects_chdir_and_relative_tf_data_dir(self): + config_dir = self.workspace / "stacks" / "application" + config_dir.mkdir(parents=True) + self.add_configuration(config_dir) + cache_dir = config_dir / ".tfdata" + cache_dir.mkdir() + (cache_dir / "environment").write_text("default", encoding="utf-8") + + result = self.run_init( + CHDIR="stacks/application", + TF_DATA_DIR=".tfdata", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.terraform_calls(), []) + self.assertIn(str(cache_dir), result.stdout) + + def test_explicit_always_and_never_modes(self): + with self.subTest(mode="always"): + result = self.run_init(BLAST_RADIUS_TERRAFORM_INIT="always") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + self.terraform_calls(), + [ + f"-chdir={self.workspace} init -backend=false -input=false", + ], + ) + + self.log.unlink() + self.add_configuration() + with self.subTest(mode="never"): + result = self.run_init(BLAST_RADIUS_TERRAFORM_INIT="never") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.terraform_calls(), []) + self.assertIn("BLAST_RADIUS_TERRAFORM_INIT=never", result.stdout) + + def test_invalid_mode_fails_with_a_clear_error(self): + result = self.run_init(BLAST_RADIUS_TERRAFORM_INIT="sometimes") + + self.assertEqual(result.returncode, 64) + self.assertEqual(self.terraform_calls(), []) + self.assertIn("expected auto, always, or never", result.stderr) + + def test_missing_chdir_fails_before_running_terraform(self): + result = self.run_init(CHDIR="missing") + + self.assertEqual(result.returncode, 66) + self.assertEqual(self.terraform_calls(), []) + self.assertIn("does not exist", result.stderr) + + +if __name__ == "__main__": + unittest.main() From ff56c45fbf018cd2cb9e17a15f232b5c2374c5f5 Mon Sep 17 00:00:00 2001 From: Ian Liu <37123262+Ianyliu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:21:42 -0400 Subject: [PATCH 2/2] Document Docker initialization modes --- Docker.md | 5 +++ README.md | 8 +++- docs/docker-initialization.md | 63 +++++++++++++++++++++++++++++++ docs/docker-terraform-versions.md | 3 ++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 docs/docker-initialization.md diff --git a/Docker.md b/Docker.md index cf7beb8..51d8e19 100644 --- a/Docker.md +++ b/Docker.md @@ -75,6 +75,11 @@ specifically `--cap-add=SYS_ADMIN`. > Note: This is considered a security risk by some, so be sure you understand how this works. +Initialization is cache-aware and configurable through +`BLAST_RADIUS_TERRAFORM_INIT=auto|always|never`. See +[Docker Terraform initialization](docs/docker-initialization.md) before +reusing private modules or provider caches created on another platform. + For more information on how this works and what it means for your host, check out the [runtime privileges][privileges] documentation. diff --git a/README.md b/README.md index 6bf5827..5030782 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,13 @@ Note: If you have spaces in your directory then you may have to change `-v ${pwd A slightly more customized variant of this is also available as an example [docker-compose.yml](./Docker/docker-compose.yml) usecase for Workspaces. -For more details on Docker usage, see [Docker.md](Docker.md) +Terraform initialization defaults to a cache-aware `auto` mode. Use +`BLAST_RADIUS_TERRAFORM_INIT=always` to force initialization or `never` to +reuse a prepared project without any initialization attempt. See +[Docker Terraform initialization](docs/docker-initialization.md) for cache, +`CHDIR`, `TF_DATA_DIR`, private-module, and provider-platform details. + +For more details on Docker usage, see [Docker.md](Docker.md). ## Kubernetes Quickstart diff --git a/docs/docker-initialization.md b/docs/docker-initialization.md new file mode 100644 index 0000000..6656c1e --- /dev/null +++ b/docs/docker-initialization.md @@ -0,0 +1,63 @@ +# Docker Terraform initialization + +The container controls Terraform initialization with +`BLAST_RADIUS_TERRAFORM_INIT`. The default is `auto`. + +| Mode | Behavior | +| --- | --- | +| `auto` | Skip when no `.tf` files exist or a non-empty Terraform data cache exists; initialize otherwise. | +| `always` | Run initialization on every container start. | +| `never` | Never run initialization. | + +Initialization uses: + +```sh +terraform init -backend=false -input=false +``` + +Disabling the backend prevents a visualization from contacting or modifying a +configured state backend. The input flag makes startup deterministic. + +## Cache and directory detection + +The configuration directory is the mounted workspace unless `CHDIR` selects a +subdirectory. An absolute `CHDIR` is used directly; a relative value is +resolved from the workspace. + +The cache is: + +1. `TF_DATA_DIR`, resolved from the configuration directory when relative; or +2. `/.terraform`. + +In `auto` mode, any non-empty cache is reused without running `terraform get` +or `terraform init`. This allows already-downloaded private modules to work +without making the container authenticate to their source again. + +Example: + +```sh +docker run --rm -it -p 5000:5000 \ + -e BLAST_RADIUS_TERRAFORM_INIT=never \ + -e CHDIR=stacks/application \ + -v "$(pwd):/data:ro" \ + --security-opt apparmor:unconfined \ + --cap-add=SYS_ADMIN \ + ianyliu/blast-radius-fork +``` + +## Cached provider compatibility + +Terraform provider binaries are operating-system and architecture specific. +A `.terraform` directory created on macOS or Windows, or on a different CPU +architecture, may contain providers that cannot execute in the Linux +container. Cached Terraform modules are source files and generally do not +have this restriction. + +If a reused cache lacks compatible Linux providers, use +`BLAST_RADIUS_TERRAFORM_INIT=always` so the container can populate its writable +overlay with compatible providers. This does not modify the read-only host +mount. Private provider and module sources may still require a mounted +Terraform CLI configuration or credentials. + +For DOT-only visualization, leave the default `auto` mode: startup skips +Terraform automatically when the mounted directory has no `.tf` files. diff --git a/docs/docker-terraform-versions.md b/docs/docker-terraform-versions.md index 9dd510b..e68e42e 100644 --- a/docs/docker-terraform-versions.md +++ b/docs/docker-terraform-versions.md @@ -2,6 +2,9 @@ Blast Radius Fork bundles a Terraform binary into the Docker image so that the container can run `terraform init` and `terraform graph` without depending on the host machine's Terraform installation. +Container startup uses the cache-aware initialization policy documented in +[Docker Terraform initialization](docker-initialization.md). + The default Dockerfile build currently pins Terraform to `1.15.8`: ```sh