Compare commits

...
2 Commits
Author SHA1 Message Date
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
Tomas DvorakandDevin 912f6f5142 feat(services): builder selection, resource reservations, static builder (C3)
services.builder picks the build strategy per service: auto detects from
repo contents (Dockerfile → railpack → nixpacks), explicit overrides force
a builder, and static builds assets in a node stage then serves them from
an nginx image (static_build_cmd/static_dir, port 80 default).

- cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation;
  cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears.
- app_settings.default_cpu/default_memory override service-create
  fallbacks; capacity_policy=block fails deploys whose requested memory
  exceeds node capacity (default warn logs via slog).
- Resource specs are validated — "512mb" fails instead of silently
  dropping the limit.
- Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code);
  DockerfileBuilder passed the Dockerfile *path* as build-context content;
  BuildImage never drained the daemon stream so builds could abort and
  errors went unseen — stream is now consumed and errorDetail surfaced.
- CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir
  on create+update; MCP args on services create/update; UI Build &
  Resources card; OpenAPI + llms.txt + CLI.md.

Verified live: static build of a real repo → nginx image → deployed;
reserves reach HostConfig (shares=256, MemoryReservation=128Mi);
validation rejects bad specs; builder persists + clears.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:08:35 +02:00
30 changed files with 2389 additions and 72 deletions

No files matched your search

