Skip to content

Document Vault bootstrap usage and extend helper configuration#174

Open
leynos wants to merge 2 commits intomainfrom
codex/implement-vault-appliance-bootstrap-helper
Open

Document Vault bootstrap usage and extend helper configuration#174
leynos wants to merge 2 commits intomainfrom
codex/implement-vault-appliance-bootstrap-helper

Conversation

@leynos
Copy link
Copy Markdown
Owner

@leynos leynos commented Oct 3, 2025

Summary

  • allow overriding the Vault API endpoint and CA bundle in bootstrap_vault_appliance.py
  • extend the pytest suite to cover address/CA propagation and discovery behaviour
  • document how to run the bootstrap helper in docs/vault-applience-bootstrap-process.md and note the options in the module design

Testing

  • uv run --with plumbum --with pytest pytest scripts/tests -q

https://chatgpt.com/codex/tasks/task_e_68dea7372f3c8322b43098f906045da7

Summary by Sourcery

Introduce a new idempotent Python helper script for bootstrapping a DigitalOcean-hosted Vault appliance with configurable Vault endpoint and custom CA bundle, update module design documentation, add user guide, and extend the test suite to cover address/CA propagation and error scenarios.

New Features:

  • Add scripts/bootstrap_vault_appliance.py for automated, idempotent initialization and configuration of the Vault appliance
  • Introduce tests in scripts/tests/test_bootstrap_vault_appliance.py covering initialization, configuration reuse, and error conditions
  • Include a minimal cmd-mox stub in scripts/tests/_vendor for command mocking in tests

Documentation:

  • Update docs/vault-appliance-module-design.md with bootstrap helper strategy and override options
  • Add docs/vault-applience-bootstrap-process.md with detailed usage guide and troubleshooting

Tests:

  • Add pytest configuration in scripts/tests/conftest.py to ensure project and vendor paths are loaded

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Oct 3, 2025

Reviewer's Guide

This PR introduces a new Python-based, idempotent Vault appliance bootstrap helper with configurable API endpoint and CA bundle support, extends the pytest suite to validate its behavior across discovery, initialization, reuse, and error scenarios, and adds comprehensive documentation covering module design and usage instructions.

Sequence diagram for Vault appliance bootstrap process

sequenceDiagram
    participant User as actor User
    participant Helper as "bootstrap_vault_appliance.py"
    participant DO as "DigitalOcean API"
    participant Vault as "Vault Appliance"
    participant Secrets as "DigitalOcean Secrets Manager"
    User->>Helper: Run bootstrap script with options
    Helper->>DO: Discover droplet IP via tag
    Helper->>Vault: Verify Vault systemd unit is active (via SSH)
    Helper->>Vault: Check Vault status (via CLI)
    alt Vault not initialized
        Helper->>Vault: Initialize Vault (operator init)
        Helper->>Secrets: Store unseal keys and root token
        Helper->>Vault: Unseal Vault
        Helper->>Vault: Enable KV v2 engine
        Helper->>Vault: Enable AppRole and write policy
        Helper->>Secrets: Store AppRole role-id and secret-id
    else Vault initialized
        alt Vault sealed
            Helper->>Secrets: Load unseal keys
            Helper->>Vault: Unseal Vault
        end
        Helper->>Secrets: Load root token
        Helper->>Vault: Enable KV v2 engine
        Helper->>Vault: Enable AppRole and write policy
        Helper->>Secrets: Store AppRole role-id and secret-id
    end
Loading

Class diagram for the new Vault bootstrap helper

classDiagram
    class BootstrapOptions {
        +environment: str
        +droplet_tag: str
        +ssh_user: str
        +mount_path: str
        +approle_name: str
        +policy_name: str
        +secret_prefix: str
        +key_shares: int
        +key_threshold: int
        +vault_address: str | None
        +ca_cert_path: str | None
    }
    class CommandRunner {
        -_local
        +run(command: str, *args: str, env: dict[str, str] | None = None): str
    }
    class SecretStore {
        -_runner: CommandRunner
        -_prefix: str
        +get(suffix: str): str | None
        +put(suffix: str, value: str): None
    }
    class bootstrap {
        +bootstrap(options: BootstrapOptions, runner: CommandRunner | None = None): None
    }
    BootstrapOptions <.. CommandRunner : uses
    CommandRunner <.. SecretStore : uses
    SecretStore <.. bootstrap : uses
    BootstrapOptions <.. bootstrap : uses
Loading

File-Level Changes

Change Details Files
Implement idempotent Vault bootstrap helper with endpoint and CA bundle overrides
  • Add scripts/bootstrap_vault_appliance.py with command-line parsing for vault_address and ca_cert_path
  • Define BootstrapOptions dataclass to include new override fields
  • Implement CommandRunner and SecretStore abstractions for deterministic CLI and secrets operations
  • Incorporate vault_address and VAULT_CACERT into environment setup for Vault CLI calls
  • Define bootstrap flow: discovery, service verification, initialize/unseal, KV mount, AppRole provisioning
scripts/bootstrap_vault_appliance.py
Expand pytest suite to cover discovery, overrides, and error handling
  • Add test_bootstrap_vault_appliance.py with scenarios for init, reuse, and missing unseal keys
  • Include vendor stub for cmd_mox to mock external commands
  • Configure pytest conftest to adjust sys.path for project and vendor imports
  • Add empty scripts/init.py to treat scripts as a module
scripts/tests/test_bootstrap_vault_appliance.py
scripts/tests/_vendor/cmd_mox/__init__.py
scripts/tests/conftest.py
scripts/__init__.py
Document bootstrap helper design in module design spec
  • Insert 'Bootstrap helper strategy' section describing discovery, idempotence, secret persistence
  • Note override options for Vault API address and CA bundle
docs/vault-appliance-module-design.md
Add user-facing guide for the bootstrap process
  • Create docs/vault-applience-bootstrap-process.md with prerequisites, options, secrets management, and troubleshooting
  • Explain key flags (--vault-address, --ca-cert-path, --secret-prefix, etc.) and safe re-run behavior
docs/vault-applience-bootstrap-process.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 3, 2025

Warning

Rate limit exceeded

@leynos has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 12 minutes and 30 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between f89f07a and 5413080.

📒 Files selected for processing (1)
  • scripts/tests/test_bootstrap_vault_appliance.py (1 hunks)

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Summary by CodeRabbit

  • New Features
    • Introduced a Vault appliance bootstrapper for DigitalOcean: discovers the appliance, initialises and unseals Vault, configures a KV v2 engine and an AppRole, and securely stores root token, unseal shares, and AppRole credentials in DigitalOcean Secrets Manager. Supports idempotent reruns, load balancer address override, and custom CA bundles for TLS.
  • Documentation
    • Added design notes and a step‑by‑step guide for bootstrapping and maintaining the Vault appliance, including prerequisites, options, and troubleshooting.
  • Tests
    • Added comprehensive mocked tests covering happy paths, re-runs, and failure scenarios.

Walkthrough

Add a Vault appliance bootstrapper script with DigitalOcean/doctl integration, secrets storage, and idempotent provisioning (init, unseal, KV v2, AppRole). Add supporting test infrastructure (cmd-mox), pytest config, and documentation detailing design and operational steps. Introduce a scripts package initialiser.

Changes

Cohort / File(s) Summary
Docs — design and process
docs/vault-appliance-module-design.md, docs/vault-applience-bootstrap-process.md
Add design decision for Python-based bootstrap helper and a new operational guide describing prerequisites, options, secrets handling, reruns, and troubleshooting.
Scripts package init
scripts/__init__.py
Add package namespace with annotations future import and docstring.
Bootstrap script
scripts/bootstrap_vault_appliance.py
Add end-to-end bootstrapper: droplet discovery via tag, Vault service check via SSH, init/unseal, DO Secrets storage/retrieval, KV v2 mount, AppRole + policy provisioning, idempotent reruns, argument parsing, and main entry point. Expose dataclass, classes, and helper functions.
Test vendor shim
scripts/tests/_vendor/cmd_mox/__init__.py
Add command-mocking framework: registry, mock command, invocation builder, local proxy, and dataclasses for calls/responses with error handling.
Pytest config
scripts/tests/conftest.py
Prepend project root and tests/_vendor to sys.path during pytest configuration.
Bootstrap tests
scripts/tests/test_bootstrap_vault_appliance.py
Add tests covering initial bootstrap, idempotent re-runs, and error on missing unseal keys; validate env propagation and single policy write.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Operator
  participant Script as bootstrap_vault_appliance.py
  participant Runner as CommandRunner
  participant DO as doctl (Droplets/Secrets)
  participant SSH as ssh
  participant Vault as vault CLI

  Operator->>Script: Run with options
  Script->>Runner: doctl compute droplet list --tag <tag>
  Runner->>DO: Execute
  DO-->>Runner: IP address
  Runner-->>Script: IP address

  Script->>Runner: ssh <user>@<ip> "systemctl is-active vault"
  Runner->>SSH: Execute
  SSH-->>Runner: active
  Runner-->>Script: OK

  Script->>Vault: vault status (VAULT_ADDR/VAULT_CACERT)
  alt Uninitialised
    Script->>Vault: vault operator init -key-shares N -key-threshold T
    Vault-->>Script: unseal keys + root token
    Script->>Runner: doctl secrets put unseal-1..N, root-token
    Runner->>DO: Store secrets
    DO-->>Runner: OK
    loop Unseal T times
      Script->>Vault: vault operator unseal <key>
      Vault-->>Script: OK
    end
  else Sealed
    Script->>Runner: doctl secrets get unseal-*
    Runner->>DO: Fetch secrets
    DO-->>Runner: keys
    loop Unseal T times
      Script->>Vault: vault operator unseal <key>
      Vault-->>Script: OK
    end
  end

  Script->>Vault: vault secrets enable -path=<mount> kv-v2 (if missing)
  Vault-->>Script: OK/exists

  Script->>Vault: vault policy write <policy> <tmpfile>
  Vault-->>Script: OK

  Script->>Vault: vault auth enable approle (if needed)
  Script->>Vault: vault write auth/approle/role/<name> ...
  Vault-->>Script: role-id, secret-id
  Script->>Runner: doctl secrets put role-id, secret-id
  Runner->>DO: Store
  DO-->>Runner: OK
  Runner-->>Script: Done
  Script-->>Operator: Exit 0
Loading
sequenceDiagram
  autonumber
  actor Operator
  participant Script
  participant Runner
  participant DO as doctl (Secrets)
  participant Vault

  Operator->>Script: Re-run bootstrap
  Script->>Vault: vault status (with VAULT_ADDR[/CACERT])
  alt Already initialised and unsealed
    Script->>Vault: ensure KV v2 mount
    Script->>Vault: ensure AppRole + policy
    Script->>Runner: refresh missing secrets (if any)
    Runner->>DO: put/get
    DO-->>Runner: OK
  else Sealed
    Script->>Runner: doctl secrets get unseal-*
    Runner->>DO: Fetch keys
    DO-->>Runner: keys
    Script->>Vault: unseal with keys
  end
  Script-->>Operator: Exit 0
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

Droplets tagged, a compass true,
Vault awakens, learns what to do;
Keys like stars in DO’s night,
KV mounts and roles take flight.
Scripts parade in tidy rows—
Tests applaud with mocked bravos.
Ship it.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly states the primary changes by highlighting documentation of Vault bootstrap usage and extension of the helper’s configuration capabilities, matching the additions of override options, tests, and docs without extraneous detail.
Description Check ✅ Passed The description succinctly outlines the addition of Vault endpoint and CA bundle overrides, test suite enhancements, and documentation of the bootstrap helper, all of which directly reflect the changes in this pull request.

Comment @coderabbitai help to get the list of available commands and usage tips.

