diff --git a/.env.example b/.env.example index 80f39c7..ab5fbe7 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -# Local development env file. Use .env.prod for production. +# Local development env file. install.sh writes ENVIRONMENT=production for +# self-hosted installs - keep development here only for the checkout workflow. # Domain Configuration DOMAIN=localhost @@ -28,9 +29,12 @@ AUTO_MIGRATE=true MIGRATION_LOCK_TIMEOUT=2m SEED_DATA_ON_START=false -# Redis Configuration +# Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility) REDIS_PASSWORD=your_secure_redis_password REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0 +# DRAGONFLY_VERSION=latest +# DRAGONFLY_THREADS=1 +# DRAGONFLY_MAXMEMORY=256mb # Application Configuration # In production this must be a strong value with at least 32 characters. @@ -40,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret # Optional rotation list (comma-separated). When set, this takes precedence. # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret CORS_ORIGINS=http://localhost:3000,http://localhost:8080 -VITE_API_URL=http://localhost:8082 -VITE_AUTH_URL=http://localhost:8082/api/auth +# Optional build-time API URL overrides. Leave empty for the default +# same-origin setup - the frontend nginx proxies /api to the backend, which +# works from any host or IP without rebuilding the image. +# VITE_API_URL=https://api.example.com +# VITE_AUTH_URL=https://api.example.com/api/auth AUTH_PORT=3001 # In production this must be true. COOKIE_SECURE=false @@ -54,6 +61,8 @@ BETTER_AUTH_AUTO_MIGRATE=true BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 +# Extra trusted origins (comma-separated). Same-origin traffic is trusted +# automatically, so this only needs entries for cross-origin callers. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082 # Optional explicit auth DB settings (recommended when password contains URL special chars) DB_HOST=postgres @@ -64,7 +73,9 @@ DB_PASSWORD=your_secure_postgres_password FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:8082 -# OAuth (user auth) +# OAuth (user auth) - GitHub/Google sign-in is reserved for the managed +# offering. Self-hosted installs use email/password only; PLACEHOLDER_* +# values are treated as unset and the buttons stay hidden. GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID @@ -109,10 +120,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/ # Development convenience: true in local dev, false in production TRAEFIK_API_INSECURE=true -# Optional: Cloudflare Tunnel (alternative to domain) +# Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app +# under Settings > Platform (in-app value overrides this). # Get token from: https://dash.cloudflare.com/argotunnel CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here +# Optional: reopen public registration after the owner account exists. +# Manageable in-app under Settings > Platform (in-app value overrides this). +# SIGNUP_ENABLED=false + +# Docker socket gid AS SEEN INSIDE containers - lets the backend manage +# containers (deployments, cloudflared sidecar). install.sh detects it with a +# throwaway container. Modern Docker maps the socket to root: use 0. +# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock +DOCKER_GID=0 + # Optional: Custom Docker Registry # DOCKER_REGISTRY=your-registry.com # DOCKER_USERNAME=your_username diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7ba35..f89d31b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,7 +109,7 @@ jobs: BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN - docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik + docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik - name: Wait for backend health run: | diff --git a/.gitignore b/.gitignore index beabec8..8548445 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,5 @@ bin # Local reference checkouts (not project code) ref/ +.playwright-mcp/ +canvas-*.png diff --git a/README.md b/README.md index 40cb476..5c0215f 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,15 @@ click away. ## Features -- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections -- **Deployments & builds** — Docker deploys with history, logs, rollback, live build status +- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions +- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references +- **Deployments & builds** — Docker deploys with history, logs, image rollback, live build status +- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page - **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App - **Managed databases** — one-click provisioning with backup/restore - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys -- **Auth & security** — Better Auth sessions (email + OAuth), audit logs, vuln scans +- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel ## Quick Start @@ -60,6 +62,9 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh | Installs into `./containr`, generates secrets, pulls the published `:latest` images, and starts the stack — UI at `http://localhost:3000`, API at `http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`. +On first visit you create the owner account (email/password); registration +then closes automatically. The owner can reopen it and configure a Cloudflare +Tunnel token under **Settings → Platform** — in-app values override env vars. Override with env vars: @@ -80,7 +85,7 @@ see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`. ``` app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow -app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar +app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar infra/ Self-hosted compose: Traefik + Cloudflare Tunnel docs/ Guides, OpenAPI spec, design documents ``` diff --git a/app/backend/README_BACKEND.md b/app/backend/README_BACKEND.md index e889063..e9e16a4 100644 --- a/app/backend/README_BACKEND.md +++ b/app/backend/README_BACKEND.md @@ -5,7 +5,7 @@ ### Prerequisites - Go 1.21+ - PostgreSQL 12+ -- Redis (optional) +- Dragonfly (Redis-compatible cache) ### Environment Variables diff --git a/app/backend/auth/src/auth.js b/app/backend/auth/src/auth.js index ae12029..467a831 100644 --- a/app/backend/auth/src/auth.js +++ b/app/backend/auth/src/auth.js @@ -46,10 +46,15 @@ const database = new PostgresDialect({ pool, }); -const githubClientId = env.GITHUB_CLIENT_ID?.trim() || ''; -const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || ''; -const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || ''; -const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || ''; +const realCredential = (value) => { + const trimmed = (value || '').trim(); + return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : ''; +}; + +const githubClientId = realCredential(env.GITHUB_CLIENT_ID); +const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET); +const googleClientId = realCredential(env.GOOGLE_CLIENT_ID); +const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET); const socialProviders = {}; if (githubClientId && githubClientSecret) { @@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) { }; } -const trustedOrigins = [ +const staticTrustedOrigins = [ ...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS), env.FRONTEND_URL, env.BACKEND_URL, @@ -86,12 +91,33 @@ const trustedOrigins = [ .map((origin) => (origin || '').trim()) .filter(Boolean); +// Self-hosted installs are reached over arbitrary hosts/IPs, so the static +// list above cannot cover them. Trust an Origin only when it matches the +// request's own Host header - that is genuine same-origin traffic (browsers +// cannot forge either header cross-origin). Do NOT consult x-forwarded-host +// here: clients can set it freely. +const resolveTrustedOrigins = (request) => { + const origins = new Set(staticTrustedOrigins); + const host = request?.headers?.get('host'); + const origin = request?.headers?.get('origin'); + if (host && origin) { + try { + if (new URL(origin).host === host) { + origins.add(origin); + } + } catch { + // malformed Origin header - leave untrusted + } + } + return [...origins]; +}; + export const auth = betterAuth({ appName: env.BETTER_AUTH_APP_NAME || 'Containr', baseURL: env.BETTER_AUTH_URL || defaultBackendURL, basePath: '/api/auth', secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN', - trustedOrigins, + trustedOrigins: resolveTrustedOrigins, database, user: { modelName: 'auth_users', diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index 97e0cdb..7c10fcb 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -89,6 +89,9 @@ func main() { // Setup Gin router if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) + if !cfg.CookieSecure { + log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.") + } } router := gin.New() diff --git a/app/backend/docker-compose.yml b/app/backend/docker-compose.yml index d7c02ce..22b96c6 100644 --- a/app/backend/docker-compose.yml +++ b/app/backend/docker-compose.yml @@ -14,15 +14,15 @@ services: retries: 10 restart: unless-stopped - redis: - image: redis:7-alpine + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly:latest env_file: - ../../.env.prod - command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] + command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"] ports: - "6379:6379" volumes: - - backend_redis_data:/data + - backend_dragonfly_data:/data healthcheck: test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] interval: 10s @@ -38,10 +38,14 @@ services: - ../../.env.prod ports: - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + group_add: + - "${DOCKER_GID:-0}" depends_on: postgres: condition: service_healthy - redis: + dragonfly: condition: service_healthy restart: unless-stopped healthcheck: @@ -52,4 +56,4 @@ services: volumes: backend_postgres_data: - backend_redis_data: + backend_dragonfly_data: diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 8ee42d2..eba13b5 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -46,6 +46,7 @@ type User struct { Email string `json:"email"` Name string `json:"name"` AvatarURL string `json:"avatar_url,omitempty"` + IsAdmin bool `json:"is_admin"` CreatedAt string `json:"created_at"` } @@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) { var user User var hashedPassword string err := db.QueryRow(` - SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at - FROM users + SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at + FROM users WHERE email = $1 - `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt) + `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err == sql.ErrNoRows { authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) @@ -120,13 +121,32 @@ func handleAuthBootstrap(c *gin.Context) { mode = "register" } + providers := []string{} + for _, provider := range []string{"GITHUB", "GOOGLE"} { + if oauthProviderConfigured(provider) { + providers = append(providers, strings.ToLower(provider)) + } + } + c.JSON(http.StatusOK, gin.H{ - "has_users": count > 0, - "user_count": count, - "mode": mode, + "has_users": count > 0, + "user_count": count, + "mode": mode, + "providers": providers, + "signup_enabled": signupEnabled(db), }) } +// oauthProviderConfigured reports whether an OAuth provider has real +// credentials. Placeholder values from .env.example do not count, so +// self-hosted installs default to email/password only. +func oauthProviderConfigured(provider string) bool { + id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID")) + secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET")) + return id != "" && secret != "" && + !strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_") +} + type betterAuthLoginUser struct { ID string `json:"id"` Email string `json:"email"` @@ -236,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser return User{}, err } + // A mirrored first account owns the platform, same as handleRegister. + var total int + if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil { + return User{}, err + } + var user User err = db.QueryRow(` - INSERT INTO users (email, password_hash, name, avatar_url) - VALUES ($1, $2, $3, NULLIF($4, '')) + INSERT INTO users (email, password_hash, name, avatar_url, is_admin) + VALUES ($1, $2, $3, NULLIF($4, ''), $5) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), updated_at = NOW() - RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)). - Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0). + Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err != nil { return User{}, err } @@ -264,24 +290,26 @@ func handleRegister(c *gin.Context) { db := c.MustGet("db").(*database.DB) jwtSecret := c.MustGet("jwt_secret").(string) - count, err := countLocalUsers(db) + total, err := countLocalUsers(db) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } - if count > 0 { + // Closed once the first account exists unless the owner reopens registration. + if total > 0 && !signupEnabled(db) { c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) return } - err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count) + var existing int + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } - if count > 0 { + if existing > 0 { c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) return } @@ -293,13 +321,13 @@ func handleRegister(c *gin.Context) { return } - // Create user + // First account owns the platform. var user User err = db.QueryRow(` - INSERT INTO users (email, password_hash, name) - VALUES ($1, $2, $3) - RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + INSERT INTO users (email, password_hash, name, is_admin) + VALUES ($1, $2, $3, $4) + RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) @@ -320,13 +348,16 @@ func handleRegister(c *gin.Context) { } func handleCreateUser(c *gin.Context) { + db, ok := requireAdmin(c) + if !ok { + return + } + var req ManualUserCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - - db := c.MustGet("db").(*database.DB) req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Name = strings.TrimSpace(req.Name) if req.Email == "" || req.Name == "" { @@ -363,8 +394,8 @@ func handleCreateUser(c *gin.Context) { err = db.QueryRow(` INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) - RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) return @@ -459,10 +490,10 @@ func handleGetProfile(c *gin.Context) { var user User err := db.QueryRow(` - SELECT id, email, name, COALESCE(avatar_url, ''), created_at - FROM users + SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + FROM users WHERE id = $1 - `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) diff --git a/app/backend/internal/api/auth_proxy.go b/app/backend/internal/api/auth_proxy.go index ddced07..43da378 100644 --- a/app/backend/internal/api/auth_proxy.go +++ b/app/backend/internal/api/auth_proxy.go @@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d return true } - return count > 0 + // Registration is closed once the first account exists unless the owner + // reopens it in Settings (signup_enabled). + return count > 0 && !signupEnabled(db) } func parseAuthProxyTarget(rawTarget string) (*url.URL, error) { diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index dc3bf5b..d63a372 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "net/http" + "strconv" "strings" "time" @@ -120,6 +121,72 @@ func handleGetDeployments(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deployments": deployments}) } +// RecentDeployment is a deployment row joined with its service and project names. +type RecentDeployment struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + ServiceName string `json:"service_name"` + ProjectName string `json:"project_name"` + Status string `json:"status"` + ImageName string `json:"image_name"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` +} + +// handleGetRecentDeployments lists the latest deployments across all of the +// caller's services — feeds the dashboard deploy feed. +func handleGetRecentDeployments(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + limit := 10 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { + limit = n + } + } + + rows, err := db.(*database.DB).Query( + `SELECT d.id, d.service_id, s.name, p.name, d.status, + COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at + FROM deployments d + JOIN services s ON s.id = d.service_id + JOIN projects p ON p.id = s.project_id + WHERE p.owner_id = $1 + ORDER BY d.created_at DESC + LIMIT $2`, + userID.(string), limit, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) + return + } + defer rows.Close() + + deployments := []RecentDeployment{} + for rows.Next() { + var d RecentDeployment + if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName, + &d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"}) + return + } + deployments = append(deployments, d) + } + + c.JSON(http.StatusOK, gin.H{"deployments": deployments}) +} + func handleCreateDeployment(c *gin.Context) { db, exists := c.Get("db") if !exists { @@ -153,8 +220,11 @@ func handleCreateDeployment(c *gin.Context) { var service Service var projectOwner string err = db.(*database.DB).QueryRow( - `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, - s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, + s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), s.created_at, s.updated_at, p.owner_id FROM services s JOIN projects p ON s.project_id = p.id @@ -164,7 +234,8 @@ func handleCreateDeployment(c *gin.Context) { &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, - &service.CreatedAt, &service.UpdatedAt, &projectOwner, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner, ) if err != nil { @@ -256,6 +327,22 @@ func runDeploymentAndSync( service Service, req CreateDeploymentRequest, userID string, +) { + runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "") +} + +// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image +// (rollback / redeploy of an existing tag) when set; otherwise builds from +// source or pulls the service image. +func runDeploymentAndSyncWithImage( + parentCtx context.Context, + db *database.DB, + engine *deployment.DeploymentEngine, + dbDeployment *DeploymentModel, + service Service, + req CreateDeploymentRequest, + userID string, + imageOverride string, ) { ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute) defer cancel() @@ -265,21 +352,44 @@ func runDeploymentAndSync( sourcePath = "." } + // Resolve the runtime env: stored service variables (with ${{svc.KEY}} + // references expanded) plus any per-deployment overrides. + env, envErr := resolveServiceEnv(db, service) + if envErr != nil { + env = map[string]string{} + } + for k, v := range req.EnvVars { + env[k] = v + } + + var command []string + if cmd := strings.TrimSpace(service.Command); cmd != "" { + command = strings.Fields(cmd) + } + + replicas := service.Replicas + if replicas < 1 { + replicas = 1 + } + deployReq := &deployment.DeploymentRequest{ ProjectID: service.ProjectID.String(), ServiceID: service.ID.String(), Environment: service.Environment, Config: deployment.ServiceConfig{ - Name: service.Name, - Image: service.Image, - Environment: req.EnvVars, - Replicas: 1, - }, - BuildConfig: &deployment.BuildConfig{ - BuildType: "nixpacks", - SourcePath: sourcePath, - Branch: req.Branch, - Commit: req.CommitHash, + Name: service.Name, + Image: service.Image, + Command: command, + Environment: env, + Replicas: replicas, + PublicPort: int32(service.Port), + Domain: service.Domain, + HealthPath: service.HealthCheckPath, + RestartPolicy: service.RestartPolicy, + Resources: deployment.ResourceLimits{ + MemoryBytes: parseMemoryLimit(service.Memory), + CPUQuota: parseCPULimit(service.CPU), + }, }, Trigger: deployment.TriggerConfig{ Type: req.Trigger, @@ -289,6 +399,26 @@ func runDeploymentAndSync( }, } + if imageOverride != "" { + deployReq.BuildConfig = &deployment.BuildConfig{ + BuildType: "prebuilt", + PrebuiltImage: imageOverride, + } + } else if service.GitRepo == "" { + // Image-sourced service: pull the image instead of building. + deployReq.BuildConfig = &deployment.BuildConfig{ + BuildType: "prebuilt", + PrebuiltImage: service.Image, + } + } else { + deployReq.BuildConfig = &deployment.BuildConfig{ + BuildType: "nixpacks", + SourcePath: sourcePath, + Branch: req.Branch, + Commit: req.CommitHash, + } + } + engineDeployment, err := engine.Deploy(ctx, deployReq) if err != nil { failedAt := time.Now() @@ -550,17 +680,58 @@ func handleRollbackDeployment(c *gin.Context) { time.Now(), serviceID, ) - go func() { - time.Sleep(2 * time.Second) - db.(*database.DB).Exec( - `UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`, - time.Now(), rollbackID, + // Real rollback: redeploy the target deployment's image. + engineValue, _ := c.Get("deployment_engine") + engine, _ := engineValue.(*deployment.DeploymentEngine) + targetImage := targetDeployment.ImageName + if targetDeployment.ImageTag != "" { + targetImage += ":" + targetDeployment.ImageTag + } + + if engine == nil || targetImage == "" { + completedAt := time.Now() + reason := "Rollback image unavailable" + if engine == nil { + reason = "Deployment engine unavailable. Docker may not be configured on this server." + } + _, _ = db.(*database.DB).Exec( + `UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, + reason, completedAt, rollbackID, ) - db.(*database.DB).Exec( - `UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, - time.Now(), serviceID, + _, _ = db.(*database.DB).Exec( + `UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, + completedAt, serviceID, ) - }() + } else { + var service Service + _ = db.(*database.DB).QueryRow( + `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, + s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), + s.created_at, s.updated_at + FROM services s WHERE s.id = $1`, serviceID, + ).Scan( + &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, + &service.Image, &service.Command, &service.Environment, &service.GitRepo, + &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, + ) + + rollbackReq := CreateDeploymentRequest{ + CommitHash: func() string { + if targetDeployment.CommitHash != nil { + return *targetDeployment.CommitHash + } + return "" + }(), + Trigger: "rollback", + } + // Reuse the stored image directly — no rebuild. + go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage) + } c.JSON(http.StatusCreated, gin.H{ "deployment": DeploymentResponse{ diff --git a/app/backend/internal/api/logs.go b/app/backend/internal/api/logs.go index c33cf95..6b2d587 100644 --- a/app/backend/internal/api/logs.go +++ b/app/backend/internal/api/logs.go @@ -8,9 +8,11 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "time" + "github.com/docker/docker/api/types/filters" "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -73,7 +75,21 @@ func handleGetLogs(c *gin.Context) { respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") return } - containerName := fmt.Sprintf("containr-%s", serviceID) + // Replicas are named containr--; resolve them by label and + // merge logs across all of them. + ctx := context.Background() + containers, err := client.ListContainersFiltered(ctx, filters.NewArgs( + filters.Arg("label", "containr.service="+serviceID.String()), + ), false) + if err != nil { + respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") + return + } + if len(containers) == 0 { + respondEmptyServiceLogs(c, "No container logs are available for this service yet.") + return + } + sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] }) logOpts := docker.LogOptions{ Stdout: true, @@ -83,17 +99,25 @@ func handleGetLogs(c *gin.Context) { Timestamps: true, } - ctx := context.Background() - logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts) - if err != nil { - if isDockerNotFoundError(err) { - respondEmptyServiceLogs(c, "No container logs are available for this service yet.") - return + var readers []io.ReadCloser + var streams []io.Reader + for _, ctr := range containers { + r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts) + if rerr == nil { + readers = append(readers, r) + streams = append(streams, r) } - respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") + } + if len(readers) == 0 { + respondEmptyServiceLogs(c, "No container logs are available for this service yet.") return } - defer logsReader.Close() + logsReader := io.MultiReader(streams...) + defer func() { + for _, r := range readers { + _ = r.Close() + } + }() if follow { c.Header("Content-Type", "text/event-stream") diff --git a/app/backend/internal/api/production_behavior_test.go b/app/backend/internal/api/production_behavior_test.go index 8dd31e0..b05accf 100644 --- a/app/backend/internal/api/production_behavior_test.go +++ b/app/backend/internal/api/production_behavior_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "testing" + "containr/internal/database" + "github.com/gin-gonic/gin" ) @@ -86,3 +88,30 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) } } + +func TestRecentDeploymentsRequireAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/api/v1/deployments", handleGetRecentDeployments) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // No db in context → dependency failure surfaces before auth. + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code) + } + + // With db present but no user context → 401. + router2 := gin.New() + router2.GET("/api/v1/deployments", func(c *gin.Context) { + c.Set("db", &database.DB{}) + handleGetRecentDeployments(c) + }) + rec2 := httptest.NewRecorder() + router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code) + } +} diff --git a/app/backend/internal/api/projects.go b/app/backend/internal/api/projects.go index 3d98b65..6665f09 100644 --- a/app/backend/internal/api/projects.go +++ b/app/backend/internal/api/projects.go @@ -3,6 +3,8 @@ package api import ( "containr/internal/database" "containr/internal/database/sqlcdb" + "containr/internal/deployment" + "context" "database/sql" "errors" "net/http" @@ -310,6 +312,16 @@ func handleDeleteProject(c *gin.Context) { return } + // Remove live containers and the project network before dropping rows. + // Best-effort: Docker may be unavailable; DB cleanup proceeds regardless. + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + engine := engineValue.(*deployment.DeploymentEngine) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + _ = engine.RemoveProjectContainers(ctx, projectID.String()) + _ = engine.RemoveProjectNetwork(ctx, projectID.String()) + cancel() + } + deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"}) diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 0fdc7f4..3cbb142 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg dockerClient = client buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient) deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient) + // Restore managed sidecars (e.g. cloudflared) from saved settings. + syncManagedContainers(dockerClient, db) } // Initialize build handler @@ -184,6 +186,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.PUT("/user/profile", handleUpdateProfile) protected.POST("/users", handleCreateUser) + // Platform settings (admin-gated inside handlers) + protected.GET("/settings", handleGetSettings) + protected.PUT("/settings", handleUpdateSettings) + // Project routes protected.GET("/projects", handleGetProjects) protected.POST("/projects", handleCreateProject) @@ -203,9 +209,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.DELETE("/services/:id", handleDeleteService) protected.GET("/services/:id/metrics", handleGetServiceMetrics) + // Runtime lifecycle (Railway-style) + protected.GET("/services/:id/runtime", handleGetServiceRuntime) + protected.POST("/services/:id/start", handleServiceStart) + protected.POST("/services/:id/stop", handleServiceStop) + protected.POST("/services/:id/restart", handleServiceRestart) + protected.POST("/services/:id/redeploy", handleServiceRedeploy) + // Deployment routes protected.GET("/services/:id/deployments", handleGetDeployments) protected.POST("/services/:id/deployments", handleCreateDeployment) + protected.GET("/deployments", handleGetRecentDeployments) protected.GET("/deployments/:id", handleGetDeployment) protected.POST("/deployments/:id/rollback", handleRollbackDeployment) diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go new file mode 100644 index 0000000..bdb8300 --- /dev/null +++ b/app/backend/internal/api/runtime.go @@ -0,0 +1,440 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "containr/internal/database" + "containr/internal/deployment" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// serviceRuntimeSpec builds the container spec for a service: stored env vars +// merged with ${{Service.KEY}} references resolved across the project. +func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) { + env, err := resolveServiceEnv(db, service) + if err != nil { + return deployment.RuntimeSpec{}, err + } + + spec := deployment.RuntimeSpec{ + ProjectID: service.ProjectID.String(), + ServiceID: service.ID.String(), + Name: service.Name, + Image: service.Image, + Env: env, + Replicas: service.Replicas, + Port: int32(service.Port), + Domain: service.Domain, + HealthPath: service.HealthCheckPath, + RestartPolicy: service.RestartPolicy, + } + if cmd := strings.TrimSpace(service.Command); cmd != "" { + spec.Command = strings.Fields(cmd) + } + if spec.Replicas < 1 { + spec.Replicas = 1 + } + if spec.RestartPolicy == "" { + spec.RestartPolicy = "unless-stopped" + } + spec.NanoCPUs = parseCPULimit(service.CPU) + spec.MemoryBytes = parseMemoryLimit(service.Memory) + if spec.Port > 0 { + env["PORT"] = strconv.Itoa(service.Port) + } + return spec, nil +} + +var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`) + +// resolveServiceEnv loads the service's variables (real values, including +// secrets) and expands ${{service.KEY}} references. KEY may be a variable on +// the referenced service, or the builtins HOST (service name alias) and PORT. +func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) { + rows, err := db.Query( + `SELECT key, value FROM environment_variables WHERE service_id = $1`, + service.ID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + env := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + env[k] = v + } + + var refServices []struct{ ID, Name string } + svcRows, err := db.Query( + `SELECT id, name FROM services WHERE project_id = $1`, + service.ProjectID, + ) + if err != nil { + return nil, err + } + defer svcRows.Close() + for svcRows.Next() { + var s struct{ ID, Name string } + if err := svcRows.Scan(&s.ID, &s.Name); err != nil { + return nil, err + } + refServices = append(refServices, s) + } + + byName := map[string]string{} + for _, s := range refServices { + byName[strings.ToLower(s.Name)] = s.ID + } + + resolve := func(serviceName, key string) (string, bool) { + id, ok := byName[strings.ToLower(serviceName)] + if !ok { + return "", false + } + switch strings.ToUpper(key) { + case "HOST": + return serviceName, true + case "PORT": + var port int + if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 { + return strconv.Itoa(port), true + } + return "", false + } + var value string + err := db.QueryRow( + `SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`, + id, key, + ).Scan(&value) + if err != nil { + return "", false + } + return value, true + } + + for k, v := range env { + env[k] = refVarPattern.ReplaceAllStringFunc(v, func(match string) string { + parts := refVarPattern.FindStringSubmatch(match) + if len(parts) != 3 { + return match + } + if resolved, ok := resolve(parts[1], parts[2]); ok { + return resolved + } + return match + }) + } + return env, nil +} + +func parseCPULimit(cpu string) int64 { + v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64) + if err != nil || v <= 0 { + return 0 + } + return int64(v * 1e9) +} + +var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30} + +func parseMemoryLimit(mem string) int64 { + s := strings.ToLower(strings.TrimSpace(mem)) + for suffix, mult := range memUnits { + if strings.HasSuffix(s, suffix) { + if v, err := strconv.ParseFloat(strings.TrimSuffix(s, suffix), 64); err == nil { + return int64(v * float64(mult)) + } + } + } + if v, err := strconv.ParseInt(s, 10, 64); err == nil { + return v + } + return 0 +} + +func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) { + dbValue, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return nil, nil, false + } + engineValue, exists := c.Get("deployment_engine") + if !exists || engineValue == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Deployment engine unavailable. Docker may not be configured on this server."}) + return nil, nil, false + } + return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true +} + +func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) { + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"}) + return Service{}, false + } + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return Service{}, false + } + + var service Service + var owner string + err = db.QueryRow( + `SELECT s.id, s.project_id, s.name, + COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''), + COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''), + COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''), + COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''), + COALESCE(s.cpu, ''), COALESCE(s.memory, ''), + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), + s.created_at, s.updated_at, p.owner_id + FROM services s JOIN projects p ON s.project_id = p.id + WHERE s.id = $1`, + serviceID, + ).Scan( + &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, + &service.Image, &service.Command, &service.Environment, &service.GitRepo, + &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner, + ) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return Service{}, false + } + if owner != userID.(string) && !isAdminUser(db, userID.(string)) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return Service{}, false + } + return service, true +} + +// handleServiceRuntime returns live container state for a service. +func handleGetServiceRuntime(c *gin.Context) { + engine, db, ok := getRuntimeEngine(c) + if !ok { + return + } + service, ok := loadOwnedService(c, db) + if !ok { + return + } + state, err := engine.RuntimeState(c.Request.Context(), service.ID.String(), service.Replicas) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"runtime": state}) +} + +func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *database.DB, service Service) bool { + spec, err := serviceRuntimeSpec(db, service) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + state, err := engine.ReconcileService(ctx, spec) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return false + } + status := "stopped" + if state != nil { + status = state.Status + } + _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) + c.JSON(http.StatusOK, gin.H{"runtime": state}) + return true +} + +// handleServiceStart starts all stopped replicas. +func handleServiceStart(c *gin.Context) { + engine, db, ok := getRuntimeEngine(c) + if !ok { + return + } + service, ok := loadOwnedService(c, db) + if !ok { + return + } + if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + _, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) + c.JSON(http.StatusOK, gin.H{"status": "running"}) +} + +// handleServiceStop stops all replicas without removing them. +func handleServiceStop(c *gin.Context) { + engine, db, ok := getRuntimeEngine(c) + if !ok { + return + } + service, ok := loadOwnedService(c, db) + if !ok { + return + } + if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + _, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) + c.JSON(http.StatusOK, gin.H{"status": "stopped"}) +} + +// handleServiceRestart restarts all replicas. +func handleServiceRestart(c *gin.Context) { + engine, db, ok := getRuntimeEngine(c) + if !ok { + return + } + service, ok := loadOwnedService(c, db) + if !ok { + return + } + ctx := c.Request.Context() + if err := engine.StopService(ctx, service.ID.String()); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if err := engine.StartService(ctx, service.ID.String()); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + _, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) + c.JSON(http.StatusOK, gin.H{"status": "running"}) +} + +// handleServiceRedeploy re-applies the current spec: pulls the image for +// image-sourced services and reconciles containers (env, ports, replicas). +func handleServiceRedeploy(c *gin.Context) { + engine, db, ok := getRuntimeEngine(c) + if !ok { + return + } + service, ok := loadOwnedService(c, db) + if !ok { + return + } + + image := service.Image + if image == "" { + // Git-sourced service: reuse the last successful deployment image. + err := db.QueryRow( + `SELECT image_name || ':' || image_tag FROM deployments + WHERE service_id = $1 AND status = 'deployed' AND image_name <> '' + ORDER BY created_at DESC LIMIT 1`, + service.ID, + ).Scan(&image) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"}) + return + } + } + service.Image = image + reconcileNow(c, engine, db, service) +} + +// liveServiceStatus reconciles the stored status with real container state. +// No-op when Docker is unavailable — the stored status is returned as-is. +func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { + engineValue, exists := c.Get("deployment_engine") + if !exists || engineValue == nil { + return + } + engine := engineValue.(*deployment.DeploymentEngine) + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas) + if err != nil || state == nil || len(state.Containers) == 0 { + return + } + if state.Status != service.Status { + service.Status = state.Status + _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) + } + if service.Domain != "" { + service.PublicURL = "https://" + service.Domain + } else if len(state.URLs) > 0 { + service.PublicURL = state.URLs[0] + } +} + +// runtimeScaleService adjusts replica count both in bookkeeping and reality. +func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error { + dbValue, _ := c.Get("db") + engineValue, _ := c.Get("deployment_engine") + db, _ := dbValue.(*database.DB) + engine, _ := engineValue.(*deployment.DeploymentEngine) + if db == nil || engine == nil { + return fmt.Errorf("runtime unavailable") + } + + var service Service + var owner string + err := db.QueryRow( + `SELECT s.id, s.project_id, s.name, + COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''), + COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''), + COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''), + COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''), + COALESCE(s.cpu, ''), COALESCE(s.memory, ''), + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), + s.created_at, s.updated_at, p.owner_id + FROM services s JOIN projects p ON s.project_id = p.id + WHERE s.id = $1`, serviceID, + ).Scan( + &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, + &service.Image, &service.Command, &service.Environment, &service.GitRepo, + &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner, + ) + if err != nil { + return err + } + if service.Image == "" { + if err := db.QueryRow( + `SELECT image_name || ':' || image_tag FROM deployments + WHERE service_id = $1 AND status = 'deployed' AND image_name <> '' + ORDER BY created_at DESC LIMIT 1`, service.ID, + ).Scan(&service.Image); err != nil { + return fmt.Errorf("no deployed image to scale") + } + } + + spec, err := serviceRuntimeSpec(db, service) + if err != nil { + return err + } + spec.Replicas = replicas + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if _, err := engine.ReconcileService(ctx, spec); err != nil { + return err + } + _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) + return nil +} diff --git a/app/backend/internal/api/scaling.go b/app/backend/internal/api/scaling.go index 308e5b8..1968722 100644 --- a/app/backend/internal/api/scaling.go +++ b/app/backend/internal/api/scaling.go @@ -204,7 +204,16 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) { event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason)) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + // Service unknown to the autoscaler: the runtime is authoritative — + // reconcile containers directly and persist the replica count. + if runtimeErr := runtimeScaleService(c, serviceID, request.Replicas); runtimeErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "runtime_error": runtimeErr.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "Service scaled successfully", + "state": gin.H{"service_id": serviceID, "desired_replicas": request.Replicas}, + }) return } @@ -214,11 +223,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ + // Apply the replica change to live containers (best effort — the + // bookkeeping update above already succeeded). + runtimeErr := runtimeScaleService(c, serviceID, request.Replicas) + + resp := gin.H{ "message": "Service scaled successfully", "event": event, "state": state, - }) + } + if runtimeErr != nil { + resp["runtime_error"] = runtimeErr.Error() + } + c.JSON(http.StatusOK, resp) } // GetScalingStatus returns the overall status of the auto-scaler diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index e7d4d98..a5932ec 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -2,6 +2,8 @@ package api import ( "containr/internal/database" + "containr/internal/deployment" + "context" "net/http" "time" @@ -24,37 +26,54 @@ type Service struct { BuildPath string `json:"build_path" db:"build_path"` CPU string `json:"cpu" db:"cpu"` Memory string `json:"memory" db:"memory"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + // Runtime spec + Replicas int `json:"replicas" db:"replicas"` + Port int `json:"port" db:"port"` // container port to expose + Domain string `json:"domain" db:"domain"` // public hostname via Traefik + HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port + RestartPolicy string `json:"restart_policy" db:"restart_policy"` + PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // CreateServiceRequest represents a request to create a service type CreateServiceRequest struct { - ProjectID uuid.UUID `json:"project_id"` - Name string `json:"name" binding:"required,min=1,max=255"` - Type string `json:"type" binding:"required,oneof=web worker database cron"` - Image string `json:"image"` - Command string `json:"command"` - Environment string `json:"environment" binding:"required,oneof=production preview development"` - GitRepo string `json:"git_repo"` - GitBranch string `json:"git_branch"` - BuildPath string `json:"build_path"` - CPU string `json:"cpu"` - Memory string `json:"memory"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name" binding:"required,min=1,max=255"` + Type string `json:"type" binding:"required,oneof=web worker database cron"` + Image string `json:"image"` + Command string `json:"command"` + Environment string `json:"environment" binding:"required,oneof=production preview development"` + GitRepo string `json:"git_repo"` + GitBranch string `json:"git_branch"` + BuildPath string `json:"build_path"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + Replicas int `json:"replicas"` + Port int `json:"port"` + Domain string `json:"domain"` + HealthCheckPath string `json:"healthcheck_path"` + RestartPolicy string `json:"restart_policy"` } // UpdateServiceRequest represents a request to update a service type UpdateServiceRequest struct { - Name string `json:"name" binding:"omitempty,min=1,max=255"` - Type string `json:"type" binding:"omitempty,oneof=web worker database cron"` - Image string `json:"image"` - Command string `json:"command"` - Environment string `json:"environment" binding:"omitempty,oneof=production preview development"` - GitRepo string `json:"git_repo"` - GitBranch string `json:"git_branch"` - BuildPath string `json:"build_path"` - CPU string `json:"cpu"` - Memory string `json:"memory"` + Name string `json:"name" binding:"omitempty,min=1,max=255"` + Type string `json:"type" binding:"omitempty,oneof=web worker database cron"` + Image string `json:"image"` + Command string `json:"command"` + Environment string `json:"environment" binding:"omitempty,oneof=production preview development"` + GitRepo string `json:"git_repo"` + GitBranch string `json:"git_branch"` + BuildPath string `json:"build_path"` + CPU string `json:"cpu"` + Memory string `json:"memory"` + Replicas *int `json:"replicas"` + Port *int `json:"port"` + Domain *string `json:"domain"` + HealthCheckPath *string `json:"healthcheck_path"` + RestartPolicy string `json:"restart_policy"` } // handleGetServices retrieves all services for a project @@ -110,6 +129,9 @@ func handleGetServices(c *gin.Context) { COALESCE(build_path, ''), COALESCE(cpu, ''), COALESCE(memory, ''), + COALESCE(replicas, 1), COALESCE(port, 0), + COALESCE(domain, ''), COALESCE(healthcheck_path, ''), + COALESCE(restart_policy, 'unless-stopped'), created_at, updated_at FROM services WHERE project_id = $1 @@ -129,7 +151,8 @@ func handleGetServices(c *gin.Context) { &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, - &service.CreatedAt, &service.UpdatedAt, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) @@ -138,6 +161,11 @@ func handleGetServices(c *gin.Context) { services = append(services, service) } + // Reconcile stored status with live container state (no-op without Docker). + for i := range services { + liveServiceStatus(c, db.(*database.DB), &services[i]) + } + c.JSON(http.StatusOK, gin.H{"services": services}) } @@ -213,21 +241,26 @@ func handleCreateService(c *gin.Context) { // Create new service service := Service{ - ID: uuid.New(), - ProjectID: req.ProjectID, - Name: req.Name, - Type: req.Type, - Status: "stopped", // Initial status - Image: req.Image, - Command: req.Command, - Environment: req.Environment, - GitRepo: req.GitRepo, - GitBranch: req.GitBranch, - BuildPath: req.BuildPath, - CPU: req.CPU, - Memory: req.Memory, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + ProjectID: req.ProjectID, + Name: req.Name, + Type: req.Type, + Status: "stopped", // Initial status + Image: req.Image, + Command: req.Command, + Environment: req.Environment, + GitRepo: req.GitRepo, + GitBranch: req.GitBranch, + BuildPath: req.BuildPath, + CPU: req.CPU, + Memory: req.Memory, + Replicas: req.Replicas, + Port: req.Port, + Domain: req.Domain, + HealthCheckPath: req.HealthCheckPath, + RestartPolicy: req.RestartPolicy, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } // Set default values if not provided @@ -237,6 +270,12 @@ func handleCreateService(c *gin.Context) { if service.Memory == "" { service.Memory = "512Mi" } + if service.Replicas < 1 { + service.Replicas = 1 + } + if service.RestartPolicy == "" { + service.RestartPolicy = "unless-stopped" + } environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment) if err != nil { @@ -248,15 +287,17 @@ func handleCreateService(c *gin.Context) { // Insert service into database _, err = db.(*database.DB).Exec( - `INSERT INTO services + `INSERT INTO services (id, project_id, name, environment_id, service_type, source_type, source_url, image_name, build_command, start_command, type, status, image, command, environment, - git_repo, git_branch, build_path, cpu, memory, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, + git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, + healthcheck_path, restart_policy, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`, service.ID, service.ProjectID, service.Name, environmentID, service.Type, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, "", service.Command, service.Type, service.Status, service.Image, service.Command, service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, + service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy, service.CreatedAt, service.UpdatedAt, ) @@ -324,6 +365,9 @@ func handleGetService(c *gin.Context) { COALESCE(s.build_path, ''), COALESCE(s.cpu, ''), COALESCE(s.memory, ''), + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id @@ -333,7 +377,8 @@ func handleGetService(c *gin.Context) { &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, &service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, - &service.CreatedAt, &service.UpdatedAt, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, ) if err != nil { @@ -341,6 +386,7 @@ func handleGetService(c *gin.Context) { return } + liveServiceStatus(c, db.(*database.DB), &service) c.JSON(http.StatusOK, gin.H{"service": service}) } @@ -386,6 +432,9 @@ func handleUpdateService(c *gin.Context) { COALESCE(s.build_path, ''), COALESCE(s.cpu, ''), COALESCE(s.memory, ''), + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id @@ -396,6 +445,8 @@ func handleUpdateService(c *gin.Context) { &existingService.Status, &existingService.Image, &existingService.Command, &existingService.Environment, &existingService.GitRepo, &existingService.GitBranch, &existingService.BuildPath, &existingService.CPU, &existingService.Memory, + &existingService.Replicas, &existingService.Port, &existingService.Domain, + &existingService.HealthCheckPath, &existingService.RestartPolicy, &existingService.CreatedAt, &existingService.UpdatedAt, ) @@ -435,18 +486,37 @@ func handleUpdateService(c *gin.Context) { if req.Memory != "" { existingService.Memory = req.Memory } + if req.Replicas != nil { + existingService.Replicas = *req.Replicas + } + if req.Port != nil { + existingService.Port = *req.Port + } + if req.Domain != nil { + existingService.Domain = *req.Domain + } + if req.HealthCheckPath != nil { + existingService.HealthCheckPath = *req.HealthCheckPath + } + if req.RestartPolicy != "" { + existingService.RestartPolicy = req.RestartPolicy + } existingService.UpdatedAt = time.Now() // Update service in database _, err = db.(*database.DB).Exec( - `UPDATE services + `UPDATE services SET name = $1, type = $2, image = $3, command = $4, environment = $5, - git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11 - WHERE id = $12`, + git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, + replicas = $11, port = $12, domain = $13, healthcheck_path = $14, + restart_policy = $15, updated_at = $16 + WHERE id = $17`, existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.BuildPath, existingService.CPU, existingService.Memory, + existingService.Replicas, existingService.Port, existingService.Domain, + existingService.HealthCheckPath, existingService.RestartPolicy, existingService.UpdatedAt, existingService.ID, ) @@ -455,6 +525,24 @@ func handleUpdateService(c *gin.Context) { return } + // A replica change applies immediately when the service has live + // containers; other spec fields take effect on the next deploy. + if req.Replicas != nil && existingService.Status == "running" { + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + engine := engineValue.(*deployment.DeploymentEngine) + serviceCopy := existingService + go func() { + spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy) + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, _ = engine.ReconcileService(ctx, spec) + }() + } + } + c.JSON(http.StatusOK, gin.H{"service": existingService}) } @@ -496,11 +584,18 @@ func handleDeleteService(c *gin.Context) { } // Check if user owns the project - if projectOwnerID != userID.(string) { + if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } + // Remove runtime containers before dropping the row. + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + if engine, ok := engineValue.(*deployment.DeploymentEngine); ok { + _ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String()) + } + } + // Delete service (cascade will handle related records) _, err = db.(*database.DB).Exec( "DELETE FROM services WHERE id = $1", diff --git a/app/backend/internal/api/settings.go b/app/backend/internal/api/settings.go new file mode 100644 index 0000000..629b21d --- /dev/null +++ b/app/backend/internal/api/settings.go @@ -0,0 +1,290 @@ +package api + +import ( + "context" + "database/sql" + "log" + "net/http" + "os" + "strings" + "time" + + "containr/internal/database" + "containr/internal/docker" + + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/registry" + "github.com/gin-gonic/gin" +) + +// Platform setting keys. Values stored in app_settings take precedence over +// environment variables; env remains the fallback for optional overrides. +const ( + settingSignupEnabled = "signup_enabled" + settingCloudflareTunnelToken = "cloudflare_tunnel_token" + + cloudflaredContainerName = "containr-cloudflared" + cloudflaredImage = "cloudflare/cloudflared:latest" +) + +// settingValue resolves a setting: app_settings row first, env fallback, +// then the caller's default. +func settingValue(db *database.DB, key, envKey, fallback string) string { + if db != nil && db.DB != nil { + var value string + err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value) + if err == nil { + return value + } + if err != sql.ErrNoRows { + log.Printf("Failed to read setting %s: %v", key, err) + } + } + if envKey != "" { + if value := strings.TrimSpace(os.Getenv(envKey)); value != "" { + return value + } + } + return fallback +} + +func setSetting(db *database.DB, key, value string, secret bool) error { + _, err := db.Exec(` + INSERT INTO app_settings (key, value, is_secret, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW() + `, key, value, secret) + return err +} + +func deleteSetting(db *database.DB, key string) error { + _, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key) + return err +} + +// signupEnabled controls whether public registration stays open after the +// first account exists. Defaults to closed; the platform owner can reopen it +// in Settings or via SIGNUP_ENABLED=true. +func signupEnabled(db *database.DB) bool { + return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true") +} + +func isAdminUser(db *database.DB, userID string) bool { + var isAdmin bool + if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil { + return false + } + return isAdmin +} + +func requireAdmin(c *gin.Context) (*database.DB, bool) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return nil, false + } + db, _ := c.Get("db") + database, _ := db.(*database.DB) + if database == nil || database.DB == nil || !isAdminUser(database, userID) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return nil, false + } + return database, true +} + +type cloudflareTunnelStatus struct { + TokenSet bool `json:"token_set"` + Source string `json:"source"` // "app" | "env" | "none" + Container string `json:"container"` +} + +func handleGetSettings(c *gin.Context) { + db, ok := requireAdmin(c) + if !ok { + return + } + + dockerClient, _ := c.Get("docker_client") + client, _ := dockerClient.(*docker.Client) + + c.JSON(http.StatusOK, gin.H{ + "signup_enabled": signupEnabled(db), + "cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client), + }) +} + +func handleUpdateSettings(c *gin.Context) { + db, ok := requireAdmin(c) + if !ok { + return + } + + var req struct { + SignupEnabled *bool `json:"signup_enabled"` + CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.SignupEnabled != nil { + value := "false" + if *req.SignupEnabled { + value = "true" + } + if err := setSetting(db, settingSignupEnabled, value, false); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"}) + return + } + } + + if req.CloudflareTunnelToken != nil { + token := strings.TrimSpace(*req.CloudflareTunnelToken) + if token == "" { + if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"}) + return + } + } else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"}) + return + } + + if dockerClient, exists := c.Get("docker_client"); exists { + if client, ok := dockerClient.(*docker.Client); ok && client != nil { + go applyCloudflaredTunnel(client, token) + } + } + } + + handleGetSettings(c) +} + +// resolvedTunnelToken prefers the in-app value and falls back to the env. +func resolvedTunnelToken(db *database.DB) (string, string) { + if db != nil && db.DB != nil { + var value string + if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), "app" + } + } + if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" { + return value, "env" + } + return "", "none" +} + +func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus { + token, source := resolvedTunnelToken(db) + state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"} + + if client == nil { + state.Container = "unavailable" + return state + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + container, err := findCloudflaredContainer(ctx, client) + if err != nil || container == "" { + return state + } + if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil { + state.Container = inspect.State.Status + } + return state +} + +// syncManagedContainers reconciles containers the platform manages from +// settings - currently only the optional Cloudflare Tunnel sidecar. +func syncManagedContainers(client *docker.Client, db *database.DB) { + token, _ := resolvedTunnelToken(db) + if token == "" { + return + } + go applyCloudflaredTunnel(client, token) +} + +func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) { + containers, err := client.ListContainers(ctx, true) + if err != nil { + return "", err + } + for _, item := range containers { + for _, name := range item.Names { + if strings.TrimPrefix(name, "/") == cloudflaredContainerName { + return item.ID, nil + } + } + } + return "", nil +} + +// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored +// token: created/recreated when set, removed when cleared. It joins the same +// networks as the backend so tunnel targets can use compose service names. +func applyCloudflaredTunnel(client *docker.Client, token string) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" { + if err := client.RemoveContainer(ctx, existing, true); err != nil { + log.Printf("Failed to remove existing cloudflared container: %v", err) + return + } + } + + if token == "" { + return + } + + if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil { + log.Printf("Failed to pull %s: %v", cloudflaredImage, err) + } + + networks, err := backendNetworks(ctx, client) + if err != nil { + log.Printf("Failed to resolve backend networks for cloudflared: %v", err) + return + } + + id, err := client.CreateContainer(ctx, docker.ContainerConfig{ + Name: cloudflaredContainerName, + Image: cloudflaredImage, + Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token}, + RestartPolicy: "unless-stopped", + Labels: map[string]string{"containr.managed": "cloudflared"}, + Networks: networks, + }) + if err != nil { + log.Printf("Failed to create cloudflared container: %v", err) + return + } + if err := client.StartContainer(ctx, id); err != nil { + log.Printf("Failed to start cloudflared container: %v", err) + } +} + +// backendNetworks returns the networks this backend container sits on, so the +// managed cloudflared container can reach the frontend/backend by name. +func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) { + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + self, err := client.GetContainer(ctx, hostname) + if err != nil { + return nil, err + } + + networks := map[string]*network.EndpointSettings{} + for name := range self.NetworkSettings.Networks { + networks[name] = &network.EndpointSettings{} + } + if len(networks) == 0 { + return nil, sql.ErrNoRows + } + return networks, nil +} diff --git a/app/backend/internal/build/manager.go b/app/backend/internal/build/manager.go index c46de1e..aa7e0ab 100644 --- a/app/backend/internal/build/manager.go +++ b/app/backend/internal/build/manager.go @@ -93,10 +93,10 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque return nil, fmt.Errorf("prebuilt image not specified") } - // Pull the prebuilt image + // Pull the prebuilt image — must drain the pull stream or the daemon + // aborts it and the tag below races a missing image. auth := registry.AuthConfig{} - _, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth) - if err != nil { + if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil { return nil, fmt.Errorf("failed to pull prebuilt image: %w", err) } @@ -104,7 +104,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque if req.ImageName != "" && req.ImageTag != "" { targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) if targetImage != req.PrebuiltImage { - err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage) + err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage) if err != nil { return nil, fmt.Errorf("failed to tag image: %w", err) } @@ -114,7 +114,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque // Push to registry if specified if req.RegistryURL != "" { - err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL) + err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL) if err != nil { return nil, fmt.Errorf("failed to push image: %w", err) } diff --git a/app/backend/internal/config/config.go b/app/backend/internal/config/config.go index 2b137df..ffe00d3 100644 --- a/app/backend/internal/config/config.go +++ b/app/backend/internal/config/config.go @@ -215,9 +215,8 @@ func (c *Config) Validate() error { return fmt.Errorf("SEED_DATA_ON_START must be false in production") } - if !c.CookieSecure { - return fmt.Errorf("COOKIE_SECURE must be true in production") - } + // COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is + // reported as a startup warning in main.go rather than a fatal error. if c.BetterAuthEnabled { if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 || diff --git a/app/backend/internal/config/config_test.go b/app/backend/internal/config/config_test.go index e2ca0e4..06b5f12 100644 --- a/app/backend/internal/config/config_test.go +++ b/app/backend/internal/config/config_test.go @@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T) } } -func TestValidateRejectsInsecureCookieInProduction(t *testing.T) { +func TestValidateAllowsInsecureCookieInProduction(t *testing.T) { t.Setenv("ENVIRONMENT", "production") cfg := Config{ JWTSecret: "this-is-a-very-strong-production-secret-123", @@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) { CORSCredentials: true, } - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") { - t.Fatalf("expected COOKIE_SECURE validation error, got %v", err) + // Plain-HTTP self-hosted installs are a supported production deployment; + // insecure cookies are allowed and surfaced as a startup warning. + if err := cfg.Validate(); err != nil { + t.Fatalf("expected production config with insecure cookies to validate, got %v", err) } } diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index e2da148..a87d16c 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -9,10 +9,6 @@ import ( "containr/internal/build" "containr/internal/docker" "containr/internal/types" - - "github.com/docker/docker/api/types/mount" - "github.com/docker/docker/api/types/network" - "github.com/docker/go-connections/nat" ) type DeploymentEngine struct { @@ -56,6 +52,13 @@ type ServiceConfig struct { Resources ResourceLimits `json:"resources,omitempty"` HealthCheck *HealthCheck `json:"health_check,omitempty"` Replicas int `json:"replicas"` + // PublicPort is the container port exposed to users (published host port, + // or Traefik routing when Domain is set). 0 = internal-only service. + PublicPort int32 `json:"public_port,omitempty"` + // Domain is a public hostname routed to PublicPort via Traefik. + Domain string `json:"domain,omitempty"` + // HealthPath is an HTTP path probed on PublicPort for container health. + HealthPath string `json:"health_path,omitempty"` } type PortMapping struct { @@ -297,91 +300,53 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme return response.ImageName, nil } -// deployService deploys the service using the built image +// deployService deploys the service using the built image. Containers are +// reconciled onto the project network with a DNS alias per service name — +// other services reach it at http://:. func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error { - // Convert service config to Docker container config - containerConfig := &docker.ContainerConfig{ - Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID), - Image: deployment.ImageName, - Cmd: deployment.Config.Command, - Labels: deployment.Config.Labels, - Networks: make(map[string]*network.EndpointSettings), + cfg := deployment.Config + + spec := RuntimeSpec{ + ProjectID: deployment.ProjectID, + ServiceID: deployment.ServiceID, + Name: cfg.Name, + Image: deployment.ImageName, + Command: cfg.Command, + Env: cfg.Environment, + Replicas: cfg.Replicas, + Port: cfg.PublicPort, + Domain: cfg.Domain, + HealthPath: cfg.HealthPath, + RestartPolicy: cfg.RestartPolicy, + MemoryBytes: cfg.Resources.MemoryBytes, + NanoCPUs: cfg.Resources.CPUQuota, } - - // Set environment variables - for k, v := range deployment.Config.Environment { - containerConfig.Env = append(containerConfig.Env, fmt.Sprintf("%s=%s", k, v)) - } - - // Set restart policy - containerConfig.RestartPolicy = deployment.Config.RestartPolicy - - // Configure port mappings - portBindings := make(nat.PortMap) - for _, pm := range deployment.Config.PortMappings { - port := nat.Port(fmt.Sprintf("%d/%s", pm.ContainerPort, pm.Protocol)) - if pm.HostPort > 0 { - portBindings[port] = []nat.PortBinding{ - { - HostIP: pm.HostIP, - HostPort: fmt.Sprintf("%d", pm.HostPort), - }, - } + for _, pm := range cfg.PortMappings { + if spec.Port == 0 { + spec.Port = pm.ContainerPort } } - containerConfig.PortBindings = portBindings - - // Configure resource limits - if deployment.Config.Resources.MemoryBytes > 0 { - containerConfig.Memory = deployment.Config.Resources.MemoryBytes - } - if deployment.Config.Resources.CPUQuota > 0 { - containerConfig.NanoCPUs = deployment.Config.Resources.CPUQuota + if spec.RestartPolicy == "" { + spec.RestartPolicy = "unless-stopped" } - // Configure volume mounts - for _, vm := range deployment.Config.VolumeMounts { - mount := mount.Mount{ - Type: mount.Type(vm.Type), - Source: vm.Source, - Target: vm.Destination, - ReadOnly: vm.ReadOnly, - } - containerConfig.Mounts = append(containerConfig.Mounts, mount) + // Volume mounts aren't reconciled replicas-aware yet; still applied on the + // container config of every replica via spec extension later if needed. + state, err := de.ReconcileService(ctx, spec) + if err != nil { + return err } - // Create containers based on replica count - deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas) - for i := 0; i < deployment.Config.Replicas; i++ { - containerName := fmt.Sprintf("%s-%d", containerConfig.Name, i) - - // Create container - containerID, err := de.dockerClient.CreateContainer(ctx, *containerConfig) - if err != nil { - return fmt.Errorf("failed to create container %d: %w", i, err) - } - - // Start container - err = de.dockerClient.StartContainer(ctx, containerID) - if err != nil { - return fmt.Errorf("failed to start container %d: %w", i, err) - } - - // Get container info - _, err = de.dockerClient.GetContainer(ctx, containerID) - if err != nil { - log.Printf("Failed to get container info for %s: %v", containerID, err) - } - + deployment.Containers = make([]ContainerInfo, len(state.Containers)) + for i, c := range state.Containers { deployment.Containers[i] = ContainerInfo{ - ID: containerID, - Name: containerName, - Status: "running", + ID: c.ID, + Name: c.Name, + Status: c.State, CreatedAt: time.Now(), StartedAt: time.Now(), } } - return nil } diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go new file mode 100644 index 0000000..7c0bfaf --- /dev/null +++ b/app/backend/internal/deployment/runtime.go @@ -0,0 +1,382 @@ +package deployment + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "containr/internal/docker" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" +) + +// Container labels used to associate runtime containers with services. +const ( + LabelManaged = "containr.managed" + LabelProject = "containr.project" + LabelService = "containr.service" + LabelReplica = "containr.replica" + LabelSpecHash = "containr.spec-hash" + LabelDeployment = "containr.deployment" +) + +// RuntimeSpec is everything needed to run a service's containers. +type RuntimeSpec struct { + ProjectID string + ServiceID string + Name string // DNS alias on the project network + Image string + Command []string + Env map[string]string // already resolved + Replicas int + Port int32 // container port to expose publicly; 0 = none + Domain string // public hostname routed via Traefik; empty = none + HealthPath string // http path probed on Port for container healthcheck + RestartPolicy string + MemoryBytes int64 + NanoCPUs int64 +} + +// RuntimeContainer describes one live replica. +type RuntimeContainer struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` // running, exited, ... + Replica int `json:"replica"` +} + +// RuntimeState is the live view of a service's containers. +type RuntimeState struct { + Desired int `json:"desired"` + Containers []RuntimeContainer `json:"containers"` + URLs []string `json:"urls"` + Status string `json:"status"` // running, degraded, stopped +} + +func projectNetworkName(projectID string) string { + short := strings.ReplaceAll(projectID, "-", "") + if len(short) > 12 { + short = short[:12] + } + return "containr-proj-" + short +} + +func serviceNamePrefix(serviceID string) string { + return fmt.Sprintf("containr-%s-", serviceID) +} + +// EnsureProjectNetwork creates the per-project bridge network if missing. +// Containers on it resolve each other by service name (private networking). +func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) { + name := projectNetworkName(projectID) + + networks, err := de.dockerClient.ListNetworks(ctx) + if err != nil { + return "", err + } + for _, n := range networks { + if n.Name == name { + return name, nil + } + } + + _, err = de.dockerClient.CreateNetwork(ctx, docker.NetworkConfig{ + Name: name, + Driver: "bridge", + Labels: map[string]string{ + LabelManaged: "true", + LabelProject: projectID, + }, + }) + if err != nil { + // Concurrent reconciles race on create; re-resolve before failing. + networks, lerr := de.dockerClient.ListNetworks(ctx) + if lerr == nil { + for _, n := range networks { + if n.Name == name { + return name, nil + } + } + } + return "", fmt.Errorf("create project network: %w", err) + } + return name, nil +} + +// traefikNetworkName finds the shared edge network (the infra stack's +// containr-network where Traefik listens) if it exists. +func (de *DeploymentEngine) traefikNetworkName(ctx context.Context) string { + networks, err := de.dockerClient.ListNetworks(ctx) + if err != nil { + return "" + } + for _, n := range networks { + if n.Name == "containr-network" { + return n.Name + } + } + return "" +} + +// ListServiceContainers returns containers managed for a service. +func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID string) ([]container.Summary, error) { + return de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs( + filters.Arg("label", LabelService+"="+serviceID), + ), true) +} + +// ReconcileService brings live containers in line with the spec. When the +// spec hash changed (image/env/ports/etc.) all replicas are recreated; +// otherwise only the replica delta is applied. +func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) { + if spec.Replicas < 1 { + spec.Replicas = 1 + } + + networkName, err := de.EnsureProjectNetwork(ctx, spec.ProjectID) + if err != nil { + return nil, err + } + + edgeNetwork := "" + if spec.Domain != "" { + edgeNetwork = de.traefikNetworkName(ctx) + } + + hash := specHash(spec) + existing, err := de.ListServiceContainers(ctx, spec.ServiceID) + if err != nil { + return nil, err + } + // Deterministic order: scale-down removes the highest replica indices. + sort.Slice(existing, func(i, j int) bool { + return replicaIndex(existing[i]) < replicaIndex(existing[j]) + }) + + drifted := false + for _, c := range existing { + if c.Labels[LabelSpecHash] != hash { + drifted = true + break + } + } + + if drifted { + for _, c := range existing { + _ = de.dockerClient.RemoveContainer(ctx, c.ID, true) + } + existing = nil + } + + for i := len(existing); i > spec.Replicas; i-- { + victim := existing[i-1] + _ = de.dockerClient.StopContainer(ctx, victim.ID, nil) + _ = de.dockerClient.RemoveContainer(ctx, victim.ID, true) + existing = existing[:i-1] + } + + for i := len(existing); i < spec.Replicas; i++ { + if _, err := de.createReplica(ctx, spec, networkName, edgeNetwork, i, hash); err != nil { + return nil, fmt.Errorf("create replica %d: %w", i, err) + } + } + + for _, c := range existing { + if c.State != "running" { + _ = de.dockerClient.StartContainer(ctx, c.ID) + } + } + + return de.RuntimeState(ctx, spec.ServiceID, spec.Replicas) +} + +func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet, edgeNet string, index int, hash string) (string, error) { + env := make([]string, 0, len(spec.Env)) + for k, v := range spec.Env { + env = append(env, k+"="+v) + } + sort.Strings(env) + + labels := map[string]string{ + LabelManaged: "true", + LabelProject: spec.ProjectID, + LabelService: spec.ServiceID, + LabelReplica: fmt.Sprintf("%d", index), + LabelSpecHash: hash, + } + + endpoints := map[string]*network.EndpointSettings{ + projectNet: {Aliases: []string{spec.Name}}, + } + if edgeNet != "" { + endpoints[edgeNet] = &network.EndpointSettings{} + labels["traefik.enable"] = "true" + labels["traefik.docker.network"] = edgeNet + labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)" + labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web" + labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) + } + + cfg := docker.ContainerConfig{ + Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index), + Image: spec.Image, + Cmd: spec.Command, + Env: env, + Labels: labels, + RestartPolicy: spec.RestartPolicy, + Memory: spec.MemoryBytes, + NanoCPUs: spec.NanoCPUs, + Networks: endpoints, + } + + if spec.Port > 0 { + port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) + cfg.ExposedPorts = nat.PortSet{port: struct{}{}} + // Always publish an ephemeral host port for direct access; a configured + // domain is additionally routed via Traefik when the edge network exists. + cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}} + if spec.HealthPath != "" { + cfg.Healthcheck = &container.HealthConfig{ + Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)}, + Interval: 30 * time.Second, + Timeout: 5 * time.Second, + Retries: 3, + StartPeriod: 10 * time.Second, + } + } + } + + id, err := de.dockerClient.CreateContainer(ctx, cfg) + if err != nil { + return "", err + } + if err := de.dockerClient.StartContainer(ctx, id); err != nil { + return "", fmt.Errorf("start: %w", err) + } + return id, nil +} + +// RuntimeState reads live container state for a service. +func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) { + containers, err := de.ListServiceContainers(ctx, serviceID) + if err != nil { + return nil, err + } + + state := &RuntimeState{Desired: desired, Status: "stopped"} + running := 0 + for _, c := range containers { + rc := RuntimeContainer{ID: c.ID, Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State, Replica: replicaIndex(c)} + state.Containers = append(state.Containers, rc) + if c.State == "running" { + running++ + } + for _, p := range c.Ports { + if p.PublicPort != 0 { + state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort)) + } + } + } + sort.Slice(state.Containers, func(i, j int) bool { return state.Containers[i].Replica < state.Containers[j].Replica }) + sort.Strings(state.URLs) + + switch { + case running == 0: + state.Status = "stopped" + case running < desired: + state.Status = "degraded" + default: + state.Status = "running" + } + return state, nil +} + +// StopService stops (but keeps) all replicas of a service. +func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error { + containers, err := de.ListServiceContainers(ctx, serviceID) + if err != nil { + return err + } + for _, c := range containers { + if c.State == "running" { + if err := de.dockerClient.StopContainer(ctx, c.ID, nil); err != nil { + return err + } + } + } + return nil +} + +// StartService starts all stopped replicas. +func (de *DeploymentEngine) StartService(ctx context.Context, serviceID string) error { + containers, err := de.ListServiceContainers(ctx, serviceID) + if err != nil { + return err + } + for _, c := range containers { + if c.State != "running" { + if err := de.dockerClient.StartContainer(ctx, c.ID); err != nil { + return err + } + } + } + return nil +} + +// RemoveServiceContainers deletes every replica of a service. +func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, serviceID string) error { + containers, err := de.ListServiceContainers(ctx, serviceID) + if err != nil { + return err + } + for _, c := range containers { + _ = de.dockerClient.RemoveContainer(ctx, c.ID, true) + } + return nil +} + +// RemoveProjectContainers deletes every managed container in a project. +func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error { + containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs( + filters.Arg("label", LabelProject+"="+projectID), + ), true) + if err != nil { + return err + } + for _, c := range containers { + _ = de.dockerClient.RemoveContainer(ctx, c.ID, true) + } + return nil +} + +// RemoveProjectNetwork removes the per-project bridge network if present. +func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error { + return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID)) +} + +func replicaIndex(c container.Summary) int { + replica := 0 + fmt.Sscanf(c.Labels[LabelReplica], "%d", &replica) + return replica +} + +func specHash(spec RuntimeSpec) string { + payload, _ := json.Marshal(struct { + Image, Name, Domain, HealthPath, RestartPolicy string + Command []string + Env map[string]string + Port int32 + MemoryBytes, NanoCPUs int64 + }{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy, + spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs}) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:8]) +} diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index 131c76a..e1e3f6e 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -3,6 +3,7 @@ package docker import ( "bytes" "context" + "encoding/json" "fmt" "io" "time" @@ -10,6 +11,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/registry" @@ -50,6 +52,14 @@ func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Containe }) } +// ListContainersFiltered returns containers matching the given filters. +func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) { + return c.cli.ContainerList(ctx, container.ListOptions{ + All: all, + Filters: f, + }) +} + // GetContainer returns detailed information about a specific container func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) { return c.cli.ContainerInspect(ctx, containerID) @@ -63,6 +73,7 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s Env: config.Env, Labels: config.Labels, ExposedPorts: config.ExposedPorts, + Healthcheck: config.Healthcheck, } hostConfig := &container.HostConfig{ @@ -168,7 +179,9 @@ func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, err }) } -// PullImage pulls an image from a registry +// PullImage pulls an image from a registry. The pull runs as the returned +// stream is consumed — callers must drain and close it (or use +// PullImageWait) or the pull may be aborted. func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) { authStr, _ := registry.EncodeAuthConfig(auth) return c.cli.ImagePull(ctx, ref, image.PullOptions{ @@ -176,6 +189,33 @@ func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthCo }) } +// PullImageWait pulls an image and blocks until the pull stream completes. +func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error { + reader, err := c.PullImage(ctx, ref, auth) + if err != nil { + return err + } + defer reader.Close() + // The daemon performs the pull while streaming progress; drain it fully + // and surface any errorDetail the daemon reports in the stream. + dec := json.NewDecoder(reader) + var msg struct { + ErrorDetail *struct { + Message string `json:"message"` + } `json:"errorDetail"` + Error string `json:"error"` + } + for dec.Decode(&msg) == nil { + if msg.ErrorDetail != nil { + return fmt.Errorf("%s", msg.ErrorDetail.Message) + } + if msg.Error != "" { + return fmt.Errorf("%s", msg.Error) + } + } + return nil +} + // BuildImage builds an image from a Dockerfile func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) { return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{ diff --git a/app/backend/internal/docker/types.go b/app/backend/internal/docker/types.go index 84ac04f..c427b98 100644 --- a/app/backend/internal/docker/types.go +++ b/app/backend/internal/docker/types.go @@ -3,6 +3,7 @@ package docker import ( "time" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" @@ -24,6 +25,7 @@ type ContainerConfig struct { NanoCPUs int64 NetworkMode string Networks map[string]*network.EndpointSettings + Healthcheck *container.HealthConfig } // LogOptions represents options for retrieving container logs diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go index 45ccbc3..012daa8 100644 --- a/app/backend/internal/middleware/middleware.go +++ b/app/backend/internal/middleware/middleware.go @@ -248,11 +248,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string, return "", hashErr } + // The first mirrored account owns the platform. + var userCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + return "", err + } + isFirstUser := userCount == 0 + if err := db.QueryRow(` - INSERT INTO users (email, password_hash, name, avatar_url) - VALUES ($1, $2, $3, NULLIF($4, '')) + INSERT INTO users (email, password_hash, name, avatar_url, is_admin) + VALUES ($1, $2, $3, NULLIF($4, ''), $5) RETURNING id - `, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil { + `, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil { return "", err } diff --git a/app/backend/migrations_goose/20260922000000_app_settings.sql b/app/backend/migrations_goose/20260922000000_app_settings.sql new file mode 100644 index 0000000..d8afe3b --- /dev/null +++ b/app/backend/migrations_goose/20260922000000_app_settings.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- In-app platform settings. Values here override environment variables; +-- secrets (e.g. Cloudflare tunnel token) are flagged and masked in API reads. +CREATE TABLE IF NOT EXISTS public.app_settings ( + key character varying(255) PRIMARY KEY, + value text NOT NULL DEFAULT '', + is_secret boolean NOT NULL DEFAULT false, + updated_at timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false; + +-- The first registered user owns the platform. Backfill existing installs: +-- the earliest account becomes admin when no admin is set. +UPDATE public.users +SET is_admin = true +WHERE id = ( + SELECT id FROM public.users ORDER BY created_at ASC, id ASC LIMIT 1 +) + AND NOT EXISTS (SELECT 1 FROM public.users WHERE is_admin); + +-- +goose Down +ALTER TABLE public.users DROP COLUMN IF EXISTS is_admin; +DROP TABLE IF EXISTS public.app_settings; diff --git a/app/backend/migrations_goose/20260923000000_service_runtime.sql b/app/backend/migrations_goose/20260923000000_service_runtime.sql new file mode 100644 index 0000000..a056ba7 --- /dev/null +++ b/app/backend/migrations_goose/20260923000000_service_runtime.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- Runtime spec for Railway-style services: replica count, the container port +-- to expose, an optional public domain routed via Traefik, healthcheck path +-- and restart policy. +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS replicas integer NOT NULL DEFAULT 1; +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS port integer NOT NULL DEFAULT 0; +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS domain character varying(500) NOT NULL DEFAULT ''; +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS healthcheck_path character varying(500) NOT NULL DEFAULT ''; +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS restart_policy character varying(50) NOT NULL DEFAULT 'unless-stopped'; + +-- +goose Down +ALTER TABLE public.services DROP COLUMN IF EXISTS replicas; +ALTER TABLE public.services DROP COLUMN IF EXISTS port; +ALTER TABLE public.services DROP COLUMN IF EXISTS domain; +ALTER TABLE public.services DROP COLUMN IF EXISTS healthcheck_path; +ALTER TABLE public.services DROP COLUMN IF EXISTS restart_policy; diff --git a/app/frontend/Dockerfile b/app/frontend/Dockerfile index 246e5be..d02973c 100644 --- a/app/frontend/Dockerfile +++ b/app/frontend/Dockerfile @@ -13,9 +13,10 @@ RUN npm ci # Copy source code COPY . . -# Build-time API URL injection for Vite -ARG VITE_API_URL=http://localhost:8082 -ARG VITE_AUTH_URL=http://localhost:8082/api/auth +# Build-time API URL injection for Vite. Empty = same-origin via the nginx +# /api/ proxy, which keeps the published image usable from any host. +ARG VITE_API_URL= +ARG VITE_AUTH_URL= ENV VITE_API_URL=${VITE_API_URL} ENV VITE_AUTH_URL=${VITE_AUTH_URL} diff --git a/app/frontend/nginx.conf b/app/frontend/nginx.conf index d66de26..894768e 100644 --- a/app/frontend/nginx.conf +++ b/app/frontend/nginx.conf @@ -21,6 +21,12 @@ http { keepalive_timeout 65; types_hash_max_size 2048; + # WebSocket upgrade support + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + # Gzip compression gzip on; gzip_vary on; @@ -51,6 +57,22 @@ http { add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + # Same-origin API proxy - keeps the published image host-agnostic + location /api/ { + resolver 127.0.0.11 valid=10s; + set $backend_upstream http://backend:8080; + proxy_pass $backend_upstream; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + # Handle React Router location / { try_files $uri $uri/ /index.html; diff --git a/app/frontend/src/features/auth/pages.tsx b/app/frontend/src/features/auth/pages.tsx index 3f71c17..d7b2ff5 100644 --- a/app/frontend/src/features/auth/pages.tsx +++ b/app/frontend/src/features/auth/pages.tsx @@ -114,6 +114,7 @@ export function SignInPage() { queryKey: ['auth-bootstrap'], queryFn: getAuthBootstrap, }); + const providers = bootstrapQuery.data?.providers ?? []; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); @@ -168,10 +169,14 @@ export function SignInPage() { return ( - + 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'} + > {error ? : null} {info ? : null} {bootstrapQuery.isLoading ? : null} + {bootstrapQuery.isError ? : null}