feat(backend)!: kill AbstractWizard, env-driven config + hf-cli
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>
This commit is contained in:
10
app/cli/__init__.py
Normal file
10
app/cli/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""hf-cli — operator commands run inside the backend container.
|
||||
|
||||
Subjects:
|
||||
admin — bootstrap / manage the initial admin user
|
||||
config — runtime config (OIDC, etc.)
|
||||
|
||||
Invoked via the shim at /usr/local/bin/hf-cli (Dockerfile-installed):
|
||||
docker exec hf-backend hf-cli admin create-user --email me@example.com --password '...'
|
||||
docker exec hf-backend hf-cli config oidc --issuer ... --client-id ... --enabled true
|
||||
"""
|
||||
48
app/cli/__main__.py
Normal file
48
app/cli/__main__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""hf-cli entry point. Dispatches to subject-specific modules."""
|
||||
import sys
|
||||
|
||||
|
||||
USAGE = """Usage:
|
||||
hf-cli admin create-user --email <e> [--username <u>] [--full-name <n>]
|
||||
[--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 <url>] [--client-id <id>] [--client-secret <s>]
|
||||
[--redirect-uri <url>] [--post-login-redirect <url>]
|
||||
[--scopes "openid email profile"] [--admin-role <role>]
|
||||
[--enabled true|false] [--show-secret]
|
||||
|
||||
Reads DATABASE_URL + SECRET_KEY from the same env as the backend. Run
|
||||
inside the backend container: `docker exec hf-backend hf-cli ...`.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 1:
|
||||
sys.stderr.write(USAGE)
|
||||
return 1
|
||||
|
||||
subject = args[0]
|
||||
rest = args[1:]
|
||||
|
||||
if subject == "admin":
|
||||
from app.cli import admin
|
||||
return admin.dispatch(rest)
|
||||
if subject == "config":
|
||||
from app.cli import config
|
||||
return config.dispatch(rest)
|
||||
if subject in ("-h", "--help", "help"):
|
||||
sys.stdout.write(USAGE)
|
||||
return 0
|
||||
|
||||
sys.stderr.write(f"unknown subject: {subject}\n\n")
|
||||
sys.stderr.write(USAGE)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
269
app/cli/admin.py
Normal file
269
app/cli/admin.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""hf-cli admin … — bootstrap and manage the deployment's admin user."""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import get_password_hash
|
||||
from app.core.config import SessionLocal, settings
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role
|
||||
|
||||
|
||||
def _open_db():
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _emit(payload: dict) -> None:
|
||||
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create-user
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_create_user(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin create-user")
|
||||
p.add_argument("--email", required=True)
|
||||
p.add_argument("--username", default=None,
|
||||
help="Defaults to email's local-part if omitted.")
|
||||
p.add_argument("--full-name", default="Admin")
|
||||
p.add_argument("--password", default=None,
|
||||
help="Required when HARBORFORGE_OIDC_ONLY=false. Ignored "
|
||||
"when OIDC_ONLY=true (use --oidc-issuer/--oidc-subject).")
|
||||
p.add_argument("--oidc-issuer", default=None,
|
||||
help="Bind the new admin to this OIDC issuer at creation. "
|
||||
"Required in OIDC_ONLY mode for the bootstrap admin.")
|
||||
p.add_argument("--oidc-subject", default=None,
|
||||
help="OIDC subject claim (sub) to bind the new admin to.")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
username = args.username or args.email.split("@", 1)[0]
|
||||
oidc_only = bool(settings.HARBORFORGE_OIDC_ONLY)
|
||||
|
||||
if oidc_only:
|
||||
if not (args.oidc_issuer and args.oidc_subject):
|
||||
sys.stderr.write(
|
||||
"HARBORFORGE_OIDC_ONLY=true: must pass --oidc-issuer and "
|
||||
"--oidc-subject so the new admin can sign in.\n"
|
||||
)
|
||||
return 2
|
||||
hashed_password = None
|
||||
else:
|
||||
if not args.password:
|
||||
sys.stderr.write("--password is required when OIDC_ONLY is false.\n")
|
||||
return 2
|
||||
hashed_password = get_password_hash(args.password)
|
||||
|
||||
if (args.oidc_issuer and not args.oidc_subject) or (args.oidc_subject and not args.oidc_issuer):
|
||||
sys.stderr.write("--oidc-issuer and --oidc-subject must be passed together.\n")
|
||||
return 2
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
existing = db.query(models.User).filter(models.User.username == username).first()
|
||||
if existing:
|
||||
sys.stderr.write(f"user '{username}' already exists (id={existing.id})\n")
|
||||
return 3
|
||||
|
||||
admin_role = db.query(Role).filter(Role.name == "admin").first()
|
||||
if not admin_role:
|
||||
sys.stderr.write(
|
||||
"admin role not found — backend startup seed should create it. "
|
||||
"Restart the container then retry.\n"
|
||||
)
|
||||
return 4
|
||||
|
||||
user = models.User(
|
||||
username=username,
|
||||
email=args.email,
|
||||
full_name=args.full_name,
|
||||
hashed_password=hashed_password,
|
||||
is_admin=True,
|
||||
is_active=True,
|
||||
role_id=admin_role.id,
|
||||
oidc_issuer=(args.oidc_issuer or None),
|
||||
oidc_subject=(args.oidc_subject or None),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
sys.stderr.write(f"DB integrity error: {e.orig}\n")
|
||||
return 5
|
||||
db.refresh(user)
|
||||
|
||||
_emit({
|
||||
"ok": True,
|
||||
"created": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_admin": user.is_admin,
|
||||
"role_id": user.role_id,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
"has_password": user.hashed_password is not None,
|
||||
},
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_list(_argv: list[str]) -> int:
|
||||
db = _open_db()
|
||||
try:
|
||||
admins = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.is_admin == True) # noqa: E712
|
||||
.order_by(models.User.id.asc())
|
||||
.all()
|
||||
)
|
||||
_emit({
|
||||
"ok": True,
|
||||
"count": len(admins),
|
||||
"admins": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"is_active": u.is_active,
|
||||
"oidc_bound": bool(u.oidc_issuer and u.oidc_subject),
|
||||
"has_password": u.hashed_password is not None,
|
||||
}
|
||||
for u in admins
|
||||
],
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set-role
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_set_role(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin set-role")
|
||||
p.add_argument("--username", required=True)
|
||||
p.add_argument("--role", required=True)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == args.username).first()
|
||||
if not user:
|
||||
sys.stderr.write(f"user '{args.username}' not found\n")
|
||||
return 3
|
||||
role = db.query(Role).filter(Role.name == args.role).first()
|
||||
if not role:
|
||||
sys.stderr.write(f"role '{args.role}' not found\n")
|
||||
return 4
|
||||
user.role_id = role.id
|
||||
user.is_admin = (args.role == "admin")
|
||||
db.commit()
|
||||
_emit({
|
||||
"ok": True,
|
||||
"user": {"id": user.id, "username": user.username, "role": role.name, "is_admin": user.is_admin},
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset-password
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_reset_password(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin reset-password")
|
||||
p.add_argument("--username", required=True)
|
||||
p.add_argument("--password", required=True)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if settings.HARBORFORGE_OIDC_ONLY:
|
||||
sys.stderr.write("HARBORFORGE_OIDC_ONLY=true: password login is disabled.\n")
|
||||
return 2
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == args.username).first()
|
||||
if not user:
|
||||
sys.stderr.write(f"user '{args.username}' not found\n")
|
||||
return 3
|
||||
user.hashed_password = get_password_hash(args.password)
|
||||
db.commit()
|
||||
_emit({"ok": True, "user": {"id": user.id, "username": user.username, "password_reset": True}})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bind-oidc — attach an OIDC identity to an existing admin
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cmd_bind_oidc(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(prog="hf-cli admin bind-oidc")
|
||||
p.add_argument("--username", required=True)
|
||||
p.add_argument("--oidc-issuer", required=True)
|
||||
p.add_argument("--oidc-subject", required=True)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
db = _open_db()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == args.username).first()
|
||||
if not user:
|
||||
sys.stderr.write(f"user '{args.username}' not found\n")
|
||||
return 3
|
||||
clash = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == args.oidc_issuer,
|
||||
models.User.oidc_subject == args.oidc_subject,
|
||||
models.User.id != user.id,
|
||||
).first()
|
||||
if clash:
|
||||
sys.stderr.write(f"OIDC subject already bound to '{clash.username}' (id={clash.id})\n")
|
||||
return 4
|
||||
user.oidc_issuer = args.oidc_issuer
|
||||
user.oidc_subject = args.oidc_subject
|
||||
db.commit()
|
||||
_emit({
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
},
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
ACTIONS = {
|
||||
"create-user": _cmd_create_user,
|
||||
"list": _cmd_list,
|
||||
"set-role": _cmd_set_role,
|
||||
"reset-password": _cmd_reset_password,
|
||||
"bind-oidc": _cmd_bind_oidc,
|
||||
}
|
||||
|
||||
|
||||
def dispatch(argv: list[str]) -> int:
|
||||
if not argv:
|
||||
sys.stderr.write("admin: 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"admin: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n")
|
||||
return 1
|
||||
return fn(rest)
|
||||
108
app/cli/config.py
Normal file
108
app/cli/config.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user