feat: external databases — register, probe, encrypted credentials

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 22:09:42 +02:00
1 parent 3664eaed1a
commit 5dc9b5f115
19 files changed
+1274 -48

No files matched your search

+72 -17
View File
@@ -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 {
+319
View File
@@ -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",
})
}
@@ -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:[email protected]:5432/appdb?sslmode=require" {
t.Fatalf("unexpected pg url: %s", u)
}
u = externalConnectionURL("postgresql", ext, "")
if u != "postgres://[email protected]: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://:[email protected]: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)
}
}
+3
View File
@@ -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)
+88 -1
View File
@@ -196,9 +196,96 @@ var dbDownloadCmd = &cobra.Command{
},
}
var dbRegisterCmd = &cobra.Command{
Use: "register <name>",
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 [<id>]",
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)
}
@@ -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 {
+36 -13
View File
@@ -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 {
@@ -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
+3
View File
@@ -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})
@@ -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
+10 -4
View File
@@ -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);
+7
View File
@@ -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()
);
+191 -8
View File
@@ -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<string | null>(null);
const [bindDb, setBindDb] = useState<DatabaseEntity | null>(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<Record<string, { ok: boolean; latency_ms?: number; error?: string }>>({});
const [createError, setCreateError] = useState<string | null>(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() {
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{db.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-secondary)] mono">{db.type}{db.version ? ` ${db.version}` : ''}</span>
{db.provider === 'external' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]">external</span>
)}
<span className={`text-xs px-1.5 py-0.5 rounded ${dbStatusClass[db.status ?? ''] ?? 'bg-[var(--surface-muted)] text-[var(--text-muted)]'}`}>{db.status}</span>
</div>
<p className="mt-0.5 text-xs text-[var(--text-muted)]">{db.plan} · {db.region}</p>
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
{db.provider === 'external' && db.external
? `${db.external.host}:${db.external.port}${db.external.database ? ` · ${db.external.database}` : ''}`
: `${db.plan} · ${db.region}`}
</p>
</div>
<ChevronDown size={15} className={`shrink-0 text-[var(--text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
@@ -2044,6 +2093,25 @@ export function DatabasesPage() {
{signedIn ? (
<>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mr-1">Actions</span>
{db.provider === 'external' ? (
<>
<button
onClick={() => testByIdMutation.mutate(db.id ?? '')}
disabled={testByIdMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
>
<RefreshCw size={11} /> Test connection
</button>
{externalTestResult[db.id ?? ''] && (
<span className={`text-xs ${externalTestResult[db.id ?? '']?.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{externalTestResult[db.id ?? '']?.ok
? `reachable (${externalTestResult[db.id ?? '']?.latency_ms ?? 0}ms)`
: `unreachable — ${externalTestResult[db.id ?? '']?.error ?? 'probe failed'}`}
</span>
)}
</>
) : (
<>
<button
onClick={() => actionMutation.mutate({ id: db.id ?? '', action: 'start' })}
disabled={running || actionMutation.isPending}
@@ -2065,6 +2133,8 @@ export function DatabasesPage() {
>
<RefreshCw size={11} /> Restart
</button>
</>
)}
<button
onClick={() => setBindDb(bindDb?.id === db.id ? null : db)}
disabled={!db.connection_url}
@@ -2107,6 +2177,7 @@ export function DatabasesPage() {
</div>
)}
{db.provider !== 'external' && (
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Backups</p>
@@ -2163,6 +2234,7 @@ export function DatabasesPage() {
</p>
)}
</div>
)}
</div>
)}
</div>
@@ -2177,18 +2249,125 @@ export function DatabasesPage() {
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">New database</h2>
<p className="text-xs text-[var(--text-tertiary)]">Provision a managed database service.</p>
<div className="mt-3 flex gap-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
{(['managed', 'external'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => { setCreateMode(mode); setCreateError(null); }}
className={`flex-1 rounded-[calc(var(--radius-md)-2px)] px-3 py-1.5 text-xs font-medium capitalize transition-colors ${createMode === mode ? 'bg-[var(--bg-base)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
>
{mode}
</button>
))}
</div>
<p className="mt-2 text-xs text-[var(--text-tertiary)]">
{createMode === 'managed' ? 'Provision a managed database service.' : 'Register a database hosted outside Containr.'}
</p>
</div>
<div className="space-y-4 px-5 py-4">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
<input
value={createForm.name}
onChange={(e) => 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)]"
/>
</div>
{createMode === 'external' ? (
<>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
<select
value={externalForm.type}
onChange={(e) => setExternalForm((f) => ({ ...f, type: e.target.value }))}
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm focus:border-[var(--accent-primary)]"
>
{DATABASE_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Host</label>
<input
value={externalForm.host}
onChange={(e) => 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)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</label>
<input
value={externalForm.port}
onChange={(e) => 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)]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Database</label>
<input
value={externalForm.database}
onChange={(e) => 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)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Username</label>
<input
value={externalForm.username}
onChange={(e) => 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)]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Password</label>
<input
type="password"
value={externalForm.password}
onChange={(e) => 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)]"
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
<input
type="checkbox"
checked={externalForm.ssl}
onChange={(e) => setExternalForm((f) => ({ ...f, ssl: e.target.checked }))}
className="accent-[var(--accent-primary)]"
/>
Require TLS/SSL
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => probeMutation.mutate()}
disabled={probeMutation.isPending || externalForm.host.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
{probeMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : null}
Test connection
</button>
{externalProbe && (
<span className={`text-xs ${externalProbe.ok ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{externalProbe.ok ? `reachable (${externalProbe.latency_ms ?? 0}ms)` : `unreachable — ${externalProbe.error ?? 'probe failed'}`}
</span>
)}
</div>
</>
) : (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</label>
@@ -2224,6 +2403,7 @@ export function DatabasesPage() {
/>
</div>
</div>
)}
{createError ? (
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
) : null}
@@ -2238,12 +2418,15 @@ export function DatabasesPage() {
</button>
<button
type="button"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || createForm.name.trim().length === 0}
onClick={() => (createMode === 'managed' ? createMutation.mutate() : registerExternalMutation.mutate())}
disabled={
(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ||
(createMode === 'managed' ? createForm.name.trim().length === 0 : externalForm.name.trim().length === 0 || externalForm.host.trim().length === 0)
}
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{createMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : null}
Create database
{(createMode === 'managed' ? createMutation.isPending : registerExternalMutation.isPending) ? <Loader2 size={13} className="animate-spin" /> : null}
{createMode === 'managed' ? 'Create database' : 'Register database'}
</button>
</div>
</div>
+176
View File
@@ -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<string, never>;
};
};
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;
+1 -1
View File
@@ -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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> 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 <id>\ncontainr up --project <id> --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.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --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 <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` 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 <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\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 <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/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 stdiLine truncated
"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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> 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 <name> 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 <id>\ncontainr up --project <id> --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.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --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 <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` 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 <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\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 <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE_URL>/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 eLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+33
View File
@@ -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'];
+123
View File
@@ -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:
+2
View File
@@ -48,6 +48,8 @@ logs <service-id> [--follow] runtime logs (SSE follow)
exec <service-id> -- <cmd> 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 <name> 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)
+1
View File
@@ -21,6 +21,7 @@ Base URL: `https://<host>/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