Compare commits

...
8 Commits
Author SHA1 Message Date
Tomáš Dvořák 4734be0d9d Merge pull request #33 from Dvorinka/feat/release-mcp
ci: MCP release binaries + docs-only path gate
2026-10-09 05:11:14 +02:00
Tomáš Dvořák 4e8d364911 Merge pull request #32 from Dvorinka/feat/shared-vars
feat(vars): shared project variables — ${{shared.KEY}} refs
2026-10-09 05:11:08 +02:00
Tomáš Dvořák f53695be4f Merge pull request #23 from Dvorinka/feat/clone-move-registries
feat: service clone/move + private registry credentials (C4)
2026-10-09 05:10:27 +02:00
Tomas DvorakandDevin 57f8b4f56e ci: release MCP binaries + path-gate heavyweight jobs
- build-cli.sh also builds containr-mcp-<os>-<arch> per platform; the
  existing containr-* asset glob in the release job picks them up
- paths-filter 'changes' job gates backend/frontend/smoke/docker on PRs —
  docs-only PRs skip ~10min of compute; skipped checks satisfy protection
- docker job requires success() explicitly (skipped parents would pass
  '!= failure'); release requires docker success to never release stale images

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:07:18 +02:00
Tomas Dvorak 58424ed0ab Merge remote-tracking branch 'origin/main' into feat/clone-move-registries 2026-10-09 01:04:41 +02:00
Tomas DvorakandDevin 25fa8c47e6 feat(vars): project shared variables — ${{shared.KEY}} refs, CRUD, UI
- project_variables table (secret entries encrypted at rest, masked in API)
- resolveServiceEnv resolves shared.* before service lookup
- GET|PUT /projects/{id}/variables; CLI 'variables shared list|set|unset';
  MCP containr_shared_variables_{list,set}
- Settings tab → Shared Variables editor; env help text mentions shared refs

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 01:04:19 +02:00
Tomas Dvorak 2049087ccd Merge remote-tracking branch 'origin/main' into feat/clone-move-registries
# Conflicts:
#	app/frontend/src/features/service/pages/ServiceDetailPage.tsx
2026-10-09 00:50:06 +02:00
Tomas DvorakandDevin 3664eaed1a feat(services): clone/move ops + owner-scoped registry credentials (C4)
Service lifecycle: POST /services/:id/clone duplicates config, volumes,
domains, access gates, builder settings, and variables (secret ciphertext
copied verbatim); POST /services/:id/move re-parents to another owned
project. Both enforce ownership, reject name conflicts, and audit.

Registries: CRUD at /registries — host-matched pull credentials for
prebuilt-image deploys, encrypted at rest via secrets pkg, never returned
(has_password flag). Empty password on update keeps the stored credential;
host normalized (scheme/case/trailing slash). Credentials thread through
BuildConfig → BuildRequest → pull auth config, used only for the pull.

Surfaces: CLI services clone|move + registries group, 7 MCP tools,
OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and
Settings registries section, llms.txt + CLI.md.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:21:27 +02:00
28 changed files with 2240 additions and 33 deletions

No files matched your search

