mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: public_port for managed databases — opt-in 0.0.0.0 bind
Managed databases have always published their port on 127.0.0.1 only.
public_port opts into binding on all interfaces so the database is
reachable off-host. Toggling via PUT recreates the container — the
named data volume survives.
- create accepts public_port; PUT /databases/{id} toggles it
- external rows reject the flag (no container exists)
- CLI: databases create --public, databases update --public/--public=false
- MCP: databases_update tool; UI: create checkbox + per-row toggle
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
5dc9b5f115
commit
b7131965b1
16 files changed
+235
-45
No files matched your search
@@ -52,7 +52,8 @@ 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
|
||||
Provider string `json:"provider"` // managed | external
|
||||
PublicPort bool `json:"public_port" db:"public_port"`
|
||||
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"`
|
||||
@@ -117,10 +118,11 @@ type DatabaseSettings struct {
|
||||
|
||||
// DatabaseCreateRequest represents a request to create a new database
|
||||
type DatabaseCreateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
|
||||
Region string `json:"region" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
|
||||
Region string `json:"region" binding:"required"`
|
||||
PublicPort bool `json:"public_port"`
|
||||
}
|
||||
|
||||
// DatabaseUpdateRequest represents a request to update a database
|
||||
@@ -128,6 +130,7 @@ type DatabaseUpdateRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
||||
PublicPort *bool `json:"public_port,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseActionRequest represents a request to perform database actions
|
||||
@@ -291,10 +294,11 @@ func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
|
||||
}
|
||||
|
||||
databaseID, dbName, dbType, err := h.createManagedDatabase(c.Request.Context(), userID, managedDatabaseCreateRequest{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Plan: req.Plan,
|
||||
Region: req.Region,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Plan: req.Plan,
|
||||
Region: req.Region,
|
||||
PublicPort: req.PublicPort,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
@@ -322,6 +326,7 @@ type managedDatabaseCreateRequest struct {
|
||||
Type string
|
||||
Plan string
|
||||
Region string
|
||||
PublicPort bool
|
||||
RuntimeVariables map[string]string
|
||||
}
|
||||
|
||||
@@ -365,16 +370,17 @@ func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID stri
|
||||
version := h.getDefaultVersion(dbType)
|
||||
|
||||
err = h.queries.CreateDatabaseService(ctx, sqlcdb.CreateDatabaseServiceParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Type: dbType,
|
||||
Status: "building",
|
||||
Version: version,
|
||||
Plan: plan,
|
||||
Region: region,
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Type: dbType,
|
||||
Status: "building",
|
||||
Version: version,
|
||||
Plan: plan,
|
||||
Region: region,
|
||||
PublicPort: req.PublicPort,
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create database service: %w", err)
|
||||
@@ -411,11 +417,40 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Plan = strings.TrimSpace(req.Plan)
|
||||
|
||||
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil {
|
||||
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil && req.PublicPort == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.PublicPort != nil {
|
||||
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
if row.Provider == "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "public_port applies to managed databases — external databases are already reachable", "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
if err := h.queries.SetDatabaseServicePublicPortByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseServicePublicPortByIDAndUserParams{
|
||||
PublicPort: *req.PublicPort,
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
ID: databaseID,
|
||||
UserID: userID,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update public port"})
|
||||
return
|
||||
}
|
||||
// Port bindings are baked into the container — recreate it so the
|
||||
// new bind scope takes effect. The data volume survives.
|
||||
if *req.PublicPort != row.PublicPort {
|
||||
go h.reprovisionManagedDatabase(databaseID, row.Name, row.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if req.BackupSchedule != nil {
|
||||
var schedule sql.NullString
|
||||
var nextBackup sql.NullTime
|
||||
@@ -1108,6 +1143,7 @@ func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
|
||||
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||
Provider: row.Provider,
|
||||
PublicPort: row.PublicPort,
|
||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||
@@ -1288,6 +1324,17 @@ func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType str
|
||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
|
||||
}
|
||||
|
||||
// reprovisionManagedDatabase recreates a managed container so changed
|
||||
// runtime settings (public_port) take effect. The data volume persists.
|
||||
func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, dbType string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if h.dockerClient != nil {
|
||||
_ = h.dockerClient.RemoveContainer(ctx, managedDatabaseContainerName(databaseID), true)
|
||||
}
|
||||
h.provisionDatabase(databaseID, databaseName, dbType)
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
@@ -1344,8 +1391,12 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
||||
}
|
||||
}
|
||||
|
||||
hostIP := "127.0.0.1"
|
||||
if row, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil && row.PublicPort {
|
||||
hostIP = "0.0.0.0"
|
||||
}
|
||||
portBindings := nat.PortMap{
|
||||
plan.Port: []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}},
|
||||
plan.Port: []nat.PortBinding{{HostIP: hostIP, HostPort: ""}},
|
||||
}
|
||||
|
||||
exposedPorts := nat.PortSet{plan.Port: struct{}{}}
|
||||
|
||||
@@ -79,6 +79,9 @@ var dbCreateCmd = &cobra.Command{
|
||||
body[key] = v
|
||||
}
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("public"); v {
|
||||
body["public_port"] = true
|
||||
}
|
||||
data, err := c.Do("POST", "/databases", body)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -88,6 +91,39 @@ var dbCreateCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var dbUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a database (--public/--public=false toggles the exposed host port)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if cmd.Flags().Changed("public") {
|
||||
v, _ := cmd.Flags().GetBool("public")
|
||||
body["public_port"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||||
body["name"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("backup-schedule") {
|
||||
v, _ := cmd.Flags().GetString("backup-schedule")
|
||||
body["backup_schedule"] = v
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return &APIError{Message: "nothing to update — pass --public/--private-style flags, --name, or --backup-schedule", ExitCode: ExitError}
|
||||
}
|
||||
data, err := c.Do("PUT", "/databases/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var dbDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a database and its data",
|
||||
@@ -278,6 +314,10 @@ 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)")
|
||||
dbCreateCmd.Flags().Bool("public", false, "bind the database port on all interfaces (default: localhost only)")
|
||||
dbUpdateCmd.Flags().Bool("public", false, "bind the database port on all interfaces")
|
||||
dbUpdateCmd.Flags().String("name", "", "rename the database")
|
||||
dbUpdateCmd.Flags().String("backup-schedule", "", "cron expression for automatic backups (empty clears)")
|
||||
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")
|
||||
@@ -287,5 +327,5 @@ func init() {
|
||||
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)
|
||||
DatabasesCmd.AddCommand(dbListCmd, dbGetCmd, dbCreateCmd, dbUpdateCmd, dbRegisterCmd, dbTestConnCmd, dbDeleteCmd, dbActionCmd, dbBackupCmd, dbRestoreCmd, dbDownloadCmd)
|
||||
}
|
||||
@@ -55,21 +55,22 @@ func (q *Queries) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBa
|
||||
}
|
||||
|
||||
const createDatabaseService = `-- name: CreateDatabaseService :exec
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
type CreateDatabaseServiceParams 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"`
|
||||
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"`
|
||||
PublicPort bool `json:"public_port"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error {
|
||||
@@ -82,6 +83,7 @@ func (q *Queries) CreateDatabaseService(ctx context.Context, arg CreateDatabaseS
|
||||
arg.Version,
|
||||
arg.Plan,
|
||||
arg.Region,
|
||||
arg.PublicPort,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
@@ -196,7 +198,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
||||
}
|
||||
|
||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
||||
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
|
||||
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.public_port, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -223,6 +225,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -230,7 +233,7 @@ func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (Databa
|
||||
}
|
||||
|
||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||
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
|
||||
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.public_port, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
@@ -262,6 +265,7 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -282,7 +286,7 @@ func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (str
|
||||
}
|
||||
|
||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
||||
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
|
||||
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.public_port, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -315,6 +319,7 @@ func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseServic
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -378,7 +383,7 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
||||
}
|
||||
|
||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||
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
|
||||
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.public_port, database_services.created_at, database_services.updated_at
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -412,6 +417,7 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
||||
&i.ExternalUsername,
|
||||
&i.ExternalPassword,
|
||||
&i.ExternalSsl,
|
||||
&i.PublicPort,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -527,6 +533,29 @@ func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNe
|
||||
return err
|
||||
}
|
||||
|
||||
const setDatabaseServicePublicPortByIDAndUser = `-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
SET public_port = $1, updated_at = $2
|
||||
WHERE id = $3 AND user_id = $4
|
||||
`
|
||||
|
||||
type SetDatabaseServicePublicPortByIDAndUserParams struct {
|
||||
PublicPort bool `json:"public_port"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setDatabaseServicePublicPortByIDAndUser,
|
||||
arg.PublicPort,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
|
||||
UPDATE database_services
|
||||
SET status = $1, connection_url = $2, updated_at = $3
|
||||
|
||||
@@ -98,6 +98,7 @@ type DatabaseService struct {
|
||||
ExternalUsername sql.NullString `json:"external_username"`
|
||||
ExternalPassword string `json:"external_password"`
|
||||
ExternalSsl bool `json:"external_ssl"`
|
||||
PublicPort bool `json:"public_port"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ type Querier interface {
|
||||
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||
SetDatabaseServicePublicPortByIDAndUser(ctx context.Context, arg SetDatabaseServicePublicPortByIDAndUserParams) error
|
||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||
|
||||
@@ -98,7 +98,8 @@ func init() {
|
||||
// Databases
|
||||
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_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse), public_port (bind on all interfaces, default localhost-only).", "POST", "/databases", nil, nil, false})
|
||||
register(spec{"containr_databases_update", "Update a database. Args: id, name, backup_schedule (empty clears), public_port — toggling recreates the container so the new bind scope applies.", "PUT", "/databases/{id}", []string{"id"}, 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})
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
ADD COLUMN IF NOT EXISTS public_port BOOLEAN NOT NULL DEFAULT false;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE database_services
|
||||
DROP COLUMN IF EXISTS public_port;
|
||||
-- +goose StatementEnd
|
||||
@@ -35,8 +35,13 @@ FROM database_services
|
||||
WHERE user_id = sqlc.arg(user_id) AND LOWER(name) = LOWER(sqlc.arg(name));
|
||||
|
||||
-- name: CreateDatabaseService :exec
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, public_port, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||
|
||||
-- name: SetDatabaseServicePublicPortByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
SET public_port = $1, updated_at = $2
|
||||
WHERE id = $3 AND user_id = $4;
|
||||
|
||||
-- name: UpdateDatabaseServiceNameAndPlanByIDAndUser :exec
|
||||
UPDATE database_services
|
||||
|
||||
@@ -146,6 +146,7 @@ CREATE TABLE database_services (
|
||||
external_username VARCHAR(255),
|
||||
external_password TEXT NOT NULL DEFAULT '',
|
||||
external_ssl BOOLEAN NOT NULL DEFAULT false,
|
||||
public_port BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
restoreDatabaseBackup,
|
||||
createManagedDatabase,
|
||||
registerExternalDatabase,
|
||||
updateDatabase,
|
||||
testDatabaseConnection,
|
||||
testDatabaseConnectionByID,
|
||||
type ExternalDatabaseInput,
|
||||
@@ -1906,7 +1907,7 @@ export function DatabasesPage() {
|
||||
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 [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
|
||||
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 }>>({});
|
||||
@@ -1926,10 +1927,11 @@ export function DatabasesPage() {
|
||||
type: createForm.type as CreateDatabaseInput['type'],
|
||||
plan: createForm.plan as CreateDatabaseInput['plan'],
|
||||
region: createForm.region.trim() || 'local',
|
||||
public_port: createForm.public,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCreateOpen(false);
|
||||
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' });
|
||||
setCreateForm({ name: '', type: 'postgresql', plan: 'hobby', region: 'local', public: false });
|
||||
setCreateError(null);
|
||||
invalidate();
|
||||
},
|
||||
@@ -1965,6 +1967,11 @@ export function DatabasesPage() {
|
||||
onSuccess: (result) => setExternalProbe(result),
|
||||
onError: (error) => setExternalProbe({ ok: false, error: error instanceof Error ? error.message : 'probe failed' }),
|
||||
});
|
||||
const publicPortMutation = useMutation({
|
||||
mutationFn: ({ id, publicPort }: { id: string; publicPort: boolean }) =>
|
||||
updateDatabase(id, { public_port: publicPort }),
|
||||
onSuccess: () => window.setTimeout(invalidate, 1500),
|
||||
});
|
||||
const testByIdMutation = useMutation({
|
||||
mutationFn: (id: string) => testDatabaseConnectionByID(id),
|
||||
onSuccess: (result, id) => setExternalTestResult((prev) => ({ ...prev, [id]: result })),
|
||||
@@ -2132,6 +2139,13 @@ export function DatabasesPage() {
|
||||
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} /> Restart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (window.confirm(`${db.public_port ? 'Stop exposing' : 'Expose'} ${db.name} on all interfaces? The container will be recreated; data persists.`)) publicPortMutation.mutate({ id: db.id ?? '', publicPort: !db.public_port }) }}
|
||||
disabled={publicPortMutation.isPending}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] border text-xs font-medium transition-colors disabled:opacity-40 ${db.public_port ? 'border-[var(--warning)] text-[var(--warning)]' : 'border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--border-default)]'}`}
|
||||
>
|
||||
<Link2 size={11} /> {db.public_port ? 'Public ✓' : 'Local only'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -2368,6 +2382,7 @@ export function DatabasesPage() {
|
||||
</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>
|
||||
@@ -2403,6 +2418,16 @@ export function DatabasesPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.public}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, public: e.target.checked }))}
|
||||
className="accent-[var(--accent-primary)]"
|
||||
/>
|
||||
Expose port on all interfaces (default: localhost only)
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{createError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-3.5 py-2.5 text-xs text-[var(--error)]">{createError}</div>
|
||||
|
||||
@@ -9093,6 +9093,8 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
provider?: "managed" | "external";
|
||||
/** @description managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container */
|
||||
public_port?: boolean;
|
||||
external?: components["schemas"]["DatabaseExternal"];
|
||||
/**
|
||||
* Format: date-time
|
||||
@@ -9128,6 +9130,8 @@ export interface components {
|
||||
plan: "hobby" | "starter" | "standard" | "business";
|
||||
/** @description Database region */
|
||||
region: string;
|
||||
/** @description Bind the database port on all interfaces (default 127.0.0.1) */
|
||||
public_port?: boolean;
|
||||
};
|
||||
DatabaseExternal: {
|
||||
host?: string;
|
||||
@@ -9161,6 +9165,8 @@ export interface components {
|
||||
plan?: "hobby" | "starter" | "standard" | "business";
|
||||
/** @description Cron expression for automatic backups; empty string clears the schedule */
|
||||
backup_schedule?: string | null;
|
||||
/** @description Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container */
|
||||
public_port?: boolean;
|
||||
};
|
||||
DatabaseActionRequest: {
|
||||
/**
|
||||
|
||||
@@ -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 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
|
||||
"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|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\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
Line truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -1692,6 +1692,13 @@ export async function getDatabase(id: string): Promise<DatabaseEntity> {
|
||||
return requestJson<DatabaseEntity>(`/databases/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function updateDatabase(id: string, input: components['schemas']['UpdateDatabaseRequest']): Promise<void> {
|
||||
await requestJson(`/databases/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
||||
await requestJson(`/databases/${encodeURIComponent(id)}/action`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -6319,6 +6319,9 @@ components:
|
||||
type: string
|
||||
enum: [managed, external]
|
||||
description: managed = Containr-provisioned container; external = registered connection to a database hosted elsewhere
|
||||
public_port:
|
||||
type: boolean
|
||||
description: managed only — bind the database port on all interfaces instead of 127.0.0.1; toggling recreates the container
|
||||
external:
|
||||
$ref: '#/components/schemas/DatabaseExternal'
|
||||
next_backup_at:
|
||||
@@ -6362,6 +6365,9 @@ components:
|
||||
region:
|
||||
type: string
|
||||
description: Database region
|
||||
public_port:
|
||||
type: boolean
|
||||
description: Bind the database port on all interfaces (default 127.0.0.1)
|
||||
|
||||
DatabaseExternal:
|
||||
type: object
|
||||
@@ -6420,6 +6426,9 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Cron expression for automatic backups; empty string clears the schedule
|
||||
public_port:
|
||||
type: boolean
|
||||
description: Toggle the published port between 127.0.0.1 and all interfaces; recreates the managed container
|
||||
|
||||
DatabaseActionRequest:
|
||||
type: object
|
||||
|
||||
+3
-1
@@ -47,7 +47,9 @@ deployments list|get|logs|rollback|cancel
|
||||
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 list|get|create|update|delete|action|backup|restore|download-backup
|
||||
create --public exposes the port on all interfaces;
|
||||
update <id> --public/--public=false toggles it (container recreates, data persists)
|
||||
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
|
||||
|
||||
@@ -22,6 +22,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
||||
- **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.
|
||||
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
|
||||
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||
|
||||
## Core objects
|
||||
|
||||
Reference in new issue
Block a user