From 912f6f51426b98530feaac9109a96044a12bc567 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 8 Oct 2026 17:08:35 +0200 Subject: [PATCH] feat(services): builder selection, resource reservations, static builder (C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- app/backend/internal/api/deployments.go | 85 +++++++++- app/backend/internal/api/resources_test.go | 48 ++++++ app/backend/internal/api/runtime.go | 54 ++++++- app/backend/internal/api/services.go | 114 ++++++++++++-- app/backend/internal/api/variables.go | 6 +- app/backend/internal/build/dockerfile.go | 8 +- app/backend/internal/build/manager.go | 5 + app/backend/internal/build/static.go | 95 +++++++++++ app/backend/internal/cli/commands/services.go | 30 +++- app/backend/internal/deployment/engine.go | 19 ++- app/backend/internal/deployment/runtime.go | 26 ++-- app/backend/internal/docker/client.go | 36 ++++- app/backend/internal/docker/types.go | 30 ++-- app/backend/internal/mcpserver/server.go | 4 +- .../20261011000000_builder_resources.sql | 17 ++ app/backend/sqlc/schema.sql | 7 +- .../service/pages/ServiceDetailPage.tsx | 147 +++++++++++++++++- app/frontend/src/generated/api-types.ts | 39 +++++ app/frontend/src/lib/api-client.ts | 10 ++ docs/api/openapi.yaml | 48 ++++++ docs/guides/CLI.md | 28 ++++ llms.txt | 1 + 22 files changed, 798 insertions(+), 59 deletions(-) create mode 100644 app/backend/internal/api/resources_test.go create mode 100644 app/backend/internal/build/static.go create mode 100644 app/backend/migrations_goose/20261011000000_builder_resources.sql diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index f57707b..304a872 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -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 "" +} diff --git a/app/backend/internal/api/resources_test.go b/app/backend/internal/api/resources_test.go new file mode 100644 index 0000000..c3e0e83 --- /dev/null +++ b/app/backend/internal/api/resources_test.go @@ -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") + } +} diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index d25cab7..20ac604 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -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 diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index 07a6704..faa48aa 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -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 { diff --git a/app/backend/internal/api/variables.go b/app/backend/internal/api/variables.go index 5e045af..79143e7 100644 --- a/app/backend/internal/api/variables.go +++ b/app/backend/internal/api/variables.go @@ -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) diff --git a/app/backend/internal/build/dockerfile.go b/app/backend/internal/build/dockerfile.go index bfc91d1..11f148e 100644 --- a/app/backend/internal/build/dockerfile.go +++ b/app/backend/internal/build/dockerfile.go @@ -211,8 +211,12 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest) // Build Docker image imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag) - // Create build context tar - buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile) + // createBuildContext wants the Dockerfile contents, not its path. + 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 { return nil, fmt.Errorf("failed to create build context: %w", err) } diff --git a/app/backend/internal/build/manager.go b/app/backend/internal/build/manager.go index aa7e0ab..c1c3cea 100644 --- a/app/backend/internal/build/manager.go +++ b/app/backend/internal/build/manager.go @@ -16,6 +16,7 @@ type BuildManager struct { railpackBuilder *RailpackBuilder nixpacksBuilder *NixpacksBuilder dockerfileBuilder *DockerfileBuilder + staticBuilder *StaticBuilder dockerClient *docker.Client cacheManager *CacheManager parallelBuilder *ParallelBuilder @@ -28,6 +29,7 @@ const ( BuildTypeRailpack BuildType = "railpack" BuildTypeNixpacks BuildType = "nixpacks" BuildTypeDockerfile BuildType = "dockerfile" + BuildTypeStatic BuildType = "static" BuildTypePrebuilt BuildType = "prebuilt" ) @@ -39,6 +41,7 @@ func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager railpackBuilder: NewRailpackBuilder(workDir, dockerClient), nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient), dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient), + staticBuilder: NewStaticBuilder(dockerClient), dockerClient: dockerClient, cacheManager: cacheManager, parallelBuilder: parallelBuilder, @@ -80,6 +83,8 @@ func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*ty return bm.nixpacksBuilder.Build(ctx, req) case BuildTypeDockerfile: return bm.dockerfileBuilder.Build(ctx, req) + case BuildTypeStatic: + return bm.staticBuilder.Build(ctx, req) case BuildTypePrebuilt: return bm.buildPrebuilt(ctx, req) default: diff --git a/app/backend/internal/build/static.go b/app/backend/internal/build/static.go new file mode 100644 index 0000000..8970110 --- /dev/null +++ b/app/backend/internal/build/static.go @@ -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 +} diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index 5f68f2c..963229b 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -79,7 +79,7 @@ var servicesCreateCmd = &cobra.Command{ body := map[string]interface{}{ "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 != "" { key := f // JSON uses snake_case keys matching the API schema. @@ -94,6 +94,14 @@ var servicesCreateCmd = &cobra.Command{ key = "restart_policy" case "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 } @@ -151,7 +159,7 @@ var servicesUpdateCmd = &cobra.Command{ return err } 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) { key := f switch f { @@ -159,6 +167,14 @@ var servicesUpdateCmd = &cobra.Command{ key = "restart_policy" case "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) body[key] = v @@ -406,6 +422,11 @@ func init() { f.String("memory", "", "memory limit (e.g. 512m)") f.Int("port", 0, "container port") 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.String("name", "", "service name") @@ -423,6 +444,11 @@ func init() { uf.Bool("clear-volumes", false, "remove all volume mounts") uf.String("maintenance", "", "maintenance mode: on|off") 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"} { verb := a diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index 4f1d3df..e461371 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -84,10 +84,11 @@ type VolumeMount struct { } type ResourceLimits struct { - MemoryBytes int64 `json:"memory_bytes,omitempty"` - CPUQuota int64 `json:"cpu_quota,omitempty"` - CPUPeriod int64 `json:"cpu_period,omitempty"` - CPUShares int64 `json:"cpu_shares,omitempty"` + MemoryBytes int64 `json:"memory_bytes,omitempty"` + MemoryReservation int64 `json:"memory_reservation,omitempty"` + CPUQuota int64 `json:"cpu_quota,omitempty"` + CPUPeriod int64 `json:"cpu_period,omitempty"` + CPUShares int64 `json:"cpu_shares,omitempty"` } type HealthCheck struct { @@ -152,6 +153,7 @@ type BuildConfig struct { PrebuiltImage string `json:"prebuilt_image"` BuildCommand string `json:"build_command"` StartCommand string `json:"start_command"` + BuildArgs map[string]string `json:"build_args,omitempty"` Environment map[string]string `json:"environment"` Branch string `json:"branch"` Commit string `json:"commit"` @@ -166,6 +168,12 @@ type TriggerConfig struct { 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 { return &DeploymentEngine{ buildManager: buildManager, @@ -290,6 +298,7 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme ImageTag: deployment.ID, BuildCommand: buildConfig.BuildCommand, StartCommand: buildConfig.StartCommand, + BuildArgs: buildConfig.BuildArgs, Environment: buildConfig.Environment, ProjectID: deployment.ProjectID, ServiceID: deployment.ServiceID, @@ -330,7 +339,9 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo HealthPath: cfg.HealthPath, RestartPolicy: cfg.RestartPolicy, MemoryBytes: cfg.Resources.MemoryBytes, + MemoryReserve: cfg.Resources.MemoryReservation, NanoCPUs: cfg.Resources.CPUQuota, + CPUShares: cfg.Resources.CPUShares, Volumes: cfg.VolumeMounts, Domains: cfg.Domains, Maintenance: cfg.Maintenance, diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index 3373c79..d81a0f9 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -47,7 +47,9 @@ type RuntimeSpec struct { HealthPath string // http path probed on Port for container healthcheck RestartPolicy string MemoryBytes int64 + MemoryReserve int64 // soft reservation; scheduler hint, not a hard cap NanoCPUs int64 + CPUShares int64 // relative weight (1024 ≈ 1 cpu), not a cap Volumes []VolumeMount // applied to every replica Domains []string // all public hostnames; Domain is the default Maintenance bool // redirect traffic to MaintenanceURL @@ -273,15 +275,17 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, } cfg := docker.ContainerConfig{ - Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index), - Image: spec.Image, - Cmd: spec.Command, - Env: env, - Labels: labels, - RestartPolicy: spec.RestartPolicy, - Memory: spec.MemoryBytes, - NanoCPUs: spec.NanoCPUs, - Networks: endpoints, + Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index), + Image: spec.Image, + Cmd: spec.Command, + Env: env, + Labels: labels, + RestartPolicy: spec.RestartPolicy, + Memory: spec.MemoryBytes, + MemoryReservation: spec.MemoryReserve, + NanoCPUs: spec.NanoCPUs, + CPUShares: spec.CPUShares, + Networks: endpoints, } for _, v := range spec.Volumes { @@ -526,12 +530,14 @@ func specHash(spec RuntimeSpec) string { Env map[string]string Port int32 MemoryBytes, NanoCPUs int64 + MemoryReserve, CPUShares int64 Volumes []VolumeMount Domains []string Maintenance bool BasicAuth string }{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}) sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:8]) diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index deccf98..c076a4c 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -156,8 +156,10 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s PortBindings: config.PortBindings, Mounts: config.Mounts, Resources: container.Resources{ - Memory: config.Memory, - NanoCPUs: config.NanoCPUs, + Memory: config.Memory, + MemoryReservation: config.MemoryReservation, + NanoCPUs: config.NanoCPUs, + CPUShares: config.CPUShares, }, 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 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, Tags: options.Tags, BuildArgs: options.BuildArgs, @@ -299,6 +301,34 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options Remove: options.Remove, 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 diff --git a/app/backend/internal/docker/types.go b/app/backend/internal/docker/types.go index 7e80196..43b6f1d 100644 --- a/app/backend/internal/docker/types.go +++ b/app/backend/internal/docker/types.go @@ -12,20 +12,22 @@ import ( // ContainerConfig represents the configuration for creating a container type ContainerConfig struct { - Name string - Image string - Cmd []string - Env []string - Labels map[string]string - RestartPolicy string - ExposedPorts nat.PortSet - PortBindings nat.PortMap - Mounts []mount.Mount - Memory int64 - NanoCPUs int64 - NetworkMode string - Networks map[string]*network.EndpointSettings - Healthcheck *container.HealthConfig + Name string + Image string + Cmd []string + Env []string + Labels map[string]string + RestartPolicy string + ExposedPorts nat.PortSet + PortBindings nat.PortMap + Mounts []mount.Mount + Memory int64 + MemoryReservation int64 + NanoCPUs int64 + CPUShares int64 + NetworkMode string + Networks map[string]*network.EndpointSettings + Healthcheck *container.HealthConfig } // LogOptions represents options for retrieving container logs diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 54b1979..30b83ce 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -53,7 +53,7 @@ func init() { // 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_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_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}) @@ -61,7 +61,7 @@ func init() { register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []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_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 register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) diff --git a/app/backend/migrations_goose/20261011000000_builder_resources.sql b/app/backend/migrations_goose/20261011000000_builder_resources.sql new file mode 100644 index 0000000..266d27d --- /dev/null +++ b/app/backend/migrations_goose/20261011000000_builder_resources.sql @@ -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 diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index fd208cc..00211bb 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -61,7 +61,12 @@ CREATE TABLE services ( published_port INTEGER NOT NULL DEFAULT 0, volumes JSONB NOT NULL DEFAULT '[]'::jsonb, 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 service_domains ( diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index d6422df..2aa7079 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -448,6 +448,9 @@ export function ServiceDetailPage() { const [domainInput, setDomainInput] = useState(''); const [domainChecks, setDomainChecks] = useState | 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({ queryKey: ['service-domains', serviceId], @@ -2193,7 +2196,11 @@ export function ServiceDetailPage() { disabled={updateServiceMutation.isPending} onClick={() => 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) }, ) } @@ -2386,6 +2393,144 @@ export function ServiceDetailPage() { )} + {!isDemoMode && ( +
+
+

Build & Resources

+ {buildForm === null && ( + + )} +
+ + {buildForm === null ? ( +
+
+

Builder

+

{service.builder ?? 'auto'}

+
+
+

Reservations

+

+ {[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`] + .filter(Boolean).join(' · ') || 'none'} +

+
+ {service.builder === 'static' && ( +
+

Static build

+

+ {service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'} +

+
+ )} +
+ ) : ( +
+
+ + +
+
+
+ + 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)]" + /> +
+
+ + 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)]" + /> +
+
+ {buildForm.builder === 'static' && ( +
+
+ + 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)]" + /> +
+
+ + 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)]" + /> +
+
+ )} +
+ + +

Builder applies to the next source build; reservations on next deploy

+
+ {updateServiceMutation.isError && ( +

{(updateServiceMutation.error as Error).message}

+ )} +
+ )} +
+ )} + {!isDemoMode && (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 108b2bf..363a0f8 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -8050,6 +8050,19 @@ export interface components { maintenance_mode?: boolean; /** @description Basic-auth usernames gating the route (hashes never returned) */ 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) */ public_url?: string; /** @@ -8107,6 +8120,19 @@ export interface components { restart_policy?: string; /** @description Volume/bind mounts applied to every replica */ 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: { /** @description Service name */ @@ -8151,6 +8177,19 @@ export interface components { maintenance_mode?: boolean; /** @description Replaces all basic-auth credentials; [] clears the gate */ 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: { /** Format: uuid */ diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 1c26179..b806352 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -58,6 +58,11 @@ export type ServiceEntity = { domains?: ServiceDomain[]; maintenanceMode?: boolean; basicAuth?: string[]; + builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static'; + cpuReserve?: string; + memoryReserve?: string; + staticBuildCmd?: string; + staticDir?: string; publicUrl?: string; }; @@ -584,6 +589,11 @@ function normalizeService(service: RawService): ServiceEntity | null { domains: service.domains as ServiceDomain[] | undefined, maintenanceMode: service.maintenance_mode, 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, }; } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6303f0f..689d1f3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5173,6 +5173,22 @@ components: items: type: string 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: type: string description: Computed public URL (published port or domain) @@ -5248,6 +5264,22 @@ components: items: $ref: '#/components/schemas/ServiceVolume' 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: type: object @@ -5312,6 +5344,22 @@ components: items: $ref: '#/components/schemas/BasicAuthCred' 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: type: object diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index feaedec..5575351 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -115,6 +115,34 @@ Maintenance mode redirects all traffic to `/api/v1/maintenance` taken offline with empty responses. Basic-auth credentials are stored bcrypt-hashed (htpasswd) and gate every request. +## Build strategy and resources + +Source-built services pick a builder per service: + +```bash +containr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static +containr services update --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 --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). + ## MCP server `containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools diff --git a/llms.txt b/llms.txt index b63268b..76b16f4 100644 --- a/llms.txt +++ b/llms.txt @@ -18,6 +18,7 @@ Base URL: `https:///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. - **Volumes**: service mounts via `PUT /services/{id}` `volumes: [{type: volume|bind, source, target, read_only}]` — replaces the whole list (`[]` clears), applied on next deploy. `GET /admin/volumes` inventories node volumes with `in_use` flags; `DELETE /admin/volumes/{name}` removes unused ones (admin). - **Domains**: multiple hostnames per service via `GET|POST /services/{id}/domains`, `DELETE /services/{id}/domains/{domain_id}`, `POST …/{domain_id}/default` (mirrors `services.domain`). `GET /services/{id}/domains/check` returns `ok|wrong-target|pending` per domain vs `app_settings.public_ip`/`PUBLIC_IP`. `PUT` `maintenance_mode` redirects all traffic to `GET /maintenance` (needs `base_url`/`BASE_URL` setting); `basic_auth: [{username,password}]` stores bcrypt htpasswd and gates the route — responses return usernames only. +- **Builders & resources**: service field `builder` = `auto|railpack|nixpacks|dockerfile|static` (auto detects; static builds assets with `static_build_cmd` and serves `static_dir` via nginx:80). `cpu`/`memory` are hard caps; `cpu_reserve`/`memory_reserve` are soft reservations (CPU shares / MemoryReservation) — empty string clears. `app_settings.default_cpu`/`default_memory` set instance defaults; `app_settings.capacity_policy=block` rejects deploys exceeding node memory (default `warn`). - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. ## Core objects