diff --git a/ROADMAP2.md b/ROADMAP2.md index 4795db3..8250069 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -192,8 +192,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface. compares vs edge IP → `ok|wrong-target|pending`. - [x] **Maintenance mode + basic-auth gate** — Traefik middleware labels; no proxy code needed. -- [ ] **Routing overrides** — `services.traefik_labels` jsonb with - allowlist validation (middlewares, headers, redirects). +- [x] **Routing overrides** — `services.traefik_labels` jsonb with + allowlist validation. Keys `middlewares..[.]` scope + to the service router (`svc--`), auto-attach after builtins; + router/service/provider-level keys rejected. API + CLI + (`--traefik-label`) + MCP + service page Access editor. - [x] **Builder override** — `services.builder` enum `auto|railpack|nixpacks|dockerfile|static`; static builder = build + nginx serve. Fixed hardcoded `nixpacks` + `BuildImage` stream drain bug. @@ -341,14 +344,13 @@ and wired refs, one click or one CLI call. CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page button (online-gated). Ceilings: linux amd64/arm64 only; agents on other platforms get a `NO_BINARY` 409. -- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch - payload carries the project owner's `registries` credentials for the - image host; the agent `docker login`s for the pull and logs out after, - and command payloads are scrubbed of passwords on completion. Agent - also verifies the container is still running ~1.5s after `run` so - crash-loops fail the deploy instead of reporting healthy. Remaining: - real builds on the node (source checkout + build daemon) — pulls - already work. +- [x] **Build-on-node** — private registry pulls + real builds shipped. + Git sources materialize via `internal/source` checkout (branch/commit, + `GIT_ASKPASS` credential injection, dumb-transport fallback), package + through `BuildManager.PackageContext`, ship as agent-token artifacts, + and `docker build` on the node (`build_image` command). Remote rollbacks + `docker save` → artifact → `load_image` on the node. Verified end-to-end + on a live agent (PR #60). - [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/ OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module (dflow's second provider), cloud-init → auto-enroll. @@ -426,8 +428,11 @@ and wired refs, one click or one CLI call. filters. Applied: audit logs (server-backed), builds (server + client date-range), activity (severity server + client category/search). Notifications table has no list surface yet — apply when one lands. -- [ ] **Notification producers round-out** — backups, agent offline, - upgrade available, security findings. +- [x] **Notification producers round-out** — all shipped: deployments + (deployments.go), backups (databases.go `createBackupProcess` → owner), + agent offline/online sweep (`reconcileAgentHealth` → admins), + upgrade-available (`checkForUpgrade`, tag-persisted) and security-scan + findings (`scanner.OnScanComplete` → project owner). - [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) — `services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper polls container NetworkIO deltas; idle past threshold → workload @@ -492,8 +497,9 @@ and wired refs, one click or one CLI call. a prior deployment, database start/stop/restart/backup-now. - **Observe** — polling log tail (300 lines, 5s refresh), per-service deployment history, env-doctor issues surfaced on the service view. - - [ ] **env var edit + apply-redeploy, domain management, replica - scaling, cron execution history** — next iteration. + - [x] **env var edit + domain management + replica scaling** (PR #61 — + mobile v1.5: variables editor, domains screen, scale + sleep + settings, notifications tab). **cron execution history** still open. - [ ] **Push notifications** — zero-infra path = self-hosted ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional. - [ ] **Offline resilience** — cached last-known state + unreachable diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index 4824a48..9652dfe 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -121,6 +121,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe spec.Volumes = loadServiceVolumes(db, service.ID) spec.Domains = serviceDomainNames(db, service.ID, service.Domain) spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID) + spec.TraefikLabels = serviceTraefikLabels(db, service.ID) if spec.Maintenance { spec.MaintenanceURL = maintenanceURL(db) } diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index c3fdf10..773bc6c 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "net/http" + "regexp" "strings" "time" @@ -58,9 +59,12 @@ type Service struct { Spread bool `json:"spread" db:"-"` // PlacementTags restrict auto/spread candidates to agents carrying all // listed tags. An explicit pin overrides them — pin + tags is rejected. - PlacementTags []string `json:"placement_tags,omitempty" db:"-"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + PlacementTags []string `json:"placement_tags,omitempty" db:"-"` + // TraefikLabels holds per-service middleware overrides — + // "middlewares..." → value. Lazy-read. + TraefikLabels map[string]string `json:"traefik_labels,omitempty" db:"-"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // loadServiceSleep fills the sleep-mode columns, which live outside the @@ -123,6 +127,78 @@ func normalizePlacementTags(tags []string) []string { return out } +// traefikLabelKey matches "middlewares.." with an optional +// "." tail — e.g. "middlewares.rl.ratelimit.average". +var traefikLabelKey = regexp.MustCompile(`^middlewares\.([A-Za-z0-9][A-Za-z0-9_-]{0,39})\.([A-Za-z]+)(\.[A-Za-z0-9_.*+-]+)?$`) + +// traefikMiddlewareTypes are the Traefik middleware kinds a service may +// define. Router/service/provider-level keys are never accepted — those +// would let a tenant hijack someone else's routing. +var traefikMiddlewareTypes = map[string]bool{ + "addprefix": true, "basicauth": true, "buffering": true, "chain": true, + "circuitbreaker": true, "compress": true, "digestauth": true, + "errors": true, "forwardauth": true, "grpcweb": true, "headers": true, + "inflightreq": true, "ipallowlist": true, "ipwhitelist": true, + "passtlsclientcert": true, "ratelimit": true, "redirectregex": true, + "redirectscheme": true, "replacepath": true, "replacepathregex": true, + "retry": true, "stripprefix": true, "stripprefixregex": true, +} + +// normalizeTraefikLabels validates a raw override map. Returns the cleaned +// map (middleware type lowercased — Traefik label keys are canonical-lower) +// or an error naming the offending key. +func normalizeTraefikLabels(raw map[string]string) (map[string]string, error) { + if len(raw) > 32 { + return nil, fmt.Errorf("traefik_labels: at most 32 entries (got %d)", len(raw)) + } + out := make(map[string]string, len(raw)) + names := map[string]bool{} + for k, v := range raw { + m := traefikLabelKey.FindStringSubmatch(k) + if m == nil { + return nil, fmt.Errorf("traefik_labels: invalid key %q — want middlewares..[.]", k) + } + mwType := strings.ToLower(m[2]) + if !traefikMiddlewareTypes[mwType] { + return nil, fmt.Errorf("traefik_labels: unsupported middleware type %q", m[2]) + } + if len(v) == 0 { + return nil, fmt.Errorf("traefik_labels: %q has an empty value", k) + } + if len(v) > 512 || strings.ContainsAny(v, "\r\n") { + return nil, fmt.Errorf("traefik_labels: %q value must be a single line under 512 chars", k) + } + out["middlewares."+m[1]+"."+mwType+m[3]] = v + names[m[1]] = true + } + if len(names) > 16 { + return nil, fmt.Errorf("traefik_labels: at most 16 middleware names") + } + return out, nil +} + +// serviceTraefikLabels lazy-reads the traefik_labels column. +func serviceTraefikLabels(db *database.DB, serviceID uuid.UUID) map[string]string { + var raw []byte + if err := db.QueryRow(`SELECT traefik_labels FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { + return nil + } + var labels map[string]string + if err := json.Unmarshal(raw, &labels); err != nil || len(labels) == 0 { + return nil + } + return labels +} + +// traefikLabelsJSON marshals an override map for the jsonb column. +func traefikLabelsJSON(labels map[string]string) []byte { + if len(labels) == 0 { + return []byte("{}") + } + raw, _ := json.Marshal(labels) + return raw +} + // tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null. func tagsJSON(tags []string) []byte { if len(tags) == 0 { @@ -139,6 +215,7 @@ func loadServiceNode(db *database.DB, s *Service) { s.NodeID = serviceNodeID(db, s.ID) s.Spread = serviceSpread(db, s.ID) s.PlacementTags = servicePlacementTags(db, s.ID) + s.TraefikLabels = serviceTraefikLabels(db, s.ID) if s.NodeID == "" { return } @@ -237,6 +314,10 @@ type CreateServiceRequest struct { // PlacementTags restrict auto/spread candidates to agents carrying all // listed tags. Mutually exclusive with an explicit node pin. PlacementTags []string `json:"placement_tags"` + // TraefikLabels defines extra Traefik middlewares for this service's + // router — keys "middlewares..[.]", validated against + // an allowlist. Defined middlewares attach to the router automatically. + TraefikLabels map[string]string `json:"traefik_labels"` } // UpdateServiceRequest represents a request to update a service @@ -275,6 +356,9 @@ type UpdateServiceRequest struct { // listed tags; pointer so [] clears the requirement. Mutually exclusive // with an explicit node pin. PlacementTags *[]string `json:"placement_tags"` + // TraefikLabels replaces the service's middleware overrides; pointer so + // {} clears them and nil leaves them untouched. + TraefikLabels *map[string]string `json:"traefik_labels"` } // handleGetServices retrieves all services for a project @@ -478,6 +562,14 @@ func handleCreateService(c *gin.Context) { if service.Builder == "static" && service.StaticDir == "" { service.StaticDir = "dist" } + if len(req.TraefikLabels) > 0 { + labels, err := normalizeTraefikLabels(req.TraefikLabels) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + service.TraefikLabels = labels + } for label, check := range map[string]func(string) error{ "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec, @@ -557,9 +649,9 @@ func handleCreateService(c *gin.Context) { healthcheck_path, restart_policy, volumes, builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, node_id, spread, placement_tags, sleep_enabled, sleep_idle_minutes, - created_at, updated_at) + traefik_labels, 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, $34, $35, $36, $37, $38)`, + $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)`, 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, @@ -572,6 +664,7 @@ func handleCreateService(c *gin.Context) { tagsJSON(service.PlacementTags), service.SleepEnabled, sql.NullInt32{Int32: int32(service.SleepIdleMinutes), Valid: service.SleepIdleMinutes > 0}, + traefikLabelsJSON(service.TraefikLabels), service.CreatedAt, service.UpdatedAt, ) @@ -848,6 +941,16 @@ func handleUpdateService(c *gin.Context) { } existingService.SleepIdleMinutes = *req.SleepIdleMinutes } + if req.TraefikLabels != nil { + labels, err := normalizeTraefikLabels(*req.TraefikLabels) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + existingService.TraefikLabels = labels + } else { + existingService.TraefikLabels = serviceTraefikLabels(db.(*database.DB), serviceID) + } for label, check := range map[string]func(string) error{ "cpu": validateCPUSpec, "cpu_reserve": validateCPUSpec, "memory": validateMemorySpec, "memory_reserve": validateMemorySpec, @@ -924,7 +1027,7 @@ func handleUpdateService(c *gin.Context) { builder = $19, cpu_reserve = $20, memory_reserve = $21, static_build_cmd = $22, static_dir = $23, sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27, - placement_tags = $28::jsonb + placement_tags = $28::jsonb, traefik_labels = $29::jsonb WHERE id = $18`, existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Environment, existingService.GitRepo, existingService.GitBranch, @@ -938,6 +1041,7 @@ func handleUpdateService(c *gin.Context) { sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""}, existingService.Spread, tagsJSON(existingService.PlacementTags), + traefikLabelsJSON(existingService.TraefikLabels), ) if err != nil { diff --git a/app/backend/internal/api/services_routing_test.go b/app/backend/internal/api/services_routing_test.go new file mode 100644 index 0000000..99dc1a5 --- /dev/null +++ b/app/backend/internal/api/services_routing_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "strings" + "testing" +) + +func TestNormalizeTraefikLabels(t *testing.T) { + valid := map[string]string{ + "middlewares.rl.ratelimit.average": "100", + "middlewares.rl.ratelimit.period": "1m", + "middlewares.sec.headers.customrequestheaders.X-Frame-Options": "DENY", + "middlewares.gzip.compress": "true", + } + out, err := normalizeTraefikLabels(valid) + if err != nil { + t.Fatalf("valid labels rejected: %v", err) + } + if len(out) != len(valid) { + t.Fatalf("expected %d labels, got %d", len(valid), len(out)) + } + if out["middlewares.gzip.compress"] != "true" { + t.Fatalf("flag-style middleware missing: %v", out) + } +} + +func TestNormalizeTraefikLabelsLowercasesType(t *testing.T) { + out, err := normalizeTraefikLabels(map[string]string{ + "middlewares.rl.RateLimit.average": "10", + }) + if err != nil { + t.Fatalf("mixed-case type rejected: %v", err) + } + if out["middlewares.rl.ratelimit.average"] != "10" { + t.Fatalf("type not lowercased: %v", out) + } +} + +func TestNormalizeTraefikLabelsRejects(t *testing.T) { + cases := map[string]string{ + "traefik.enable": "true", // provider-level + "middlewares..ratelimit.average": "1", // empty name + "middlewares.x.evilinject.thing": "1", // unknown type + "routers.svc.rule": "x", // router hijack + "middlewares.x.ratelimit.average.bad\ninjection": "1", // control char in key fails regex + } + for k, v := range cases { + if _, err := normalizeTraefikLabels(map[string]string{k: v}); err == nil { + t.Fatalf("key %q should have been rejected", k) + } + } +} + +func TestNormalizeTraefikLabelsRejectsBadValues(t *testing.T) { + if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": ""}); err == nil { + t.Fatal("empty value accepted") + } + if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": strings.Repeat("x", 600)}); err == nil { + t.Fatal("oversized value accepted") + } + if _, err := normalizeTraefikLabels(map[string]string{"middlewares.a.ratelimit.average": "1\nmalicious"}); err == nil { + t.Fatal("newline value accepted") + } +} diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index b0c0f7c..fa8c10e 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -134,6 +134,11 @@ var servicesCreateCmd = &cobra.Command{ } body["placement_tags"] = tags } + if labels, err := traefikLabelFlag(cmd); err != nil { + return err + } else if labels != nil { + body["traefik_labels"] = labels + } data, err := c.Do("POST", "/projects/"+args[0]+"/services", body) if err != nil { return err @@ -148,6 +153,28 @@ var servicesCreateCmd = &cobra.Command{ }, } +// traefikLabelFlag parses --traefik-label key=value pairs into the API's +// map shape. Returns nil when the flag was never passed; an empty map when +// only empty values were given (clears overrides on update). +func traefikLabelFlag(cmd *cobra.Command) (map[string]string, error) { + if !cmd.Flags().Changed("traefik-label") { + return nil, nil + } + pairs, _ := cmd.Flags().GetStringArray("traefik-label") + labels := map[string]string{} + for _, p := range pairs { + if strings.TrimSpace(p) == "" { + continue + } + k, v, ok := strings.Cut(p, "=") + if !ok || strings.TrimSpace(k) == "" { + return nil, &APIError{Message: fmt.Sprintf("invalid --traefik-label %q — want key=value", p), ExitCode: ExitError} + } + labels[strings.TrimSpace(k)] = v + } + return labels, nil +} + var servicesDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a service", @@ -241,6 +268,11 @@ var servicesUpdateCmd = &cobra.Command{ v, _ := cmd.Flags().GetInt("sleep-idle") body["sleep_idle_minutes"] = v } + if labels, err := traefikLabelFlag(cmd); err != nil { + return err + } else if labels != nil { + body["traefik_labels"] = labels + } if cmd.Flags().Changed("maintenance") { v, _ := cmd.Flags().GetString("maintenance") body["maintenance_mode"] = v == "on" || v == "true" @@ -533,6 +565,7 @@ func init() { f.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)") f.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)") + f.StringArray("traefik-label", nil, "Traefik middleware override, key=value (e.g. middlewares.rl.ratelimit.average=100); repeatable") uf := servicesUpdateCmd.Flags() uf.String("name", "", "service name") @@ -557,6 +590,7 @@ func init() { uf.String("static-dir", "", "output dir for the static builder (empty clears)") uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)") uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") + uf.StringArray("traefik-label", nil, "Traefik middleware override, key=value; repeatable; empty list clears via --traefik-label ''") uf.String("node", "", "pin to a node agent id, 'auto' for least-loaded, 'local' to clear") uf.Bool("spread", false, "spread replicas across all online schedulable nodes (--spread=false to disable)") uf.String("placement-tags", "", "comma-separated node tags required for remote placement (empty clears)") diff --git a/app/backend/internal/deployment/edge_labels_test.go b/app/backend/internal/deployment/edge_labels_test.go new file mode 100644 index 0000000..cc4916c --- /dev/null +++ b/app/backend/internal/deployment/edge_labels_test.go @@ -0,0 +1,60 @@ +package deployment + +import ( + "strings" + "testing" +) + +func TestEdgeLabelsCustomMiddlewares(t *testing.T) { + spec := RuntimeSpec{ + ServiceID: "01234567-89ab-cdef-0123-456789abcdef", + Port: 8080, + Domains: []string{"app.example.com"}, + TraefikLabels: map[string]string{ + "middlewares.rl.ratelimit.average": "100", + "middlewares.rl.ratelimit.period": "1m", + "middlewares.sec.headers.customresponseheaders.X-Frame-Options": "DENY", + }, + } + labels := edgeLabels(spec, "containr-edge") + + if got := labels["traefik.http.middlewares.svc-01234567-rl.ratelimit.average"]; got != "100" { + t.Fatalf("ratelimit label missing: %v", labels) + } + if got := labels["traefik.http.middlewares.svc-01234567-sec.headers.customresponseheaders.X-Frame-Options"]; got != "DENY" { + t.Fatalf("headers label missing: %v", labels) + } + chain := labels["traefik.http.routers.svc-01234567.middlewares"] + if chain != "svc-01234567-rl,svc-01234567-sec" { + t.Fatalf("middleware chain wrong: %q", chain) + } +} + +func TestEdgeLabelsCustomAfterBuiltins(t *testing.T) { + spec := RuntimeSpec{ + ServiceID: "01234567-89ab-cdef-0123-456789abcdef", + Port: 8080, + Domains: []string{"app.example.com"}, + BasicAuthUsers: "u:$apr1$x", + TraefikLabels: map[string]string{ + "middlewares.rl.ratelimit.average": "10", + }, + } + labels := edgeLabels(spec, "containr-edge") + chain := labels["traefik.http.routers.svc-01234567.middlewares"] + if !strings.HasPrefix(chain, "svc-01234567-auth,") || !strings.HasSuffix(chain, "svc-01234567-rl") { + t.Fatalf("builtin should precede custom: %q", chain) + } +} + +func TestEdgeLabelsNoCustom(t *testing.T) { + spec := RuntimeSpec{ + ServiceID: "01234567-89ab-cdef-0123-456789abcdef", + Port: 8080, + Domains: []string{"app.example.com"}, + } + labels := edgeLabels(spec, "containr-edge") + if _, ok := labels["traefik.http.routers.svc-01234567.middlewares"]; ok { + t.Fatal("middleware chain should be absent with no middlewares") + } +} diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index d9e6f92..98caf24 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -55,6 +55,10 @@ type RuntimeSpec struct { Maintenance bool // redirect traffic to MaintenanceURL MaintenanceURL string // absolute URL; empty = serve empty response BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated + // TraefikLabels defines extra middlewares for this service's router — + // keys "middlewares..[.]" (API-validated against an + // allowlist). Defined middlewares attach to the router after builtins. + TraefikLabels map[string]string // NodeID pins the service to a node agent; "" runs on the local host. NodeID string // Spread distributes replicas across all online, schedulable agents @@ -274,44 +278,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet: {Aliases: []string{spec.Name}}, } if edgeNet != "" { - router := "svc-" + spec.ServiceID[:8] - domains := spec.Domains - if len(domains) == 0 && spec.Domain != "" { - domains = []string{spec.Domain} - } - sort.Strings(domains) - rules := make([]string, 0, len(domains)) - for _, d := range domains { - rules = append(rules, "Host(`"+d+"`)") - } endpoints[edgeNet] = &network.EndpointSettings{} - labels["traefik.enable"] = "true" - labels["traefik.docker.network"] = edgeNet - labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ") - labels["traefik.http.routers."+router+".entrypoints"] = "web" - labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) - - var middlewares []string - if spec.Maintenance { - if spec.MaintenanceURL != "" { - mw := router + "-maint" - labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*" - labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL - labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false" - middlewares = append(middlewares, mw) - } else { - // No redirect target configured — take the site offline with - // empty responses instead of leaking traffic to the app. - labels["traefik.http.routers."+router+".service"] = "noop@internal" - } - } - if spec.BasicAuthUsers != "" { - mw := router + "-auth" - labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers - middlewares = append(middlewares, mw) - } - if len(middlewares) > 0 { - labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",") + for k, v := range edgeLabels(spec, edgeNet) { + labels[k] = v } } @@ -399,6 +368,81 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, // vpnkit drops a conflicting binding). var errPortNotBound = errors.New("requested host port not bound") +// edgeLabels builds the Traefik router/service/middleware labels for a +// replica on the edge network: host rules, load balancer port, builtin +// maintenance/basic-auth middlewares, then operator-defined overrides. +func edgeLabels(spec RuntimeSpec, edgeNet string) map[string]string { + router := "svc-" + spec.ServiceID[:8] + domains := spec.Domains + if len(domains) == 0 && spec.Domain != "" { + domains = []string{spec.Domain} + } + sort.Strings(domains) + rules := make([]string, 0, len(domains)) + for _, d := range domains { + rules = append(rules, "Host(`"+d+"`)") + } + + labels := map[string]string{ + "traefik.enable": "true", + "traefik.docker.network": edgeNet, + "traefik.http.routers." + router + ".rule": strings.Join(rules, " || "), + "traefik.http.routers." + router + ".entrypoints": "web", + "traefik.http.services." + router + ".loadbalancer.server.port": fmt.Sprintf("%d", spec.Port), + } + + var middlewares []string + if spec.Maintenance { + if spec.MaintenanceURL != "" { + mw := router + "-maint" + labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*" + labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL + labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false" + middlewares = append(middlewares, mw) + } else { + // No redirect target configured — take the site offline with + // empty responses instead of leaking traffic to the app. + labels["traefik.http.routers."+router+".service"] = "noop@internal" + } + } + if spec.BasicAuthUsers != "" { + mw := router + "-auth" + labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers + middlewares = append(middlewares, mw) + } + // Operator-defined middlewares: "middlewares.." becomes + // "-.", and every defined name joins the router's + // chain after builtins. Names sort for a deterministic chain order. + customMWs := map[string][]string{} + for k, v := range spec.TraefikLabels { + rest := strings.TrimPrefix(k, "middlewares.") + dot := strings.Index(rest, ".") + if dot <= 0 { + continue + } + customMWs[rest[:dot]] = append(customMWs[rest[:dot]], rest[dot+1:]+"="+v) + } + mwNames := make([]string, 0, len(customMWs)) + for name := range customMWs { + mwNames = append(mwNames, name) + } + sort.Strings(mwNames) + for _, name := range mwNames { + mw := router + "-" + name + sort.Strings(customMWs[name]) + for _, kv := range customMWs[name] { + if i := strings.Index(kv, "="); i > 0 { + labels["traefik.http.middlewares."+mw+"."+kv[:i]] = kv[i+1:] + } + } + middlewares = append(middlewares, mw) + } + if len(middlewares) > 0 { + labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",") + } + return labels +} + // isPortConflict reports whether a container create failed because the // requested host port was already bound. func isPortConflict(err error) bool { diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index b37f2c8..f19d379 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -53,7 +53,7 @@ func init() { // Services register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false}) - register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas, volumes = [{type: volume|bind, source, target, read_only}], builder = auto|railpack|nixpacks|dockerfile|static, cpu, memory, cpu_reserve, memory_reserve, static_build_cmd, static_dir, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides).", "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, sleep_enabled (bool — scale to zero on idle), sleep_idle_minutes (1-1440, default 15), node_id (agent id | 'auto' = least-loaded online node; git services build on the node itself), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin), placement_tags (string array — remote placement only onto nodes carrying every tag; re-resolved per reconcile, pin overrides), traefik_labels (object — extra Traefik middlewares for this service's router; keys 'middlewares..[.]', allowlist-validated; defined middlewares attach to the router automatically).", "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}) @@ -66,7 +66,7 @@ func init() { register(spec{"containr_services_move", "Move a service to another project the caller owns. Args: id, project_id (required).", "POST", "/services/{id}/move", []string{"id"}, []string{"project_id"}, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_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. builder = auto|railpack|nixpacks|dockerfile|static, cpu_reserve, memory_reserve, static_build_cmd, static_dir (empty string clears reserves/static fields). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first.", "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). sleep_enabled (bool) + sleep_idle_minutes (1-1440) enable scale-to-zero on idle; a wake placeholder answers traffic while the service resumes. node_id pins placement to a node agent ('auto' = least-loaded, 'local' = run on the Containr host). spread (bool) distributes replicas across every online schedulable node; mutually exclusive with a pin — clear node_id to 'local' first if pinned. placement_tags (string array; [] clears) restricts auto/spread placement to nodes carrying every tag; mutually exclusive with an explicit pin — clear the pin first. traefik_labels (object; {} clears) replaces the service's Traefik middleware overrides — keys 'middlewares..[.]', e.g. {'middlewares.rl.ratelimit.average': '100'}.", "PUT", "/services/{id}", []string{"id"}, nil, false}) // Domains register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) diff --git a/app/backend/migrations_goose/20261113000000_service_traefik_labels.sql b/app/backend/migrations_goose/20261113000000_service_traefik_labels.sql new file mode 100644 index 0000000..e5ac943 --- /dev/null +++ b/app/backend/migrations_goose/20261113000000_service_traefik_labels.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Routing overrides: per-service Traefik middleware definitions stored as +-- { "middlewares...": "value" }. Validated against an +-- allowlist; applied to the service's router and auto-attached to its +-- middleware chain at reconcile time. +ALTER TABLE services ADD COLUMN IF NOT EXISTS traefik_labels jsonb NOT NULL DEFAULT '{}'; + +-- +goose Down +ALTER TABLE services DROP COLUMN IF EXISTS traefik_labels; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 25c96ea..3118d21 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -69,7 +69,8 @@ CREATE TABLE services ( static_dir VARCHAR(255) NOT NULL DEFAULT '', node_id VARCHAR(255), spread BOOLEAN NOT NULL DEFAULT false, - placement_tags JSONB NOT NULL DEFAULT '[]' + placement_tags JSONB NOT NULL DEFAULT '[]', + traefik_labels JSONB NOT NULL DEFAULT '{}' ); CREATE TABLE registries ( diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 86e0182..c97a734 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -485,7 +485,7 @@ export function ServiceDetailPage() { const [volumesForm, setVolumesForm] = useState(null); const [domainInput, setDomainInput] = useState(''); const [domainChecks, setDomainChecks] = useState | null>(null); - const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); + const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string; traefikLabels: string } | null>(null); const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null); const [nodeDraft, setNodeDraft] = useState(null); const nodesQuery = useQuery({ @@ -2490,7 +2490,13 @@ export function ServiceDetailPage() {