codescene-delta-analysis[bot]

This comment was marked as outdated.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

  • Consider modularizing the bootstrap script by splitting droplet discovery, Vault initialization, and secret‐store interactions into separate classes or modules to improve readability and testability.
  • Avoid catching all Exceptions in main; handle known error cases explicitly or re‐raise unexpected exceptions so failures aren't silently masked.
  • Centralize building and injecting VAULT_ADDR/VAULT_CACERT/VAULT_TOKEN environment settings into a helper or context manager to reduce repetition across command invocations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider modularizing the bootstrap script by splitting droplet discovery, Vault initialization, and secret‐store interactions into separate classes or modules to improve readability and testability.
- Avoid catching all Exceptions in main; handle known error cases explicitly or re‐raise unexpected exceptions so failures aren't silently masked.
- Centralize building and injecting VAULT_ADDR/VAULT_CACERT/VAULT_TOKEN environment settings into a helper or context manager to reduce repetition across command invocations.

## Individual Comments

### Comment 1
<location> `scripts/bootstrap_vault_appliance.py:262-269` </location>
<code_context>
+    root_token: str | None = init_data.get("root_token")
+    if not root_token:
+        raise RuntimeError("Vault did not return a root token during initialisation.")
+    for index, key in enumerate(unseal_keys, start=1):
+        secrets.put(f"unseal-{index}", key)
+    secrets.put("root-token", root_token)
+    return {"unseal_keys": list(unseal_keys), "root_token": root_token}
</code_context>

<issue_to_address>
**suggestion:** Unseal key enumeration may mismatch with key_shares if Vault changes output format.

Validate that the number of unseal keys matches options.key_shares before storing, to prevent mismatches if Vault output changes.

```suggestion
    unseal_keys: Iterable[str] = init_data.get("unseal_keys_b64", [])
    if len(unseal_keys) != options.key_shares:
        raise RuntimeError(
            f"Vault returned {len(unseal_keys)} unseal keys, but options.key_shares is {options.key_shares}. Aborting to prevent mismatched key storage."
        )
    root_token: str | None = init_data.get("root_token")
    if not root_token:
        raise RuntimeError("Vault did not return a root token during initialisation.")
    for index, key in enumerate(unseal_keys, start=1):
        secrets.put(f"unseal-{index}", key)
    secrets.put("root-token", root_token)
    return {"unseal_keys": list(unseal_keys), "root_token": root_token}
```
</issue_to_address>

### Comment 2
<location> `scripts/bootstrap_vault_appliance.py:272-278` </location>
<code_context>
+
+def load_unseal_keys(options: BootstrapOptions, secrets: SecretStore) -> list[str]:
+    keys: list[str] = []
+    for index in range(1, options.key_shares + 1):
+        key = secrets.get(f"unseal-{index}")
+        if key:
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Loading unseal keys may skip missing keys without warning.

Log a warning if the number of loaded keys is less than expected to help with troubleshooting.

```suggestion
import logging

def load_unseal_keys(options: BootstrapOptions, secrets: SecretStore) -> list[str]:
    keys: list[str] = []
    for index in range(1, options.key_shares + 1):
        key = secrets.get(f"unseal-{index}")
        if key:
            keys.append(key)
    if len(keys) < options.key_shares:
        logging.warning(
            "Loaded %d unseal keys, but expected %d. Some keys may be missing.",
            len(keys),
            options.key_shares,
        )
    return keys
```
</issue_to_address>

### Comment 3
<location> `scripts/bootstrap_vault_appliance.py:291-292` </location>
<code_context>
+        raise RuntimeError(
+            "Insufficient unseal keys available; aborting to keep Vault sealed."
+        )
+    for key in keys[: options.key_threshold]:
+        runner.run("vault", "operator", "unseal", key, env=env)
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Unseal process does not handle errors from vault operator unseal.

Check the result of each unseal command and report or abort on failure to improve reliability.

```suggestion
    for key in keys[: options.key_threshold]:
        result = runner.run("vault", "operator", "unseal", key, env=env)
        if hasattr(result, "returncode"):
            if result.returncode != 0:
                raise RuntimeError(
                    f"Vault unseal failed for key: {key!r} with exit code {result.returncode}.\n"
                    f"Output: {getattr(result, 'stdout', '')}\nError: {getattr(result, 'stderr', '')}"
                )
        elif result is False or result is None:
            raise RuntimeError(
                f"Vault unseal failed for key: {key!r}. Command runner returned {result!r}."
            )
```
</issue_to_address>

