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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 17:08:35 +02:00
1 parent e995c98d05
commit 912f6f5142
22 files changed
+798 -59

No files matched your search

+79 -6
View File
@@ -4,9 +4,11 @@ import (
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/deployqueue"
"containr/internal/docker"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
@@ -232,6 +234,9 @@ func handleCreateDeployment(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s
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.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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 {
@@ -401,6 +408,13 @@ func runDeploymentAndSyncWithImage(
// ephemeral ports.
_ = 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)
maintURL := ""
if maintenanceMode {
@@ -417,7 +431,7 @@ func runDeploymentAndSyncWithImage(
Command: command,
Environment: env,
Replicas: replicas,
PublicPort: int32(service.Port),
PublicPort: publicPort,
PublishedPort: publishedPort,
Domain: service.Domain,
HealthPath: service.HealthCheckPath,
@@ -425,8 +439,10 @@ func runDeploymentAndSyncWithImage(
VolumeMounts: loadServiceVolumes(db, service.ID),
Domains: serviceDomainNames(db, service.ID, service.Domain),
Resources: deployment.ResourceLimits{
MemoryBytes: parseMemoryLimit(service.Memory),
CPUQuota: parseCPULimit(service.CPU),
MemoryBytes: parseMemoryLimit(service.Memory),
MemoryReservation: parseMemoryLimit(service.MemoryReserve),
CPUQuota: parseCPULimit(service.CPU),
CPUShares: parseCPUReserveShares(service.CPUReserve),
},
Maintenance: maintenanceMode,
MaintenanceURL: maintURL,
@@ -452,13 +468,33 @@ func runDeploymentAndSyncWithImage(
PrebuiltImage: service.Image,
}
} 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{
BuildType: "nixpacks",
BuildType: buildType,
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
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)
@@ -822,6 +858,9 @@ func handleRollbackDeployment(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s WHERE s.id = $1`, serviceID,
).Scan(
@@ -829,7 +868,9 @@ func handleRollbackDeployment(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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{
@@ -863,3 +904,35 @@ func handleRollbackDeployment(c *gin.Context) {
"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 ""
}
@@ -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")
}
}
+51 -3
View File
@@ -53,6 +53,13 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
}
spec.NanoCPUs = parseCPULimit(service.CPU)
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.Domains = serviceDomainNames(db, service.ID, service.Domain)
spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID)
@@ -160,6 +167,32 @@ func parseCPULimit(cpu string) int64 {
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}
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.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s JOIN projects p ON s.project_id = p.id
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.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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 {
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.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s
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.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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 {
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.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s JOIN projects p ON s.project_id = p.id
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.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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 {
return err
+105 -9
View File
@@ -41,6 +41,11 @@ type Service struct {
Domains []ServiceDomain `json:"domains,omitempty" db:"-"`
MaintenanceMode bool `json:"maintenance_mode" db:"-"`
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"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@@ -117,6 +122,11 @@ type CreateServiceRequest struct {
Volumes []ServiceVolume `json:"volumes"`
MaintenanceMode bool `json:"maintenance_mode"`
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
@@ -139,6 +149,11 @@ type UpdateServiceRequest struct {
Volumes *[]ServiceVolume `json:"volumes"`
MaintenanceMode *bool `json:"maintenance_mode"`
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
@@ -183,6 +198,9 @@ func handleGetServices(c *gin.Context) {
COALESCE(replicas, 1), COALESCE(port, 0),
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
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
FROM services
WHERE project_id = $1
@@ -203,7 +221,9 @@ func handleGetServices(c *gin.Context) {
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
@@ -314,12 +334,38 @@ func handleCreateService(c *gin.Context) {
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 == "" {
service.CPU = "0.5"
service.CPU = firstNonEmpty(settingValue(db.(*database.DB), "default_cpu", "", ""), "0.5")
}
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 {
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,
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, 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)`,
healthcheck_path, restart_policy, volumes,
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,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command,
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
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 {
@@ -443,6 +493,9 @@ func handleGetService(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s
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.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&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 {
@@ -520,6 +575,9 @@ func handleUpdateService(c *gin.Context) {
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
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
FROM services s
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.Replicas, &existingService.Port, &existingService.Domain,
&existingService.HealthCheckPath, &existingService.RestartPolicy,
&existingService.Builder, &existingService.CPUReserve,
&existingService.MemoryReserve, &existingService.StaticBuildCmd,
&existingService.StaticDir,
&existingService.CreatedAt, &existingService.UpdatedAt,
)
@@ -586,6 +647,37 @@ func handleUpdateService(c *gin.Context) {
if 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,
// so the COALESCE fallback needs an untyped nil when volumes aren't sent.
var volumesArg interface{}
@@ -607,7 +699,9 @@ func handleUpdateService(c *gin.Context) {
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
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`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -615,6 +709,8 @@ func handleUpdateService(c *gin.Context) {
existingService.Replicas, existingService.Port, existingService.Domain,
existingService.HealthCheckPath, existingService.RestartPolicy,
existingService.UpdatedAt, volumesArg, existingService.ID,
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
existingService.StaticBuildCmd, existingService.StaticDir,
)
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, ''),
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
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`,
serviceID,
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
&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)
}
c.JSON(http.StatusOK, resp)