From 5dc9b5f115091c10c30d413920f7fabe032e2099 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 8 Oct 2026 22:09:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20external=20databases=20=E2=80=94=20regi?= =?UTF-8?q?ster,=20probe,=20encrypted=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Databases hosted outside Containr can now be registered alongside managed ones. The connection is probed before storing (protocol-level for postgres/redis-family via real drivers, TCP dial for the rest), credentials are encrypted at rest and never returned, and external rows skip managed lifecycle actions, backups, and Docker metrics. - POST /databases/register-external — probe + create - POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error) - POST /databases/{id}/test-connection — re-probe stored credentials - provider/external fields on database services; privileged reads get a composed connection URL with loopback hosts rewritten to the caller's - CLI: databases register + test-connection; MCP: 3 new tools - UI: managed/external toggle in the create modal, external badge and host display, per-row test button, backups section hidden - Migration widens the plan CHECK to admit 'external' Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/backend/internal/api/databases.go | 89 ++++- app/backend/internal/api/dbexternal.go | 319 ++++++++++++++++++ app/backend/internal/api/dbexternal_test.go | 97 ++++++ app/backend/internal/api/routes.go | 3 + .../internal/cli/commands/databases.go | 89 ++++- .../internal/database/sqlcdb/databases.sql.go | 82 ++++- .../internal/database/sqlcdb/models.go | 49 ++- .../internal/database/sqlcdb/querier.go | 1 + app/backend/internal/mcpserver/server.go | 3 + .../20261013000000_external_databases.sql | 33 ++ app/backend/sqlc/queries/databases.sql | 14 +- app/backend/sqlc/schema.sql | 7 + app/frontend/src/features/secondary/pages.tsx | 199 ++++++++++- app/frontend/src/generated/api-types.ts | 176 ++++++++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 33 ++ docs/api/openapi.yaml | 123 +++++++ docs/guides/CLI.md | 2 + llms.txt | 1 + 19 files changed, 1274 insertions(+), 48 deletions(-) create mode 100644 app/backend/internal/api/dbexternal.go create mode 100644 app/backend/internal/api/dbexternal_test.go create mode 100644 app/backend/migrations_goose/20261013000000_external_databases.sql diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index d395c7d..9053e78 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -4,6 +4,7 @@ import ( "archive/tar" "containr/internal/database/sqlcdb" "containr/internal/docker" + "containr/internal/secrets" "context" "crypto/rand" "database/sql" @@ -51,12 +52,30 @@ type DatabaseService struct { 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 + 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 @@ -467,21 +486,20 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) { databaseID := c.Param("id") userID = h.effectiveUserID(c, userID, databaseID) - exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ + 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 !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) - return - } - - if h.dockerClient != nil { + 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 @@ -515,17 +533,24 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) { return } - exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ + 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 !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + 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 } @@ -619,6 +644,13 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) { 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)) @@ -659,17 +691,23 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) { return } - exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{ + 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 !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"}) + 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 } @@ -787,7 +825,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) { } func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics { - if h.dockerClient == nil { + if h.dockerClient == nil || db.Provider == "external" { return h.generateMockMetrics() } @@ -945,7 +983,11 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings { // 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) } @@ -987,7 +1029,7 @@ func rewriteLoopbackURL(rawURL, host string) string { } func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { - if h.dockerClient == nil { + if h.dockerClient == nil || db.Provider == "external" { return db } @@ -1054,7 +1096,7 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da } func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService { - return DatabaseService{ + svc := DatabaseService{ ID: row.ID, OwnerID: row.UserID, Name: row.Name, @@ -1065,10 +1107,23 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService { Region: row.Region, BackupSchedule: databaseNullString(row.BackupSchedule), NextBackupAt: databaseNullTimePtr(row.NextBackupAt), + Provider: row.Provider, 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 { diff --git a/app/backend/internal/api/dbexternal.go b/app/backend/internal/api/dbexternal.go new file mode 100644 index 0000000..1c97fad --- /dev/null +++ b/app/backend/internal/api/dbexternal.go @@ -0,0 +1,319 @@ +package api + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "containr/internal/database/sqlcdb" + "containr/internal/secrets" + + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" +) + +// externalDatabaseRequest is the shared payload for registering an external +// database and for ad-hoc connection tests. +type externalDatabaseRequest struct { + Name string `json:"name"` + Type string `json:"type" binding:"required"` + Host string `json:"host" binding:"required"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + SSL bool `json:"ssl"` +} + +var externalDefaultPorts = map[string]int{ + "postgresql": 5432, + "mysql": 3306, + "mariadb": 3306, + "mongodb": 27017, + "redis": 6379, + "dragonfly": 6379, + "clickhouse": 9000, +} + +// externalConnectionURL composes a DSN for display/bind use. The password is +// embedded only on privileged reads — the value is decrypted at that point. +func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string { + host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port)) + creds := "" + if ext.Username != "" { + creds = url.QueryEscape(ext.Username) + if password != "" { + creds += ":" + url.QueryEscape(password) + } + creds += "@" + } + switch dbType { + case "postgresql": + u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database) + if ext.SSL { + u += "?sslmode=require" + } + return u + case "mysql", "mariadb": + return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database) + case "mongodb": + u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database) + if ext.SSL { + u += "?tls=true" + } + return u + case "redis", "dragonfly": + scheme := "redis" + if ext.SSL { + scheme = "rediss" + } + // Redis auth is password-only; username support is ACL-era. + if password != "" { + creds = ":" + url.QueryEscape(password) + "@" + if ext.Username != "" { + creds = url.QueryEscape(ext.Username) + creds + } + } + return fmt.Sprintf("%s://%s%s", scheme, creds, host) + case "clickhouse": + return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database) + } + return fmt.Sprintf("%s://%s%s", dbType, creds, host) +} + +// probeExternalDatabase verifies reachability. postgres and redis-family get +// real protocol checks via the vendored drivers; everything else gets a TCP +// dial — enough to catch typos and firewall issues without new dependencies. +func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) { + addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port)) + dialer := &net.Dialer{Timeout: 5 * time.Second} + start := time.Now() + + switch req.Type { + case "postgresql": + sslmode := "disable" + if req.SSL { + sslmode = "require" + } + dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5", + url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr, + url.QueryEscape(req.Database), sslmode) + conn, err := sql.Open("postgres", dsn) + if err != nil { + return 0, err + } + defer conn.Close() + if err := conn.PingContext(ctx); err != nil { + return 0, err + } + case "redis", "dragonfly": + client := redis.NewClient(&redis.Options{ + Addr: addr, + Username: req.Username, + Password: req.Password, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + TLSConfig: redisTLSConfig(req.SSL, req.Host), + }) + defer client.Close() + if err := client.Ping(ctx).Err(); err != nil { + return 0, err + } + default: + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return 0, err + } + conn.Close() + } + return time.Since(start), nil +} + +func redisTLSConfig(enabled bool, host string) *tls.Config { + if !enabled { + return nil + } + return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12} +} + +// handleTestDatabaseConnection probes arbitrary connection parameters — used +// by the register form's "test" button and by agents before registering. +func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) { + if _, ok := requireAuthenticatedUserID(c); !ok { + return + } + var req externalDatabaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + if err := normalizeExternalRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + latency, err := probeExternalDatabase(ctx, req) + if err != nil { + c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()}) +} + +// TestDatabaseConnectionByID re-probes a registered external database using +// its stored (decrypted) credentials. +func (h *DatabaseHandler) TestDatabaseConnectionByID(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", "code": "NOT_FOUND"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"}) + return + } + if row.Provider != "external" { + c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"}) + return + } + + req := externalDatabaseRequest{ + Type: row.Type, + Host: databaseNullString(row.ExternalHost), + Port: int(row.ExternalPort.Int32), + Database: databaseNullString(row.ExternalName), + Username: databaseNullString(row.ExternalUsername), + Password: secrets.Decrypt(row.ExternalPassword), + SSL: row.ExternalSsl, + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + latency, probeErr := probeExternalDatabase(ctx, req) + if probeErr != nil { + c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()}) +} + +func normalizeExternalRequest(req *externalDatabaseRequest) error { + req.Type = normalizeDatabaseType(req.Type) + if _, ok := supportedDatabaseTypes[req.Type]; !ok { + return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type) + } + req.Host = strings.TrimSpace(req.Host) + if req.Host == "" { + return errors.New("host is required") + } + if req.Port <= 0 { + req.Port = externalDefaultPorts[req.Type] + } + if req.Port <= 0 || req.Port > 65535 { + return errors.New("port must be 1-65535") + } + return nil +} + +// RegisterExternalDatabase records a database hosted elsewhere — same list, +// same bind surface, no container. The connection is probed first so typos +// fail at registration, not at the first service bind. +func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + var req externalDatabaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"}) + return + } + if err := normalizeExternalRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) + return + } + + probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + if _, err := probeExternalDatabase(probeCtx, req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Connection check failed: %v", err), + "code": "DEPENDENCY_UNAVAILABLE", + }) + return + } + + count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{ + UserID: userID, + Name: req.Name, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"}) + return + } + if count > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"}) + return + } + + databaseID := generateDatabaseID(req.Name) + now := sql.NullTime{Time: time.Now(), Valid: true} + encPassword := "" + if req.Password != "" { + encPassword = secrets.Encrypt(req.Password) + } + if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{ + ID: databaseID, + UserID: userID, + Name: req.Name, + Type: req.Type, + Version: "", + Plan: "external", + Region: "external", + ExternalHost: sql.NullString{String: req.Host, Valid: true}, + ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true}, + ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""}, + ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""}, + ExternalPassword: encPassword, + ExternalSsl: req.SSL, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"}) + return + } + + LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{ + "name": req.Name, + "type": req.Type, + "host": req.Host, + }) + c.JSON(http.StatusCreated, gin.H{ + "id": databaseID, + "message": "External database registered", + "status": "running", + }) +} diff --git a/app/backend/internal/api/dbexternal_test.go b/app/backend/internal/api/dbexternal_test.go new file mode 100644 index 0000000..f32b3c6 --- /dev/null +++ b/app/backend/internal/api/dbexternal_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "context" + "net" + "testing" +) + +func TestNormalizeExternalRequestDefaults(t *testing.T) { + req := externalDatabaseRequest{Type: "PostgreSQL", Host: " db.example.com ", Port: 0} + if err := normalizeExternalRequest(&req); err != nil { + t.Fatalf("normalize failed: %v", err) + } + if req.Type != "postgresql" { + t.Fatalf("type not normalized: %q", req.Type) + } + if req.Host != "db.example.com" { + t.Fatalf("host not trimmed: %q", req.Host) + } + if req.Port != 5432 { + t.Fatalf("default port = %d, want 5432", req.Port) + } +} + +func TestNormalizeExternalRequestRejects(t *testing.T) { + cases := []externalDatabaseRequest{ + {Type: "sqlite", Host: "x"}, // unsupported type + {Type: "postgresql", Host: " "}, // empty host + {Type: "postgresql", Host: "h", Port: 70000}, // out of range + } + for i, req := range cases { + if err := normalizeExternalRequest(&req); err == nil { + t.Fatalf("case %d: expected error", i) + } + } +} + +func TestExternalConnectionURLMasking(t *testing.T) { + ext := &DatabaseExternalRef{ + Host: "db.example.com", + Port: 5432, + Database: "appdb", + Username: "svc", + SSL: true, + } + u := externalConnectionURL("postgresql", ext, "s3cret") + if u != "postgres://svc:s3cret@db.example.com:5432/appdb?sslmode=require" { + t.Fatalf("unexpected pg url: %s", u) + } + u = externalConnectionURL("postgresql", ext, "") + if u != "postgres://svc@db.example.com:5432/appdb?sslmode=require" { + t.Fatalf("unexpected passwordless url: %s", u) + } + + r := &DatabaseExternalRef{Host: "cache.internal", Port: 6379, SSL: true} + u = externalConnectionURL("redis", r, "pw") + if u != "rediss://:pw@cache.internal:6379" { + t.Fatalf("unexpected redis url: %s", u) + } +} + +func TestProbeExternalDatabaseTCP(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + // TCP fallback path — mysql uses a plain dial. + latency, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{ + Type: "mysql", Host: "127.0.0.1", Port: port, + }) + if err != nil { + t.Fatalf("probe should succeed against open listener: %v", err) + } + if latency <= 0 { + t.Fatalf("latency not recorded") + } + + // Closed port must fail fast. + if _, err := probeExternalDatabase(context.Background(), externalDatabaseRequest{ + Type: "mysql", Host: "127.0.0.1", Port: 1, + }); err == nil { + t.Fatalf("probe should fail against closed port") + } +} + +func TestRedisTLSConfig(t *testing.T) { + if redisTLSConfig(false, "h") != nil { + t.Fatalf("disabled ssl must produce nil tls config") + } + cfg := redisTLSConfig(true, "db.example.com") + if cfg == nil || cfg.ServerName != "db.example.com" { + t.Fatalf("expected ServerName passthrough, got %+v", cfg) + } +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index c472239..f15ac47 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -383,6 +383,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.POST("/builds/plan", buildHandler.GetBuildPlan) authed.POST("/databases", databaseHandler.CreateDatabase) + authed.POST("/databases/register-external", databaseHandler.RegisterExternalDatabase) + authed.POST("/databases/test-connection", databaseHandler.TestDatabaseConnection) + authed.POST("/databases/:id/test-connection", databaseHandler.TestDatabaseConnectionByID) authed.PUT("/databases/:id", databaseHandler.UpdateDatabase) authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase) authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction) diff --git a/app/backend/internal/cli/commands/databases.go b/app/backend/internal/cli/commands/databases.go index 92d80ea..621ce3d 100644 --- a/app/backend/internal/cli/commands/databases.go +++ b/app/backend/internal/cli/commands/databases.go @@ -196,9 +196,96 @@ var dbDownloadCmd = &cobra.Command{ }, } +var dbRegisterCmd = &cobra.Command{ + Use: "register ", + Short: "Register an external database (probes the connection first)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body := map[string]interface{}{"name": args[0]} + for _, f := range []string{"type", "host", "database", "username", "password"} { + if v, _ := cmd.Flags().GetString(f); v != "" { + body[f] = v + } + } + if v, _ := cmd.Flags().GetInt("port"); v > 0 { + body["port"] = v + } + if v, _ := cmd.Flags().GetBool("ssl"); v { + body["ssl"] = true + } + data, err := c.Do("POST", "/databases/register-external", body) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + m, _ := unwrapObject(data) + fmt.Printf("Registered external database %s (%s)\n", args[0], str(m, "id")) + return nil + }, +} + +var dbTestConnCmd = &cobra.Command{ + Use: "test-connection []", + Short: "Probe a database connection — by id, or with --type/--host flags", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + var data []byte + if len(args) == 1 { + data, err = c.Do("POST", "/databases/"+args[0]+"/test-connection", map[string]interface{}{}) + } else { + body := map[string]interface{}{} + for _, f := range []string{"type", "host", "database", "username", "password"} { + if v, _ := cmd.Flags().GetString(f); v != "" { + body[f] = v + } + } + if v, _ := cmd.Flags().GetInt("port"); v > 0 { + body["port"] = v + } + if v, _ := cmd.Flags().GetBool("ssl"); v { + body["ssl"] = true + } + data, err = c.Do("POST", "/databases/test-connection", body) + } + if err != nil { + return err + } + m, _ := unwrapObject(data) + if str(m, "ok") == "false" { + return &APIError{Message: "connection failed: " + str(m, "error"), ExitCode: ExitError} + } + if JSONMode() { + PrintRaw(data) + return nil + } + fmt.Printf("Connection ok (%sms)\n", str(m, "latency_ms")) + return nil + }, +} + func init() { dbCreateCmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse") dbCreateCmd.Flags().String("version", "", "engine version tag") dbCreateCmd.Flags().String("storage-size", "", "volume size (e.g. 10Gi)") - DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd) + for _, cmd := range []*cobra.Command{dbRegisterCmd, dbTestConnCmd} { + cmd.Flags().String("type", "postgres", "postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse") + cmd.Flags().String("host", "", "database host") + cmd.Flags().Int("port", 0, "database port (default per type)") + cmd.Flags().String("database", "", "database name") + cmd.Flags().String("username", "", "database username") + cmd.Flags().String("password", "", "database password (stored encrypted on register)") + cmd.Flags().Bool("ssl", false, "require TLS") + } + DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd) } diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go index 48ac8e6..804e336 100644 --- a/app/backend/internal/database/sqlcdb/databases.sql.go +++ b/app/backend/internal/database/sqlcdb/databases.sql.go @@ -88,6 +88,52 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS return err } +const createExternalDatabaseService = `-- name: CreateExternalDatabaseService :exec +INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, + provider, external_host, external_port, external_name, external_username, external_password, external_ssl, + created_at, updated_at) +VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15) +` + +type CreateExternalDatabaseServiceParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Version string `json:"version"` + Plan string `json:"plan"` + Region string `json:"region"` + ExternalHost sql.NullString `json:"external_host"` + ExternalPort sql.NullInt32 `json:"external_port"` + ExternalName sql.NullString `json:"external_name"` + ExternalUsername sql.NullString `json:"external_username"` + ExternalPassword string `json:"external_password"` + ExternalSsl bool `json:"external_ssl"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error { + _, err := q.db.ExecContext(ctx, createExternalDatabaseService, + arg.ID, + arg.UserID, + arg.Name, + arg.Type, + arg.Version, + arg.Plan, + arg.Region, + arg.ExternalHost, + arg.ExternalPort, + arg.ExternalName, + arg.ExternalUsername, + arg.ExternalPassword, + arg.ExternalSsl, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + const databaseServiceExistsByIDAndUser = `-- name: DatabaseServiceExistsByIDAndUser :one SELECT EXISTS( SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2 @@ -150,7 +196,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a } const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at FROM database_services WHERE id = $1 ` @@ -170,6 +216,13 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa &i.ConnectionUrl, &i.BackupSchedule, &i.NextBackupAt, + &i.Provider, + &i.ExternalHost, + &i.ExternalPort, + &i.ExternalName, + &i.ExternalUsername, + &i.ExternalPassword, + &i.ExternalSsl, &i.CreatedAt, &i.UpdatedAt, ) @@ -177,7 +230,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa } const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at FROM database_services WHERE id = $1 AND user_id = $2 ` @@ -202,6 +255,13 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData &i.ConnectionUrl, &i.BackupSchedule, &i.NextBackupAt, + &i.Provider, + &i.ExternalHost, + &i.ExternalPort, + &i.ExternalName, + &i.ExternalUsername, + &i.ExternalPassword, + &i.ExternalSsl, &i.CreatedAt, &i.UpdatedAt, ) @@ -222,7 +282,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str } const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at FROM database_services ORDER BY created_at DESC ` @@ -248,6 +308,13 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic &i.ConnectionUrl, &i.BackupSchedule, &i.NextBackupAt, + &i.Provider, + &i.ExternalHost, + &i.ExternalPort, + &i.ExternalName, + &i.ExternalUsername, + &i.ExternalPassword, + &i.ExternalSsl, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -311,7 +378,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg } const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.id, database_services.user_id, database_services.name, database_services.type, database_services.status, database_services.version, database_services.plan, database_services.region, database_services.connection_url, database_services.backup_schedule, database_services.next_backup_at, database_services.provider, database_services.external_host, database_services.external_port, database_services.external_name, database_services.external_username, database_services.external_password, database_services.external_ssl, database_services.created_at, database_services.updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC @@ -338,6 +405,13 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) &i.ConnectionUrl, &i.BackupSchedule, &i.NextBackupAt, + &i.Provider, + &i.ExternalHost, + &i.ExternalPort, + &i.ExternalName, + &i.ExternalUsername, + &i.ExternalPassword, + &i.ExternalSsl, &i.CreatedAt, &i.UpdatedAt, ); err != nil { diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 585c59c..cd00e1d 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -80,19 +80,26 @@ type DatabaseBackup struct { } type DatabaseService struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - ConnectionUrl sql.NullString `json:"connection_url"` - BackupSchedule sql.NullString `json:"backup_schedule"` - NextBackupAt sql.NullTime `json:"next_backup_at"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Version string `json:"version"` + Plan string `json:"plan"` + Region string `json:"region"` + ConnectionUrl sql.NullString `json:"connection_url"` + BackupSchedule sql.NullString `json:"backup_schedule"` + NextBackupAt sql.NullTime `json:"next_backup_at"` + Provider string `json:"provider"` + ExternalHost sql.NullString `json:"external_host"` + ExternalPort sql.NullInt32 `json:"external_port"` + ExternalName sql.NullString `json:"external_name"` + ExternalUsername sql.NullString `json:"external_username"` + ExternalPassword string `json:"external_password"` + ExternalSsl bool `json:"external_ssl"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` } type Deployment struct { @@ -167,6 +174,17 @@ type ProjectMember struct { CreatedAt sql.NullTime `json:"created_at"` } +type Registry struct { + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"owner_id"` + Name string `json:"name"` + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type Service struct { ID uuid.UUID `json:"id"` Name string `json:"name"` @@ -204,6 +222,11 @@ type Service struct { Volumes json.RawMessage `json:"volumes"` MaintenanceMode bool `json:"maintenance_mode"` BasicAuthUsers string `json:"basic_auth_users"` + Builder string `json:"builder"` + CpuReserve string `json:"cpu_reserve"` + MemoryReserve string `json:"memory_reserve"` + StaticBuildCmd string `json:"static_build_cmd"` + StaticDir string `json:"static_dir"` } type ServiceDomain struct { diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 343dea9..dabb673 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -22,6 +22,7 @@ type Querier interface { CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error + CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 1e86475..90b45b3 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -99,6 +99,9 @@ func init() { register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false}) register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false}) register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false}) + register(spec{"containr_databases_register_external", "Register a database hosted elsewhere — probed first, password stored encrypted. Args: name (required), type (required), host (required), port, database, username, password, ssl.", "POST", "/databases/register-external", nil, []string{"name", "type", "host"}, false}) + register(spec{"containr_databases_test_connection", "Probe a DB connection ad-hoc. Args: type, host (required), port, database, username, password, ssl.", "POST", "/databases/test-connection", nil, []string{"type", "host"}, false}) + register(spec{"containr_databases_test_connection_by_id", "Re-probe a registered external database with its stored credentials. Args: id.", "POST", "/databases/{id}/test-connection", []string{"id"}, nil, false}) register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false}) register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false}) register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true}) diff --git a/app/backend/migrations_goose/20261013000000_external_databases.sql b/app/backend/migrations_goose/20261013000000_external_databases.sql new file mode 100644 index 0000000..22c6731 --- /dev/null +++ b/app/backend/migrations_goose/20261013000000_external_databases.sql @@ -0,0 +1,33 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE database_services + ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'managed', + ADD COLUMN IF NOT EXISTS external_host VARCHAR(255), + ADD COLUMN IF NOT EXISTS external_port INTEGER, + ADD COLUMN IF NOT EXISTS external_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS external_username VARCHAR(255), + ADD COLUMN IF NOT EXISTS external_password TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS external_ssl BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE database_services + DROP CONSTRAINT IF EXISTS database_services_plan_check, + ADD CONSTRAINT database_services_plan_check + CHECK (plan IN ('hobby', 'starter', 'standard', 'business', 'external')); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE database_services + DROP CONSTRAINT IF EXISTS database_services_plan_check, + ADD CONSTRAINT database_services_plan_check + CHECK (plan IN ('hobby', 'starter', 'standard', 'business')); + +ALTER TABLE database_services + DROP COLUMN IF EXISTS provider, + DROP COLUMN IF EXISTS external_host, + DROP COLUMN IF EXISTS external_port, + DROP COLUMN IF EXISTS external_name, + DROP COLUMN IF EXISTS external_username, + DROP COLUMN IF EXISTS external_password, + DROP COLUMN IF EXISTS external_ssl; +-- +goose StatementEnd diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql index 7bd1bd7..a3713b7 100644 --- a/app/backend/sqlc/queries/databases.sql +++ b/app/backend/sqlc/queries/databases.sql @@ -1,16 +1,16 @@ -- name: ListDatabaseServicesByUser :many -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.* FROM database_services WHERE user_id = $1 ORDER BY created_at DESC; -- name: ListAllDatabaseServices :many -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.* FROM database_services ORDER BY created_at DESC; -- name: GetDatabaseServiceByID :one -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.* FROM database_services WHERE id = $1; @@ -20,7 +20,7 @@ FROM database_services WHERE id = $1; -- name: GetDatabaseServiceByIDAndUser :one -SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at +SELECT database_services.* FROM database_services WHERE id = $1 AND user_id = $2; @@ -110,3 +110,9 @@ WHERE backup_schedule IS NOT NULL AND backup_schedule <> '' UPDATE database_services SET next_backup_at = $1 WHERE id = $2; + +-- name: CreateExternalDatabaseService :exec +INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, + provider, external_host, external_port, external_name, external_username, external_password, external_ssl, + created_at, updated_at) +VALUES ($1, $2, $3, $4, 'running', $5, $6, $7, 'external', $8, $9, $10, $11, $12, $13, $14, $15); diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 60f3e19..e6e5cf7 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -139,6 +139,13 @@ CREATE TABLE database_services ( connection_url TEXT, backup_schedule VARCHAR(100), next_backup_at TIMESTAMP WITH TIME ZONE, + provider VARCHAR(20) NOT NULL DEFAULT 'managed', + external_host VARCHAR(255), + external_port INTEGER, + external_name VARCHAR(255), + external_username VARCHAR(255), + external_password TEXT NOT NULL DEFAULT '', + external_ssl BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index acc401b..5be063d 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -30,6 +30,10 @@ import { createDatabaseBackup, restoreDatabaseBackup, createManagedDatabase, + registerExternalDatabase, + testDatabaseConnection, + testDatabaseConnectionByID, + type ExternalDatabaseInput, listServicesByProject, listServiceVariables, updateServiceVariables, @@ -1901,7 +1905,11 @@ export function DatabasesPage() { const [expandedId, setExpandedId] = useState(null); const [bindDb, setBindDb] = useState(null); const [createOpen, setCreateOpen] = useState(false); + const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed'); const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' }); + const [externalForm, setExternalForm] = useState({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false }); + const [externalProbe, setExternalProbe] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null); + const [externalTestResult, setExternalTestResult] = useState>({}); const [createError, setCreateError] = useState(null); const databasesQuery = useQuery({ queryKey: ['databases'], @@ -1929,6 +1937,40 @@ export function DatabasesPage() { setCreateError(error instanceof Error ? error.message : 'Failed to create database'); }, }); + const externalPayload = (): ExternalDatabaseInput => ({ + name: externalForm.name.trim(), + type: externalForm.type, + host: externalForm.host.trim(), + port: Number(externalForm.port) || undefined, + database: externalForm.database.trim() || undefined, + username: externalForm.username.trim() || undefined, + password: externalForm.password || undefined, + ssl: externalForm.ssl, + }); + const registerExternalMutation = useMutation({ + mutationFn: () => registerExternalDatabase(externalPayload()), + onSuccess: () => { + setCreateOpen(false); + setExternalForm({ name: '', type: 'postgresql', host: '', port: '', database: '', username: '', password: '', ssl: false }); + setExternalProbe(null); + setCreateError(null); + invalidate(); + }, + onError: (error) => { + setCreateError(error instanceof Error ? error.message : 'Failed to register database'); + }, + }); + const probeMutation = useMutation({ + mutationFn: () => testDatabaseConnection(externalPayload()), + onSuccess: (result) => setExternalProbe(result), + onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }), + }); + const testByIdMutation = useMutation({ + mutationFn: (id: string) => testDatabaseConnectionByID(id), + onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })), + onError: (error, id) => + setExternalTestResult((prev) => ({ ...prev, [id]: { ok: false, error: error instanceof Error ? error.message : 'probe failed' } })), + }); const actionMutation = useMutation({ mutationFn: ({ id, action }: { id: string; action: 'start' | 'stop' | 'restart' }) => databaseAction(id, action), @@ -2014,9 +2056,16 @@ export function DatabasesPage() {
{db.name} {db.type}{db.version ? ` ${db.version}` : ''} + {db.provider === 'external' && ( + external + )} {db.status}
-

