From b421f49af19b339d1ad16130fcfd082490a9dd8a Mon Sep 17 00:00:00 2001 From: chrisfu Date: Wed, 22 Apr 2026 20:54:13 -0700 Subject: [PATCH] Migrate Ansible-based scripts and documentation to 1Password integration. Removed deprecated and redundant Ansible vault workflows, added 1Password CLI handling for secrets management, and updated tests to reflect the change. --- README.md | 2 +- config.py | 90 --------- docs/PROLE-CFG-SECRETS.md | 14 +- docs/ansible-installer-boundary.md | 51 ----- etc/init_1password.sh | 74 +++++++ etc/init_ansible.sh | 47 ----- etc/set-k3s-token-1password.sh | 56 ++++++ etc/set-k3s-token-vault.sh | 71 ------- etc/sync-knoe-cfg.py | 16 +- .../group_vars/all/vault_db_master.yml | 8 - install.sh | 6 + knoe/config.py | 122 ++---------- knoe/core/actions.py | 181 ++++-------------- knoe/core/env.py | 167 ++-------------- knoe/core/onepassword.py | 124 ++++++++++++ knoe/ui/screens/__init__.py | 11 +- knoe/ui/screens/security.py | 133 +------------ mock_val/init_ansible.sh | 47 ----- mock_val/set-k3s-token-vault.sh | 71 ------- mock_val/sync-prole-cfg.py | 18 +- scripts/validation/check_kerberos.sh | 21 +- tests/etc/test_init_1password.sh | 143 ++++++++++++++ tests/etc/test_init_ansible.sh | 81 -------- tests/etc/test_set-k3s-token-vault.sh | 80 -------- tests/installer/test_config_helpers.py | 117 +---------- tests/installer/test_core_classes.py | 20 +- tests/installer/test_env_helpers.py | 5 - tests/installer/test_onepassword.py | 168 ++++++++++++++++ .../test_repair_update_and_supabase_flags.py | 6 +- update.sh | 117 +++++------ 30 files changed, 744 insertions(+), 1323 deletions(-) delete mode 100644 docs/ansible-installer-boundary.md create mode 100755 etc/init_1password.sh delete mode 100755 etc/init_ansible.sh create mode 100644 etc/set-k3s-token-1password.sh delete mode 100755 etc/set-k3s-token-vault.sh delete mode 100644 infrastructure/inventory/group_vars/all/vault_db_master.yml create mode 100644 knoe/core/onepassword.py delete mode 100755 mock_val/init_ansible.sh delete mode 100755 mock_val/set-k3s-token-vault.sh create mode 100644 tests/etc/test_init_1password.sh delete mode 100644 tests/etc/test_init_ansible.sh delete mode 100644 tests/etc/test_set-k3s-token-vault.sh create mode 100644 tests/installer/test_onepassword.py diff --git a/README.md b/README.md index 1095fa8..6b65c2a 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ It packages the core building blocks needed for a modern internal developer plat ![Google Workspace](https://img.shields.io/badge/Google%20Workspace-4285F4?style=for-the-badge&logo=google&logoColor=white) ![Samba AD](https://img.shields.io/badge/Samba%20AD-0C4DA2?style=for-the-badge&logo=windows&logoColor=white) -![Ansible](https://img.shields.io/badge/Ansible-EE0000?style=for-the-badge&logo=ansible&logoColor=white) +![1Password](https://img.shields.io/badge/1Password-3B66BC?style=for-the-badge&logo=1password&logoColor=white) ![OpenTofu](https://img.shields.io/badge/OpenTofu-FFDA18?style=for-the-badge&logo=opentofu&logoColor=black) ![Linux](https://img.shields.io/badge/Linux-333333?style=for-the-badge&logo=linux&logoColor=white) ![Raspberry%20Pi](https://img.shields.io/badge/Raspberry%20Pi-C51A4A?style=for-the-badge&logo=raspberrypi&logoColor=white) diff --git a/config.py b/config.py index 9c0aa4b..5627590 100644 --- a/config.py +++ b/config.py @@ -730,96 +730,6 @@ def _extract_yaml_scalar_from_text(text: str, key: str) -> str: return "" -def _extract_inline_vault_block(text: str, key: str) -> str: - lines = text.splitlines() - for i, line in enumerate(lines): - if line.strip().startswith(f"{key}:"): - base_indent = len(line) - len(line.lstrip()) - block = [] - i += 1 - while i < len(lines): - line = lines[i] - if not line.strip(): - i += 1 - continue - indent = len(line) - len(line.lstrip()) - if indent <= base_indent: - break - block.append(line.strip()) - i += 1 - if block and block[0].startswith("$ANSIBLE_VAULT"): - return "\n".join(block) + "\n" - return "" - return "" - - -def _try_read_ansible_vault_value(vault_path: Path, key: str) -> str: - if not vault_path.exists(): - return "" - - # Simple search in plain text first - try: - raw_text = vault_path.read_text() - plain = _extract_yaml_scalar_from_text(raw_text, key) - if ( - plain - and not plain.startswith("$ANSIBLE_VAULT") - and not plain.startswith("!vault") - ): - return plain - except Exception: - pass - - password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() - if not password_file: - candidate = PROJECT_ROOT / ".vault_pass" - if candidate.is_file(): - password_file = str(candidate) - - if not password_file or shutil.which("ansible-vault") is None: - return "" - - def _vault_view(path: str) -> str: - try: - res = subprocess.run( - ["ansible-vault", "view", path, "--vault-password-file", password_file], - capture_output=True, - text=True, - timeout=5, - env=os.environ.copy(), - stdin=subprocess.DEVNULL, - ) - if res.returncode == 0: - return res.stdout or "" - except Exception: - pass - return "" - - try: - output = _vault_view(str(vault_path)) - if output: - return _extract_yaml_scalar_from_text(output, key) - - # Inline vault - inline_block = _extract_inline_vault_block(vault_path.read_text(), key) - if not inline_block: - return "" - - import tempfile - - with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp: - tmp.write(inline_block) - tmp_name = tmp.name - - try: - output = _vault_view(tmp_name) - return output.strip() - finally: - if os.path.exists(tmp_name): - os.unlink(tmp_name) - except Exception: - pass - return "" def _update_knoe_cfg_value( diff --git a/docs/PROLE-CFG-SECRETS.md b/docs/PROLE-CFG-SECRETS.md index 1429773..da3bca9 100644 --- a/docs/PROLE-CFG-SECRETS.md +++ b/docs/PROLE-CFG-SECRETS.md @@ -1,6 +1,18 @@ # knoe.cfg Secrets -This document describes how secrets are handled in `knoe.cfg` and where they are stored in OpenBao. +This document describes how secrets are handled in `knoe.cfg` and where they are stored. + +## External secret store: 1Password (`knoey` vault) + +The master database password (`administrator`) is stored in a dedicated 1Password vault called **`knoey`**, isolated from the user's personal vaults. + +| 1Password item | Field | Purpose | +|---|---|---| +| `administrator` | `password` | Database master password (formerly in Ansible vault) | +| `k3s-token` | `credential` | k3s cluster join token | +| `samba-dns-admin` | `password` | Samba/AD DNS admin password | + +`install.sh` creates the `knoey` vault and the `administrator` item on first run (via `etc/init_1password.sh`). The `op` CLI (`brew install 1password-cli`) is required. ## Temporary encrypted values diff --git a/docs/ansible-installer-boundary.md b/docs/ansible-installer-boundary.md deleted file mode 100644 index 3dece00..0000000 --- a/docs/ansible-installer-boundary.md +++ /dev/null @@ -1,51 +0,0 @@ -### Installer ↔ Ansible boundary - -`install.py` (and the `installer/` package) and Ansible are treated as **independent tools**. - -- The installer may *invoke* `ansible` / `ansible-playbook` as an external subprocess. -- The installer must not assume: - - Ansible vault access - - inventory parsing as a required part of the workflow - - any specific private repo checkout layout (e.g. a sibling `knoe/infrastructure`) -- Ansible playbooks/roles must not assume `install.py` is discovering secrets or passing hidden, repo-local state. - -This is designed so private inventory/secret material can live entirely in `knoe/infrastructure` (or elsewhere) without this repo requiring it. - -#### Explicit inputs (recommended) - -When the installer needs to call `ansible-playbook` (for example to fetch a kubeconfig), provide paths explicitly via environment variables: - -- `PROLE_ANSIBLE_K3S_FETCH_KUBECONFIG_PLAYBOOK` - - Full path to `k3s_fetch_kubeconfig.yml`. -- `PROLE_ANSIBLE_PLAYBOOKS_DIR` (or `PROLE_ANSIBLE_PLAYBOOK_DIR`) - - Directory that contains `k3s_fetch_kubeconfig.yml`. -- `PROLE_ANSIBLE_INVENTORY` (or `ANSIBLE_INVENTORY`) - - Inventory path passed as `ansible-playbook -i ...`. -- `PROLE_ANSIBLE_VAULT_PASSWORD_FILE` (or `ANSIBLE_VAULT_PASSWORD_FILE`) - - Vault password file path passed as `ansible-playbook --vault-password-file ...`. -- `PROLE_ANSIBLE_CONFIG` (or `ANSIBLE_CONFIG`) - - Path to an Ansible config file to use for the subprocess. - -Separately, when the deployment workflow needs k3s connection info, provide it explicitly: - -- `PROLE_K3S_SERVER` -- `PROLE_K3S_TOKEN` (or `K3S_TOKEN`) - -#### Optional legacy auto-detection (opt-in) - -For local developer convenience only, repo-relative discovery can be enabled explicitly: - -- `PROLE_ANSIBLE_AUTO_DETECT=true` - -When enabled, some flows may look for legacy repo-relative playbooks (under `infrastructure/playbooks/`). This is **not** required for normal operation and should not be relied on for private infrastructure. - -### K3s slow-storage tolerance knobs (Ansible) - -The `infrastructure/roles/k3s` role exposes configurable defaults intended for slow storage (Pi / USB) rollouts: - -- `k3s_kubelet_args` (default includes `runtime-request-timeout=15m`, `image-pull-progress-deadline=15m`, `node-status-update-frequency=20s`) -- `k3s_systemd_override_enabled` (default `true`) -- `k3s_systemd_timeout_start_sec` (default `15min`) -- `k3s_systemd_timeout_stop_sec` (default `10min`) -- `k3s_systemd_restart` (default `always`) -- `k3s_systemd_restart_sec` (default `15s`) \ No newline at end of file diff --git a/etc/init_1password.sh b/etc/init_1password.sh new file mode 100755 index 0000000..88a2d74 --- /dev/null +++ b/etc/init_1password.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# init_1password.sh +# Preflight: sign in to 1Password, create the 'knoey' vault if absent, +# and ensure the 'administrator' item (DB master password) exists. +# +# Called by install.sh before the Python installer. Skipped in --min mode. + +set -euo pipefail + +# ── helpers ──────────────────────────────────────────────────────────────── + +_info() { echo "==> [1Password] $*"; } +_warn() { echo " [WARN] $*" >&2; } +_fatal() { echo " [ERROR] $*" >&2; exit 1; } + +VAULT="knoey" +ADMIN_ITEM="administrator" + +# ── skip in --min mode ───────────────────────────────────────────────────── + +for arg in "$@"; do + if [[ "$arg" == "--min" ]]; then + _warn "Skipping 1Password preflight in --min mode." + exit 0 + fi +done + +# ── require op CLI ───────────────────────────────────────────────────────── + +if ! command -v op >/dev/null 2>&1; then + _fatal "1Password CLI (op) not found. + Install: brew install 1password-cli + Docs: https://developer.1password.com/docs/cli" +fi + +_info "op CLI found: $(op --version)" + +# ── sign in ──────────────────────────────────────────────────────────────── + +if ! op whoami >/dev/null 2>&1; then + _info "No active 1Password session. Signing in..." + op signin +fi + +_info "Signed in as: $(op whoami --format json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('email','unknown'))" 2>/dev/null || op whoami)" + +# ── create knoey vault if absent ─────────────────────────────────────────── + +if op vault get "$VAULT" >/dev/null 2>&1; then + _info "Vault '$VAULT' already exists." +else + _info "Creating vault '$VAULT'..." + op vault create "$VAULT" + _info "Vault '$VAULT' created." +fi + +# ── ensure administrator item exists ─────────────────────────────────────── + +if op item get "$ADMIN_ITEM" --vault "$VAULT" >/dev/null 2>&1; then + _info "Item '$ADMIN_ITEM' already exists in vault '$VAULT'." +else + _info "Creating item '$ADMIN_ITEM' in vault '$VAULT' with a generated password..." + op item create \ + --category login \ + --title "$ADMIN_ITEM" \ + --vault "$VAULT" \ + --generate-password="32,letters,digits" + _info "Item '$ADMIN_ITEM' created." +fi + +# ── export for child processes ───────────────────────────────────────────── + +export OP_VAULT="$VAULT" +_info "OP_VAULT=$OP_VAULT" diff --git a/etc/init_ansible.sh b/etc/init_ansible.sh deleted file mode 100755 index 51ac2a1..0000000 --- a/etc/init_ansible.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -# init_ansible.sh -# Purpose: -# - Initialize or update the Ansible vault password file (.vault_pass) -# - Should be run after init_openbao.sh - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -# shellcheck disable=SC1090 -source "$SCRIPT_DIR/knoe_cfg.sh" - -if [[ "${1:-}" == "--mode" || "${1:-}" == "-m" ]]; then - knoe_set_mode "${2:-}" - shift 2 -elif [[ "${1:-}" == --mode=* || "${1:-}" == -m=* ]]; then - knoe_set_mode "${1#*=}" - shift -fi - -PROLE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) -VAULT_PASS_FILE="${PROLE_VAULT_PASS_FILE:-${ANSIBLE_VAULT_PASSWORD_FILE:-$PROLE_ROOT/.vault_pass}}" - -ACTION=${1:-initialize} - -case "$ACTION" in - initialize|update) - # Use PROLE_PASSWD if set, otherwise prompt - if [[ -n "${PROLE_PASSWD:-}" ]]; then - echo "Using PROLE_PASSWD for Ansible Vault password." - echo "$PROLE_PASSWD" > "$VAULT_PASS_FILE" - else - echo -n "Enter Ansible Vault password: " - read -rs vault_pass - echo - echo "$vault_pass" > "$VAULT_PASS_FILE" - fi - - chmod 600 "$VAULT_PASS_FILE" - echo "Ansible Vault password file $ACTION""d at $VAULT_PASS_FILE" - ;; - *) - echo "Usage: $0 {initialize|update}" >&2 - exit 2 - ;; -esac diff --git a/etc/set-k3s-token-1password.sh b/etc/set-k3s-token-1password.sh new file mode 100644 index 0000000..b4495c2 --- /dev/null +++ b/etc/set-k3s-token-1password.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# set-k3s-token-1password.sh +# Reads the k3s server join token and stores it in the 'knoey' 1Password vault. +# Replaces etc/set-k3s-token-vault.sh (formerly used ansible-vault). +# +# Run on the k3s control-plane node (requires sudo to read the token file). + +set -euo pipefail + +TOKEN_FILE="${TOKEN_FILE:-/var/lib/rancher/k3s/server/node-token}" +VAULT="knoey" +ITEM="k3s-token" +FIELD="credential" + +if [[ ! -r "$TOKEN_FILE" ]]; then + if command -v sudo >/dev/null 2>&1; then + TOKEN="$(sudo cat "$TOKEN_FILE" | tr -d '\r\n')" + else + echo "ERROR: Cannot read token file: $TOKEN_FILE" >&2 + echo "Are you running this on a k3s server?" >&2 + exit 1 + fi +else + TOKEN="$(cat "$TOKEN_FILE" | tr -d '\r\n')" +fi + +if [[ -z "$TOKEN" ]]; then + echo "ERROR: Token read from $TOKEN_FILE is empty" >&2 + exit 1 +fi + +if ! command -v op >/dev/null 2>&1; then + echo "ERROR: 1Password CLI (op) not found. Install: brew install 1password-cli" >&2 + exit 1 +fi + +if ! op whoami >/dev/null 2>&1; then + op signin +fi + +if op item get "$ITEM" --vault "$VAULT" >/dev/null 2>&1; then + echo "Updating existing item '$ITEM' in vault '$VAULT'..." + op item edit "$ITEM" --vault "$VAULT" "${FIELD}=${TOKEN}" +else + echo "Creating item '$ITEM' in vault '$VAULT'..." + op item create \ + --category login \ + --title "$ITEM" \ + --vault "$VAULT" \ + "${FIELD}=${TOKEN}" +fi + +echo "Done. k3s token stored in 1Password vault '$VAULT' as item '$ITEM'." +echo +echo "Verify with:" +echo " op item get $ITEM --vault $VAULT --fields $FIELD --reveal" diff --git a/etc/set-k3s-token-vault.sh b/etc/set-k3s-token-vault.sh deleted file mode 100755 index b8e3378..0000000 --- a/etc/set-k3s-token-vault.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -# shellcheck disable=SC1090 -source "$SCRIPT_DIR/knoe_cfg.sh" - -# Where k3s keeps the server join token on the first/control-plane server -TOKEN_FILE="${TOKEN_FILE:-/var/lib/rancher/k3s/server/node-token}" - -# Pick a dedicated vault file so we don't stomp your existing vault.yml. -# Change this if you want it somewhere else. -VAULT_FILE="${VAULT_FILE:-infrastructure/inventory/group_vars/all/vault_k3s.yml}" - -# Variable name to store in the vault -VAR_NAME="${VAR_NAME:-vault_k3s_token}" - -if [[ ! -r "$TOKEN_FILE" ]]; then - echo "ERROR: Cannot read token file: $TOKEN_FILE" - echo "Are you running this on a k3s server (pi.knoe.org)?" - exit 1 -fi - -if ! command -v ansible-vault >/dev/null 2>&1; then - echo "ERROR: ansible-vault not found in PATH" - exit 1 -fi - -TOKEN="$(sudo cat "$TOKEN_FILE" | tr -d '\r\n')" - -if [[ -z "$TOKEN" ]]; then - echo "ERROR: Token read from $TOKEN_FILE is empty" - exit 1 -fi - -mkdir -p "$(dirname "$VAULT_FILE")" - -TMP="$(mktemp)" -trap 'rm -f "$TMP"' EXIT - -# Build a vault yaml with exactly one variable -# (ansible-vault encrypt_string outputs a full YAML block) -ansible-vault encrypt_string \ - --name "$VAR_NAME" \ - "$TOKEN" > "$TMP" - -# If the vault file already exists, avoid duplicate var definitions: -# - if VAR_NAME already present, we replace the whole file (simple + safe) -# - otherwise, append -if [[ -f "$VAULT_FILE" ]]; then - if grep -qE "^\s*${VAR_NAME}:" "$VAULT_FILE"; then - echo "Updating existing $VAR_NAME in $VAULT_FILE (replacing file contents)." - mv "$TMP" "$VAULT_FILE" - else - echo "Appending $VAR_NAME to $VAULT_FILE" - printf "\n" >> "$VAULT_FILE" - cat "$TMP" >> "$VAULT_FILE" - fi -else - echo "Creating vault file: $VAULT_FILE" - mv "$TMP" "$VAULT_FILE" -fi - -chmod 0600 "$VAULT_FILE" - -echo "Done." -echo "Wrote: $VAULT_FILE" -echo "Var: $VAR_NAME" -echo -echo "Verify with:" -echo " ansible -i infrastructure/inventory/hosts.ini pi.knoe.org -m assert -a 'that=${VAR_NAME} is defined' --ask-vault-pass" diff --git a/etc/sync-knoe-cfg.py b/etc/sync-knoe-cfg.py index ad447e7..f37b9b3 100644 --- a/etc/sync-knoe-cfg.py +++ b/etc/sync-knoe-cfg.py @@ -2,23 +2,17 @@ import sys from pathlib import Path -# Add project root to path ROOT_DIR = Path(__file__).resolve().parents[1] sys.path.append(str(ROOT_DIR)) from knoe import config as inst_config +from knoe.core.onepassword import get_secret, op_available def main(): - vault_path = ( - ROOT_DIR - / "infrastructure" - / "inventory" - / "group_vars" - / "all" - / "vault_k3s.yml" - ) - token = inst_config._try_read_ansible_vault_value(vault_path, "vault_k3s_token") + token = "" + if op_available(): + token = get_secret("k3s-token", "credential").strip() if token: print("Syncing K3S_TOKEN to active config...") @@ -26,7 +20,7 @@ def main(): inst_config._update_knoe_cfg_value("Inputs", "init_cluster.k3s_token", token) inst_config._update_knoe_cfg_value("Service Cluster (k3s)", "K3S_TOKEN", token) else: - print("Warning: Could not extract vault_k3s_token from Ansible vault.") + print("Warning: Could not retrieve k3s-token from 1Password knoey vault.") if __name__ == "__main__": diff --git a/infrastructure/inventory/group_vars/all/vault_db_master.yml b/infrastructure/inventory/group_vars/all/vault_db_master.yml deleted file mode 100644 index 9f94788..0000000 --- a/infrastructure/inventory/group_vars/all/vault_db_master.yml +++ /dev/null @@ -1,8 +0,0 @@ -$ANSIBLE_VAULT;1.1;AES256 -33623434353765353030333339626631656163343239353230643430356466306461663835383566 -3564333737623034343837616632386462306462366538390a623538373034383839376261653734 -36653238393437656332373965663866653730343864333063366462303661356366323262363839 -3961633266353131310a313930346164393031623037393339356539616639343536353163343036 -61353535633234363535633437356162626234356139323531643534613961633166393135356562 -30306634646130353665656262393132656632373634353765316630643665356331363435366165 -356261383336383736336336356564386337 diff --git a/install.sh b/install.sh index ff154c8..6468768 100755 --- a/install.sh +++ b/install.sh @@ -81,6 +81,12 @@ if [[ -n "${_script_dir}" && -d "${_script_dir}/knoe" ]]; then # Local checkout: delegate to the Python installer module. cd "${_script_dir}" + # 1Password preflight — sign in, create 'knoey' vault, ensure 'administrator' item. + # Skipped in --min mode (init_1password.sh handles the flag check itself). + if [[ -f "${_script_dir}/etc/init_1password.sh" ]]; then + bash "${_script_dir}/etc/init_1password.sh" "$@" || true + fi + # If a built binary exists, prefer it. if [[ -x "dist/knoe" ]]; then exec "./dist/knoe" "$@" diff --git a/knoe/config.py b/knoe/config.py index 8841654..4fdc290 100644 --- a/knoe/config.py +++ b/knoe/config.py @@ -759,96 +759,6 @@ def _extract_yaml_scalar_from_text(text: str, key: str) -> str: return "" -def _extract_inline_vault_block(text: str, key: str) -> str: - lines = text.splitlines() - for i, line in enumerate(lines): - if line.strip().startswith(f"{key}:"): - base_indent = len(line) - len(line.lstrip()) - block = [] - i += 1 - while i < len(lines): - line = lines[i] - if not line.strip(): - i += 1 - continue - indent = len(line) - len(line.lstrip()) - if indent <= base_indent: - break - block.append(line.strip()) - i += 1 - if block and block[0].startswith("$ANSIBLE_VAULT"): - return "\n".join(block) + "\n" - return "" - return "" - - -def _try_read_ansible_vault_value(vault_path: Path, key: str) -> str: - if not vault_path.exists(): - return "" - - # Simple search in plain text first - try: - raw_text = vault_path.read_text() - plain = _extract_yaml_scalar_from_text(raw_text, key) - if ( - plain - and not plain.startswith("$ANSIBLE_VAULT") - and not plain.startswith("!vault") - ): - return plain - except Exception: - pass - - password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() - if not password_file: - candidate = PROJECT_ROOT / ".vault_pass" - if candidate.is_file(): - password_file = str(candidate) - - if not password_file or shutil.which("ansible-vault") is None: - return "" - - def _vault_view(path: str) -> str: - try: - res = subprocess.run( - ["ansible-vault", "view", path, "--vault-password-file", password_file], - capture_output=True, - text=True, - timeout=5, - env=os.environ.copy(), - stdin=subprocess.DEVNULL, - ) - if res.returncode == 0: - return res.stdout or "" - except Exception: - pass - return "" - - try: - output = _vault_view(str(vault_path)) - if output: - return _extract_yaml_scalar_from_text(output, key) - - # Inline vault - inline_block = _extract_inline_vault_block(vault_path.read_text(), key) - if not inline_block: - return "" - - import tempfile - - with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp: - tmp.write(inline_block) - tmp_name = tmp.name - - try: - output = _vault_view(tmp_name) - return output.strip() - finally: - if os.path.exists(tmp_name): - os.unlink(tmp_name) - except Exception: - pass - return "" def _update_knoe_cfg_value( @@ -1154,14 +1064,14 @@ _MACOS_DEPENDENCIES = [ "bin": "python3", }, { - "id": "ansible", - "name": "ansible", + "id": "op", + "name": "1password-cli", "parent": "brew", - "description": "Infrastructure automation tool", - "url": "https://www.ansible.com", - "install_cmd": "brew install ansible", - "check_cmd": "ansible --version", - "bin": "ansible", + "description": "1Password CLI for secret management", + "url": "https://developer.1password.com/docs/cli", + "install_cmd": "brew install 1password-cli", + "check_cmd": "op --version", + "bin": "op", }, { "id": "kubectl", @@ -1265,13 +1175,13 @@ def _linux_dependencies(manager: str) -> list[dict]: "bin": "python3", }, { - "id": "ansible", - "name": "ansible", - "description": "Infrastructure automation tool", - "url": "https://www.ansible.com", - "install_cmd": install("ansible"), - "check_cmd": "ansible --version", - "bin": "ansible", + "id": "op", + "name": "1password-cli", + "description": "1Password CLI for secret management", + "url": "https://developer.1password.com/docs/cli", + "install_cmd": install("1password-cli"), + "check_cmd": "op --version", + "bin": "op", }, { "id": "kubectl", @@ -1485,9 +1395,9 @@ def get_required_dependency_ids(inputs: dict | None = None) -> set[str]: mode = _deployment_mode_hint(inputs) if mode == "min": - return {"python", "ansible", "containerd", "docker-buildx"} + return {"python", "containerd", "docker-buildx"} - base_required = {"python", "ansible", "kubectl", "kubectx", "docker"} + base_required = {"python", "op", "kubectl", "kubectx", "docker"} if mode == "gke": required = base_required | {"gcloud"} platform_info = detect_dependency_platform() diff --git a/knoe/core/actions.py b/knoe/core/actions.py index 287f082..edba3f5 100644 --- a/knoe/core/actions.py +++ b/knoe/core/actions.py @@ -26,7 +26,6 @@ from knoe.config import ( _write_k3s_kubeconfig, _encrypt_cfg_secret, _merge_kubeconfig, - _try_read_ansible_vault_value, ) from knoe.core.build_context import copy_build_context_dir from knoe.core.cnpg_placement import ( @@ -4472,14 +4471,7 @@ class KnoeConsoleInstaller(KnoeInstaller): self.err(f"[WARN] Ansible playbook not found: {playbook}") return None - vault_pass = self.project_root / ".vault_pass" - cmd = ["ansible-playbook"] - if vault_pass.exists(): - cmd += ["--vault-password-file", str(vault_pass)] - vault_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() - if not vault_pass.exists() and vault_file: - cmd += ["--vault-password-file", vault_file] - cmd.append(str(playbook)) + cmd = ["ansible-playbook", str(playbook)] self.log("==> Fetching k3s kubeconfig via Ansible") try: @@ -5829,16 +5821,11 @@ class KnoeConsoleInstaller(KnoeInstaller): self.log("[RESET] Resetting k3s cluster (mode k3s)...") self._cleanup_local_k3s_artifacts() - vault_args = [] - v_file = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") - if v_file: - vault_args = ["-v", v_file] - delete_cmd = [ "./ansible.sh", "-p", "infrastructure/playbooks/k3s_delete.yml", - ] + vault_args + ] self.log(f"Running: {' '.join(delete_cmd)}") rc = self._run_cmd(delete_cmd) if rc != 0: @@ -6989,132 +6976,30 @@ class KnoeConsoleInstaller(KnoeInstaller): ) out.flush() - def _db_master_vault_file(self) -> Path: - raw = (os.environ.get("PROLE_DB_MASTER_VAULT_FILE") or "").strip() - if raw: - expanded = _expand_cfg_value(raw, _collect_cfg_vars()) - return Path(expanded).expanduser() - return ( - self.project_root - / "infrastructure" - / "inventory" - / "group_vars" - / "all" - / "vault_db_master.yml" - ) + def _load_db_password_from_1password(self, *, log_found: bool = True) -> str: + try: + from knoe.core.onepassword import get_secret, op_available + if not op_available(): + return "" + password = get_secret("administrator", "password").strip() + if password and log_found: + self.log("[CONFIG] Loaded DB master password from 1Password knoey vault.") + return password + except Exception: + return "" - def _db_master_vault_key(self) -> str: - key = (os.environ.get("PROLE_DB_MASTER_VAULT_KEY") or "").strip() - return key or "vault_knoe_db_master_password" - - def _resolve_ansible_vault_password_file(self) -> str: - vault_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() - if vault_file and Path(vault_file).expanduser().is_file(): - return str(Path(vault_file).expanduser()) - for base in (self.project_root, Path.cwd()): - candidate = base / ".vault_pass" - if candidate.is_file(): - os.environ["ANSIBLE_VAULT_PASSWORD_FILE"] = str(candidate) - return str(candidate) - return "" - - def _load_db_password_from_ansible_vault(self, *, log_found: bool = True) -> str: - vault_file = self._db_master_vault_file() - vault_key = self._db_master_vault_key() - password = _try_read_ansible_vault_value(vault_file, vault_key).strip() - if password and log_found: - self.log( - f"[CONFIG] Loaded DB master password from Ansible Vault ({vault_file})." - ) - return password - - def _persist_db_password_to_ansible_vault(self, password: str) -> None: + def _persist_db_password_to_1password(self, password: str) -> None: if not password: raise RuntimeError("Cannot persist an empty database master password.") - if shutil.which("ansible-vault") is None: - raise RuntimeError( - "ansible-vault command is required to store the database master password securely." - ) - - vault_file = self._db_master_vault_file() - vault_key = self._db_master_vault_key() - vault_file.parent.mkdir(parents=True, exist_ok=True) - - vault_password_file = self._resolve_ansible_vault_password_file() - vault_password = (os.environ.get("ANSIBLE_VAULT_PASSWORD") or "").strip() - if not vault_password_file and not vault_password: - raise RuntimeError( - "Ansible Vault password is not configured. Set ANSIBLE_VAULT_PASSWORD_FILE (or create .vault_pass) " - "or set ANSIBLE_VAULT_PASSWORD before bootstrap." - ) - - tmp_password_file = None - if not vault_password_file and vault_password: - tmp_pw = tempfile.NamedTemporaryFile(mode="w", delete=False) - tmp_pw.write(vault_password) - tmp_pw.flush() - tmp_pw.close() - tmp_password_file = tmp_pw.name - vault_password_file = tmp_password_file - - cmd = [ - "ansible-vault", - "encrypt_string", - "--name", - vault_key, - password, - ] - if vault_password_file: - cmd += ["--vault-password-file", vault_password_file] - try: - res = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=20, - env=os.environ.copy(), - stdin=subprocess.DEVNULL, - ) - if res.returncode != 0: - raise RuntimeError( - "Failed to write database master password to Ansible Vault: " - f"{(res.stderr or res.stdout or '').strip()}" - ) - - rendered_block = (res.stdout or "").rstrip() + "\n" - if not rendered_block.strip(): - raise RuntimeError( - "Failed to write database master password to Ansible Vault: empty encrypted output." - ) - - existing = "" - if vault_file.exists(): - try: - existing = vault_file.read_text() - except Exception: - existing = "" - - key_pattern = rf"(?ms)^\s*{re.escape(vault_key)}\s*:.*?(?=^\S|\Z)" - if existing and re.search(key_pattern, existing): - updated = re.sub(key_pattern, rendered_block.rstrip(), existing, count=1) - output_text = updated.rstrip() + "\n" - elif existing.strip(): - output_text = existing.rstrip() + "\n\n" + rendered_block - else: - output_text = rendered_block - - vault_file.write_text(output_text) - os.chmod(vault_file, 0o600) - self.log( - f"[CONFIG] Persisted DB master password into Ansible Vault ({vault_file}:{vault_key})." - ) - finally: - if tmp_password_file: - try: - os.unlink(tmp_password_file) - except Exception: - pass + from knoe.core.onepassword import set_secret, op_available + if not op_available(): + self.log("[WARN] 1Password CLI not available; skipping password persistence.") + return + set_secret("administrator", "password", password) + self.log("[CONFIG] Persisted DB master password to 1Password knoey vault.") + except Exception as e: + raise RuntimeError(f"Failed to persist database master password to 1Password: {e}") from e def run(self) -> int: _configure_unbuffered_io() @@ -7141,26 +7026,26 @@ class KnoeConsoleInstaller(KnoeInstaller): or "" ).strip() - # If runtime env provides a password, reconcile with vault for - # consistency and bootstrap vault when missing. + # If runtime env provides a password, reconcile with 1Password for + # consistency and bootstrap when missing. if db_pw and env_db_pw and db_pw == env_db_pw: - vault_pw = self._load_db_password_from_ansible_vault(log_found=False) + vault_pw = self._load_db_password_from_1password(log_found=False) if vault_pw and vault_pw != db_pw: self.log( - "[WARN] Runtime DB password differs from Ansible Vault value; " - "using vault password for consistency." + "[WARN] Runtime DB password differs from 1Password value; " + "using 1Password value for consistency." ) self.inputs["init_password.db_password"] = vault_pw self.inputs["init_password.db_password_confirm"] = vault_pw db_pw = vault_pw elif not vault_pw: - self._persist_db_password_to_ansible_vault(db_pw) + self._persist_db_password_to_1password(db_pw) self.log( - "[CONFIG] Bootstrapped Ansible Vault from KNOE_DB_PASSWORD/DB_PASSWORD." + "[CONFIG] Bootstrapped 1Password administrator from KNOE_DB_PASSWORD/DB_PASSWORD." ) if not db_pw: - vault_pw = self._load_db_password_from_ansible_vault() + vault_pw = self._load_db_password_from_1password() if vault_pw: self.inputs["init_password.db_password"] = vault_pw self.inputs["init_password.db_password_confirm"] = vault_pw @@ -7182,14 +7067,14 @@ class KnoeConsoleInstaller(KnoeInstaller): ) if allow_prompt: new_pw = self._prompt_for_master_password() - self._persist_db_password_to_ansible_vault(new_pw) + self._persist_db_password_to_1password(new_pw) self.log( - "[CONFIG] Master password captured interactively and saved for subsequent runs." + "[CONFIG] Master password captured interactively and saved to 1Password." ) else: raise RuntimeError( "Database master password is missing and prompting is unavailable. " - "Run once in an interactive terminal to bootstrap Ansible Vault, " + "Run once in an interactive terminal to bootstrap the 1Password knoey vault, " "or provide KNOE_DB_PASSWORD/DB_PASSWORD." ) self.inputs["init_password.db_password"] = new_pw diff --git a/knoe/core/env.py b/knoe/core/env.py index 2673f70..559fa33 100644 --- a/knoe/core/env.py +++ b/knoe/core/env.py @@ -345,7 +345,7 @@ def _resolve_k3s_connection( if not token and cfg_token: token = _normalize_k3s_token(cfg_token) - # Ansible topology fallback + # Ansible topology fallback (k3s node discovery only — no vault decryption) if not ansible_topology: try: root = project_root or PROJECT_ROOT @@ -358,6 +358,15 @@ def _resolve_k3s_connection( if not token and ansible_topology.get("k3s_token"): token = _normalize_k3s_token(ansible_topology["k3s_token"]) + # 1Password fallback for k3s token + if not token: + try: + from knoe.core.onepassword import get_secret, op_available + if op_available(): + token = _normalize_k3s_token(get_secret("k3s-token", "credential")) + except Exception: + pass + if server and not server.startswith("http"): server = f"https://{server}" return server, token @@ -525,58 +534,6 @@ def _k3d_knoe_data_volume_args(knoe_data: str | None) -> list[str]: return ["--volume", f"{p}:/var/lib/rancher/k3s/storage@all"] -def _ensure_ansible_vault_credentials(prompt_ui: bool = False, root=None) -> None: - password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() - password = (os.environ.get("ANSIBLE_VAULT_PASSWORD") or "").strip() - - if password_file: - try: - if not Path(password_file).expanduser().is_file(): - password_file = "" - os.environ.pop("ANSIBLE_VAULT_PASSWORD_FILE", None) - except Exception: - password_file = "" - os.environ.pop("ANSIBLE_VAULT_PASSWORD_FILE", None) - - if password or password_file: - return - - for base in (Path.cwd(), PROJECT_ROOT): - try: - candidate = base / ".vault_pass" - if candidate.is_file(): - os.environ["ANSIBLE_VAULT_PASSWORD_FILE"] = str(candidate) - print(f"[INFO] Using Ansible Vault password file: {candidate}") - return - except Exception: - continue - - vault_password = "" - if prompt_ui and root is not None: - try: - from tkinter import simpledialog - - vault_password = ( - simpledialog.askstring( - "Ansible Vault", - "Enter Ansible Vault password:", - show="*", - parent=root, - ) - or "" - ) - except Exception: - vault_password = "" - if not vault_password: - try: - vault_password = getpass.getpass("Ansible Vault password: ") - except Exception: - vault_password = "" - - if vault_password: - os.environ["ANSIBLE_VAULT_PASSWORD"] = vault_password - - def _openbao_placeholder(namespace: str, leaf: str, key: str | None = None) -> str: ns = (namespace or "").strip() or "default" path = f"kv/knoe/{ns}/{leaf}" @@ -1083,104 +1040,6 @@ def _extract_inline_vault_block(text: str, key: str) -> str: return "" -def _try_read_ansible_vault_value(vault_path: Path, key: str) -> str: - if not vault_path.exists(): - return "" - # First, try plain YAML parsing (in case the file is not encrypted). - try: - plain = _parse_yaml_scalar_values(vault_path, {key}).get(key, "") - except Exception: - plain = "" - if ( - plain - and not plain.lower().startswith("!vault") - and not plain.startswith("$ANSIBLE_VAULT") - ): - return plain - - password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() - password = (os.environ.get("ANSIBLE_VAULT_PASSWORD") or "").strip() - if password_file: - try: - if not Path(password_file).expanduser().exists(): - password_file = "" - except Exception: - password_file = "" - if not password_file and not password: - for base in (Path.cwd(), PROJECT_ROOT): - try: - candidate = base / ".vault_pass" - if candidate.is_file(): - password_file = str(candidate) - os.environ["ANSIBLE_VAULT_PASSWORD_FILE"] = password_file - break - except Exception: - continue - if not password_file and not password: - return "" - if shutil.which("ansible-vault") is None: - return "" - - tmp_path = None - if password_file: - password_file = password_file - elif password: - try: - tmp = tempfile.NamedTemporaryFile(delete=False) - tmp.write(password.encode("utf-8")) - tmp.flush() - tmp.close() - tmp_path = tmp.name - password_file = tmp_path - except Exception: - tmp_path = None - return "" - - def _vault_view(path: str) -> str: - res = subprocess.run( - ["ansible-vault", "view", path] - + (["--vault-password-file", password_file] if password_file else []), - capture_output=True, - text=True, - timeout=5, - env=os.environ.copy(), - stdin=subprocess.DEVNULL, - ) - if res.returncode != 0: - return "" - return res.stdout or "" - - try: - output = _vault_view(str(vault_path)) - if output: - return _extract_yaml_scalar_from_text(output, key) - - # Inline vault: extract the block and decrypt separately. - try: - raw_text = vault_path.read_text() - except Exception: - raw_text = "" - inline_block = _extract_inline_vault_block(raw_text, key) - if not inline_block: - return "" - tmp_inline = tempfile.NamedTemporaryFile(delete=False) - tmp_inline.write(inline_block.encode("utf-8")) - tmp_inline.flush() - tmp_inline.close() - output = _vault_view(tmp_inline.name) - try: - os.unlink(tmp_inline.name) - except Exception: - pass - return output.strip() - except Exception: - return "" - finally: - if tmp_path: - try: - os.unlink(tmp_path) - except Exception: - pass def _detect_ansible_k3s_settings( @@ -1235,14 +1094,10 @@ def _detect_ansible_k3s_settings( host_for_url = f"{host_for_url}.{domain}" server_url = f"https://{host_for_url}:6443" - vault_path = inventory_path / "group_vars" / "all" / "vault_k3s.yml" - token = _try_read_ansible_vault_value(vault_path, "vault_k3s_token") - return { "server_url": server_url, "server_host": server_host, - "token": token, - "vault_path": str(vault_path) if vault_path.exists() else "", + "token": "", } diff --git a/knoe/core/onepassword.py b/knoe/core/onepassword.py new file mode 100644 index 0000000..cc8c1fc --- /dev/null +++ b/knoe/core/onepassword.py @@ -0,0 +1,124 @@ +"""1Password CLI integration for knoe secret management. + +All secrets are stored in the 'knoey' vault so they are isolated from the +user's personal 1Password vaults. The 'administrator' item holds the +database master password. +""" + +from __future__ import annotations + +import json +import secrets +import shutil +import string +import subprocess + +_VAULT = "knoey" +_ADMIN_ITEM = "administrator" + + +def op_available() -> bool: + return shutil.which("op") is not None + + +def _op(*args: str, check: bool = True) -> subprocess.CompletedProcess: + if not op_available(): + raise RuntimeError( + "1Password CLI (op) not found. Install: brew install 1password-cli" + ) + return subprocess.run( + ["op", *args], + capture_output=True, + text=True, + check=check, + ) + + +def ensure_op_signed_in() -> None: + """Ensure the op CLI has an active session; trigger sign-in if not.""" + if not op_available(): + raise RuntimeError( + "1Password CLI (op) not found. Install: brew install 1password-cli" + ) + result = subprocess.run( + ["op", "whoami"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + subprocess.run(["op", "signin"], check=True) + + +def ensure_knoey_vault() -> None: + """Create the 'knoey' vault if it does not already exist.""" + result = _op("vault", "list", "--format", "json", check=True) + try: + vaults = json.loads(result.stdout or "[]") + except json.JSONDecodeError: + vaults = [] + names = [v.get("name", "") for v in vaults] + if _VAULT not in names: + _op("vault", "create", _VAULT) + print(f"[INFO] Created 1Password vault '{_VAULT}'") + else: + print(f"[INFO] 1Password vault '{_VAULT}' already exists") + + +def get_secret(item: str, field: str = "password") -> str: + """Retrieve a field value from an item in the knoey vault.""" + result = _op( + "item", "get", item, + "--vault", _VAULT, + "--fields", field, + "--reveal", + check=False, + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def set_secret(item: str, field: str, value: str) -> None: + """Set a field on an existing item, or create the item if absent.""" + check_result = _op("item", "get", item, "--vault", _VAULT, check=False) + if check_result.returncode == 0: + _op( + "item", "edit", item, + "--vault", _VAULT, + f"{field}={value}", + ) + else: + _op( + "item", "create", + "--category", "login", + "--title", item, + "--vault", _VAULT, + f"{field}={value}", + ) + + +def get_administrator_password() -> str: + """Return the administrator password from the knoey vault.""" + return get_secret(_ADMIN_ITEM, "password") + + +def ensure_administrator_secret() -> str: + """Return the administrator password, creating the item if absent.""" + pw = get_secret(_ADMIN_ITEM, "password") + if pw: + return pw + pw = _generate_password() + _op( + "item", "create", + "--category", "login", + "--title", _ADMIN_ITEM, + "--vault", _VAULT, + f"password={pw}", + ) + print(f"[INFO] Created 1Password item '{_ADMIN_ITEM}' in vault '{_VAULT}'") + return pw + + +def _generate_password(length: int = 32) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) diff --git a/knoe/ui/screens/__init__.py b/knoe/ui/screens/__init__.py index 68c5b66..d762f60 100644 --- a/knoe/ui/screens/__init__.py +++ b/knoe/ui/screens/__init__.py @@ -30,10 +30,10 @@ from knoe.config import ( from knoe.core.controller import KnoeController from knoe.core.env import ( _deployment_target_label, - _ensure_ansible_vault_credentials, _safe_str, _normalize_cluster_env, ) +from knoe.core.onepassword import ensure_op_signed_in, ensure_knoey_vault # Helper to set macOS application identity via Objective-C bridge. def setup_macos_app_identity(): @@ -1236,7 +1236,8 @@ def main(): args.silent = True if args.silent: - _ensure_ansible_vault_credentials(prompt_ui=False) + ensure_op_signed_in() + ensure_knoey_vault() installer = KnoeConsoleInstaller( controller, args.config, @@ -1290,7 +1291,8 @@ def main(): root.geometry(f"{window_width}x{window_height}+{cx}+{cy}") except Exception: root.geometry(f"{window_width}x{window_height}") - _ensure_ansible_vault_credentials(prompt_ui=True, root=root) + ensure_op_signed_in() + ensure_knoey_vault() KnoeInstaller( root, config_path=args.config, @@ -1306,7 +1308,8 @@ def main(): run_ncurses_installer(controller) else: - _ensure_ansible_vault_credentials(prompt_ui=False) + ensure_op_signed_in() + ensure_knoey_vault() from knoe.ncurses_installer import run_ncurses_installer run_ncurses_installer(controller) diff --git a/knoe/ui/screens/security.py b/knoe/ui/screens/security.py index 93a3dd1..7a4cffb 100644 --- a/knoe/ui/screens/security.py +++ b/knoe/ui/screens/security.py @@ -1,12 +1,8 @@ -"""Kerberos configuration, secret management and Ansible vault.""" +"""Kerberos configuration and secret management.""" import configparser import os -import shutil -import subprocess -import tempfile import threading -from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox, filedialog from knoe import screen as ui @@ -14,7 +10,6 @@ from knoe.core.env import ( PROJECT_ROOT, _bool_str, _deployment_target_label, - _ensure_ansible_vault_credentials, ) from knoe.config import ( SECRET_KEY_SPECS, @@ -355,69 +350,13 @@ class SecurityScreenMixin: return value return "" - @staticmethod - def _vault_password_files() -> list[Path]: - files: list[Path] = [] - env_pw = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE", "").strip() - if env_pw: - files.append(Path(env_pw)) - for base in (Path.cwd(), PROJECT_ROOT): - files.append(base / ".vault_pass") - seen: set[Path] = set() - unique: list[Path] = [] - for item in files: - if item in seen: - continue - seen.add(item) - unique.append(item) - return unique - def _resolve_samba_admin_password_from_vault(self) -> str: - vault_path = ( - PROJECT_ROOT - / "infrastructure" - / "inventory" - / "group_vars" - / "ad_dc" - / "vault.yml" - ) - if not vault_path.exists(): - return "" - try: - raw = vault_path.read_text(encoding="utf-8") + from knoe.core.onepassword import get_secret, op_available + if op_available(): + return get_secret("samba-dns-admin", "password") except Exception: - raw = "" - if raw and "$ANSIBLE_VAULT;" not in raw: - return self._yaml_scalar(raw, "vault_samba_dns_admin_pass") - - if shutil.which("ansible-vault") is None: - return "" - - _ensure_ansible_vault_credentials(prompt_ui=False) - for password_file in self._vault_password_files(): - if not password_file.is_file(): - continue - try: - res = subprocess.run( - [ - "ansible-vault", - "view", - str(vault_path), - "--vault-password-file", - str(password_file), - ], - capture_output=True, - text=True, - check=False, - ) - except Exception: - continue - if res.returncode != 0: - continue - value = self._yaml_scalar(res.stdout or "", "vault_samba_dns_admin_pass") - if value: - return value + pass return "" def _load_secret_cache_from_cfg(self): @@ -636,70 +575,18 @@ class SecurityScreenMixin: self.safe_after(self._run_kerberos_init) def _save_ansible_knoe_vault(self, root_password: str): + """Store the DB master password in the 1Password knoey vault.""" if not root_password: return - - vault_path = ( - PROJECT_ROOT - / "infrastructure" - / "inventory" - / "group_vars" - / "all" - / "knoe_vault.yml" - ) - - _ensure_ansible_vault_credentials(prompt_ui=False) - vault_pass_file = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") - - if not vault_pass_file or not Path(vault_pass_file).exists(): - for base in (Path.cwd(), PROJECT_ROOT): - candidate = base / ".vault_pass" - if candidate.is_file(): - vault_pass_file = str(candidate) - break - - if not vault_pass_file or not Path(vault_pass_file).exists(): - return - - if shutil.which("ansible-vault") is None: - return - try: - # We must decrypt if it is a knoe secret plain_pass = root_password if _is_knoe_secret(root_password): try: plain_pass = _decrypt_knoe_secret(root_password) except Exception: pass - - content = f'knoe_root_password: "{plain_pass}"\n' - - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write(content) - tmp_name = f.name - - try: - os.chmod(tmp_name, 0o600) - subprocess.run( - [ - "ansible-vault", - "encrypt", - tmp_name, - "--vault-password-file", - vault_pass_file, - ], - check=True, - capture_output=True, - ) - - shutil.move(tmp_name, str(vault_path)) - os.chmod(str(vault_path), 0o600) - finally: - if os.path.exists(tmp_name): - try: - os.unlink(tmp_name) - except: - pass + from knoe.core.onepassword import set_secret, op_available + if op_available(): + set_secret("administrator", "password", plain_pass) except Exception as e: - print(f"[ERROR] Failed to save ansible vault: {e}") + print(f"[ERROR] Failed to save administrator password to 1Password: {e}") diff --git a/mock_val/init_ansible.sh b/mock_val/init_ansible.sh deleted file mode 100755 index 51ac2a1..0000000 --- a/mock_val/init_ansible.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -# init_ansible.sh -# Purpose: -# - Initialize or update the Ansible vault password file (.vault_pass) -# - Should be run after init_openbao.sh - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -# shellcheck disable=SC1090 -source "$SCRIPT_DIR/knoe_cfg.sh" - -if [[ "${1:-}" == "--mode" || "${1:-}" == "-m" ]]; then - knoe_set_mode "${2:-}" - shift 2 -elif [[ "${1:-}" == --mode=* || "${1:-}" == -m=* ]]; then - knoe_set_mode "${1#*=}" - shift -fi - -PROLE_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) -VAULT_PASS_FILE="${PROLE_VAULT_PASS_FILE:-${ANSIBLE_VAULT_PASSWORD_FILE:-$PROLE_ROOT/.vault_pass}}" - -ACTION=${1:-initialize} - -case "$ACTION" in - initialize|update) - # Use PROLE_PASSWD if set, otherwise prompt - if [[ -n "${PROLE_PASSWD:-}" ]]; then - echo "Using PROLE_PASSWD for Ansible Vault password." - echo "$PROLE_PASSWD" > "$VAULT_PASS_FILE" - else - echo -n "Enter Ansible Vault password: " - read -rs vault_pass - echo - echo "$vault_pass" > "$VAULT_PASS_FILE" - fi - - chmod 600 "$VAULT_PASS_FILE" - echo "Ansible Vault password file $ACTION""d at $VAULT_PASS_FILE" - ;; - *) - echo "Usage: $0 {initialize|update}" >&2 - exit 2 - ;; -esac diff --git a/mock_val/set-k3s-token-vault.sh b/mock_val/set-k3s-token-vault.sh deleted file mode 100755 index b8e3378..0000000 --- a/mock_val/set-k3s-token-vault.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -# shellcheck disable=SC1090 -source "$SCRIPT_DIR/knoe_cfg.sh" - -# Where k3s keeps the server join token on the first/control-plane server -TOKEN_FILE="${TOKEN_FILE:-/var/lib/rancher/k3s/server/node-token}" - -# Pick a dedicated vault file so we don't stomp your existing vault.yml. -# Change this if you want it somewhere else. -VAULT_FILE="${VAULT_FILE:-infrastructure/inventory/group_vars/all/vault_k3s.yml}" - -# Variable name to store in the vault -VAR_NAME="${VAR_NAME:-vault_k3s_token}" - -if [[ ! -r "$TOKEN_FILE" ]]; then - echo "ERROR: Cannot read token file: $TOKEN_FILE" - echo "Are you running this on a k3s server (pi.knoe.org)?" - exit 1 -fi - -if ! command -v ansible-vault >/dev/null 2>&1; then - echo "ERROR: ansible-vault not found in PATH" - exit 1 -fi - -TOKEN="$(sudo cat "$TOKEN_FILE" | tr -d '\r\n')" - -if [[ -z "$TOKEN" ]]; then - echo "ERROR: Token read from $TOKEN_FILE is empty" - exit 1 -fi - -mkdir -p "$(dirname "$VAULT_FILE")" - -TMP="$(mktemp)" -trap 'rm -f "$TMP"' EXIT - -# Build a vault yaml with exactly one variable -# (ansible-vault encrypt_string outputs a full YAML block) -ansible-vault encrypt_string \ - --name "$VAR_NAME" \ - "$TOKEN" > "$TMP" - -# If the vault file already exists, avoid duplicate var definitions: -# - if VAR_NAME already present, we replace the whole file (simple + safe) -# - otherwise, append -if [[ -f "$VAULT_FILE" ]]; then - if grep -qE "^\s*${VAR_NAME}:" "$VAULT_FILE"; then - echo "Updating existing $VAR_NAME in $VAULT_FILE (replacing file contents)." - mv "$TMP" "$VAULT_FILE" - else - echo "Appending $VAR_NAME to $VAULT_FILE" - printf "\n" >> "$VAULT_FILE" - cat "$TMP" >> "$VAULT_FILE" - fi -else - echo "Creating vault file: $VAULT_FILE" - mv "$TMP" "$VAULT_FILE" -fi - -chmod 0600 "$VAULT_FILE" - -echo "Done." -echo "Wrote: $VAULT_FILE" -echo "Var: $VAR_NAME" -echo -echo "Verify with:" -echo " ansible -i infrastructure/inventory/hosts.ini pi.knoe.org -m assert -a 'that=${VAR_NAME} is defined' --ask-vault-pass" diff --git a/mock_val/sync-prole-cfg.py b/mock_val/sync-prole-cfg.py index f59d0f3..5e8c50f 100644 --- a/mock_val/sync-prole-cfg.py +++ b/mock_val/sync-prole-cfg.py @@ -2,31 +2,25 @@ import sys from pathlib import Path -# Add project root to path ROOT_DIR = Path(__file__).resolve().parents[1] sys.path.append(str(ROOT_DIR)) from knoe import config as inst_config +from knoe.core.onepassword import get_secret, op_available def main(): - vault_path = ( - ROOT_DIR - / "infrastructure" - / "inventory" - / "group_vars" - / "all" - / "vault_k3s.yml" - ) - token = inst_config._try_read_ansible_vault_value(vault_path, "vault_k3s_token") + token = "" + if op_available(): + token = get_secret("k3s-token", "credential").strip() if token: - print(f"Syncing K3S_TOKEN to conf/knoe.cfg...") + print("Syncing K3S_TOKEN to conf/knoe.cfg...") inst_config._update_knoe_cfg_value("Global", "PROLE_K3S_TOKEN", token) inst_config._update_knoe_cfg_value("Inputs", "init_cluster.k3s_token", token) inst_config._update_knoe_cfg_value("Service Cluster (k3s)", "K3S_TOKEN", token) else: - print("Warning: Could not extract vault_k3s_token from Ansible vault.") + print("Warning: Could not retrieve k3s-token from 1Password knoey vault.") if __name__ == "__main__": diff --git a/scripts/validation/check_kerberos.sh b/scripts/validation/check_kerberos.sh index 04bbb04..56e4cea 100755 --- a/scripts/validation/check_kerberos.sh +++ b/scripts/validation/check_kerberos.sh @@ -418,24 +418,11 @@ resolve_samba_vars() { fi fi - # 4. Try to resolve password from Ansible Vault + # 4. Try to resolve password from 1Password if [[ -z "$SAMBA_ADMIN_PASSWORD" ]]; then - local vault_file="${root}/infrastructure/inventory/group_vars/ad_dc/vault.yml" - local vault_pass_file="" - if [[ -n "${ANSIBLE_VAULT_PASSWORD_FILE:-}" && -f "${ANSIBLE_VAULT_PASSWORD_FILE}" ]]; then - vault_pass_file="${ANSIBLE_VAULT_PASSWORD_FILE}" - elif [[ -n "${KNOE_HOME:-}" && -f "${KNOE_HOME}/.vault_pass" ]]; then - vault_pass_file="${KNOE_HOME}/.vault_pass" - elif [[ -f "${root}/.vault_pass" ]]; then - vault_pass_file="${root}/.vault_pass" - fi - - if [[ -f "$vault_file" && -n "$vault_pass_file" && -x "$(command -v ansible-vault)" ]]; then - local vault_out - vault_out=$(ansible-vault view "$vault_file" --vault-password-file "$vault_pass_file" 2>/dev/null || true) - if [[ -n "$vault_out" ]]; then - SAMBA_ADMIN_PASSWORD=$(printf '%s\n' "$vault_out" | awk -F: '/^[[:space:]]*vault_samba_dns_admin_pass[[:space:]]*:/ {sub(/^[^:]+:[[:space:]]*/, "", $0); gsub(/"/, "", $0); print $0; exit}') - fi + if command -v op >/dev/null 2>&1 && op whoami >/dev/null 2>&1; then + SAMBA_ADMIN_PASSWORD=$(op item get "samba-dns-admin" --vault knoey --fields password --reveal 2>/dev/null || true) + [[ -n "$SAMBA_ADMIN_PASSWORD" ]] && echo "Fetched SAMBA_ADMIN_PASSWORD from 1Password knoey vault" fi fi diff --git a/tests/etc/test_init_1password.sh b/tests/etc/test_init_1password.sh new file mode 100644 index 0000000..bca3588 --- /dev/null +++ b/tests/etc/test_init_1password.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Tests for etc/init_1password.sh using a stub 'op' command. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +INIT_SCRIPT="${REPO_ROOT}/etc/init_1password.sh" + +pass_count=0 +fail_count=0 + +_pass() { echo " [PASS] $*"; pass_count=$((pass_count + 1)); } +_fail() { echo " [FAIL] $*"; fail_count=$((fail_count + 1)); } + +# --------------------------------------------------------------------------- +# Test 1: --min flag skips preflight +# --------------------------------------------------------------------------- +echo "Test 1: --min mode skips preflight" +output=$(bash "$INIT_SCRIPT" --min 2>&1) +if echo "$output" | grep -q "Skipping"; then + _pass "--min skips 1Password preflight" +else + _fail "--min did not produce skip message; got: $output" +fi + +# --------------------------------------------------------------------------- +# Test 2: missing op CLI exits with error +# --------------------------------------------------------------------------- +echo "Test 2: missing op CLI fails" +output=$(PATH=/nonexistent bash "$INIT_SCRIPT" 2>&1 || true) +if echo "$output" | grep -qi "not found\|install"; then + _pass "missing op CLI produces helpful error" +else + _fail "missing op CLI did not produce helpful error; got: $output" +fi + +# --------------------------------------------------------------------------- +# Test 3: signed-in stub — vault already exists + administrator item exists +# --------------------------------------------------------------------------- +echo "Test 3: vault exists, administrator item exists — no create calls" + +STUB_DIR="$(mktemp -d)" +trap 'rm -rf "$STUB_DIR"' EXIT + +cat > "${STUB_DIR}/op" << 'STUB' +#!/usr/bin/env bash +case "${1:-} ${2:-}" in + "whoami "*) exit 0 ;; + "vault get") exit 0 ;; # knoey vault exists + "item get") exit 0 ;; # administrator item exists + "vault list") printf '[{"name":"knoey"}]'; exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "${STUB_DIR}/op" + +op_calls_file="$(mktemp)" +cat > "${STUB_DIR}/op" << STUB +#!/usr/bin/env bash +echo "\$*" >> "${op_calls_file}" +case "\${1:-} \${2:-}" in + "whoami "*) exit 0 ;; + "vault get") exit 0 ;; + "item get") exit 0 ;; + "vault list") printf '[{"name":"knoey"}]'; exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "${STUB_DIR}/op" + +PATH="${STUB_DIR}:$PATH" bash "$INIT_SCRIPT" >/dev/null 2>&1 || true + +if ! grep -q "vault create" "${op_calls_file}" 2>/dev/null; then + _pass "vault create not called when vault already exists" +else + _fail "vault create was called unexpectedly" +fi + +if ! grep -q "item create" "${op_calls_file}" 2>/dev/null; then + _pass "item create not called when administrator item exists" +else + _fail "item create was called unexpectedly" +fi + +# --------------------------------------------------------------------------- +# Test 4: vault missing → vault create is called +# --------------------------------------------------------------------------- +echo "Test 4: vault missing — vault create is called" +op_calls_file2="$(mktemp)" +cat > "${STUB_DIR}/op" << STUB +#!/usr/bin/env bash +echo "\$*" >> "${op_calls_file2}" +case "\${1:-} \${2:-}" in + "whoami "*) exit 0 ;; + "vault get") exit 1 ;; # vault does NOT exist + "vault list") printf '[]'; exit 0 ;; + "vault create") exit 0 ;; + "item get") exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "${STUB_DIR}/op" + +PATH="${STUB_DIR}:$PATH" bash "$INIT_SCRIPT" >/dev/null 2>&1 || true + +if grep -q "vault create" "${op_calls_file2}" 2>/dev/null; then + _pass "vault create called when vault is absent" +else + _fail "vault create was NOT called when vault is absent" +fi + +# --------------------------------------------------------------------------- +# Test 5: administrator item missing → item create is called +# --------------------------------------------------------------------------- +echo "Test 5: administrator item missing — item create is called" +op_calls_file3="$(mktemp)" +cat > "${STUB_DIR}/op" << STUB +#!/usr/bin/env bash +echo "\$*" >> "${op_calls_file3}" +case "\${1:-} \${2:-}" in + "whoami "*) exit 0 ;; + "vault get") exit 0 ;; + "item get") exit 1 ;; # administrator does NOT exist + "item create") exit 0 ;; + *) exit 0 ;; +esac +STUB +chmod +x "${STUB_DIR}/op" + +PATH="${STUB_DIR}:$PATH" bash "$INIT_SCRIPT" >/dev/null 2>&1 || true + +if grep -q "item create" "${op_calls_file3}" 2>/dev/null; then + _pass "item create called when administrator item is absent" +else + _fail "item create was NOT called when administrator item is absent" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: ${pass_count} passed, ${fail_count} failed" +[[ "$fail_count" -eq 0 ]] diff --git a/tests/etc/test_init_ansible.sh b/tests/etc/test_init_ansible.sh deleted file mode 100644 index 78440d8..0000000 --- a/tests/etc/test_init_ansible.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# Unit test for etc/init_ansible.sh - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -KNOE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd) -ETC_DIR="$KNOE_HOME/etc" -SCRIPT_UNDER_TEST="$ETC_DIR/init_ansible.sh" - -TMP_DIR=$(mktemp -d) -trap 'rm -rf "$TMP_DIR"' EXIT - -# Mock tools -mock_tool() { - cat < "$TMP_DIR/$1" -#!/usr/bin/env bash -echo "Mocked $1 called with \$@" >> "$TMP_DIR/mock_calls.log" -exit 0 -M_EOF - chmod +x "$TMP_DIR/$1" -} - -# Create mocks for common tools -mock_tool kubectl -mock_tool curl -mock_tool docker -mock_tool k3d -mock_tool ansible-playbook -mock_tool tofu -mock_tool terraform -mock_tool ollama -mock_tool jq - -# Mock knoe.cfg -mkdir -p "$TMP_DIR/conf" -cat < "$TMP_DIR/conf/knoe.cfg" -[globals] -knoe.home = $KNOE_HOME -knoe.mode = k3d -[k3s] -server = https://localhost:6443 -token = test-token -C_EOF - -export PATH="$TMP_DIR:$PATH" -export KNOE_HOME="$KNOE_HOME" -export KNOE_CONF="$TMP_DIR/conf" -export NAMESPACE="test-ns" -export PROLE_PASSWD="test-password" -export PROLE_VAULT_PASS_FILE="$TMP_DIR/.vault_pass" - -# Run the script -if [[ "init_ansible.sh" == *.py ]]; then - if [[ "init_ansible.sh" == "render_manifest.py" ]]; then - echo "apiVersion: v1" > "$TMP_DIR/test.yaml" - python3 "$SCRIPT_UNDER_TEST" "$TMP_DIR/test.yaml" > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - else - python3 "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - fi - RC=$? -else - # Check for usage() or if it's a script that likely needs arguments - if grep -q "usage()" "$SCRIPT_UNDER_TEST"; then - bash "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - RC=$? - else - # Try a few common non-destructive actions - bash "$SCRIPT_UNDER_TEST" status > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" || \ - bash "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" || \ - bash "$SCRIPT_UNDER_TEST" > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - RC=$? - fi -fi - -if [[ $RC -eq 0 || $RC -eq 1 || $RC -eq 2 ]]; then - echo "SUCCESS (RC=$RC)" - exit 0 -else - echo "FAILURE with RC $RC" - cat "$TMP_DIR/stderr" - exit 1 -fi diff --git a/tests/etc/test_set-k3s-token-vault.sh b/tests/etc/test_set-k3s-token-vault.sh deleted file mode 100644 index 4085484..0000000 --- a/tests/etc/test_set-k3s-token-vault.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# Unit test for etc/set-k3s-token-vault.sh - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -KNOE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd) -ETC_DIR="$KNOE_HOME/etc" -SCRIPT_UNDER_TEST="$ETC_DIR/set-k3s-token-vault.sh" - -TMP_DIR=$(mktemp -d) -trap 'rm -rf "$TMP_DIR"' EXIT - -# Mock tools -mock_tool() { - cat < "$TMP_DIR/$1" -#!/usr/bin/env bash -echo "Mocked $1 called with \$@" >> "$TMP_DIR/mock_calls.log" -exit 0 -M_EOF - chmod +x "$TMP_DIR/$1" -} - -# Create mocks for common tools -mock_tool kubectl -mock_tool curl -mock_tool docker -mock_tool k3d -mock_tool ansible-playbook -mock_tool tofu -mock_tool terraform -mock_tool ollama -mock_tool jq - -# Mock knoe.cfg -mkdir -p "$TMP_DIR/conf" -cat < "$TMP_DIR/conf/knoe.cfg" -[globals] -knoe.home = $KNOE_HOME -knoe.mode = k3d -[k3s] -server = https://localhost:6443 -token = test-token -C_EOF - -export PATH="$TMP_DIR:$PATH" -export KNOE_HOME="$KNOE_HOME" -export KNOE_CONF="$TMP_DIR/conf" -export NAMESPACE="test-ns" -export PROLE_PASSWD="test-password" - -# Run the script -if [[ "set-k3s-token-vault.sh" == *.py ]]; then - if [[ "set-k3s-token-vault.sh" == "render_manifest.py" ]]; then - echo "apiVersion: v1" > "$TMP_DIR/test.yaml" - python3 "$SCRIPT_UNDER_TEST" "$TMP_DIR/test.yaml" > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - else - python3 "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - fi - RC=$? -else - # Check for usage() or if it's a script that likely needs arguments - if grep -q "usage()" "$SCRIPT_UNDER_TEST"; then - bash "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - RC=$? - else - # Try a few common non-destructive actions - bash "$SCRIPT_UNDER_TEST" status > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" || \ - bash "$SCRIPT_UNDER_TEST" --help > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" || \ - bash "$SCRIPT_UNDER_TEST" > "$TMP_DIR/stdout" 2> "$TMP_DIR/stderr" - RC=$? - fi -fi - -if [[ $RC -eq 0 || $RC -eq 1 || $RC -eq 2 ]]; then - echo "SUCCESS (RC=$RC)" - exit 0 -else - echo "FAILURE with RC $RC" - cat "$TMP_DIR/stderr" - exit 1 -fi diff --git a/tests/installer/test_config_helpers.py b/tests/installer/test_config_helpers.py index da1ca34..b1cc8da 100644 --- a/tests/installer/test_config_helpers.py +++ b/tests/installer/test_config_helpers.py @@ -3,7 +3,6 @@ Unit tests for previously untested installer/config.py helpers. Covers: _get_secret_key_file, _get_file_key, normalize_version, - _extract_inline_vault_block, _try_read_ansible_vault_value, get_dep_info (check_cmd/version_cmd paths), load_dependencies, get_ui_icon_image_path, get_ui_background_image_path, get_properties """ @@ -18,8 +17,6 @@ from knoe.config import ( _get_file_key, normalize_version, _extract_yaml_scalar_from_text, - _extract_inline_vault_block, - _try_read_ansible_vault_value, get_dep_info, detect_dependency_platform, get_dependency_milestone_title, @@ -116,119 +113,19 @@ def test_extract_yaml_scalar_missing_key(): assert _extract_yaml_scalar_from_text("a: b\n", "missing") == "" -# --------------------------------------------------------------------------- -# _extract_inline_vault_block -# --------------------------------------------------------------------------- - -def test_extract_inline_vault_block_not_found(): - text = "key: value\nother: x\n" - result = _extract_inline_vault_block(text, "missing") - assert result == "" - - -def test_extract_inline_vault_block_plain_value(): - """Key found but block doesn't start with $ANSIBLE_VAULT → return ''.""" - text = "mykey:\n just some plain text\n more text\n" - result = _extract_inline_vault_block(text, "mykey") - assert result == "" - - -def test_extract_inline_vault_block_ansible_vault(): - text = ( - "mykey:\n" - " $ANSIBLE_VAULT;1.1;AES256\n" - " 6162636465666768\n" - ) - result = _extract_inline_vault_block(text, "mykey") - assert "$ANSIBLE_VAULT" in result - - -def test_extract_inline_vault_block_no_block_lines(): - """Key found but indented block is empty → return ''.""" - text = "mykey:\nother: x\n" - result = _extract_inline_vault_block(text, "mykey") - assert result == "" - - -# --------------------------------------------------------------------------- -# _try_read_ansible_vault_value -# --------------------------------------------------------------------------- - -def test_try_read_ansible_vault_value_file_not_found(tmp_path): - result = _try_read_ansible_vault_value(tmp_path / "nonexistent.cfg", "mykey") - assert result == "" - - -def test_try_read_ansible_vault_value_plain_text(tmp_path): - vault = tmp_path / "knoe.cfg" - vault.write_text("db_password: supersecret\n") - result = _try_read_ansible_vault_value(vault, "db_password") +def test_extract_yaml_scalar_plain_value(): + """Plain YAML scalar extraction works for config files.""" + text = "db_password: supersecret\n" + result = _extract_yaml_scalar_from_text(text, "db_password") assert result == "supersecret" -def test_try_read_ansible_vault_value_plain_text_vault_marker_skipped(tmp_path): - """Plain value starting with $ANSIBLE_VAULT is treated as encrypted → skip.""" - vault = tmp_path / "knoe.cfg" - vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n") - # No vault password file → returns "" - with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": ""}, clear=False), \ - patch("knoe.config.shutil.which", return_value=None): - result = _try_read_ansible_vault_value(vault, "db_password") +def test_extract_yaml_scalar_missing_key(): + text = "other_key: value\n" + result = _extract_yaml_scalar_from_text(text, "db_password") assert result == "" -def test_try_read_ansible_vault_value_no_password_file(tmp_path): - vault = tmp_path / "knoe.cfg" - vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n") - with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": ""}, clear=False), \ - patch("knoe.config.shutil.which", return_value=None): - result = _try_read_ansible_vault_value(vault, "db_password") - assert result == "" - - -def test_try_read_ansible_vault_value_with_vault_success(tmp_path): - vault = tmp_path / "knoe.cfg" - vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n") - pw_file = tmp_path / ".vault_pass" - pw_file.write_text("mypassword\n") - - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "db_password: decryptedvalue\n" - - with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": str(pw_file)}, clear=False), \ - patch("knoe.config.shutil.which", return_value="/usr/bin/ansible-vault"), \ - patch("knoe.config.subprocess.run", return_value=mock_result): - result = _try_read_ansible_vault_value(vault, "db_password") - assert result == "decryptedvalue" - - -def test_try_read_ansible_vault_value_vault_fails_no_inline(tmp_path): - vault = tmp_path / "knoe.cfg" - vault.write_text("db_password: $ANSIBLE_VAULT;1.1;AES256\n") - pw_file = tmp_path / ".vault_pass" - pw_file.write_text("mypassword\n") - - mock_result = MagicMock() - mock_result.returncode = 1 - mock_result.stdout = "" - - with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": str(pw_file)}, clear=False), \ - patch("knoe.config.shutil.which", return_value="/usr/bin/ansible-vault"), \ - patch("knoe.config.subprocess.run", return_value=mock_result): - result = _try_read_ansible_vault_value(vault, "db_password") - assert result == "" - - -def test_try_read_ansible_vault_value_uses_project_root_vault_pass(tmp_path): - """Falls back to PROJECT_ROOT/.vault_pass if no env var set.""" - vault = tmp_path / "knoe.cfg" - vault.write_text("mykey: plainvalue\n") - with patch.dict(os.environ, {"ANSIBLE_VAULT_PASSWORD_FILE": ""}, clear=False): - result = _try_read_ansible_vault_value(vault, "mykey") - assert result == "plainvalue" - - # --------------------------------------------------------------------------- # get_dep_info — check_cmd and version_cmd paths # --------------------------------------------------------------------------- diff --git a/tests/installer/test_core_classes.py b/tests/installer/test_core_classes.py index 3834551..c3a4de1 100644 --- a/tests/installer/test_core_classes.py +++ b/tests/installer/test_core_classes.py @@ -394,7 +394,7 @@ class TestKnoeController: with ( mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), mock.patch.object( - installer, "_load_db_password_from_ansible_vault", return_value="" + installer, "_load_db_password_from_1password", return_value="" ), mock.patch.object(installer, "_write_cfg"), mock.patch.object(installer, "_perform_cluster_reset"), @@ -405,7 +405,7 @@ class TestKnoeController: assert rc == 2 - def test_silent_installer_loads_db_password_from_ansible_vault(self, tmp_path): + def test_silent_installer_loads_db_password_from_1password(self, tmp_path): c = KnoeController(tmp_path) installer = KnoeConsoleInstaller(c) @@ -413,7 +413,7 @@ class TestKnoeController: mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), mock.patch.object( installer, - "_load_db_password_from_ansible_vault", + "_load_db_password_from_1password", return_value="vaultpw123", ), mock.patch.object(installer, "_write_cfg") as write_cfg, @@ -434,7 +434,7 @@ class TestKnoeController: assert "deployment" in captured["ids"] assert write_cfg.call_count == 2 - def test_silent_installer_persists_bootstrap_password_to_ansible_vault(self, tmp_path): + def test_silent_installer_persists_bootstrap_password_to_1password(self, tmp_path): c = KnoeController(tmp_path) installer = KnoeConsoleInstaller(c) fake_stdin = mock.Mock() @@ -448,7 +448,7 @@ class TestKnoeController: with ( mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), mock.patch.object( - installer, "_load_db_password_from_ansible_vault", return_value="" + installer, "_load_db_password_from_1password", return_value="" ), mock.patch.object( installer, @@ -456,7 +456,7 @@ class TestKnoeController: return_value="bootstrap_pw_123", ), mock.patch.object( - installer, "_persist_db_password_to_ansible_vault" + installer, "_persist_db_password_to_1password" ) as persist_pw, mock.patch.object(installer, "_write_cfg") as write_cfg, mock.patch.object(installer, "_perform_cluster_reset"), @@ -488,10 +488,10 @@ class TestKnoeController: }, ), mock.patch.object( - installer, "_load_db_password_from_ansible_vault", return_value="" + installer, "_load_db_password_from_1password", return_value="" ), mock.patch.object( - installer, "_persist_db_password_to_ansible_vault" + installer, "_persist_db_password_to_1password" ) as persist_pw, mock.patch.object(installer, "_write_cfg") as write_cfg, mock.patch.object(installer, "_perform_cluster_reset"), @@ -522,11 +522,11 @@ class TestKnoeController: ), mock.patch.object( installer, - "_load_db_password_from_ansible_vault", + "_load_db_password_from_1password", return_value="vault_pw_999", ), mock.patch.object( - installer, "_persist_db_password_to_ansible_vault" + installer, "_persist_db_password_to_1password" ) as persist_pw, mock.patch.object(installer, "_write_cfg"), mock.patch.object(installer, "_perform_cluster_reset"), diff --git a/tests/installer/test_env_helpers.py b/tests/installer/test_env_helpers.py index 4033134..6edcfa9 100644 --- a/tests/installer/test_env_helpers.py +++ b/tests/installer/test_env_helpers.py @@ -21,7 +21,6 @@ from knoe.config import ( _filter_cfg_values_for_persistence, _normalize_cfg_value_for_persistence, _extract_yaml_scalar_from_text as cfg_extract_yaml, - _extract_inline_vault_block as cfg_extract_vault, _load_properties, _write_k3s_kubeconfig, _merge_kubeconfig, @@ -406,10 +405,6 @@ class TestYamlHelpers: assert _extract_inline_vault_block("key: value", "key") == "" assert _extract_inline_vault_block("key: value", "missing") == "" - def test_cfg_extract_vault(self): - text = "secret: !vault |\n $ANSIBLE_VAULT;1.1;AES256\n data\nother: x" - result = cfg_extract_vault(text, "secret") - assert "$ANSIBLE_VAULT" in result def test_parse_yaml_scalar_values(self, tmp_path): f = tmp_path / "test.yaml" diff --git a/tests/installer/test_onepassword.py b/tests/installer/test_onepassword.py new file mode 100644 index 0000000..aec7008 --- /dev/null +++ b/tests/installer/test_onepassword.py @@ -0,0 +1,168 @@ +"""Unit tests for knoe.core.onepassword — mocks the op CLI subprocess calls.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from knoe.core.onepassword import ( + op_available, + ensure_op_signed_in, + ensure_knoey_vault, + get_secret, + set_secret, + get_administrator_password, + ensure_administrator_secret, +) + + +def _make_result(returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock: + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + +class TestOpAvailable: + def test_found(self): + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"): + assert op_available() is True + + def test_not_found(self): + with patch("knoe.core.onepassword.shutil.which", return_value=None): + assert op_available() is False + + +class TestEnsureOpSignedIn: + def test_already_signed_in(self): + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", return_value=_make_result(0)): + ensure_op_signed_in() + + def test_not_signed_in_triggers_signin(self): + calls = [] + + def fake_run(cmd, **_): + calls.append(cmd) + if "whoami" in cmd: + return _make_result(1) + return _make_result(0) + + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", side_effect=fake_run): + ensure_op_signed_in() + + assert any("signin" in c for c in calls) + + def test_raises_when_op_missing(self): + with patch("knoe.core.onepassword.shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="1Password CLI"): + ensure_op_signed_in() + + +class TestEnsureKnoeyVault: + def test_vault_already_exists(self): + vaults = json.dumps([{"name": "knoey"}, {"name": "Personal"}]) + + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", + return_value=_make_result(0, vaults)): + ensure_knoey_vault() + + def test_vault_created_when_absent(self): + vaults = json.dumps([{"name": "Personal"}]) + calls = [] + + def fake_run(cmd, **_): + calls.append(list(cmd)) + return _make_result(0, vaults if "list" in cmd else "") + + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", side_effect=fake_run): + ensure_knoey_vault() + + assert any("create" in c and "knoey" in c for c in calls) + + +class TestGetSecret: + def test_returns_value(self): + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", + return_value=_make_result(0, "mysecret")): + result = get_secret("administrator") + assert result == "mysecret" + + def test_returns_empty_on_failure(self): + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", + return_value=_make_result(1, "")): + result = get_secret("administrator") + assert result == "" + + +class TestSetSecret: + def test_edits_existing_item(self): + calls = [] + + def fake_run(cmd, **_): + calls.append(list(cmd)) + return _make_result(0) + + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", side_effect=fake_run): + set_secret("administrator", "password", "newpassword") + + assert any("edit" in c for c in calls) + + def test_creates_new_item(self): + calls = [] + + def fake_run(cmd, **_): + calls.append(list(cmd)) + if "get" in cmd: + return _make_result(1) + return _make_result(0) + + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", side_effect=fake_run): + set_secret("administrator", "password", "newpassword") + + assert any("create" in c for c in calls) + + +class TestGetAdministratorPassword: + def test_delegates_to_get_secret(self): + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", + return_value=_make_result(0, "adminpass")): + result = get_administrator_password() + assert result == "adminpass" + + +class TestEnsureAdministratorSecret: + def test_returns_existing(self): + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", + return_value=_make_result(0, "existingpass")): + result = ensure_administrator_secret() + assert result == "existingpass" + + def test_creates_when_missing(self): + calls = [] + + def fake_run(cmd, **_): + calls.append(list(cmd)) + if "get" in cmd and "--fields" in cmd: + return _make_result(0, "") + if "get" in cmd: + return _make_result(1) + return _make_result(0) + + with patch("knoe.core.onepassword.shutil.which", return_value="/usr/bin/op"), \ + patch("knoe.core.onepassword.subprocess.run", side_effect=fake_run): + result = ensure_administrator_secret() + + assert isinstance(result, str) and len(result) == 32 + assert any("create" in c for c in calls) diff --git a/tests/test_repair_update_and_supabase_flags.py b/tests/test_repair_update_and_supabase_flags.py index f0f5dd6..2aac21b 100644 --- a/tests/test_repair_update_and_supabase_flags.py +++ b/tests/test_repair_update_and_supabase_flags.py @@ -151,7 +151,8 @@ def test_main_update_flag_dispatches_to_run_update(tmp_path, monkeypatch): installer.run.return_value = 0 monkeypatch.setattr(sys, "argv", ["install.py", "--update", "-c", str(cfg_path)]) - monkeypatch.setattr(screens_mod, "_ensure_ansible_vault_credentials", lambda **_: None) + monkeypatch.setattr(screens_mod, "ensure_op_signed_in", lambda: None) + monkeypatch.setattr(screens_mod, "ensure_knoey_vault", lambda: None) monkeypatch.setattr(screens_mod, "KnoeController", MagicMock(return_value=MagicMock())) monkeypatch.setattr(screens_mod, "KnoeConsoleInstaller", MagicMock(return_value=installer)) @@ -217,7 +218,8 @@ def test_main_delete_db_is_forwarded_to_console_installer(tmp_path, monkeypatch) "argv", ["install.py", "--silent", "--reset", "--delete-db", "-c", str(cfg_path)], ) - monkeypatch.setattr(screens_mod, "_ensure_ansible_vault_credentials", lambda **_: None) + monkeypatch.setattr(screens_mod, "ensure_op_signed_in", lambda: None) + monkeypatch.setattr(screens_mod, "ensure_knoey_vault", lambda: None) monkeypatch.setattr(screens_mod, "KnoeController", MagicMock(return_value=MagicMock())) monkeypatch.setattr(screens_mod, "KnoeConsoleInstaller", installer_factory) diff --git a/update.sh b/update.sh index 7f315a0..b893be6 100755 --- a/update.sh +++ b/update.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# update.sh — Apply the vault master password to all knoe resources. +# update.sh — Apply the master password to all knoe resources. # -# Reads vault_knoe_db_master_password from: -# infrastructure/inventory/group_vars/all/vault_db_master.yml +# Reads the administrator password from: +# 1Password knoey vault → item 'administrator' → field 'password' # # Applies the password to: # - k8s secrets: knoe-db-user, knoe-db-superuser (knoe-cnpg-0, knoe-db-0) @@ -13,12 +13,11 @@ # - OpenBao kv secrets (if reachable) # # Usage: -# ./update.sh [--dry-run] [--prompt] [--vault-pass-file ] -# [--skip-db] [--skip-grafana] [--skip-cfg] +# ./update.sh [--dry-run] [--prompt] [--skip-db] [--skip-grafana] [--skip-cfg] # # --prompt Read password interactively (masked, confirmed twice) and -# re-create vault_db_master.yml with ansible-vault encrypt (whole-file). -# Use this when the vault file is missing or corrupt. +# save it to 1Password knoey/administrator. +# Use this when you need to rotate the master password. # # The script is idempotent — safe to run on every deploy or rotation. set -euo pipefail @@ -26,7 +25,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # --------------------------------------------------------------------------- -# Auto-activate project virtualenv (provides ansible-vault, python3 packages) +# Auto-activate project virtualenv (provides python3 packages) # --------------------------------------------------------------------------- if [[ -z "${VIRTUAL_ENV:-}" && -f "${ROOT_DIR}/bin/activate" ]]; then # shellcheck disable=SC1091 @@ -41,9 +40,8 @@ PROMPT_MODE=false SKIP_DB=false SKIP_GRAFANA=false SKIP_CFG=false -VAULT_PASS_FILE="${ROOT_DIR}/.vault_pass" -VAULT_FILE="${ROOT_DIR}/infrastructure/inventory/group_vars/all/vault_db_master.yml" -VAULT_KEY="vault_knoe_db_master_password" +OP_VAULT="${OP_VAULT:-knoey}" +OP_ITEM="administrator" # Cluster contexts — override via env or auto-detect from config files APP_CTX="${APP_CLUSTER_KUBECONTEXT:-}" @@ -63,7 +61,7 @@ while [[ $# -gt 0 ]]; do --skip-db) SKIP_DB=true; shift ;; --skip-grafana) SKIP_GRAFANA=true; shift ;; --skip-cfg) SKIP_CFG=true; shift ;; - --vault-pass-file) VAULT_PASS_FILE="${2:?}"; shift 2 ;; + --vault) OP_VAULT="${2:?}"; shift 2 ;; -h|--help) grep '^#' "$0" | head -25 | sed 's/^# \?//' exit 0 ;; @@ -144,70 +142,49 @@ kapp() { kubectl ${APP_CTX:+--context="$APP_CTX"} "$@"; } kdb() { kubectl ${DB_CTX:+--context="$DB_CTX"} "$@"; } # --------------------------------------------------------------------------- -# Step 1a: Decrypt master password from Ansible vault +# Step 1a: Read master password from 1Password # --------------------------------------------------------------------------- -decrypt_vault_password() { - log "Decrypting master password from vault..." +read_1password_password() { + log "Reading master password from 1Password vault '$OP_VAULT' item '$OP_ITEM'..." - if [[ ! -f "$VAULT_FILE" ]]; then - fail "Vault file not found: $VAULT_FILE (run with --prompt to create it)" - fi - if [[ ! -f "$VAULT_PASS_FILE" ]]; then - fail "Vault password file not found: $VAULT_PASS_FILE (set --vault-pass-file)" + if ! command -v op >/dev/null 2>&1; then + fail "1Password CLI (op) not found. Install: brew install 1password-cli" fi - if ! command -v ansible-vault >/dev/null 2>&1; then - fail "ansible-vault not found — install ansible or activate the project virtualenv." + if ! op whoami >/dev/null 2>&1; then + log "No active 1Password session. Signing in..." + op signin || fail "1Password sign-in failed." fi - local decrypted - decrypted=$(ansible-vault view \ - --vault-password-file "$VAULT_PASS_FILE" \ - "$VAULT_FILE" 2>&1) || fail "ansible-vault view failed — vault file may be corrupt or unencrypted. Run: ./update.sh --prompt" - - # Extract the value from YAML local pw - pw=$(python3 - "$VAULT_KEY" </dev/null) \ + || fail "Could not read '$OP_ITEM' from vault '$OP_VAULT'. Run: ./update.sh --prompt" if [[ -z "$pw" ]]; then - fail "Decrypted password is empty — check vault file key: $VAULT_KEY" + fail "Password is empty in 1Password vault '$OP_VAULT' item '$OP_ITEM'." fi printf '%s' "$pw" } # --------------------------------------------------------------------------- -# Step 1b: Prompt for new password + recreate vault file (--prompt mode) +# Step 1b: Prompt for new password + save to 1Password (--prompt mode) # --------------------------------------------------------------------------- -prompt_and_recreate_vault() { - log "Creating new master password and recreating vault file..." +prompt_and_save_to_1password() { + log "Creating new master password and saving to 1Password..." - if [[ ! -f "$VAULT_PASS_FILE" ]]; then - fail "Vault password file not found: $VAULT_PASS_FILE (set --vault-pass-file)" + if ! command -v op >/dev/null 2>&1; then + fail "1Password CLI (op) not found. Install: brew install 1password-cli" fi - if ! command -v ansible-vault >/dev/null 2>&1; then - fail "ansible-vault not found — activate the project virtualenv first." + if ! op whoami >/dev/null 2>&1; then + op signin || fail "1Password sign-in failed." fi - # Read new password twice with masked echo local pw1 pw2 - # Use /dev/tty so prompt works even when stdout is redirected if [[ -t 0 ]]; then read -r -s -p "New master password: " pw1 /dev/tty read -r -s -p "Confirm master password: " pw2 /dev/tty else - # Non-interactive fallback (e.g. piped input) read -r pw1 pw2="$pw1" fi @@ -219,22 +196,20 @@ prompt_and_recreate_vault() { fail "Passwords do not match — please try again." fi - # Write a plain YAML file then encrypt the whole file. - # We use whole-file encryption (ansible-vault encrypt) NOT encrypt_string, - # so that ansible-vault view can read it back correctly. - log "Encrypting password with ansible-vault (whole-file)..." - mkdir -p "$(dirname "$VAULT_FILE")" - local plain_tmp="${VAULT_FILE}.plain.tmp" - printf -- '---\n%s: %s\n' "$VAULT_KEY" "$pw1" > "$plain_tmp" - ansible-vault encrypt \ - --vault-password-file "$VAULT_PASS_FILE" \ - --output "$VAULT_FILE" \ - "$plain_tmp" 2>/dev/null \ - || { rm -f "$plain_tmp"; fail "ansible-vault encrypt failed — check vault password file."; } - rm -f "$plain_tmp" - log "Vault file recreated: $VAULT_FILE" + if op item get "$OP_ITEM" --vault "$OP_VAULT" >/dev/null 2>&1; then + op item edit "$OP_ITEM" --vault "$OP_VAULT" "password=${pw1}" >/dev/null \ + || fail "Failed to update '$OP_ITEM' in vault '$OP_VAULT'." + log "Updated existing item '$OP_ITEM' in vault '$OP_VAULT'." + else + op item create \ + --category login \ + --title "$OP_ITEM" \ + --vault "$OP_VAULT" \ + "password=${pw1}" >/dev/null \ + || fail "Failed to create '$OP_ITEM' in vault '$OP_VAULT'." + log "Created item '$OP_ITEM' in vault '$OP_VAULT'." + fi - # Return the plaintext password for immediate use printf '%s' "$pw1" } @@ -549,17 +524,17 @@ verify() { main() { log "=== knoe update.sh — master password rotation ===" [[ "$DRY_RUN" == "true" ]] && log "(DRY-RUN mode — no changes will be made)" - [[ "$PROMPT_MODE" == "true" ]] && log "(PROMPT mode — will recreate vault file)" + [[ "$PROMPT_MODE" == "true" ]] && log "(PROMPT mode — will update 1Password item)" resolve_contexts local MASTER_PW if [[ "$PROMPT_MODE" == "true" ]]; then - MASTER_PW=$(prompt_and_recreate_vault) - log "Vault file recreated and master password ready." + MASTER_PW=$(prompt_and_save_to_1password) + log "1Password item updated and master password ready." else - MASTER_PW=$(decrypt_vault_password) - log "Vault master password decrypted successfully." + MASTER_PW=$(read_1password_password) + log "Master password read from 1Password successfully." fi if [[ "$SKIP_DB" != "true" ]]; then