Compare commits

...

1 Commits

Author SHA1 Message Date
zhi
c1288b5fa9 feat: wizard config volume integration
- entrypoint.sh: wait for config file before starting uvicorn
- config.py: resolve DB URL from wizard config volume
- init_wizard.py: read config from file instead of HTTP
- Dockerfile: use entrypoint.sh
2026-03-06 13:46:38 +00:00
4 changed files with 95 additions and 41 deletions

View File

@@ -15,9 +15,10 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
RUN chmod +x entrypoint.sh
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Wait for wizard config, then start uvicorn
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -1,3 +1,5 @@
import os
import json
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
@@ -5,6 +7,30 @@ from pydantic_settings import BaseSettings
from typing import Optional
def _resolve_db_url(env_url: str) -> str:
"""Read DB config from wizard config volume if available, else use env."""
config_dir = os.getenv("CONFIG_DIR", "/config")
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
config_path = os.path.join(config_dir, config_file)
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
cfg = json.load(f)
db_cfg = cfg.get("database")
if db_cfg:
host = db_cfg.get("host", "mysql")
port = db_cfg.get("port", 3306)
user = db_cfg.get("user", "harborforge")
password = db_cfg.get("password", "harborforge_pass")
database = db_cfg.get("database", "harborforge")
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
except Exception:
pass
return env_url
class Settings(BaseSettings):
DATABASE_URL: str = "mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge"
SECRET_KEY: str = "change-me-in-production"
@@ -18,7 +44,9 @@ class Settings(BaseSettings):
settings = Settings()
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
# Resolve DB URL: wizard config volume > env > default
_db_url = _resolve_db_url(settings.DATABASE_URL)
engine = create_engine(_db_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View File

@@ -1,13 +1,12 @@
"""
HarborForge initialization via AbstractWizard.
HarborForge initialization from AbstractWizard config volume.
On startup, reads config from AbstractWizard and creates:
- Admin user (if not exists)
- Default project (if configured)
Reads config from shared volume (written by AbstractWizard).
On startup, creates admin user and default project if not exists.
"""
import os
import json
import logging
import httpx
from sqlalchemy.orm import Session
from app.models import models
@@ -15,40 +14,45 @@ from app.api.deps import get_password_hash
logger = logging.getLogger("harborforge.init")
WIZARD_URL = os.getenv("WIZARD_URL", "http://wizard:8080")
WIZARD_CONFIG = os.getenv("WIZARD_CONFIG", "harborforge.json")
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
def fetch_wizard_config() -> dict | None:
"""Fetch initialization config from AbstractWizard."""
url = f"{WIZARD_URL}/api/v1/config/{WIZARD_CONFIG}"
def load_config() -> dict | None:
"""Load initialization config from shared volume."""
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
if not os.path.exists(config_path):
logger.info("No config file at %s, skipping initialization", config_path)
return None
try:
resp = httpx.get(url, timeout=10)
if resp.status_code == 200:
data = resp.json()
# AbstractWizard wraps in {"data": ...}
return data.get("data", data)
elif resp.status_code == 404:
logger.info("No wizard config found at %s, skipping initialization", url)
return None
else:
logger.warning("Wizard returned %d: %s", resp.status_code, resp.text)
return None
except httpx.ConnectError:
logger.info("AbstractWizard not available at %s, skipping", WIZARD_URL)
return None
with open(config_path, "r") as f:
return json.load(f)
except Exception as e:
logger.warning("Failed to fetch wizard config: %s", e)
logger.warning("Failed to read config %s: %s", config_path, e)
return None
def init_admin_user(db: Session, admin_cfg: dict) -> None:
def get_db_url(config: dict) -> str | None:
"""Build DATABASE_URL from wizard config, or fall back to env."""
db_cfg = config.get("database")
if not db_cfg:
return os.getenv("DATABASE_URL")
host = db_cfg.get("host", "mysql")
port = db_cfg.get("port", 3306)
user = db_cfg.get("user", "harborforge")
password = db_cfg.get("password", "harborforge_pass")
database = db_cfg.get("database", "harborforge")
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
"""Create admin user if not exists."""
username = admin_cfg.get("username", "admin")
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
logger.info("Admin user '%s' already exists (id=%d), skipping", username, existing.id)
return
return existing
password = admin_cfg.get("password", "changeme")
user = models.User(
@@ -63,11 +67,14 @@ def init_admin_user(db: Session, admin_cfg: dict) -> None:
db.commit()
db.refresh(user)
logger.info("Created admin user '%s' (id=%d)", username, user.id)
return user
def init_default_project(db: Session, project_cfg: dict, admin_user_id: int) -> None:
def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None:
"""Create default project if configured and not exists."""
name = project_cfg.get("name", "Default")
name = project_cfg.get("name")
if not name:
return
existing = db.query(models.Project).filter(models.Project.name == name).first()
if existing:
logger.info("Project '%s' already exists (id=%d), skipping", name, existing.id)
@@ -76,7 +83,7 @@ def init_default_project(db: Session, project_cfg: dict, admin_user_id: int) ->
project = models.Project(
name=name,
description=project_cfg.get("description", ""),
owner_id=admin_user_id,
owner_id=owner_id,
)
db.add(project)
db.commit()
@@ -85,23 +92,22 @@ def init_default_project(db: Session, project_cfg: dict, admin_user_id: int) ->
def run_init(db: Session) -> None:
"""Main initialization entry point."""
config = fetch_wizard_config()
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
if not config:
return
logger.info("Running HarborForge initialization from AbstractWizard")
logger.info("Running HarborForge initialization from wizard config")
# Admin user
admin_cfg = config.get("admin")
admin_user = None
if admin_cfg:
init_admin_user(db, admin_cfg)
admin_user = init_admin_user(db, admin_cfg)
# Default project
project_cfg = config.get("default_project")
if project_cfg:
admin = db.query(models.User).filter(models.User.is_admin == True).first()
if admin:
init_default_project(db, project_cfg, admin.id)
if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id)
logger.info("Initialization complete")

19
entrypoint.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
# Wait for wizard config before starting uvicorn
CONFIG_DIR="${CONFIG_DIR:-/config}"
CONFIG_FILE="${CONFIG_FILE:-harborforge.json}"
CONFIG_PATH="$CONFIG_DIR/$CONFIG_FILE"
echo "HarborForge Backend - waiting for config..."
echo " Config path: $CONFIG_PATH"
while true; do
if [ -f "$CONFIG_PATH" ]; then
echo " Config found! Starting backend..."
break
fi
echo " Config not ready, waiting 5s... (run setup wizard via SSH tunnel)"
sleep 5
done
exec uvicorn app.main:app --host 0.0.0.0 --port 8000