package api import ( "archive/tar" "containr/internal/database/sqlcdb" "containr/internal/deployment" "containr/internal/docker" "containr/internal/secrets" "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "math" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/google/uuid" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/registry" "github.com/docker/go-connections/nat" "github.com/gin-gonic/gin" _ "github.com/lib/pq" ) const ( managedDatabaseContainerPrefix = "containr-db" managedDatabaseVolumePrefix = "containr-db-vol" managedDatabaseBackupVolume = "containr-db-backups" managedDatabaseBackupImage = "alpine:3.20" managedDatabaseDefaultPlan = "hobby" managedDatabaseDefaultRegion = "us-east" ) // DatabaseService represents a managed database service type DatabaseService struct { ID string `json:"id" db:"id"` OwnerID string `json:"-" db:"user_id"` Name string `json:"name" db:"name"` Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly Status string `json:"status" db:"status"` // running, stopped, building, error Version string `json:"version" db:"version"` Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business Region string `json:"region" db:"region"` BackupSchedule string `json:"backup_schedule,omitempty"` NextBackupAt *time.Time `json:"next_backup_at,omitempty"` Provider string `json:"provider"` // managed | external PublicPort bool `json:"public_port" db:"public_port"` BackupTargetID string `json:"backup_target_id,omitempty"` // S3-compatible archive destination External *DatabaseExternalRef `json:"external,omitempty"` // connection target for external DBs CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` ConnectionURL string `json:"connection_url"` Metrics DatabaseMetrics `json:"metrics"` Backups DatabaseBackupConfig `json:"backups"` Settings DatabaseSettings `json:"settings"` // externalPasswordEnc is the stored ciphertext — needed to compose the // connection URL on privileged reads, never serialized. externalPasswordEnc string `json:"-"` } // DatabaseExternalRef describes a registered external database's connection // target. The password is stored encrypted and never returned — has_password // signals presence. type DatabaseExternalRef struct { Host string `json:"host"` Port int `json:"port"` Database string `json:"database"` Username string `json:"username"` HasPassword bool `json:"has_password"` SSL bool `json:"ssl"` } // DatabaseMetrics represents database performance metrics type DatabaseMetrics struct { CPU float64 `json:"cpu"` Memory float64 `json:"memory"` Storage float64 `json:"storage"` Connections int `json:"connections"` ReadIOPS int `json:"read_iops"` WriteIOPS int `json:"write_iops"` NetworkIn float64 `json:"network_in"` NetworkOut float64 `json:"network_out"` } // DatabaseBackupConfig represents backup configuration type DatabaseBackupConfig struct { Enabled bool `json:"enabled"` LastBackup *time.Time `json:"last_backup,omitempty"` Retention int `json:"retention"` // days NextBackup *time.Time `json:"next_backup,omitempty"` Backups []DatabaseBackup `json:"backups"` } // DatabaseBackup represents a single backup type DatabaseBackup struct { ID string `json:"id"` CreatedAt time.Time `json:"created_at"` Size string `json:"size"` Status string `json:"status"` // completed, failed, in_progress Remote bool `json:"remote"` // archive also exists on the configured backup target } // DatabaseSettings represents database configuration type DatabaseSettings struct { MaxConnections int `json:"max_connections"` Timeout int `json:"timeout"` // seconds SSL bool `json:"ssl"` Logging bool `json:"logging"` } // DatabaseCreateRequest represents a request to create a new database type DatabaseCreateRequest struct { Name string `json:"name" binding:"required"` Type string `json:"type" binding:"required"` Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"` Region string `json:"region" binding:"required"` PublicPort bool `json:"public_port"` } // DatabaseUpdateRequest represents a request to update a database type DatabaseUpdateRequest struct { Name string `json:"name,omitempty"` Plan string `json:"plan,omitempty"` BackupSchedule *string `json:"backup_schedule,omitempty"` PublicPort *bool `json:"public_port,omitempty"` BackupTargetID *string `json:"backup_target_id,omitempty"` // empty string clears } // DatabaseActionRequest represents a request to perform database actions type DatabaseActionRequest struct { Action string `json:"action" binding:"required,oneof=start stop restart"` } // DatabaseBackupRequest represents a request to create a backup type DatabaseBackupRequest struct { DatabaseID string `json:"database_id" binding:"required"` } // DatabaseRestoreRequest represents a request to restore from backup type DatabaseRestoreRequest struct { DatabaseID string `json:"database_id" binding:"required"` BackupID string `json:"backup_id" binding:"required"` } // DatabaseHandler handles database service operations type DatabaseHandler struct { db *sql.DB queries *sqlcdb.Queries dockerClient *docker.Client } var supportedDatabaseTypes = map[string]struct{}{ "postgresql": {}, "redis": {}, "dragonfly": {}, "mysql": {}, "mariadb": {}, "mongodb": {}, "clickhouse": {}, } var databaseDefaultVersions = map[string]string{ "postgresql": "16.2", "redis": "7.2", "dragonfly": "1.24", "mysql": "8.4", "mariadb": "11.4", "mongodb": "7.0", "clickhouse": "24.8", } var ( errDatabaseNameRequired = errors.New("name is required") errUnsupportedDatabaseType = errors.New("unsupported database type") errUnsupportedDatabasePlan = errors.New("unsupported database plan") errDatabaseNameAlreadyInUse = errors.New("database name already exists") ) // NewDatabaseHandler creates a new database handler func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandler { return &DatabaseHandler{ db: db, queries: sqlcdb.New(db), dockerClient: dockerClient, } } // GetDatabases returns database services. The platform is public-read: every // caller sees the fleet, but connection URLs (which may embed credentials) // are only exposed to admins. func (h *DatabaseHandler) GetDatabases(c *gin.Context) { userID := optionalUserID(c) isAdmin := contextIsAdmin(c) rows, err := h.queries.ListAllDatabaseServices(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"}) return } databases := make([]DatabaseService, 0, len(rows)) for _, row := range rows { db := mapDatabaseServiceRow(row) db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) privileged := isAdmin || (userID != "" && db.OwnerID == userID) if privileged { if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil { db.Backups = backupConfig } else { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} } db.Settings = h.generateMockSettings() db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c)) } else { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.ConnectionURL = "" } databases = append(databases, db) } c.JSON(http.StatusOK, gin.H{"databases": databases}) } // GetDatabase returns a specific database service func (h *DatabaseHandler) GetDatabase(c *gin.Context) { userID := optionalUserID(c) isAdmin := contextIsAdmin(c) databaseID := c.Param("id") row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"}) return } db := mapDatabaseServiceRow(row) db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) privileged := isAdmin || (userID != "" && db.OwnerID == userID) if privileged { if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil { db.Backups = backupConfig } else { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} } db.Settings = h.generateMockSettings() db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c)) } else { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.ConnectionURL = "" } c.JSON(http.StatusOK, db) } // effectiveUserID returns the user scope for row-level queries. Admins act // on behalf of the row's owner so management works across accounts. func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string { if !contextIsAdmin(c) || databaseID == "" { return userID } owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID) if err == nil && owner != "" { return owner } return userID } // CreateDatabase creates a new database service func (h *DatabaseHandler) CreateDatabase(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } var req DatabaseCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } databaseID, dbName, dbType, err := h.createManagedDatabase(c.Request.Context(), userID, managedDatabaseCreateRequest{ Name: req.Name, Type: req.Type, Plan: req.Plan, Region: req.Region, PublicPort: req.PublicPort, }) if err != nil { switch { case errors.Is(err, errDatabaseNameRequired), errors.Is(err, errUnsupportedDatabaseType), errors.Is(err, errUnsupportedDatabasePlan): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) case errors.Is(err, errDatabaseNameAlreadyInUse): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database"}) } return } go h.provisionDatabase(databaseID, dbName, dbType) c.JSON(http.StatusCreated, gin.H{ "id": databaseID, "message": "Database provisioning started", "status": "building", }) } type managedDatabaseCreateRequest struct { Name string Type string Plan string Region string PublicPort bool RuntimeVariables map[string]string // ProjectID + NetworkAlias attach the container to a project network // under Alias — set by graph-template deploys so sibling services can // resolve the database by name. ProjectID string NetworkAlias string } func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) { name = strings.TrimSpace(req.Name) if name == "" { return "", "", "", errDatabaseNameRequired } dbType = normalizeDatabaseType(req.Type) if _, exists := supportedDatabaseTypes[dbType]; !exists { return "", "", "", fmt.Errorf("%w: %s", errUnsupportedDatabaseType, dbType) } plan := strings.TrimSpace(req.Plan) if plan == "" { plan = managedDatabaseDefaultPlan } if !isSupportedDatabasePlan(plan) { return "", "", "", fmt.Errorf("%w: %s", errUnsupportedDatabasePlan, plan) } region := strings.TrimSpace(req.Region) if region == "" { region = managedDatabaseDefaultRegion } count, err := h.queries.CountDatabaseServicesByUserAndName(ctx, sqlcdb.CountDatabaseServicesByUserAndNameParams{ UserID: userID, Name: name, }) if err != nil { return "", "", "", fmt.Errorf("failed to validate database name: %w", err) } if count > 0 { return "", "", "", errDatabaseNameAlreadyInUse } databaseID = generateDatabaseID(name) now := time.Now() version := h.getDefaultVersion(dbType) var projectID uuid.NullUUID if req.ProjectID != "" { if parsed, parseErr := uuid.Parse(req.ProjectID); parseErr == nil { projectID = uuid.NullUUID{UUID: parsed, Valid: true} } } err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{ ID: databaseID, UserID: userID, Name: name, Type: dbType, Status: "building", Version: version, Plan: plan, Region: region, PublicPort: req.PublicPort, ProjectID: projectID, CreatedAt: sql.NullTime{Time: now, Valid: true}, UpdatedAt: sql.NullTime{Time: now, Valid: true}, }) if err != nil { return "", "", "", fmt.Errorf("failed to create database service: %w", err) } return databaseID, name, dbType, nil } func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context, userID string, req managedDatabaseCreateRequest) (string, error) { databaseID, name, dbType, err := h.createManagedDatabase(ctx, userID, req) if err != nil { return "", err } go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias}) return databaseID, nil } // dbNetworkAttach carries the optional project-network membership for a // managed database provisioned by a graph template. type dbNetworkAttach struct { ProjectID string Alias string } // UpdateDatabase updates a database service func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) var req DatabaseUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Name = strings.TrimSpace(req.Name) req.Plan = strings.TrimSpace(req.Plan) if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil && req.BackupTargetID == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } if req.PublicPort != nil || req.BackupTargetID != nil { row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"}) return } if req.PublicPort != nil { if row.Provider == "external" { c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"}) return } if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{ PublicPort: *req.PublicPort, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, UserID: userID, }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"}) return } // Port bindings are baked into the container — recreate it so the // new bind scope takes effect. The data volume survives. if *req.PublicPort != row.PublicPort { go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type) } } if req.BackupTargetID != nil { if row.Provider == "external" { c.JSON(http.StatusBadRequest, gin.H{"error": "backup targets apply to managed databases — external backups are not supported yet", "code": "VALIDATION"}) return } targetID := strings.TrimSpace(*req.BackupTargetID) if targetID != "" { if _, err := h.queries.GetBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.GetBackupTargetByIDAndUserParams{ID: targetID, UserID: userID}); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Backup target not found", "code": "NOT_FOUND"}) return } } if err := h.queries.SetDatabaseBackupTargetByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupTargetByIDAndUserParams{ BackupTargetID: sql.NullString{String: targetID, Valid: targetID != ""}, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, UserID: userID, }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup target"}) return } } } if req.BackupSchedule != nil { var schedule sql.NullString var nextBackup sql.NullTime if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" { next, err := calculateNextRun(expr, "UTC") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"}) return } schedule = sql.NullString{String: expr, Valid: true} nextBackup = sql.NullTime{Time: *next, Valid: true} } err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{ BackupSchedule: schedule, NextBackupAt: nextBackup, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup schedule"}) return } } if req.Name != "" || req.Plan != "" { now := sql.NullTime{Time: time.Now(), Valid: true} var err error switch { case req.Name != "" && req.Plan != "": err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{ Name: req.Name, Plan: req.Plan, UpdatedAt: now, ID: databaseID, UserID: userID, }) case req.Name != "": err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{ Name: req.Name, UpdatedAt: now, ID: databaseID, UserID: userID, }) default: err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{ Plan: req.Plan, UpdatedAt: now, ID: databaseID, UserID: userID, }) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"}) return } } c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"}) } // DeleteDatabase deletes a database service func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if row.Provider != "external" && h.dockerClient != nil { if err := h.deleteManagedDatabaseRuntime(databaseID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete runtime database"}) return } } err = h.queries.DeleteDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.DeleteDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete database"}) return } c.JSON(http.StatusOK, gin.H{"message": "Database deleted successfully"}) } // PerformDatabaseAction performs actions on a database (start, stop, restart) func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) var req DatabaseActionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if row.Provider == "external" { c.JSON(http.StatusBadRequest, gin.H{ "error": "Lifecycle actions apply to managed databases only — external databases are run elsewhere", "code": "VALIDATION", }) return } var newStatus string switch req.Action { case "start": if h.dockerClient != nil { if err := h.startManagedDatabase(databaseID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start database runtime"}) return } } newStatus = "running" case "stop": if h.dockerClient != nil { if err := h.stopManagedDatabase(databaseID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stop database runtime"}) return } } newStatus = "stopped" case "restart": newStatus = "building" if err := h.queries.SetDatabaseServiceStatusByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServiceStatusByIDAndUserParams{ Status: newStatus, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, UserID: userID, }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database status"}) return } if h.dockerClient != nil { if err := h.restartManagedDatabase(databaseID); err != nil { _ = h.queries.SetDatabaseServiceStatusByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServiceStatusByIDAndUserParams{ Status: "error", UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, UserID: userID, }) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restart database runtime"}) return } } newStatus = "running" default: c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"}) return } err = h.queries.SetDatabaseServiceStatusByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServiceStatusByIDAndUserParams{ Status: newStatus, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database status"}) return } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Database %s initiated", req.Action), "status": newStatus, }) } // CreateBackup creates a backup of a database func (h *DatabaseHandler) CreateBackup(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if row.Status == "building" { c.JSON(http.StatusConflict, gin.H{"error": "Database is still provisioning"}) return } if row.Provider == "external" { c.JSON(http.StatusBadRequest, gin.H{ "error": "Backups run against the managed dump pipeline — external database backups are not supported yet", "code": "VALIDATION", }) return } backupID := generateDatabaseBackupID(databaseID) archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID)) now := time.Now() if err := h.queries.CreateDatabaseBackup(c.Request.Context(), sqlcdb.CreateDatabaseBackupParams{ ID: backupID, DatabaseID: databaseID, Size: "pending", Status: "in_progress", BackupPath: sql.NullString{String: archivePath, Valid: true}, CreatedAt: sql.NullTime{Time: now, Valid: true}, }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup record"}) return } go h.createBackupProcess(databaseID, backupID, archivePath) c.JSON(http.StatusCreated, gin.H{ "backup_id": backupID, "message": "Backup creation started", "status": "in_progress", }) } // RestoreBackup restores a database from a backup func (h *DatabaseHandler) RestoreBackup(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) var req DatabaseRestoreRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } dbRow, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if dbRow.Provider == "external" { c.JSON(http.StatusBadRequest, gin.H{ "error": "Restores run against the managed dump pipeline — external database restores are not supported yet", "code": "VALIDATION", }) return } backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ ID: req.BackupID, DatabaseID: databaseID, UserID: userID, }) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check backup"}) return } if backup.Status != "completed" { c.JSON(http.StatusConflict, gin.H{"error": "Backup is not ready for restore"}) return } backupPath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(req.BackupID)) if backup.BackupPath.Valid && strings.TrimSpace(backup.BackupPath.String) != "" { backupPath = sanitizeBackupArchivePath(backup.BackupPath.String) } go h.restoreBackupProcess(databaseID, req.BackupID, backupPath) c.JSON(http.StatusOK, gin.H{ "message": "Database restore started", "status": "in_progress", }) } // DownloadBackup streams a completed backup archive out of the backups volume. // Archives live inside the containr-db-backups docker volume, so a one-shot // utility container cats the file and its stdout is demuxed to the response. func (h *DatabaseHandler) DownloadBackup(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) backupID := c.Param("bid") backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ ID: backupID, DatabaseID: databaseID, UserID: userID, }) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check backup"}) return } if backup.Status != "completed" { c.JSON(http.StatusConflict, gin.H{"error": "Backup is not completed"}) return } if h.dockerClient == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"}) return } archive := managedDatabaseBackupArchivePath(backupID) if backup.BackupPath.Valid && strings.TrimSpace(backup.BackupPath.String) != "" { archive = sanitizeBackupArchivePath(backup.BackupPath.String) } // Container logs cannot carry binary data (the daemon sanitizes non-UTF-8), // so the archive is copied out of a stopped utility container instead. ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute) defer cancel() containerID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{ Name: h.generateUtilityContainerName("backup-dl", "shared"), Image: managedDatabaseBackupImage, Cmd: []string{"true"}, RestartPolicy: "no", Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup", ReadOnly: true}}, Labels: map[string]string{ "containr.managed": "true", "containr.utility": "backup-dl", }, NetworkMode: "none", }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read backup archive"}) return } defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }() tarStream, err := h.dockerClient.CopyFromContainer(ctx, containerID, "/backup/"+archive) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"}) return } defer tarStream.Close() tarReader := tar.NewReader(tarStream) if _, err := tarReader.Next(); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"}) return } c.Header("Content-Type", "application/gzip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", archive)) c.Status(http.StatusOK) if _, err := io.Copy(c.Writer, tarReader); err != nil { log.Printf("backup download %s: stream error: %v", backupID, err) } } func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics { if h.dockerClient == nil || db.Provider == "external" { return h.generateMockMetrics() } statsCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() stats, err := h.dockerClient.GetContainerStats(statsCtx, managedDatabaseContainerName(db.ID), false) if err != nil { return h.generateMockMetrics() } defer stats.Body.Close() var payload dockercontainer.StatsResponse if err := json.NewDecoder(stats.Body).Decode(&payload); err != nil { return h.generateMockMetrics() } cpu := calculateDockerCPUPercent(payload) memory := calculateDockerMemoryPercent(payload) readBytes, writeBytes := calculateDockerBlkio(payload) networkInBytes, networkOutBytes := calculateDockerNetwork(payload) return DatabaseMetrics{ CPU: roundFloat(cpu, 2), Memory: roundFloat(memory, 2), Storage: 0, Connections: 0, ReadIOPS: int(readBytes / 1024), WriteIOPS: int(writeBytes / 1024), NetworkIn: roundFloat(float64(networkInBytes)/(1024*1024), 3), NetworkOut: roundFloat(float64(networkOutBytes)/(1024*1024), 3), } } func (h *DatabaseHandler) generateMockMetrics() DatabaseMetrics { return DatabaseMetrics{ CPU: 25.0 + (float64(time.Now().Unix() % 50)), Memory: 60.0 + (float64(time.Now().Unix() % 30)), Storage: 45.0 + (float64(time.Now().Unix() % 40)), Connections: 10 + (int(time.Now().Unix() % 20)), ReadIOPS: 150 + (int(time.Now().Unix() % 100)), WriteIOPS: 80 + (int(time.Now().Unix() % 50)), NetworkIn: 2.5 + (float64(time.Now().Unix()%10))/10, NetworkOut: 1.8 + (float64(time.Now().Unix()%8))/10, } } func calculateDockerCPUPercent(payload dockercontainer.StatsResponse) float64 { cpuDelta := float64(payload.CPUStats.CPUUsage.TotalUsage) - float64(payload.PreCPUStats.CPUUsage.TotalUsage) systemDelta := float64(payload.CPUStats.SystemUsage) - float64(payload.PreCPUStats.SystemUsage) if cpuDelta <= 0 || systemDelta <= 0 { return 0 } onlineCPUs := float64(payload.CPUStats.OnlineCPUs) if onlineCPUs <= 0 { onlineCPUs = float64(len(payload.CPUStats.CPUUsage.PercpuUsage)) if onlineCPUs <= 0 { onlineCPUs = 1 } } return (cpuDelta / systemDelta) * onlineCPUs * 100 } func calculateDockerMemoryPercent(payload dockercontainer.StatsResponse) float64 { usage := float64(payload.MemoryStats.Usage) limit := float64(payload.MemoryStats.Limit) if usage <= 0 || limit <= 0 { return 0 } cache := 0.0 if payload.MemoryStats.Stats != nil { if v, ok := payload.MemoryStats.Stats["cache"]; ok { cache = float64(v) } } adjusted := usage - cache if adjusted < 0 { adjusted = usage } return (adjusted / limit) * 100 } func calculateDockerBlkio(payload dockercontainer.StatsResponse) (readBytes uint64, writeBytes uint64) { for _, entry := range payload.BlkioStats.IoServiceBytesRecursive { switch strings.ToLower(strings.TrimSpace(entry.Op)) { case "read": readBytes += entry.Value case "write": writeBytes += entry.Value } } return readBytes, writeBytes } func calculateDockerNetwork(payload dockercontainer.StatsResponse) (rxBytes uint64, txBytes uint64) { for _, networkStats := range payload.Networks { rxBytes += networkStats.RxBytes txBytes += networkStats.TxBytes } return rxBytes, txBytes } func roundFloat(value float64, places int) float64 { pow := math.Pow(10, float64(places)) return math.Round(value*pow) / pow } func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string, db DatabaseService) (DatabaseBackupConfig, error) { rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{ DatabaseID: db.ID, UserID: userID, Limit: 20, }) if err != nil { return DatabaseBackupConfig{}, err } var lastBackup *time.Time backups := make([]DatabaseBackup, 0, len(rows)) for _, row := range rows { createdAt := databaseNullTime(row.CreatedAt) backups = append(backups, DatabaseBackup{ ID: row.ID, CreatedAt: createdAt, Size: row.Size, Status: row.Status, Remote: row.RemoteKey.Valid && row.RemoteKey.String != "", }) if lastBackup == nil && row.Status == "completed" { t := createdAt lastBackup = &t } } return DatabaseBackupConfig{ Enabled: db.BackupSchedule != "", LastBackup: lastBackup, Retention: 30, NextBackup: db.NextBackupAt, Backups: backups, }, nil } func (h *DatabaseHandler) generateMockSettings() DatabaseSettings { return DatabaseSettings{ MaxConnections: 100, Timeout: 30, SSL: true, Logging: true, } } // resolveConnectionURL returns the stored connection URL for privileged // viewers, or "" when the runtime has not produced one (not provisioned yet). // External databases compose it from the registered fields on demand. func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string { if db.Provider == "external" && db.External != nil { return externalConnectionURL(db.Type, db.External, secrets.Decrypt(db.externalPasswordEnc)) } return strings.TrimSpace(db.ConnectionURL) } // requestHostname returns the host the client used to reach the API, without // the port. Empty when it cannot be determined. func requestHostname(c *gin.Context) string { host := strings.TrimSpace(c.Request.Host) if host == "" { return "" } if h, _, err := net.SplitHostPort(host); err == nil { return strings.Trim(h, "[]") } return strings.Trim(host, "[]") } // rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host // the caller actually used, so URLs handed to remote browsers are usable. func rewriteLoopbackURL(rawURL, host string) string { if rawURL == "" || host == "" { return rawURL } u, err := url.Parse(rawURL) if err != nil { return rawURL } switch strings.ToLower(u.Hostname()) { case "localhost", "127.0.0.1", "0.0.0.0", "::1", "": default: return rawURL } port := u.Port() if port == "" { u.Host = host } else { u.Host = net.JoinHostPort(host, port) } return u.String() } func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { if h.dockerClient == nil || db.Provider == "external" { return db } status, err := h.resolveManagedRuntimeStatus(ctx, db) if err != nil { return db } if status == db.Status { return db } db.Status = status updateCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = h.queries.SetDatabaseServiceStatusByID(updateCtx, sqlcdb.SetDatabaseServiceStatusByIDParams{ Status: status, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: db.ID, }) return db } func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db DatabaseService) (string, error) { inspect, err := h.dockerClient.GetContainer(ctx, managedDatabaseContainerName(db.ID)) if err != nil { if !isDockerNotFoundError(err) { return "", err } // During startup provision there can be a short gap before the runtime exists. if db.Status == "building" && time.Since(db.UpdatedAt) < 2*time.Minute { return "building", nil } if db.Status == "stopped" { return "stopped", nil } return "error", nil } if inspect.State == nil { return db.Status, nil } if inspect.State.Running { return "running", nil } if inspect.State.Restarting { return "building", nil } switch strings.ToLower(strings.TrimSpace(inspect.State.Status)) { case "created", "restarting": return "building", nil case "exited", "dead": if inspect.State.ExitCode != 0 && db.Status != "stopped" { return "error", nil } return "stopped", nil case "paused": return "stopped", nil default: return db.Status, nil } } func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService { svc := DatabaseService{ ID: row.ID, OwnerID: row.UserID, Name: row.Name, Type: row.Type, Status: row.Status, Version: row.Version, Plan: row.Plan, Region: row.Region, BackupSchedule: databaseNullString(row.BackupSchedule), NextBackupAt: databaseNullTimePtr(row.NextBackupAt), Provider: row.Provider, PublicPort: row.PublicPort, BackupTargetID: databaseNullString(row.BackupTargetID), ConnectionURL: databaseNullString(row.ConnectionUrl), CreatedAt: databaseNullTime(row.CreatedAt), UpdatedAt: databaseNullTime(row.UpdatedAt), } if svc.Provider == "external" { svc.External = &DatabaseExternalRef{ Host: databaseNullString(row.ExternalHost), Port: int(row.ExternalPort.Int32), Database: databaseNullString(row.ExternalName), Username: databaseNullString(row.ExternalUsername), HasPassword: row.ExternalPassword != "", SSL: row.ExternalSsl, } svc.externalPasswordEnc = row.ExternalPassword } return svc } func databaseNullTime(value sql.NullTime) time.Time { if value.Valid { return value.Time } return time.Time{} } func databaseNullTimePtr(value sql.NullTime) *time.Time { if value.Valid { t := value.Time return &t } return nil } func databaseNullString(value sql.NullString) string { if value.Valid { return value.String } return "" } func (h *DatabaseHandler) getDefaultVersion(dbType string) string { if version, exists := databaseDefaultVersions[dbType]; exists { return version } return "latest" } func normalizeDatabaseType(dbType string) string { switch strings.ToLower(strings.TrimSpace(dbType)) { case "postgres", "postgresql", "pg": return "postgresql" case "redis": return "redis" case "dragonfly", "dragonflydb": return "dragonfly" case "mysql": return "mysql" case "mariadb": return "mariadb" case "mongo", "mongodb": return "mongodb" case "clickhouse": return "clickhouse" default: return strings.ToLower(strings.TrimSpace(dbType)) } } func isSupportedDatabasePlan(plan string) bool { switch strings.ToLower(strings.TrimSpace(plan)) { case "hobby", "starter", "standard", "business": return true default: return false } } func generateDatabaseID(name string) string { slug := sanitizeDBIdentifier(name, "database") randomPart := "000000" buf := make([]byte, 3) if _, err := rand.Read(buf); err == nil { randomPart = hex.EncodeToString(buf) } return fmt.Sprintf("db_%d_%s_%s", time.Now().Unix(), slug, randomPart) } func generateDatabaseBackupID(databaseID string) string { slug := sanitizeDBIdentifier(databaseID, "database") randomPart := "0000" buf := make([]byte, 2) if _, err := rand.Read(buf); err == nil { randomPart = hex.EncodeToString(buf) } return fmt.Sprintf("backup_%d_%s_%s", time.Now().Unix(), slug, randomPart) } func managedDatabaseBackupArchivePath(backupID string) string { return sanitizeDBIdentifier(backupID, "backup") + ".tar.gz" } func sanitizeBackupArchivePath(value string) string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, "/") value = strings.TrimSuffix(value, ".tar.gz") return sanitizeDBIdentifier(value, "backup") + ".tar.gz" } func sanitizeDBIdentifier(value, fallback string) string { value = strings.TrimSpace(strings.ToLower(value)) if value == "" { return fallback } var b strings.Builder lastWasSep := false for _, r := range value { isAlphaNum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') if isAlphaNum { b.WriteRune(r) lastWasSep = false continue } if !lastWasSep { b.WriteRune('_') lastWasSep = true } } sanitized := strings.Trim(b.String(), "_") if sanitized == "" { return fallback } if len(sanitized) > 40 { sanitized = sanitized[:40] } return sanitized } func managedDatabaseContainerName(databaseID string) string { name := strings.ReplaceAll(strings.ToLower(databaseID), "_", "-") name = strings.Trim(name, "-") if name == "" { name = "db" } full := managedDatabaseContainerPrefix + "-" + name if len(full) > 63 { full = full[:63] full = strings.TrimRight(full, "-") } return full } func managedDatabaseVolumeName(databaseID string) string { name := strings.ReplaceAll(strings.ToLower(databaseID), "_", "-") name = strings.Trim(name, "-") if name == "" { name = "db" } full := managedDatabaseVolumePrefix + "-" + name if len(full) > 63 { full = full[:63] full = strings.TrimRight(full, "-") } return full } type databaseRuntimePlan struct { Image string Port nat.Port Env []string Cmd []string DataDir string ConnectionURL func(hostPort string) string } func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) { h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{}) } // reprovisionManagedDatabase recreates a managed container so changed // runtime settings (public_port) take effect. The data volume persists. func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, dbType string) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if h.dockerClient != nil { _ = h.dockerClient.RemoveContainer(ctx, managedDatabaseContainerName(databaseID), true) } h.provisionDatabase(databaseID, databaseName, dbType) } func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if h.dockerClient == nil { _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{}) return } connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach) if err != nil { log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err) _ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{}) return } _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: connectionURL, Valid: true}) } func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status string, connectionURL sql.NullString) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return h.queries.SetDatabaseServiceStatusAndConnectionByID(ctx, sqlcdb.SetDatabaseServiceStatusAndConnectionByIDParams{ Status: status, ConnectionUrl: connectionURL, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, }) } func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) { plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables) if err != nil { return "", err } containerName := managedDatabaseContainerName(databaseID) volumeName := managedDatabaseVolumeName(databaseID) if err := h.ensureImage(ctx, plan.Image); err != nil { return "", err } if _, err := h.dockerClient.CreateVolume(ctx, docker.VolumeConfig{ Name: volumeName, Labels: map[string]string{ "containr.managed": "true", "containr.database": databaseID, }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already exists") { return "", fmt.Errorf("failed to create volume: %w", err) } } hostIP := "127.0.0.1" if row, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && row.PublicPort { hostIP = "0.0.0.0" } portBindings := nat.PortMap{ plan.Port: []nat.PortBinding{{HostIP: hostIP, HostPort: ""}}, } exposedPorts := nat.PortSet{plan.Port: struct{}{}} containerID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{ Name: containerName, Image: plan.Image, Cmd: plan.Cmd, Env: plan.Env, RestartPolicy: "unless-stopped", ExposedPorts: exposedPorts, PortBindings: portBindings, Mounts: []mount.Mount{ { Type: mount.TypeVolume, Source: volumeName, Target: plan.DataDir, }, }, Labels: map[string]string{ "containr.managed": "true", "containr.database": databaseID, "containr.type": dbType, }, NetworkMode: "bridge", }) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "already in use") { _ = h.dockerClient.RemoveContainer(ctx, containerName, true) containerID, err = h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{ Name: containerName, Image: plan.Image, Cmd: plan.Cmd, Env: plan.Env, RestartPolicy: "unless-stopped", ExposedPorts: exposedPorts, PortBindings: portBindings, Mounts: []mount.Mount{ {Type: mount.TypeVolume, Source: volumeName, Target: plan.DataDir}, }, Labels: map[string]string{ "containr.managed": "true", "containr.database": databaseID, "containr.type": dbType, }, NetworkMode: "bridge", }) } if err != nil { return "", fmt.Errorf("failed to create container: %w", err) } } if err := h.dockerClient.StartContainer(ctx, containerID); err != nil { return "", fmt.Errorf("failed to start container: %w", err) } // Graph-template deploys attach the database to the project network so // sibling services resolve it by alias ({{service..host}}). if attach.ProjectID != "" && attach.Alias != "" { projectNet := deployment.ProjectNetworkName(attach.ProjectID) if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{ Aliases: []string{attach.Alias}, }); err != nil { log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err) } } hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port) if err != nil { return "", err } if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil { return "", err } return plan.ConnectionURL(hostPort), nil } func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containerID string, port nat.Port) (string, error) { deadline := time.Now().Add(20 * time.Second) for time.Now().Before(deadline) { inspect, err := h.dockerClient.GetContainer(ctx, containerID) if err != nil { return "", fmt.Errorf("failed to inspect container: %w", err) } if inspect.NetworkSettings != nil { if bindings, ok := inspect.NetworkSettings.Ports[port]; ok && len(bindings) > 0 { hostPort := strings.TrimSpace(bindings[0].HostPort) if hostPort != "" { return hostPort, nil } } } select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(300 * time.Millisecond): } } return "", fmt.Errorf("published host port not available") } // waitForDatabaseReadiness probes the database's TCP port until it accepts a // connection. The published port lives on host loopback, which a containerized // backend cannot reach; in that case the database is additionally attached to // the backend's own network and probed by container IP. func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error { probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" { if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil { log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err) } if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" { probeAddr = net.JoinHostPort(ip, containerPort) } } deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond) if err == nil { _ = conn.Close() return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(300 * time.Millisecond): } } return fmt.Errorf("database runtime did not become reachable on %s", probeAddr) } func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string { inspect, err := h.dockerClient.GetContainer(ctx, containerID) if err != nil || inspect.NetworkSettings == nil { return "" } if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil { return strings.TrimSpace(ep.IPAddress) } return "" } func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error { if _, err := h.dockerClient.GetImageInfo(ctx, imageRef); err == nil { return nil } reader, err := h.dockerClient.PullImage(ctx, imageRef, registry.AuthConfig{}) if err != nil { return fmt.Errorf("failed to pull image %q: %w", imageRef, err) } defer reader.Close() _, _ = io.Copy(io.Discard, reader) return nil } func buildDatabaseRuntimePlan(dbType, databaseName string, runtimeVariables map[string]string) (databaseRuntimePlan, error) { databaseName = sanitizeDBIdentifier(databaseName, "app") newPassword := func() (string, error) { buf := make([]byte, 12) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } safeUser := func(v string) string { return url.QueryEscape(v) } safePass := func(v string) string { return url.QueryEscape(v) } variable := func(key, fallback string) string { if runtimeVariables == nil { return fallback } if value, exists := runtimeVariables[key]; exists { value = strings.TrimSpace(value) if value != "" { return value } } return fallback } passwordVariable := func(key string) (string, error) { if runtimeVariables != nil { if value, exists := runtimeVariables[key]; exists { value = strings.TrimSpace(value) if value != "" { return value, nil } } } return newPassword() } switch dbType { case "postgresql": user := sanitizeDBIdentifier(variable("POSTGRES_USER", "app"), "app") dbName := sanitizeDBIdentifier(variable("POSTGRES_DB", databaseName), databaseName) password, err := passwordVariable("POSTGRES_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "postgres:16-alpine", Port: nat.Port("5432/tcp"), DataDir: "/var/lib/postgresql/data", Env: []string{ "POSTGRES_DB=" + dbName, "POSTGRES_USER=" + user, "POSTGRES_PASSWORD=" + password, }, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("postgresql://%s:%s@localhost:%s/%s?sslmode=disable", safeUser(user), safePass(password), hostPort, dbName) }, }, nil case "redis": password, err := passwordVariable("REDIS_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "redis:7-alpine", Port: nat.Port("6379/tcp"), DataDir: "/data", Cmd: []string{"redis-server", "--appendonly", "yes", "--requirepass", password}, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("redis://default:%s@localhost:%s/0", safePass(password), hostPort) }, }, nil case "dragonfly": password, err := passwordVariable("DRAGONFLY_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "docker.dragonflydb.io/dragonflydb/dragonfly:latest", Port: nat.Port("6379/tcp"), DataDir: "/data", Cmd: []string{"--requirepass", password}, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("redis://default:%s@localhost:%s/0", safePass(password), hostPort) }, }, nil case "mysql": user := sanitizeDBIdentifier(variable("MYSQL_USER", "app"), "app") dbName := sanitizeDBIdentifier(variable("MYSQL_DATABASE", databaseName), databaseName) password, err := passwordVariable("MYSQL_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } rootPassword, err := passwordVariable("MYSQL_ROOT_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "mysql:8.4", Port: nat.Port("3306/tcp"), DataDir: "/var/lib/mysql", Env: []string{ "MYSQL_DATABASE=" + dbName, "MYSQL_USER=" + user, "MYSQL_PASSWORD=" + password, "MYSQL_ROOT_PASSWORD=" + rootPassword, }, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("mysql://%s:%s@localhost:%s/%s", safeUser(user), safePass(password), hostPort, dbName) }, }, nil case "mariadb": user := sanitizeDBIdentifier(variable("MARIADB_USER", "app"), "app") dbName := sanitizeDBIdentifier(variable("MARIADB_DATABASE", databaseName), databaseName) password, err := passwordVariable("MARIADB_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } rootPassword, err := passwordVariable("MARIADB_ROOT_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "mariadb:11", Port: nat.Port("3306/tcp"), DataDir: "/var/lib/mysql", Env: []string{ "MARIADB_DATABASE=" + dbName, "MARIADB_USER=" + user, "MARIADB_PASSWORD=" + password, "MARIADB_ROOT_PASSWORD=" + rootPassword, }, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("mysql://%s:%s@localhost:%s/%s", safeUser(user), safePass(password), hostPort, dbName) }, }, nil case "mongodb": user := sanitizeDBIdentifier(variable("MONGO_INITDB_ROOT_USERNAME", "admin"), "admin") dbName := sanitizeDBIdentifier(variable("MONGO_INITDB_DATABASE", databaseName), databaseName) password, err := passwordVariable("MONGO_INITDB_ROOT_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "mongo:7", Port: nat.Port("27017/tcp"), DataDir: "/data/db", Env: []string{ "MONGO_INITDB_ROOT_USERNAME=" + user, "MONGO_INITDB_ROOT_PASSWORD=" + password, "MONGO_INITDB_DATABASE=" + dbName, }, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("mongodb://%s:%s@localhost:%s/%s?authSource=admin", safeUser(user), safePass(password), hostPort, dbName) }, }, nil case "clickhouse": user := sanitizeDBIdentifier(variable("CLICKHOUSE_USER", "default"), "default") dbName := sanitizeDBIdentifier(variable("CLICKHOUSE_DB", databaseName), databaseName) password, err := passwordVariable("CLICKHOUSE_PASSWORD") if err != nil { return databaseRuntimePlan{}, err } return databaseRuntimePlan{ Image: "clickhouse/clickhouse-server:24.8", Port: nat.Port("8123/tcp"), DataDir: "/var/lib/clickhouse", Env: []string{ "CLICKHOUSE_DB=" + dbName, "CLICKHOUSE_USER=" + user, "CLICKHOUSE_PASSWORD=" + password, }, ConnectionURL: func(hostPort string) string { return fmt.Sprintf("http://%s:%s@localhost:%s/%s", safeUser(user), safePass(password), hostPort, dbName) }, }, nil default: return databaseRuntimePlan{}, fmt.Errorf("unsupported database type: %s", dbType) } } func (h *DatabaseHandler) startManagedDatabase(databaseID string) error { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() if err := h.dockerClient.StartContainer(ctx, managedDatabaseContainerName(databaseID)); err != nil { if isDockerNotFoundError(err) { return nil } return err } return nil } func (h *DatabaseHandler) stopManagedDatabase(databaseID string) error { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() timeout := 15 * time.Second if err := h.dockerClient.StopContainer(ctx, managedDatabaseContainerName(databaseID), &timeout); err != nil { if isDockerNotFoundError(err) { return nil } return err } return nil } func (h *DatabaseHandler) restartManagedDatabase(databaseID string) error { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() timeout := 15 * time.Second if err := h.dockerClient.RestartContainer(ctx, managedDatabaseContainerName(databaseID), &timeout); err != nil { if isDockerNotFoundError(err) { return nil } return err } return nil } func (h *DatabaseHandler) deleteManagedDatabaseRuntime(databaseID string) error { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() containerName := managedDatabaseContainerName(databaseID) volumeName := managedDatabaseVolumeName(databaseID) stopTimeout := 10 * time.Second if err := h.dockerClient.StopContainer(ctx, containerName, &stopTimeout); err != nil && !isDockerNotFoundError(err) { return err } if err := h.dockerClient.RemoveContainer(ctx, containerName, true); err != nil && !isDockerNotFoundError(err) { return err } if err := h.dockerClient.RemoveVolume(ctx, volumeName, true); err != nil && !isDockerNotFoundError(err) { return err } return nil } func isDockerNotFoundError(err error) bool { if err == nil { return false } lower := strings.ToLower(err.Error()) return strings.Contains(lower, "not found") || strings.Contains(lower, "no such") } func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath string) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() if h.dockerClient == nil { _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) return } sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath) if err != nil { _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) return } if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil { if key, err := h.uploadBackupToTarget(ctx, dbRow, backupPath, backupID); err != nil { log.Printf("containr: backup %s remote upload failed: %v (local archive kept)", backupID, err) } else if key != "" { log.Printf("containr: backup %s shipped to %s", backupID, key) } } _ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true) } func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() if h.dockerClient == nil { _ = h.setDatabaseStatus(databaseID, "error") return } // Restore the archive from the configured S3 target when the local copy // is gone (fresh node, pruned volume) — local takes precedence. if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && dbRow.BackupTargetID.Valid && dbRow.BackupTargetID.String != "" { if ok, _ := h.backupArchiveExists(ctx, backupPath); !ok { if b, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(ctx, sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ ID: backupID, DatabaseID: databaseID, UserID: dbRow.UserID, }); err == nil && b.RemoteKey.Valid && b.RemoteKey.String != "" { if err := h.downloadBackupToVolume(ctx, dbRow, b.RemoteKey.String, backupPath); err != nil { log.Printf("containr: remote restore fetch failed for backup %s: %v", backupID, err) _ = h.setDatabaseStatus(databaseID, "error") return } } } } _ = h.setDatabaseStatus(databaseID, "building") _ = h.stopManagedDatabase(databaseID) if err := h.restoreDatabaseVolumeSnapshot(ctx, databaseID, backupPath); err != nil { _ = h.setDatabaseStatus(databaseID, "error") return } if err := h.startManagedDatabase(databaseID); err != nil { _ = h.setDatabaseStatus(databaseID, "error") return } _ = h.setDatabaseStatus(databaseID, "running") } func (h *DatabaseHandler) setDatabaseStatus(databaseID, status string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return h.queries.SetDatabaseServiceStatusByID(ctx, sqlcdb.SetDatabaseServiceStatusByIDParams{ Status: status, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: databaseID, }) } func (h *DatabaseHandler) setDatabaseBackupStatus(backupID, status, size string, markCompleted bool) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() completedAt := sql.NullTime{} if markCompleted { completedAt = sql.NullTime{Time: time.Now(), Valid: true} } return h.queries.SetDatabaseBackupStatusByID(ctx, sqlcdb.SetDatabaseBackupStatusByIDParams{ Status: status, Size: size, CompletedAt: completedAt, ID: backupID, }) } func (h *DatabaseHandler) snapshotDatabaseVolume(ctx context.Context, databaseID, backupPath string) (string, error) { sourceVolume := managedDatabaseVolumeName(databaseID) if err := h.ensureImage(ctx, managedDatabaseBackupImage); err != nil { return "", err } if _, err := h.dockerClient.CreateVolume(ctx, docker.VolumeConfig{Name: managedDatabaseBackupVolume}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already exists") { return "", fmt.Errorf("failed to create backup volume: %w", err) } archive := sanitizeBackupArchivePath(backupPath) if archive == "" { return "", fmt.Errorf("backup path is required") } cmd := []string{"sh", "-c", fmt.Sprintf("tar -czf /backup/%s -C /source .", archive)} containerID, err := h.runOneShotUtilityContainer(ctx, "backup", databaseID, managedDatabaseBackupImage, cmd, []mount.Mount{ {Type: mount.TypeVolume, Source: sourceVolume, Target: "/source", ReadOnly: true}, {Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup"}, }) if err != nil { return "", err } defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }() sizeLabel, err := h.estimateBackupArchiveSize(ctx, archive) if err != nil { return "unknown", nil } return sizeLabel, nil } func (h *DatabaseHandler) restoreDatabaseVolumeSnapshot(ctx context.Context, databaseID, backupPath string) error { targetVolume := managedDatabaseVolumeName(databaseID) archive := sanitizeBackupArchivePath(backupPath) if archive == "" { return fmt.Errorf("backup path is required") } cmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p /target && rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; tar -xzf /backup/%s -C /target", archive)} containerID, err := h.runOneShotUtilityContainer(ctx, "restore", databaseID, managedDatabaseBackupImage, cmd, []mount.Mount{ {Type: mount.TypeVolume, Source: targetVolume, Target: "/target"}, {Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup", ReadOnly: true}, }) if err != nil { return err } defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }() return nil } func (h *DatabaseHandler) estimateBackupArchiveSize(ctx context.Context, archivePath string) (string, error) { archive := sanitizeBackupArchivePath(archivePath) cmd := []string{"sh", "-c", fmt.Sprintf("wc -c < /backup/%s", archive)} containerID, err := h.runOneShotUtilityContainer(ctx, "backup-size", "shared", managedDatabaseBackupImage, cmd, []mount.Mount{ {Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup", ReadOnly: true}, }) if err != nil { return "", err } defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }() logReader, err := h.dockerClient.GetContainerLogs(ctx, containerID, docker.LogOptions{ Stdout: true, Stderr: false, Follow: false, Tail: "1", }) if err != nil { return "", err } defer logReader.Close() raw, err := io.ReadAll(logReader) if err != nil { return "", err } clean := strings.TrimSpace(strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { return r } if r == '\n' || r == '\r' || r == ' ' || r == '\t' { return r } return -1 }, string(raw))) fields := strings.Fields(clean) if len(fields) == 0 { return "", fmt.Errorf("unable to parse backup size") } sizeBytes, err := strconv.ParseInt(fields[0], 10, 64) if err != nil { return "", err } return humanReadableBytes(sizeBytes), nil } func (h *DatabaseHandler) runOneShotUtilityContainer( ctx context.Context, prefix string, databaseID string, image string, cmd []string, mounts []mount.Mount, ) (string, error) { containerName := h.generateUtilityContainerName(prefix, databaseID) containerID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{ Name: containerName, Image: image, Cmd: cmd, RestartPolicy: "no", Mounts: mounts, Labels: map[string]string{ "containr.managed": "true", "containr.utility": prefix, }, NetworkMode: "none", }) if err != nil { return "", fmt.Errorf("failed to create utility container: %w", err) } if err := h.dockerClient.StartContainer(ctx, containerID); err != nil { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) return containerID, fmt.Errorf("failed to start utility container: %w", err) } if err := h.waitForContainerExit(ctx, containerID, 10*time.Minute); err != nil { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) return containerID, err } return containerID, nil } func (h *DatabaseHandler) waitForContainerExit(ctx context.Context, containerID string, timeout time.Duration) error { waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() for { inspect, err := h.dockerClient.GetContainer(waitCtx, containerID) if err != nil { return fmt.Errorf("failed to inspect utility container: %w", err) } if inspect.State != nil && !inspect.State.Running { if inspect.State.ExitCode != 0 { return fmt.Errorf("utility container exited with code %d", inspect.State.ExitCode) } return nil } select { case <-waitCtx.Done(): return waitCtx.Err() case <-time.After(300 * time.Millisecond): } } } func (h *DatabaseHandler) generateUtilityContainerName(prefix, databaseID string) string { suffix := "0000" buf := make([]byte, 2) if _, err := rand.Read(buf); err == nil { suffix = hex.EncodeToString(buf) } name := fmt.Sprintf("containr-%s-%s-%s", sanitizeDBIdentifier(prefix, "job"), sanitizeDBIdentifier(databaseID, "db"), suffix) name = strings.ReplaceAll(name, "_", "-") if len(name) > 63 { name = name[:63] } return strings.Trim(name, "-") } func humanReadableBytes(size int64) string { if size <= 0 { return "0 B" } units := []string{"B", "KB", "MB", "GB", "TB"} value := float64(size) unit := 0 for value >= 1024 && unit < len(units)-1 { value /= 1024 unit++ } return fmt.Sprintf("%.2f %s", value, units[unit]) }