Merge pull request #25 from Dvorinka/feat/db-public-port

feat: public_port for managed databases — opt-in 0.0.0.0 bind
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-10-09 05:27:00 +02:00
commit c7256954c2
16 files changed
+235 -45

No files matched your search

+72 -21
View File
@@ -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{}{}}
+41 -1
View File
@@ -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
+2 -1
View File
@@ -100,7 +100,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
+7 -2
View File
@@ -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
+1
View File
@@ -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()
);
+27 -2
View File
@@ -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>
+6
View File
@@ -9185,6 +9185,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
@@ -9220,6 +9222,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;
@@ -9253,6 +9257,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: {
/**
+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 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",
+7
View File
@@ -1708,6 +1708,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',
+9
View File
@@ -6391,6 +6391,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:
@@ -6434,6 +6437,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
@@ -6492,6 +6498,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
View File
@@ -48,7 +48,9 @@ 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)
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
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
+1
View File
@@ -23,6 +23,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