feat: full app provisioning for all COO agents + race-hardened BW helper
App credentials now flow for every agent: shared handle_oidc_interaction
helper fills the per-app OIDC login form/TOTP/consent (the panel session
is not shared across app clients -- each may demand fresh credentials),
and the Discourse signup clears the prefilled username field before
typing (prefill+typed concatenation exceeded the 20-char cap and failed
validation silently).
BW helper hardened against the sync races observed across concurrent
containers: get_item_id re-syncs stale caches, create/edit retry with
backoff and post-write sync. This class of failure was mine -- the
login-path fix from session 3 left read paths on stale caches.
Final validated matrix (validate-all-logins.py, fresh-context logins
plus live API checks): 9/10 fully green; vp-compliance blocked on a
corrupt stored password (Cloudron admin reset needed). Redmine access
still Cloudron-denied for vp-secops, svp-knel, vp-techcompliance,
vp-facilities ("You do not have access" at the OIDC interaction).
This commit is contained in:
+46
-2
@@ -24,6 +24,7 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ class BitwardenHelper:
|
||||
self.totp_secret = totp_secret
|
||||
self.server_url = server_url
|
||||
self.session: Optional[str] = None
|
||||
self._last_sync: float = 0.0
|
||||
|
||||
def _run_bw(self, args: list[str], capture: bool = True) -> str:
|
||||
"""Run a bw CLI command with the active session."""
|
||||
@@ -121,6 +123,43 @@ class BitwardenHelper:
|
||||
not appear in list/search results.
|
||||
"""
|
||||
self._run_bw(["sync"])
|
||||
self._last_sync = time.monotonic()
|
||||
|
||||
def _sync_if_stale(self, max_age_s: float = 30.0) -> None:
|
||||
"""Re-sync if the cache is older than max_age_s seconds.
|
||||
|
||||
Multiple containers share this vault (host wrapper, provisioner
|
||||
runs). A read performed on a stale cache sees ghosts: items that
|
||||
exist server-side appear missing (or vice versa). Cheap enough
|
||||
to run before every read.
|
||||
"""
|
||||
if time.monotonic() - self._last_sync > max_age_s:
|
||||
self.sync()
|
||||
|
||||
def _run_bw_with_retry(self, args: list[str], retries: int = 3) -> str:
|
||||
"""Run a bw command, retrying on transient failures.
|
||||
|
||||
The bw CLI occasionally returns empty output or non-JSON errors
|
||||
under load (observed: empty create response, 'Expecting value'
|
||||
JSON decode upstream). Retry with backoff before failing.
|
||||
"""
|
||||
last_err = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
return self._run_bw(args)
|
||||
except (RuntimeError, json.JSONDecodeError) as e:
|
||||
last_err = e
|
||||
msg = str(e)
|
||||
# Non-retryable failures: re-raise immediately
|
||||
if "not found" in msg.lower() or "already exists" in msg.lower():
|
||||
raise
|
||||
if attempt < retries:
|
||||
delay = 2 * attempt
|
||||
time.sleep(delay)
|
||||
self.sync()
|
||||
raise RuntimeError(
|
||||
f"bw {' '.join(args)} failed after {retries} retries: {last_err}"
|
||||
)
|
||||
|
||||
def generate_password(self, length: int = 32) -> str:
|
||||
"""Generate a strong password."""
|
||||
@@ -147,8 +186,11 @@ class BitwardenHelper:
|
||||
|
||||
Returns the item ID if exactly one match exists, None if no match,
|
||||
and raises RuntimeError if multiple items share the name (ambiguous).
|
||||
Always syncs if the cache is stale -- containers share this vault
|
||||
and a stale cache sees ghosts.
|
||||
"""
|
||||
try:
|
||||
self._sync_if_stale()
|
||||
output = self._run_bw(["list", "items", "--search", name])
|
||||
items = json.loads(output)
|
||||
# Filter to exact name matches (bw search is fuzzy)
|
||||
@@ -228,8 +270,9 @@ class BitwardenHelper:
|
||||
item["collectionIds"] = [collection_id]
|
||||
|
||||
encoded_item = self._encode(item)
|
||||
output = self._run_bw(["create", "item", encoded_item])
|
||||
output = self._run_bw_with_retry(["create", "item", encoded_item])
|
||||
created = json.loads(output)
|
||||
self.sync()
|
||||
return created.get("id", "")
|
||||
|
||||
def update_item(
|
||||
@@ -271,8 +314,9 @@ class BitwardenHelper:
|
||||
]
|
||||
|
||||
encoded_item = self._encode(current)
|
||||
output = self._run_bw(["edit", "item", item_id, encoded_item])
|
||||
output = self._run_bw_with_retry(["edit", "item", item_id, encoded_item])
|
||||
updated = json.loads(output)
|
||||
self.sync()
|
||||
return updated.get("id", item_id)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user