From c9951c0e1f9aca8ba49270f7e462e6cc817ad906 Mon Sep 17 00:00:00 2001 From: kkuo Date: Thu, 23 Jul 2026 17:16:33 -0400 Subject: [PATCH] feat(g/infra): Add database credential backup to Vault Back up database cluster credentials to a Vault KV path when credential-usernames is set, updating the secret only when a value has changed. Retrieved passwords are masked in the logs and never printed. --- gh-actions/infra/backup-database/README.md | 37 +- gh-actions/infra/backup-database/action.yaml | 74 +++- .../backup-database/backup_credentials.py | 323 ++++++++++++++ .../tests/test_backup_database.py | 397 +++++++++++++++++- 4 files changed, 811 insertions(+), 20 deletions(-) create mode 100644 gh-actions/infra/backup-database/backup_credentials.py diff --git a/gh-actions/infra/backup-database/README.md b/gh-actions/infra/backup-database/README.md index f642383..fa40762 100644 --- a/gh-actions/infra/backup-database/README.md +++ b/gh-actions/infra/backup-database/README.md @@ -9,16 +9,20 @@ authenticate to JAAS beforehand. ## Inputs -| Input | Required | Default | Description | -| ------------- | -------- | --------------- | -------------------------------------------------- | -| `model` | yes | | Juju model name | -| `model-owner` | no | | Juju model owner, for models owned by another user | -| `application` | yes | | Application whose units will be considered | -| `action` | no | `create-backup` | Juju action name | -| `parameters` | no | `{}` | JSON object containing action parameters | -| `unit-role` | no | `non-primary` | `non-primary`, `primary`, or `any` | -| `timeout` | no | `6h` | Duration passed to Juju's `--wait`, e.g. `30m` | -| `dry-run` | no | `false` | Skip backup creation but still list backups | +| Input | Required | Default | Description | +| ------------------------- | -------- | --------------- | -------------------------------------------------- | +| `model` | yes | | Juju model name | +| `model-owner` | no | | Juju model owner, for models owned by another user | +| `application` | yes | | Application whose units will be considered | +| `action` | no | `create-backup` | Juju action name | +| `parameters` | no | `{}` | JSON object containing action parameters | +| `unit-role` | no | `non-primary` | `non-primary`, `primary`, or `any` | +| `timeout` | no | `6h` | Duration passed to Juju's `--wait`, e.g. `30m` | +| `credential-usernames` | no | | Comma-separated database usernames to back up | +| `credentials-secret-path` | no | see below | Vault KV path under the `secret` mount | +| `vault-addr` | no | | Vault URL; required for credential backup | +| `vault-token` | no | | Vault token; required for credential backup | +| `dry-run` | no | `false` | Skip backup creation but still list backups | Only `model` and `application` are strictly required. `model-owner` is only needed when targeting a model owned by another user. @@ -32,6 +36,16 @@ eligible non-primary unit is selected, falling back to the primary with a degraded warning when no eligible replica remains. `any` skips role discovery. Excluded units and degraded fallbacks are noted in the job summary. +## Credential backup + +When `credential-usernames` is set, the action also retrieves each listed +user's password with the charm's `get-password` action and stores it in Vault, +updating the secret only when a value has changed. Credentials are stored at +`secret/services/{model}/{application}-credentials`, overridable with +`credentials-secret-path`. Retrieved passwords are masked in the logs and never +printed. During a dry run, credentials are retrieved and compared but Vault is +not written. + ## Usage ```yaml @@ -52,6 +66,9 @@ steps: model: example-model model-owner: ${{ vars.JUJU_MODEL_OWNER }} application: database + credential-usernames: charmed-replication + vault-addr: ${{ vars.VAULT_ADDR }} + vault-token: ${{ steps.credentials.outputs.vault_token }} dry-run: "true" ``` diff --git a/gh-actions/infra/backup-database/action.yaml b/gh-actions/infra/backup-database/action.yaml index db8bdb7..cd4d410 100644 --- a/gh-actions/infra/backup-database/action.yaml +++ b/gh-actions/infra/backup-database/action.yaml @@ -27,6 +27,22 @@ inputs: description: Duration passed to Juju's --wait option, e.g. 30m or 6h required: false default: 6h + credential-usernames: + description: Comma-separated database usernames whose credentials should be backed up + required: false + default: "" + credentials-secret-path: + description: Vault KV path under the secret mount for database credentials + required: false + default: "" + vault-addr: + description: Vault server address, required when credential-usernames is set + required: false + default: "" + vault-token: + description: Vault token, required when credential-usernames is set + required: false + default: "" dry-run: description: Validate model access and unit selection without running the action required: false @@ -35,6 +51,41 @@ inputs: runs: using: composite steps: + - name: Install Vault + if: ${{ inputs.credential-usernames != '' }} + shell: bash + run: | + set -eo pipefail + if ! snap list vault >/dev/null 2>&1; then + sudo snap install vault + fi + vault version + + - name: Prepare credential path + id: prepare + if: ${{ inputs.credential-usernames != '' }} + shell: bash + env: + APPLICATION: ${{ inputs.application }} + CREDENTIALS_SECRET_PATH: ${{ inputs.credentials-secret-path }} + MODEL: ${{ inputs.model }} + run: | + set -eo pipefail + credentials_path="${CREDENTIALS_SECRET_PATH:-services/${MODEL}/${APPLICATION}-credentials}" + echo "credentials-path=${credentials_path}" >> "${GITHUB_OUTPUT}" + + - name: Fetch current cluster credentials + id: cluster-credentials + if: ${{ inputs.credential-usernames != '' }} + uses: hashicorp/vault-action@v4 + with: + url: ${{ inputs.vault-addr }} + method: token + token: ${{ inputs.vault-token }} + exportEnv: false + ignoreNotFound: true + secrets: secret/data/${{ steps.prepare.outputs.credentials-path }} ** | CURRENT_ + - name: Run database backup id: database-backup shell: bash @@ -49,18 +100,37 @@ runs: DRY_RUN: ${{ inputs.dry-run }} run: python3 "${{ github.action_path }}/backup.py" + - name: Back up database credentials + id: database-credentials + if: ${{ always() && inputs.credential-usernames != '' }} + shell: bash + env: + MODEL: ${{ inputs.model }} + MODEL_OWNER: ${{ inputs.model-owner }} + APPLICATION: ${{ inputs.application }} + TIMEOUT: ${{ inputs.timeout }} + CREDENTIAL_USERNAMES: ${{ inputs.credential-usernames }} + CREDENTIALS_SECRET_PATH: ${{ inputs.credentials-secret-path }} + DRY_RUN: ${{ inputs.dry-run }} + VAULT_ADDR: ${{ inputs.vault-addr }} + VAULT_TOKEN: ${{ inputs.vault-token }} + VAULT_CREDENTIAL_OUTPUTS_JSON: ${{ toJSON(steps.cluster-credentials.outputs) }} + run: python3 "${{ github.action_path }}/backup_credentials.py" + - name: Write job summary if: always() shell: bash env: BACKUP_RESULT: ${{ steps.database-backup.outputs.result }} + CREDENTIAL_RESULT: ${{ steps.database-credentials.outputs.result }} run: | set -eo pipefail - [[ -n "${BACKUP_RESULT}" ]] || exit 0 + [[ -n "${BACKUP_RESULT}${CREDENTIAL_RESULT}" ]] || exit 0 { echo "## Database backup" echo echo "| Operation | Target | Unit | Result | Notes |" echo "| --------- | ------ | ---- | ------ | ----- |" - jq -Rr 'select(length > 0) | fromjson | [.operation, "`\(.target)`", (.unit // "—" | "`\(.)`"), .result, (.notes // "—")] | "| " + join(" | ") + " |"' <<< "${BACKUP_RESULT}" + printf '%s\n%s\n' "${BACKUP_RESULT}" "${CREDENTIAL_RESULT}" | + jq -Rr 'select(length > 0) | fromjson | [.operation, "`\(.target)`", (.unit // "—" | "`\(.)`"), .result, (.notes // "—")] | "| " + join(" | ") + " |"' } >> "${GITHUB_STEP_SUMMARY}" diff --git a/gh-actions/infra/backup-database/backup_credentials.py b/gh-actions/infra/backup-database/backup_credentials.py new file mode 100644 index 0000000..57f3673 --- /dev/null +++ b/gh-actions/infra/backup-database/backup_credentials.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 + +"""Back up database cluster credentials to Vault.""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +from backup_helpers import ( + CommandCapture, + CommandRunner, + juju_operations, + run_command, + set_result_output, +) + +logger = logging.getLogger(__name__) + +VAULT_CREDENTIAL_OUTPUT_PREFIX = "CURRENT_" +SUMMARY_FAILURE = "❌ Failure" +SUMMARY_SUCCESS = "✅ Success" +SUMMARY_DRY_RUN = "⏭️ Dry run" + + +class CredentialBackupError(RuntimeError): + """Raised when cluster credentials cannot be retrieved or stored.""" + + +@dataclass(frozen=True) +class CredentialTarget: + """Configuration for one database credential backup target.""" + + model: str + application: str + model_owner: str = "" + timeout: str = "6h" + usernames: tuple[str, ...] = () + secret_path: str | None = None + vault_credentials: Mapping[str, str] = field(default_factory=dict) + vault_secret_exists: bool = False + + @property + def qualified_model(self) -> str: + """Return the owner-qualified model name expected by Juju.""" + + if not self.model_owner: + return self.model + return f"{self.model_owner}/{self.model}" + + @property + def target_name(self) -> str: + """Return the target name used in logs and summaries.""" + + return f"{self.model}/{self.application}" + + @property + def resolved_secret_path(self) -> str: + """Return the Vault KV path used for cluster credentials.""" + + return ( + self.secret_path or f"services/{self.model}/{self.application}-credentials" + ) + + +def target_from_action_inputs(values: Mapping[str, str]) -> CredentialTarget: + """Build a normalized credential target from action environment values.""" + + usernames = tuple( + dict.fromkeys( + username.strip() + for username in values.get("CREDENTIAL_USERNAMES", "").split(",") + if username.strip() + ) + ) + vault_outputs = json.loads(values.get("VAULT_CREDENTIAL_OUTPUTS_JSON", "{}")) + if not isinstance(vault_outputs, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in vault_outputs.items() + ): + raise TypeError("Vault credential outputs must be a JSON object of strings") + + output_keys = [_vault_action_output_key(username) for username in usernames] + if len(set(output_keys)) != len(output_keys): + raise ValueError("credential usernames produce duplicate Vault Action outputs") + vault_credentials = { + username: vault_outputs[output_key] + for username, output_key in zip(usernames, output_keys) + if output_key in vault_outputs + } + + application = values.get("APPLICATION", "").strip() + if not application: + raise ValueError("application is required") + model = values.get("MODEL", "").strip() + if not model: + raise ValueError("model is required") + model_owner = values.get("MODEL_OWNER", "").strip() + return CredentialTarget( + model=model, + application=application, + model_owner=model_owner, + timeout=values.get("TIMEOUT", "6h"), + usernames=usernames, + secret_path=values.get("CREDENTIALS_SECRET_PATH") or None, + vault_credentials=vault_credentials, + vault_secret_exists=bool(vault_credentials), + ) + + +def _vault_action_output_key(username: str) -> str: + """Return the output name produced by Vault Action for one username.""" + + value = f"{VAULT_CREDENTIAL_OUTPUT_PREFIX}{username}".replace(".", "__") + value = value.replace("-", "") + return "".join( + character for character in value if character == "_" or character.isalnum() + ) + + +def run_credential_backup( + target: CredentialTarget, + *, + dry_run: bool, + output_path: Path | None, + command_runner: CommandRunner | None = None, + temporary_root: Path | None = None, +) -> int: + """Retrieve cluster credentials and update Vault only when needed.""" + + if not target.usernames: + logger.info("Cluster credential backup is not configured") + return 0 + + mode = "dry run" if dry_run else "live backup" + logger.info("Starting credential %s for %s", mode, target.target_name) + try: + with tempfile.TemporaryDirectory(dir=temporary_root) as temporary_directory: + changed = _backup_cluster_credentials( + target, + dry_run=dry_run, + runner=command_runner or run_command, + temporary_directory=Path(temporary_directory), + ) + except CredentialBackupError as error: + logger.error( + "Unable to back up cluster credentials for %s: %s", + target.target_name, + error, + ) + set_result_output( + output_path, + operation="credential backup", + target=target.target_name, + result=SUMMARY_FAILURE, + notes="Credential retrieval or storage failed", + ) + return 1 + + set_result_output( + output_path, + operation="credential backup", + target=target.target_name, + result=SUMMARY_DRY_RUN if dry_run else SUMMARY_SUCCESS, + notes=_summary_note(dry_run=dry_run, credentials_changed=changed), + ) + logger.info("Completed credential %s for %s", mode, target.target_name) + return 0 + + +def _backup_cluster_credentials( + target: CredentialTarget, + *, + dry_run: bool, + runner: CommandRunner, + temporary_directory: Path, +) -> bool: + """Retrieve configured credentials and update Vault only when needed.""" + + credential_count = len(target.usernames) + logger.info("Validating %d configured cluster credential(s)", credential_count) + credentials: dict[str, str] = {} + for index, username in enumerate(target.usernames): + logger.info( + "Retrieving cluster credential for %s (%d of %d)", + username, + index + 1, + credential_count, + ) + capture = CommandCapture.create(temporary_directory, f"credential-{index}") + command = [ + "juju", + "run", + "--model", + target.qualified_model, + f"{target.application}/leader", + "get-password", + f"username={username}", + "--format=json", + f"--wait={target.timeout}", + ] + if capture.run(runner, command) != 0: + raise CredentialBackupError( + f"get-password failed for {username}; command output was withheld" + ) + password = _password_from_juju_output( + capture.stdout, expected_username=username + ) + _mask_secret(password) + credentials[username] = password + + logger.info("Configured cluster credentials were retrieved successfully") + logger.info("Comparing cluster credentials with the values read from Vault") + if all( + target.vault_credentials.get(key) == value for key, value in credentials.items() + ): + logger.info("Cluster credentials are already current; skipping the Vault write") + return False + + if dry_run: + logger.info("Dry run: cluster credentials differ; skipping the Vault write") + return False + + logger.info("Cluster credentials changed; writing the updated values to Vault") + vault_input = temporary_directory / "vault-credentials-input.json" + vault_input.write_text(json.dumps(credentials)) + vault_input.chmod(0o600) + capture = CommandCapture.create(temporary_directory, "vault-write") + command = [ + "vault", + "kv", + "patch" if target.vault_secret_exists else "put", + *(["-method=rw"] if target.vault_secret_exists else []), + "-mount=secret", + target.resolved_secret_path, + f"@{vault_input}", + ] + if capture.run(runner, command) != 0: + reason = capture.stderr.read_text(errors="replace").strip() + raise CredentialBackupError( + f"Vault write failed: {reason or 'Vault exited with no error output'}" + ) + logger.info("Cluster credentials were updated in Vault successfully") + return True + + +def _mask_secret(value: str) -> None: + """Register a secret with the GitHub Actions log masker.""" + + if os.environ.get("GITHUB_ACTIONS") != "true": + return + escaped = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::add-mask::{escaped}", flush=True) + + +def _password_from_juju_output(output_path: Path, *, expected_username: str) -> str: + """Extract one non-empty password from a completed ``get-password`` run.""" + + try: + operations = juju_operations(output_path) + if len(operations) != 1: + raise ValueError + operation = operations[0] + results = operation.get("results") + if ( + operation.get("status") != "completed" + or not isinstance(results, dict) + or results.get("return-code") != 0 + or results.get("username") != expected_username + or not isinstance(results.get("password"), str) + or not results["password"] + ): + raise ValueError + return results["password"] + except (json.JSONDecodeError, OSError, ValueError) as error: + raise CredentialBackupError( + f"get-password returned an invalid result for {expected_username}" + ) from error + + +def _summary_note(*, dry_run: bool, credentials_changed: bool) -> str: + """Describe credential handling for the summary.""" + + if dry_run: + return "Cluster credential retrieval validated; Vault write skipped" + if credentials_changed: + return "Cluster credentials updated in Vault" + return "Cluster credentials already current in Vault" + + +def main() -> int: + """Validate action environment inputs and run credential backup.""" + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + dry_run_value = os.environ.get("DRY_RUN", "false").lower() + if dry_run_value not in {"true", "false"}: + logger.error("DRY_RUN must be true or false") + return 2 + + try: + target = target_from_action_inputs(os.environ) + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error: + logger.error("Invalid action inputs: %s", error) + return 2 + + output_value = os.environ.get("GITHUB_OUTPUT") + return run_credential_backup( + target, + dry_run=dry_run_value == "true", + output_path=Path(output_value) if output_value else None, + temporary_root=( + Path(os.environ["RUNNER_TEMP"]) if os.environ.get("RUNNER_TEMP") else None + ), + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gh-actions/infra/backup-database/tests/test_backup_database.py b/gh-actions/infra/backup-database/tests/test_backup_database.py index 4ab8599..f4cd00d 100644 --- a/gh-actions/infra/backup-database/tests/test_backup_database.py +++ b/gh-actions/infra/backup-database/tests/test_backup_database.py @@ -9,6 +9,7 @@ from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from typing import Any +from unittest.mock import patch ACTION_DIR = Path(__file__).parents[1] sys.path.insert(0, str(ACTION_DIR)) @@ -25,6 +26,7 @@ def load_module(name: str): backup_helpers = load_module("backup_helpers") backup = load_module("backup") +backup_credentials = load_module("backup_credentials") TEST_MODEL_OWNER = "test-owner" DEFAULT_ACTION_INPUTS = { "ACTION": "create-backup", @@ -41,12 +43,17 @@ def __init__( fail_model: str | None = None, fail_status: bool = False, failed_action: str | None = None, + passwords: dict[str, str] | None = None, + vault_error: str | None = None, ): self.fixture = fixture self.fail_model = fail_model self.fail_status = fail_status self.failed_action = failed_action + self.passwords = passwords or {} + self.vault_error = vault_error self.commands: list[list[str]] = [] + self.vault_writes: list[dict[str, Any]] = [] @staticmethod def _model(command: list[str]) -> str | None: @@ -66,10 +73,25 @@ def _action(command: list[str]) -> str: return command[command.index("--model") + 3] return command[3] + @staticmethod + def _parameter(command: list[str], name: str) -> str: + for argument in command: + if argument.startswith(f"{name}="): + return argument.removeprefix(f"{name}=") + raise ValueError(f"command does not pass {name}") + def __call__(self, command: list[str], stdout: Path, stderr: Path) -> int: self.commands.append(list(command)) stdout.touch() stderr.touch() + if command[0] == "vault": + self.vault_writes.append( + json.loads(Path(command[-1].removeprefix("@")).read_text()) + ) + if self.vault_error is not None: + stderr.write_text(self.vault_error) + return 2 + return 0 if command[1] == "status": if self.fail_status: stderr.write_text("simulated status failure") @@ -82,6 +104,23 @@ def __call__(self, command: list[str], stdout: Path, stderr: Path) -> int: action = self._action(command) unit = self._unit(command) status = "failed" if action == self.failed_action else "completed" + if action == "get-password": + username = self._parameter(command, "username") + stdout.write_text( + json.dumps( + { + unit: { + "results": { + "password": self.passwords[username], + "return-code": 0, + "username": username, + }, + "status": status, + } + } + ) + ) + return 0 if action == "list-backups": stdout.write_text( json.dumps( @@ -182,6 +221,30 @@ def test_action_inputs_are_passed_through(self): self.assertEqual(backup_target.unit_role, "any") self.assertEqual(backup_target.timeout, "45s") + def test_credential_action_inputs_are_passed_through(self): + credential_target = backup_credentials.target_from_action_inputs( + { + "MODEL": "model:variant", + "MODEL_OWNER": "owner", + "APPLICATION": "database_app", + "TIMEOUT": "45s", + "CREDENTIAL_USERNAMES": " operator, monitoring, operator, ", + "CREDENTIALS_SECRET_PATH": "services/custom/credentials", + "VAULT_CREDENTIAL_OUTPUTS_JSON": ( + '{"CURRENT_operator":"old-operator","CURRENT_unmanaged":"keep"}' + ), + } + ) + self.assertEqual(credential_target.usernames, ("operator", "monitoring")) + self.assertEqual(credential_target.timeout, "45s") + self.assertEqual( + credential_target.vault_credentials, {"operator": "old-operator"} + ) + self.assertTrue(credential_target.vault_secret_exists) + self.assertEqual( + credential_target.resolved_secret_path, "services/custom/credentials" + ) + def test_invalid_parameter_json_fails(self): with self.assertRaises(json.JSONDecodeError): backup.target_from_action_inputs( @@ -193,6 +256,47 @@ def test_invalid_parameter_json_fails(self): } ) + def test_maps_normalized_vault_action_outputs_to_usernames(self): + target = backup_credentials.target_from_action_inputs( + { + "MODEL": "model", + "MODEL_OWNER": TEST_MODEL_OWNER, + "APPLICATION": "database", + "CREDENTIAL_USERNAMES": "charmed-operator,stats.reader", + "VAULT_CREDENTIAL_OUTPUTS_JSON": ( + '{"CURRENT_charmedoperator":"one","CURRENT_stats__reader":"two"}' + ), + } + ) + + self.assertEqual( + target.vault_credentials, + {"charmed-operator": "one", "stats.reader": "two"}, + ) + + def test_unrelated_vault_action_outputs_do_not_mark_secret_as_existing(self): + target = backup_credentials.target_from_action_inputs( + { + "MODEL": "model", + "APPLICATION": "database", + "CREDENTIAL_USERNAMES": "operator", + "VAULT_CREDENTIAL_OUTPUTS_JSON": '{"vault_token":"token"}', + } + ) + + self.assertFalse(target.vault_secret_exists) + + def test_rejects_colliding_vault_action_output_names(self): + with self.assertRaisesRegex(ValueError, "duplicate Vault Action outputs"): + backup_credentials.target_from_action_inputs( + { + "MODEL": "model", + "MODEL_OWNER": TEST_MODEL_OWNER, + "APPLICATION": "database", + "CREDENTIAL_USERNAMES": "charmed-operator,charmedoperator", + } + ) + def test_action_model_owner_qualifies_model(self): backup_target = backup.target_from_action_inputs( { @@ -202,11 +306,22 @@ def test_action_model_owner_qualifies_model(self): "APPLICATION": "database", } ) + credential_target = backup_credentials.target_from_action_inputs( + { + "MODEL": "database-model", + "MODEL_OWNER": TEST_MODEL_OWNER, + "APPLICATION": "database", + } + ) self.assertEqual( backup_target.qualified_model, f"{TEST_MODEL_OWNER}/database-model", ) + self.assertEqual( + credential_target.resolved_secret_path, + "services/database-model/database-credentials", + ) def test_model_owner_is_optional(self): backup_target = backup.target_from_action_inputs( @@ -216,20 +331,40 @@ def test_model_owner_is_optional(self): "APPLICATION": "database", } ) + credential_target = backup_credentials.target_from_action_inputs( + {"MODEL": "database-model", "APPLICATION": "database"} + ) self.assertEqual(backup_target.qualified_model, "database-model") + self.assertEqual(credential_target.qualified_model, "database-model") def test_model_is_required(self): - with self.assertRaisesRegex(ValueError, "model is required"): - backup.target_from_action_inputs( - {"MODEL_OWNER": TEST_MODEL_OWNER, "APPLICATION": "database"} - ) + for target_from_inputs in ( + backup.target_from_action_inputs, + backup_credentials.target_from_action_inputs, + ): + with self.subTest(target_from_inputs=target_from_inputs): + with self.assertRaisesRegex(ValueError, "model is required"): + target_from_inputs( + { + "MODEL_OWNER": TEST_MODEL_OWNER, + "APPLICATION": "database", + } + ) def test_application_is_required(self): - with self.assertRaisesRegex(ValueError, "application is required"): - backup.target_from_action_inputs( - {"MODEL": "database-model", "MODEL_OWNER": TEST_MODEL_OWNER} - ) + for target_from_inputs in ( + backup.target_from_action_inputs, + backup_credentials.target_from_action_inputs, + ): + with self.subTest(target_from_inputs=target_from_inputs): + with self.assertRaisesRegex(ValueError, "application is required"): + target_from_inputs( + { + "MODEL": "database-model", + "MODEL_OWNER": TEST_MODEL_OWNER, + } + ) def test_summary_text_flattens_markdown_and_newlines(self): self.assertEqual( @@ -301,6 +436,24 @@ def test_dry_run_skips_backup_and_lists_backups(self): self.assertEqual( records[0]["notes"], "Selection validated; backup action not run" ) + self.assertFalse( + any( + command[0] == "vault" or "get-password" in command + for command in runner.commands + ) + ) + + def test_blank_credential_usernames_are_ignored(self): + target = backup_credentials.target_from_action_inputs( + { + "MODEL": "database-model", + "MODEL_OWNER": TEST_MODEL_OWNER, + "APPLICATION": "database", + "CREDENTIAL_USERNAMES": " , , ", + } + ) + + self.assertEqual(target.usernames, ()) def test_status_failure_summary_omits_unit(self): runner = FakeRunner(self.fixture("healthy-three.json"), fail_status=True) @@ -352,6 +505,29 @@ def test_juju_run_parser_rejects_invalid_operation_envelopes(self): output.write_text(json.dumps(response)) self.assertFalse(backup_helpers.juju_run_succeeded(output)) + def test_password_parser_rejects_a_mismatched_username(self): + response = { + "database/0": { + "status": "completed", + "results": { + "password": "sensitive-password", + "return-code": 0, + "username": "different-user", + }, + } + } + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "output.json" + output.write_text(json.dumps(response)) + + with self.assertRaisesRegex( + backup_credentials.CredentialBackupError, + "requested-user", + ): + backup_credentials._password_from_juju_output( + output, expected_username="requested-user" + ) + def test_builds_action_argument_list(self): runner = FakeRunner(self.fixture("healthy-three.json")) target = backup.BackupTarget( @@ -422,6 +598,211 @@ def test_lists_backups_and_prints_output_after_success(self): self.assertIn("backup-2026-07-22", stdout.getvalue()) self.assertIn("list warning", stderr.getvalue()) + def test_dry_run_compares_credentials_without_writing_vault(self): + secret = "do-not%print\nthis\rpassword" + runner = FakeRunner( + self.fixture("healthy-three.json"), passwords={"operator": secret} + ) + target = backup_credentials.CredentialTarget( + model="dry-run", + application="database", + model_owner=TEST_MODEL_OWNER, + usernames=("operator",), + secret_path="services/private/database-credentials", + vault_credentials={"operator": "old-secret"}, + vault_secret_exists=True, + ) + stdout = io.StringIO() + stderr = io.StringIO() + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + with self.assertLogs(backup_credentials.logger, level="INFO") as logs: + with ( + patch.dict( + backup_credentials.os.environ, + {"GITHUB_ACTIONS": "true"}, + ), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + result = backup_credentials.run_credential_backup( + target, + dry_run=True, + output_path=root / "github-output", + command_runner=runner, + temporary_root=root, + ) + records = read_records(root / "github-output") + log_output = "\n".join(logs.output) + + self.assertEqual(result, 0) + self.assertEqual( + [FakeRunner._action(command) for command in runner.commands], + ["get-password"], + ) + self.assertIn("--wait=6h", runner.commands[0]) + vault_commands = [ + command for command in runner.commands if command[0] == "vault" + ] + self.assertEqual(vault_commands, []) + self.assertEqual( + stdout.getvalue(), + "::add-mask::do-not%25print%0Athis%0Dpassword\n", + ) + self.assertNotIn(secret, stderr.getvalue()) + self.assertNotIn(secret, json.dumps(records)) + self.assertIn("Starting credential dry run for dry-run/database", log_output) + self.assertIn("Retrieving cluster credential for operator (1 of 1)", log_output) + self.assertIn( + "Comparing cluster credentials with the values read from Vault", log_output + ) + self.assertIn( + "cluster credentials differ; skipping the Vault write", log_output + ) + for sensitive_value in ( + secret, + "services/private/database-credentials", + TEST_MODEL_OWNER, + ): + self.assertNotIn(sensitive_value, log_output) + self.assertIn("Vault write skipped", records[0]["notes"]) + + def test_updates_changed_credentials_and_preserves_other_vault_keys(self): + secret = "new-secret" + runner = FakeRunner( + self.fixture("healthy-three.json"), + passwords={"operator": secret}, + ) + target = backup_credentials.CredentialTarget( + model="model", + application="database", + model_owner=TEST_MODEL_OWNER, + usernames=("operator",), + vault_credentials={"operator": "old-secret"}, + vault_secret_exists=True, + ) + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + result = backup_credentials.run_credential_backup( + target, + dry_run=False, + output_path=root / "github-output", + command_runner=runner, + temporary_root=root, + ) + + self.assertEqual(result, 0) + self.assertEqual( + runner.vault_writes, + [{"operator": secret}], + ) + vault_command = next( + command for command in runner.commands if command[0] == "vault" + ) + self.assertEqual(vault_command[2], "patch") + self.assertIn("-method=rw", vault_command) + self.assertFalse( + any( + secret in argument + for command in runner.commands + for argument in command + ) + ) + + def test_creates_missing_vault_secret(self): + runner = FakeRunner( + self.fixture("healthy-three.json"), + passwords={"operator": "new-secret"}, + ) + target = backup_credentials.CredentialTarget( + model="model", + application="database", + model_owner=TEST_MODEL_OWNER, + usernames=("operator",), + ) + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + result = backup_credentials.run_credential_backup( + target, + dry_run=False, + output_path=root / "github-output", + command_runner=runner, + temporary_root=root, + ) + + self.assertEqual(result, 0) + self.assertEqual(runner.vault_writes, [{"operator": "new-secret"}]) + vault_commands = [ + command for command in runner.commands if command[0] == "vault" + ] + self.assertEqual([command[2] for command in vault_commands], ["put"]) + self.assertEqual(vault_commands[0][-2], "services/model/database-credentials") + + def test_logs_vault_write_failure_reason_after_masking_credentials(self): + secret = "database-password" + runner = FakeRunner( + self.fixture("healthy-three.json"), + passwords={"operator": secret}, + vault_error="Error writing data to Vault: permission denied", + ) + target = backup_credentials.CredentialTarget( + model="model", + application="database", + model_owner=TEST_MODEL_OWNER, + usernames=("operator",), + ) + stdout = io.StringIO() + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + with self.assertLogs(backup_credentials.logger, level="ERROR") as logs: + with ( + patch.dict( + backup_credentials.os.environ, + {"GITHUB_ACTIONS": "true"}, + ), + redirect_stdout(stdout), + ): + result = backup_credentials.run_credential_backup( + target, + dry_run=False, + output_path=root / "github-output", + command_runner=runner, + temporary_root=root, + ) + + log_output = "\n".join(logs.output) + self.assertEqual(result, 1) + self.assertEqual(stdout.getvalue(), f"::add-mask::{secret}\n") + self.assertIn("permission denied", log_output) + + def test_skips_vault_write_when_credentials_are_unchanged(self): + runner = FakeRunner( + self.fixture("healthy-three.json"), + passwords={"operator": "same-secret"}, + ) + target = backup_credentials.CredentialTarget( + model="model", + application="database", + model_owner=TEST_MODEL_OWNER, + usernames=("operator",), + vault_credentials={"operator": "same-secret"}, + vault_secret_exists=True, + ) + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + result = backup_credentials.run_credential_backup( + target, + dry_run=False, + output_path=root / "github-output", + command_runner=runner, + temporary_root=root, + ) + records = read_records(root / "github-output") + + self.assertEqual(result, 0) + self.assertEqual(runner.vault_writes, []) + self.assertIn("already current in Vault", records[0]["notes"]) + def test_degraded_success_uses_warning_result(self): runner = FakeRunner(self.fixture("unhealthy-replicas.json")) target = backup.BackupTarget(