From b7131965b1a594b5326e39771969bf0ca66f76a8 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 8 Oct 2026 22:19:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20public=5Fport=20for=20managed=20databas?= =?UTF-8?q?es=20=E2=80=94=20opt-in=200.0.0.0=20bind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- app/backend/internal/api/databases.go | 93 ++++++++++++++----- .../internal/cli/commands/databases.go | 42 ++++++++- .../internal/database/sqlcdb/databases.sql.go | 61 ++++++++---- .../internal/database/sqlcdb/models.go | 1 + .../internal/database/sqlcdb/querier.go | 1 + app/backend/internal/mcpserver/server.go | 3 +- .../20261014000000_db_public_port.sql | 11 +++ app/backend/sqlc/queries/databases.sql | 9 +- app/backend/sqlc/schema.sql | 1 + app/frontend/src/features/secondary/pages.tsx | 29 +++++- app/frontend/src/generated/api-types.ts | 6 ++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 7 ++ docs/api/openapi.yaml | 9 ++ docs/guides/CLI.md | 4 +- llms.txt | 1 + 16 files changed, 235 insertions(+), 45 deletions(-) create mode 100644 app/backend/migrations_goose/20261014000000_db_public_port.sql diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 9053e78..b0ef822 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -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{}{}} diff --git a/app/backend/internal/cli/commands/databases.go b/app/backend/internal/cli/commands/databases.go index 621ce3d..c5a0e61 100644 --- a/app/backend/internal/cli/commands/databases.go +++ b/app/backend/internal/cli/commands/databases.go @@ -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 ", + 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 ", 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) } diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go index 804e336..b7dba52 100644 --- a/app/backend/internal/database/sqlcdb/databases.sql.go +++ b/app/backend/internal/database/sqlcdb/databases.sql.go @@ -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 diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index cd00e1d..b72de53 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -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"` } diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index dabb673..9ac43e4 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -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 diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 90b45b3..7f10cc7 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -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}) diff --git a/app/backend/migrations_goose/20261014000000_db_public_port.sql b/app/backend/migrations_goose/20261014000000_db_public_port.sql new file mode 100644 index 0000000..0705d52 --- /dev/null +++ b/app/backend/migrations_goose/20261014000000_db_public_port.sql @@ -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 diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql index a3713b7..9aef17d 100644 --- a/app/backend/sqlc/queries/databases.sql +++ b/app/backend/sqlc/queries/databases.sql @@ -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 diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index e6e5cf7..0d22ee9 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -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() ); diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 5be063d..f75e92a 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -31,6 +31,7 @@ import { restoreDatabaseBackup, createManagedDatabase, registerExternalDatabase, + updateDatabase, testDatabaseConnection, testDatabaseConnectionByID, type ExternalDatabaseInput, @@ -1906,7 +1907,7 @@ export function DatabasesPage() { const [bindDb, setBindDb] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [createMode, setCreateMode] = useState<'managed' | 'external'>('managed'); - const [createForm, setCreateForm] = useState({ name: '', type: 'postgresql', plan: 'hobby', region: 'local' }); + const [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>({}); @@ -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" > Restart + + )} @@ -2368,6 +2382,7 @@ export function DatabasesPage() { ) : ( + <>
@@ -2403,6 +2418,16 @@ export function DatabasesPage() { />
+ + )} {createError ? (
{createError}
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 0fcbdee..cf339d6 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -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: { /** diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index ca517d1..f8ef268 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/CLI.md", "section": "guides", "title": "Containr CLI", - "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 6646eba..c0da838 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1692,6 +1692,13 @@ export async function getDatabase(id: string): Promise { return requestJson(`/databases/${encodeURIComponent(id)}`); } +export async function updateDatabase(id: string, input: components['schemas']['UpdateDatabaseRequest']): Promise { + await requestJson(`/databases/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(input), + }); +} + export async function databaseAction(id: string, action: 'start' | 'stop' | 'restart'): Promise { await requestJson(`/databases/${encodeURIComponent(id)}/action`, { method: 'POST', diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4aa13de..7fc495b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 47c311e..970d378 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -47,7 +47,9 @@ deployments list|get|logs|rollback|cancel logs [--follow] runtime logs (SSE follow) exec -- one-off container command variables list|set|unset env vars (secrets masked; --redeploy applies them) -databases list|get|create|delete|action|backup|restore|download-backup +databases list|get|create|update|delete|action|backup|restore|download-backup + create --public exposes the port on all interfaces; + update --public/--public=false toggles it (container recreates, data persists) databases register register an external database (--type --host --port --database --username --password --ssl; probed before storing) databases test-connection probe a connection — by id, or ad-hoc flags cron list|get|create|delete|trigger|executions diff --git a/llms.txt b/llms.txt index da0653c..9bfe2d5 100644 --- a/llms.txt +++ b/llms.txt @@ -22,6 +22,7 @@ Base URL: `https:///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