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)
+6 -2
View File
@@ -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)
}
+5
View File
@@ -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:
+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
}
+28 -2
View File
@@ -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
+15 -4
View File
@@ -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,
+16 -10
View File
@@ -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])
+33 -3
View File
@@ -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
+16 -14
View File
@@ -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
+2 -2
View File
@@ -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})
@@ -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
+6 -1
View File
@@ -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 (
@@ -448,6 +448,9 @@ export function ServiceDetailPage() {
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | 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() {
</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 && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
+39
View File
@@ -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 */
+10
View File
@@ -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,
};
}