mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(services): traefik routing overrides via allowlisted middleware labels (#64)
services.traefik_labels (jsonb) holds operator-defined Traefik middlewares keyed 'middlewares.<name>.<type>[.<field>]', validated against an allowlist at write time. edgeLabels (extracted from createReplica) scopes each definition to the service router as <router>-<name> and auto-attaches it to the router's middleware chain after builtins. Router/service/provider-level keys are rejected. Surfaces: create/update API fields, services create/update --traefik-label (repeatable, key=value), MCP arg docs, OpenAPI + regenerated types, service-page Access editor (Routing overrides card + textarea). Also marks three stale ROADMAP2 items shipped: notification producers round-out, build-on-node (PR #60), mobile env/domains/scaling (PR #61). 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
4071ed2991
commit
a4bb7ad559
14 files changed
+445
-63
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.<name>.<type>.<field>" → 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.<name>.<type>" with an optional
|
||||
// ".<subkey>" 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.<name>.<type>[.<field>]", 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.<name>.<type>[.<field>]", 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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <id>",
|
||||
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)")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.<name>.<type>[.<field>]" (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.<name>.<rest>" becomes
|
||||
// "<router>-<name>.<rest>", 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 {
|
||||
|
||||
@@ -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.<name>.<type>[.<field>]', 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.<name>.<type>[.<field>]', 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})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
-- Routing overrides: per-service Traefik middleware definitions stored as
|
||||
-- { "middlewares.<name>.<type>.<field>": "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;
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user