Drops the AbstractWizard config-volume bootstrap entirely. All deploy-time
config now comes from docker env vars (.env). First-deploy admin user + OIDC
provider config are operator-driven via `docker exec hf_backend hf-cli ...`.
Backend changes:
- entrypoint.sh: drop config-wait loop, just exec uvicorn
- app/core/config.py: drop _resolve_db_url + OIDC_* env vars (DB only now);
keep HARBORFORGE_OIDC_ONLY (deploy-time policy)
- app/init_wizard.py → app/init_bootstrap.py: drop load_config / admin / OIDC /
default-project bootstrap; keep idempotent startup seed (permissions,
default roles, acc-mgr + deleted-user builtins)
- app/main.py: /config/status now returns {initialized: <admin exists>};
startup() imports init_bootstrap.run_bootstrap
- app/api/routers/oidc.py: get_effective_oidc reads DB only (no env fallback)
- app/services/harborforge_config.py: removed (replaced by direct env reads)
- app/services/discord_wakeup.py: HF_DISCORD_GUILD_ID / HF_DISCORD_BOT_TOKEN env
- app/api/routers/users.py + tests/conftest.py: rename init_wizard refs
New hf-cli surface (app/cli/, invoked via /usr/local/bin/hf-cli shim):
hf-cli admin create-user --email <e> [--username <u>] [--password <p>]
[--oidc-issuer <url> --oidc-subject <sub>]
hf-cli admin list
hf-cli admin set-role --username <u> --role <admin|mgr|dev|guest|account-manager>
hf-cli admin reset-password --username <u> --password <p>
hf-cli admin bind-oidc --username <u> --oidc-issuer <url> --oidc-subject <sub>
hf-cli config oidc [--issuer/...] [--client-id/...] [--client-secret/...]
[--redirect-uri/...] [--enabled true|false] [--show-secret]
Bootstrap migration on existing deployments: existing admin / OIDC settings
in the DB are preserved across the cutover; only the wizard config-volume
+ wizard sidecar services need to be removed from compose. Restart picks
up the new entrypoint + skips the config wait.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import requests
|
|
from fastapi import HTTPException
|
|
|
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
|
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
|
|
|
|
|
|
def _discord_config() -> dict[str, str | None]:
|
|
"""Discord wakeup is configured via env vars (previously read from the
|
|
AbstractWizard config file). Returns guild_id+bot_token or Nones."""
|
|
return {
|
|
"guild_id": os.getenv("HARBORFORGE_DISCORD_GUILD_ID") or None,
|
|
"bot_token": os.getenv("HARBORFORGE_DISCORD_BOT_TOKEN") or None,
|
|
}
|
|
|
|
|
|
def _headers(bot_token: str) -> dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bot {bot_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
def _ensure_category(guild_id: str, bot_token: str) -> str | None:
|
|
resp = requests.get(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), timeout=15)
|
|
if not resp.ok:
|
|
raise HTTPException(status_code=502, detail=f"Discord list channels failed: {resp.text}")
|
|
for ch in resp.json():
|
|
if ch.get("type") == 4 and ch.get("name") == WAKEUP_CATEGORY_NAME:
|
|
return ch.get("id")
|
|
payload = {"name": WAKEUP_CATEGORY_NAME, "type": 4}
|
|
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
|
|
if not created.ok:
|
|
raise HTTPException(status_code=502, detail=f"Discord create category failed: {created.text}")
|
|
return created.json().get("id")
|
|
|
|
|
|
def create_private_wakeup_channel(discord_user_id: str, title: str, message: str) -> dict[str, Any]:
|
|
cfg = _discord_config()
|
|
guild_id = cfg.get("guild_id")
|
|
bot_token = cfg.get("bot_token")
|
|
if not guild_id or not bot_token:
|
|
raise HTTPException(status_code=400, detail="Discord wakeup config is incomplete")
|
|
|
|
category_id = _ensure_category(guild_id, bot_token)
|
|
channel_name = f"wake-{discord_user_id[-6:]}-{int(datetime.now(timezone.utc).timestamp())}"
|
|
payload = {
|
|
"name": channel_name,
|
|
"type": 0,
|
|
"parent_id": category_id,
|
|
"permission_overwrites": [
|
|
{"id": guild_id, "type": 0, "deny": "1024"},
|
|
{"id": discord_user_id, "type": 1, "allow": "1024"},
|
|
],
|
|
"topic": title,
|
|
}
|
|
created = requests.post(f"{DISCORD_API_BASE}/guilds/{guild_id}/channels", headers=_headers(bot_token), json=payload, timeout=15)
|
|
if not created.ok:
|
|
raise HTTPException(status_code=502, detail=f"Discord create channel failed: {created.text}")
|
|
channel = created.json()
|
|
sent = requests.post(
|
|
f"{DISCORD_API_BASE}/channels/{channel['id']}/messages",
|
|
headers=_headers(bot_token),
|
|
json={"content": message},
|
|
timeout=15,
|
|
)
|
|
if not sent.ok:
|
|
raise HTTPException(status_code=502, detail=f"Discord send message failed: {sent.text}")
|
|
return {
|
|
"guild_id": guild_id,
|
|
"channel_id": channel.get("id"),
|
|
"channel_name": channel.get("name"),
|
|
"message_id": sent.json().get("id"),
|
|
}
|