- Remove all ZITADEL_FIRSTINSTANCE_* environment variables - Fixes migration error: duplicate key constraint violation - Root cause: Bug in Zitadel v2.63.7 FirstInstance migration - Workaround: Complete initial setup via web UI - Upstream issue: https://github.com/zitadel/zitadel/issues/8791 Changes: - Clean up obsolete documentation (OIDC_AUTOMATION.md, SETUP_GUIDE.md, COLLABORA_SETUP.md) - Add PROJECT_REFERENCE.md for essential configuration info - Add force recreate functionality with clean database volumes - Update bootstrap instructions for web UI setup - Document one-time manual setup requirement for OIDC automation Zitadel now deploys successfully and is accessible at: https://zitadel.test.vrije.cloud 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
5 KiB
Python
171 lines
5 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Automate creation of Zitadel API service user and Personal Access Token.
|
||
This script logs in as admin and creates a machine user with a PAT for API automation.
|
||
"""
|
||
|
||
import requests
|
||
import sys
|
||
import time
|
||
import re
|
||
from urllib.parse import urlparse, parse_qs
|
||
|
||
def login_and_get_session(domain, username, password):
|
||
"""Login to Zitadel and get authenticated session cookies."""
|
||
session = requests.Session()
|
||
session.headers.update({
|
||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||
})
|
||
|
||
# Start login flow
|
||
login_url = f"https://{domain}/ui/login/loginname"
|
||
print(f"📡 Initiating login to {domain}...")
|
||
|
||
# Get login page to establish session
|
||
resp = session.get(login_url, allow_redirects=True)
|
||
|
||
# Submit username
|
||
login_data = {
|
||
'loginName': username
|
||
}
|
||
resp = session.post(login_url, data=login_data, allow_redirects=True)
|
||
|
||
# Submit password
|
||
password_url = f"https://{domain}/ui/login/password"
|
||
password_data = {
|
||
'password': password
|
||
}
|
||
resp = session.post(password_url, data=password_data, allow_redirects=True)
|
||
|
||
if 'set-cookie' in resp.headers or len(session.cookies) > 0:
|
||
print("✅ Login successful!")
|
||
return session
|
||
else:
|
||
print(f"❌ Login failed. Status: {resp.status_code}")
|
||
print(f"Response: {resp.text[:500]}")
|
||
return None
|
||
|
||
def create_machine_user(session, domain):
|
||
"""Create a machine user via Management API."""
|
||
api_url = f"https://{domain}/management/v1/users/machine"
|
||
|
||
print("🤖 Creating API automation service user...")
|
||
|
||
payload = {
|
||
"userName": "api-automation",
|
||
"name": "API Automation Service",
|
||
"description": "Service account for automated OIDC app provisioning",
|
||
"accessTokenType": "ACCESS_TOKEN_TYPE_BEARER"
|
||
}
|
||
|
||
resp = session.post(api_url, json=payload)
|
||
|
||
if resp.status_code in [200, 201]:
|
||
data = resp.json()
|
||
user_id = data.get('userId')
|
||
print(f"✅ Machine user created: {user_id}")
|
||
return user_id
|
||
elif resp.status_code == 409:
|
||
print("ℹ️ Machine user already exists")
|
||
# Try to get existing user
|
||
list_url = f"https://{domain}/management/v1/users/_search"
|
||
search_payload = {
|
||
"query": {
|
||
"userName": "api-automation"
|
||
}
|
||
}
|
||
resp = session.post(list_url, json=search_payload)
|
||
if resp.status_code == 200:
|
||
users = resp.json().get('result', [])
|
||
if users:
|
||
user_id = users[0].get('id')
|
||
print(f"✅ Found existing user: {user_id}")
|
||
return user_id
|
||
return None
|
||
else:
|
||
print(f"❌ Failed to create machine user. Status: {resp.status_code}")
|
||
print(f"Response: {resp.text}")
|
||
return None
|
||
|
||
def create_pat(session, domain, user_id):
|
||
"""Create a Personal Access Token for the machine user."""
|
||
pat_url = f"https://{domain}/management/v1/users/{user_id}/pats"
|
||
|
||
print("🔑 Creating Personal Access Token...")
|
||
|
||
payload = {
|
||
"expirationDate": "2099-12-31T23:59:59Z"
|
||
}
|
||
|
||
resp = session.post(pat_url, json=payload)
|
||
|
||
if resp.status_code in [200, 201]:
|
||
data = resp.json()
|
||
token = data.get('token')
|
||
if token:
|
||
print("✅ Personal Access Token created successfully!")
|
||
return token
|
||
else:
|
||
print("⚠️ PAT created but token not in response")
|
||
print(f"Response: {resp.text}")
|
||
return None
|
||
else:
|
||
print(f"❌ Failed to create PAT. Status: {resp.status_code}")
|
||
print(f"Response: {resp.text}")
|
||
return None
|
||
|
||
def main():
|
||
if len(sys.argv) != 4:
|
||
print("Usage: python3 bootstrap_api_token.py <domain> <admin_username> <admin_password>")
|
||
print("Example: python3 bootstrap_api_token.py zitadel.test.vrije.cloud 'admin@test.zitadel.test.vrije.cloud' 'password123'")
|
||
sys.exit(1)
|
||
|
||
domain = sys.argv[1]
|
||
username = sys.argv[2]
|
||
password = sys.argv[3]
|
||
|
||
print(f"""
|
||
🚀 Zitadel API Token Bootstrap
|
||
================================
|
||
Domain: {domain}
|
||
Admin: {username}
|
||
""")
|
||
|
||
# Step 1: Login
|
||
session = login_and_get_session(domain, username, password)
|
||
if not session:
|
||
print("\n❌ Failed to establish session")
|
||
sys.exit(1)
|
||
|
||
# Small delay to ensure session is established
|
||
time.sleep(2)
|
||
|
||
# Step 2: Create machine user
|
||
user_id = create_machine_user(session, domain)
|
||
if not user_id:
|
||
print("\n❌ Failed to create or find machine user")
|
||
sys.exit(1)
|
||
|
||
# Small delay
|
||
time.sleep(1)
|
||
|
||
# Step 3: Create PAT
|
||
token = create_pat(session, domain, user_id)
|
||
if not token:
|
||
print("\n❌ Failed to create Personal Access Token")
|
||
sys.exit(1)
|
||
|
||
print(f"""
|
||
✅ SUCCESS! API automation is ready.
|
||
|
||
📋 Personal Access Token:
|
||
{token}
|
||
|
||
🔐 Add this to your secrets file:
|
||
zitadel_api_token: {token}
|
||
|
||
Then re-run: ansible-playbook -i hcloud.yml playbooks/deploy.yml
|
||
""")
|
||
|
||
if __name__ == '__main__':
|
||
main()
|