From 4458de56b0930bd619b4c767fdfeb69fc62245ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:11:19 +0200 Subject: [PATCH] feat(scheduling): node tags + service placement_tags affinity (#58) Operator-set node labels (node_agents.tags) gate which nodes a service may schedule onto: services.placement_tags requires every listed tag. auto and spread filter eligible agents by tags; tags are re-evaluated each reconcile so retagging a node re-spreads replicas. Pin + tags and local + tags rejected; empty array clears the requirement. Surfaces: --placement-tags on services create/update, nodes update --tags, MCP args, OpenAPI, node-detail tag editor, service-detail placement-tags input. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 15 ++- app/backend/internal/api/agents.go | 33 ++++++- app/backend/internal/api/noderunner.go | 41 +++++++- app/backend/internal/api/runtime.go | 1 + app/backend/internal/api/services.go | 92 +++++++++++++++--- app/backend/internal/cli/commands/nodes.go | 16 +++- app/backend/internal/cli/commands/services.go | 21 ++++ .../internal/database/sqlcdb/agents.sql.go | 95 ++++++++++++++++++- .../internal/database/sqlcdb/models.go | 2 + .../internal/database/sqlcdb/querier.go | 7 ++ app/backend/internal/deployment/engine.go | 4 + app/backend/internal/deployment/runtime.go | 11 +++ app/backend/internal/mcpserver/server.go | 6 +- .../20261112000000_placement_tags.sql | 11 +++ app/backend/sqlc/queries/agents.sql | 32 +++++++ app/backend/sqlc/schema.sql | 6 +- .../src/features/nodes/NodeDetailPage.tsx | 45 ++++++++- .../service/pages/ServiceDetailPage.tsx | 24 +++++ app/frontend/src/generated/api-types.ts | 11 ++- app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 7 +- docs/api/openapi.yaml | 21 +++- docs/guides/CLI.md | 9 ++ 23 files changed, 475 insertions(+), 37 deletions(-) create mode 100644 app/backend/migrations_goose/20261112000000_placement_tags.sql diff --git a/ROADMAP2.md b/ROADMAP2.md index 6fdd6f0..b5006de 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -298,9 +298,18 @@ and wired refs, one click or one CLI call. inventory-driven, so remote replicas show up and tear down wherever they run. Remote nodes run registry-pulled images only — git builds, Traefik domains, and remote sleep are - documented ceilings. Remaining: `scheduling_rules` affinity - policies, deployment status reported back from agent - results, spread weighting by real capacity. + documented ceilings. Tag affinity shipped: `node_agents.tags` + (operator-set via `PUT /agents/:id` / `nodes update --tags`) + + `services.placement_tags` — `auto` and `spread` only consider + online schedulable agents carrying every required tag, tags are + re-evaluated each reconcile (a tag change re-spreads replicas), + unsatisfiable requirements fail with a clear error, pin + tags + and local + tags are rejected, empty array clears. Surfaces: + `--placement-tags` on services create/update, `--tags` on + `nodes update`, MCP args, OpenAPI, node-detail tag editor + + service-detail placement-tags input. Remaining: deployment + status reported back from agent results, spread weighting by + real capacity. - [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a `self_upgrade` command carrying platform + sha256 + server version; the agent downloads its replacement from `GET /agents/download/ diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index 554cfd9..b7bc75b 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -42,6 +42,9 @@ type NodeAgent struct { LastHeartbeat time.Time `json:"last_heartbeat"` AutoPrune bool `json:"auto_prune"` Schedulable bool `json:"schedulable"` + // Tags are operator-set placement labels; services with placement_tags + // only schedule onto agents carrying every required tag. + Tags []string `json:"tags"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Metadata map[string]interface{} `json:"metadata"` @@ -242,10 +245,15 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) { } options := make([]gin.H, 0, len(rows)) for _, row := range rows { + var tags []string + if len(row.Tags) > 0 { + _ = json.Unmarshal(row.Tags, &tags) + } options = append(options, gin.H{ "id": row.ID, "name": row.Name, "status": row.Status.String, + "tags": tags, }) } c.JSON(http.StatusOK, gin.H{"nodes": options}) @@ -262,6 +270,7 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { Version: row.Version.String, AutoPrune: row.AutoPrune, Schedulable: row.Schedulable, + Tags: []string{}, Metadata: map[string]interface{}{}, } if row.LastHeartbeat.Valid { @@ -276,6 +285,9 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { unmarshalRaw(row.Capabilities, &agent.Capabilities) unmarshalRaw(row.Resources, &agent.Resources) unmarshalRaw(row.Metadata, &agent.Metadata) + if len(row.Tags) > 0 { + _ = json.Unmarshal(row.Tags, &agent.Tags) + } return agent } @@ -546,7 +558,26 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)}) + // Tags persist through their own column write — UpdateAgent keeps the + // register-path signature untouched. + resp := agentFromRow(updated) + if raw, ok := updates["tags"]; ok { + var tags []string + if b, mErr := json.Marshal(raw); mErr == nil { + _ = json.Unmarshal(b, &tags) + } + tags = normalizePlacementTags(tags) + if err := h.q.SetAgentTags(ctx, sqlcdb.SetAgentTagsParams{ + ID: id, + Column2: json.RawMessage(tagsJSON(tags)), + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent tags"}) + return + } + resp.Tags = tags + } + + c.JSON(http.StatusOK, gin.H{"agent": resp}) } // applyAgentUpdates merges whitelisted keys from an arbitrary update map. diff --git a/app/backend/internal/api/noderunner.go b/app/backend/internal/api/noderunner.go index 83e8abb..7717596 100644 --- a/app/backend/internal/api/noderunner.go +++ b/app/backend/internal/api/noderunner.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "database/sql" + "encoding/json" "fmt" "strconv" "strings" @@ -56,11 +57,18 @@ func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.R // sorted by id. Replicas wrap when they outnumber nodes. The local host is // not a spread target — pin "local" or leave unset for local execution. func (r *agentNodeRunner) ReconcileSpread(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) { - agents, err := r.q.ListSchedulableAgents(ctx) + tagsRaw, _ := json.Marshal(spec.PlacementTags) + if len(spec.PlacementTags) == 0 { + tagsRaw = []byte("[]") + } + agents, err := r.q.ListSchedulableAgentsMatching(ctx, json.RawMessage(tagsRaw)) if err != nil { return nil, fmt.Errorf("list schedulable nodes: %w", err) } if len(agents) == 0 { + if len(spec.PlacementTags) > 0 { + return nil, fmt.Errorf("no online schedulable nodes carry placement tags %v — tag a node or relax the requirement", spec.PlacementTags) + } return nil, fmt.Errorf("no online schedulable nodes — connect an agent or unset spread") } return r.reconcileOnAgents(ctx, spec, func(i int) string { @@ -68,6 +76,22 @@ func (r *agentNodeRunner) ReconcileSpread(ctx context.Context, spec deployment.R }) } +// ReconcileAuto resolves the least-loaded online agent matching the +// service's placement tags and reconciles every replica onto it. Stale +// replicas on other agents retire through reconcileOnAgents — a service +// follows its tags when the matching set changes. +func (r *agentNodeRunner) ReconcileAuto(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) { + tagsRaw, _ := json.Marshal(spec.PlacementTags) + if len(spec.PlacementTags) == 0 { + tagsRaw = []byte("[]") + } + agentID, err := r.q.PickLeastLoadedAgentMatching(ctx, json.RawMessage(tagsRaw)) + if err != nil { + return nil, fmt.Errorf("no online nodes carry placement tags %v", spec.PlacementTags) + } + return r.reconcileOnAgents(ctx, spec, func(int) string { return agentID }) +} + // RemoteRuntimeState reads the service's remote replicas from inventory. // Rows only carry the last dispatched state — the agent doesn't stream // container events — so "running" means "last create succeeded". @@ -344,16 +368,25 @@ func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, container } // resolveNodePin validates or resolves a node_id request value. "auto" picks -// the least-loaded online agent; "" / "local" clears the pin. -func resolveNodePin(ctx context.Context, db *database.DB, requested string) (string, error) { +// the least-loaded online agent that carries every required placement tag; +// "" / "local" clears the pin. An explicit pin bypasses tag filtering — +// the operator is overriding placement on purpose. +func resolveNodePin(ctx context.Context, db *database.DB, requested string, requiredTags []string) (string, error) { requested = strings.TrimSpace(requested) if requested == "" || requested == "local" { return "", nil } q := sqlcdb.New(db.DB) if requested == "auto" { - id, err := q.PickLeastLoadedAgent(ctx) + tagsRaw, _ := json.Marshal(requiredTags) + if len(requiredTags) == 0 { + tagsRaw = []byte("[]") + } + id, err := q.PickLeastLoadedAgentMatching(ctx, json.RawMessage(tagsRaw)) if err != nil { + if len(requiredTags) > 0 { + return "", fmt.Errorf("no online nodes carry placement tags %v", requiredTags) + } return "", fmt.Errorf("no online nodes available") } return id, nil diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index d20f904..4824a48 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -94,6 +94,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe RestartPolicy: service.RestartPolicy, NodeID: serviceNodeID(db, service.ID), Spread: serviceSpread(db, service.ID), + PlacementTags: servicePlacementTags(db, service.ID), } // Best effort: reuse the host port from the last live deployment so the // public URL survives restarts. Column exists post-migration; older DBs diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index f165e60..b57d1e3 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -55,9 +55,12 @@ type Service struct { NodeName string `json:"node_name,omitempty" db:"-"` // Spread distributes replicas across every online, schedulable agent. // Mutually exclusive with an explicit NodeID pin. - Spread bool `json:"spread" db:"-"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + 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"` } // loadServiceSleep fills the sleep-mode columns, which live outside the @@ -91,12 +94,51 @@ func serviceSpread(db *database.DB, serviceID uuid.UUID) bool { return false } -// loadServiceNode fills the pin + spread + the agent's display name for -// responses. +// servicePlacementTags reads the placement_tags column with the lazy pattern. +func servicePlacementTags(db *database.DB, serviceID uuid.UUID) []string { + var raw []byte + if err := db.QueryRow(`SELECT placement_tags FROM services WHERE id = $1`, serviceID).Scan(&raw); err != nil { + return nil + } + var tags []string + if err := json.Unmarshal(raw, &tags); err != nil { + return nil + } + return tags +} + +// normalizePlacementTags trims, lowercases, and dedupes tag input. Tags are +// matched verbatim against node_agents.tags. +func normalizePlacementTags(tags []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(tags)) + for _, t := range tags { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "" || seen[t] { + continue + } + seen[t] = true + out = append(out, t) + } + return out +} + +// tagsJSON marshals a tag list for a jsonb column; empty stays an array, not null. +func tagsJSON(tags []string) []byte { + if len(tags) == 0 { + return []byte("[]") + } + raw, _ := json.Marshal(tags) + return raw +} + +// loadServiceNode fills the pin + spread + placement tags + the agent's +// display name for responses. func loadServiceNode(db *database.DB, s *Service) { var name sql.NullString s.NodeID = serviceNodeID(db, s.ID) s.Spread = serviceSpread(db, s.ID) + s.PlacementTags = servicePlacementTags(db, s.ID) if s.NodeID == "" { return } @@ -188,6 +230,9 @@ type CreateServiceRequest struct { // Spread distributes replicas across every online, schedulable agent. // Mutually exclusive with an explicit node pin. Spread *bool `json:"spread"` + // PlacementTags restrict auto/spread candidates to agents carrying all + // listed tags. Mutually exclusive with an explicit node pin. + PlacementTags []string `json:"placement_tags"` } // UpdateServiceRequest represents a request to update a service @@ -222,6 +267,10 @@ type UpdateServiceRequest struct { // Spread toggles multi-node replica distribution; pointer so false is // a real update, not a missing field. Spread *bool `json:"spread"` + // PlacementTags restrict auto/spread candidates to agents carrying all + // listed tags; pointer so [] clears the requirement. Mutually exclusive + // with an explicit node pin. + PlacementTags *[]string `json:"placement_tags"` } // handleGetServices retrieves all services for a project @@ -465,12 +514,17 @@ func handleCreateService(c *gin.Context) { if req.Spread != nil { service.Spread = *req.Spread } + service.PlacementTags = normalizePlacementTags(req.PlacementTags) if req.NodeID != "" { - if service.Spread && req.NodeID != "auto" { + if service.Spread && req.NodeID != "auto" && req.NodeID != "local" { c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive", "code": "VALIDATION"}) return } - nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID) + if len(service.PlacementTags) > 0 && req.NodeID != "auto" { + c.JSON(http.StatusBadRequest, gin.H{"error": "placement tags only apply to remote placement — set node_id=\"auto\" or omit it", "code": "VALIDATION"}) + return + } + nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID, service.PlacementTags) if nodeErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"}) return @@ -488,9 +542,9 @@ func handleCreateService(c *gin.Context) { git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, healthcheck_path, restart_policy, volumes, builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, - node_id, spread, created_at, updated_at) + node_id, spread, placement_tags, 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)`, + $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`, 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, @@ -500,6 +554,7 @@ func handleCreateService(c *gin.Context) { service.StaticBuildCmd, service.StaticDir, sql.NullString{String: service.NodeID, Valid: service.NodeID != ""}, service.Spread, + tagsJSON(service.PlacementTags), service.CreatedAt, service.UpdatedAt, ) @@ -807,13 +862,22 @@ func handleUpdateService(c *gin.Context) { } else { existingService.Spread = serviceSpread(db.(*database.DB), serviceID) } + if req.PlacementTags != nil { + existingService.PlacementTags = normalizePlacementTags(*req.PlacementTags) + } else { + existingService.PlacementTags = servicePlacementTags(db.(*database.DB), serviceID) + } if req.NodeID != nil { nodeValue := strings.TrimSpace(*req.NodeID) if existingService.Spread && nodeValue != "" && nodeValue != "auto" && nodeValue != "local" { c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive — set spread=false first", "code": "VALIDATION"}) return } - nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), nodeValue) + if len(existingService.PlacementTags) > 0 && nodeValue != "" && nodeValue != "auto" { + c.JSON(http.StatusBadRequest, gin.H{"error": "service has placement tags — clear them (placement_tags=[]) before pinning or going local", "code": "VALIDATION"}) + return + } + nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), nodeValue, existingService.PlacementTags) if nodeErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"}) return @@ -826,6 +890,10 @@ func handleUpdateService(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before enabling spread", "code": "VALIDATION"}) return } + if len(existingService.PlacementTags) > 0 && existingService.NodeID != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "service already has an explicit node pin — clear it (node_id=\"local\") before setting placement tags", "code": "VALIDATION"}) + return + } existingService.UpdatedAt = time.Now() @@ -838,7 +906,8 @@ func handleUpdateService(c *gin.Context) { 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, - sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27 + sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26, spread = $27, + placement_tags = $28::jsonb WHERE id = $18`, existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Environment, existingService.GitRepo, existingService.GitBranch, @@ -851,6 +920,7 @@ func handleUpdateService(c *gin.Context) { existingService.SleepEnabled, existingService.SleepIdleMinutes, sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""}, existingService.Spread, + tagsJSON(existingService.PlacementTags), ) if err != nil { diff --git a/app/backend/internal/cli/commands/nodes.go b/app/backend/internal/cli/commands/nodes.go index 8a5d7c0..f9979d4 100644 --- a/app/backend/internal/cli/commands/nodes.go +++ b/app/backend/internal/cli/commands/nodes.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "strings" "github.com/spf13/cobra" ) @@ -83,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{ var nodesUpdateCmd = &cobra.Command{ Use: "update ", - Short: "Update a node agent (--name, --auto-prune)", + Short: "Update a node agent (--name, --auto-prune, --tags)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := client() @@ -97,8 +98,18 @@ var nodesUpdateCmd = &cobra.Command{ if cmd.Flags().Changed("auto-prune") { body["auto_prune"], _ = cmd.Flags().GetBool("auto-prune") } + if cmd.Flags().Changed("tags") { + raw, _ := cmd.Flags().GetString("tags") + tags := []string{} + for _, t := range strings.Split(raw, ",") { + if t = strings.TrimSpace(t); t != "" { + tags = append(tags, t) + } + } + body["tags"] = tags + } if len(body) == 0 { - return &APIError{Message: "nothing to update — pass --name or --auto-prune", ExitCode: ExitError} + return &APIError{Message: "nothing to update — pass --name, --auto-prune or --tags", ExitCode: ExitError} } data, err := c.Do("PUT", "/agents/"+args[0], body) if err != nil { @@ -292,6 +303,7 @@ var nodeTokensRevokeCmd = &cobra.Command{ func init() { nodesUpdateCmd.Flags().String("name", "", "node display name") nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune") + nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)") nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)") nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)") nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd) diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index 2b7d1dc..dfa00d5 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -117,6 +117,15 @@ var servicesCreateCmd = &cobra.Command{ if v, _ := cmd.Flags().GetBool("spread"); v { body["spread"] = v } + if v, _ := cmd.Flags().GetString("placement-tags"); v != "" { + tags := []string{} + for _, t := range strings.Split(v, ",") { + if t = strings.TrimSpace(t); t != "" { + tags = append(tags, t) + } + } + body["placement_tags"] = tags + } data, err := c.Do("POST", "/projects/"+args[0]+"/services", body) if err != nil { return err @@ -210,6 +219,16 @@ var servicesUpdateCmd = &cobra.Command{ v, _ := cmd.Flags().GetBool("spread") body["spread"] = v } + if cmd.Flags().Changed("placement-tags") { + v, _ := cmd.Flags().GetString("placement-tags") + tags := []string{} + for _, t := range strings.Split(v, ",") { + if t = strings.TrimSpace(t); t != "" { + tags = append(tags, t) + } + } + body["placement_tags"] = tags + } if cmd.Flags().Changed("sleep-idle") { v, _ := cmd.Flags().GetInt("sleep-idle") body["sleep_idle_minutes"] = v @@ -503,6 +522,7 @@ func init() { f.String("static-dir", "", "output dir for the static builder (default dist)") f.String("node", "", "pin to a node agent id, or 'auto' for least-loaded (image services only)") f.Bool("spread", false, "distribute replicas across all online schedulable nodes") + f.String("placement-tags", "", "comma-separated node tags required for remote placement (auto/spread only)") uf := servicesUpdateCmd.Flags() uf.String("name", "", "service name") @@ -529,6 +549,7 @@ func init() { uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") 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)") for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} { verb := a diff --git a/app/backend/internal/database/sqlcdb/agents.sql.go b/app/backend/internal/database/sqlcdb/agents.sql.go index 821b114..d215260 100644 --- a/app/backend/internal/database/sqlcdb/agents.sql.go +++ b/app/backend/internal/database/sqlcdb/agents.sql.go @@ -75,7 +75,7 @@ INSERT INTO node_agents ( id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) -RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable +RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags ` type CreateAgentParams struct { @@ -123,6 +123,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA &i.Metadata, &i.AutoPrune, &i.Schedulable, + &i.Tags, ) return i, err } @@ -257,7 +258,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete } const getAgent = `-- name: GetAgent :one -SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable FROM node_agents WHERE id = $1 +SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents WHERE id = $1 ` func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) { @@ -279,12 +280,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) { &i.Metadata, &i.AutoPrune, &i.Schedulable, + &i.Tags, ) return i, err } const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one -SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable FROM node_agents +SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents WHERE hostname = $1 AND ip_address = $2 LIMIT 1 ` @@ -313,6 +315,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd &i.Metadata, &i.AutoPrune, &i.Schedulable, + &i.Tags, ) return i, err } @@ -520,7 +523,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea } const listAgents = `-- name: ListAgents :many -SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable FROM node_agents ORDER BY created_at ASC +SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents ORDER BY created_at ASC ` func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) { @@ -548,6 +551,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) { &i.Metadata, &i.AutoPrune, &i.Schedulable, + &i.Tags, ); err != nil { return nil, err } @@ -725,6 +729,42 @@ func (q *Queries) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableA return items, nil } +const listSchedulableAgentsMatching = `-- name: ListSchedulableAgentsMatching :many +SELECT id, name FROM node_agents +WHERE status = 'online' AND schedulable + AND tags @> $1::jsonb +ORDER BY id +` + +type ListSchedulableAgentsMatchingRow struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Only agents carrying every required placement tag (jsonb array containment). +func (q *Queries) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) { + rows, err := q.db.QueryContext(ctx, listSchedulableAgentsMatching, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSchedulableAgentsMatchingRow{} + for rows.Next() { + var i ListSchedulableAgentsMatchingRow + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listServiceContainers = `-- name: ListServiceContainers :many SELECT id, name, node_agent_id, status FROM container_instances WHERE service_id = $1 @@ -794,6 +834,36 @@ func (q *Queries) PickLeastLoadedAgent(ctx context.Context) (string, error) { return id, err } +const pickLeastLoadedAgentMatching = `-- name: PickLeastLoadedAgentMatching :one +SELECT a.id FROM node_agents a +LEFT JOIN LATERAL ( + SELECT h.container_count FROM agent_heartbeats h + WHERE h.node_agent_id = a.id + ORDER BY h.timestamp DESC LIMIT 1 +) h ON true +WHERE a.status = 'online' AND a.schedulable + AND a.tags @> $1::jsonb +ORDER BY + COALESCE( + (a.resources->'memory'->>'used')::double precision + / NULLIF((a.resources->'memory'->>'total')::double precision, 0), + 1.0 + ) ASC, + COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC, + COALESCE(h.container_count, 0) ASC, + a.created_at ASC +LIMIT 1 +` + +// Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents +// carrying every required placement tag. +func (q *Queries) PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error) { + row := q.db.QueryRowContext(ctx, pickLeastLoadedAgentMatching, dollar_1) + var id string + err := row.Scan(&id) + return id, err +} + const scrubCommandPayload = `-- name: ScrubCommandPayload :exec UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1 ` @@ -822,6 +892,20 @@ func (q *Queries) SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulab return err } +const setAgentTags = `-- name: SetAgentTags :exec +UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1 +` + +type SetAgentTagsParams struct { + ID string `json:"id"` + Column2 json.RawMessage `json:"column_2"` +} + +func (q *Queries) SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error { + _, err := q.db.ExecContext(ctx, setAgentTags, arg.ID, arg.Column2) + return err +} + const updateAgent = `-- name: UpdateAgent :one UPDATE node_agents SET name = $2, @@ -838,7 +922,7 @@ UPDATE node_agents SET schedulable = $13, updated_at = NOW() WHERE id = $1 -RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable +RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags ` type UpdateAgentParams struct { @@ -890,6 +974,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA &i.Metadata, &i.AutoPrune, &i.Schedulable, + &i.Tags, ) return i, err } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 93f97ad..d28a042 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -177,6 +177,7 @@ type NodeAgent struct { Metadata pqtype.NullRawMessage `json:"metadata"` AutoPrune bool `json:"auto_prune"` Schedulable bool `json:"schedulable"` + Tags json.RawMessage `json:"tags"` } type Notification struct { @@ -277,6 +278,7 @@ type Service struct { StaticDir string `json:"static_dir"` NodeID sql.NullString `json:"node_id"` Spread bool `json:"spread"` + PlacementTags json.RawMessage `json:"placement_tags"` } type ServiceDomain struct { diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index c8ecf23..f5b57e7 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -7,6 +7,7 @@ package sqlcdb import ( "context" "database/sql" + "encoding/json" "github.com/google/uuid" ) @@ -95,6 +96,8 @@ type Querier interface { // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) + // Only agents carrying every required placement tag (jsonb array containment). + ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) @@ -107,10 +110,14 @@ type Querier interface { // Resource-aware: lowest memory utilisation first, then cpu, then container // count. Agents without telemetry sort last (NULL → worst score). PickLeastLoadedAgent(ctx context.Context) (string, error) + // Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents + // carrying every required placement tag. + PickLeastLoadedAgentMatching(ctx context.Context, dollar_1 json.RawMessage) (string, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error + SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index 54b3e08..df501c9 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -20,6 +20,10 @@ type NodeRunner interface { // ReconcileSpread distributes the service's replicas across every online, // schedulable agent — replica i lands on agents[i % len]. ReconcileSpread(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) + // ReconcileAuto picks the least-loaded online agent carrying every tag in + // spec.PlacementTags and reconciles all replicas onto it. Re-resolved on + // every reconcile — the service follows the tag, not a stored node. + ReconcileAuto(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) // ControlService fans a lifecycle action ("start"|"stop"|"restart"|"remove") // out to every replica of the service on the agent. ControlService(ctx context.Context, serviceID, agentID, action string) error diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index ea1787e..ba3168d 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -60,6 +60,9 @@ type RuntimeSpec struct { // Spread distributes replicas across all online, schedulable agents // instead of a single node. Mutually exclusive with NodeID. Spread bool + // PlacementTags restricts auto/spread candidates to agents carrying + // every listed tag. An explicit NodeID pin bypasses tag filtering. + PlacementTags []string } // RuntimeContainer describes one live replica. @@ -171,6 +174,14 @@ func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSp } return de.nodeRunner.ReconcileOnNode(ctx, spec) } + // Placement tags without a pin/spread resolve to the least-loaded + // matching agent on every reconcile — soft affinity, re-evaluated. + if len(spec.PlacementTags) > 0 { + if de.nodeRunner == nil { + return nil, fmt.Errorf("service has placement tags but remote dispatch is unavailable") + } + return de.nodeRunner.ReconcileAuto(ctx, spec) + } if spec.Replicas < 1 { spec.Replicas = 1 } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index c77036e..654771c 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, node_id (agent id | 'auto' = least-loaded online node; image services only — remote nodes can't run git builds), spread (bool — distribute replicas across every online schedulable node; mutually exclusive with a node pin).", "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, node_id (agent id | 'auto' = least-loaded online node; image services only — remote nodes can't run git builds), 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_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.", "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.", "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}) @@ -173,7 +173,7 @@ func init() { register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false}) register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false}) - register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name.", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name"}, false}) + register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags"}, false}) register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true}) diff --git a/app/backend/migrations_goose/20261112000000_placement_tags.sql b/app/backend/migrations_goose/20261112000000_placement_tags.sql new file mode 100644 index 0000000..5d96093 --- /dev/null +++ b/app/backend/migrations_goose/20261112000000_placement_tags.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Placement tags: operators label node_agents (tags jsonb array of strings), +-- services require a tag set (placement_tags). "auto" and spread placement +-- only consider online schedulable agents carrying every required tag. +-- Explicit node_id pins bypass tag filtering — the pin is an operator override. +ALTER TABLE services ADD COLUMN IF NOT EXISTS placement_tags jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS tags jsonb NOT NULL DEFAULT '[]'; + +-- +goose Down +ALTER TABLE services DROP COLUMN IF EXISTS placement_tags; +ALTER TABLE node_agents DROP COLUMN IF EXISTS tags; diff --git a/app/backend/sqlc/queries/agents.sql b/app/backend/sqlc/queries/agents.sql index 2400943..e649893 100644 --- a/app/backend/sqlc/queries/agents.sql +++ b/app/backend/sqlc/queries/agents.sql @@ -141,6 +141,38 @@ SELECT id, name FROM node_agents WHERE status = 'online' AND schedulable ORDER BY id; +-- name: ListSchedulableAgentsMatching :many +-- Only agents carrying every required placement tag (jsonb array containment). +SELECT id, name FROM node_agents +WHERE status = 'online' AND schedulable + AND tags @> $1::jsonb +ORDER BY id; + +-- name: PickLeastLoadedAgentMatching :one +-- Same resource-aware ordering as PickLeastLoadedAgent, restricted to agents +-- carrying every required placement tag. +SELECT a.id FROM node_agents a +LEFT JOIN LATERAL ( + SELECT h.container_count FROM agent_heartbeats h + WHERE h.node_agent_id = a.id + ORDER BY h.timestamp DESC LIMIT 1 +) h ON true +WHERE a.status = 'online' AND a.schedulable + AND a.tags @> $1::jsonb +ORDER BY + COALESCE( + (a.resources->'memory'->>'used')::double precision + / NULLIF((a.resources->'memory'->>'total')::double precision, 0), + 1.0 + ) ASC, + COALESCE((a.resources->'cpu'->>'usage')::double precision, 100.0) ASC, + COALESCE(h.container_count, 0) ASC, + a.created_at ASC +LIMIT 1; + +-- name: SetAgentTags :exec +UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1; + -- name: ListServiceContainers :many SELECT id, name, node_agent_id, status FROM container_instances WHERE service_id = $1; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 6c799d9..25c96ea 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -68,7 +68,8 @@ CREATE TABLE services ( static_build_cmd VARCHAR(255) NOT NULL DEFAULT '', static_dir VARCHAR(255) NOT NULL DEFAULT '', node_id VARCHAR(255), - spread BOOLEAN NOT NULL DEFAULT false + spread BOOLEAN NOT NULL DEFAULT false, + placement_tags JSONB NOT NULL DEFAULT '[]' ); CREATE TABLE registries ( @@ -196,7 +197,8 @@ CREATE TABLE node_agents ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), metadata JSONB, auto_prune BOOLEAN NOT NULL DEFAULT false, - schedulable BOOLEAN NOT NULL DEFAULT true + schedulable BOOLEAN NOT NULL DEFAULT true, + tags JSONB NOT NULL DEFAULT '[]' ); CREATE TABLE container_instances ( diff --git a/app/frontend/src/features/nodes/NodeDetailPage.tsx b/app/frontend/src/features/nodes/NodeDetailPage.tsx index ee6c6ee..100c464 100644 --- a/app/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/app/frontend/src/features/nodes/NodeDetailPage.tsx @@ -47,6 +47,7 @@ export function NodeDetailPage() { const [confirmDelete, setConfirmDelete] = useState(false); const [confirmVolumes, setConfirmVolumes] = useState(false); const [confirmDrain, setConfirmDrain] = useState(false); + const [tagDraft, setTagDraft] = useState(null); const agentQuery = useQuery({ queryKey: ['node-agent', id], @@ -79,8 +80,8 @@ export function NodeDetailPage() { queryClient.invalidateQueries({ queryKey: ['usage-agents'] }); }; const updateMutation = useMutation({ - mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body), - onSuccess: () => { setRename(null); invalidate(); }, + mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body), + onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); }, }); const pruneMutation = useMutation({ mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }), @@ -177,6 +178,46 @@ export function NodeDetailPage() { mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}

)} +

