Replaced the stale session-1 provision_discourse/provision_redmine with the flows proven on vp-techops this session. Both are now parameterized by the agent manifest (username derived from name, hyphens stripped, overridable via username: field). Discourse: login modal -> OpenID button -> signup on first login -> RSA User API key flow (PKCS1v15 decrypt, JSON payload). Redmine: KNEL Cloud SSO button -> consent -> Show/Reset on the API access key section via targeted DOM traversal. Added docs/JOURNAL.md with all working selectors, flows, gotchas, and verification results so future sessions do not rediscover them.
1180 lines
44 KiB
Python
1180 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
provision-agent.py — Playwright automation for AI agent identity provisioning.
|
|
|
|
Enrolls AI agent identities in Cloudron, logs into Gitea/Discourse/Redmine via
|
|
SSO, generates API keys, and stores all credentials in Bitwarden.
|
|
|
|
Usage:
|
|
python3 provision-agent.py # provision all agents in manifest
|
|
python3 provision-agent.py --agent vp-techops # provision one agent
|
|
python3 provision-agent.py --phase1-only # Cloudron enrollment only
|
|
python3 provision-agent.py --dry-run # validate manifest without browser
|
|
|
|
Manifest: agents.yaml (see agents.yaml.example)
|
|
|
|
See: ~/Q3/agent-identity-bootstrap.md for the full architecture.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from playwright.sync_api import BrowserContext, Page, sync_playwright
|
|
|
|
from bw_helper import BitwardenHelper
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
log = logging.getLogger("provision")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com")
|
|
GITEA_URL = os.environ.get("GITEA_URL", "https://git.knownelement.com")
|
|
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")
|
|
|
|
|
|
def _debug_dump(page: Page, label: str) -> None:
|
|
"""Save screenshot + DOM dump for debugging selector issues."""
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
ts = time.strftime("%H%M%S")
|
|
screenshot_path = STATE_DIR / f"debug-{label}-{ts}.png"
|
|
dom_path = STATE_DIR / f"debug-{label}-{ts}.txt"
|
|
try:
|
|
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
elements = page.evaluate("""() => {
|
|
const results = [];
|
|
document.querySelectorAll('input, button, select, textarea, label, h2, h3, h4, h5, code, .ui.header, .ui.message, [data-tab], .tw-font-semibold').forEach(el => {
|
|
const tag = el.tagName.toLowerCase();
|
|
const text = el.textContent.trim().substring(0, 80);
|
|
const id = el.id || '';
|
|
const name = el.getAttribute('name') || '';
|
|
const type = el.getAttribute('type') || '';
|
|
const value = el.getAttribute('value') || '';
|
|
const cls = (el.className || '').substring(0, 50);
|
|
if (text || id || name || type || value) results.push(`<${tag}> id=${id} name=${name} type=${type} value=${value} class=${cls} text="${text}"`);
|
|
});
|
|
return results.join('\\n');
|
|
}""")
|
|
dom_path.write_text(f"URL: {page.url}\\n\\n{elements}")
|
|
log.info(f" Debug dump saved: {screenshot_path.name}, {dom_path.name}")
|
|
except Exception as e:
|
|
log.warning(f" Debug dump failed: {e}")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cloudron enrollment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def enroll_cloudron(
|
|
page: Page,
|
|
agent: dict,
|
|
bw: BitwardenHelper,
|
|
) -> dict:
|
|
"""
|
|
Phase 1: Accept Cloudron invite, set password, enable 2FA.
|
|
|
|
Returns a dict with the agent's Cloudron credentials.
|
|
"""
|
|
name = agent["name"]
|
|
invite_url = agent["cloudron_invite"]
|
|
display_name = agent.get("display_name", name)
|
|
|
|
log.info(f"[{name}] Phase 1: Cloudron enrollment — {invite_url}")
|
|
|
|
# Check if already provisioned
|
|
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": cloudron_email,
|
|
"password": bw.get_item_password(item_name),
|
|
}
|
|
|
|
# Generate a strong password
|
|
password = bw.generate_password(length=32)
|
|
log.info(f"[{name}] Generated password ({len(password)} chars)")
|
|
|
|
# Navigate to invite link
|
|
page.goto(invite_url, wait_until="networkidle")
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Fill in the invite acceptance form using real Cloudron selectors
|
|
page.wait_for_selector('#inputPassword', timeout=15000)
|
|
|
|
page.fill('#inputPassword', password)
|
|
page.fill('#inputPasswordRepeat', password)
|
|
|
|
# Set display name if field exists
|
|
display_field = page.query_selector('#inputDisplayName')
|
|
if display_field:
|
|
display_field.fill(display_name)
|
|
|
|
# Submit — Cloudron uses a button with class btn-primary or type submit
|
|
submit = page.query_selector('button[type="submit"], button.btn-primary, button:has-text("Setup"), button:has-text("Create"), button:has-text("Accept")')
|
|
if submit:
|
|
submit.click()
|
|
else:
|
|
page.keyboard.press('Enter')
|
|
|
|
page.wait_for_load_state("networkidle")
|
|
log.info(f"[{name}] Invite accepted")
|
|
|
|
# Enable 2FA
|
|
totp_secret = enable_cloudron_2fa(page, agent, bw)
|
|
|
|
# Store credential in Bitwarden
|
|
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
|
|
bw.create_item(
|
|
name=item_name,
|
|
username=cloudron_email,
|
|
password=password,
|
|
uris=[CLOUDRON_BASE],
|
|
collection_name=name,
|
|
totp_secret=totp_secret,
|
|
)
|
|
log.info(f"[{name}] Cloudron credential stored in Bitwarden (collection: {name})")
|
|
|
|
return {"username": cloudron_email, "password": password, "totp_secret": totp_secret}
|
|
|
|
|
|
def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|
"""
|
|
Enable TOTP 2FA on a Cloudron account via the profile page.
|
|
|
|
Cloudron's 2FA enrollment flow (discovered via DOM dump):
|
|
1. Navigate to #/profile
|
|
2. Click "Setup" to start 2FA enrollment
|
|
3. Cloudron defaults to Passkey -- click "switchToTotp" to switch
|
|
4. TOTP secret appears in page text (base32 encoded)
|
|
5. Enter TOTP code in #totpTokenInput, click Enable
|
|
|
|
Returns the TOTP secret (empty string if already enabled or failed).
|
|
"""
|
|
name = agent["name"]
|
|
log.info(f"[{name}] Enabling 2FA on Cloudron account")
|
|
|
|
page.evaluate('() => window.location.hash = "#/profile"')
|
|
page.wait_for_timeout(3000)
|
|
|
|
# If profile didn't load, try clicking the profile nav link
|
|
if "#/profile" not in page.url:
|
|
for nav_text in ["Profile", "Settings", "Account"]:
|
|
sec_link = page.locator(f'a[href*="#/profile"], a:has-text("{nav_text}")')
|
|
if sec_link.count() > 0:
|
|
sec_link.first.click()
|
|
page.wait_for_timeout(3000)
|
|
break
|
|
|
|
_debug_dump(page, f"cloudron-profile-{name}")
|
|
|
|
# Check if 2FA is already enabled
|
|
page_text = page.evaluate("() => document.body.innerText")
|
|
if "totp" in page_text.lower() and "enabled" in page_text.lower():
|
|
log.info(f"[{name}] 2FA already enabled on Cloudron")
|
|
return ""
|
|
|
|
# Step 1: Click "Setup" to start 2FA enrollment
|
|
setup_clicked = False
|
|
for selector in [
|
|
'text=Setup',
|
|
'[role="button"]:has-text("Setup")',
|
|
'button:has-text("Setup")',
|
|
'a:has-text("Setup")',
|
|
]:
|
|
loc = page.locator(selector)
|
|
if loc.count() > 0 and loc.first.is_visible():
|
|
loc.first.click()
|
|
page.wait_for_timeout(3000)
|
|
setup_clicked = True
|
|
break
|
|
|
|
if not setup_clicked:
|
|
log.warning(f"[{name}] Could not find 2FA Setup button")
|
|
_debug_dump(page, f"cloudron-2fa-no-setup-{name}")
|
|
return ""
|
|
|
|
# Step 2: Switch from Passkey to TOTP mode
|
|
page_text = page.evaluate("() => document.body.innerText")
|
|
if "switchToTotp" in page_text or "Passkey" in page_text:
|
|
for selector in [
|
|
'text=switchToTotp',
|
|
'text=profile.enable2FA.switchToTotp',
|
|
'a:has-text("TOTP")',
|
|
'[role="button"]:has-text("TOTP")',
|
|
]:
|
|
loc = page.locator(selector)
|
|
if loc.count() > 0 and loc.first.is_visible():
|
|
loc.first.click()
|
|
page.wait_for_timeout(3000)
|
|
break
|
|
|
|
_debug_dump(page, f"cloudron-2fa-totp-mode-{name}")
|
|
|
|
# Step 3: Extract TOTP secret (base32 string in page text)
|
|
import re
|
|
page_text = page.evaluate("() => document.body.innerText")
|
|
totp_secret = ""
|
|
|
|
# Look in code/pre elements first
|
|
for el in page.query_selector_all("code, pre"):
|
|
text = el.text_content().strip().replace(" ", "")
|
|
if text and re.match(r'^[A-Z2-7=]+$', text):
|
|
totp_secret = text
|
|
break
|
|
|
|
# Fall back to regex in page text
|
|
if not totp_secret:
|
|
matches = re.findall(r'[A-Z2-7]{16,}=?', page_text.replace(" ", ""))
|
|
if matches:
|
|
totp_secret = matches[0]
|
|
|
|
if not totp_secret:
|
|
log.error(f"[{name}] Could not extract TOTP secret")
|
|
_debug_dump(page, f"cloudron-2fa-no-secret-{name}")
|
|
return ""
|
|
|
|
log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...")
|
|
|
|
# Step 4: Enter TOTP confirmation code
|
|
import pyotp
|
|
totp_code = pyotp.TOTP(totp_secret).now()
|
|
|
|
token_input = page.query_selector("#totpTokenInput")
|
|
if not token_input:
|
|
for sel in ['input[name="totpToken"]', 'input[type="text"]:visible']:
|
|
loc = page.locator(sel)
|
|
if loc.count() > 0 and loc.first.is_visible():
|
|
token_input = loc.first.element_handle()
|
|
break
|
|
|
|
if token_input:
|
|
token_input.click()
|
|
page.keyboard.type(totp_code)
|
|
page.wait_for_timeout(500)
|
|
|
|
# Click Enable/Confirm button
|
|
for btn_text in ["Enable", "Confirm", "Verify", "OK", "Save"]:
|
|
loc = page.locator(f'[role="button"]:has-text("{btn_text}"), button:has-text("{btn_text}")')
|
|
if loc.count() > 0 and loc.first.is_visible():
|
|
loc.first.click()
|
|
break
|
|
page.wait_for_timeout(3000)
|
|
log.info(f"[{name}] 2FA confirmation submitted")
|
|
else:
|
|
log.warning(f"[{name}] Could not find TOTP confirmation input")
|
|
_debug_dump(page, f"cloudron-2fa-no-input-{name}")
|
|
|
|
return totp_secret
|
|
|
|
|
|
def decode_qr_from_base64(data_uri: str) -> str:
|
|
"""Decode a TOTP secret from a base64 QR code data URI."""
|
|
import base64
|
|
import io
|
|
|
|
from PIL import Image
|
|
from pyzbar.pyzbar import decode
|
|
|
|
# Extract base64 data from data URI
|
|
header, b64data = data_uri.split(",", 1)
|
|
img_bytes = base64.b64decode(b64data)
|
|
img = Image.open(io.BytesIO(img_bytes))
|
|
|
|
decoded = decode(img)
|
|
if decoded:
|
|
# TOTP URIs look like: otpauth://totp/Label?secret=XXXX&...
|
|
uri = decoded[0].data.decode()
|
|
if "secret=" in uri:
|
|
return uri.split("secret=")[1].split("&")[0]
|
|
|
|
raise RuntimeError("Could not decode TOTP secret from QR code")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# System access (Phase 2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
|
|
"""
|
|
Login to the Cloudron panel to establish an SSO session.
|
|
|
|
Once authenticated to the Cloudron panel, subsequent SSO flows to
|
|
Cloudron-managed apps (Gitea, Redmine, Discourse, etc.) auto-approve
|
|
without requiring a separate consent click.
|
|
|
|
Returns True if login succeeded or session already active.
|
|
"""
|
|
name = agent["name"]
|
|
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
|
|
cloudron_item = f"{name} Cloudron"
|
|
password = bw.get_item_password(cloudron_item)
|
|
|
|
log.info(f"[{name}] Establishing Cloudron panel session")
|
|
|
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Already logged in? (must be on the panel, not redirected to OIDC)
|
|
if "login" not in page.url.lower() and "openid" not in page.url.lower():
|
|
log.info(f"[{name}] Cloudron panel session already active")
|
|
return True
|
|
|
|
# If redirected to OIDC login page, fill that too
|
|
# (Cloudron login.html and OIDC login look similar, both use #inputUsername/#inputPassword)
|
|
|
|
# Fill Cloudron login form (Pankow/Vue needs keyboard events, not fill())
|
|
page.wait_for_selector("#inputPassword", timeout=15000)
|
|
page.click("#inputUsername")
|
|
page.keyboard.type(cloudron_email)
|
|
page.click("#inputPassword")
|
|
page.keyboard.type(password)
|
|
|
|
# Submit: Cloudron Pankow UI uses <div role="button"> instead of <button>.
|
|
submitted = False
|
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
|
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
|
if btn.count() > 0 and btn.first.is_visible():
|
|
btn.first.click()
|
|
submitted = True
|
|
break
|
|
if not submitted:
|
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
|
submitted = True
|
|
page.wait_for_timeout(5000)
|
|
|
|
# Handle TOTP if prompted (Cloudron OIDC uses #inputTotpToken, not #inputTotp)
|
|
totp_input = page.query_selector("#inputTotpToken")
|
|
if totp_input and totp_input.is_visible():
|
|
totp_code = bw.get_totp(cloudron_item)
|
|
totp_input.click()
|
|
page.keyboard.type(totp_code)
|
|
# Cloudron TOTP submit button has specific ID
|
|
submit = page.locator('#totpTokenSubmitButton')
|
|
if submit.count() == 0:
|
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
|
submit = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
|
if submit.count() > 0 and submit.first.is_visible():
|
|
break
|
|
if submit.count() > 0:
|
|
submit.first.click()
|
|
else:
|
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
|
page.wait_for_timeout(5000)
|
|
|
|
# After submit, check if we're past the login form.
|
|
# The OIDC flow stays on /openid/interaction/... but changes from
|
|
# login form to consent page. Check for absence of password field.
|
|
page.wait_for_timeout(3000)
|
|
has_password = page.query_selector('#inputPassword')
|
|
if not has_password or not has_password.is_visible():
|
|
log.info(f"[{name}] Cloudron panel login successful")
|
|
return True
|
|
|
|
# Handle consent/authorize page if present
|
|
for consent_text in ["Continue", "Authorize", "Allow", "Accept"]:
|
|
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}")')
|
|
if consent_btn.count() > 0 and consent_btn.first.is_visible():
|
|
consent_btn.first.click()
|
|
page.wait_for_timeout(5000)
|
|
break
|
|
|
|
has_password = page.query_selector('#inputPassword')
|
|
if not has_password or not has_password.is_visible():
|
|
log.info(f"[{name}] Cloudron panel login successful")
|
|
return True
|
|
|
|
log.error(f"[{name}] Cloudron panel login failed (still on login page: {page.url})")
|
|
return False
|
|
|
|
|
|
def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
|
|
sso_button_selector: str = "") -> bool:
|
|
"""
|
|
Log into a Cloudron-managed app via SSO.
|
|
|
|
Requires an active Cloudron panel session (call cloudron_panel_login first).
|
|
Clicks the SSO/OAuth button on the app's login page, which redirects
|
|
through Cloudron's OIDC provider and back to the app authenticated.
|
|
|
|
Args:
|
|
sso_button_selector: CSS selector for the SSO button. If empty,
|
|
tries common patterns (oauth2 link, "cloudron" text, etc.).
|
|
|
|
Returns True if login succeeded.
|
|
"""
|
|
name = agent["name"]
|
|
log.info(f"[{name}] SSO login: {system_url}")
|
|
|
|
page.goto(system_url, wait_until="networkidle")
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Already authenticated?
|
|
if not page.query_selector('input[type="password"]'):
|
|
log.info(f"[{name}] Already authenticated at {system_url}")
|
|
return True
|
|
|
|
# Click the SSO/OAuth button
|
|
sso_clicked = False
|
|
|
|
if sso_button_selector:
|
|
btn = page.locator(sso_button_selector)
|
|
if btn.count() > 0:
|
|
btn.first.click()
|
|
sso_clicked = True
|
|
|
|
if not sso_clicked:
|
|
for selector in [
|
|
'a[href*="oauth2/cloudron"]',
|
|
'a[href*="oauth"]',
|
|
'button[id*="login-oauth"]',
|
|
'a:has-text("cloudron")',
|
|
'a:has-text("Cloud")',
|
|
'button:has-text("cloudron")',
|
|
'button:has-text("Cloud")',
|
|
]:
|
|
btn = page.locator(selector)
|
|
if btn.count() > 0 and btn.first.is_visible():
|
|
btn.first.click()
|
|
sso_clicked = True
|
|
break
|
|
|
|
if not sso_clicked:
|
|
log.error(f"[{name}] Could not find SSO button at {system_url}")
|
|
_debug_dump(page, f"sso-no-button-{name}")
|
|
return False
|
|
|
|
page.wait_for_timeout(5000)
|
|
log.info(f"[{name}] SSO redirect: {page.url}")
|
|
|
|
# Handle Cloudron OIDC login page if redirected.
|
|
# The OIDC page may show: (a) a login form, (b) a consent page,
|
|
# or (c) nothing visible (auto-redirect). Handle all three.
|
|
if "openid" in page.url or "interaction" in page.url:
|
|
page.wait_for_timeout(2000)
|
|
|
|
has_login_form = page.query_selector("#inputPassword")
|
|
if has_login_form and has_login_form.is_visible():
|
|
log.info(f"[{name}] Handling Cloudron OIDC login")
|
|
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
|
|
cloudron_item = f"{name} Cloudron"
|
|
password = bw.get_item_password(cloudron_item)
|
|
|
|
page.click("#inputUsername")
|
|
page.keyboard.type(cloudron_email)
|
|
page.click("#inputPassword")
|
|
page.keyboard.type(password)
|
|
|
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
|
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
|
if btn.count() > 0 and btn.first.is_visible():
|
|
btn.first.click()
|
|
break
|
|
else:
|
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
|
page.wait_for_timeout(5000)
|
|
|
|
# Handle TOTP if prompted
|
|
totp_input = page.query_selector("#inputTotp")
|
|
if totp_input and totp_input.is_visible():
|
|
totp_code = bw.get_totp(cloudron_item)
|
|
totp_input.fill(totp_code)
|
|
for text in ["Log in", "Sign in", "Submit", "Continue"]:
|
|
btn = page.locator(f'[role="button"]:has-text("{text}"), button:has-text("{text}")')
|
|
if btn.count() > 0 and btn.first.is_visible():
|
|
btn.first.click()
|
|
break
|
|
page.wait_for_timeout(5000)
|
|
else:
|
|
log.info(f"[{name}] OIDC session active — looking for consent")
|
|
|
|
# Try to find and click any consent/authorize button.
|
|
# The OIDC page may require explicit consent even when authenticated.
|
|
page.wait_for_timeout(2000)
|
|
for consent_text in ["Continue", "Authorize", "Allow", "Accept", "Approve"]:
|
|
consent_btn = page.locator(f'[role="button"]:has-text("{consent_text}"), button:has-text("{consent_text}"), input[value="{consent_text}"]')
|
|
if consent_btn.count() > 0:
|
|
log.info(f"[{name}] Clicking OIDC consent: {consent_text}")
|
|
consent_btn.first.click(force=True)
|
|
page.wait_for_timeout(5000)
|
|
break
|
|
else:
|
|
# No consent button found — try submitting any form on the page
|
|
# (OIDC auto-approve may need a form POST)
|
|
page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }")
|
|
page.wait_for_timeout(5000)
|
|
|
|
page.wait_for_timeout(3000)
|
|
log.info(f"[{name}] SSO result: {page.url}")
|
|
|
|
# Verify we're back on the target app (not stuck on OIDC/login)
|
|
if "openid" in page.url or "interaction" in page.url:
|
|
log.error(f"[{name}] SSO login failed (stuck on OIDC page)")
|
|
_debug_dump(page, f"sso-stuck-{name}")
|
|
return False
|
|
|
|
if "login" in page.url.lower():
|
|
log.error(f"[{name}] SSO login failed (still on login page)")
|
|
_debug_dump(page, f"sso-failed-{name}")
|
|
return False
|
|
|
|
log.info(f"[{name}] SSO login successful")
|
|
return True
|
|
|
|
|
|
def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|
"""Generate a Gitea API token via SSO login. Returns the token."""
|
|
name = agent["name"]
|
|
systems = agent.get("systems", {})
|
|
gitea_cfg = systems.get("gitea", {})
|
|
|
|
if not gitea_cfg:
|
|
log.info(f"[{name}] No Gitea config — skipping")
|
|
return ""
|
|
|
|
item_name = f"{name} Gitea"
|
|
if bw.item_exists(item_name):
|
|
log.info(f"[{name}] Gitea token already exists — skipping")
|
|
return bw.get_item_password(item_name)
|
|
|
|
url = gitea_cfg.get("url", GITEA_URL)
|
|
token_name = gitea_cfg.get("token_name", f"{name}-api")
|
|
|
|
sso_login(page, f"{url}/user/login", agent, bw,
|
|
sso_button_selector='a[href*="oauth2/cloudron"]')
|
|
|
|
# Navigate to API token settings
|
|
page.goto(f"{url}/user/settings/applications", wait_until="domcontentloaded")
|
|
page.wait_for_timeout(3000)
|
|
_debug_dump(page, f"gitea-settings-{name}")
|
|
|
|
# Generate new token — the input may not be visible to Playwright
|
|
# (behind flash message or in collapsed section). Use JS to fill.
|
|
page.evaluate(f"""() => {{
|
|
const input = document.querySelector('#name');
|
|
if (input) {{
|
|
input.value = '{token_name}';
|
|
input.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
input.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
}}
|
|
}}""")
|
|
|
|
# Select scopes via JS (radio buttons may also be non-visible)
|
|
scope_map = {
|
|
"repository": "write:repository",
|
|
"user": "write:user",
|
|
"organization": "write:organization",
|
|
"issue": "write:issue",
|
|
"package": "write:package",
|
|
"notification": "read:notification",
|
|
"misc": "read:misc",
|
|
}
|
|
page.evaluate("""(scopes) => {
|
|
for (const [cat, val] of Object.entries(scopes)) {
|
|
const radio = document.querySelector('input[value="' + val + '"]');
|
|
if (radio) { radio.checked = true; radio.dispatchEvent(new Event('change', {bubbles: true})); }
|
|
}
|
|
}""", scope_map)
|
|
|
|
# Click Generate Token via JS
|
|
page.evaluate("""() => {
|
|
const btns = document.querySelectorAll('button');
|
|
for (const b of btns) {
|
|
if (b.textContent.trim() === 'Generate Token') { b.click(); return; }
|
|
}
|
|
}""")
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Extract the generated token.
|
|
# Gitea shows it in a flash-info message div as plain text (the token
|
|
# string itself, not wrapped in <code>).
|
|
token_el = page.query_selector('.ui.info.message.flash-info, .ui.info.message')
|
|
if not token_el:
|
|
token_el = page.query_selector('.ui.message code, input[readonly]')
|
|
|
|
token = ""
|
|
if token_el:
|
|
token = token_el.text_content().strip()
|
|
# The flash message may contain extra text; extract just the token
|
|
# (Gitea tokens are 40-char hex strings)
|
|
import re
|
|
match = re.search(r'[a-f0-9]{40}', token)
|
|
if match:
|
|
token = match.group(0)
|
|
if not token:
|
|
log.error(f"[{name}] Could not extract Gitea API token")
|
|
_debug_dump(page, f"gitea-no-token-{name}")
|
|
return ""
|
|
|
|
log.info(f"[{name}] Gitea token generated: {token[:8]}...")
|
|
|
|
# Store in Bitwarden
|
|
bw.create_item(
|
|
name=item_name,
|
|
username=name,
|
|
password=token,
|
|
uris=[url],
|
|
collection_name=name,
|
|
custom_fields={"token_name": token_name},
|
|
)
|
|
log.info(f"[{name}] Gitea token stored in Bitwarden")
|
|
|
|
return token
|
|
|
|
|
|
def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|
"""
|
|
Discourse SSO login + User API key generation.
|
|
|
|
Proven flow (session 2, verified on vp-techops):
|
|
1. Click .login-button to open the login modal
|
|
2. Click the OpenID Connect button inside the modal
|
|
3. First login redirects to /signup with email pre-authenticated:
|
|
fill username, click Sign Up
|
|
4. User API key via the RSA-encrypted flow:
|
|
- Generate RSA keypair, pass public key to /user-api-key/new
|
|
- Click Authorize
|
|
- Capture the POST response payload, decrypt (PKCS1v15),
|
|
parse JSON to extract the "key" field
|
|
|
|
The resulting key authenticates via the User-Api-Key header
|
|
(NOT Api-Key -- that is for admin-created keys).
|
|
|
|
Returns the API key (empty string on failure).
|
|
"""
|
|
name = agent["name"]
|
|
systems = agent.get("systems", {})
|
|
discourse_cfg = systems.get("discourse", {})
|
|
|
|
if not discourse_cfg:
|
|
log.info(f"[{name}] No Discourse config -- skipping")
|
|
return ""
|
|
|
|
item_name = f"{name} Discourse"
|
|
if bw.item_exists(item_name):
|
|
log.info(f"[{name}] Discourse key already exists -- skipping")
|
|
return bw.get_item_password(item_name)
|
|
|
|
url = discourse_cfg.get("url", DISCOURSE_URL)
|
|
username = agent.get("username", name.replace("-", ""))
|
|
|
|
# --- SSO login ---
|
|
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
|
|
if not page.query_selector("#current-user, .current-user"):
|
|
login_btn = page.locator(".login-button, button:has-text('Log In')")
|
|
if login_btn.count() > 0:
|
|
login_btn.first.click()
|
|
page.wait_for_timeout(3000)
|
|
|
|
sso_btn = page.locator('button:has-text("OpenID")')
|
|
if sso_btn.count() > 0:
|
|
sso_btn.first.click()
|
|
page.wait_for_timeout(5000)
|
|
|
|
# First login: /signup with email already authenticated by OIDC
|
|
if "/signup" in page.url:
|
|
page.wait_for_timeout(2000)
|
|
username_input = page.locator('#new-account-username, input[name="username"]')
|
|
if username_input.count() > 0 and username_input.first.is_visible():
|
|
username_input.first.click()
|
|
page.keyboard.type(username)
|
|
page.wait_for_timeout(1000)
|
|
for btn_text in ["Create Account", "Sign Up", "Register"]:
|
|
loc = page.locator(f'button:has-text("{btn_text}")')
|
|
if loc.count() > 0 and loc.first.is_visible():
|
|
loc.first.click()
|
|
page.wait_for_timeout(5000)
|
|
break
|
|
log.info(f"[{name}] Discourse account created: {username}")
|
|
|
|
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
if not page.query_selector("#current-user, .current-user"):
|
|
log.error(f"[{name}] Discourse SSO login failed")
|
|
_debug_dump(page, f"discourse-login-failed-{name}")
|
|
return ""
|
|
log.info(f"[{name}] Discourse SSO login OK")
|
|
|
|
# --- User API key (RSA flow) ---
|
|
import base64
|
|
import secrets
|
|
import uuid as uuid_lib
|
|
from urllib.parse import quote_plus
|
|
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
|
from cryptography.hazmat.primitives.asymmetric import rsa as asym_rsa
|
|
|
|
private_key = asym_rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
public_pem = private_key.public_key().public_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
).decode("ascii")
|
|
|
|
nonce = secrets.token_hex(16)
|
|
client_id = str(uuid_lib.uuid4())
|
|
app_name = f"TSG-Agent-{agent.get('display_name', name)}"
|
|
params = (
|
|
f"?application_name={quote_plus(app_name)}"
|
|
f"&client_id={client_id}"
|
|
f"&nonce={nonce}"
|
|
f"&scopes=read%2Cwrite"
|
|
f"&public_key={quote_plus(public_pem)}"
|
|
)
|
|
|
|
api_responses = []
|
|
|
|
def handle_response(response):
|
|
if "user-api-key" in response.url and response.request.method == "POST":
|
|
try:
|
|
api_responses.append(response.text())
|
|
except Exception:
|
|
pass
|
|
|
|
page.on("response", handle_response)
|
|
try:
|
|
page.goto(f"{url}/user-api-key/new{params}", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
|
|
for btn_text in ["Authorize", "Approve", "Continue"]:
|
|
loc = page.locator(f'button:has-text("{btn_text}"), .btn-primary')
|
|
if loc.count() > 0 and loc.first.is_visible():
|
|
loc.first.click()
|
|
break
|
|
page.wait_for_timeout(3000)
|
|
finally:
|
|
page.remove_listener("response", handle_response)
|
|
|
|
api_key = ""
|
|
for resp_text in api_responses:
|
|
try:
|
|
data = json.loads(resp_text)
|
|
encrypted_raw = data.get("key") or data.get("payload") or ""
|
|
if not encrypted_raw:
|
|
continue
|
|
encrypted = base64.b64decode(
|
|
encrypted_raw.replace("\n", "").replace("\r", "").replace(" ", "")
|
|
)
|
|
# Discourse uses PKCS1v15; try OAEP variants as fallback
|
|
paddings = [
|
|
asym_padding.PKCS1v15(),
|
|
asym_padding.OAEP(
|
|
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
|
|
algorithm=hashes.SHA256(), label=None),
|
|
asym_padding.OAEP(
|
|
mgf=asym_padding.MGF1(algorithm=hashes.SHA1()),
|
|
algorithm=hashes.SHA1(), label=None),
|
|
]
|
|
for pad in paddings:
|
|
try:
|
|
decrypted = private_key.decrypt(encrypted, pad).decode("ascii")
|
|
try:
|
|
api_key = json.loads(decrypted).get("key", decrypted)
|
|
except json.JSONDecodeError:
|
|
api_key = decrypted
|
|
break
|
|
except Exception:
|
|
continue
|
|
if api_key:
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if not api_key:
|
|
log.error(f"[{name}] Could not extract Discourse API key")
|
|
_debug_dump(page, f"discourse-no-key-{name}")
|
|
return ""
|
|
|
|
log.info(f"[{name}] Discourse API key obtained: {api_key[:8]}...")
|
|
|
|
bw.create_item(
|
|
name=item_name,
|
|
username=username,
|
|
password=api_key,
|
|
uris=[url],
|
|
collection_name=name,
|
|
)
|
|
log.info(f"[{name}] Discourse API key stored in Bitwarden")
|
|
|
|
return api_key
|
|
|
|
|
|
def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|
"""
|
|
Redmine SSO login + API access key extraction.
|
|
|
|
Proven flow (session 2, verified on vp-techops):
|
|
1. Click "Continue with KNEL Cloud" (#login-oauth-submit-1)
|
|
2. OIDC may show a consent page -- click Continue
|
|
3. On /my/account click "Show" in the .api-key-actions section
|
|
4. Read the 40-hex key from the #api-access-key pre element
|
|
5. If no key exists yet, click the API-key Reset link via targeted
|
|
DOM traversal (a generic "Reset" match clicks the wrong section)
|
|
|
|
Prereq: the Cloudron user must have Redmine app access granted by
|
|
the Cloudron admin, else OIDC shows "You do not have access".
|
|
|
|
Returns the API key (empty string on failure).
|
|
"""
|
|
name = agent["name"]
|
|
systems = agent.get("systems", {})
|
|
redmine_cfg = systems.get("redmine", {})
|
|
|
|
if not redmine_cfg:
|
|
log.info(f"[{name}] No Redmine config -- skipping")
|
|
return ""
|
|
|
|
item_name = f"{name} Redmine"
|
|
if bw.item_exists(item_name):
|
|
log.info(f"[{name}] Redmine key already exists -- skipping")
|
|
return bw.get_item_password(item_name)
|
|
|
|
url = redmine_cfg.get("url", REDMINE_URL)
|
|
username = agent.get("username", name.replace("-", ""))
|
|
|
|
# --- SSO login ---
|
|
page.goto(f"{url}/login", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
|
|
sso_btn = page.locator(
|
|
'#login-oauth-submit-1, button:has-text("KNEL"), button:has-text("Continue")'
|
|
)
|
|
if sso_btn.count() > 0 and sso_btn.first.is_visible():
|
|
sso_btn.first.click()
|
|
page.wait_for_timeout(5000)
|
|
|
|
if "openid" in page.url.lower():
|
|
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
|
|
cbtn = page.locator(
|
|
f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")'
|
|
)
|
|
if cbtn.count() > 0 and cbtn.first.is_visible():
|
|
cbtn.first.click()
|
|
page.wait_for_timeout(5000)
|
|
break
|
|
|
|
if "/login" in page.url:
|
|
log.error(f"[{name}] Redmine SSO failed (still on login page)")
|
|
_debug_dump(page, f"redmine-sso-failed-{name}")
|
|
return ""
|
|
log.info(f"[{name}] Redmine SSO login OK")
|
|
|
|
# --- API key ---
|
|
page.goto(f"{url}/my/account", wait_until="domcontentloaded", timeout=30000)
|
|
page.wait_for_timeout(3000)
|
|
_debug_dump(page, f"redmine-account-{name}")
|
|
|
|
api_key = ""
|
|
|
|
# A key usually exists (auto-created); reveal it via "Show"
|
|
show_btn = page.locator('.api-key-actions a:has-text("Show"), a:has-text("Show")')
|
|
if show_btn.count() > 0 and show_btn.first.is_visible():
|
|
show_btn.first.click()
|
|
page.wait_for_timeout(2000)
|
|
api_el = page.query_selector("#api-access-key")
|
|
if api_el:
|
|
matches = re.findall(r"[a-f0-9]{40}", api_el.text_content())
|
|
if matches:
|
|
api_key = matches[0]
|
|
|
|
# No key yet: generate via the Reset link next to #api-access-key
|
|
if not api_key:
|
|
reset_clicked = page.evaluate("""() => {
|
|
const apiSection = document.querySelector('#api-access-key');
|
|
if (!apiSection) return false;
|
|
let container = apiSection.closest('div, p, fieldset');
|
|
while (container && container.parentElement) {
|
|
const reset = Array.from(container.querySelectorAll('a, button')).find(el =>
|
|
el.textContent.trim().toLowerCase() === 'reset' && el.offsetParent !== null
|
|
);
|
|
if (reset) { reset.click(); return true; }
|
|
container = container.parentElement;
|
|
if (container.tagName === 'FIELDSET' || container.tagName === 'FORM') break;
|
|
}
|
|
return false;
|
|
}""")
|
|
if reset_clicked:
|
|
page.wait_for_timeout(3000)
|
|
confirm = page.locator(
|
|
'button:has-text("OK"), button:has-text("Confirm"), button:has-text("Yes")'
|
|
)
|
|
if confirm.count() > 0 and confirm.first.is_visible():
|
|
confirm.first.click()
|
|
page.wait_for_timeout(3000)
|
|
api_el = page.query_selector("#api-access-key")
|
|
if api_el:
|
|
matches = re.findall(r"[a-f0-9]{40}", api_el.text_content())
|
|
if matches:
|
|
api_key = matches[0]
|
|
|
|
if not api_key:
|
|
log.error(f"[{name}] Could not get Redmine API key")
|
|
_debug_dump(page, f"redmine-no-key-{name}")
|
|
return ""
|
|
|
|
log.info(f"[{name}] Redmine API key obtained: {api_key[:8]}...")
|
|
|
|
bw.create_item(
|
|
name=item_name,
|
|
username=username,
|
|
password=api_key,
|
|
uris=[url],
|
|
collection_name=name,
|
|
)
|
|
log.info(f"[{name}] Redmine API key stored in Bitwarden")
|
|
|
|
return api_key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def verify_gitea(token: str, agent: dict) -> bool:
|
|
"""Verify the Gitea API token works."""
|
|
import urllib.request
|
|
|
|
url = agent.get("systems", {}).get("gitea", {}).get("url", GITEA_URL)
|
|
req = urllib.request.Request(f"{url}/api/v1/user", headers={"Authorization": f"token {token}"})
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
data = json.loads(resp.read())
|
|
log.info(f" Gitea verify: user={data.get('login', '?')}")
|
|
return resp.status == 200
|
|
except Exception as e:
|
|
log.error(f" Gitea verify FAILED: {e}")
|
|
return False
|
|
|
|
|
|
def verify_redmine(key: str, agent: dict) -> bool:
|
|
"""Verify the Redmine API key works."""
|
|
import urllib.request
|
|
|
|
url = agent.get("systems", {}).get("redmine", {}).get("url", REDMINE_URL)
|
|
req = urllib.request.Request(f"{url}/users/current.json", headers={"X-Redmine-API-Key": key})
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
data = json.loads(resp.read())
|
|
log.info(f" Redmine verify: user={data.get('user', {}).get('login', '?')}")
|
|
return resp.status == 200
|
|
except Exception as e:
|
|
log.error(f" Redmine verify FAILED: {e}")
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def load_manifest(path: str) -> list[dict]:
|
|
"""Load and validate the agent manifest."""
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
agents = data.get("agents", [])
|
|
if not agents:
|
|
log.error("No agents found in manifest")
|
|
sys.exit(1)
|
|
|
|
for agent in agents:
|
|
if "cloudron_invite" not in agent or "REPLACE" in agent["cloudron_invite"]:
|
|
log.warning(f"Agent {agent.get('name', '?')} has no valid Cloudron invite")
|
|
|
|
return agents
|
|
|
|
|
|
def provision_agent(
|
|
context: BrowserContext,
|
|
agent: dict,
|
|
bw: BitwardenHelper,
|
|
phase1_only: bool = False,
|
|
) -> dict:
|
|
"""Provision a single agent identity."""
|
|
name = agent["name"]
|
|
results = {"name": name, "cloudron": False, "gitea": False, "discourse": False, "redmine": False}
|
|
|
|
page = context.new_page()
|
|
|
|
try:
|
|
# Phase 1: Cloudron enrollment
|
|
enroll_cloudron(page, agent, bw)
|
|
results["cloudron"] = True
|
|
log.info(f"[{name}] Phase 1 complete: Cloudron identity enrolled")
|
|
|
|
if phase1_only:
|
|
log.info(f"[{name}] Phase 1 only — skipping system access")
|
|
return results
|
|
|
|
systems = agent.get("systems", {})
|
|
if not systems:
|
|
log.info(f"[{name}] No systems configured — Phase 1 only")
|
|
return results
|
|
|
|
# Establish Cloudron panel session for SSO (shared across all apps)
|
|
sso_page = context.new_page()
|
|
cloudron_panel_login(sso_page, agent, bw)
|
|
sso_page.close()
|
|
|
|
# Phase 2: System access (fresh page for each system)
|
|
for system_name in ["gitea", "discourse", "redmine"]:
|
|
system_page = context.new_page()
|
|
try:
|
|
if system_name == "gitea":
|
|
token = provision_gitea(system_page, agent, bw)
|
|
results["gitea"] = bool(token) and verify_gitea(token, agent)
|
|
elif system_name == "discourse":
|
|
key = provision_discourse(system_page, agent, bw)
|
|
results["discourse"] = bool(key)
|
|
elif system_name == "redmine":
|
|
key = provision_redmine(system_page, agent, bw)
|
|
results["redmine"] = bool(key) and verify_redmine(key, agent)
|
|
except Exception as e:
|
|
log.error(f"[{name}] {system_name} provisioning failed: {e}")
|
|
finally:
|
|
system_page.close()
|
|
|
|
log.info(f"[{name}] All phases complete: {results}")
|
|
|
|
except Exception as e:
|
|
log.error(f"[{name}] Provisioning failed: {e}")
|
|
results["error"] = str(e)
|
|
finally:
|
|
page.close()
|
|
|
|
# Save state (in finally so partial results survive failures)
|
|
state_file = STATE_DIR / f"{name}.json"
|
|
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
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Provision AI agent identities")
|
|
parser.add_argument("--manifest", default="agents.yaml", help="Path to manifest file")
|
|
parser.add_argument("--agent", help="Provision only this agent")
|
|
parser.add_argument("--phase1-only", action="store_true", help="Cloudron enrollment only")
|
|
parser.add_argument("--enable-2fa", action="store_true", help="Enable 2FA on existing Cloudron accounts")
|
|
parser.add_argument("--dry-run", action="store_true", help="Validate manifest without browser")
|
|
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:
|
|
agents = [a for a in agents if a["name"] == args.agent]
|
|
if not agents:
|
|
log.error(f"Agent '{args.agent}' not found in manifest")
|
|
sys.exit(1)
|
|
|
|
log.info(f"Manifest: {len(agents)} agent(s) to provision")
|
|
for a in agents:
|
|
log.info(f" - {a['name']} ({a.get('display_name', '?')}) [{a.get('priority', '?')}]")
|
|
|
|
if args.dry_run:
|
|
log.info("Dry run — manifest validated successfully")
|
|
return
|
|
|
|
# Initialize Bitwarden
|
|
bw = BitwardenHelper(
|
|
client_id=os.environ["BW_CLIENTID"],
|
|
client_secret=os.environ["BW_CLIENTSECRET"],
|
|
password=os.environ["BW_PASSWORD"],
|
|
totp_secret=os.environ.get("BW_TOTP_SECRET", ""),
|
|
server_url=os.environ.get("BW_SERVER", ""),
|
|
)
|
|
log.info("Connecting to Bitwarden...")
|
|
bw.login()
|
|
log.info("Bitwarden session established")
|
|
|
|
# Launch Playwright
|
|
headless = not args.headed
|
|
if os.environ.get("HEADFUL", "false").lower() == "true":
|
|
headless = False
|
|
|
|
all_results = []
|
|
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch(headless=headless)
|
|
|
|
for agent in agents:
|
|
# Fresh context per agent (no cookie/session bleed)
|
|
context = browser.new_context(
|
|
accept_downloads=False,
|
|
java_script_enabled=True,
|
|
)
|
|
log.info(f"=== Provisioning: {agent['name']} ===")
|
|
try:
|
|
if args.enable_2fa:
|
|
page = context.new_page()
|
|
cloudron_panel_login(page, agent, bw)
|
|
totp_secret = enable_cloudron_2fa(page, agent, bw)
|
|
if totp_secret:
|
|
item_name = f"{agent['name']} Cloudron"
|
|
bw.update_item(item_name, totp_secret=totp_secret)
|
|
log.info(f"[{agent['name']}] Updated BW item with TOTP secret (in place)")
|
|
page.close()
|
|
all_results.append({"name": agent["name"], "2fa": bool(totp_secret)})
|
|
else:
|
|
result = provision_agent(context, agent, bw, args.phase1_only)
|
|
all_results.append(result)
|
|
except Exception as e:
|
|
log.error(f"FAILED: {agent['name']}: {e}")
|
|
all_results.append({"name": agent["name"], "error": str(e)})
|
|
finally:
|
|
context.close()
|
|
|
|
browser.close()
|
|
|
|
# Summary
|
|
log.info("\n=== PROVISIONING SUMMARY ===")
|
|
for r in all_results:
|
|
status = "OK" if "error" not in r else "FAILED"
|
|
systems = []
|
|
for s in ["cloudron", "gitea", "discourse", "redmine"]:
|
|
if r.get(s):
|
|
systems.append(s)
|
|
log.info(f" {r['name']}: {status} — {', '.join(systems) if systems else '(none)'}")
|
|
|
|
# Exit non-zero if any failed
|
|
if any("error" in r for r in all_results):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|