{db.plan} · {db.region}

+

+ {db.provider === 'external' && db.external + ? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}` + : `${db.plan} · ${db.region}`} +

@@ -2044,6 +2093,25 @@ export function DatabasesPage() { {signedIn ? ( <> Actions + {db.provider === 'external' ? ( + <> + + {externalTestResult[db.id ?? ''] && ( + + {externalTestResult[db.id ?? '']?.ok + ? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)` + : `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`} + + )} + + ) : ( + <> + + )} + ))} + +

+ {createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'} +

setCreateForm((f) => ({ ...f, name: e.target.value }))} + value={createMode === 'managed' ? createForm.name : externalForm.name} + onChange={(e) => (createMode === 'managed' ? setCreateForm((f) => ({ ...f, name: e.target.value })) : setExternalForm((f) => ({ ...f, name: e.target.value })))} placeholder="my-postgres" className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" />
+ {createMode === 'external' ? ( + <> +
+
+ + +
+
+ + setExternalForm((f) => ({ ...f, host: e.target.value }))} + placeholder="db.example.com" + className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" + /> +
+
+
+
+ + setExternalForm((f) => ({ ...f, port: e.target.value }))} + placeholder="default" + className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" + /> +
+
+ + setExternalForm((f) => ({ ...f, database: e.target.value }))} + placeholder="optional" + className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" + /> +
+
+
+
+ + setExternalForm((f) => ({ ...f, username: e.target.value }))} + placeholder="optional" + className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" + /> +
+
+ + setExternalForm((f) => ({ ...f, password: e.target.value }))} + placeholder="optional" + className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]" + /> +
+
+ +
+ + {externalProbe && ( + + {externalProbe.ok ? `reachable (${externalProbe.latency_ms ?? 0}ms)` : `unreachable — ${externalProbe.error ?? 'probe failed'}`} + + )} +
+ + ) : (
@@ -2224,6 +2403,7 @@ export function DatabasesPage() { />
+ )} {createError ? (
{createError}
) : null} @@ -2238,12 +2418,15 @@ export function DatabasesPage() {
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 445812f..0fcbdee 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -3034,6 +3034,154 @@ export interface paths { patch?: never; trace?: never; }; + "/databases/register-external": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Register external database + * @description Record a database hosted elsewhere — connection is probed first (postgres/redis get protocol-level checks, others TCP). Password stored encrypted, never returned. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalDatabaseRequest"] & Record; + }; + }; + responses: { + /** @description External database registered */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation failed or connection probe failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Name already in use */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/databases/test-connection": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Probe a database connection + * @description Ad-hoc connectivity check — postgres via libpq ping, redis/dragonfly via PING, other engines via TCP dial. Always 200; `ok` carries the result. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ExternalDatabaseRequest"]; + }; + }; + responses: { + /** @description Probe result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + latency_ms?: number; + error?: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/databases/{id}/test-connection": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Re-probe a registered external database + * @description Uses the stored credentials. External databases only. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Probe result (`ok` carries the outcome) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Database not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/databases/{id}": { parameters: { query?: never; @@ -8940,6 +9088,12 @@ export interface components { region?: string; /** @description Cron expression for automatic backups, empty when unset */ backup_schedule?: string; + /** + * @description managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere + * @enum {string} + */ + provider?: "managed" | "external"; + external?: components["schemas"]["DatabaseExternal"]; /** * Format: date-time * @description Next scheduled backup run @@ -8975,6 +9129,28 @@ export interface components { /** @description Database region */ region: string; }; + DatabaseExternal: { + host?: string; + port?: number; + database?: string; + username?: string; + has_password?: boolean; + ssl?: boolean; + }; + ExternalDatabaseRequest: { + /** @description Display name (required for register, unused for probes) */ + name?: string; + /** @enum {string} */ + type: "postgresql" | "redis" | "mysql" | "mariadb" | "mongodb" | "clickhouse" | "dragonfly"; + host: string; + /** @description Defaults to the engine's standard port */ + port?: number; + database?: string; + username?: string; + /** @description Stored encrypted; never returned */ + password?: string; + ssl?: boolean; + }; UpdateDatabaseRequest: { /** @description Updated database name */ name?: string; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 437fccb..ca517d1 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/CLI.md", "section": "guides", "title": "Containr CLI", - "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 0684e40..6646eba 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1733,6 +1733,39 @@ export async function createManagedDatabase( return { id: payload.id, status: payload.status ?? 'building' }; } +export type ExternalDatabaseInput = { + name?: string; + type: string; + host: string; + port?: number; + database?: string; + username?: string; + password?: string; + ssl?: boolean; +}; + +export async function registerExternalDatabase(input: ExternalDatabaseInput): Promise<{ id: string }> { + const payload = await requestJson<{ id?: string }>('/databases/register-external', { + method: 'POST', + body: JSON.stringify(input), + }); + if (!payload.id) { + throw new ApiError('Database response is invalid', 500); + } + return { id: payload.id }; +} + +export async function testDatabaseConnection(input: ExternalDatabaseInput): Promise<{ ok: boolean; latency_ms?: number; error?: string }> { + return requestJson('/databases/test-connection', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function testDatabaseConnectionByID(id: string): Promise<{ ok: boolean; latency_ms?: number; error?: string }> { + return requestJson(`/databases/${encodeURIComponent(id)}/test-connection`, { method: 'POST' }); +} + export type ConnectGitRepositoryInput = components['schemas']['ConnectGitRepoRequest']; export type CreateGitWebhookInput = components['schemas']['CreateWebhookRequest']; export type GitWebhookEntity = components['schemas']['GitWebhook']; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8b03c5a..4aa13de 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2050,6 +2050,80 @@ paths: schema: $ref: '#/components/schemas/DatabaseCreateResponse' + /databases/register-external: + post: + tags: + - Databases + summary: Register external database + description: Record a database hosted elsewhere — connection is probed first (postgres/redis get protocol-level checks, others TCP). Password stored encrypted, never returned. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ExternalDatabaseRequest' + - type: object + required: [name] + responses: + '201': + description: External database registered + '400': + description: Validation failed or connection probe failed + '409': + description: Name already in use + + /databases/test-connection: + post: + tags: + - Databases + summary: Probe a database connection + description: Ad-hoc connectivity check — postgres via libpq ping, redis/dragonfly via PING, other engines via TCP dial. Always 200; `ok` carries the result. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalDatabaseRequest' + responses: + '200': + description: Probe result + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + latency_ms: + type: integer + error: + type: string + + /databases/{id}/test-connection: + post: + tags: + - Databases + summary: Re-probe a registered external database + description: Uses the stored credentials. External databases only. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Probe result (`ok` carries the outcome) + '404': + description: Database not found + /databases/{id}: get: tags: @@ -6241,6 +6315,12 @@ components: backup_schedule: type: string description: Cron expression for automatic backups, empty when unset + provider: + type: string + enum: [managed, external] + description: managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere + external: + $ref: '#/components/schemas/DatabaseExternal' next_backup_at: type: string format: date-time @@ -6283,6 +6363,49 @@ components: type: string description: Database region + DatabaseExternal: + type: object + properties: + host: + type: string + port: + type: integer + database: + type: string + username: + type: string + has_password: + type: boolean + ssl: + type: boolean + + ExternalDatabaseRequest: + type: object + required: + - type + - host + properties: + name: + type: string + description: Display name (required for register, unused for probes) + type: + type: string + enum: [postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly] + host: + type: string + port: + type: integer + description: Defaults to the engine's standard port + database: + type: string + username: + type: string + password: + type: string + description: Stored encrypted; never returned + ssl: + type: boolean + UpdateDatabaseRequest: type: object properties: diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 6490c6f..47c311e 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -48,6 +48,8 @@ logs [--follow] runtime logs (SSE follow) exec -- one-off container command variables list|set|unset env vars (secrets masked; --redeploy applies them) databases list|get|create|delete|action|backup|restore|download-backup +databases register register an external database (--type --host --port --database --username --password --ssl; probed before storing) +databases test-connection probe a connection — by id, or ad-hoc flags cron list|get|create|delete|trigger|executions templates list|get|create|delete|deploy nodes list|get|delete|tokens node agents + onboarding tokens (admin) diff --git a/llms.txt b/llms.txt index d5dbe8b..da0653c 100644 --- a/llms.txt +++ b/llms.txt @@ -21,6 +21,7 @@ Base URL: `https:///api/v1`. OpenAPI: `docs/api/openapi.yaml`. - **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`). - **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous. - **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential. +- **External databases**: `POST /databases/register-external` (probes first; `{name,type,host,port,database,username,password,ssl}`), `POST /databases/test-connection` (ad-hoc probe → `{ok,latency_ms,error}`), `POST /databases/{id}/test-connection` (re-probe with stored credentials). Rows carry `provider:"external"` + `external{host,port,database,username,has_password,ssl}`; passwords encrypted at rest, never returned. Lifecycle actions and backups reject external rows — no container exists. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. ## Core objects