+91 -13
View File
@@ -4,9 +4,11 @@ import (
"containr/internal/database" "containr/internal/database"
"containr/internal/deployment" "containr/internal/deployment"
"containr/internal/deployqueue" "containr/internal/deployqueue"
"containr/internal/docker"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -232,6 +234,9 @@ func handleCreateDeployment(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -242,7 +247,9 @@ func handleCreateDeployment(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
) )
if err != nil { if err != nil {
@@ -401,6 +408,13 @@ func runDeploymentAndSyncWithImage(
// ephemeral ports. // ephemeral ports.
_ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort)
publicPort := int32(service.Port)
if service.Builder == "static" && publicPort == 0 {
// The static builder's image is nginx — route to its default port
// unless the user picked another.
publicPort = 80
}
maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID) maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID)
maintURL := "" maintURL := ""
if maintenanceMode { if maintenanceMode {
@@ -417,7 +431,7 @@ func runDeploymentAndSyncWithImage(
Command: command, Command: command,
Environment: env, Environment: env,
Replicas: replicas, Replicas: replicas,
PublicPort: int32(service.Port), PublicPort: publicPort,
PublishedPort: publishedPort, PublishedPort: publishedPort,
Domain: service.Domain, Domain: service.Domain,
HealthPath: service.HealthCheckPath, HealthPath: service.HealthCheckPath,
@@ -425,8 +439,10 @@ func runDeploymentAndSyncWithImage(
VolumeMounts: loadServiceVolumes(db, service.ID), VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain), Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{ Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory), MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU), MemoryReservation: parseMemoryLimit(service.MemoryReserve),
CPUQuota: parseCPULimit(service.CPU),
CPUShares: parseCPUReserveShares(service.CPUReserve),
}, },
Maintenance: maintenanceMode, Maintenance: maintenanceMode,
MaintenanceURL: maintURL, MaintenanceURL: maintURL,
@@ -440,25 +456,50 @@ func runDeploymentAndSyncWithImage(
}, },
} }
if imageOverride != "" { if imageOverride != "" || service.GitRepo == "" {
deployReq.BuildConfig = &deployment.BuildConfig{ // Prebuilt image deploy (rollback, redeploy, or image-sourced
BuildType: "prebuilt", // service): resolve private-registry credentials by image host.
PrebuiltImage: imageOverride, image := imageOverride
if image == "" {
image = service.Image
} }
} else if service.GitRepo == "" { var ownerID string
// Image-sourced service: pull the image instead of building. _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID)
auth := registryAuthFor(db, ownerID, image)
deployReq.BuildConfig = &deployment.BuildConfig{ deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "prebuilt", BuildType: "prebuilt",
PrebuiltImage: service.Image, PrebuiltImage: image,
PullUsername: auth.Username,
PullPassword: auth.Password,
} }
} else { } else {
// "auto" leaves BuildType empty so the manager detects it; the static
// builder additionally feeds its generated Dockerfile the service's
// build command and output dir.
buildType := ""
if service.Builder != "" && service.Builder != "auto" {
buildType = service.Builder
}
deployReq.BuildConfig = &deployment.BuildConfig{ deployReq.BuildConfig = &deployment.BuildConfig{
BuildType: "nixpacks", BuildType: buildType,
SourcePath: sourcePath, SourcePath: sourcePath,
Branch: req.Branch, Branch: req.Branch,
Commit: req.CommitHash, Commit: req.CommitHash,
NoCache: req.NoCache, NoCache: req.NoCache,
} }
if buildType == "static" {
deployReq.BuildConfig.BuildCommand = firstNonEmpty(service.StaticBuildCmd, "npm ci && npm run build")
deployReq.BuildConfig.BuildArgs = map[string]string{"STATIC_DIR": firstNonEmpty(service.StaticDir, "dist")}
}
}
if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" {
failedAt := time.Now()
_, _ = db.Exec(
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
msg, failedAt, dbDeployment.ID,
)
return
} }
engineDeployment, err := engine.Deploy(ctx, deployReq) engineDeployment, err := engine.Deploy(ctx, deployReq)
@@ -822,6 +863,9 @@ func handleRollbackDeployment(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s WHERE s.id = $1`, serviceID, FROM services s WHERE s.id = $1`, serviceID,
).Scan( ).Scan(
@@ -829,7 +873,9 @@ func handleRollbackDeployment(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
) )
rollbackReq := CreateDeploymentRequest{ rollbackReq := CreateDeploymentRequest{
@@ -863,3 +909,35 @@ func handleRollbackDeployment(c *gin.Context) {
"message": "Rollback initiated", "message": "Rollback initiated",
}) })
} }
// capacityCheck compares the requested per-replica memory (limit, falling
// back to reservation) against node capacity. app_settings.capacity_policy
// = "block" fails the deployment; the default "warn" only logs. CPU is not
// gated — shares are a weight, not a reservation.
func capacityCheck(ctx context.Context, db *database.DB, client *docker.Client, service Service, replicas int) string {
if client == nil {
return ""
}
requested := parseMemoryLimit(service.Memory)
if requested == 0 {
requested = parseMemoryLimit(service.MemoryReserve)
}
if requested == 0 {
return ""
}
info, err := client.GetSystemInfo(ctx)
if err != nil || info.MemTotal <= 0 {
return ""
}
need := requested * int64(replicas)
if need <= info.MemTotal {
return ""
}
msg := fmt.Sprintf("requested %d bytes x %d replicas exceeds node memory (%d bytes)",
requested, replicas, info.MemTotal)
if strings.ToLower(settingValue(db, "capacity_policy", "", "warn")) == "block" {
return msg
}
slog.Warn("capacity check exceeded", "service_id", service.ID, "detail", msg)
return ""
}
+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,
}
}
@@ -0,0 +1,48 @@
package api
import "testing"
func TestParseCPUReserveShares(t *testing.T) {
cases := map[string]int64{
"": 0,
"0": 0,
"-1": 0,
"abc": 0,
"0.25": 256,
"1": 1024,
"2.5": 2560,
" 0.5 ": 512,
}
for in, want := range cases {
if got := parseCPUReserveShares(in); got != want {
t.Errorf("parseCPUReserveShares(%q) = %d, want %d", in, got, want)
}
}
}
func TestValidateResourceSpecs(t *testing.T) {
if err := validateCPUSpec(""); err != nil {
t.Error("empty cpu should pass")
}
if err := validateCPUSpec("0.5"); err != nil {
t.Error("0.5 should be valid cpu")
}
if err := validateCPUSpec("512mb"); err == nil {
t.Error("512mb should fail cpu validation")
}
if err := validateMemorySpec(""); err != nil {
t.Error("empty memory should pass")
}
if err := validateMemorySpec("512Mi"); err != nil {
t.Error("512Mi should be valid memory")
}
if err := validateMemorySpec("1g"); err != nil {
t.Error("1g should be valid memory")
}
if err := validateMemorySpec("0.5"); err == nil {
t.Error("0.5 should fail memory validation (parses to <1 byte)")
}
if err := validateMemorySpec("lots"); err == nil {
t.Error("'lots' should fail memory validation")
}
}
+8
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/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart) authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/redeploy", handleServiceRedeploy) 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("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment) authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
authed.POST("/deployments/:id/cancel", handleCancelDeployment) authed.POST("/deployments/:id/cancel", handleCancelDeployment)
@@ -352,6 +354,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// One-off exec console (docker exec, 30s ceiling) // One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService) 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. // Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp) authed.POST("/git/github-app/connect", handleConnectGitHubApp)
+51 -3
View File
@@ -53,6 +53,13 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
} }
spec.NanoCPUs = parseCPULimit(service.CPU) spec.NanoCPUs = parseCPULimit(service.CPU)
spec.MemoryBytes = parseMemoryLimit(service.Memory) spec.MemoryBytes = parseMemoryLimit(service.Memory)
spec.MemoryReserve = parseMemoryLimit(service.MemoryReserve)
spec.CPUShares = parseCPUReserveShares(service.CPUReserve)
if service.Builder == "static" && spec.Port == 0 {
// The static builder's image is nginx; expose its default port so
// Traefik has something to route to without extra config.
spec.Port = 80
}
spec.Volumes = loadServiceVolumes(db, service.ID) spec.Volumes = loadServiceVolumes(db, service.ID)
spec.Domains = serviceDomainNames(db, service.ID, service.Domain) spec.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID) spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
@@ -160,6 +167,32 @@ func parseCPULimit(cpu string) int64 {
return int64(v * 1e9) return int64(v * 1e9)
} }
// parseCPUReserveShares maps a cpu fraction to Docker's relative share
// weight: 1024 shares ≈ one CPU.
func parseCPUReserveShares(cpu string) int64 {
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
if err != nil || v <= 0 {
return 0
}
return int64(v * 1024)
}
// validateCPUSpec and validateMemorySpec reject values that silently parse
// to zero — a typo like "512mb" must fail loudly instead of dropping the limit.
func validateCPUSpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseCPULimit(s) <= 0 {
return fmt.Errorf("must be a positive cpu fraction (e.g. 0.5, 2)")
}
return nil
}
func validateMemorySpec(v string) error {
if s := strings.TrimSpace(v); s != "" && parseMemoryLimit(s) <= 0 {
return fmt.Errorf("must be bytes with unit (e.g. 256Mi, 1g)")
}
return nil
}
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30} var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
func parseMemoryLimit(mem string) int64 { func parseMemoryLimit(mem string) int64 {
@@ -215,6 +248,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, WHERE s.id = $1`,
@@ -224,7 +260,9 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
) )
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
@@ -258,6 +296,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, s.project_id s.created_at, s.updated_at, s.project_id
FROM services s FROM services s
WHERE s.id = $1`, WHERE s.id = $1`,
@@ -267,7 +308,9 @@ func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &projectID,
) )
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
@@ -549,6 +592,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at, p.owner_id s.created_at, s.updated_at, p.owner_id
FROM services s JOIN projects p ON s.project_id = p.id FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`, serviceID, WHERE s.id = $1`, serviceID,
@@ -557,7 +603,9 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt, &owner,
) )
if err != nil { if err != nil {
return err return err
+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
}
+105 -9
View File
@@ -41,6 +41,11 @@ type Service struct {
Domains []ServiceDomain `json:"domains,omitempty" db:"-"` Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"` MaintenanceMode bool `json:"maintenance_mode" db:"-"`
BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes
Builder string `json:"builder" db:"builder"` // auto|railpack|nixpacks|dockerfile|static
CPUReserve string `json:"cpu_reserve,omitempty" db:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve,omitempty" db:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd,omitempty" db:"static_build_cmd"`
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
@@ -117,6 +122,11 @@ type CreateServiceRequest struct {
Volumes []ServiceVolume `json:"volumes"` Volumes []ServiceVolume `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"` MaintenanceMode bool `json:"maintenance_mode"`
BasicAuth []BasicAuthCred `json:"basic_auth"` BasicAuth []BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve string `json:"cpu_reserve"`
MemoryReserve string `json:"memory_reserve"`
StaticBuildCmd string `json:"static_build_cmd"`
StaticDir string `json:"static_dir"`
} }
// UpdateServiceRequest represents a request to update a service // UpdateServiceRequest represents a request to update a service
@@ -139,6 +149,11 @@ type UpdateServiceRequest struct {
Volumes *[]ServiceVolume `json:"volumes"` Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"` MaintenanceMode *bool `json:"maintenance_mode"`
BasicAuth *[]BasicAuthCred `json:"basic_auth"` BasicAuth *[]BasicAuthCred `json:"basic_auth"`
Builder string `json:"builder" binding:"omitempty,oneof=auto railpack nixpacks dockerfile static"`
CPUReserve *string `json:"cpu_reserve"`
MemoryReserve *string `json:"memory_reserve"`
StaticBuildCmd *string `json:"static_build_cmd"`
StaticDir *string `json:"static_dir"`
} }
// handleGetServices retrieves all services for a project // handleGetServices retrieves all services for a project
@@ -183,6 +198,9 @@ func handleGetServices(c *gin.Context) {
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(replicas, 1), COALESCE(port, 0),
COALESCE(domain, ''), COALESCE(healthcheck_path, ''), COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
COALESCE(restart_policy, 'unless-stopped'), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''),
COALESCE(memory_reserve, ''), COALESCE(static_build_cmd, ''),
COALESCE(static_dir, ''),
created_at, updated_at created_at, updated_at
FROM services FROM services
WHERE project_id = $1 WHERE project_id = $1
@@ -203,7 +221,9 @@ func handleGetServices(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
@@ -314,12 +334,38 @@ func handleCreateService(c *gin.Context) {
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
// Set default values if not provided // Set default values if not provided; app_settings.default_cpu /
// default_memory override the hardcoded fallbacks.
if service.CPU == "" { if service.CPU == "" {
service.CPU = "0.5" service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
} }
if service.Memory == "" { if service.Memory == "" {
service.Memory = "512Mi" service.Memory = firstNonEmpty(settingValue(db.(*database.DB), "default_memory", "", ""), "512Mi")
}
if req.Builder == "" {
service.Builder = "auto"
} else {
service.Builder = req.Builder
}
service.CPUReserve = req.CPUReserve
service.MemoryReserve = req.MemoryReserve
service.StaticBuildCmd = req.StaticBuildCmd
service.StaticDir = req.StaticDir
if service.Builder == "static" && service.StaticDir == "" {
service.StaticDir = "dist"
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
} {
v := map[string]string{
"cpu": service.CPU, "cpu_reserve": service.CPUReserve,
"memory": service.Memory, "memory_reserve": service.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
} }
if service.Replicas < 1 { if service.Replicas < 1 {
service.Replicas = 1 service.Replicas = 1
@@ -351,14 +397,18 @@ func handleCreateService(c *gin.Context) {
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name, (id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment, build_command, start_command, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, created_at, updated_at) healthcheck_path, restart_policy, volumes,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)`, builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
$27, $28, $29, $30, $31, $32, $33)`,
service.ID, service.ProjectID, service.Name, environmentID, service.Type, service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command, "", service.Command, service.Type, service.Status, service.Image, service.Command,
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy, service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
volumesJSON, service.CreatedAt, service.UpdatedAt, volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -443,6 +493,9 @@ func handleGetService(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -454,7 +507,9 @@ func handleGetService(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo, &service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -520,6 +575,9 @@ func handleUpdateService(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0), COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'), COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at s.created_at, s.updated_at
FROM services s FROM services s
JOIN projects p ON s.project_id = p.id JOIN projects p ON s.project_id = p.id
@@ -532,6 +590,9 @@ func handleUpdateService(c *gin.Context) {
&existingService.BuildPath, &existingService.CPU, &existingService.Memory, &existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.Replicas, &existingService.Port, &existingService.Domain, &existingService.Replicas, &existingService.Port, &existingService.Domain,
&existingService.HealthCheckPath, &existingService.RestartPolicy, &existingService.HealthCheckPath, &existingService.RestartPolicy,
&existingService.Builder, &existingService.CPUReserve,
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
&existingService.StaticDir,
&existingService.CreatedAt, &existingService.UpdatedAt, &existingService.CreatedAt, &existingService.UpdatedAt,
) )
@@ -586,6 +647,37 @@ func handleUpdateService(c *gin.Context) {
if req.RestartPolicy != "" { if req.RestartPolicy != "" {
existingService.RestartPolicy = req.RestartPolicy existingService.RestartPolicy = req.RestartPolicy
} }
if req.Builder != "" {
existingService.Builder = req.Builder
}
if req.CPUReserve != nil {
existingService.CPUReserve = *req.CPUReserve
}
if req.MemoryReserve != nil {
existingService.MemoryReserve = *req.MemoryReserve
}
if req.StaticBuildCmd != nil {
existingService.StaticBuildCmd = *req.StaticBuildCmd
}
if req.StaticDir != nil {
existingService.StaticDir = *req.StaticDir
}
if existingService.Builder == "static" && existingService.StaticDir == "" {
existingService.StaticDir = "dist"
}
for label, check := range map[string]func(string) error{
"cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec,
"memory": validateMemorySpec, "memory_reserve": validateMemorySpec,
} {
v := map[string]string{
"cpu": existingService.CPU, "cpu_reserve": existingService.CPUReserve,
"memory": existingService.Memory, "memory_reserve": existingService.MemoryReserve,
}[label]
if err := check(v); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s: %s", label, err.Error()), "code": "VALIDATION"})
return
}
}
// lib/pq sends a nil []byte as an empty bytea literal rather than NULL, // lib/pq sends a nil []byte as an empty bytea literal rather than NULL,
// so the COALESCE fallback needs an untyped nil when volumes aren't sent. // so the COALESCE fallback needs an untyped nil when volumes aren't sent.
var volumesArg interface{} var volumesArg interface{}
@@ -607,7 +699,9 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5, SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
replicas = $11, port = $12, domain = $13, healthcheck_path = $14, replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16 restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23
WHERE id = $18`, WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -615,6 +709,8 @@ func handleUpdateService(c *gin.Context) {
existingService.Replicas, existingService.Port, existingService.Domain, existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy, existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, volumesArg, existingService.ID, existingService.UpdatedAt, volumesArg, existingService.ID,
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
existingService.StaticBuildCmd, existingService.StaticDir,
) )
if err != nil { if err != nil {
+4 -2
View File
@@ -218,12 +218,14 @@ func handleUpdateVariables(c *gin.Context) {
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''), `SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''), COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'), COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
COALESCE(cpu, ''), COALESCE(memory, '') COALESCE(cpu, ''), COALESCE(memory, ''),
COALESCE(builder, 'auto'), COALESCE(cpu_reserve, ''), COALESCE(memory_reserve, '')
FROM services WHERE id = $1`, FROM services WHERE id = $1`,
serviceID, serviceID,
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command, ).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath, &svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
&svc.RestartPolicy, &svc.CPU, &svc.Memory) &svc.RestartPolicy, &svc.CPU, &svc.Memory,
&svc.Builder, &svc.CPUReserve, &svc.MemoryReserve)
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc) resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
} }
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
+6 -2
View File
@@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
// Build Docker image // Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar // createBuildContext wants the Dockerfile contents, not its path.
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile) dockerfileContent, err := os.ReadFile(optimizedDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to read Dockerfile: %w", err)
}
buildCtx, err := createBuildContext(req.SourcePath, string(dockerfileContent))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err) return nil, fmt.Errorf("failed to create build context: %w", err)
} }
+6 -1
View File
@@ -16,6 +16,7 @@ type BuildManager struct {
railpackBuilder *RailpackBuilder railpackBuilder *RailpackBuilder
nixpacksBuilder *NixpacksBuilder nixpacksBuilder *NixpacksBuilder
dockerfileBuilder *DockerfileBuilder dockerfileBuilder *DockerfileBuilder
staticBuilder *StaticBuilder
dockerClient *docker.Client dockerClient *docker.Client
cacheManager *CacheManager cacheManager *CacheManager
parallelBuilder *ParallelBuilder parallelBuilder *ParallelBuilder
@@ -28,6 +29,7 @@ const (
BuildTypeRailpack BuildType = "railpack" BuildTypeRailpack BuildType = "railpack"
BuildTypeNixpacks BuildType = "nixpacks" BuildTypeNixpacks BuildType = "nixpacks"
BuildTypeDockerfile BuildType = "dockerfile" BuildTypeDockerfile BuildType = "dockerfile"
BuildTypeStatic BuildType = "static"
BuildTypePrebuilt BuildType = "prebuilt" BuildTypePrebuilt BuildType = "prebuilt"
) )
@@ -39,6 +41,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager
railpackBuilder: NewRailpackBuilder(workDir, dockerClient), railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient), nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient), dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
staticBuilder: NewStaticBuilder(dockerClient),
dockerClient: dockerClient, dockerClient: dockerClient,
cacheManager: cacheManager, cacheManager: cacheManager,
parallelBuilder: parallelBuilder, parallelBuilder: parallelBuilder,
@@ -80,6 +83,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty
return bm.nixpacksBuilder.Build(ctx, req) return bm.nixpacksBuilder.Build(ctx, req)
case BuildTypeDockerfile: case BuildTypeDockerfile:
return bm.dockerfileBuilder.Build(ctx, req) return bm.dockerfileBuilder.Build(ctx, req)
case BuildTypeStatic:
return bm.staticBuilder.Build(ctx, req)
case BuildTypePrebuilt: case BuildTypePrebuilt:
return bm.buildPrebuilt(ctx, req) return bm.buildPrebuilt(ctx, req)
default: default:
@@ -95,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 // Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image. // 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 { if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err) return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
} }
+95
View File
@@ -0,0 +1,95 @@
package build
import (
"context"
"fmt"
"containr/internal/docker"
"containr/internal/types"
)
// StaticBuilder produces an nginx-served image from a repo's built assets.
// STATIC_BUILD_CMD runs inside a node build stage; STATIC_DIR names the
// output directory copied into the nginx image. Both are overridable via
// build args so repos with non-node toolchains can swap the build image too.
type StaticBuilder struct {
dockerClient *docker.Client
}
func NewStaticBuilder(dockerClient *docker.Client) *StaticBuilder {
return &StaticBuilder{dockerClient: dockerClient}
}
const staticDockerfile = `ARG BUILD_IMAGE=node:20-alpine
FROM ${BUILD_IMAGE} AS assets
WORKDIR /app
COPY . .
ARG STATIC_BUILD_CMD
RUN sh -c "${STATIC_BUILD_CMD}"
FROM nginx:alpine
ARG STATIC_DIR=dist
COPY --from=assets /app/${STATIC_DIR} /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`
func (s *StaticBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
buildCmd := req.BuildCommand
if buildCmd == "" {
buildCmd = "npm ci && npm run build"
}
staticDir := req.BuildArgs["STATIC_DIR"]
if staticDir == "" {
staticDir = "dist"
}
buildArgs := map[string]*string{}
for k, v := range req.BuildArgs {
v := v
buildArgs[k] = &v
}
for k, v := range req.Environment {
v := v
buildArgs[k] = &v
}
buildArgs["STATIC_BUILD_CMD"] = &buildCmd
buildArgs["STATIC_DIR"] = &staticDir
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
buildCtx, err := createBuildContext(req.SourcePath, staticDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
defer buildCtx.Close()
if _, err := s.dockerClient.BuildImage(ctx, buildCtx, docker.BuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
NoCache: req.NoCache,
Remove: true,
}); err != nil {
return nil, fmt.Errorf("failed to build static image: %w", err)
}
if req.RegistryURL != "" {
full := fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
if err := s.dockerClient.PushImage(ctx, imageName, req.RegistryURL); err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
imageName = full
}
imageInfo, err := s.dockerClient.GetImageInfo(ctx, imageName)
if err != nil {
imageInfo = &docker.ImageInfo{Size: 0}
}
return &types.BuildResponse{
ImageName: imageName,
ImageTag: req.ImageTag,
Size: imageInfo.Size,
Digest: imageInfo.Digest,
}, nil
}
@@ -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)
}
+88 -3
View File
@@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{
body := map[string]interface{}{ body := map[string]interface{}{
"name": args[1], "name": args[1],
} }
for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} { for _, f := range []string{"type", "image", "git-repo", "git-branch", "build-path", "command", "environment", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
if v, _ := cmd.Flags().GetString(f); v != "" { if v, _ := cmd.Flags().GetString(f); v != "" {
key := f key := f
// JSON uses snake_case keys matching the API schema. // JSON uses snake_case keys matching the API schema.
@@ -94,6 +94,14 @@ var servicesCreateCmd = &cobra.Command{
key = "restart_policy" key = "restart_policy"
case "healthcheck-path": case "healthcheck-path":
key = "healthcheck_path" key = "healthcheck_path"
case "cpu-reserve":
key = "cpu_reserve"
case "memory-reserve":
key = "memory_reserve"
case "static-cmd":
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
} }
body[key] = v body[key] = v
} }
@@ -151,7 +159,7 @@ var servicesUpdateCmd = &cobra.Command{
return err return err
} }
body := map[string]interface{}{} body := map[string]interface{}{}
for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory"} { for _, f := range []string{"name", "image", "command", "domain", "restart-policy", "healthcheck-path", "cpu", "memory", "builder", "cpu-reserve", "memory-reserve", "static-cmd", "static-dir"} {
if cmd.Flags().Changed(f) { if cmd.Flags().Changed(f) {
key := f key := f
switch f { switch f {
@@ -159,6 +167,14 @@ var servicesUpdateCmd = &cobra.Command{
key = "restart_policy" key = "restart_policy"
case "healthcheck-path": case "healthcheck-path":
key = "healthcheck_path" key = "healthcheck_path"
case "cpu-reserve":
key = "cpu_reserve"
case "memory-reserve":
key = "memory_reserve"
case "static-cmd":
key = "static_build_cmd"
case "static-dir":
key = "static_dir"
} }
v, _ := cmd.Flags().GetString(f) v, _ := cmd.Flags().GetString(f)
body[key] = v body[key] = v
@@ -210,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 // volumeFlagValues parses repeated "src:target[:ro]" flags into API mount
// objects. Bind mounts are type=bind, named volumes type=volume. // objects. Bind mounts are type=bind, named volumes type=volume.
func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} { func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interface{} {
@@ -406,6 +477,11 @@ func init() {
f.String("memory", "", "memory limit (e.g. 512m)") f.String("memory", "", "memory limit (e.g. 512m)")
f.Int("port", 0, "container port") f.Int("port", 0, "container port")
f.Int("replicas", 0, "replica count") f.Int("replicas", 0, "replica count")
f.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
f.String("cpu-reserve", "", "soft CPU reservation (e.g. 0.25)")
f.String("memory-reserve", "", "soft memory reservation (e.g. 128Mi)")
f.String("static-cmd", "", "build command for the static builder")
f.String("static-dir", "", "output dir for the static builder (default dist)")
uf := servicesUpdateCmd.Flags() uf := servicesUpdateCmd.Flags()
uf.String("name", "", "service name") uf.String("name", "", "service name")
@@ -423,6 +499,11 @@ func init() {
uf.Bool("clear-volumes", false, "remove all volume mounts") uf.Bool("clear-volumes", false, "remove all volume mounts")
uf.String("maintenance", "", "maintenance mode: on|off") uf.String("maintenance", "", "maintenance mode: on|off")
uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)") uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)")
uf.String("builder", "", "build strategy: auto|railpack|nixpacks|dockerfile|static")
uf.String("cpu-reserve", "", "soft CPU reservation (empty clears)")
uf.String("memory-reserve", "", "soft memory reservation (empty clears)")
uf.String("static-cmd", "", "build command for the static builder (empty clears)")
uf.String("static-dir", "", "output dir for the static builder (empty clears)")
for _, a := range []string{"start", "stop", "restart", "redeploy"} { for _, a := range []string{"start", "stop", "restart", "redeploy"} {
verb := a verb := a
@@ -433,7 +514,11 @@ func init() {
RunE: serviceAction(verb), 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") domainsAddCmd.Flags().Bool("default", false, "set as the default domain")
servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd) 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)
} }
+1
View File
@@ -74,6 +74,7 @@ func init() {
rootCmd.AddCommand(commands.ExecCmd) rootCmd.AddCommand(commands.ExecCmd)
rootCmd.AddCommand(commands.VariablesCmd) rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.VolumesCmd) rootCmd.AddCommand(commands.VolumesCmd)
rootCmd.AddCommand(commands.RegistriesCmd)
rootCmd.AddCommand(commands.DatabasesCmd) rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd) rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd) rootCmd.AddCommand(commands.TemplatesCmd)
+23 -7
View File
@@ -84,10 +84,11 @@ type VolumeMount struct {
} }
type ResourceLimits struct { type ResourceLimits struct {
MemoryBytes int64 `json:"memory_bytes,omitempty"` MemoryBytes int64 `json:"memory_bytes,omitempty"`
CPUQuota int64 `json:"cpu_quota,omitempty"` MemoryReservation int64 `json:"memory_reservation,omitempty"`
CPUPeriod int64 `json:"cpu_period,omitempty"` CPUQuota int64 `json:"cpu_quota,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"` CPUPeriod int64 `json:"cpu_period,omitempty"`
CPUShares int64 `json:"cpu_shares,omitempty"`
} }
type HealthCheck struct { type HealthCheck struct {
@@ -152,10 +153,14 @@ type BuildConfig struct {
PrebuiltImage string `json:"prebuilt_image"` PrebuiltImage string `json:"prebuilt_image"`
BuildCommand string `json:"build_command"` BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"` StartCommand string `json:"start_command"`
BuildArgs map[string]string `json:"build_args,omitempty"`
Environment map[string]string `json:"environment"` Environment map[string]string `json:"environment"`
Branch string `json:"branch"` // Pull credentials for private prebuilt images.
Commit string `json:"commit"` PullUsername string `json:"pull_username,omitempty"`
NoCache bool `json:"no_cache"` PullPassword string `json:"pull_password,omitempty"`
Branch string `json:"branch"`
Commit string `json:"commit"`
NoCache bool `json:"no_cache"`
} }
type TriggerConfig struct { type TriggerConfig struct {
@@ -166,6 +171,12 @@ type TriggerConfig struct {
Timestamp time.Time `json:"timestamp"` // When trigger occurred Timestamp time.Time `json:"timestamp"` // When trigger occurred
} }
// DockerClient exposes the engine's docker handle for read-only queries
// (capacity checks, image info).
func (de *DeploymentEngine) DockerClient() *docker.Client {
return de.dockerClient
}
func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine { func NewDeploymentEngine(buildManager *build.BuildManager, dockerClient *docker.Client) *DeploymentEngine {
return &DeploymentEngine{ return &DeploymentEngine{
buildManager: buildManager, buildManager: buildManager,
@@ -290,7 +301,10 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
ImageTag: deployment.ID, ImageTag: deployment.ID,
BuildCommand: buildConfig.BuildCommand, BuildCommand: buildConfig.BuildCommand,
StartCommand: buildConfig.StartCommand, StartCommand: buildConfig.StartCommand,
BuildArgs: buildConfig.BuildArgs,
Environment: buildConfig.Environment, Environment: buildConfig.Environment,
PullUsername: buildConfig.PullUsername,
PullPassword: buildConfig.PullPassword,
ProjectID: deployment.ProjectID, ProjectID: deployment.ProjectID,
ServiceID: deployment.ServiceID, ServiceID: deployment.ServiceID,
DeploymentID: deployment.ID, DeploymentID: deployment.ID,
@@ -330,7 +344,9 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
HealthPath: cfg.HealthPath, HealthPath: cfg.HealthPath,
RestartPolicy: cfg.RestartPolicy, RestartPolicy: cfg.RestartPolicy,
MemoryBytes: cfg.Resources.MemoryBytes, MemoryBytes: cfg.Resources.MemoryBytes,
MemoryReserve: cfg.Resources.MemoryReservation,
NanoCPUs: cfg.Resources.CPUQuota, NanoCPUs: cfg.Resources.CPUQuota,
CPUShares: cfg.Resources.CPUShares,
Volumes: cfg.VolumeMounts, Volumes: cfg.VolumeMounts,
Domains: cfg.Domains, Domains: cfg.Domains,
Maintenance: cfg.Maintenance, Maintenance: cfg.Maintenance,
+16 -10
View File
@@ -47,7 +47,9 @@ type RuntimeSpec struct {
HealthPath string // http path probed on Port for container healthcheck HealthPath string // http path probed on Port for container healthcheck
RestartPolicy string RestartPolicy string
MemoryBytes int64 MemoryBytes int64
MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap
NanoCPUs int64 NanoCPUs int64
CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap
Volumes []VolumeMount // applied to every replica Volumes []VolumeMount // applied to every replica
Domains []string // all public hostnames; Domain is the default Domains []string // all public hostnames; Domain is the default
Maintenance bool // redirect traffic to MaintenanceURL Maintenance bool // redirect traffic to MaintenanceURL
@@ -273,15 +275,17 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
} }
cfg := docker.ContainerConfig{ cfg := docker.ContainerConfig{
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index), Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
Image: spec.Image, Image: spec.Image,
Cmd: spec.Command, Cmd: spec.Command,
Env: env, Env: env,
Labels: labels, Labels: labels,
RestartPolicy: spec.RestartPolicy, RestartPolicy: spec.RestartPolicy,
Memory: spec.MemoryBytes, Memory: spec.MemoryBytes,
NanoCPUs: spec.NanoCPUs, MemoryReservation: spec.MemoryReserve,
Networks: endpoints, NanoCPUs: spec.NanoCPUs,
CPUShares: spec.CPUShares,
Networks: endpoints,
} }
for _, v := range spec.Volumes { for _, v := range spec.Volumes {
@@ -526,12 +530,14 @@ func specHash(spec RuntimeSpec) string {
Env map[string]string Env map[string]string
Port int32 Port int32
MemoryBytes, NanoCPUs int64 MemoryBytes, NanoCPUs int64
MemoryReserve, CPUShares int64
Volumes []VolumeMount Volumes []VolumeMount
Domains []string Domains []string
Maintenance bool Maintenance bool
BasicAuth string BasicAuth string
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy, }{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes, spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs,
spec.MemoryReserve, spec.CPUShares, spec.Volumes,
spec.Domains, spec.Maintenance, spec.BasicAuthUsers}) spec.Domains, spec.Maintenance, spec.BasicAuthUsers})
sum := sha256.Sum256(payload) sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:8]) return hex.EncodeToString(sum[:8])
+33 -3
View File
@@ -156,8 +156,10 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
PortBindings: config.PortBindings, PortBindings: config.PortBindings,
Mounts: config.Mounts, Mounts: config.Mounts,
Resources: container.Resources{ Resources: container.Resources{
Memory: config.Memory, Memory: config.Memory,
NanoCPUs: config.NanoCPUs, MemoryReservation: config.MemoryReservation,
NanoCPUs: config.NanoCPUs,
CPUShares: config.CPUShares,
}, },
NetworkMode: container.NetworkMode(config.NetworkMode), NetworkMode: container.NetworkMode(config.NetworkMode),
} }
@@ -291,7 +293,7 @@ func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.Au
// BuildImage builds an image from a Dockerfile // BuildImage builds an image from a Dockerfile
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) { func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{ resp, err := c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
Dockerfile: options.Dockerfile, Dockerfile: options.Dockerfile,
Tags: options.Tags, Tags: options.Tags,
BuildArgs: options.BuildArgs, BuildArgs: options.BuildArgs,
@@ -299,6 +301,34 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
Remove: options.Remove, Remove: options.Remove,
NoCache: options.NoCache, NoCache: options.NoCache,
}) })
if err != nil {
return resp, err
}
// The daemon aborts the build if the response stream isn't consumed,
// and reports failures only inside that stream.
defer resp.Body.Close()
var buildErr string
dec := json.NewDecoder(resp.Body)
for {
var line struct {
ErrorDetail struct {
Message string `json:"message"`
} `json:"errorDetail"`
Error string `json:"error"`
}
if err := dec.Decode(&line); err != nil {
break
}
if line.ErrorDetail.Message != "" {
buildErr = line.ErrorDetail.Message
} else if line.Error != "" {
buildErr = line.Error
}
}
if buildErr != "" {
return resp, fmt.Errorf("docker build failed: %s", buildErr)
}
return resp, nil
} }
// RemoveImage removes an image // RemoveImage removes an image
+16 -14
View File
@@ -12,20 +12,22 @@ import (
// ContainerConfig represents the configuration for creating a container // ContainerConfig represents the configuration for creating a container
type ContainerConfig struct { type ContainerConfig struct {
Name string Name string
Image string Image string
Cmd []string Cmd []string
Env []string Env []string
Labels map[string]string Labels map[string]string
RestartPolicy string RestartPolicy string
ExposedPorts nat.PortSet ExposedPorts nat.PortSet
PortBindings nat.PortMap PortBindings nat.PortMap
Mounts []mount.Mount Mounts []mount.Mount
Memory int64 Memory int64
NanoCPUs int64 MemoryReservation int64
NetworkMode string NanoCPUs int64
Networks map[string]*network.EndpointSettings CPUShares int64
Healthcheck *container.HealthConfig NetworkMode string
Networks map[string]*network.EndpointSettings
Healthcheck *container.HealthConfig
} }
// LogOptions represents options for retrieving container logs // LogOptions represents options for retrieving container logs
+10 -2
View File
@@ -53,15 +53,17 @@ func init() {
// Services // Services
register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false}) register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false})
register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}].", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false})
register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true}) register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true})
register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false}) register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false})
register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false}) 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_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_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_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_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.", "PUT", "/services/{id}", []string{"id"}, nil, 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})
// Domains // Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []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_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}) 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) // 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_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}) register(spec{"containr_volumes_delete", "Delete an unused docker volume. Requires confirm:true. (admin)", "DELETE", "/admin/volumes/{name}", []string{"name"}, nil, true})
+4
View File
@@ -29,6 +29,10 @@ type BuildRequest struct {
Branch string `json:"branch"` // Git branch Branch string `json:"branch"` // Git branch
Commit string `json:"commit"` // Git commit SHA Commit string `json:"commit"` // Git commit SHA
NoCache bool `json:"no_cache"` // Disable Docker layer cache 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 // BuildResponse represents the response from a build operation
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE services ADD COLUMN IF NOT EXISTS builder varchar(32) NOT NULL DEFAULT 'auto';
ALTER TABLE services ADD COLUMN IF NOT EXISTS cpu_reserve varchar(20) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS memory_reserve varchar(20) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_build_cmd varchar(255) NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN IF NOT EXISTS static_dir varchar(255) NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE services DROP COLUMN IF EXISTS static_dir;
ALTER TABLE services DROP COLUMN IF EXISTS static_build_cmd;
ALTER TABLE services DROP COLUMN IF EXISTS memory_reserve;
ALTER TABLE services DROP COLUMN IF EXISTS cpu_reserve;
ALTER TABLE services DROP COLUMN IF EXISTS builder;
-- +goose StatementEnd
@@ -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
+18 -1
View File
@@ -61,7 +61,24 @@ CREATE TABLE services (
published_port INTEGER NOT NULL DEFAULT 0, published_port INTEGER NOT NULL DEFAULT 0,
volumes JSONB NOT NULL DEFAULT '[]'::jsonb, volumes JSONB NOT NULL DEFAULT '[]'::jsonb,
maintenance_mode BOOLEAN NOT NULL DEFAULT false, maintenance_mode BOOLEAN NOT NULL DEFAULT false,
basic_auth_users TEXT NOT NULL DEFAULT '' basic_auth_users TEXT NOT NULL DEFAULT '',
builder VARCHAR(32) NOT NULL DEFAULT 'auto',
cpu_reserve VARCHAR(20) NOT NULL DEFAULT '',
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
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 ( CREATE TABLE service_domains (
@@ -20,6 +20,10 @@ import {
createUserToken, createUserToken,
listUserTokens, listUserTokens,
revokeUserToken, revokeUserToken,
listRegistries,
createRegistry,
deleteRegistry,
type Registry,
listDatabases, listDatabases,
databaseAction, databaseAction,
updateDatabaseBackupSchedule, 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() { export function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1603,6 +1736,7 @@ export function SettingsPage() {
{/* Personal Access Tokens */} {/* Personal Access Tokens */}
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} /> <UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
<RegistriesSection />
{/* Platform Settings - owner only */} {/* Platform Settings - owner only */}
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null} {profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
@@ -43,6 +43,9 @@ import {
removeServiceDomain, removeServiceDomain,
setDefaultServiceDomain, setDefaultServiceDomain,
checkServiceDomains, checkServiceDomains,
cloneService,
moveService,
listProjects,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode'; 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 = () => { const invalidateService = () => {
queryClient.invalidateQueries({ queryKey: ['service', serviceId] }); queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] }); queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
@@ -448,6 +481,9 @@ export function ServiceDetailPage() {
const [domainInput, setDomainInput] = useState(''); const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null); const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const [buildForm, setBuildForm] = useState<{
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
} | null>(null);
const domainsQuery = useQuery({ const domainsQuery = useQuery({
queryKey: ['service-domains', serviceId], queryKey: ['service-domains', serviceId],
@@ -2193,7 +2229,11 @@ export function ServiceDetailPage() {
disabled={updateServiceMutation.isPending} disabled={updateServiceMutation.isPending}
onClick={() => onClick={() =>
updateServiceMutation.mutate( updateServiceMutation.mutate(
{ volumes: volumesForm.filter((v) => v.source.trim() && v.target.startsWith('/')) }, {
volumes: volumesForm
.filter((v) => v.source.trim() && v.target.startsWith('/'))
.map((v) => ({ type: v.type ?? 'volume' as const, source: v.source, target: v.target, read_only: v.read_only ?? false })),
},
{ onSuccess: () => setVolumesForm(null) }, { onSuccess: () => setVolumesForm(null) },
) )
} }
@@ -2386,6 +2426,144 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{!isDemoMode && (
<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)]">Build &amp; Resources</h3>
{buildForm === null && (
<button
type="button"
onClick={() =>
setBuildForm({
builder: service.builder ?? 'auto',
cpuReserve: service.cpuReserve ?? '',
memoryReserve: service.memoryReserve ?? '',
staticCmd: service.staticBuildCmd ?? '',
staticDir: service.staticDir ?? '',
})
}
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"
>
Edit
</button>
)}
</div>
{buildForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
.filter(Boolean).join(' · ') || 'none'}
</p>
</div>
{service.builder === 'static' && (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
</p>
</div>
)}
</div>
) : (
<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">Builder</label>
<select
value={buildForm.builder}
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
<input
value={buildForm.cpuReserve}
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
placeholder="0.25"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
<input
value={buildForm.memoryReserve}
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
placeholder="128Mi"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
</div>
{buildForm.builder === 'static' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
<input
value={buildForm.staticCmd}
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
placeholder="npm ci && npm run build"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
<input
value={buildForm.staticDir}
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
placeholder="dist"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
updateServiceMutation.mutate(
{
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
cpu_reserve: buildForm.cpuReserve,
memory_reserve: buildForm.memoryReserve,
static_build_cmd: buildForm.staticCmd,
static_dir: buildForm.staticDir,
},
{ onSuccess: () => setBuildForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setBuildForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && ( {!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]"> <div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -2487,6 +2665,135 @@ export function ServiceDetailPage() {
</div> </div>
</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>
)} )}
</div> </div>
+329
View File
@@ -971,6 +971,276 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/services/{id}/domains": {
parameters: { parameters: {
query?: never; query?: never;
@@ -8050,6 +8320,19 @@ export interface components {
maintenance_mode?: boolean; maintenance_mode?: boolean;
/** @description Basic-auth usernames gating the route (hashes never returned) */ /** @description Basic-auth usernames gating the route (hashes never returned) */
basic_auth?: string[]; basic_auth?: string[];
/**
* @description Build strategy; auto detects from repo contents
* @enum {string}
*/
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
/** @description Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares) */
cpu_reserve?: string;
/** @description Soft memory reservation (e.g. 128Mi) */
memory_reserve?: string;
/** @description Build command for the static builder (default "npm ci && npm run build") */
static_build_cmd?: string;
/** @description Output directory for the static builder (default dist) */
static_dir?: string;
/** @description Computed public URL (published port or domain) */ /** @description Computed public URL (published port or domain) */
public_url?: string; public_url?: string;
/** /**
@@ -8107,6 +8390,19 @@ export interface components {
restart_policy?: string; restart_policy?: string;
/** @description Volume/bind mounts applied to every replica */ /** @description Volume/bind mounts applied to every replica */
volumes?: components["schemas"]["ServiceVolume"][]; volumes?: components["schemas"]["ServiceVolume"][];
/**
* @description Build strategy (default auto)
* @enum {string}
*/
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
/** @description Soft CPU reservation (fraction, e.g. 0.25) */
cpu_reserve?: string;
/** @description Soft memory reservation (e.g. 128Mi) */
memory_reserve?: string;
/** @description Build command for the static builder */
static_build_cmd?: string;
/** @description Output directory for the static builder (default dist) */
static_dir?: string;
}; };
UpdateServiceRequest: { UpdateServiceRequest: {
/** @description Service name */ /** @description Service name */
@@ -8151,6 +8447,19 @@ export interface components {
maintenance_mode?: boolean; maintenance_mode?: boolean;
/** @description Replaces all basic-auth credentials; [] clears the gate */ /** @description Replaces all basic-auth credentials; [] clears the gate */
basic_auth?: components["schemas"]["BasicAuthCred"][]; basic_auth?: components["schemas"]["BasicAuthCred"][];
/**
* @description Build strategy
* @enum {string}
*/
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
/** @description Soft CPU reservation (empty string clears) */
cpu_reserve?: string;
/** @description Soft memory reservation (empty string clears) */
memory_reserve?: string;
/** @description Build command for the static builder (empty string clears) */
static_build_cmd?: string;
/** @description Output directory for the static builder (empty string clears) */
static_dir?: string;
}; };
ServiceDomain: { ServiceDomain: {
/** Format: uuid */ /** Format: uuid */
@@ -8182,6 +8491,26 @@ export interface components {
/** @description Plaintext on write — stored as bcrypt htpasswd; never returned */ /** @description Plaintext on write — stored as bcrypt htpasswd; never returned */
password: string; 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: { ServiceVolume: {
/** /**
* @description volume = docker named volume, bind = host path * @description volume = docker named volume, bind = host path
+72
View File
@@ -58,6 +58,11 @@ export type ServiceEntity = {
domains?: ServiceDomain[]; domains?: ServiceDomain[];
maintenanceMode?: boolean; maintenanceMode?: boolean;
basicAuth?: string[]; basicAuth?: string[];
builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static';
cpuReserve?: string;
memoryReserve?: string;
staticBuildCmd?: string;
staticDir?: string;
publicUrl?: string; publicUrl?: string;
}; };
@@ -584,6 +589,11 @@ function normalizeService(service: RawService): ServiceEntity | null {
domains: service.domains as ServiceDomain[] | undefined, domains: service.domains as ServiceDomain[] | undefined,
maintenanceMode: service.maintenance_mode, maintenanceMode: service.maintenance_mode,
basicAuth: service.basic_auth as string[] | undefined, basicAuth: service.basic_auth as string[] | undefined,
builder: service.builder as ServiceEntity['builder'],
cpuReserve: service.cpu_reserve,
memoryReserve: service.memory_reserve,
staticBuildCmd: service.static_build_cmd,
staticDir: service.static_dir,
publicUrl: service.public_url, publicUrl: service.public_url,
}; };
} }
@@ -1175,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 { function normalizeRuntime(runtime?: ServiceRuntime): ServiceRuntime {
const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] }; const state = runtime ?? { desired: 0, status: 'stopped' as const, urls: [], ports: [], containers: [] };
// The API emits null (not []) for empty collections — coerce at the boundary. // The API emits null (not []) for empty collections — coerce at the boundary.
+249
View File
@@ -667,6 +667,167 @@ paths:
'200': '200':
description: Reconciled runtime state 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: /services/{id}/domains:
get: get:
tags: tags:
@@ -5173,6 +5334,22 @@ components:
items: items:
type: string type: string
description: Basic-auth usernames gating the route (hashes never returned) description: Basic-auth usernames gating the route (hashes never returned)
builder:
type: string
enum: [auto, railpack, nixpacks, dockerfile, static]
description: Build strategy; auto detects from repo contents
cpu_reserve:
type: string
description: Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares)
memory_reserve:
type: string
description: Soft memory reservation (e.g. 128Mi)
static_build_cmd:
type: string
description: Build command for the static builder (default "npm ci && npm run build")
static_dir:
type: string
description: Output directory for the static builder (default dist)
public_url: public_url:
type: string type: string
description: Computed public URL (published port or domain) description: Computed public URL (published port or domain)
@@ -5248,6 +5425,22 @@ components:
items: items:
$ref: '#/components/schemas/ServiceVolume' $ref: '#/components/schemas/ServiceVolume'
description: Volume/bind mounts applied to every replica description: Volume/bind mounts applied to every replica
builder:
type: string
enum: [auto, railpack, nixpacks, dockerfile, static]
description: Build strategy (default auto)
cpu_reserve:
type: string
description: Soft CPU reservation (fraction, e.g. 0.25)
memory_reserve:
type: string
description: Soft memory reservation (e.g. 128Mi)
static_build_cmd:
type: string
description: Build command for the static builder
static_dir:
type: string
description: Output directory for the static builder (default dist)
UpdateServiceRequest: UpdateServiceRequest:
type: object type: object
@@ -5312,6 +5505,22 @@ components:
items: items:
$ref: '#/components/schemas/BasicAuthCred' $ref: '#/components/schemas/BasicAuthCred'
description: Replaces all basic-auth credentials; [] clears the gate description: Replaces all basic-auth credentials; [] clears the gate
builder:
type: string
enum: [auto, railpack, nixpacks, dockerfile, static]
description: Build strategy
cpu_reserve:
type: string
description: Soft CPU reservation (empty string clears)
memory_reserve:
type: string
description: Soft memory reservation (empty string clears)
static_build_cmd:
type: string
description: Build command for the static builder (empty string clears)
static_dir:
type: string
description: Output directory for the static builder (empty string clears)
ServiceDomain: ServiceDomain:
type: object type: object
@@ -5365,6 +5574,44 @@ components:
type: string type: string
description: Plaintext on write — stored as bcrypt htpasswd; never returned 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: ServiceVolume:
type: object type: object
required: required:
@@ -7684,5 +7931,7 @@ tags:
description: Vulnerability scans and compliance reporting description: Vulnerability scans and compliance reporting
- name: Cron - name: Cron
description: Scheduled jobs description: Scheduled jobs
- name: Registries
description: Private-image pull credentials
- name: Gateway - name: Gateway
description: API gateway — upstream services, keys, analytics description: API gateway — upstream services, keys, analytics
+54
View File
@@ -38,7 +38,9 @@ auth login|logout|status token auth and profiles
tokens list|create|revoke personal access tokens tokens list|create|revoke personal access tokens
projects list|get|create|delete projects list|get|create|delete
services list|get|create|update|delete|start|stop|restart|redeploy 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 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) volumes list|delete docker volume inventory + cleanup (admin)
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch) deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
deployments list|get|logs|rollback|cancel deployments list|get|logs|rollback|cancel
@@ -115,6 +117,58 @@ Maintenance mode redirects all traffic to `<BASE_URL>/api/v1/maintenance`
taken offline with empty responses. Basic-auth credentials are stored taken offline with empty responses. Basic-auth credentials are stored
bcrypt-hashed (htpasswd) and gate every request. bcrypt-hashed (htpasswd) and gate every request.
## Build strategy and resources
Source-built services pick a builder per service:
```bash
containr services update <id> --builder railpack # auto|railpack|nixpacks|dockerfile|static
containr services update <id> --builder static --static-cmd "npm run build" --static-dir dist
```
`auto` detects from repo contents (Dockerfile → railpack → nixpacks).
`static` builds assets in a node stage and serves them from nginx —
`--static-cmd` runs inside the build stage (default
`npm ci && npm run build`), `--static-dir` is copied into the nginx image
(default `dist`, exposed on port 80).
Resource reservations complement the hard `cpu`/`memory` limits:
```bash
containr services update <id> --cpu-reserve 0.25 --memory-reserve 128Mi
```
`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/
`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty
string clears. `app_settings.default_cpu`/`default_memory` change the
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 ## MCP server
`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools `containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools
+4 -1
View File
@@ -18,11 +18,14 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **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. - **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.
- **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). - **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. - **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. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects ## 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 ## Docs