diff --git a/README.md b/README.md index 22fbc26..fe09775 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ the right `-var-file` flags every time is tedious and error-prone. `tf` does it pass through - **plan, apply, etc.** auto-run `terraform init` with backend config, then inject `-var-file` flags + - **force-unlock** auto-runs `terraform init` with backend config so the lock can be located, + but does not inject `-var-file` flags - `tf plan` automatically saves a binary plan to `/tmp/.tfplan` (view with `tf show /tmp/.tfplan`). Skipped if the user passes their own `-out` flag. - `tf apply` automatically appends `-auto-approve`, since locally the plan has already been diff --git a/bin/tf b/bin/tf index f9c9854..e273c12 100755 --- a/bin/tf +++ b/bin/tf @@ -168,7 +168,9 @@ SUBCOMMANDS_ACCEPTING_VAR_FILE = frozenset({ "plan", "apply", "destroy", "import", "refresh", "console", }) -SUBCOMMANDS_NEEDING_BACKEND = SUBCOMMANDS_ACCEPTING_VAR_FILE | {"init"} +SUBCOMMANDS_NEEDING_BACKEND = SUBCOMMANDS_ACCEPTING_VAR_FILE | { + "init", "force-unlock", +} SUBCOMMANDS_NEEDING_DATA_DIR = frozenset({"show", "output", "state"}) @@ -252,15 +254,21 @@ class TfRunner: self._run_init(backend) - tfvars = TfVarsFiles.find(context) + if self.subcommand in SUBCOMMANDS_ACCEPTING_VAR_FILE: + tfvars = TfVarsFiles.find(context) - if tfvars.is_using_deprecated_filenames(): - print( - f"tf: using account-ID tfvars (deprecated). Rename to {context.profile}.tfvars.", - file=sys.stderr, - ) + if tfvars.is_using_deprecated_filenames(): + print( + f"tf: using account-ID tfvars (deprecated). Rename to {context.profile}.tfvars.", + file=sys.stderr, + ) + + command = self.build_command(tfvars) - command = self.build_command(tfvars) + if list(tfvars): + print(f"tf: var-files: {', '.join(tfvars)}", file=sys.stderr) + else: + command = ["terraform"] + self.args if self.subcommand == "plan" and not self._has_out_flag(): out_path = f"/tmp/{context.profile}.tfplan" @@ -271,9 +279,6 @@ class TfRunner: command.append("-auto-approve") print("tf: auto-approving apply", file=sys.stderr) - if list(tfvars): - print(f"tf: var-files: {', '.join(tfvars)}", file=sys.stderr) - os.execvp("terraform", command) def _has_out_flag(self) -> bool: diff --git a/tests/test_main.py b/tests/test_main.py index 1b8ef62..7aaf4af 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -374,3 +374,103 @@ def test_apply_does_not_add_out_flag(self, monkeypatch): command = mock_execvp.call_args[0][1] assert not any(arg.startswith("-out=") for arg in command) + + +class TestTfRunnerForceUnlock: + def test_force_unlock_runs_init_before_command(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.subprocess.run") as mock_run, \ + patch("tf.os.execvp"): + mock_run.return_value.returncode = 0 + tf.TfRunner(["force-unlock", "abc123"]).call() + + init_command = mock_run.call_args[0][0] + assert init_command[0] == "terraform" + assert init_command[1] == "init" + assert any(arg.startswith("-backend-config=bucket=") for arg in init_command) + + def test_force_unlock_sets_tf_data_dir(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + monkeypatch.delenv("TF_DATA_DIR", raising=False) + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.subprocess.run", return_value=type("R", (), {"returncode": 0})()), \ + patch("tf.os.execvp"): + tf.TfRunner(["force-unlock", "abc123"]).call() + + assert os.environ["TF_DATA_DIR"] == ".terraform.123456789012-eu-west-1" + + def test_force_unlock_does_not_inject_var_files(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.TfVarsFiles.find", + return_value=tf.TfVarsFiles(["staging.tfvars"])), \ + patch("tf.subprocess.run", return_value=type("R", (), {"returncode": 0})()), \ + patch("tf.os.execvp") as mock_execvp: + tf.TfRunner(["force-unlock", "abc123"]).call() + + command = mock_execvp.call_args[0][1] + assert "-var-file" not in command + assert "staging.tfvars" not in command + + def test_force_unlock_does_not_search_for_tfvars(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.TfVarsFiles.find") as mock_find, \ + patch("tf.subprocess.run", return_value=type("R", (), {"returncode": 0})()), \ + patch("tf.os.execvp"): + tf.TfRunner(["force-unlock", "abc123"]).call() + + mock_find.assert_not_called() + + def test_force_unlock_does_not_add_auto_approve(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.subprocess.run", return_value=type("R", (), {"returncode": 0})()), \ + patch("tf.os.execvp") as mock_execvp: + tf.TfRunner(["force-unlock", "abc123"]).call() + + command = mock_execvp.call_args[0][1] + assert "-auto-approve" not in command + + def test_force_unlock_does_not_add_out_flag(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.subprocess.run", return_value=type("R", (), {"returncode": 0})()), \ + patch("tf.os.execvp") as mock_execvp: + tf.TfRunner(["force-unlock", "abc123"]).call() + + command = mock_execvp.call_args[0][1] + assert not any(arg.startswith("-out=") for arg in command) + + def test_force_unlock_passes_lock_id_through(self, monkeypatch): + monkeypatch.setenv("AWS_VAULT", "staging") + monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1") + + with patch("tf.TfBackend._get_repo_name", return_value="my-repo"), \ + patch("tf.TfVarsFiles._get_account_id", return_value="123456789012"), \ + patch("tf.subprocess.run", return_value=type("R", (), {"returncode": 0})()), \ + patch("tf.os.execvp") as mock_execvp: + tf.TfRunner(["force-unlock", "abc123"]).call() + + mock_execvp.assert_called_once_with( + "terraform", ["terraform", "force-unlock", "abc123"] + )