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.

This commit is contained in:
chrisfu 2026-04-22 20:54:13 -07:00
parent 55b6a6aff3
commit b421f49af1
30 changed files with 744 additions and 1323 deletions

View File

@ -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) ![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) ![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) ![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) ![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) ![Raspberry%20Pi](https://img.shields.io/badge/Raspberry%20Pi-C51A4A?style=for-the-badge&logo=raspberrypi&logoColor=white)

View File

@ -730,96 +730,6 @@ def _extract_yaml_scalar_from_text(text: str, key: str) -> str:
return "" 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( def _update_knoe_cfg_value(

View File

@ -1,6 +1,18 @@
# knoe.cfg Secrets # 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 ## Temporary encrypted values

View File

@ -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`)

74
etc/init_1password.sh Executable file
View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -2,23 +2,17 @@
import sys import sys
from pathlib import Path from pathlib import Path
# Add project root to path
ROOT_DIR = Path(__file__).resolve().parents[1] ROOT_DIR = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT_DIR)) sys.path.append(str(ROOT_DIR))
from knoe import config as inst_config from knoe import config as inst_config
from knoe.core.onepassword import get_secret, op_available
def main(): def main():
vault_path = ( token = ""
ROOT_DIR if op_available():
/ "infrastructure" token = get_secret("k3s-token", "credential").strip()
/ "inventory"
/ "group_vars"
/ "all"
/ "vault_k3s.yml"
)
token = inst_config._try_read_ansible_vault_value(vault_path, "vault_k3s_token")
if token: if token:
print("Syncing K3S_TOKEN to active config...") 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("Inputs", "init_cluster.k3s_token", token)
inst_config._update_knoe_cfg_value("Service Cluster (k3s)", "K3S_TOKEN", token) inst_config._update_knoe_cfg_value("Service Cluster (k3s)", "K3S_TOKEN", token)
else: 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__": if __name__ == "__main__":

View File

@ -1,8 +0,0 @@
$ANSIBLE_VAULT;1.1;AES256
33623434353765353030333339626631656163343239353230643430356466306461663835383566
3564333737623034343837616632386462306462366538390a623538373034383839376261653734
36653238393437656332373965663866653730343864333063366462303661356366323262363839
3961633266353131310a313930346164393031623037393339356539616639343536353163343036
61353535633234363535633437356162626234356139323531643534613961633166393135356562
30306634646130353665656262393132656632373634353765316630643665356331363435366165
356261383336383736336336356564386337

View File

@ -81,6 +81,12 @@ if [[ -n "${_script_dir}" && -d "${_script_dir}/knoe" ]]; then
# Local checkout: delegate to the Python installer module. # Local checkout: delegate to the Python installer module.
cd "${_script_dir}" 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 a built binary exists, prefer it.
if [[ -x "dist/knoe" ]]; then if [[ -x "dist/knoe" ]]; then
exec "./dist/knoe" "$@" exec "./dist/knoe" "$@"

View File

@ -759,96 +759,6 @@ def _extract_yaml_scalar_from_text(text: str, key: str) -> str:
return "" 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( def _update_knoe_cfg_value(
@ -1154,14 +1064,14 @@ _MACOS_DEPENDENCIES = [
"bin": "python3", "bin": "python3",
}, },
{ {
"id": "ansible", "id": "op",
"name": "ansible", "name": "1password-cli",
"parent": "brew", "parent": "brew",
"description": "Infrastructure automation tool", "description": "1Password CLI for secret management",
"url": "https://www.ansible.com", "url": "https://developer.1password.com/docs/cli",
"install_cmd": "brew install ansible", "install_cmd": "brew install 1password-cli",
"check_cmd": "ansible --version", "check_cmd": "op --version",
"bin": "ansible", "bin": "op",
}, },
{ {
"id": "kubectl", "id": "kubectl",
@ -1265,13 +1175,13 @@ def _linux_dependencies(manager: str) -> list[dict]:
"bin": "python3", "bin": "python3",
}, },
{ {
"id": "ansible", "id": "op",
"name": "ansible", "name": "1password-cli",
"description": "Infrastructure automation tool", "description": "1Password CLI for secret management",
"url": "https://www.ansible.com", "url": "https://developer.1password.com/docs/cli",
"install_cmd": install("ansible"), "install_cmd": install("1password-cli"),
"check_cmd": "ansible --version", "check_cmd": "op --version",
"bin": "ansible", "bin": "op",
}, },
{ {
"id": "kubectl", "id": "kubectl",
@ -1485,9 +1395,9 @@ def get_required_dependency_ids(inputs: dict | None = None) -> set[str]:
mode = _deployment_mode_hint(inputs) mode = _deployment_mode_hint(inputs)
if mode == "min": 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": if mode == "gke":
required = base_required | {"gcloud"} required = base_required | {"gcloud"}
platform_info = detect_dependency_platform() platform_info = detect_dependency_platform()

View File

@ -26,7 +26,6 @@ from knoe.config import (
_write_k3s_kubeconfig, _write_k3s_kubeconfig,
_encrypt_cfg_secret, _encrypt_cfg_secret,
_merge_kubeconfig, _merge_kubeconfig,
_try_read_ansible_vault_value,
) )
from knoe.core.build_context import copy_build_context_dir from knoe.core.build_context import copy_build_context_dir
from knoe.core.cnpg_placement import ( from knoe.core.cnpg_placement import (
@ -4472,14 +4471,7 @@ class KnoeConsoleInstaller(KnoeInstaller):
self.err(f"[WARN] Ansible playbook not found: {playbook}") self.err(f"[WARN] Ansible playbook not found: {playbook}")
return None return None
vault_pass = self.project_root / ".vault_pass" cmd = ["ansible-playbook", str(playbook)]
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))
self.log("==> Fetching k3s kubeconfig via Ansible") self.log("==> Fetching k3s kubeconfig via Ansible")
try: try:
@ -5829,16 +5821,11 @@ class KnoeConsoleInstaller(KnoeInstaller):
self.log("[RESET] Resetting k3s cluster (mode k3s)...") self.log("[RESET] Resetting k3s cluster (mode k3s)...")
self._cleanup_local_k3s_artifacts() 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 = [ delete_cmd = [
"./ansible.sh", "./ansible.sh",
"-p", "-p",
"infrastructure/playbooks/k3s_delete.yml", "infrastructure/playbooks/k3s_delete.yml",
] + vault_args ]
self.log(f"Running: {' '.join(delete_cmd)}") self.log(f"Running: {' '.join(delete_cmd)}")
rc = self._run_cmd(delete_cmd) rc = self._run_cmd(delete_cmd)
if rc != 0: if rc != 0:
@ -6989,132 +6976,30 @@ class KnoeConsoleInstaller(KnoeInstaller):
) )
out.flush() out.flush()
def _db_master_vault_file(self) -> Path: def _load_db_password_from_1password(self, *, log_found: bool = True) -> str:
raw = (os.environ.get("PROLE_DB_MASTER_VAULT_FILE") or "").strip() try:
if raw: from knoe.core.onepassword import get_secret, op_available
expanded = _expand_cfg_value(raw, _collect_cfg_vars()) if not op_available():
return Path(expanded).expanduser() return ""
return ( password = get_secret("administrator", "password").strip()
self.project_root if password and log_found:
/ "infrastructure" self.log("[CONFIG] Loaded DB master password from 1Password knoey vault.")
/ "inventory" return password
/ "group_vars" except Exception:
/ "all" return ""
/ "vault_db_master.yml"
)
def _db_master_vault_key(self) -> str: def _persist_db_password_to_1password(self, password: str) -> None:
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:
if not password: if not password:
raise RuntimeError("Cannot persist an empty database master 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: try:
res = subprocess.run( from knoe.core.onepassword import set_secret, op_available
cmd, if not op_available():
capture_output=True, self.log("[WARN] 1Password CLI not available; skipping password persistence.")
text=True, return
timeout=20, set_secret("administrator", "password", password)
env=os.environ.copy(), self.log("[CONFIG] Persisted DB master password to 1Password knoey vault.")
stdin=subprocess.DEVNULL, except Exception as e:
) raise RuntimeError(f"Failed to persist database master password to 1Password: {e}") from e
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
def run(self) -> int: def run(self) -> int:
_configure_unbuffered_io() _configure_unbuffered_io()
@ -7141,26 +7026,26 @@ class KnoeConsoleInstaller(KnoeInstaller):
or "" or ""
).strip() ).strip()
# If runtime env provides a password, reconcile with vault for # If runtime env provides a password, reconcile with 1Password for
# consistency and bootstrap vault when missing. # consistency and bootstrap when missing.
if db_pw and env_db_pw and db_pw == env_db_pw: 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: if vault_pw and vault_pw != db_pw:
self.log( self.log(
"[WARN] Runtime DB password differs from Ansible Vault value; " "[WARN] Runtime DB password differs from 1Password value; "
"using vault password for consistency." "using 1Password value for consistency."
) )
self.inputs["init_password.db_password"] = vault_pw self.inputs["init_password.db_password"] = vault_pw
self.inputs["init_password.db_password_confirm"] = vault_pw self.inputs["init_password.db_password_confirm"] = vault_pw
db_pw = vault_pw db_pw = vault_pw
elif not 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( 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: if not db_pw:
vault_pw = self._load_db_password_from_ansible_vault() vault_pw = self._load_db_password_from_1password()
if vault_pw: if vault_pw:
self.inputs["init_password.db_password"] = vault_pw self.inputs["init_password.db_password"] = vault_pw
self.inputs["init_password.db_password_confirm"] = vault_pw self.inputs["init_password.db_password_confirm"] = vault_pw
@ -7182,14 +7067,14 @@ class KnoeConsoleInstaller(KnoeInstaller):
) )
if allow_prompt: if allow_prompt:
new_pw = self._prompt_for_master_password() 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( self.log(
"[CONFIG] Master password captured interactively and saved for subsequent runs." "[CONFIG] Master password captured interactively and saved to 1Password."
) )
else: else:
raise RuntimeError( raise RuntimeError(
"Database master password is missing and prompting is unavailable. " "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." "or provide KNOE_DB_PASSWORD/DB_PASSWORD."
) )
self.inputs["init_password.db_password"] = new_pw self.inputs["init_password.db_password"] = new_pw

View File

@ -345,7 +345,7 @@ def _resolve_k3s_connection(
if not token and cfg_token: if not token and cfg_token:
token = _normalize_k3s_token(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: if not ansible_topology:
try: try:
root = project_root or PROJECT_ROOT root = project_root or PROJECT_ROOT
@ -358,6 +358,15 @@ def _resolve_k3s_connection(
if not token and ansible_topology.get("k3s_token"): if not token and ansible_topology.get("k3s_token"):
token = _normalize_k3s_token(ansible_topology["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"): if server and not server.startswith("http"):
server = f"https://{server}" server = f"https://{server}"
return server, token 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"] 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: def _openbao_placeholder(namespace: str, leaf: str, key: str | None = None) -> str:
ns = (namespace or "").strip() or "default" ns = (namespace or "").strip() or "default"
path = f"kv/knoe/{ns}/{leaf}" path = f"kv/knoe/{ns}/{leaf}"
@ -1083,104 +1040,6 @@ def _extract_inline_vault_block(text: str, key: str) -> str:
return "" 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( def _detect_ansible_k3s_settings(
@ -1235,14 +1094,10 @@ def _detect_ansible_k3s_settings(
host_for_url = f"{host_for_url}.{domain}" host_for_url = f"{host_for_url}.{domain}"
server_url = f"https://{host_for_url}:6443" 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 { return {
"server_url": server_url, "server_url": server_url,
"server_host": server_host, "server_host": server_host,
"token": token, "token": "",
"vault_path": str(vault_path) if vault_path.exists() else "",
} }

124
knoe/core/onepassword.py Normal file
View File

@ -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))

View File

@ -30,10 +30,10 @@ from knoe.config import (
from knoe.core.controller import KnoeController from knoe.core.controller import KnoeController
from knoe.core.env import ( from knoe.core.env import (
_deployment_target_label, _deployment_target_label,
_ensure_ansible_vault_credentials,
_safe_str, _safe_str,
_normalize_cluster_env, _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. # Helper to set macOS application identity via Objective-C bridge.
def setup_macos_app_identity(): def setup_macos_app_identity():
@ -1236,7 +1236,8 @@ def main():
args.silent = True args.silent = True
if args.silent: if args.silent:
_ensure_ansible_vault_credentials(prompt_ui=False) ensure_op_signed_in()
ensure_knoey_vault()
installer = KnoeConsoleInstaller( installer = KnoeConsoleInstaller(
controller, controller,
args.config, args.config,
@ -1290,7 +1291,8 @@ def main():
root.geometry(f"{window_width}x{window_height}+{cx}+{cy}") root.geometry(f"{window_width}x{window_height}+{cx}+{cy}")
except Exception: except Exception:
root.geometry(f"{window_width}x{window_height}") 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( KnoeInstaller(
root, root,
config_path=args.config, config_path=args.config,
@ -1306,7 +1308,8 @@ def main():
run_ncurses_installer(controller) run_ncurses_installer(controller)
else: else:
_ensure_ansible_vault_credentials(prompt_ui=False) ensure_op_signed_in()
ensure_knoey_vault()
from knoe.ncurses_installer import run_ncurses_installer from knoe.ncurses_installer import run_ncurses_installer
run_ncurses_installer(controller) run_ncurses_installer(controller)

View File

@ -1,12 +1,8 @@
"""Kerberos configuration, secret management and Ansible vault.""" """Kerberos configuration and secret management."""
import configparser import configparser
import os import os
import shutil
import subprocess
import tempfile
import threading import threading
from pathlib import Path
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox, filedialog from tkinter import ttk, messagebox, filedialog
from knoe import screen as ui from knoe import screen as ui
@ -14,7 +10,6 @@ from knoe.core.env import (
PROJECT_ROOT, PROJECT_ROOT,
_bool_str, _bool_str,
_deployment_target_label, _deployment_target_label,
_ensure_ansible_vault_credentials,
) )
from knoe.config import ( from knoe.config import (
SECRET_KEY_SPECS, SECRET_KEY_SPECS,
@ -355,69 +350,13 @@ class SecurityScreenMixin:
return value return value
return "" 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: 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: 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: except Exception:
raw = "" pass
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
return "" return ""
def _load_secret_cache_from_cfg(self): def _load_secret_cache_from_cfg(self):
@ -636,70 +575,18 @@ class SecurityScreenMixin:
self.safe_after(self._run_kerberos_init) self.safe_after(self._run_kerberos_init)
def _save_ansible_knoe_vault(self, root_password: str): def _save_ansible_knoe_vault(self, root_password: str):
"""Store the DB master password in the 1Password knoey vault."""
if not root_password: if not root_password:
return 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: try:
# We must decrypt if it is a knoe secret
plain_pass = root_password plain_pass = root_password
if _is_knoe_secret(root_password): if _is_knoe_secret(root_password):
try: try:
plain_pass = _decrypt_knoe_secret(root_password) plain_pass = _decrypt_knoe_secret(root_password)
except Exception: except Exception:
pass pass
from knoe.core.onepassword import set_secret, op_available
content = f'knoe_root_password: "{plain_pass}"\n' if op_available():
set_secret("administrator", "password", plain_pass)
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
except Exception as e: except Exception as e:
print(f"[ERROR] Failed to save ansible vault: {e}") print(f"[ERROR] Failed to save administrator password to 1Password: {e}")

View File

@ -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

View File

@ -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"

View File

@ -2,31 +2,25 @@
import sys import sys
from pathlib import Path from pathlib import Path
# Add project root to path
ROOT_DIR = Path(__file__).resolve().parents[1] ROOT_DIR = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT_DIR)) sys.path.append(str(ROOT_DIR))
from knoe import config as inst_config from knoe import config as inst_config
from knoe.core.onepassword import get_secret, op_available
def main(): def main():
vault_path = ( token = ""
ROOT_DIR if op_available():
/ "infrastructure" token = get_secret("k3s-token", "credential").strip()
/ "inventory"
/ "group_vars"
/ "all"
/ "vault_k3s.yml"
)
token = inst_config._try_read_ansible_vault_value(vault_path, "vault_k3s_token")
if token: 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("Global", "PROLE_K3S_TOKEN", token)
inst_config._update_knoe_cfg_value("Inputs", "init_cluster.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) inst_config._update_knoe_cfg_value("Service Cluster (k3s)", "K3S_TOKEN", token)
else: 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__": if __name__ == "__main__":

View File

@ -418,24 +418,11 @@ resolve_samba_vars() {
fi fi
fi fi
# 4. Try to resolve password from Ansible Vault # 4. Try to resolve password from 1Password
if [[ -z "$SAMBA_ADMIN_PASSWORD" ]]; then if [[ -z "$SAMBA_ADMIN_PASSWORD" ]]; then
local vault_file="${root}/infrastructure/inventory/group_vars/ad_dc/vault.yml" if command -v op >/dev/null 2>&1 && op whoami >/dev/null 2>&1; then
local vault_pass_file="" SAMBA_ADMIN_PASSWORD=$(op item get "samba-dns-admin" --vault knoey --fields password --reveal 2>/dev/null || true)
if [[ -n "${ANSIBLE_VAULT_PASSWORD_FILE:-}" && -f "${ANSIBLE_VAULT_PASSWORD_FILE}" ]]; then [[ -n "$SAMBA_ADMIN_PASSWORD" ]] && echo "Fetched SAMBA_ADMIN_PASSWORD from 1Password knoey vault"
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
fi fi
fi fi

View File

@ -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 ]]

View File

@ -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 <<M_EOF > "$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 <<C_EOF > "$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

View File

@ -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 <<M_EOF > "$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 <<C_EOF > "$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

View File

@ -3,7 +3,6 @@ Unit tests for previously untested installer/config.py helpers.
Covers: Covers:
_get_secret_key_file, _get_file_key, normalize_version, _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_dep_info (check_cmd/version_cmd paths), load_dependencies,
get_ui_icon_image_path, get_ui_background_image_path, get_properties get_ui_icon_image_path, get_ui_background_image_path, get_properties
""" """
@ -18,8 +17,6 @@ from knoe.config import (
_get_file_key, _get_file_key,
normalize_version, normalize_version,
_extract_yaml_scalar_from_text, _extract_yaml_scalar_from_text,
_extract_inline_vault_block,
_try_read_ansible_vault_value,
get_dep_info, get_dep_info,
detect_dependency_platform, detect_dependency_platform,
get_dependency_milestone_title, 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") == "" assert _extract_yaml_scalar_from_text("a: b\n", "missing") == ""
# --------------------------------------------------------------------------- def test_extract_yaml_scalar_plain_value():
# _extract_inline_vault_block """Plain YAML scalar extraction works for config files."""
# --------------------------------------------------------------------------- text = "db_password: supersecret\n"
result = _extract_yaml_scalar_from_text(text, "db_password")
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")
assert result == "supersecret" assert result == "supersecret"
def test_try_read_ansible_vault_value_plain_text_vault_marker_skipped(tmp_path): def test_extract_yaml_scalar_missing_key():
"""Plain value starting with $ANSIBLE_VAULT is treated as encrypted → skip.""" text = "other_key: value\n"
vault = tmp_path / "knoe.cfg" result = _extract_yaml_scalar_from_text(text, "db_password")
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")
assert result == "" 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 # get_dep_info — check_cmd and version_cmd paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -394,7 +394,7 @@ class TestKnoeController:
with ( with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object( 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, "_write_cfg"),
mock.patch.object(installer, "_perform_cluster_reset"), mock.patch.object(installer, "_perform_cluster_reset"),
@ -405,7 +405,7 @@ class TestKnoeController:
assert rc == 2 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) c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c) installer = KnoeConsoleInstaller(c)
@ -413,7 +413,7 @@ class TestKnoeController:
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object( mock.patch.object(
installer, installer,
"_load_db_password_from_ansible_vault", "_load_db_password_from_1password",
return_value="vaultpw123", return_value="vaultpw123",
), ),
mock.patch.object(installer, "_write_cfg") as write_cfg, mock.patch.object(installer, "_write_cfg") as write_cfg,
@ -434,7 +434,7 @@ class TestKnoeController:
assert "deployment" in captured["ids"] assert "deployment" in captured["ids"]
assert write_cfg.call_count == 2 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) c = KnoeController(tmp_path)
installer = KnoeConsoleInstaller(c) installer = KnoeConsoleInstaller(c)
fake_stdin = mock.Mock() fake_stdin = mock.Mock()
@ -448,7 +448,7 @@ class TestKnoeController:
with ( with (
mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}), mock.patch.object(installer, "_load_inputs_from_cfg", return_value={}),
mock.patch.object( mock.patch.object(
installer, "_load_db_password_from_ansible_vault", return_value="" installer, "_load_db_password_from_1password", return_value=""
), ),
mock.patch.object( mock.patch.object(
installer, installer,
@ -456,7 +456,7 @@ class TestKnoeController:
return_value="bootstrap_pw_123", return_value="bootstrap_pw_123",
), ),
mock.patch.object( mock.patch.object(
installer, "_persist_db_password_to_ansible_vault" installer, "_persist_db_password_to_1password"
) as persist_pw, ) as persist_pw,
mock.patch.object(installer, "_write_cfg") as write_cfg, mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"), mock.patch.object(installer, "_perform_cluster_reset"),
@ -488,10 +488,10 @@ class TestKnoeController:
}, },
), ),
mock.patch.object( mock.patch.object(
installer, "_load_db_password_from_ansible_vault", return_value="" installer, "_load_db_password_from_1password", return_value=""
), ),
mock.patch.object( mock.patch.object(
installer, "_persist_db_password_to_ansible_vault" installer, "_persist_db_password_to_1password"
) as persist_pw, ) as persist_pw,
mock.patch.object(installer, "_write_cfg") as write_cfg, mock.patch.object(installer, "_write_cfg") as write_cfg,
mock.patch.object(installer, "_perform_cluster_reset"), mock.patch.object(installer, "_perform_cluster_reset"),
@ -522,11 +522,11 @@ class TestKnoeController:
), ),
mock.patch.object( mock.patch.object(
installer, installer,
"_load_db_password_from_ansible_vault", "_load_db_password_from_1password",
return_value="vault_pw_999", return_value="vault_pw_999",
), ),
mock.patch.object( mock.patch.object(
installer, "_persist_db_password_to_ansible_vault" installer, "_persist_db_password_to_1password"
) as persist_pw, ) as persist_pw,
mock.patch.object(installer, "_write_cfg"), mock.patch.object(installer, "_write_cfg"),
mock.patch.object(installer, "_perform_cluster_reset"), mock.patch.object(installer, "_perform_cluster_reset"),

View File

@ -21,7 +21,6 @@ from knoe.config import (
_filter_cfg_values_for_persistence, _filter_cfg_values_for_persistence,
_normalize_cfg_value_for_persistence, _normalize_cfg_value_for_persistence,
_extract_yaml_scalar_from_text as cfg_extract_yaml, _extract_yaml_scalar_from_text as cfg_extract_yaml,
_extract_inline_vault_block as cfg_extract_vault,
_load_properties, _load_properties,
_write_k3s_kubeconfig, _write_k3s_kubeconfig,
_merge_kubeconfig, _merge_kubeconfig,
@ -406,10 +405,6 @@ class TestYamlHelpers:
assert _extract_inline_vault_block("key: value", "key") == "" assert _extract_inline_vault_block("key: value", "key") == ""
assert _extract_inline_vault_block("key: value", "missing") == "" 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): def test_parse_yaml_scalar_values(self, tmp_path):
f = tmp_path / "test.yaml" f = tmp_path / "test.yaml"

View File

@ -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)

View File

@ -151,7 +151,8 @@ def test_main_update_flag_dispatches_to_run_update(tmp_path, monkeypatch):
installer.run.return_value = 0 installer.run.return_value = 0
monkeypatch.setattr(sys, "argv", ["install.py", "--update", "-c", str(cfg_path)]) 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, "KnoeController", MagicMock(return_value=MagicMock()))
monkeypatch.setattr(screens_mod, "KnoeConsoleInstaller", MagicMock(return_value=installer)) 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", "argv",
["install.py", "--silent", "--reset", "--delete-db", "-c", str(cfg_path)], ["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, "KnoeController", MagicMock(return_value=MagicMock()))
monkeypatch.setattr(screens_mod, "KnoeConsoleInstaller", installer_factory) monkeypatch.setattr(screens_mod, "KnoeConsoleInstaller", installer_factory)

117
update.sh
View File

@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/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: # Reads the administrator password from:
# infrastructure/inventory/group_vars/all/vault_db_master.yml # 1Password knoey vault → item 'administrator' → field 'password'
# #
# Applies the password to: # Applies the password to:
# - k8s secrets: knoe-db-user, knoe-db-superuser (knoe-cnpg-0, knoe-db-0) # - k8s secrets: knoe-db-user, knoe-db-superuser (knoe-cnpg-0, knoe-db-0)
@ -13,12 +13,11 @@
# - OpenBao kv secrets (if reachable) # - OpenBao kv secrets (if reachable)
# #
# Usage: # Usage:
# ./update.sh [--dry-run] [--prompt] [--vault-pass-file <path>] # ./update.sh [--dry-run] [--prompt] [--skip-db] [--skip-grafana] [--skip-cfg]
# [--skip-db] [--skip-grafana] [--skip-cfg]
# #
# --prompt Read password interactively (masked, confirmed twice) and # --prompt Read password interactively (masked, confirmed twice) and
# re-create vault_db_master.yml with ansible-vault encrypt (whole-file). # save it to 1Password knoey/administrator.
# Use this when the vault file is missing or corrupt. # Use this when you need to rotate the master password.
# #
# The script is idempotent — safe to run on every deploy or rotation. # The script is idempotent — safe to run on every deploy or rotation.
set -euo pipefail set -euo pipefail
@ -26,7 +25,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 if [[ -z "${VIRTUAL_ENV:-}" && -f "${ROOT_DIR}/bin/activate" ]]; then
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@ -41,9 +40,8 @@ PROMPT_MODE=false
SKIP_DB=false SKIP_DB=false
SKIP_GRAFANA=false SKIP_GRAFANA=false
SKIP_CFG=false SKIP_CFG=false
VAULT_PASS_FILE="${ROOT_DIR}/.vault_pass" OP_VAULT="${OP_VAULT:-knoey}"
VAULT_FILE="${ROOT_DIR}/infrastructure/inventory/group_vars/all/vault_db_master.yml" OP_ITEM="administrator"
VAULT_KEY="vault_knoe_db_master_password"
# Cluster contexts — override via env or auto-detect from config files # Cluster contexts — override via env or auto-detect from config files
APP_CTX="${APP_CLUSTER_KUBECONTEXT:-}" APP_CTX="${APP_CLUSTER_KUBECONTEXT:-}"
@ -63,7 +61,7 @@ while [[ $# -gt 0 ]]; do
--skip-db) SKIP_DB=true; shift ;; --skip-db) SKIP_DB=true; shift ;;
--skip-grafana) SKIP_GRAFANA=true; shift ;; --skip-grafana) SKIP_GRAFANA=true; shift ;;
--skip-cfg) SKIP_CFG=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) -h|--help)
grep '^#' "$0" | head -25 | sed 's/^# \?//' grep '^#' "$0" | head -25 | sed 's/^# \?//'
exit 0 ;; exit 0 ;;
@ -144,70 +142,49 @@ kapp() { kubectl ${APP_CTX:+--context="$APP_CTX"} "$@"; }
kdb() { kubectl ${DB_CTX:+--context="$DB_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() { read_1password_password() {
log "Decrypting master password from vault..." log "Reading master password from 1Password vault '$OP_VAULT' item '$OP_ITEM'..."
if [[ ! -f "$VAULT_FILE" ]]; then if ! command -v op >/dev/null 2>&1; then
fail "Vault file not found: $VAULT_FILE (run with --prompt to create it)" fail "1Password CLI (op) not found. Install: brew install 1password-cli"
fi
if [[ ! -f "$VAULT_PASS_FILE" ]]; then
fail "Vault password file not found: $VAULT_PASS_FILE (set --vault-pass-file)"
fi fi
if ! command -v ansible-vault >/dev/null 2>&1; then if ! op whoami >/dev/null 2>&1; then
fail "ansible-vault not found — install ansible or activate the project virtualenv." log "No active 1Password session. Signing in..."
op signin || fail "1Password sign-in failed."
fi 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 local pw
pw=$(python3 - "$VAULT_KEY" <<PY pw=$(op item get "$OP_ITEM" --vault "$OP_VAULT" --fields password --reveal 2>/dev/null) \
import sys, yaml || fail "Could not read '$OP_ITEM' from vault '$OP_VAULT'. Run: ./update.sh --prompt"
try:
data = yaml.safe_load("""${decrypted}""")
except Exception as e:
raise SystemExit(f"YAML parse error: {e}")
key = sys.argv[1]
if not isinstance(data, dict) or key not in data:
raise SystemExit(f"Key '{key}' not found in vault file. Available keys: {list(data.keys()) if isinstance(data, dict) else 'none'}")
print(data[key])
PY
)
if [[ -z "$pw" ]]; then 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 fi
printf '%s' "$pw" 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() { prompt_and_save_to_1password() {
log "Creating new master password and recreating vault file..." log "Creating new master password and saving to 1Password..."
if [[ ! -f "$VAULT_PASS_FILE" ]]; then if ! command -v op >/dev/null 2>&1; then
fail "Vault password file not found: $VAULT_PASS_FILE (set --vault-pass-file)" fail "1Password CLI (op) not found. Install: brew install 1password-cli"
fi fi
if ! command -v ansible-vault >/dev/null 2>&1; then if ! op whoami >/dev/null 2>&1; then
fail "ansible-vault not found — activate the project virtualenv first." op signin || fail "1Password sign-in failed."
fi fi
# Read new password twice with masked echo
local pw1 pw2 local pw1 pw2
# Use /dev/tty so prompt works even when stdout is redirected
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
read -r -s -p "New master password: " pw1 </dev/tty; echo >/dev/tty read -r -s -p "New master password: " pw1 </dev/tty; echo >/dev/tty
read -r -s -p "Confirm master password: " pw2 </dev/tty; echo >/dev/tty read -r -s -p "Confirm master password: " pw2 </dev/tty; echo >/dev/tty
else else
# Non-interactive fallback (e.g. piped input)
read -r pw1 read -r pw1
pw2="$pw1" pw2="$pw1"
fi fi
@ -219,22 +196,20 @@ prompt_and_recreate_vault() {
fail "Passwords do not match — please try again." fail "Passwords do not match — please try again."
fi fi
# Write a plain YAML file then encrypt the whole file. if op item get "$OP_ITEM" --vault "$OP_VAULT" >/dev/null 2>&1; then
# We use whole-file encryption (ansible-vault encrypt) NOT encrypt_string, op item edit "$OP_ITEM" --vault "$OP_VAULT" "password=${pw1}" >/dev/null \
# so that ansible-vault view can read it back correctly. || fail "Failed to update '$OP_ITEM' in vault '$OP_VAULT'."
log "Encrypting password with ansible-vault (whole-file)..." log "Updated existing item '$OP_ITEM' in vault '$OP_VAULT'."
mkdir -p "$(dirname "$VAULT_FILE")" else
local plain_tmp="${VAULT_FILE}.plain.tmp" op item create \
printf -- '---\n%s: %s\n' "$VAULT_KEY" "$pw1" > "$plain_tmp" --category login \
ansible-vault encrypt \ --title "$OP_ITEM" \
--vault-password-file "$VAULT_PASS_FILE" \ --vault "$OP_VAULT" \
--output "$VAULT_FILE" \ "password=${pw1}" >/dev/null \
"$plain_tmp" 2>/dev/null \ || fail "Failed to create '$OP_ITEM' in vault '$OP_VAULT'."
|| { rm -f "$plain_tmp"; fail "ansible-vault encrypt failed — check vault password file."; } log "Created item '$OP_ITEM' in vault '$OP_VAULT'."
rm -f "$plain_tmp" fi
log "Vault file recreated: $VAULT_FILE"
# Return the plaintext password for immediate use
printf '%s' "$pw1" printf '%s' "$pw1"
} }
@ -549,17 +524,17 @@ verify() {
main() { main() {
log "=== knoe update.sh — master password rotation ===" log "=== knoe update.sh — master password rotation ==="
[[ "$DRY_RUN" == "true" ]] && log "(DRY-RUN mode — no changes will be made)" [[ "$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 resolve_contexts
local MASTER_PW local MASTER_PW
if [[ "$PROMPT_MODE" == "true" ]]; then if [[ "$PROMPT_MODE" == "true" ]]; then
MASTER_PW=$(prompt_and_recreate_vault) MASTER_PW=$(prompt_and_save_to_1password)
log "Vault file recreated and master password ready." log "1Password item updated and master password ready."
else else
MASTER_PW=$(decrypt_vault_password) MASTER_PW=$(read_1password_password)
log "Vault master password decrypted successfully." log "Master password read from 1Password successfully."
fi fi
if [[ "$SKIP_DB" != "true" ]]; then if [[ "$SKIP_DB" != "true" ]]; then