+29 -1
View File
@@ -17,6 +17,27 @@ permissions:
contents: read
jobs:
# Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped
# required checks still satisfy branch protection ("skipped" = success).
changes:
name: Changed paths
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'app/**'
- 'infra/**'
- 'docker-compose.yml'
- 'install.sh'
- '.github/workflows/**'
- 'docs/api/**'
infra:
name: Infra (Compose + Scripts)
runs-on: ubuntu-latest
@@ -48,6 +69,8 @@ jobs:
backend:
name: Backend (Go)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/backend
@@ -70,6 +93,8 @@ jobs:
frontend:
name: Frontend (React)
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
defaults:
run:
working-directory: app/frontend
@@ -100,6 +125,8 @@ jobs:
name: Compose boot smoke
runs-on: ubuntu-latest
timeout-minutes: 20
needs: changes
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -137,6 +164,7 @@ jobs:
name: Build & publish images
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success'
permissions:
contents: read
packages: write
@@ -227,7 +255,7 @@ jobs:
name: Release
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && needs.docker.result == 'success'
permissions:
contents: write
packages: write
+12 -7
View File
@@ -456,16 +456,21 @@ func runDeploymentAndSyncWithImage(
},
}
if imageOverride != "" {
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: imageOverride,
if imageOverride != "" || service.GitRepo == "" {
// Prebuilt image deploy (rollback, redeploy, or image-sourced
// service): resolve private-registry credentials by image host.
image := imageOverride
if image == "" {
image = service.Image
}
} else if service.GitRepo == "" {
// Image-sourced service: pull the image instead of building.
var ownerID string
_ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt",
PrebuiltImage: service.Image,
PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
}
} else {
// "auto" leaves BuildType empty so the manager detects it; the static
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"net/http"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Project-level shared variables — referenced from service env values as
// ${{shared.KEY}} and resolved at runtime in resolveServiceEnv.
type ProjectVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsSecret bool `json:"is_secret" db:"is_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
func handleGetProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
return
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables})
}
func handleUpdateProjectVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var req UpdateVariablesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
storedSecrets := map[string]string{}
secretRows, err := db.(*database.DB).Query(
`SELECT key, value FROM project_variables WHERE project_id = $1 AND is_secret = TRUE`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
return
}
for secretRows.Next() {
var k, v string
if err := secretRows.Scan(&k, &v); err == nil {
storedSecrets[k] = v
}
}
secretRows.Close()
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM project_variables WHERE project_id = $1`, projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
now := time.Now()
for _, v := range req.Variables {
value := v.Value
if v.IsSecret && value == maskedSecretValue {
if stored, ok := storedSecrets[v.Key]; ok {
value = stored
}
}
if v.IsSecret {
value = secrets.Encrypt(value)
}
if _, err = tx.Exec(
`INSERT INTO project_variables (id, project_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.New(), projectID, v.Key, value, v.IsSecret, now, now,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, key, value, is_secret, created_at, updated_at
FROM project_variables WHERE project_id = $1 ORDER BY key ASC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
variables := []ProjectVariable{}
for rows.Next() {
var v ProjectVariable
if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
if v.IsSecret {
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Project variables updated successfully"})
}
// resolveSharedVar returns the decrypted value of a project variable.
func resolveSharedVar(db *database.DB, projectID uuid.UUID, key string) (string, bool) {
var value string
err := db.QueryRow(
`SELECT value FROM project_variables WHERE project_id = $1 AND key = $2`,
projectID, key,
).Scan(&value)
if err != nil {
return "", false
}
return secrets.Decrypt(value), true
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/secrets"
"github.com/docker/docker/api/types/registry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Registry is a stored credential for pulling private images. Password is
// encrypted at rest and never returned — `has_password` signals presence.
type Registry struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Host string `json:"host"` // e.g. ghcr.io, registry.example.com; docker.io for Docker Hub
Username string `json:"username"`
HasPassword bool `json:"has_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type registryRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Host string `json:"host" binding:"required,min=1,max=255"`
Username string `json:"username"`
Password string `json:"password"`
}
func handleListRegistries(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
rows, err := db.(*database.DB).Query(
`SELECT id, name, host, username, password <> '', created_at, updated_at
FROM registries WHERE owner_id = $1 ORDER BY created_at DESC`,
userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registries"})
return
}
defer rows.Close()
registries := []Registry{}
for rows.Next() {
var r Registry
if err := rows.Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt); err == nil {
registries = append(registries, r)
}
}
c.JSON(http.StatusOK, gin.H{"registries": registries})
}
func handleCreateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Encrypt only a real credential — encrypting "" would still satisfy
// `password <> ''` and report has_password for anonymous registries.
password := ""
if req.Password != "" {
password = secrets.Encrypt(req.Password)
}
var r Registry
err := db.(*database.DB).QueryRow(
`INSERT INTO registries (owner_id, name, host, username, password)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
userID.(string), req.Name, req.Host, req.Username, password,
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
if strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "A registry for this host already exists", "code": "CONFLICT"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registry"})
return
}
c.JSON(http.StatusCreated, gin.H{"registry": r})
}
func handleUpdateRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
var req registryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
req.Host = strings.ToLower(strings.TrimSpace(req.Host))
req.Host = strings.TrimPrefix(strings.TrimPrefix(req.Host, "https://"), "http://")
req.Host = strings.TrimSuffix(req.Host, "/")
// Empty password keeps the stored credential — there is no "clear"
// operation; delete + recreate to remove a password.
var passwordArg interface{}
if req.Password != "" {
passwordArg = secrets.Encrypt(req.Password)
}
var r Registry
err = db.(*database.DB).QueryRow(
`UPDATE registries SET name = $1, host = $2, username = $3,
password = COALESCE($4, password), updated_at = now()
WHERE id = $5 AND owner_id = $6
RETURNING id, name, host, username, password <> '', created_at, updated_at`,
req.Name, req.Host, req.Username, passwordArg, registryID, userID.(string),
).Scan(&r.ID, &r.Name, &r.Host, &r.Username, &r.HasPassword, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"registry": r})
}
func handleDeleteRegistry(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
registryID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid registry ID", "code": "VALIDATION"})
return
}
res, err := db.(*database.DB).Exec(
`DELETE FROM registries WHERE id = $1 AND owner_id = $2`,
registryID, userID.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete registry"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Registry not found", "code": "NOT_FOUND"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Registry deleted"})
}
// registryAuthFor resolves pull credentials for an image reference: the
// caller's registry whose host matches, else anonymous. docker.io covers
// bare image names (nginx:latest).
func registryAuthFor(db *database.DB, ownerID, imageRef string) registry.AuthConfig {
host := registryHost(imageRef)
var username, password string
err := db.QueryRow(
`SELECT username, password FROM registries WHERE owner_id = $1 AND host = $2`,
ownerID, host,
).Scan(&username, &password)
if err != nil {
return registry.AuthConfig{}
}
return registry.AuthConfig{
Username: username,
Password: secrets.Decrypt(password),
ServerAddress: host,
}
}
+12
View File
@@ -336,6 +336,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/clone", handleCloneService)
authed.POST("/services/:id/move", handleMoveService)
authed.POST("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
@@ -344,6 +346,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables)
// Project-level shared variables — ${{shared.KEY}} refs.
authed.GET("/projects/:id/variables", handleGetProjectVariables)
authed.PUT("/projects/:id/variables", handleUpdateProjectVariables)
// Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs)
@@ -352,6 +358,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService)
// Private-image registries — owner-scoped, credentials encrypted at rest.
authed.GET("/registries", handleListRegistries)
authed.POST("/registries", handleCreateRegistry)
authed.PUT("/registries/:id", handleUpdateRegistry)
authed.DELETE("/registries/:id", handleDeleteRegistry)
// Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
+3
View File
@@ -119,6 +119,9 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
}
resolve := func(serviceName, key string) (string, bool) {
if strings.EqualFold(serviceName, "shared") {
return resolveSharedVar(db, service.ProjectID, key)
}
id, ok := byName[strings.ToLower(serviceName)]
if !ok {
return "", false
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"net/http"
"strings"
"time"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CloneServiceRequest struct {
Name string `json:"name"`
ProjectID uuid.UUID `json:"project_id"` // empty = clone within the same project
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
}
// handleCloneService duplicates a service's full configuration — image/git
// source, volumes, domains, access gates, builder settings, and variables —
// into the same or another project. Containers are not cloned; the copy
// starts stopped until its first deploy.
func handleCloneService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
var req CloneServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
source, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
targetProjectID := source.ProjectID
if req.ProjectID != uuid.Nil {
targetProjectID = req.ProjectID
}
if !ownsProject(db.(*database.DB), targetProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = source.Name + "-copy"
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
targetProjectID, name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in target project", "code": "CONFLICT"})
return
}
environment := source.Environment
if req.Environment != "" {
environment = req.Environment
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID)
newID := uuid.New()
now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID))
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, maintenance_mode, basic_auth_users,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
SELECT $1, $2, $3, $4, service_type, source_type, source_url, image_name,
build_command, start_command, type, 'stopped', image, command, $5,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, $6::jsonb, $7, $8,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
$9, $9
FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return
}
if _, err := db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"})
return
}
// Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"})
return
}
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
type MoveServiceRequest struct {
ProjectID uuid.UUID `json:"project_id" binding:"required"`
}
// handleMoveService re-parents a service to another project owned by the
// caller. Variables, domains, and volumes follow via the service id;
// running containers keep running (they join the new project's network on
// the next deploy).
func handleMoveService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
userID, _ := c.Get("user_id")
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID", "code": "VALIDATION"})
return
}
var req MoveServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
return
}
service, ok := loadOwnedService(c, db.(*database.DB))
if !ok {
return
}
if req.ProjectID == service.ProjectID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service is already in that project", "code": "VALIDATION"})
return
}
if !ownsProject(db.(*database.DB), req.ProjectID, userID.(string), contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project", "code": "FORBIDDEN"})
return
}
var count int
_ = db.(*database.DB).QueryRow(
`SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2`,
req.ProjectID, service.Name,
).Scan(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "A service with this name already exists in the target project", "code": "CONFLICT"})
return
}
envName := service.Environment
if envName == "" {
envName = "production"
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), req.ProjectID, envName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"})
return
}
res, err := db.(*database.DB).Exec(
`UPDATE services SET project_id = $1, environment_id = $2, updated_at = $3 WHERE id = $4`,
req.ProjectID, environmentID, time.Now(), serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move service"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found", "code": "NOT_FOUND"})
return
}
LogAuditWithRequest(c, "service", serviceID.String(), "service.move", map[string]interface{}{"to_project": req.ProjectID.String()})
c.JSON(http.StatusOK, gin.H{"message": "Service moved", "project_id": req.ProjectID})
}
// ownsProject is true when the user owns the project or is an admin.
func ownsProject(db *database.DB, projectID uuid.UUID, userID string, isAdmin bool) bool {
if isAdmin {
return true
}
var owner string
err := db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, projectID).Scan(&owner)
return err == nil && owner == userID
}
// loadServiceVolumesJSON returns the raw stored mounts for a verbatim copy.
func loadServiceVolumesJSON(db *database.DB, serviceID uuid.UUID) []byte {
var raw []byte
if err := db.QueryRow(`SELECT volumes FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil || len(raw) == 0 {
return []byte("[]")
}
return raw
}
+1 -1
View File
@@ -100,7 +100,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
// Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image.
auth := registry.AuthConfig{}
auth := registry.AuthConfig{Username: req.PullUsername, Password: req.PullPassword}
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
}
@@ -0,0 +1,160 @@
package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// RegistriesCmd manages private-image registry credentials.
var RegistriesCmd = &cobra.Command{
Use: "registries",
Aliases: []string{"registry"},
Short: "Manage registry credentials",
}
var registriesListCmd = &cobra.Command{
Use: "list",
Short: "List registry credentials",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/registries", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "registries")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, r := range items {
hasPw := ""
if b, ok := r["has_password"].(bool); ok && b {
hasPw = "yes"
}
rows = append(rows, []string{str(r, "id"), str(r, "name"), str(r, "host"), str(r, "username"), hasPw})
}
printRows(data, []string{"ID", "NAME", "HOST", "USERNAME", "PASSWORD"}, rows)
return nil
},
}
var registriesAddCmd = &cobra.Command{
Use: "add <name> <host>",
Short: "Add a registry credential (e.g. ghcr.io, registry.example.com, docker.io)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{"name": args[0], "host": args[1]}
if v, _ := cmd.Flags().GetString("username"); v != "" {
body["username"] = v
}
if v, _ := cmd.Flags().GetString("password"); v != "" {
body["password"] = v
}
data, err := c.Do("POST", "/registries", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
r, _ := unwrapObject(data, "registry")
fmt.Printf("Added registry %s (%s)\n", str(r, "name"), str(r, "host"))
return nil
},
}
var registriesUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a registry credential (empty --password keeps the stored one)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "host", "username", "password"} {
if cmd.Flags().Changed(f) {
v, _ := cmd.Flags().GetString(f)
body[f] = v
}
}
if len(body) == 0 {
return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError}
}
// The API requires name+host — backfill them from the stored row
// when the caller didn't supply both.
_, hasName := body["name"]
_, hasHost := body["host"]
if !hasName || !hasHost {
data, err := c.Do("GET", "/registries", nil)
if err != nil {
return err
}
items, _ := unwrapList(data, "registries")
for _, r := range items {
if str(r, "id") == args[0] {
if !hasName {
body["name"] = str(r, "name")
}
if !hasHost {
body["host"] = str(r, "host")
}
}
}
}
data, err := c.Do("PUT", "/registries/"+args[0], body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Updated registry %s\n", args[0])
return nil
},
}
var registriesRemoveCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a registry credential",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove registry %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/registries/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed registry %s\n", args[0])
return nil
},
}
func init() {
registriesAddCmd.Flags().String("username", "", "registry username")
registriesAddCmd.Flags().String("password", "", "registry password/token (stored encrypted)")
registriesUpdateCmd.Flags().String("name", "", "display name")
registriesUpdateCmd.Flags().String("host", "", "registry host")
registriesUpdateCmd.Flags().String("username", "", "registry username")
registriesUpdateCmd.Flags().String("password", "", "registry password/token")
RegistriesCmd.AddCommand(registriesListCmd, registriesAddCmd, registriesUpdateCmd, registriesRemoveCmd)
}
+60 -1
View File
@@ -226,6 +226,61 @@ var servicesUpdateCmd = &cobra.Command{
},
}
var servicesCloneCmd = &cobra.Command{
Use: "clone <id>",
Short: "Clone a service (config, volumes, domains, variables)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
for _, f := range []string{"name", "project", "environment"} {
if v, _ := cmd.Flags().GetString(f); v != "" {
key := f
if f == "project" {
key = "project_id"
}
body[key] = v
}
}
data, err := c.Do("POST", "/services/"+args[0]+"/clone", body)
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
m, _ := unwrapObject(data)
fmt.Printf("Cloned to service %s (%s)\n", str(m, "service_id"), str(m, "name"))
return nil
},
}
var servicesMoveCmd = &cobra.Command{
Use: "move <id> <project-id>",
Short: "Move a service to another project",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/services/"+args[0]+"/move", map[string]interface{}{"project_id": args[1]})
if err != nil {
return err
}
if JSONMode() {
PrintRaw(data)
return nil
}
fmt.Printf("Moved service %s to project %s\n", args[0], args[1])
return nil
},
}
// volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
// objects. Bind mounts are type=bind, named volumes type=volume.
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
@@ -459,7 +514,11 @@ func init() {
RunE: serviceAction(verb),
})
}
servicesCloneCmd.Flags().String("name", "", "name for the clone (default <name>-copy)")
servicesCloneCmd.Flags().String("project", "", "target project id (default: same project)")
servicesCloneCmd.Flags().String("environment", "", "target environment (default: source's)")
domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd)
ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd, servicesCloneCmd, servicesMoveCmd)
}
+88 -14
View File
@@ -50,7 +50,7 @@ var varsSetCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
@@ -73,7 +73,7 @@ var varsUnsetCmd = &cobra.Command{
drop[k] = true
}
redeploy, _ := cmd.Flags().GetBool("redeploy")
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
return mutateVars(serviceVarsPath(args[0]), args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
@@ -85,20 +85,92 @@ var varsUnsetCmd = &cobra.Command{
},
}
func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
data, err := c.Do("GET", "/services/"+serviceID+"/variables", nil)
if err != nil {
return nil, err
}
return unwrapList(data, "variables")
// Shared variables — project scope, referenced as ${{shared.KEY}}.
var varsSharedCmd = &cobra.Command{
Use: "shared",
Short: "Manage project-level shared variables (${{shared.KEY}})",
}
func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
var sharedVarsListCmd = &cobra.Command{
Use: "list <project-id>",
Short: "List shared project variables (secrets shown masked)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", projectVarsPath(args[0]), nil)
if err != nil {
return err
}
items, err := unwrapList(data, "variables")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, v := range items {
rows = append(rows, []string{str(v, "key"), str(v, "value"), str(v, "is_secret")})
}
printRows(data, []string{"KEY", "VALUE", "SECRET"}, rows)
return nil
},
}
var sharedVarsSetCmd = &cobra.Command{
Use: "set <project-id> KEY=VALUE [KEY=VALUE...]",
Short: "Set shared project variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
secret, _ := cmd.Flags().GetBool("secret")
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
for _, pair := range args[1:] {
k, v, found := strings.Cut(pair, "=")
if !found || k == "" {
continue
}
vars = upsertVar(vars, k, v, secret)
}
return vars
})
},
}
var sharedVarsUnsetCmd = &cobra.Command{
Use: "unset <project-id> KEY [KEY...]",
Short: "Remove shared project variables",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
drop := map[string]bool{}
for _, k := range args[1:] {
drop[k] = true
}
return mutateVars(projectVarsPath(args[0]), args[0], false, func(vars []map[string]interface{}) []map[string]interface{} {
out := vars[:0]
for _, v := range vars {
if !drop[str(v, "key")] {
out = append(out, v)
}
}
return out
})
},
}
func serviceVarsPath(serviceID string) string { return "/services/" + serviceID + "/variables" }
func projectVarsPath(projectID string) string { return "/projects/" + projectID + "/variables" }
func mutateVars(path, label string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
c, err := client()
if err != nil {
return err
}
vars, err := fetchVars(c, serviceID)
data, err := c.Do("GET", path, nil)
if err != nil {
return err
}
vars, err := unwrapList(data, "variables")
if err != nil {
return err
}
@@ -115,7 +187,7 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
if redeploy {
body["redeploy"] = true
}
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body)
data, err = c.Do("PUT", path, body)
if err != nil {
return err
}
@@ -125,9 +197,9 @@ func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interf
}
obj, _ := unwrapObject(data)
if obj["redeploy_queued"] == true {
fmt.Printf("Variables updated on %s — redeploy queued\n", serviceID)
fmt.Printf("Variables updated on %s — redeploy queued\n", label)
} else {
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", label)
}
return nil
}
@@ -147,5 +219,7 @@ func init() {
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
sharedVarsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
varsSharedCmd.AddCommand(sharedVarsListCmd, sharedVarsSetCmd, sharedVarsUnsetCmd)
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd, varsSharedCmd)
}
+1
View File
@@ -74,6 +74,7 @@ func init() {
rootCmd.AddCommand(commands.ExecCmd)
rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.RegistriesCmd)
rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd)
+8 -3
View File
@@ -155,9 +155,12 @@ type BuildConfig struct {
StartCommand string `json:"start_command"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Environment map[string]string `json:"environment"`
Branch string `json:"branch"`
Commit string `json:"commit"`
NoCache bool `json:"no_cache"`
// Pull credentials for private prebuilt images.
PullUsername string `json:"pull_username,omitempty"`
PullPassword string `json:"pull_password,omitempty"`
Branch string `json:"branch"`
Commit string `json:"commit"`
NoCache bool `json:"no_cache"`
}
type TriggerConfig struct {
@@ -300,6 +303,8 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
StartCommand: buildConfig.StartCommand,
BuildArgs: buildConfig.BuildArgs,
Environment: buildConfig.Environment,
PullUsername: buildConfig.PullUsername,
PullPassword: buildConfig.PullPassword,
ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID,
DeploymentID: deployment.ID,
+10
View File
@@ -59,6 +59,8 @@ func init() {
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false})
register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false})
register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false})
register(spec{"containr_services_clone", "Clone a service (config, volumes, domains, variables) into the same or another project. Args: id, name, project_id, environment.", "POST", "/services/{id}/clone", []string{"id"}, nil, false})
register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false})
register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false})
register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false})
register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields).", "PUT", "/services/{id}", []string{"id"}, nil, false})
@@ -70,6 +72,12 @@ func init() {
register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false})
register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false})
// Registries — private-image pull credentials, owner-scoped.
register(spec{"containr_registries_list", "List registry credentials (passwords never returned)", "GET", "/registries", nil, nil, false})
register(spec{"containr_registries_create", "Add a registry credential. Args: name (required), host (required — e.g. ghcr.io, docker.io), username, password (stored encrypted).", "POST", "/registries", nil, []string{"name", "host"}, false})
register(spec{"containr_registries_update", "Update a registry credential. Args: id, name, host (required), username, password (empty keeps stored).", "PUT", "/registries/{id}", []string{"id"}, []string{"name", "host"}, false})
register(spec{"containr_registries_delete", "Remove a registry credential. Requires confirm:true.", "DELETE", "/registries/{id}", []string{"id"}, nil, true})
// Docker volumes (admin)
register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false})
register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
@@ -77,6 +85,8 @@ func init() {
// Variables
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
register(spec{"containr_shared_variables_list", "List project-level shared variables, referenced from service env as ${{shared.KEY}} (secrets masked). Args: id (project).", "GET", "/projects/{id}/variables", []string{"id"}, nil, false})
register(spec{"containr_shared_variables_set", "Replace all shared project variables. Args: id (project), variables = [{key, value, is_secret}]. Services reference them as ${{shared.KEY}} — resolved at deploy/refresh, not live-updated.", "PUT", "/projects/{id}/variables", []string{"id"}, []string{"variables"}, false})
// Deployments
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
+4
View File
@@ -29,6 +29,10 @@ type BuildRequest struct {
Branch string `json:"branch"` // Git branch
Commit string `json:"commit"` // Git commit SHA
NoCache bool `json:"no_cache"` // Disable Docker layer cache
// Registry credentials for pulling a private prebuilt image.
PullUsername string `json:"pull_username,omitempty"`
PullPassword string `json:"pull_password,omitempty"`
}
// BuildResponse represents the response from a build operation
@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS registries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner_id, host)
);
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS registries;
-- +goose StatementEnd
@@ -0,0 +1,19 @@
-- +goose Up
-- Project-level shared variables (Railway-style). Services reference them via
-- ${{shared.KEY}} in environment values; secret entries are encrypted at rest
-- and masked in API responses exactly like service variables.
CREATE TABLE IF NOT EXISTS project_variables (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key varchar(255) NOT NULL,
value text NOT NULL DEFAULT '',
is_secret boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, key)
);
CREATE INDEX IF NOT EXISTS idx_project_variables_project ON project_variables(project_id);
-- +goose Down
DROP TABLE IF EXISTS project_variables;
+12
View File
@@ -69,6 +69,18 @@ CREATE TABLE services (
static_dir VARCHAR(255) NOT NULL DEFAULT ''
);
CREATE TABLE registries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner_id, host)
);
CREATE TABLE service_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
@@ -20,6 +20,10 @@ import {
createUserToken,
listUserTokens,
revokeUserToken,
listRegistries,
createRegistry,
deleteRegistry,
type Registry,
listDatabases,
databaseAction,
updateDatabaseBackupSchedule,
@@ -1430,6 +1434,135 @@ function UserTokensSection({ isAdmin }: { isAdmin: boolean }) {
);
}
function RegistriesSection() {
const queryClient = useQueryClient();
const registriesQuery = useQuery({
queryKey: ['registries'],
queryFn: listRegistries,
});
const [name, setName] = useState('');
const [host, setHost] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const createMutation = useMutation({
mutationFn: () =>
createRegistry({
name: name.trim(),
host: host.trim(),
username: username.trim() || undefined,
password: password || undefined,
}),
onSuccess: () => {
setName('');
setHost('');
setUsername('');
setPassword('');
queryClient.invalidateQueries({ queryKey: ['registries'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRegistry(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['registries'] }),
});
const registries = registriesQuery.data ?? [];
return (
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Archive size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Registry credentials</h2>
<p className="text-xs text-[var(--text-tertiary)]">Pull private images on deploy - matched by image host, stored encrypted</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (e.g. ghcr)"
className="h-9 flex-1 min-w-[120px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="Host (ghcr.io, docker.io)"
className="h-9 flex-1 min-w-[160px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
className="h-9 w-40 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password / token"
className="h-9 w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !name.trim() || !host.trim()}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{createMutation.isPending ? 'Adding…' : 'Add registry'}
</button>
</div>
{createMutation.isError ? (
<p className="text-xs text-[var(--error)]">
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to add registry'}
</p>
) : null}
{registriesQuery.isLoading ? (
<div className="py-4 text-center">
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : registries.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
No credentials. Image pulls are anonymous unless a registry matching the image host exists here.
</p>
) : (
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
{registries.map((r: Registry) => (
<div key={r.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{r.name}</span>
<span className="ml-2 mono text-[var(--text-muted)]">{r.host}</span>
{r.username ? <span className="ml-2 text-[var(--text-secondary)]">{r.username}</span> : null}
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">
{r.has_password ? 'password set' : 'anonymous'}
</span>
</div>
<button
onClick={() => {
if (window.confirm(`Remove credentials for ${r.host}?`)) {
deleteMutation.mutate(r.id);
}
}}
disabled={deleteMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
</section>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -1603,6 +1736,7 @@ export function SettingsPage() {
{/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
<RegistriesSection />
{/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
@@ -43,6 +43,9 @@ import {
removeServiceDomain,
setDefaultServiceDomain,
checkServiceDomains,
cloneService,
moveService,
listProjects,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
@@ -412,6 +415,36 @@ export function ServiceDetailPage() {
},
});
const projectsQuery = useQuery({
queryKey: ['projects'],
queryFn: () => listProjects({ limit: 100 }),
enabled: !isDemoMode && activeSection === 'settings',
});
const cloneMutation = useMutation({
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['project-services'] });
const targetProject = lifecycleForm?.cloneProject || projectId;
navigate(`/projects/${targetProject}/services/${data.service_id}`);
},
});
const moveMutation = useMutation({
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
onSuccess: (_data, targetProjectId) => {
queryClient.invalidateQueries({ queryKey: ['project-services'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
},
});
const [lifecycleForm, setLifecycleForm] = useState<{
cloneName: string;
cloneProject: string;
moveTarget: string;
} | null>(null);
const invalidateService = () => {
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
@@ -1222,8 +1255,9 @@ export function ServiceDetailPage() {
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
<p className="text-sm text-[var(--text-secondary)]">
Runtime values injected at deploy. Reference another service's variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
shared variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
</p>
</div>
</div>
@@ -2632,6 +2666,135 @@ export function ServiceDetailPage() {
</div>
</div>
)}
{!isDemoMode && signedIn && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
{lifecycleForm === null && (
<button
type="button"
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Manage
</button>
)}
</div>
{lifecycleForm === null ? (
<p className="text-xs text-[var(--text-tertiary)]">
Duplicate this service into the same or another project, move it between projects, or delete it.
</p>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
<div className="flex flex-wrap items-center gap-2">
<input
value={lifecycleForm.cloneName}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
placeholder={`${service.name}-copy`}
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<select
value={lifecycleForm.cloneProject}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">same project</option>
{(projectsQuery.data ?? [])
.filter((p) => p.id !== projectId)
.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button"
disabled={cloneMutation.isPending}
onClick={() =>
cloneMutation.mutate({
name: lifecycleForm.cloneName.trim() || undefined,
project_id: lifecycleForm.cloneProject || undefined,
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
</button>
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
Copies config, volumes, domains, and variables. The clone starts stopped.
</p>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
<div className="flex flex-wrap items-center gap-2">
<select
value={lifecycleForm.moveTarget}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">choose a project…</option>
{(projectsQuery.data ?? [])
.filter((p) => p.id !== projectId)
.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button"
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
onClick={() => {
if (window.confirm(`Move ${service.name} to another project?`)) {
moveMutation.mutate(lifecycleForm.moveTarget);
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
>
{moveMutation.isPending ? 'Moving…' : 'Move'}
</button>
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
Variables, domains, and volumes move with the service. Running containers keep running.
</p>
</div>
<div className="pt-3 border-t border-[var(--border-subtle)]">
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
<button
type="button"
disabled={deleteServiceMutation.isPending}
onClick={() => {
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
deleteServiceMutation.mutate();
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLifecycleForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Close
</button>
{(cloneMutation.isError || moveMutation.isError) && (
<p className="text-xs text-[var(--error)]">
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
</p>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listProjectVariables, updateProjectVariables } from '@/lib/api-client';
import { Loader2, Plus, Save, Trash2, KeyRound } from 'lucide-react';
type Row = { key: string; value: string; isSecret: boolean };
// SharedVariablesSection edits project-level variables referenced from
// service env values as ${{shared.KEY}}.
export function SharedVariablesSection({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<Row[] | null>(null);
const varsQuery = useQuery({
queryKey: ['project-variables', projectId],
queryFn: () => listProjectVariables(projectId),
});
const saveMutation = useMutation({
mutationFn: (rows: Row[]) =>
updateProjectVariables(
projectId,
rows
.filter((r) => r.key.trim())
.map((r) => ({ key: r.key.trim(), value: r.value, is_secret: r.isSecret })),
),
onSuccess: () => {
setDrafts(null);
queryClient.invalidateQueries({ queryKey: ['project-variables', projectId] });
},
});
const rows: Row[] = drafts ?? (varsQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret }));
const dirty = drafts !== null;
const updateRow = (i: number, patch: Partial<Row>) => {
const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
setDrafts(next);
};
return (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Shared Variables</h3>
<p className="mt-1 text-xs text-[var(--text-muted)]">
Reference from any service as{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>. Applied on next
deploy/redeploy — not live-updated.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setDrafts([...rows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 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)] transition-colors"
>
<Plus size={12} />
Add
</button>
<button
onClick={() => saveMutation.mutate(rows)}
disabled={!dirty || saveMutation.isPending}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] disabled:opacity-50 transition-all"
style={{ background: 'var(--accent-primary)' }}
>
{saveMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save
</button>
</div>
</div>
{varsQuery.isLoading ? (
<div className="flex items-center justify-center py-6 text-[var(--text-muted)]">
<Loader2 size={16} className="animate-spin" />
</div>
) : rows.length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No shared variables.</p>
) : (
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<input
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
placeholder="KEY"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<input
value={row.value}
onChange={(e) => updateRow(i, { value: e.target.value })}
type={row.isSecret ? 'password' : 'text'}
placeholder="value"
autoComplete="off"
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<button
type="button"
title={row.isSecret ? 'Secret — masked on save' : 'Mark as secret'}
onClick={() => updateRow(i, { isSecret: !row.isSecret })}
className={`w-9 h-9 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
}`}
>
<KeyRound size={12} />
</button>
<button
type="button"
onClick={() => setDrafts(rows.filter((_, idx) => idx !== i))}
className="w-9 h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{saveMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save shared variables'}
</p>
)}
</div>
);
}
@@ -26,6 +26,7 @@ import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
import { MetricsDashboard } from '../components/MetricsDashboard';
import { SharedVariablesSection } from '../components/SharedVariablesSection';
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
import {
ArrowLeft,
@@ -935,6 +936,8 @@ export function ProjectWorkspacePage() {
</div>
</div>
{!isDemoMode && signedIn && <SharedVariablesSection projectId={project.id} />}
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
<button
+382
View File
@@ -971,6 +971,276 @@ export interface paths {
patch?: never;
trace?: never;
};
"/services/{id}/clone": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clone service
* @description Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/** @description Name for the clone (default "<name>-copy") */
name?: string;
/**
* Format: uuid
* @description Target project (default same project; caller must own it)
*/
project_id?: string;
/** @enum {string} */
environment?: "production" | "preview" | "development";
};
};
};
responses: {
/** @description Clone created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** Format: uuid */
service_id?: string;
name?: string;
message?: string;
};
};
};
/** @description Name already exists in target project */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/move": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Move service
* @description Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** Format: uuid */
project_id: string;
};
};
};
responses: {
/** @description Service moved */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Name conflict in target project */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/registries": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List registry credentials
* @description Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Registry list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
registries?: components["schemas"]["Registry"][];
};
};
};
};
};
put?: never;
/**
* Add registry credential
* @description Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RegistryRequest"];
};
};
responses: {
/** @description Registry created */
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description A registry for this host already exists */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/registries/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Update registry credential
* @description Empty password keeps the stored credential — delete and recreate to remove a password.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RegistryRequest"];
};
};
responses: {
/** @description Registry updated */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Registry not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
post?: never;
/** Remove registry credential */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Registry removed */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Registry not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/services/{id}/domains": {
parameters: {
query?: never;
@@ -3570,6 +3840,98 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{id}/variables": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List shared project variables
* @description Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
*/
get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Variables retrieved */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
variables?: components["schemas"]["ServiceVariable"][];
};
};
};
/** @description Project not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
/**
* Replace shared project variables
* @description Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
*/
put: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateVariablesRequest"];
};
};
responses: {
/** @description Variables updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
variables?: components["schemas"]["ServiceVariable"][];
};
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/git/github-app/install-url": {
parameters: {
query?: never;
@@ -8221,6 +8583,26 @@ export interface components {
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
password: string;
};
Registry: {
/** Format: uuid */
id?: string;
name?: string;
/** @description Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub */
host?: string;
username?: string;
has_password?: boolean;
/** Format: date-time */
created_at?: string;
/** Format: date-time */
updated_at?: string;
};
RegistryRequest: {
name: string;
host: string;
username?: string;
/** @description Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential. */
password?: string;
};
ServiceVolume: {
/**
* @description volume = docker named volume, bind = host path
+78
View File
@@ -1185,6 +1185,68 @@ export async function deleteService(serviceId: string): Promise<void> {
});
}
export type CloneServiceInput = {
name?: string;
project_id?: string;
environment?: string;
};
export async function cloneService(serviceId: string, input: CloneServiceInput): Promise<{ service_id: string; name: string }> {
return requestJson<{ service_id: string; name: string }>(`/services/${serviceId}/clone`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function moveService(serviceId: string, projectId: string): Promise<void> {
await requestJson(`/services/${serviceId}/move`, {
method: 'POST',
body: JSON.stringify({ project_id: projectId }),
});
}
export type Registry = {
id: string;
name: string;
host: string;
username?: string;
has_password: boolean;
created_at?: string;
updated_at?: string;
};
export type RegistryInput = {
name: string;
host: string;
username?: string;
password?: string;
};
export async function listRegistries(): Promise<Registry[]> {
const payload = await requestJson<{ registries?: Registry[] }>('/registries');
return payload.registries ?? [];
}
export async function createRegistry(input: RegistryInput): Promise<Registry> {
const payload = await requestJson<{ registry: Registry }>('/registries', {
method: 'POST',
body: JSON.stringify(input),
});
return payload.registry;
}
export async function updateRegistry(id: string, input: RegistryInput): Promise<Registry> {
const payload = await requestJson<{ registry: Registry }>(`/registries/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
});
return payload.registry;
}
export async function deleteRegistry(id: string): Promise<void> {
await requestJson(`/registries/${id}`, { method: 'DELETE' });
}
function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
// The API emits null (not []) for empty collections — coerce at the boundary.
@@ -1248,6 +1310,22 @@ export async function updateServiceVariables(
return normalizeServiceVariables(payload.variables);
}
export async function listProjectVariables(projectId: string): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/projects/${projectId}/variables`);
return normalizeServiceVariables(payload.variables);
}
export async function updateProjectVariables(
projectId: string,
variables: UpdateServiceVariableInput[],
): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(
`/projects/${projectId}/variables`,
{ method: 'PUT', body: JSON.stringify({ variables }) },
);
return normalizeServiceVariables(payload.variables);
}
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
const searchParams = new URLSearchParams();
if (input.resource) {
+273
View File
@@ -667,6 +667,167 @@ paths:
'200':
description: Reconciled runtime state
/services/{id}/clone:
post:
tags:
- Services
summary: Clone service
description: Duplicates config, volumes, domains, access gates, builder settings, and variables into the same or another project. Containers are not cloned — the copy starts stopped.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: Name for the clone (default "<name>-copy")
project_id:
type: string
format: uuid
description: Target project (default same project; caller must own it)
environment:
type: string
enum: [production, preview, development]
responses:
'201':
description: Clone created
content:
application/json:
schema:
type: object
properties:
service_id:
type: string
format: uuid
name:
type: string
message:
type: string
'409':
description: Name already exists in target project
/services/{id}/move:
post:
tags:
- Services
summary: Move service
description: Re-parents the service to another project the caller owns. Variables, domains, and volumes follow the service; running containers keep running and join the new project's network on next deploy.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [project_id]
properties:
project_id:
type: string
format: uuid
responses:
'200':
description: Service moved
'409':
description: Name conflict in target project
/registries:
get:
tags:
- Registries
summary: List registry credentials
description: Private-image pull credentials for the caller. Passwords are never returned — `has_password` signals presence.
security:
- bearerAuth: []
responses:
'200':
description: Registry list
content:
application/json:
schema:
type: object
properties:
registries:
type: array
items:
$ref: '#/components/schemas/Registry'
post:
tags:
- Registries
summary: Add registry credential
description: Credentials are matched by image host (e.g. ghcr.io, registry.example.com, docker.io for Docker Hub) on prebuilt-image deploys. Password stored encrypted at rest.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegistryRequest'
responses:
'201':
description: Registry created
'409':
description: A registry for this host already exists
/registries/{id}:
put:
tags:
- Registries
summary: Update registry credential
description: Empty password keeps the stored credential — delete and recreate to remove a password.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegistryRequest'
responses:
'200':
description: Registry updated
'404':
description: Registry not found
delete:
tags:
- Registries
summary: Remove registry credential
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Registry removed
'404':
description: Registry not found
/services/{id}/domains:
get:
tags:
@@ -2388,6 +2549,78 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/projects/{id}/variables:
get:
tags:
- Projects
summary: List shared project variables
description: Project-level variables shared across services. Services reference them as `${{shared.KEY}}` in environment values — resolved at deploy/reconcile, not live-updated. Secret values are masked.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Variables retrieved
content:
application/json:
schema:
type: object
properties:
variables:
type: array
items:
$ref: '#/components/schemas/ServiceVariable'
'404':
description: Project not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
tags:
- Projects
summary: Replace shared project variables
description: Replace the full shared-variable set for a project. Fetch the current list first and merge — the server replaces wholesale. Masked secret placeholders (`********`) keep the stored value.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateVariablesRequest'
responses:
'200':
description: Variables updated
content:
application/json:
schema:
type: object
properties:
variables:
type: array
items:
$ref: '#/components/schemas/ServiceVariable'
'400':
description: Invalid input
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# Git integration
/git/github-app/install-url:
get:
@@ -5413,6 +5646,44 @@ components:
type: string
description: Plaintext on write — stored as bcrypt htpasswd; never returned
Registry:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
host:
type: string
description: Registry host matched against the image reference — ghcr.io, registry.example.com, docker.io for Docker Hub
username:
type: string
has_password:
type: boolean
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
RegistryRequest:
type: object
required:
- name
- host
properties:
name:
type: string
host:
type: string
username:
type: string
password:
type: string
description: Plaintext on write — stored encrypted; never returned. Empty on update keeps the stored credential.
ServiceVolume:
type: object
required:
@@ -7732,5 +8003,7 @@ tags:
description: Vulnerability scans and compliance reporting
- name: Cron
description: Scheduled jobs
- name: Registries
description: Private-image pull credentials
- name: Gateway
description: API gateway — upstream services, keys, analytics
+27
View File
@@ -38,13 +38,16 @@ auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens
projects list|get|create|delete
services list|get|create|update|delete|start|stop|restart|redeploy
services clone|move duplicate config or re-parent a service
services domains list|add|remove|default|check multi-domain + DNS preflight
registries list|add|update|remove private-image pull credentials
volumes list|delete docker volume inventory + cleanup (admin)
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
deployments list|get|logs|rollback|cancel
logs <service-id> [--follow] runtime logs (SSE follow)
exec <service-id> -- <cmd> one-off container command
variables list|set|unset env vars (secrets masked; --redeploy applies them)
variables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}
databases list|get|create|delete|action|backup|restore|download-backup
cron list|get|create|delete|trigger|executions
templates list|get|create|delete|deploy
@@ -143,6 +146,30 @@ instance-level defaults used at service creation;
`app_settings.capacity_policy=block` fails deployments whose requested
memory exceeds node capacity (default `warn` logs only).
## Clone, move, registries
`services clone` duplicates config, volumes, domains, access gates,
builder settings, and variables — ciphertext secret values are copied
verbatim (no decrypt/re-encrypt). The clone starts stopped:
```bash
containr services clone <id> --name staging-api --project <other-project-id> --environment preview
containr services move <id> <project-id> # name conflicts → 409
```
Note: cloned domains are verbatim copies — the same hostname attached to
two services makes Traefik routing ambiguous. Re-domain or undeploy one.
`registries` stores private-image pull credentials, owner-scoped and
encrypted at rest. Matched by image host on every image-sourced deploy:
```bash
containr registries add ghcr ghcr.io --username <user> --password <pat>
containr registries add hub docker.io --username <user> --password <pw> # Docker Hub
containr registries update <id> --username <new-user> # empty --password keeps stored
containr registries remove <id>
```
## MCP server
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
+4 -1
View File
@@ -16,14 +16,17 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
- **Variables**: service env values support `${{service.KEY}}` cross-service refs (builtins HOST/PORT) and `${{shared.KEY}}` project shared variables (`GET|PUT /projects/{id}/variables`, CLI `variables shared …`). Resolved at deploy/reconcile, not live-updated.
- **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin).
- **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only.
- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`).
- **Clone & move**: `POST /services/{id}/clone` `{name?, project_id?, environment?}` copies config, volumes, domains, access gates, and variables (secret ciphertext verbatim; clone starts stopped). `POST /services/{id}/move` `{project_id}` re-parents a service — both require ownership of source and target; name conflicts → `CONFLICT`. Cloned domains are verbatim copies: the same hostname on two services makes routing ambiguous.
- **Registries**: `GET|POST /registries`, `PUT|DELETE /registries/{id}` — owner-scoped private-image pull credentials, encrypted at rest, never returned (`has_password` signals presence). `host` matched against the image reference on image deploys (`docker.io` for Docker Hub; scheme/case normalized). Empty `password` on update keeps the stored credential.
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
`projects` → `services` (git/app deploys) → `deployments`; plus `databases`, `templates`, `cron-jobs`, `variables`, `volumes`, `domains`, `registries`, `preview-environments`, `nodes`, `scaling`, `security`, `gateway`, `admin`, `notifications`, `user/tokens`.
## Docs
+10 -3
View File
@@ -21,18 +21,25 @@ for platform in $PLATFORMS; do
GOARCH=${platform#*/}
OUTPUT_NAME="containr-${GOOS}-${GOARCH}"
MCP_NAME="containr-mcp-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe"
MCP_NAME="${MCP_NAME}.exe"
fi
echo "📦 Building for ${platform}..."
echo "📦 Building CLI for ${platform}..."
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags "$LDFLAGS" \
-o "bin/${OUTPUT_NAME}" \
./cmd/cli
echo "✅ Built bin/${OUTPUT_NAME}"
echo "📦 Building MCP server for ${platform}..."
GOOS=$GOOS GOARCH=$GOARCH go build \
-ldflags "$LDFLAGS" \
-o "bin/${MCP_NAME}" \
./cmd/mcp
echo "✅ Built bin/${OUTPUT_NAME} + bin/${MCP_NAME}"
done
echo "🎉 CLI build complete!"