+ tags: + {tagDraft === null ? ( + <> + {agent.tags.length > 0 + ? agent.tags.map((t) => ( + {t} + )) + : none} + {isAdmin && ( + + )} + + ) : ( + <> + setTagDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) }); + if (e.key === 'Escape') setTagDraft(null); + }} + autoFocus + placeholder="eu-west, gpu" + className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-52" + /> + + + + )} +

running on {service.nodeName} )}
+ {!service.nodeId && ( +
+ + { + const next = e.target.value.split(',').map((t) => t.trim()).filter(Boolean); + const prev = service.placementTags ?? []; + if (next.join('|') !== prev.join('|')) { + updateServiceMutation.mutate({ placement_tags: next }); + } + }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} + className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-72" + /> +
+ )} {updateServiceMutation.isError && (

{(updateServiceMutation.error as Error).message}

)} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 35496e8..e46f523 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -8008,6 +8008,7 @@ export interface paths { id?: string; name?: string; status?: string; + tags?: string[]; }[]; }; }; @@ -8104,7 +8105,7 @@ export interface paths { }; /** * Update node agent - * @description Partial update — accepts any subset of agent fields (name, status, labels, etc.). + * @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. */ put: { parameters: { @@ -10944,6 +10945,8 @@ export interface components { node_name?: string; /** @description Replicas are distributed across every online, schedulable node agent (replica i → agents[i mod n]). Mutually exclusive with an explicit node_id pin. */ spread?: boolean; + /** @description Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). */ + placement_tags?: string[]; /** @description Computed public URL (published port or domain) */ public_url?: string; /** @@ -11018,6 +11021,8 @@ export interface components { node_id?: string; /** @description Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin */ spread?: boolean; + /** @description Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' */ + placement_tags?: string[]; }; UpdateServiceRequest: { /** @description Service name */ @@ -11083,6 +11088,8 @@ export interface components { node_id?: string; /** @description Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin — send 'local' first to clear a pin */ spread?: boolean; + /** @description Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first */ + placement_tags?: string[]; }; ServiceDomain: { /** Format: uuid */ @@ -12202,6 +12209,8 @@ export interface components { auto_prune?: boolean; /** @description False on a cordoned/drained node — `auto` placement skips it and new explicit pins are rejected. */ schedulable?: boolean; + /** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */ + tags?: string[]; /** Format: date-time */ last_heartbeat?: string; /** Format: date-time */ diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 03f4993..c1c9ce6 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/CLI.md", "section": "guides", "title": "Containr CLI", - "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity enriched activity feed; --severity/--category/\n --resource/--project/--limit filters\nshell interactive PTY shell inside the service\n container (bash→sh fallback; resizes follow)\nbanners list|create|update|delete announcement banners (admin);\n active banners print on `auth status`\ninvites list|create|revoke team invite links (admin); create\n prints the one-time acceptance URL\nadmin impersonate mint a 15-min token acting as a user\nadmin github-app status|manifest|convert\n self-provision the instance GitHub App\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Node placement\n\nPin a service to a registered node agent, or let the scheduler pick the\nleast-loaded online node:\n\n```bash\ncontainr services create --project --name api --image ghcr.io/x/api:v1 --node \ncontainr services update --node auto # least-loaded online node\ncontainr services update --node local # move back to this server\n```\n\n`auto` scores nodes by real headroom — lowest memory utilisation, then\ncpu, then container count. Or spread replicas across every online,\nschedulable node (mutually exclusive with a pin):\n\n```bash\ncontainr services update --replicas 4 --spread # replica i → node[i % n]\ncontainr services update --spread=false # back to single placement\n```\n\nRemote nodes run registry-pulled images only — git builds, domains\n(Traefik lives on the host), and sleep detection stay local. Start,\nstop, restart, redeploy, and delete all fan out to the pinned node's\nagent. `services get` shows the resolved `node_name`.\n\nTake a node out of rotation or empty it entirely (admin):\n\n```bash\ncontainr nodes cordon # no new pins; running replicas stay\ncontainr nodes uncordon \ncontainr nodes drain # cordon + remove its containers + unpin services\ncontainr nodes upgrade # agent pulls its replacement from the server, re-execs\n```\n\n`nodes upgrade` swaps the agent binary for the one bundled in the running\nserver image — the agent downloads it over the API, verifies the sha256 the\nserver computed, sanity-checks it, atomically replaces its own executable,\nand re-execs. The node must be online and the server must ship a binary for\nits architecture (`linux-amd64`/`linux-arm64`).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity enriched activity feed; --severity/--category/\n --resource/--project/--limit filters\nshell interactive PTY shell inside the service\n container (bash→sh fallback; resizes follow)\nbanners list|create|update|delete announcement banners (admin);\n active banners print on `auth status`\ninvites list|create|revoke team invite links (admin); create\n prints the one-time acceptance URL\nadmin impersonate mint a 15-min token acting as a user\nadmin github-app status|manifest|convert\n self-provision the instance GitHub App\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Node placement\n\nPin a service to a registered node agent, or let the scheduler pick the\nleast-loaded online node:\n\n```bash\ncontainr services create --project --name api --image ghcr.io/x/api:v1 --node \ncontainr services update --node auto # least-loaded online node\ncontainr services update --node local # move back to this server\n```\n\n`auto` scores nodes by real headroom — lowest memory utilisation, then\ncpu, then container count. Or spread replicas across every online,\nschedulable node (mutually exclusive with a pin):\n\n```bash\ncontainr services update --replicas 4 --spread # replica i → node[i % n]\ncontainr services update --spread=false # back to single placement\n```\n\nConstrain `auto`/`spread` placement to nodes carrying operator tags —\n`nodes update` labels a node, `--placement-tags` requires every listed tag\n(mutually exclusive with a pin; an empty value clears the requirement):\n\n```bash\ncontainr nodes update --tags gpu,eu-west\ncontainr services update --placement-tags gpu # only gpu nodes are eligible\n```\n\nRemote nodes run registry-pulled images only — git builds, domains\n(Traefik lives on the host), and sleep detection stay local. Start,\nstop, restart, redeploy, and delete all fan out to the pinned node's\nagent. `services get` shows the resolved `node_name`.\n\nTake a node out of rotation or empty it entirely (admin):\n\n```bash\ncontainr nodes cordon # no new pins; running replicas stay\ncontainr nodes uncordon \ncontainr nodes drain # cordon + remove its containers + unpin services\ncontainr nodes upgrade # agent pulls its replacement from the server, re-execs\n```\n\n`nodes upgrade` swaps the agent binary for the one bundled in the running\nserver image — the agent downloads it over the API, verifies the sha256 the\nserver computed, sanity-checks it, atomically replaces its own executable,\nand re-execs. The node must be online and the server must ship a binary for\nits architecture (`linux-amd64`/`linux-arm64`).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index c2f0214..2282a60 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -87,6 +87,7 @@ export type ServiceEntity = { nodeId?: string; nodeName?: string; spread?: boolean; + placementTags?: string[]; publicUrl?: string; }; @@ -94,6 +95,7 @@ export type NodeOption = { id: string; name: string; status: string; + tags?: string[]; }; export async function listNodeOptions(): Promise { @@ -358,6 +360,7 @@ export type NodeAgentEntity = { resources: NodeResources; autoPrune: boolean; schedulable: boolean; + tags: string[]; metadata: Record; lastHeartbeat?: string; createdAt?: string; @@ -698,6 +701,7 @@ function normalizeService(service: RawService): ServiceEntity | null { nodeId: service.node_id, nodeName: service.node_name, spread: service.spread, + placementTags: service.placement_tags, publicUrl: service.public_url, }; } @@ -1100,6 +1104,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null { lastHeartbeat: agent.last_heartbeat, autoPrune: agent.auto_prune ?? false, schedulable: agent.schedulable ?? true, + tags: Array.isArray(agent.tags) ? agent.tags : [], metadata: (agent.metadata ?? {}) as Record, createdAt: agent.created_at, updatedAt: agent.updated_at, @@ -1139,7 +1144,7 @@ export async function listAgents(): Promise { return normalizeAgentArray(payload.agents); } -export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise { +export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise { const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, { method: 'PUT', body: JSON.stringify(body), diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 968cc51..a19a84a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5107,6 +5107,9 @@ paths: type: string status: type: string + tags: + type: array + items: { type: string } /agents: get: @@ -5157,7 +5160,7 @@ paths: tags: - Agents summary: Update node agent - description: Partial update — accepts any subset of agent fields (name, status, labels, etc.). + description: Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. security: - bearerAuth: [] parameters: @@ -7017,6 +7020,10 @@ components: spread: type: boolean description: Replicas are distributed across every online, schedulable node agent (replica i → agents[i mod n]). Mutually exclusive with an explicit node_id pin. + placement_tags: + type: array + items: { type: string } + description: Node tags required for remote placement — auto/spread only consider agents carrying every tag; re-resolved each reconcile. Mutually exclusive with an explicit node_id pin (read-only on responses when a pin is set). public_url: type: string description: Computed public URL (published port or domain) @@ -7114,6 +7121,10 @@ components: spread: type: boolean description: Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin + placement_tags: + type: array + items: { type: string } + description: Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' UpdateServiceRequest: type: object @@ -7206,6 +7217,10 @@ components: spread: type: boolean description: Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin — send 'local' first to clear a pin + placement_tags: + type: array + items: { type: string } + description: Restrict remote placement to agents carrying every listed tag; [] clears. Mutually exclusive with a pin or 'local' — clear the pin first ServiceDomain: type: object @@ -9091,6 +9106,10 @@ components: description: >- False on a cordoned/drained node — `auto` placement skips it and new explicit pins are rejected. + tags: + type: array + items: { type: string } + description: Operator-set placement labels; services with placement_tags require all of them on the target node. last_heartbeat: type: string format: date-time diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index ac8c9bf..e9223ea 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -152,6 +152,15 @@ containr services update --replicas 4 --spread # replica i → node[i % containr services update --spread=false # back to single placement ``` +Constrain `auto`/`spread` placement to nodes carrying operator tags — +`nodes update` labels a node, `--placement-tags` requires every listed tag +(mutually exclusive with a pin; an empty value clears the requirement): + +```bash +containr nodes update --tags gpu,eu-west +containr services update --placement-tags gpu # only gpu nodes are eligible +``` + Remote nodes run registry-pulled images only — git builds, domains (Traefik lives on the host), and sleep detection stay local. Start, stop, restart, redeploy, and delete all fan out to the pinned node's