mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
e995c98d05
commit
912f6f5142
22 files changed
+798
-59
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user