fix: resolve BW state sync issue -- add sync() to login lifecycle
The provisioner's BitwardenHelper.login() was missing the critical `bw sync` step that the host wrapper includes. Without syncing after login, the container's local vault cache was empty/stale, causing items to vanish between container runs. Added sync() call at end of login() and before list_items(). Also fixed container UID/GID to match host user (1002:1002) for proper bind-mount access, and added source-code volume mounts for fast iteration. Verified with 5-phase cross-container persistence test (create in container A, verify in fresh container B, update in C, confirm in D).
This commit is contained in:
+2
-2
@@ -23,8 +23,8 @@ RUN wget -q -O /tmp/bw.zip \
|
|||||||
COPY requirements.txt /tmp/
|
COPY requirements.txt /tmp/
|
||||||
RUN python3 -m pip install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
|
RUN python3 -m pip install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
|
||||||
|
|
||||||
# Create non-root user for Playwright
|
# Create non-root user for Playwright, matching host UID/GID for bind-mount access
|
||||||
RUN groupadd -r provision && useradd -r -g provision -G audio,video -m -d /home/provision provision \
|
RUN groupadd -r -g 1002 provision && useradd -r -u 1002 -g provision -G audio,video -m -d /home/provision provision \
|
||||||
&& mkdir -p "/home/provision/.config/Bitwarden CLI" \
|
&& mkdir -p "/home/provision/.config/Bitwarden CLI" \
|
||||||
&& chown -R provision:provision /home/provision
|
&& chown -R provision:provision /home/provision
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,18 @@ class BitwardenHelper:
|
|||||||
if not self.session:
|
if not self.session:
|
||||||
raise RuntimeError("BW unlock failed -- no session token returned")
|
raise RuntimeError("BW unlock failed -- no session token returned")
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
"""Sync the local vault cache with the server.
|
||||||
|
|
||||||
|
Must be called after login and before any read to guarantee
|
||||||
|
the local cache reflects the latest server state. Without this,
|
||||||
|
items created by other clients (e.g. the host bw wrapper) will
|
||||||
|
not appear in list/search results.
|
||||||
|
"""
|
||||||
|
self._run_bw(["sync"])
|
||||||
|
|
||||||
def generate_password(self, length: int = 32) -> str:
|
def generate_password(self, length: int = 32) -> str:
|
||||||
"""Generate a strong password."""
|
"""Generate a strong password."""
|
||||||
return self._run_bw(["generate", "-ulns", "--length", str(length)])
|
return self._run_bw(["generate", "-ulns", "--length", str(length)])
|
||||||
@@ -280,6 +292,7 @@ class BitwardenHelper:
|
|||||||
|
|
||||||
def list_items(self) -> list[dict]:
|
def list_items(self) -> list[dict]:
|
||||||
"""List all items in the vault."""
|
"""List all items in the vault."""
|
||||||
|
self.sync()
|
||||||
output = self._run_bw(["list", "items"])
|
output = self._run_bw(["list", "items"])
|
||||||
return json.loads(output)
|
return json.loads(output)
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ services:
|
|||||||
- ./agents.yaml:/app/agents.yaml:ro
|
- ./agents.yaml:/app/agents.yaml:ro
|
||||||
- ./state:/app/state
|
- ./state:/app/state
|
||||||
- ./bw-state:/home/provision/.config/Bitwarden CLI
|
- ./bw-state:/home/provision/.config/Bitwarden CLI
|
||||||
|
- ./bw_helper.py:/app/bw_helper.py:ro
|
||||||
|
- ./provision-agent.py:/app/provision-agent.py:ro
|
||||||
|
- ./test_bw_helper.py:/app/test_bw_helper.py:ro
|
||||||
|
- ./test_bw_persistence.py:/app/test_bw_persistence.py:ro
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
test_bw_persistence.py -- Cross-container BW state persistence test.
|
||||||
|
|
||||||
|
Verifies that BW items created in one container run are visible in a
|
||||||
|
subsequent container run (the "state sync" issue from session 1).
|
||||||
|
|
||||||
|
This test is designed to be invoked twice:
|
||||||
|
Run 1 (create): python3 test_bw_persistence.py create
|
||||||
|
Run 2 (verify): python3 test_bw_persistence.py verify
|
||||||
|
Run 3 (update): python3 test_bw_persistence.py update
|
||||||
|
Run 4 (confirm): python3 test_bw_persistence.py confirm
|
||||||
|
Run 5 (cleanup): python3 test_bw_persistence.py cleanup
|
||||||
|
|
||||||
|
Each run is a SEPARATE container invocation. If the bw-state bind mount
|
||||||
|
and sync logic are working, run 2 will see the item created in run 1,
|
||||||
|
and run 4 will see the update from run 3.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision \
|
||||||
|
test_bw_persistence.py <phase>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from bw_helper import BitwardenHelper
|
||||||
|
|
||||||
|
TEST_ITEM = "TEST-PERSISTENCE-CROSSCONTAINER"
|
||||||
|
TEST_PASSWORD_ORIG = "OriginalPersistPassword123!"
|
||||||
|
TEST_PASSWORD_UPDATED = "UpdatedPersistPassword456@"
|
||||||
|
|
||||||
|
|
||||||
|
def make_helper() -> BitwardenHelper:
|
||||||
|
return BitwardenHelper(
|
||||||
|
client_id=os.environ["BW_CLIENTID"],
|
||||||
|
client_secret=os.environ["BW_CLIENTSECRET"],
|
||||||
|
password=os.environ["BW_PASSWORD"],
|
||||||
|
server_url=os.environ.get("BW_SERVER", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def phase_create():
|
||||||
|
bw = make_helper()
|
||||||
|
bw.login()
|
||||||
|
|
||||||
|
existing = bw.get_item_id(TEST_ITEM)
|
||||||
|
if existing:
|
||||||
|
bw._run_bw(["delete", "item", existing])
|
||||||
|
|
||||||
|
item_id = bw.create_item(
|
||||||
|
name=TEST_ITEM,
|
||||||
|
username="persist-test@example.com",
|
||||||
|
password=TEST_PASSWORD_ORIG,
|
||||||
|
uris=["https://persist.example.com"],
|
||||||
|
collection_name="test",
|
||||||
|
)
|
||||||
|
assert item_id, "create should return an ID"
|
||||||
|
print(f"CREATE_OK: item_id={item_id}")
|
||||||
|
print(f"HOST_SYNC_CHECK: run 'bw list items' on host to verify visibility")
|
||||||
|
|
||||||
|
|
||||||
|
def phase_verify():
|
||||||
|
bw = make_helper()
|
||||||
|
bw.login()
|
||||||
|
|
||||||
|
assert bw.item_exists(TEST_ITEM), \
|
||||||
|
"FRESH CONTAINER CANNOT SEE ITEM CREATED BY PREVIOUS CONTAINER"
|
||||||
|
pw = bw.get_item_password(TEST_ITEM)
|
||||||
|
assert pw == TEST_PASSWORD_ORIG, \
|
||||||
|
f"Password mismatch: expected {TEST_PASSWORD_ORIG}, got {pw}"
|
||||||
|
print("VERIFY_OK: item visible in fresh container run")
|
||||||
|
|
||||||
|
|
||||||
|
def phase_update():
|
||||||
|
bw = make_helper()
|
||||||
|
bw.login()
|
||||||
|
|
||||||
|
original_id = bw.get_item_id(TEST_ITEM)
|
||||||
|
assert original_id, "item must exist before update"
|
||||||
|
|
||||||
|
updated_id = bw.update_item(TEST_ITEM, password=TEST_PASSWORD_UPDATED)
|
||||||
|
assert updated_id == original_id, "update must preserve item ID"
|
||||||
|
print(f"UPDATE_OK: item_id={updated_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def phase_confirm():
|
||||||
|
bw = make_helper()
|
||||||
|
bw.login()
|
||||||
|
|
||||||
|
pw = bw.get_item_password(TEST_ITEM)
|
||||||
|
assert pw == TEST_PASSWORD_UPDATED, \
|
||||||
|
f"Update did not persist: expected {TEST_PASSWORD_UPDATED}, got {pw}"
|
||||||
|
print("CONFIRM_OK: update visible in fresh container run")
|
||||||
|
|
||||||
|
|
||||||
|
def phase_cleanup():
|
||||||
|
bw = make_helper()
|
||||||
|
bw.login()
|
||||||
|
|
||||||
|
item_id = bw.get_item_id(TEST_ITEM)
|
||||||
|
if item_id:
|
||||||
|
bw._run_bw(["delete", "item", item_id])
|
||||||
|
assert not bw.item_exists(TEST_ITEM), "cleanup failed"
|
||||||
|
print("CLEANUP_OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: test_bw_persistence.py <create|verify|update|confirm|cleanup>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
phase = sys.argv[1]
|
||||||
|
phases = {
|
||||||
|
"create": phase_create,
|
||||||
|
"verify": phase_verify,
|
||||||
|
"update": phase_update,
|
||||||
|
"confirm": phase_confirm,
|
||||||
|
"cleanup": phase_cleanup,
|
||||||
|
}
|
||||||
|
if phase not in phases:
|
||||||
|
print(f"Unknown phase: {phase}")
|
||||||
|
sys.exit(1)
|
||||||
|
phases[phase]()
|
||||||
|
print("SUCCESS")
|
||||||
Reference in New Issue
Block a user