diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f6dfaa7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.env +agents.yaml +state/ +__pycache__/ +*.pyc +*.md +.crush/ diff --git a/Dockerfile b/Dockerfile index 5d9f066..e90e714 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update && \ && rm -rf /var/lib/apt/lists/* # Install Bitwarden CLI -RUN npx -y @bitwarden/cli@2026.7.0 || true +RUN npm install -g @bitwarden/cli@2025.6.0 # Install Python dependencies COPY requirements.txt /tmp/ diff --git a/agents.yaml.example b/agents.yaml.example index 526df94..db194a5 100644 --- a/agents.yaml.example +++ b/agents.yaml.example @@ -10,6 +10,7 @@ agents: - name: vp-techops display_name: "VP TechOps" + cloudron_email: "vp-techops@turnsys.com" priority: Q3 cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN" systems: @@ -28,6 +29,7 @@ agents: - name: vp-secops display_name: "VP SecOps" + cloudron_email: "vp-secops@turnsys.com" priority: Q3 cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN" systems: @@ -46,6 +48,7 @@ agents: - name: vp-techcompliance display_name: "VP TechCompliance" + cloudron_email: "vp-techcompliance@turnsys.com" priority: Q3 cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN" systems: @@ -65,18 +68,21 @@ agents: # Q4 agents — enroll in Cloudron only (Phase 1), no system access yet - name: coo display_name: "Chief Operating Officer" + cloudron_email: "coo@turnsys.com" priority: Q4 cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN" systems: {} - name: svp-knel display_name: "SVP KNEL" + cloudron_email: "svp-knel@turnsys.com" priority: Q4 cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN" systems: {} - name: svp-tctc display_name: "SVP TCTC" + cloudron_email: "svp-tctc@turnsys.com" priority: Q4 cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN" systems: {} diff --git a/bw-helper.py b/bw-helper.py index d1efcc2..22cb24e 100644 --- a/bw-helper.py +++ b/bw-helper.py @@ -74,7 +74,7 @@ class BitwardenHelper: def generate_password(self, length: int = 32) -> str: """Generate a strong password.""" - return self._run_bw(["generate", "-uluns", "--length", str(length)]) + return self._run_bw(["generate", "-ulns", "--length", str(length)]) def get_totp(self, item_name: str) -> str: """Get the current TOTP code for a Bitwarden item.""" @@ -172,8 +172,13 @@ class BitwardenHelper: try: self._run_bw(["get", "item", name]) return True - except RuntimeError: - return False + except RuntimeError as e: + # Distinguish "not found" (expected) from real errors (network, session expired). + err_msg = str(e).lower() + if "not found" in err_msg or "no item" in err_msg: + return False + # Real error — re-raise so we don't silently create duplicates. + raise def get_item_password(self, name: str) -> str: """Get the password field from a Bitwarden item.""" diff --git a/provision-agent.py b/provision-agent.py index d5d79d7..1621c3b 100644 --- a/provision-agent.py +++ b/provision-agent.py @@ -50,7 +50,6 @@ DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com") REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com") STATE_DIR = Path("/app/state") -STATE_DIR.mkdir(exist_ok=True) # --------------------------------------------------------------------------- # Cloudron enrollment @@ -77,8 +76,9 @@ def enroll_cloudron( item_name = f"{name} Cloudron" if bw.item_exists(item_name): log.info(f"[{name}] Cloudron credential already exists in Bitwarden — skipping") + cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") return { - "username": agent.get("cloudron_email", f"{name}@tsys-cloudron.knel.net"), + "username": cloudron_email, "password": bw.get_item_password(item_name), } @@ -97,8 +97,10 @@ def enroll_cloudron( if len(password_inputs) >= 2: password_inputs[0].fill(password) password_inputs[1].fill(password) - else: + elif len(password_inputs) == 1: password_inputs[0].fill(password) + else: + raise RuntimeError(f"[{name}] No password field found on Cloudron invite page") # Set display name if field exists name_field = page.query_selector('input[name="displayName"], input[name="name"]') @@ -117,7 +119,7 @@ def enroll_cloudron( totp_secret = enable_cloudron_2fa(page, agent, bw) # Store credential in Bitwarden - cloudron_email = agent.get("cloudron_email", f"{name}@tsys-cloudron.knel.net") + cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") bw.create_item( name=item_name, username=cloudron_email, @@ -235,14 +237,14 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper) -> # Fill Cloudron SSO login form cloudron_item = f"{name} Cloudron" - username = agent.get("cloudron_email", f"{name}@tsys-cloudron.knel.net") + cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") password = bw.get_item_password(cloudron_item) user_input = page.query_selector('input[name="username"], input[type="email"], input[name="email"]') pass_input = page.query_selector('input[type="password"]') if user_input: - user_input.fill(username) + user_input.fill(cloudron_email) if pass_input: pass_input.fill(password) @@ -569,14 +571,17 @@ def provision_agent( except Exception as e: log.error(f"[{name}] Provisioning failed: {e}") - raise + results["error"] = str(e) finally: page.close() - # Save state + # Save state (in finally so partial results survive failures) state_file = STATE_DIR / f"{name}.json" - with open(state_file, "w") as f: - json.dump(results, f, indent=2) + try: + with open(state_file, "w") as f: + json.dump(results, f, indent=2) + except OSError: + log.warning(f"[{name}] Could not write state file: {state_file}") return results @@ -590,6 +595,9 @@ def main(): parser.add_argument("--headed", action="store_true", help="Show browser (debugging)") args = parser.parse_args() + # Ensure state directory exists (deferred from module level so --dry-run works) + STATE_DIR.mkdir(parents=True, exist_ok=True) + # Load manifest agents = load_manifest(args.manifest) if args.agent: