mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
3664eaed1a
commit
5dc9b5f115
19 files changed
+1274
-48
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user