fix: critical bugs in agent identity provisioning [#442]
Email domain bug (would have caused all provisioning to fail):
- Cloudron email default was tsys-cloudron.knel.net (the dashboard host)
instead of turnsys.com (the actual identity domain). Fixed in 3 places.
- Added explicit cloudron_email field to all agents in agents.yaml.example.
Other fixes:
- STATE_DIR.mkdir() moved from module level to main() so --dry-run and
--help work outside the container.
- IndexError guard: password_inputs[0] crashes if zero fields found.
- State file save moved to finally block so partial results survive
provisioning failures.
- Exception in provision_agent no longer re-raised (was preventing state
file write and summary reporting).
- BW item_exists no longer swallows network/session errors as 'not found'
(was causing duplicate credential creation).
- Redundant -u flag in bw generate (-uluns → -ulns).
- Dockerfile: npx install with || true → npm install -g (silent failure
would cause runtime 'bw: command not found').
- Added .dockerignore to prevent .env/agents.yaml/state from entering image.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
agents.yaml
|
||||||
|
state/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.md
|
||||||
|
.crush/
|
||||||
+1
-1
@@ -9,7 +9,7 @@ RUN apt-get update && \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Bitwarden CLI
|
# Install Bitwarden CLI
|
||||||
RUN npx -y @bitwarden/cli@2026.7.0 || true
|
RUN npm install -g @bitwarden/cli@2025.6.0
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt /tmp/
|
COPY requirements.txt /tmp/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
agents:
|
agents:
|
||||||
- name: vp-techops
|
- name: vp-techops
|
||||||
display_name: "VP TechOps"
|
display_name: "VP TechOps"
|
||||||
|
cloudron_email: "vp-techops@turnsys.com"
|
||||||
priority: Q3
|
priority: Q3
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems:
|
systems:
|
||||||
@@ -28,6 +29,7 @@ agents:
|
|||||||
|
|
||||||
- name: vp-secops
|
- name: vp-secops
|
||||||
display_name: "VP SecOps"
|
display_name: "VP SecOps"
|
||||||
|
cloudron_email: "vp-secops@turnsys.com"
|
||||||
priority: Q3
|
priority: Q3
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems:
|
systems:
|
||||||
@@ -46,6 +48,7 @@ agents:
|
|||||||
|
|
||||||
- name: vp-techcompliance
|
- name: vp-techcompliance
|
||||||
display_name: "VP TechCompliance"
|
display_name: "VP TechCompliance"
|
||||||
|
cloudron_email: "vp-techcompliance@turnsys.com"
|
||||||
priority: Q3
|
priority: Q3
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems:
|
systems:
|
||||||
@@ -65,18 +68,21 @@ agents:
|
|||||||
# Q4 agents — enroll in Cloudron only (Phase 1), no system access yet
|
# Q4 agents — enroll in Cloudron only (Phase 1), no system access yet
|
||||||
- name: coo
|
- name: coo
|
||||||
display_name: "Chief Operating Officer"
|
display_name: "Chief Operating Officer"
|
||||||
|
cloudron_email: "coo@turnsys.com"
|
||||||
priority: Q4
|
priority: Q4
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems: {}
|
systems: {}
|
||||||
|
|
||||||
- name: svp-knel
|
- name: svp-knel
|
||||||
display_name: "SVP KNEL"
|
display_name: "SVP KNEL"
|
||||||
|
cloudron_email: "svp-knel@turnsys.com"
|
||||||
priority: Q4
|
priority: Q4
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems: {}
|
systems: {}
|
||||||
|
|
||||||
- name: svp-tctc
|
- name: svp-tctc
|
||||||
display_name: "SVP TCTC"
|
display_name: "SVP TCTC"
|
||||||
|
cloudron_email: "svp-tctc@turnsys.com"
|
||||||
priority: Q4
|
priority: Q4
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems: {}
|
systems: {}
|
||||||
|
|||||||
+7
-2
@@ -74,7 +74,7 @@ class BitwardenHelper:
|
|||||||
|
|
||||||
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", "-uluns", "--length", str(length)])
|
return self._run_bw(["generate", "-ulns", "--length", str(length)])
|
||||||
|
|
||||||
def get_totp(self, item_name: str) -> str:
|
def get_totp(self, item_name: str) -> str:
|
||||||
"""Get the current TOTP code for a Bitwarden item."""
|
"""Get the current TOTP code for a Bitwarden item."""
|
||||||
@@ -172,8 +172,13 @@ class BitwardenHelper:
|
|||||||
try:
|
try:
|
||||||
self._run_bw(["get", "item", name])
|
self._run_bw(["get", "item", name])
|
||||||
return True
|
return True
|
||||||
except RuntimeError:
|
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
|
return False
|
||||||
|
# Real error — re-raise so we don't silently create duplicates.
|
||||||
|
raise
|
||||||
|
|
||||||
def get_item_password(self, name: str) -> str:
|
def get_item_password(self, name: str) -> str:
|
||||||
"""Get the password field from a Bitwarden item."""
|
"""Get the password field from a Bitwarden item."""
|
||||||
|
|||||||
+16
-8
@@ -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")
|
REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com")
|
||||||
|
|
||||||
STATE_DIR = Path("/app/state")
|
STATE_DIR = Path("/app/state")
|
||||||
STATE_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cloudron enrollment
|
# Cloudron enrollment
|
||||||
@@ -77,8 +76,9 @@ def enroll_cloudron(
|
|||||||
item_name = f"{name} Cloudron"
|
item_name = f"{name} Cloudron"
|
||||||
if bw.item_exists(item_name):
|
if bw.item_exists(item_name):
|
||||||
log.info(f"[{name}] Cloudron credential already exists in Bitwarden — skipping")
|
log.info(f"[{name}] Cloudron credential already exists in Bitwarden — skipping")
|
||||||
|
cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com")
|
||||||
return {
|
return {
|
||||||
"username": agent.get("cloudron_email", f"{name}@tsys-cloudron.knel.net"),
|
"username": cloudron_email,
|
||||||
"password": bw.get_item_password(item_name),
|
"password": bw.get_item_password(item_name),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +97,10 @@ def enroll_cloudron(
|
|||||||
if len(password_inputs) >= 2:
|
if len(password_inputs) >= 2:
|
||||||
password_inputs[0].fill(password)
|
password_inputs[0].fill(password)
|
||||||
password_inputs[1].fill(password)
|
password_inputs[1].fill(password)
|
||||||
else:
|
elif len(password_inputs) == 1:
|
||||||
password_inputs[0].fill(password)
|
password_inputs[0].fill(password)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"[{name}] No password field found on Cloudron invite page")
|
||||||
|
|
||||||
# Set display name if field exists
|
# Set display name if field exists
|
||||||
name_field = page.query_selector('input[name="displayName"], input[name="name"]')
|
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)
|
totp_secret = enable_cloudron_2fa(page, agent, bw)
|
||||||
|
|
||||||
# Store credential in Bitwarden
|
# 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(
|
bw.create_item(
|
||||||
name=item_name,
|
name=item_name,
|
||||||
username=cloudron_email,
|
username=cloudron_email,
|
||||||
@@ -235,14 +237,14 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper) ->
|
|||||||
|
|
||||||
# Fill Cloudron SSO login form
|
# Fill Cloudron SSO login form
|
||||||
cloudron_item = f"{name} Cloudron"
|
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)
|
password = bw.get_item_password(cloudron_item)
|
||||||
|
|
||||||
user_input = page.query_selector('input[name="username"], input[type="email"], input[name="email"]')
|
user_input = page.query_selector('input[name="username"], input[type="email"], input[name="email"]')
|
||||||
pass_input = page.query_selector('input[type="password"]')
|
pass_input = page.query_selector('input[type="password"]')
|
||||||
|
|
||||||
if user_input:
|
if user_input:
|
||||||
user_input.fill(username)
|
user_input.fill(cloudron_email)
|
||||||
if pass_input:
|
if pass_input:
|
||||||
pass_input.fill(password)
|
pass_input.fill(password)
|
||||||
|
|
||||||
@@ -569,14 +571,17 @@ def provision_agent(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"[{name}] Provisioning failed: {e}")
|
log.error(f"[{name}] Provisioning failed: {e}")
|
||||||
raise
|
results["error"] = str(e)
|
||||||
finally:
|
finally:
|
||||||
page.close()
|
page.close()
|
||||||
|
|
||||||
# Save state
|
# Save state (in finally so partial results survive failures)
|
||||||
state_file = STATE_DIR / f"{name}.json"
|
state_file = STATE_DIR / f"{name}.json"
|
||||||
|
try:
|
||||||
with open(state_file, "w") as f:
|
with open(state_file, "w") as f:
|
||||||
json.dump(results, f, indent=2)
|
json.dump(results, f, indent=2)
|
||||||
|
except OSError:
|
||||||
|
log.warning(f"[{name}] Could not write state file: {state_file}")
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -590,6 +595,9 @@ def main():
|
|||||||
parser.add_argument("--headed", action="store_true", help="Show browser (debugging)")
|
parser.add_argument("--headed", action="store_true", help="Show browser (debugging)")
|
||||||
args = parser.parse_args()
|
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
|
# Load manifest
|
||||||
agents = load_manifest(args.manifest)
|
agents = load_manifest(args.manifest)
|
||||||
if args.agent:
|
if args.agent:
|
||||||
|
|||||||
Reference in New Issue
Block a user