Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docker-terraform-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=()
Expand Down
5 changes: 5 additions & 0 deletions Docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</details>
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 13 additions & 15 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 "$@"
82 changes: 82 additions & 0 deletions docker-terraform-init.sh
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions docs/docker-initialization.md
Original file line number Diff line number Diff line change
@@ -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. `<configuration-directory>/.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.
3 changes: 3 additions & 0 deletions docs/docker-terraform-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions tests/test_docker_init.py
Original file line number Diff line number Diff line change
@@ -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()
Loading