### Comment 4
<location> `scripts/bootstrap_vault_appliance.py:299` </location>
<code_context>
+    mounts = json.loads(runner.run("vault", "secrets", "list", "-format=json", env=env))
+    mount_path = f"{options.mount_path}/"
+    current = mounts.get(mount_path)
+    if current and current.get("type") == "kv" and current.get("options", {}).get("version") == "2":
+        return
+    runner.run(
</code_context>

<issue_to_address>
**suggestion:** KV mount check may not handle partial or misconfigured mounts.

Warn or handle cases where an existing mount is present but misconfigured, as enabling a new mount may leave the old one and cause confusion.
</issue_to_address>

### Comment 5
<location> `scripts/tests/test_bootstrap_vault_appliance.py:28-37` </location>
<code_context>
+def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for error conditions during Vault initialisation and configuration.

Include test cases for scenarios where Vault commands fail, such as inactive systemd units, failed initialisation, or unexpected secrets manager responses, to verify error handling.

Suggested implementation:

```python
def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
    registry = CommandRegistry()
    doctl = registry.create("doctl")
    vault = registry.create("vault")
    ssh = registry.create("ssh")
    runner = CommandRunner(local_module=registry.local_proxy)

    options = make_options()

    doctl.queue(
        "compute",

def test_bootstrap_vault_systemd_inactive(tmp_path, mocker) -> None:
    registry = CommandRegistry()
    vault = registry.create("vault")
    runner = CommandRunner(local_module=registry.local_proxy)

    # Simulate systemd unit inactive
    mocker.patch.object(vault, "status", return_value="inactive")
    options = make_options()

    with pytest.raises(RuntimeError, match="Vault systemd unit is inactive"):
        # Replace with actual bootstrap call that checks systemd status
        bootstrap_vault(options, runner, vault)

def test_bootstrap_vault_initialisation_failure(tmp_path, mocker) -> None:
    registry = CommandRegistry()
    vault = registry.create("vault")
    runner = CommandRunner(local_module=registry.local_proxy)

    # Simulate vault init command failure
    mocker.patch.object(vault, "init", side_effect=Exception("Vault initialisation failed"))
    options = make_options()

    with pytest.raises(Exception, match="Vault initialisation failed"):
        # Replace with actual bootstrap call that initialises vault
        bootstrap_vault(options, runner, vault)

def test_bootstrap_vault_secrets_manager_error(tmp_path, mocker) -> None:
    registry = CommandRegistry()
    vault = registry.create("vault")
    runner = CommandRunner(local_module=registry.local_proxy)

    # Simulate secrets manager error
    mocker.patch("scripts.bootstrap_vault_appliance.SecretsManager.store", side_effect=Exception("Secrets manager error"))
    options = make_options()

    with pytest.raises(Exception, match="Secrets manager error"):
        # Replace with actual bootstrap call that stores secrets
        bootstrap_vault(options, runner, vault)

```

- You may need to import `pytest` and `mocker` if not already present.
- Replace `bootstrap_vault(options, runner, vault)` with the actual function that performs the bootstrap logic in your codebase.
- Adjust the error messages and mocks to match your implementation details.
- Ensure that the error handling in your bootstrap logic raises the expected exceptions.
</issue_to_address>

### Comment 6
<location> `scripts/tests/test_bootstrap_vault_appliance.py:310-319` </location>
<code_context>
+def test_bootstrap_aborts_when_unseal_keys_missing() -> None:
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding a test for missing root token when Vault is initialized.

Please also add a test for the scenario where the root token is missing after Vault initialization to verify correct error handling.
</issue_to_address>

### Comment 7
<location> `scripts/bootstrap_vault_appliance.py:45` </location>
<code_context>
+    ca_cert_path: str | None = None
+
+
+class CommandRunner:
+    """Thin wrapper around :mod:`plumbum` for deterministic command execution."""
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider removing the CommandRunner class and using simple helper functions with partial binding to reduce mental overhead and code complexity.

```markdown
The indirection from `CommandRunner` + per‐call `env` + tiny one‐off helpers adds a lot of mental overhead. You can collapse all of that by:

1. Dropping `CommandRunner` entirely and using a single helper (or two)  
2. Binding `vault`/`doctl` + their shared `env` once via `functools.partial`  
3. Inlining the few JSON parses and error checks

For example, at the top of your file replace:

```python
class CommandRunner:
    def __init__(self, local_module=local):
        self._local = local_module
    def run(self, command: str, *args: str, env=None) -> str:
        cmd = self._local[command]
        if env: cmd = cmd.with_env(**env)
        if args: cmd = cmd[args]
        return cmd()
```

with:

```python
import subprocess
from functools import partial

def run(cmd: list[str], env: dict[str,str] | None = None) -> str:
    result = subprocess.run(cmd, capture_output=True, text=True, check=True, env=env)
    return result.stdout

# bind once in `bootstrap()`:
vault_env = { "VAULT_ADDR": address, **({"VAULT_CACERT": ca_cert} if ca_cert else {}) }
vault = partial(run, ["vault"], env=vault_env)
doctl = partial(run, ["doctl"])
```

Then your calls become:

```python
# before
status = runner.run("ssh", f"{user}@{ip}", "sudo", "systemctl", "is-active", "vault")

# after
status = vault(["ssh", f"{user}@{ip}", "sudo", "systemctl", "is-active", "vault"]).strip()

# before
mounts = json.loads(runner.run("vault", "secrets", "list", "-format=json", env=env))

# after
mounts = json.loads(vault(["secrets", "list", "-format=json"]))
```

— deleting `CommandRunner`, removing per‐call `env` args, and collapsing `SecretStore` methods into a couple of free functions will shave off hundreds of lines and flatten nesting without losing any functionality.
</issue_to_address>

### Comment 8
<location> `scripts/bootstrap_vault_appliance.py:1` </location>
<code_context>
+#!/usr/bin/env -S uv run python
+# /// script
+# requires-python = ">=3.13"
</code_context>

<issue_to_address>
**issue (review_instructions):** Add behavioural and unit tests for the new bootstrap_vault_appliance.py script.

This script introduces significant new functionality for bootstrapping a Vault appliance. You must provide both behavioural and unit tests to verify its correct operation, including happy and unhappy paths, as required by the review instructions.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*`

**Instructions:**
For any new feature or change to an existing feature, both behavioural *and* unit tests are required.

</details>
</issue_to_address>

### Comment 9
<location> `scripts/bootstrap_vault_appliance.py:412` </location>
<code_context>
+    try:
+        options = parse_args(argv)
+        bootstrap(options)
+    except Exception as exc:  # noqa: BLE001 - top-level guard
+        print(f"Error: {exc}", file=sys.stderr)
+        return 1
</code_context>

<issue_to_address>
**issue (review_instructions):** Demonstrate error handling and failure modes with tests.

The script contains error handling for various failure modes (e.g., missing secrets, unhealthy Vault service). You must provide tests that demonstrate these error paths are handled as expected.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*`

**Instructions:**
Bug fixes *must* be demonstrated by a test.

</details>
</issue_to_address>

### Comment 10
<location> `docs/vault-applience-bootstrap-process.md:55` </location>
<code_context>
+- `--mount-path`, `--approle-name`, and `--policy-name` customise the KV engine
+  path and DOKS AppRole naming.
+
+All options accept the values documented in `scripts/bootstrap_vault_appliance.py
+--help`.
+
</code_context>

<issue_to_address>
**suggestion (review_instructions):** This line exceeds 80 columns; please wrap paragraphs and bullets to 80 columns.

Please ensure all paragraphs and bullet points are wrapped to 80 columns for readability.

<details>
<summary>Review instructions:</summary>

**Path patterns:** `**/*.md`

**Instructions:**
Paragraphs and bullets must be wrapped to 80 columns

</details>
</issue_to_address>

### Comment 11
<location> `scripts/tests/_vendor/cmd_mox/__init__.py:86` </location>
<code_context>
        return MockCommandInvocation(self, tuple(), env)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Replace `tuple()` with `()` ([`tuple-literal`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/tuple-literal))

```suggestion
        return MockCommandInvocation(self, (), env)
```

<br/><details><summary>Explanation</summary>The most concise and Pythonic way to create an empty tuple is to use the `()`
literal.

This fits in with the way we create tuples with items, saving a bit of
mental energy that might be taken up with thinking about two different ways of
creating tuples:

```python
x = ("first", "second")
```

Doing things this way has the added advantage of being a nice little performance
improvement. Here are the timings before and after the change:

```
$ python3 -m timeit "tuple()"
10000000 loops, best of 5: 22.6 nsec per loop
```

```
$ python3 -m timeit "()"
50000000 loops, best of 5: 5.46 nsec per loop
```
</details>
</issue_to_address>

### Comment 12
<location> `scripts/bootstrap_vault_appliance.py:275-276` </location>
<code_context>
def load_unseal_keys(options: BootstrapOptions, secrets: SecretStore) -> list[str]:
    keys: list[str] = []
    for index in range(1, options.key_shares + 1):
        key = secrets.get(f"unseal-{index}")
        if key:
            keys.append(key)
    return keys

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
        if key := secrets.get(f"unseal-{index}"):
```
</issue_to_address>

### Comment 13
<location> `scripts/tests/_vendor/cmd_mox/__init__.py:41-42` </location>
<code_context>
    def with_env(self, **env: str) -> "MockCommandInvocation":
        merged = dict(self._env)
        merged.update(env)
        return MockCommandInvocation(self._command, self._args, merged)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Merge dictionary updates via the union operator [×2] ([`dict-assign-update-to-union`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/dict-assign-update-to-union/))

```suggestion
        merged = dict(self._env) | env
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +262 to +269
unseal_keys: Iterable[str] = init_data.get("unseal_keys_b64", [])
root_token: str | None = init_data.get("root_token")
if not root_token:
raise RuntimeError("Vault did not return a root token during initialisation.")
for index, key in enumerate(unseal_keys, start=1):
secrets.put(f"unseal-{index}", key)
secrets.put("root-token", root_token)
return {"unseal_keys": list(unseal_keys), "root_token": root_token}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Unseal key enumeration may mismatch with key_shares if Vault changes output format.

Validate that the number of unseal keys matches options.key_shares before storing, to prevent mismatches if Vault output changes.

Suggested change
unseal_keys: Iterable[str] = init_data.get("unseal_keys_b64", [])
root_token: str | None = init_data.get("root_token")
if not root_token:
raise RuntimeError("Vault did not return a root token during initialisation.")
for index, key in enumerate(unseal_keys, start=1):
secrets.put(f"unseal-{index}", key)
secrets.put("root-token", root_token)
return {"unseal_keys": list(unseal_keys), "root_token": root_token}
unseal_keys: Iterable[str] = init_data.get("unseal_keys_b64", [])
if len(unseal_keys) != options.key_shares:
raise RuntimeError(
f"Vault returned {len(unseal_keys)} unseal keys, but options.key_shares is {options.key_shares}. Aborting to prevent mismatched key storage."
)
root_token: str | None = init_data.get("root_token")
if not root_token:
raise RuntimeError("Vault did not return a root token during initialisation.")
for index, key in enumerate(unseal_keys, start=1):
secrets.put(f"unseal-{index}", key)
secrets.put("root-token", root_token)
return {"unseal_keys": list(unseal_keys), "root_token": root_token}

Comment on lines +272 to +278
def load_unseal_keys(options: BootstrapOptions, secrets: SecretStore) -> list[str]:
keys: list[str] = []
for index in range(1, options.key_shares + 1):
key = secrets.get(f"unseal-{index}")
if key:
keys.append(key)
return keys
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Loading unseal keys may skip missing keys without warning.

Log a warning if the number of loaded keys is less than expected to help with troubleshooting.

Suggested change
def load_unseal_keys(options: BootstrapOptions, secrets: SecretStore) -> list[str]:
keys: list[str] = []
for index in range(1, options.key_shares + 1):
key = secrets.get(f"unseal-{index}")
if key:
keys.append(key)
return keys
import logging
def load_unseal_keys(options: BootstrapOptions, secrets: SecretStore) -> list[str]:
keys: list[str] = []
for index in range(1, options.key_shares + 1):
key = secrets.get(f"unseal-{index}")
if key:
keys.append(key)
if len(keys) < options.key_shares:
logging.warning(
"Loaded %d unseal keys, but expected %d. Some keys may be missing.",
len(keys),
options.key_shares,
)
return keys

Comment on lines +291 to +292
for key in keys[: options.key_threshold]:
runner.run("vault", "operator", "unseal", key, env=env)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Unseal process does not handle errors from vault operator unseal.

Check the result of each unseal command and report or abort on failure to improve reliability.

Suggested change
for key in keys[: options.key_threshold]:
runner.run("vault", "operator", "unseal", key, env=env)
for key in keys[: options.key_threshold]:
result = runner.run("vault", "operator", "unseal", key, env=env)
if hasattr(result, "returncode"):
if result.returncode != 0:
raise RuntimeError(
f"Vault unseal failed for key: {key!r} with exit code {result.returncode}.\n"
f"Output: {getattr(result, 'stdout', '')}\nError: {getattr(result, 'stderr', '')}"
)
elif result is False or result is None:
raise RuntimeError(
f"Vault unseal failed for key: {key!r}. Command runner returned {result!r}."
)

mounts = json.loads(runner.run("vault", "secrets", "list", "-format=json", env=env))
mount_path = f"{options.mount_path}/"
current = mounts.get(mount_path)
if current and current.get("type") == "kv" and current.get("options", {}).get("version") == "2":
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: KV mount check may not handle partial or misconfigured mounts.

Warn or handle cases where an existing mount is present but misconfigured, as enabling a new mount may leave the old one and cause confusion.

Comment on lines +28 to +37
def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
registry = CommandRegistry()
doctl = registry.create("doctl")
vault = registry.create("vault")
ssh = registry.create("ssh")
runner = CommandRunner(local_module=registry.local_proxy)

options = make_options()

doctl.queue(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding tests for error conditions during Vault initialisation and configuration.

Include test cases for scenarios where Vault commands fail, such as inactive systemd units, failed initialisation, or unexpected secrets manager responses, to verify error handling.

Suggested implementation:

def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
    registry = CommandRegistry()
    doctl = registry.create("doctl")
    vault = registry.create("vault")
    ssh = registry.create("ssh")
    runner = CommandRunner(local_module=registry.local_proxy)

    options = make_options()

    doctl.queue(
        "compute",

def test_bootstrap_vault_systemd_inactive(tmp_path, mocker) -> None:
    registry = CommandRegistry()
    vault = registry.create("vault")
    runner = CommandRunner(local_module=registry.local_proxy)

    # Simulate systemd unit inactive
    mocker.patch.object(vault, "status", return_value="inactive")
    options = make_options()

    with pytest.raises(RuntimeError, match="Vault systemd unit is inactive"):
        # Replace with actual bootstrap call that checks systemd status
        bootstrap_vault(options, runner, vault)

def test_bootstrap_vault_initialisation_failure(tmp_path, mocker) -> None:
    registry = CommandRegistry()
    vault = registry.create("vault")
    runner = CommandRunner(local_module=registry.local_proxy)

    # Simulate vault init command failure
    mocker.patch.object(vault, "init", side_effect=Exception("Vault initialisation failed"))
    options = make_options()

    with pytest.raises(Exception, match="Vault initialisation failed"):
        # Replace with actual bootstrap call that initialises vault
        bootstrap_vault(options, runner, vault)

def test_bootstrap_vault_secrets_manager_error(tmp_path, mocker) -> None:
    registry = CommandRegistry()
    vault = registry.create("vault")
    runner = CommandRunner(local_module=registry.local_proxy)

    # Simulate secrets manager error
    mocker.patch("scripts.bootstrap_vault_appliance.SecretsManager.store", side_effect=Exception("Secrets manager error"))
    options = make_options()

    with pytest.raises(Exception, match="Secrets manager error"):
        # Replace with actual bootstrap call that stores secrets
        bootstrap_vault(options, runner, vault)
  • You may need to import pytest and mocker if not already present.
  • Replace bootstrap_vault(options, runner, vault) with the actual function that performs the bootstrap logic in your codebase.
  • Adjust the error messages and mocks to match your implementation details.
  • Ensure that the error handling in your bootstrap logic raises the expected exceptions.

Comment on lines +310 to +319
def test_bootstrap_aborts_when_unseal_keys_missing() -> None:
registry = CommandRegistry()
doctl = registry.create("doctl")
vault = registry.create("vault")
ssh = registry.create("ssh")
runner = CommandRunner(local_module=registry.local_proxy)

options = replace(make_options(), vault_address=None)

doctl.queue(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for missing root token when Vault is initialized.

Please also add a test for the scenario where the root token is missing after Vault initialization to verify correct error handling.

ca_cert_path: str | None = None


class CommandRunner:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider removing the CommandRunner class and using simple helper functions with partial binding to reduce mental overhead and code complexity.

The indirection from `CommandRunner` + per‐call `env` + tiny one‐off helpers adds a lot of mental overhead. You can collapse all of that by:

1. Dropping `CommandRunner` entirely and using a single helper (or two)  
2. Binding `vault`/`doctl` + their shared `env` once via `functools.partial`  
3. Inlining the few JSON parses and error checks

For example, at the top of your file replace:

```python
class CommandRunner:
    def __init__(self, local_module=local):
        self._local = local_module
    def run(self, command: str, *args: str, env=None) -> str:
        cmd = self._local[command]
        if env: cmd = cmd.with_env(**env)
        if args: cmd = cmd[args]
        return cmd()

with:

import subprocess
from functools import partial

def run(cmd: list[str], env: dict[str,str] | None = None) -> str:
    result = subprocess.run(cmd, capture_output=True, text=True, check=True, env=env)
    return result.stdout

# bind once in `bootstrap()`:
vault_env = { "VAULT_ADDR": address, **({"VAULT_CACERT": ca_cert} if ca_cert else {}) }
vault = partial(run, ["vault"], env=vault_env)
doctl = partial(run, ["doctl"])

Then your calls become:

# before
status = runner.run("ssh", f"{user}@{ip}", "sudo", "systemctl", "is-active", "vault")

# after
status = vault(["ssh", f"{user}@{ip}", "sudo", "systemctl", "is-active", "vault"]).strip()

# before
mounts = json.loads(runner.run("vault", "secrets", "list", "-format=json", env=env))

# after
mounts = json.loads(vault(["secrets", "list", "-format=json"]))

— deleting CommandRunner, removing per‐call env args, and collapsing SecretStore methods into a couple of free functions will shave off hundreds of lines and flatten nesting without losing any functionality.

return MockCommandInvocation(self, values)

def with_env(self, **env: str) -> MockCommandInvocation:
return MockCommandInvocation(self, tuple(), env)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Replace tuple() with () (tuple-literal)

Suggested change
return MockCommandInvocation(self, tuple(), env)
return MockCommandInvocation(self, (), env)


ExplanationThe most concise and Pythonic way to create an empty tuple is to use the ()
literal.

This fits in with the way we create tuples with items, saving a bit of
mental energy that might be taken up with thinking about two different ways of
creating tuples:

x = ("first", "second")

Doing things this way has the added advantage of being a nice little performance
improvement. Here are the timings before and after the change:

$ python3 -m timeit "tuple()"
10000000 loops, best of 5: 22.6 nsec per loop
$ python3 -m timeit "()"
50000000 loops, best of 5: 5.46 nsec per loop

Comment on lines +275 to +276
key = secrets.get(f"unseal-{index}")
if key:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use named expression to simplify assignment and conditional (use-named-expression)

Suggested change
key = secrets.get(f"unseal-{index}")
if key:
if key := secrets.get(f"unseal-{index}"):

Comment on lines +41 to +42
merged = dict(self._env)
merged.update(env)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Merge dictionary updates via the union operator [×2] (dict-assign-update-to-union)

Suggested change
merged = dict(self._env)
merged.update(env)
merged = dict(self._env) | env

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 24

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8dc7346 and f89f07a.

📒 Files selected for processing (7)
  • docs/vault-appliance-module-design.md (1 hunks)
  • docs/vault-applience-bootstrap-process.md (1 hunks)
  • scripts/__init__.py (1 hunks)
  • scripts/bootstrap_vault_appliance.py (1 hunks)
  • scripts/tests/_vendor/cmd_mox/__init__.py (1 hunks)
  • scripts/tests/conftest.py (1 hunks)
  • scripts/tests/test_bootstrap_vault_appliance.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.py

⚙️ CodeRabbit configuration file

**/*.py: - Keep cyclomatic complexity ≤ 12

  • Follow single responsibility and CQRS (command/query segregation)
  • Docstrings must follow the numpy style guide. Use a single-line summary for private functions and methods, and full structured docs for all public interfaces.
  • Move conditionals with >2 branches to predicate/helper functions
  • Avoid eval, exec, pickle, monkey-patching, ctypes, unsafe shell
  • Every module must begin with a triple-quoted docstring explaining its purpose, utility, and usage, including example calls if appropriate.
  • Comments and docs must follow en-GB-oxendict (-ize / -yse / -our) spelling and grammar
  • Lint suppressions:
    • Blanket # noqa, file-level skips, and categories are forbidden
    • Only narrow in-line disables (# noqa: XYZ) are permitted, and must be accompanied by FIXME: or a ticket link, and used only as a last resort.
  • Use pytest fixtures for shared setup (conftest.py or fixtures/)
  • Replace duplicate tests with @pytest.mark.parametrize
  • Prefer pytest-mock or unittest.mock for stubs/mocks
  • Use assert …, "message" over bare asserts
  • Reflect all API/behaviour changes in docs/ and update roadmap on completion
  • Files must not exceed 400 logical lines:
    • Decompose large modules into subpackages
    • Split large match/case or dispatch tables by domain and collocate with targets if appropriate
    • Move bulky data (fixtures, templates) to external files for parsing at runtime
  • Mutable defaults and shadowed built-ins are forbidden
  • All code must have clear type hints using modern style (A | B, list[str], class Foo[A]:, type Bar = int, etc.), with ABC imports drawn from the correct stdlib module.
  • All path manipulation must be performed using pathlib for cross platform safety. Do not use string manipulation for extraction of path components or filename elements.

Files:

  • scripts/__init__.py
  • scripts/tests/conftest.py
  • scripts/tests/_vendor/cmd_mox/__init__.py
  • scripts/tests/test_bootstrap_vault_appliance.py
  • scripts/bootstrap_vault_appliance.py
docs/**/*.md

📄 CodeRabbit inference engine (docs/documentation-style-guide.md)

docs/**/*.md: Use British English based on the Oxford English Dictionary (en-oxendict) in documentation
The word “outwith” is acceptable in documentation
Use the Oxford comma
Treat company names as collective nouns (e.g., “Lille Industries are…”)
Write headings in sentence case
Use Markdown headings (#, ##, ###, …) in order without skipping levels
Follow markdownlint recommendations
Provide code blocks and lists using standard Markdown syntax
Always provide a language identifier for fenced code blocks; use plaintext for non-code text
Use '-' as the first-level bullet and renumber lists when items change
Prefer inline links using text or angle-bracketed URLs
Ensure blank lines before and after bulleted lists and fenced code blocks
Ensure tables have a delimiter line below the header row
Expand any uncommon acronym on first use (e.g., Continuous Integration (CI))
Wrap paragraphs at 80 columns
Wrap code at 120 columns (within documentation)
Do not wrap tables
Use footnotes referenced with [^label]
Include Mermaid diagrams where they add clarity
When embedding figures, use Markdown image syntax alt text and provide brief, descriptive alt text
Add a short description before each Mermaid diagram for screen readers

docs/**/*.md: Use docs/ as the source of truth for requirements, dependencies, and architectural decisions, and proactively update it when things change
Documentation in docs/ must use en-GB-oxendict spelling and grammar (LICENSE naming is exempt)
Keep TypeScript examples in docs small and compilable (typecheck during builds); mirror production patterns

Files:

  • docs/vault-applience-bootstrap-process.md
  • docs/vault-appliance-module-design.md
docs/**/*.{md,mdx}

📄 CodeRabbit inference engine (docs/contents.md)

Documentation style guide – conventions for clear, consistent docs

Files:

  • docs/vault-applience-bootstrap-process.md
  • docs/vault-appliance-module-design.md
**/*.md

📄 CodeRabbit inference engine (AGENTS.md)

**/*.md: Validate Markdown with make markdownlint; wrap paragraphs/bullets at 80 columns, code blocks at 120, do not wrap tables/headings; use dashes for bullets and GFM footnotes
Run make fmt after documentation changes to format Markdown and fix table markup; validate Mermaid diagrams with make nixie

Files:

  • docs/vault-applience-bootstrap-process.md
  • docs/vault-appliance-module-design.md

⚙️ CodeRabbit configuration file

**/*.md: * Avoid 2nd person or 1st person pronouns ("I", "you", "we")

  • Use en-GB-oxendict (-ize / -yse / -our) spelling and grammar
  • Headings must not be wrapped.
  • Documents must start with a level 1 heading
  • Headings must correctly increase or decrease by no more than one level at a time
  • Use GitHub-flavoured Markdown style for footnotes and endnotes.
  • Numbered footnotes must be numbered by order of appearance in the document.

Files:

  • docs/vault-applience-bootstrap-process.md
  • docs/vault-appliance-module-design.md
🧬 Code graph analysis (2)
scripts/tests/test_bootstrap_vault_appliance.py (2)
scripts/tests/_vendor/cmd_mox/__init__.py (3)
  • CommandRegistry (99-113)
  • create (104-107)
  • queue (55-56)
scripts/bootstrap_vault_appliance.py (4)
  • BootstrapOptions (29-42)
  • CommandRunner (45-57)
  • bootstrap (372-405)
  • get (70-92)
scripts/bootstrap_vault_appliance.py (1)
scripts/tests/_vendor/cmd_mox/__init__.py (2)
  • with_env (40-43)
  • with_env (85-86)
⏰ Context from checks skipped due to timeout of 120000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Sourcery review
🔇 Additional comments (9)
scripts/__init__.py (1)

1-3: LGTM!

The package initialiser correctly establishes the scripts namespace with a clear module docstring and future annotations import.

docs/vault-applience-bootstrap-process.md (1)

3-87: LGTM!

The documentation clearly explains the bootstrap process, prerequisites, running instructions, secrets management, and troubleshooting. The structure, language, and formatting follow the guidelines correctly.

docs/vault-appliance-module-design.md (2)

59-72: LGTM!

The bootstrap helper strategy documentation is clear, comprehensive, and follows en-GB-oxendict spelling and grammar conventions. The structure and content align well with the implementation details.


59-72: Correct the American spelling.

Line 59 contains "behavior" which should be "behaviour" to follow en-GB-oxendict spelling conventions.

As per coding guidelines.

Apply this diff:

-- **Bootstrap helper strategy (2024-12-06).** The Python bootstrapper resolves
-  the appliance IP via DigitalOcean tags before touching Vault. It records the
-  generated root token and unseal shares in DigitalOcean Secrets Manager using
-  a configurable prefix so repeated runs converge without manual data capture.
-  Subsequent invocations read the stored material, unseal Vault only when
-  necessary, and ensure the `secret/` KV v2 engine plus the `doks` AppRole exist
-  with deterministic TTLs. The helper writes the AppRole policy from a
-  temporary file to sidestep shell heredocs and stores the resulting role and
-  secret identifiers alongside the unseal keys for reuse by CI workflows. Tests
-  exercise happy and unhappy paths via `cmd-mox` backed command mocks to
-  validate DigitalOcean, Vault, and SSH interactions without hitting live APIs.
-  Operators can override the Vault address when routing through the managed
-  load balancer and supply a custom CA bundle so Vault CLI calls validate the
-  TLS chain without disabling verification.
+- **Bootstrap helper strategy (2024-12-06).** The Python bootstrapper resolves
+  the appliance IP via DigitalOcean tags before touching Vault. It records the
+  generated root token and unseal shares in DigitalOcean Secrets Manager using
+  a configurable prefix so repeated runs converge without manual data capture.
+  Subsequent invocations read the stored material, unseal Vault only when
+  necessary, and ensure the `secret/` KV v2 engine plus the `doks` AppRole exist
+  with deterministic TTLs. The helper writes the AppRole policy from a
+  temporary file to sidestep shell heredocs and stores the resulting role and
+  secret identifiers alongside the unseal keys for reuse by CI workflows. Tests
+  exercise happy and unhappy paths via `cmd-mox` backed command mocks to
+  validate DigitalOcean, Vault, and SSH interactions without hitting live APIs.
+  Operators can override the Vault address when routing through the managed
+  load balancer and supply a custom CA bundle so Vault CLI calls validate the
+  TLS chain without disabling verification.

Wait, I misread. Let me re-check the line. Actually, the text does not contain "behavior", I was mistaken. Let me re-examine.

Actually, looking at line 59-72, I don't see "behavior" in the provided text. The word "idempotent" appears on line 12 in the context, but the changed lines 59-72 do not contain "behavior". Let me withdraw that comment.

scripts/tests/test_bootstrap_vault_appliance.py (3)

28-176: LGTM!

The test correctly validates the full bootstrap flow, including droplet discovery, Vault initialisation, unseal key provisioning, AppRole setup, secret storage, and environment propagation (VAULT_TOKEN, VAULT_ADDR). The assertions confirm that the policy write occurs exactly once with the correct environment.


178-308: LGTM!

The test correctly validates that the bootstrap flow reuses existing configuration without re-initialising Vault. The assertions confirm that no init calls are made and that the initial Vault interaction applies VAULT_ADDR and VAULT_CACERT correctly.


310-359: LGTM!

The test correctly validates that the bootstrap flow aborts with a RuntimeError when unseal keys are missing. The assertions confirm that the error message matches "Insufficient unseal keys" and that VAULT_CACERT is not set when the CA path is absent.

scripts/bootstrap_vault_appliance.py (2)

1-419: Verify that the file does not exceed 400 logical lines.

The file has 419 total lines. Ensure that the number of logical lines (excluding blank lines, comments, and docstrings) does not exceed 400. If it does, decompose the module into subpackages or split functions into separate files.

As per coding guidelines.


372-405: LGTM!

The bootstrap function correctly orchestrates the entire workflow: discovering the droplet, verifying the Vault service, reading status, initialising or unsealing as needed, and ensuring the KV mount and AppRole exist. The logic is idempotent and handles both fresh installations and existing configurations appropriately.

@@ -0,0 +1,87 @@
# Vault appliance bootstrap process
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Correct the filename typo.

The filename contains "applience" but should be "appliance". Rename the file to vault-appliance-bootstrap-process.md to match the correct spelling.

🤖 Prompt for AI Agents
In docs/vault-applience-bootstrap-process.md around lines 1 to 1, the filename
contains a typo ("applience"); rename the file to
docs/vault-appliance-bootstrap-process.md so the spelling is correct and update
any references (links, TOCs, scripts, CI configs) to point to the new filename
to avoid broken links.

class CommandRunner:
"""Thin wrapper around :mod:`plumbum` for deterministic command execution."""

def __init__(self, local_module=local):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a type hint to the parameter.

The local_module parameter in CommandRunner.__init__ requires a type hint. Use Any from the typing module or a protocol if a more specific type is available.

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/bootstrap_vault_appliance.py around line 48, the __init__ signature
def __init__(self, local_module=local): is missing a type hint for local_module;
update the parameter to include a type (e.g., local_module: Any = local), add
the necessary import from typing (from typing import Any) at the top of the
file, or use a more specific protocol type if available per project conventions,
and ensure the default value remains unchanged.

Comment on lines +123 to +199
def parse_args(argv: Sequence[str] | None = None) -> BootstrapOptions:
parser = argparse.ArgumentParser(description="Bootstrap the Vault appliance")
parser.add_argument(
"--environment",
required=True,
help="Logical environment identifier (for example 'dev').",
)
parser.add_argument(
"--droplet-tag",
required=True,
help="DigitalOcean tag used to discover the Vault droplet.",
)
parser.add_argument(
"--ssh-user",
default="root",
help="SSH user with access to the appliance (default: root).",
)
parser.add_argument(
"--mount-path",
default="secret",
help="KV v2 mount point used for application secrets (default: secret).",
)
parser.add_argument(
"--approle-name",
default="doks-deployer",
help="Name of the AppRole consumed by the DOKS workflow.",
)
parser.add_argument(
"--policy-name",
default="doks-deployer",
help="Name of the policy bound to the DOKS AppRole.",
)
parser.add_argument(
"--secret-prefix",
required=True,
help="Prefix for DigitalOcean Secrets that store unseal material.",
)
parser.add_argument(
"--key-shares",
type=int,
default=5,
help="Number of unseal key shares to generate when initialising Vault.",
)
parser.add_argument(
"--key-threshold",
type=int,
default=3,
help="Number of unseal shares required to unseal Vault.",
)
parser.add_argument(
"--vault-address",
help=(
"Override the Vault API address. Defaults to the discovered droplet"
" IP on port 8200 over HTTPS."
),
)
parser.add_argument(
"--ca-cert-path",
help=(
"Optional path to the Vault certificate authority bundle. When set,"
" exported as VAULT_CACERT for CLI calls."
),
)
args = parser.parse_args(argv)
return BootstrapOptions(
environment=args.environment,
droplet_tag=args.droplet_tag,
ssh_user=args.ssh_user,
mount_path=args.mount_path.rstrip("/"),
approle_name=args.approle_name,
policy_name=args.policy_name,
secret_prefix=args.secret_prefix,
key_shares=args.key_shares,
key_threshold=args.key_threshold,
vault_address=args.vault_address.rstrip("/") if args.vault_address else None,
ca_cert_path=args.ca_cert_path,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the public function.

The parse_args function requires a structured docstring following the numpy style guide, including parameters, returns, and a summary.

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/bootstrap_vault_appliance.py around lines 123 to 199, the public
function parse_args is missing a numpy-style docstring; add a concise
numpy-style docstring immediately below the def line that includes a one-line
summary, a Parameters section documenting argv (type and purpose), a Returns
section documenting the BootstrapOptions return type and what it contains, and
(optionally) Notes or Raises if relevant; keep wording brief and accurate and
follow numpy formatting conventions so the function is properly documented for
users and tooling.

Comment on lines +202 to +223
def discover_droplet_ip(options: BootstrapOptions, runner: CommandRunner) -> str:
output = runner.run(
"doctl",
"compute",
"droplet",
"list",
"--tag-name",
options.droplet_tag,
"--format",
"PublicIPv4",
"--no-header",
)
addresses = [line.strip() for line in output.splitlines() if line.strip()]
if not addresses:
raise RuntimeError(
f"No droplets tagged '{options.droplet_tag}' found in DigitalOcean."
)
if len(addresses) > 1:
raise RuntimeError(
"Multiple droplets matched the Vault tag; aborting to avoid ambiguity."
)
return addresses[0]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the public function.

The discover_droplet_ip function requires a structured docstring following the numpy style guide, including parameters, returns, raises, and a summary.

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/bootstrap_vault_appliance.py around lines 202 to 223, add a
numpy-style docstring to the public function discover_droplet_ip describing what
the function does, its parameters (options: BootstrapOptions, runner:
CommandRunner), the return value (str — the discovered public IPv4 address), and
the exceptions raised (RuntimeError when no droplets found or when multiple
matches exist); keep the docstring concise with a one-line summary, a short
description if needed, and explicit "Parameters", "Returns", and "Raises"
sections formatted to follow numpy docstring conventions.

Comment on lines +226 to +237
def verify_vault_service(ip: str, options: BootstrapOptions, runner: CommandRunner) -> None:
status = runner.run(
"ssh",
f"{options.ssh_user}@{ip}",
"sudo",
"systemctl",
"is-active",
"vault",
).strip()
if status != "active":
raise RuntimeError(f"Vault systemd unit is not active (reported '{status}').")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the public function.

The verify_vault_service function requires a structured docstring following the numpy style guide, including parameters, raises, and a summary.

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/bootstrap_vault_appliance.py around lines 226 to 237, the public
function verify_vault_service is missing a numpy-style docstring; add a
docstring immediately under the def that briefly summarizes the function,
documents parameters (ip: str, options: BootstrapOptions, runner: CommandRunner)
with types, and documents raises (RuntimeError) with conditions, following numpy
docstring format (short summary line, blank line, Parameters:, Raises:). Keep it
concise and ensure types and the condition that the vault systemd unit is not
active are included.

@@ -0,0 +1,359 @@
from __future__ import annotations
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a module docstring.

Every module must begin with a triple-quoted docstring explaining its purpose, utility, and usage. Add a docstring describing that this module contains tests for the Vault appliance bootstrap script.

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/tests/test_bootstrap_vault_appliance.py at line 1, the module is
missing the required triple-quoted module docstring; add a top-of-file docstring
(as the first statement) that briefly explains that this module contains tests
for the Vault appliance bootstrap script, describing its purpose, what it tests,
and any usage notes per the coding guidelines.

Comment on lines +13 to +25
def make_options() -> BootstrapOptions:
return BootstrapOptions(
environment="dev",
droplet_tag="vault-dev",
ssh_user="root",
mount_path="secret",
approle_name="doks",
policy_name="doks",
secret_prefix="dev-vault",
key_shares=5,
key_threshold=3,
vault_address="https://vault.dev.example:8200",
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the helper function.

The make_options helper requires a docstring (single-line summary is sufficient for private functions).

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/tests/test_bootstrap_vault_appliance.py around lines 13 to 25, the
helper function make_options is missing a docstring; add a single-line docstring
immediately below the def that succinctly describes the function (e.g., "Return
a BootstrapOptions instance preconfigured for vault appliance tests"), using a
standard Python string literal directly under the function definition so the
function has an inline docstring.

Comment on lines +28 to +176
def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
registry = CommandRegistry()
doctl = registry.create("doctl")
vault = registry.create("vault")
ssh = registry.create("ssh")
runner = CommandRunner(local_module=registry.local_proxy)

options = make_options()

doctl.queue(
"compute",
"droplet",
"list",
"--tag-name",
options.droplet_tag,
"--format",
"PublicIPv4",
"--no-header",
stdout="203.0.113.10\n",
)
ssh.queue(
f"{options.ssh_user}@203.0.113.10",
"sudo",
"systemctl",
"is-active",
"vault",
stdout="active\n",
)
vault.queue(
"status",
"-format=json",
stdout=json.dumps({"initialized": False, "sealed": True}),
)
init_payload = json.dumps(
{
"unseal_keys_b64": [
"key-1",
"key-2",
"key-3",
"key-4",
"key-5",
],
"root_token": "root-token",
}
)
vault.queue(
"operator",
"init",
"-key-shares",
str(options.key_shares),
"-key-threshold",
str(options.key_threshold),
"-format=json",
stdout=init_payload,
)
for index in range(1, options.key_shares + 1):
doctl.queue(
"secrets",
"manager",
"secrets",
"create",
f"{options.secret_prefix}-unseal-{index}",
"--data",
f"key-{index}",
)
doctl.queue(
"secrets",
"manager",
"secrets",
"create",
f"{options.secret_prefix}-root-token",
"--data",
"root-token",
)
for index in range(1, options.key_threshold + 1):
vault.queue("operator", "unseal", f"key-{index}")
vault.queue(
"status",
"-format=json",
stdout=json.dumps({"initialized": True, "sealed": False}),
)
vault.queue(
"secrets",
"list",
"-format=json",
stdout=json.dumps({}),
)
vault.queue(
"secrets",
"enable",
"-path",
options.mount_path,
"kv-v2",
)
vault.queue(
"auth",
"list",
"-format=json",
stdout=json.dumps({}),
)
vault.queue("auth", "enable", "approle")
vault.queue()
vault.queue(
"write",
f"auth/approle/role/{options.approle_name}",
f"token_policies={options.policy_name}",
"secret_id_ttl=24h",
"token_ttl=1h",
"token_max_ttl=4h",
)
vault.queue(
"read",
"-field=role_id",
f"auth/approle/role/{options.approle_name}/role-id",
stdout="role-123\n",
)
vault.queue(
"write",
"-f",
"-field=secret_id",
f"auth/approle/role/{options.approle_name}/secret-id",
stdout="secret-456\n",
)
doctl.queue(
"secrets",
"manager",
"secrets",
"create",
f"{options.secret_prefix}-role-id",
"--data",
"role-123",
)
doctl.queue(
"secrets",
"manager",
"secrets",
"create",
f"{options.secret_prefix}-secret-id",
"--data",
"secret-456",
)

bootstrap(options, runner=runner)

policy_calls = [call for call in vault.calls if call.args[:2] == ("policy", "write")]
assert len(policy_calls) == 1
assert policy_calls[0].env.get("VAULT_TOKEN") == "root-token"
assert policy_calls[0].env.get("VAULT_ADDR") == options.vault_address

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the test function.

The test function requires a docstring explaining what behaviour it validates (full bootstrap flow including initialisation, unsealing, KV mount, AppRole setup, and environment propagation).

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/tests/test_bootstrap_vault_appliance.py around lines 28 to 176, the
test function test_bootstrap_initialises_and_configures_vault is missing a
docstring; add a concise docstring immediately under the def line describing
what the test validates (the full bootstrap flow: Vault initialization,
unsealing, KV mount, AppRole setup, secret storage in Doctl, and propagation of
VAULT_TOKEN/VAULT_ADDR to commands) — one short sentence or a brief two-line
description is sufficient and should follow the project's docstring style.

Comment on lines +178 to +308
def test_bootstrap_reuses_existing_configuration() -> None:
registry = CommandRegistry()
doctl = registry.create("doctl")
vault = registry.create("vault")
ssh = registry.create("ssh")
runner = CommandRunner(local_module=registry.local_proxy)

options = replace(make_options(), ca_cert_path="/tmp/vault-ca.pem")

doctl.queue(
"compute",
"droplet",
"list",
"--tag-name",
options.droplet_tag,
"--format",
"PublicIPv4",
"--no-header",
stdout="203.0.113.10\n",
)
ssh.queue(
f"{options.ssh_user}@203.0.113.10",
"sudo",
"systemctl",
"is-active",
"vault",
stdout="active\n",
)
vault.queue(
"status",
"-format=json",
stdout=json.dumps({"initialized": True, "sealed": False}),
)
doctl.queue(
"secrets",
"manager",
"secrets",
"get",
f"{options.secret_prefix}-root-token",
"--output",
"json",
stdout=json.dumps({"secret": {"value": "root-token"}}),
)
vault.queue(
"status",
"-format=json",
stdout=json.dumps({"initialized": True, "sealed": False}),
)
vault.queue(
"secrets",
"list",
"-format=json",
stdout=json.dumps(
{"secret/": {"type": "kv", "options": {"version": "2"}}}
),
)
vault.queue(
"auth",
"list",
"-format=json",
stdout=json.dumps({"approle/": {}}),
)
vault.queue()
vault.queue(
"write",
f"auth/approle/role/{options.approle_name}",
f"token_policies={options.policy_name}",
"secret_id_ttl=24h",
"token_ttl=1h",
"token_max_ttl=4h",
)
vault.queue(
"read",
"-field=role_id",
f"auth/approle/role/{options.approle_name}/role-id",
stdout="role-abc\n",
)
vault.queue(
"write",
"-f",
"-field=secret_id",
f"auth/approle/role/{options.approle_name}/secret-id",
stdout="secret-xyz\n",
)
doctl.queue(
"secrets",
"manager",
"secrets",
"create",
f"{options.secret_prefix}-role-id",
"--data",
"role-abc",
exit_code=10,
)
doctl.queue(
"secrets",
"manager",
"secrets",
"update",
f"{options.secret_prefix}-role-id",
"--data",
"role-abc",
)
doctl.queue(
"secrets",
"manager",
"secrets",
"create",
f"{options.secret_prefix}-secret-id",
"--data",
"secret-xyz",
exit_code=10,
)
doctl.queue(
"secrets",
"manager",
"secrets",
"update",
f"{options.secret_prefix}-secret-id",
"--data",
"secret-xyz",
)

bootstrap(options, runner=runner)

init_calls = [call for call in vault.calls if call.args[:2] == ("operator", "init")]
assert not init_calls
first_call = vault.calls[0]
assert first_call.env.get("VAULT_ADDR") == options.vault_address
assert first_call.env.get("VAULT_CACERT") == "/tmp/vault-ca.pem"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the test function.

The test function requires a docstring explaining what behaviour it validates (reusing existing configuration without re-initialising Vault, and propagating CA certificate path).

As per coding guidelines.

🤖 Prompt for AI Agents
In scripts/tests/test_bootstrap_vault_appliance.py around lines 178 to 308, the
test function test_bootstrap_reuses_existing_configuration() is missing a
docstring; add a concise one-line docstring at the top of the function
(immediately under the def) that states the behaviour being validated (that
existing Vault configuration is reused, Vault is not re-initialised, and the CA
cert path is propagated to VAULT_CACERT).

Comment on lines +310 to +359
def test_bootstrap_aborts_when_unseal_keys_missing() -> None:
registry = CommandRegistry()
doctl = registry.create("doctl")
vault = registry.create("vault")
ssh = registry.create("ssh")
runner = CommandRunner(local_module=registry.local_proxy)

options = replace(make_options(), vault_address=None)

doctl.queue(
"compute",
"droplet",
"list",
"--tag-name",
options.droplet_tag,
"--format",
"PublicIPv4",
"--no-header",
stdout="203.0.113.10\n",
)
ssh.queue(
f"{options.ssh_user}@203.0.113.10",
"sudo",
"systemctl",
"is-active",
"vault",
stdout="active\n",
)
vault.queue(
"status",
"-format=json",
stdout=json.dumps({"initialized": True, "sealed": True}),
)
for index in range(1, options.key_shares + 1):
doctl.queue(
"secrets",
"manager",
"secrets",
"get",
f"{options.secret_prefix}-unseal-{index}",
"--output",
"json",
exit_code=1,
)

with pytest.raises(RuntimeError, match="Insufficient unseal keys"):
bootstrap(options, runner=runner)

assert vault.calls[0].env.get("VAULT_ADDR") == "https://203.0.113.10:8200"
assert "VAULT_CACERT" not in vault.calls[0].env
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a docstring to the test function.

The test function requires a docstring explaining what behaviour it validates (aborting when unseal keys are missing and not setting VAULT_CACERT when CA path is absent).

As per coding guidelines.

🤖 Prompt for AI Agents
scripts/tests/test_bootstrap_vault_appliance.py around lines 310 to 359: the
test function lacks a docstring; add a short docstring immediately under the def
line that concisely states what the test validates (that bootstrap aborts when
unseal keys are missing and that VAULT_CACERT is not set when no CA path is
provided). Keep it brief (one or two sentences) and follow the project's
docstring style.

@leynos
Copy link
Copy Markdown
Owner Author

leynos commented Oct 3, 2025

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

scripts/tests/test_bootstrap_vault_appliance.py

Comment on lines +178 to +307

def test_bootstrap_reuses_existing_configuration() -> None:
    registry = CommandRegistry()
    doctl = registry.create("doctl")
    vault = registry.create("vault")
    ssh = registry.create("ssh")
    runner = CommandRunner(local_module=registry.local_proxy)

    options = replace(make_options(), ca_cert_path="/tmp/vault-ca.pem")

    doctl.queue(
        "compute",
        "droplet",
        "list",
        "--tag-name",
        options.droplet_tag,
        "--format",
        "PublicIPv4",
        "--no-header",
        stdout="203.0.113.10\n",
    )
    ssh.queue(
        f"{options.ssh_user}@203.0.113.10",
        "sudo",
        "systemctl",
        "is-active",
        "vault",
        stdout="active\n",
    )
    vault.queue(
        "status",
        "-format=json",
        stdout=json.dumps({"initialized": True, "sealed": False}),
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "get",
        f"{options.secret_prefix}-root-token",
        "--output",
        "json",
        stdout=json.dumps({"secret": {"value": "root-token"}}),
    )
    vault.queue(
        "status",
        "-format=json",
        stdout=json.dumps({"initialized": True, "sealed": False}),
    )
    vault.queue(
        "secrets",
        "list",
        "-format=json",
        stdout=json.dumps(
            {"secret/": {"type": "kv", "options": {"version": "2"}}}
        ),
    )
    vault.queue(
        "auth",
        "list",
        "-format=json",
        stdout=json.dumps({"approle/": {}}),
    )
    vault.queue()
    vault.queue(
        "write",
        f"auth/approle/role/{options.approle_name}",
        f"token_policies={options.policy_name}",
        "secret_id_ttl=24h",
        "token_ttl=1h",
        "token_max_ttl=4h",
    )
    vault.queue(
        "read",
        "-field=role_id",
        f"auth/approle/role/{options.approle_name}/role-id",
        stdout="role-abc\n",
    )
    vault.queue(
        "write",
        "-f",
        "-field=secret_id",
        f"auth/approle/role/{options.approle_name}/secret-id",
        stdout="secret-xyz\n",
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "create",
        f"{options.secret_prefix}-role-id",
        "--data",
        "role-abc",
        exit_code=10,
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "update",
        f"{options.secret_prefix}-role-id",
        "--data",
        "role-abc",
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "create",
        f"{options.secret_prefix}-secret-id",
        "--data",
        "secret-xyz",
        exit_code=10,
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "update",
        f"{options.secret_prefix}-secret-id",
        "--data",
        "secret-xyz",
    )

    bootstrap(options, runner=runner)

    init_calls = [call for call in vault.calls if call.args[:2] == ("operator", "init")]
    assert not init_calls
    first_call = vault.calls[0]
    assert first_call.env.get("VAULT_ADDR") == options.vault_address
    assert first_call.env.get("VAULT_CACERT") == "/tmp/vault-ca.pem"

❌ New issue: Large Method
test_bootstrap_reuses_existing_configuration has 126 lines, threshold = 70

@leynos
Copy link
Copy Markdown
Owner Author

leynos commented Oct 3, 2025

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

scripts/tests/test_bootstrap_vault_appliance.py

Comment on lines +28 to +175

def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
    registry = CommandRegistry()
    doctl = registry.create("doctl")
    vault = registry.create("vault")
    ssh = registry.create("ssh")
    runner = CommandRunner(local_module=registry.local_proxy)

    options = make_options()

    doctl.queue(
        "compute",
        "droplet",
        "list",
        "--tag-name",
        options.droplet_tag,
        "--format",
        "PublicIPv4",
        "--no-header",
        stdout="203.0.113.10\n",
    )
    ssh.queue(
        f"{options.ssh_user}@203.0.113.10",
        "sudo",
        "systemctl",
        "is-active",
        "vault",
        stdout="active\n",
    )
    vault.queue(
        "status",
        "-format=json",
        stdout=json.dumps({"initialized": False, "sealed": True}),
    )
    init_payload = json.dumps(
        {
            "unseal_keys_b64": [
                "key-1",
                "key-2",
                "key-3",
                "key-4",
                "key-5",
            ],
            "root_token": "root-token",
        }
    )
    vault.queue(
        "operator",
        "init",
        "-key-shares",
        str(options.key_shares),
        "-key-threshold",
        str(options.key_threshold),
        "-format=json",
        stdout=init_payload,
    )
    for index in range(1, options.key_shares + 1):
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "create",
            f"{options.secret_prefix}-unseal-{index}",
            "--data",
            f"key-{index}",
        )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "create",
        f"{options.secret_prefix}-root-token",
        "--data",
        "root-token",
    )
    for index in range(1, options.key_threshold + 1):
        vault.queue("operator", "unseal", f"key-{index}")
    vault.queue(
        "status",
        "-format=json",
        stdout=json.dumps({"initialized": True, "sealed": False}),
    )
    vault.queue(
        "secrets",
        "list",
        "-format=json",
        stdout=json.dumps({}),
    )
    vault.queue(
        "secrets",
        "enable",
        "-path",
        options.mount_path,
        "kv-v2",
    )
    vault.queue(
        "auth",
        "list",
        "-format=json",
        stdout=json.dumps({}),
    )
    vault.queue("auth", "enable", "approle")
    vault.queue()
    vault.queue(
        "write",
        f"auth/approle/role/{options.approle_name}",
        f"token_policies={options.policy_name}",
        "secret_id_ttl=24h",
        "token_ttl=1h",
        "token_max_ttl=4h",
    )
    vault.queue(
        "read",
        "-field=role_id",
        f"auth/approle/role/{options.approle_name}/role-id",
        stdout="role-123\n",
    )
    vault.queue(
        "write",
        "-f",
        "-field=secret_id",
        f"auth/approle/role/{options.approle_name}/secret-id",
        stdout="secret-456\n",
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "create",
        f"{options.secret_prefix}-role-id",
        "--data",
        "role-123",
    )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "create",
        f"{options.secret_prefix}-secret-id",
        "--data",
        "secret-456",
    )

    bootstrap(options, runner=runner)

    policy_calls = [call for call in vault.calls if call.args[:2] == ("policy", "write")]
    assert len(policy_calls) == 1
    assert policy_calls[0].env.get("VAULT_TOKEN") == "root-token"
    assert policy_calls[0].env.get("VAULT_ADDR") == options.vault_address

❌ New issue: Large Method
test_bootstrap_initialises_and_configures_vault has 144 lines, threshold = 70

@leynos
Copy link
Copy Markdown
Owner Author

leynos commented Oct 3, 2025

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

scripts/bootstrap_vault_appliance.py

Comment on lines +123 to +199

def parse_args(argv: Sequence[str] | None = None) -> BootstrapOptions:
    parser = argparse.ArgumentParser(description="Bootstrap the Vault appliance")
    parser.add_argument(
        "--environment",
        required=True,
        help="Logical environment identifier (for example 'dev').",
    )
    parser.add_argument(
        "--droplet-tag",
        required=True,
        help="DigitalOcean tag used to discover the Vault droplet.",
    )
    parser.add_argument(
        "--ssh-user",
        default="root",
        help="SSH user with access to the appliance (default: root).",
    )
    parser.add_argument(
        "--mount-path",
        default="secret",
        help="KV v2 mount point used for application secrets (default: secret).",
    )
    parser.add_argument(
        "--approle-name",
        default="doks-deployer",
        help="Name of the AppRole consumed by the DOKS workflow.",
    )
    parser.add_argument(
        "--policy-name",
        default="doks-deployer",
        help="Name of the policy bound to the DOKS AppRole.",
    )
    parser.add_argument(
        "--secret-prefix",
        required=True,
        help="Prefix for DigitalOcean Secrets that store unseal material.",
    )
    parser.add_argument(
        "--key-shares",
        type=int,
        default=5,
        help="Number of unseal key shares to generate when initialising Vault.",
    )
    parser.add_argument(
        "--key-threshold",
        type=int,
        default=3,
        help="Number of unseal shares required to unseal Vault.",
    )
    parser.add_argument(
        "--vault-address",
        help=(
            "Override the Vault API address. Defaults to the discovered droplet"
            " IP on port 8200 over HTTPS."
        ),
    )
    parser.add_argument(
        "--ca-cert-path",
        help=(
            "Optional path to the Vault certificate authority bundle. When set,"
            " exported as VAULT_CACERT for CLI calls."
        ),
    )
    args = parser.parse_args(argv)
    return BootstrapOptions(
        environment=args.environment,
        droplet_tag=args.droplet_tag,
        ssh_user=args.ssh_user,
        mount_path=args.mount_path.rstrip("/"),
        approle_name=args.approle_name,
        policy_name=args.policy_name,
        secret_prefix=args.secret_prefix,
        key_shares=args.key_shares,
        key_threshold=args.key_threshold,
        vault_address=args.vault_address.rstrip("/") if args.vault_address else None,
        ca_cert_path=args.ca_cert_path,
    )

❌ New issue: Large Method
parse_args has 77 lines, threshold = 70

@leynos
Copy link
Copy Markdown
Owner Author

leynos commented Oct 3, 2025

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

scripts/bootstrap_vault_appliance.py

Comment on file

    mounts = json.loads(runner.run("vault", "secrets", "list", "-format=json", env=env))
    mount_path = f"{options.mount_path}/"
    current = mounts.get(mount_path)
    if current and current.get("type") == "kv" and current.get("options", {}).get("version") == "2":

❌ New issue: Complex Conditional
ensure_kv_mount has 1 complex conditionals with 2 branches, threshold = 2

@coderabbitai

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the full test file to understand the structure
cat scripts/tests/test_bootstrap_vault_appliance.py

Length of output: 8885


@leynos, I'll suggest a fix for both large test methods by extracting repetitive command queuing into helper functions. This will reduce duplication and bring both tests under the 70-line threshold.

Suggested Fix

Extract common setup and command queuing patterns into helper functions:

from __future__ import annotations

import json
from dataclasses import replace

import pytest

from cmd_mox import CommandRegistry, MockCommand

from scripts.bootstrap_vault_appliance import BootstrapOptions, CommandRunner, bootstrap


def make_options() -> BootstrapOptions:
    return BootstrapOptions(
        environment="dev",
        droplet_tag="vault-dev",
        ssh_user="root",
        mount_path="secret",
        approle_name="doks",
        policy_name="doks",
        secret_prefix="dev-vault",
        key_shares=5,
        key_threshold=3,
        vault_address="https://vault.dev.example:8200",
    )


def setup_test_environment() -> tuple[CommandRegistry, MockCommand, MockCommand, MockCommand, CommandRunner]:
    """Set up test environment with mocked commands."""
    registry = CommandRegistry()
    doctl = registry.create("doctl")
    vault = registry.create("vault")
    ssh = registry.create("ssh")
    runner = CommandRunner(local_module=registry.local_proxy)
    return registry, doctl, vault, ssh, runner


def queue_droplet_discovery(doctl: MockCommand, options: BootstrapOptions, ip: str = "203.0.113.10") -> None:
    """Queue droplet discovery command."""
    doctl.queue(
        "compute",
        "droplet",
        "list",
        "--tag-name",
        options.droplet_tag,
        "--format",
        "PublicIPv4",
        "--no-header",
        stdout=f"{ip}\n",
    )


def queue_ssh_verification(ssh: MockCommand, options: BootstrapOptions, ip: str = "203.0.113.10") -> None:
    """Queue SSH service verification command."""
    ssh.queue(
        f"{options.ssh_user}@{ip}",
        "sudo",
        "systemctl",
        "is-active",
        "vault",
        stdout="active\n",
    )


def queue_vault_status(vault: MockCommand, initialized: bool, sealed: bool) -> None:
    """Queue vault status check command."""
    vault.queue(
        "status",
        "-format=json",
        stdout=json.dumps({"initialized": initialized, "sealed": sealed}),
    )


def queue_vault_initialization(vault: MockCommand, doctl: MockCommand, options: BootstrapOptions) -> None:
    """Queue commands for vault initialisation and secret storage."""
    init_payload = json.dumps(
        {
            "unseal_keys_b64": [f"key-{i}" for i in range(1, options.key_shares + 1)],
            "root_token": "root-token",
        }
    )
    vault.queue(
        "operator",
        "init",
        "-key-shares",
        str(options.key_shares),
        "-key-threshold",
        str(options.key_threshold),
        "-format=json",
        stdout=init_payload,
    )
    for index in range(1, options.key_shares + 1):
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "create",
            f"{options.secret_prefix}-unseal-{index}",
            "--data",
            f"key-{index}",
        )
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "create",
        f"{options.secret_prefix}-root-token",
        "--data",
        "root-token",
    )


def queue_vault_unsealing(vault: MockCommand, options: BootstrapOptions) -> None:
    """Queue vault unsealing commands."""
    for index in range(1, options.key_threshold + 1):
        vault.queue("operator", "unseal", f"key-{index}")
    queue_vault_status(vault, initialized=True, sealed=False)


def queue_kv_mount_setup(vault: MockCommand, options: BootstrapOptions, exists: bool = False) -> None:
    """Queue KV mount setup commands."""
    if exists:
        vault.queue(
            "secrets",
            "list",
            "-format=json",
            stdout=json.dumps(
                {"secret/": {"type": "kv", "options": {"version": "2"}}}
            ),
        )
    else:
        vault.queue(
            "secrets",
            "list",
            "-format=json",
            stdout=json.dumps({}),
        )
        vault.queue(
            "secrets",
            "enable",
            "-path",
            options.mount_path,
            "kv-v2",
        )


def queue_approle_setup(
    vault: MockCommand,
    doctl: MockCommand,
    options: BootstrapOptions,
    role_id: str,
    secret_id: str,
    exists: bool = False,
    update_mode: bool = False,
) -> None:
    """Queue AppRole setup commands."""
    if exists:
        vault.queue(
            "auth",
            "list",
            "-format=json",
            stdout=json.dumps({"approle/": {}}),
        )
    else:
        vault.queue(
            "auth",
            "list",
            "-format=json",
            stdout=json.dumps({}),
        )
        vault.queue("auth", "enable", "approle")
    
    vault.queue()  # Policy write placeholder
    vault.queue(
        "write",
        f"auth/approle/role/{options.approle_name}",
        f"token_policies={options.policy_name}",
        "secret_id_ttl=24h",
        "token_ttl=1h",
        "token_max_ttl=4h",
    )
    vault.queue(
        "read",
        "-field=role_id",
        f"auth/approle/role/{options.approle_name}/role-id",
        stdout=f"{role_id}\n",
    )
    vault.queue(
        "write",
        "-f",
        "-field=secret_id",
        f"auth/approle/role/{options.approle_name}/secret-id",
        stdout=f"{secret_id}\n",
    )
    
    if update_mode:
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "create",
            f"{options.secret_prefix}-role-id",
            "--data",
            role_id,
            exit_code=10,
        )
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "update",
            f"{options.secret_prefix}-role-id",
            "--data",
            role_id,
        )
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "create",
            f"{options.secret_prefix}-secret-id",
            "--data",
            secret_id,
            exit_code=10,
        )
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "update",
            f"{options.secret_prefix}-secret-id",
            "--data",
            secret_id,
        )
    else:
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "create",
            f"{options.secret_prefix}-role-id",
            "--data",
            role_id,
        )
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "create",
            f"{options.secret_prefix}-secret-id",
            "--data",
            secret_id,
        )


def queue_root_token_retrieval(doctl: MockCommand, options: BootstrapOptions, token: str = "root-token") -> None:
    """Queue root token retrieval command."""
    doctl.queue(
        "secrets",
        "manager",
        "secrets",
        "get",
        f"{options.secret_prefix}-root-token",
        "--output",
        "json",
        stdout=json.dumps({"secret": {"value": token}}),
    )


def test_bootstrap_initialises_and_configures_vault(tmp_path) -> None:
    registry, doctl, vault, ssh, runner = setup_test_environment()
    options = make_options()

    queue_droplet_discovery(doctl, options)
    queue_ssh_verification(ssh, options)
    queue_vault_status(vault, initialized=False, sealed=True)
    queue_vault_initialization(vault, doctl, options)
    queue_vault_unsealing(vault, options)
    queue_kv_mount_setup(vault, options, exists=False)
    queue_approle_setup(vault, doctl, options, "role-123", "secret-456", exists=False)

    bootstrap(options, runner=runner)

    policy_calls = [call for call in vault.calls if call.args[:2] == ("policy", "write")]
    assert len(policy_calls) == 1
    assert policy_calls[0].env.get("VAULT_TOKEN") == "root-token"
    assert policy_calls[0].env.get("VAULT_ADDR") == options.vault_address


def test_bootstrap_reuses_existing_configuration() -> None:
    registry, doctl, vault, ssh, runner = setup_test_environment()
    options = replace(make_options(), ca_cert_path="/tmp/vault-ca.pem")

    queue_droplet_discovery(doctl, options)
    queue_ssh_verification(ssh, options)
    queue_vault_status(vault, initialized=True, sealed=False)
    queue_root_token_retrieval(doctl, options)
    queue_vault_status(vault, initialized=True, sealed=False)
    queue_kv_mount_setup(vault, options, exists=True)
    queue_approle_setup(vault, doctl, options, "role-abc", "secret-xyz", exists=True, update_mode=True)

    bootstrap(options, runner=runner)

    init_calls = [call for call in vault.calls if call.args[:2] == ("operator", "init")]
    assert not init_calls
    first_call = vault.calls[0]
    assert first_call.env.get("VAULT_ADDR") == options.vault_address
    assert first_call.env.get("VAULT_CACERT") == "/tmp/vault-ca.pem"


def test_bootstrap_aborts_when_unseal_keys_missing() -> None:
    registry, doctl, vault, ssh, runner = setup_test_environment()
    options = replace(make_options(), vault_address=None)

    queue_droplet_discovery(doctl, options)
    queue_ssh_verification(ssh, options)
    queue_vault_status(vault, initialized=True, sealed=True)
    
    for index in range(1, options.key_shares + 1):
        doctl.queue(
            "secrets",
            "manager",
            "secrets",
            "get",
            f"{options.secret_prefix}-unseal-{index}",
            "--output",
            "json",
            exit_code=1,
        )

    with pytest.raises(RuntimeError, match="Insufficient unseal keys"):
        bootstrap(options, runner=runner)

    assert vault.calls[0].env.get("VAULT_ADDR") == "https://203.0.113.10:8200"
    assert "VAULT_CACERT" not in vault.calls[0].env

AI Coding Agent Prompt

Refactor the test file `scripts/tests/test_bootstrap_vault_appliance.py` to reduce method lengths by extracting repetitive command queuing patterns into helper functions.

**Context:**
- The file contains pytest tests for a Vault bootstrap script.
- Tests use cmd_mox's CommandRegistry to mock external commands (doctl, vault, ssh).
- Two tests exceed the 70-line threshold: `test_bootstrap_initialises_and_configures_vault` (144 lines) and `test_bootstrap_reuses_existing_configuration` (126 lines).

**Required changes:**

1. Extract common setup into `setup_test_environment()` that returns a tuple of registry, doctl, vault, ssh, and runner.

2. Create helper functions for repetitive command queuing patterns:
   - `queue_droplet_discovery(doctl, options, ip="203.0.113.10")`
   - `queue_ssh_verification(ssh, options, ip="203.0.113.10")`
   - `queue_vault_status(vault, initialized, sealed)`
   - `queue_vault_initialization(vault, doctl, options)`
   - `queue_vault_unsealing(vault, options)`
   - `queue_kv_mount_setup(vault, options, exists=False)`
   - `queue_approle_setup(vault, doctl, options, role_id, secret_id, exists=False, update_mode=False)`
   - `queue_root_token_retrieval(doctl, options, token="root-token")`

3. Refactor all three test functions to use these helpers, maintaining identical test behaviour and assertions.

4. Ensure helper functions:
   - Have type hints matching the existing code style
   - Include docstrings explaining their purpose
   - Accept parameters for values that vary between tests (e.g., IP addresses, role IDs, secret IDs)
   - Support both creation and update modes for secret operations via the `update_mode` parameter in `queue_approle_setup`

5. Maintain all existing imports and the `make_options()` helper function unchanged.

**Constraints:**
- Do not modify test assertions or expected behaviour
- Preserve the exact command arguments and responses
- Keep the same test names and signatures
- Use en-GB-oxendict spelling in docstrings
- Follow the existing code style with type hints and formatting

🐰

@leynos
Copy link
Copy Markdown
Owner Author

leynos commented Oct 3, 2025

@coderabbitai Please suggest a fix for this issue and supply a prompt for an AI coding agent to enable it to apply the fix:

scripts/bootstrap_vault_appliance.py

Comment on lines +372 to +405

def bootstrap(options: BootstrapOptions, runner: CommandRunner | None = None) -> None:
    command_runner = runner or CommandRunner()
    secrets = SecretStore(command_runner, options.secret_prefix)

    ip_address = discover_droplet_ip(options, command_runner)
    verify_vault_service(ip_address, options, command_runner)

    address = options.vault_address or f"https://{ip_address}:8200"
    vault_env: dict[str, str] = {"VAULT_ADDR": address}
    if options.ca_cert_path:
        vault_env["VAULT_CACERT"] = options.ca_cert_path
    status = read_vault_status(command_runner, vault_env)

    if not status.get("initialized", False):
        init_data = initialise_vault(options, command_runner, vault_env, secrets)
        unseal_vault(init_data["unseal_keys"], options, command_runner, vault_env)
        vault_env["VAULT_TOKEN"] = init_data["root_token"]
    else:
        if status.get("sealed", False):
            unseal_keys = load_unseal_keys(options, secrets)
            unseal_vault(unseal_keys, options, command_runner, vault_env)
        root_token = secrets.get("root-token")
        if not root_token:
            raise RuntimeError(
                "Vault is initialised but no root token is stored; cannot proceed."
            )
        vault_env["VAULT_TOKEN"] = root_token

    post_status = read_vault_status(command_runner, vault_env)
    if post_status.get("sealed", False):
        raise RuntimeError("Vault remains sealed after attempting to unseal it.")

    ensure_kv_mount(options, command_runner, vault_env)
    ensure_approle(options, command_runner, vault_env, secrets)

❌ New issue: Complex Method
bootstrap has a cyclomatic complexity of 11, threshold = 9

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 3, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the ensure_kv_mount function from the bootstrap script
ast-grep --pattern $'def ensure_kv_mount($$$) {
  $$$
}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find the ensure_kv_mount function with context
rg -A 20 "^def ensure_kv_mount" scripts/bootstrap_vault_appliance.py

Length of output: 643


@leynos: The complex conditional can be simplified by extracting the validation logic into a helper function or using intermediate variables to improve readability.

Suggested fix:

def _is_kv_v2_mount(mount_info: dict | None) -> bool:
    """Check if a mount is a KV version 2 secrets engine."""
    if not mount_info:
        return False
    if mount_info.get("type") != "kv":
        return False
    options = mount_info.get("options", {})
    return options.get("version") == "2"


def ensure_kv_mount(options: BootstrapOptions, runner: CommandRunner, env: dict[str, str]) -> None:
    mounts = json.loads(runner.run("vault", "secrets", "list", "-format=json", env=env))
    mount_path = f"{options.mount_path}/"
    current = mounts.get(mount_path)
    if _is_kv_v2_mount(current):
        return
    runner.run(
        "vault",
        "secrets",
        "enable",
        "-path",
        options.mount_path,
        "kv-v2",
        env=env,
    )

AI coding agent prompt:

Refactor the `ensure_kv_mount` function in `scripts/bootstrap_vault_appliance.py` to reduce conditional complexity:

1. Add a new helper function `_is_kv_v2_mount` before `ensure_kv_mount` that takes an optional dictionary parameter and returns a boolean
2. The helper should check three conditions in sequence with early returns:
   - Return False if mount_info is None or falsy
   - Return False if the "type" key is not "kv"
   - Extract the "options" dict (defaulting to empty dict), then return whether "version" equals "2"
3. Replace the complex conditional in `ensure_kv_mount` with a call to `_is_kv_v2_mount(current)`
4. Maintain all existing behaviour and logic
5. Preserve the function signature and all other code unchanged

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 3, 2025

Tip

For best results, initiate chat on the files or code changes.

@leynos: The bootstrap function's complexity stems from nested conditionals around initialization, unsealing, and token management. Extract these into focused helper functions to reduce cyclomatic complexity below the threshold.

Suggested fix

Add these helper functions before bootstrap:

def _build_vault_environment(options: BootstrapOptions, ip_address: str) -> dict[str, str]:
    """Build the base Vault environment with address and optional CA certificate."""
    address = options.vault_address or f"https://{ip_address}:8200"
    vault_env: dict[str, str] = {"VAULT_ADDR": address}
    if options.ca_cert_path:
        vault_env["VAULT_CACERT"] = options.ca_cert_path
    return vault_env


def _ensure_vault_unsealed_and_authenticated(
    options: BootstrapOptions,
    runner: CommandRunner,
    vault_env: dict[str, str],
    secrets: SecretStore,
    status: dict,
) -> None:
    """Ensure Vault is unsealed and vault_env contains VAULT_TOKEN."""
    if not status.get("initialized", False):
        init_data = initialise_vault(options, runner, vault_env, secrets)
        unseal_vault(init_data["unseal_keys"], options, runner, vault_env)
        vault_env["VAULT_TOKEN"] = init_data["root_token"]
    else:
        if status.get("sealed", False):
            unseal_keys = load_unseal_keys(options, secrets)
            unseal_vault(unseal_keys, options, runner, vault_env)
        root_token = secrets.get("root-token")
        if not root_token:
            raise RuntimeError(
                "Vault is initialised but no root token is stored; cannot proceed."
            )
        vault_env["VAULT_TOKEN"] = root_token


def _verify_vault_unsealed(runner: CommandRunner, vault_env: dict[str, str]) -> None:
    """Verify that Vault is unsealed after unsealing attempts."""
    post_status = read_vault_status(runner, vault_env)
    if post_status.get("sealed", False):
        raise RuntimeError("Vault remains sealed after attempting to unseal it.")

Then replace the bootstrap function with this refactored version:

def bootstrap(options: BootstrapOptions, runner: CommandRunner | None = None) -> None:
    command_runner = runner or CommandRunner()
    secrets = SecretStore(command_runner, options.secret_prefix)

    ip_address = discover_droplet_ip(options, command_runner)
    verify_vault_service(ip_address, options, command_runner)

    vault_env = _build_vault_environment(options, ip_address)
    status = read_vault_status(command_runner, vault_env)

    _ensure_vault_unsealed_and_authenticated(
        options, command_runner, vault_env, secrets, status
    )
    _verify_vault_unsealed(command_runner, vault_env)

    ensure_kv_mount(options, command_runner, vault_env)
    ensure_approle(options, command_runner, vault_env, secrets)

This refactoring:

  • Reduces bootstrap cyclomatic complexity from 11 to 1
  • Extracts environment setup, unsealing/auth logic, and verification into focused helpers
  • Maintains identical behaviour and error handling
  • Improves testability by isolating decision logic

AI coding agent prompt

Refactor the `bootstrap` function in `scripts/bootstrap_vault_appliance.py` to reduce cyclomatic complexity from 11 to below 9 by extracting helper functions.

Add three new private helper functions immediately before the `bootstrap` function:

1. `_build_vault_environment(options: BootstrapOptions, ip_address: str) -> dict[str, str]`
   - Docstring: "Build the base Vault environment with address and optional CA certificate."
   - Determine address from options.vault_address or construct from ip_address
   - Create dict with VAULT_ADDR key
   - If options.ca_cert_path is set, add VAULT_CACERT key
   - Return the environment dict

2. `_ensure_vault_unsealed_and_authenticated(options: BootstrapOptions, runner: CommandRunner, vault_env: dict[str, str], secrets: SecretStore, status: dict) -> None`
   - Docstring: "Ensure Vault is unsealed and vault_env contains VAULT_TOKEN."
   - If status indicates not initialized:
     * Call initialise_vault
     * Call unseal_vault with init_data unseal keys
     * Set VAULT_TOKEN in vault_env from init_data root_token
   - Else:
     * If status indicates sealed, load unseal keys and call unseal_vault
     * Retrieve root token from secrets
     * Raise RuntimeError if root token missing
     * Set VAULT_TOKEN in vault_env from retrieved token

3. `_verify_vault_unsealed(runner: CommandRunner, vault_env: dict[str, str]) -> None`
   - Docstring: "Verify that Vault is unsealed after unsealing attempts."
   - Read vault status using runner and vault_env
   - If status indicates sealed, raise RuntimeError with message "Vault remains sealed after attempting to unseal it."

Then refactor the `bootstrap` function body to:
- Keep the first 4 lines unchanged (runner setup, secrets, ip discovery, service verification)
- Replace environment building code with call to `_build_vault_environment`
- Replace the large if/else block with call to `_ensure_vault_unsealed_and_authenticated`
- Replace post-status verification with call to `_verify_vault_unsealed`
- Keep the final two lines unchanged (ensure_kv_mount and ensure_approle calls)

Preserve all existing behaviour, error messages, and logic exactly. Use en-GB-oxendict spelling in docstrings.

🐰

Copy link
Copy Markdown

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gates Failed
New code is healthy (1 new file with code health below 9.00)
Enforce advisory code health rules (2 files with Complex Method, Complex Conditional, Large Method)

Gates Passed
4 Quality Gates Passed

See analysis details in CodeScene

Reason for failure
New code is healthy Violations Code Health Impact
bootstrap_vault_appliance.py 3 rules 8.99 Suppress
Enforce advisory code health rules Violations Code Health Impact
bootstrap_vault_appliance.py 3 advisory rules 8.99 Suppress
test_bootstrap_vault_appliance.py 1 advisory rule 9.31 Suppress

Quality Gate Profile: Pay Down Tech Debt
Want more control? Customize Code Health rules or catch issues early with our IDE extension and CLI tool.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant