package api import ( "containr/internal/database/sqlcdb" "containr/internal/docker" "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math" "net" "net/http" "net/url" "strconv" "strings" "time" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "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"` 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"` 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"` } // 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 } // 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"` } // DatabaseUpdateRequest represents a request to update a database type DatabaseUpdateRequest struct { Name string `json:"name,omitempty"` Plan string `json:"plan,omitempty"` } // 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 all database services for a user func (h *DatabaseHandler) GetDatabases(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID) 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 := mapDatabaseServiceListRow(row) db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil { db.Backups = backupConfig } else { db.Backups = h.generateMockBackupConfig() } db.Settings = h.generateMockSettings() db.ConnectionURL = h.resolveConnectionURL(db) 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, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") 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 fetch database"}) return } db := mapDatabaseServiceGetRow(row) db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil { db.Backups = backupConfig } else { db.Backups = h.generateMockBackupConfig() } db.Settings = h.generateMockSettings() db.ConnectionURL = h.resolveConnectionURL(db) c.JSON(http.StatusOK, db) } // 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, }) 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 RuntimeVariables map[string]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) err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{ ID: databaseID, UserID: userID, Name: name, Type: dbType, Status: "building", Version: version, Plan: plan, Region: region, 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) return databaseID, nil } // UpdateDatabase updates a database service func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { userID, ok := requireAuthenticatedUserID(c) if !ok { return } databaseID := c.Param("id") 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 == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } 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") exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) return } if 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") var req DatabaseActionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) 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") 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 } 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") var req DatabaseRestoreRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ ID: databaseID, UserID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"}) return } if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) 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", }) } func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics { if h.dockerClient == nil { 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, databaseID string) (DatabaseBackupConfig, error) { rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{ DatabaseID: databaseID, 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, }) if lastBackup == nil && row.Status == "completed" { t := createdAt lastBackup = &t } } var nextBackup *time.Time if lastBackup != nil { t := lastBackup.Add(24 * time.Hour) nextBackup = &t } return DatabaseBackupConfig{ Enabled: true, LastBackup: lastBackup, Retention: 30, NextBackup: nextBackup, Backups: backups, }, nil } func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig { return DatabaseBackupConfig{ Enabled: true, LastBackup: &time.Time{}, Retention: 30, NextBackup: &time.Time{}, Backups: []DatabaseBackup{ { ID: "backup_1", CreatedAt: time.Now().Add(-6 * time.Hour), Size: "245 MB", Status: "completed", }, { ID: "backup_2", CreatedAt: time.Now().Add(-24 * time.Hour), Size: "238 MB", Status: "completed", }, { ID: "backup_3", CreatedAt: time.Now().Add(-48 * time.Hour), Size: "241 MB", Status: "completed", }, }, } } func (h *DatabaseHandler) generateMockSettings() DatabaseSettings { return DatabaseSettings{ MaxConnections: 100, Timeout: 30, SSL: true, Logging: true, } } func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string { if strings.TrimSpace(db.ConnectionURL) != "" { return db.ConnectionURL } return h.generateConnectionURL(db) } func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string { safeName := sanitizeDBIdentifier(db.Name, "app") switch db.Type { case "postgresql": return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName) case "redis": return fmt.Sprintf("redis://%s.containr.local:6379", safeName) case "dragonfly": return fmt.Sprintf("redis://%s.containr.local:6379", safeName) case "mysql": return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName) case "mariadb": return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName) case "mongodb": return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName) case "clickhouse": return fmt.Sprintf("http://%s.containr.local:8123", safeName) default: return "" } } func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { if h.dockerClient == nil { 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 mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService { return DatabaseService{ ID: row.ID, Name: row.Name, Type: row.Type, Status: row.Status, Version: row.Version, Plan: row.Plan, Region: row.Region, ConnectionURL: databaseNullString(row.ConnectionUrl), CreatedAt: databaseNullTime(row.CreatedAt), UpdatedAt: databaseNullTime(row.UpdatedAt), } } func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService { return DatabaseService{ ID: row.ID, Name: row.Name, Type: row.Type, Status: row.Status, Version: row.Version, Plan: row.Plan, Region: row.Region, ConnectionURL: databaseNullString(row.ConnectionUrl), CreatedAt: databaseNullTime(row.CreatedAt), UpdatedAt: databaseNullTime(row.UpdatedAt), } } func databaseNullTime(value sql.NullTime) time.Time { if value.Valid { return value.Time } return time.Time{} } 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) } func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if h.dockerClient == nil { fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType}) _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""}) return } connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables) if err != nil { _ = 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) (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) } } portBindings := nat.PortMap{ plan.Port: []nat.PortBinding{{HostIP: "127.0.0.1", 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) } hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port) if err != nil { return "", err } if err := waitForRuntimePortReadiness(ctx, hostPort); 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") } func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error { addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", addr, 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", addr) } 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 } _ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true) } func (h *DatabaseHandler) restoreBackupProcess(databaseID, _ string, backupPath string) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() if h.dockerClient == nil { _ = 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]) }