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>
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""hf-cli config … — runtime configuration stored in DB.
|
|
|
|
Currently only the OIDC provider config has a CLI surface (it used to
|
|
live in the AbstractWizard config). Mirrors dialectic-cli's
|
|
`config oidc` shape: only the flags you pass are mutated, the rest stays
|
|
unchanged. Prints the post-update row with client_secret masked unless
|
|
--show-secret is given.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
|
|
from app.core.config import SessionLocal
|
|
from app.models.oidc_settings import OidcSettings
|
|
|
|
|
|
def _emit(payload: dict) -> None:
|
|
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
|
|
|
|
def _bool(v: str) -> bool:
|
|
return v.lower() in ("1", "true", "yes", "on")
|
|
|
|
|
|
def _cmd_oidc(argv: list[str]) -> int:
|
|
p = argparse.ArgumentParser(prog="hf-cli config oidc")
|
|
p.add_argument("--issuer", default=None)
|
|
p.add_argument("--client-id", default=None)
|
|
p.add_argument("--client-secret", default=None)
|
|
p.add_argument("--redirect-uri", default=None)
|
|
p.add_argument("--post-login-redirect", default=None)
|
|
p.add_argument("--scopes", default=None,
|
|
help='Default: "openid email profile"')
|
|
p.add_argument("--admin-role", default=None,
|
|
help="OIDC role name that bootstraps an unbound hf admin "
|
|
"on first OIDC-only login. Default: admin.")
|
|
p.add_argument("--enabled", default=None,
|
|
help="true|false. Without this flag the row's existing "
|
|
"value is preserved.")
|
|
p.add_argument("--show-secret", action="store_true",
|
|
help="Reveal client_secret in the output (local audit "
|
|
"only — never paste into chat).")
|
|
args = p.parse_args(argv)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
|
if row is None:
|
|
row = OidcSettings(id=1, enabled=False)
|
|
db.add(row)
|
|
|
|
if args.issuer is not None:
|
|
row.issuer = args.issuer.strip() or None
|
|
if args.client_id is not None:
|
|
row.client_id = args.client_id.strip() or None
|
|
if args.client_secret is not None:
|
|
row.client_secret = args.client_secret or None
|
|
if args.redirect_uri is not None:
|
|
row.redirect_uri = args.redirect_uri.strip() or None
|
|
if args.post_login_redirect is not None:
|
|
row.post_login_redirect = args.post_login_redirect.strip() or None
|
|
if args.scopes is not None:
|
|
row.scopes = args.scopes.strip() or None
|
|
if args.admin_role is not None:
|
|
row.admin_role = args.admin_role.strip() or None
|
|
if args.enabled is not None:
|
|
row.enabled = _bool(args.enabled)
|
|
|
|
db.commit()
|
|
db.refresh(row)
|
|
|
|
out: dict = {
|
|
"enabled": bool(row.enabled),
|
|
"issuer": row.issuer,
|
|
"client_id": row.client_id,
|
|
"redirect_uri": row.redirect_uri,
|
|
"post_login_redirect": row.post_login_redirect,
|
|
"scopes": row.scopes,
|
|
"admin_role": row.admin_role,
|
|
}
|
|
if args.show_secret:
|
|
out["client_secret"] = row.client_secret
|
|
elif row.client_secret:
|
|
out["client_secret"] = "***set***"
|
|
else:
|
|
out["client_secret"] = None
|
|
|
|
_emit({"ok": True, "config": out})
|
|
return 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
ACTIONS = {
|
|
"oidc": _cmd_oidc,
|
|
}
|
|
|
|
|
|
def dispatch(argv: list[str]) -> int:
|
|
if not argv:
|
|
sys.stderr.write("config: missing action; one of: " + ", ".join(ACTIONS) + "\n")
|
|
return 1
|
|
action, rest = argv[0], argv[1:]
|
|
fn = ACTIONS.get(action)
|
|
if not fn:
|
|
sys.stderr.write(f"config: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n")
|
|
return 1
|
|
return fn(rest)
|