feat(scheduling): node tags + service placement_tags affinity

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-09 17:58:39 +02:00
1 parent 9de586f177
commit 736fc59747
23 files changed
+475 -37

No files matched your search

+12 -3
View File
@@ -298,9 +298,18 @@ and wired refs, one click or one CLI call.
inventory-driven, so remote replicas show up and tear down wherever inventory-driven, so remote replicas show up and tear down wherever
they run. Remote nodes run registry-pulled they run. Remote nodes run registry-pulled
images only — git builds, Traefik domains, and remote sleep are images only — git builds, Traefik domains, and remote sleep are
documented ceilings. Remaining: `scheduling_rules` affinity documented ceilings. Tag affinity shipped: `node_agents.tags`
policies, deployment status reported back from agent (operator-set via `PUT /agents/:id` / `nodes update --tags`) +
results, spread weighting by real capacity. `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 - [x] **Agent self-upgrade** — `POST /agents/:id/upgrade` enqueues a
`self_upgrade` command carrying platform + sha256 + server version; `self_upgrade` command carrying platform + sha256 + server version;
the agent downloads its replacement from `GET /agents/download/ the agent downloads its replacement from `GET /agents/download/
+32 -1
View File
@@ -42,6 +42,9 @@ type NodeAgent struct {
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
@@ -242,10 +245,15 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
} }
options := make([]gin.H, 0, len(rows)) options := make([]gin.H, 0, len(rows))
for _, row := range rows { for _, row := range rows {
var tags []string
if len(row.Tags) > 0 {
_ = json.Unmarshal(row.Tags, &tags)
}
options = append(options, gin.H{ options = append(options, gin.H{
"id": row.ID, "id": row.ID,
"name": row.Name, "name": row.Name,
"status": row.Status.String, "status": row.Status.String,
"tags": tags,
}) })
} }
c.JSON(http.StatusOK, gin.H{"nodes": options}) c.JSON(http.StatusOK, gin.H{"nodes": options})
@@ -262,6 +270,7 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
Version: row.Version.String, Version: row.Version.String,
AutoPrune: row.AutoPrune, AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable, Schedulable: row.Schedulable,
Tags: []string{},
Metadata: map[string]interface{}{}, Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
@@ -276,6 +285,9 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
unmarshalRaw(row.Capabilities, &agent.Capabilities) unmarshalRaw(row.Capabilities, &agent.Capabilities)
unmarshalRaw(row.Resources, &agent.Resources) unmarshalRaw(row.Resources, &agent.Resources)
unmarshalRaw(row.Metadata, &agent.Metadata) unmarshalRaw(row.Metadata, &agent.Metadata)
if len(row.Tags) > 0 {
_ = json.Unmarshal(row.Tags, &agent.Tags)
}
return agent return agent
} }
@@ -546,7 +558,26 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
return 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. // applyAgentUpdates merges whitelisted keys from an arbitrary update map.
+37 -4
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "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 // 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. // 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) { 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 { if err != nil {
return nil, fmt.Errorf("list schedulable nodes: %w", err) return nil, fmt.Errorf("list schedulable nodes: %w", err)
} }
if len(agents) == 0 { 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 nil, fmt.Errorf("no online schedulable nodes — connect an agent or unset spread")
} }
return r.reconcileOnAgents(ctx, spec, func(i int) string { 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. // RemoteRuntimeState reads the service's remote replicas from inventory.
// Rows only carry the last dispatched state — the agent doesn't stream // Rows only carry the last dispatched state — the agent doesn't stream
// container events — so "running" means "last create succeeded". // 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 // resolveNodePin validates or resolves a node_id request value. "auto" picks
// the least-loaded online agent; "" / "local" clears the pin. // the least-loaded online agent that carries every required placement tag;
func resolveNodePin(ctx context.Context, db *database.DB, requested string) (string, error) { // "" / "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) requested = strings.TrimSpace(requested)
if requested == "" || requested == "local" { if requested == "" || requested == "local" {
return "", nil return "", nil
} }
q := sqlcdb.New(db.DB) q := sqlcdb.New(db.DB)
if requested == "auto" { 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 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 "", fmt.Errorf("no online nodes available")
} }
return id, nil return id, nil
+1
View File
@@ -94,6 +94,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
RestartPolicy: service.RestartPolicy, RestartPolicy: service.RestartPolicy,
NodeID: serviceNodeID(db, service.ID), NodeID: serviceNodeID(db, service.ID),
Spread: serviceSpread(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 // Best effort: reuse the host port from the last live deployment so the
// public URL survives restarts. Column exists post-migration; older DBs // public URL survives restarts. Column exists post-migration; older DBs
+81 -11
View File
@@ -55,9 +55,12 @@ type Service struct {
NodeName string `json:"node_name,omitempty" db:"-"` NodeName string `json:"node_name,omitempty" db:"-"`
// Spread distributes replicas across every online, schedulable agent. // Spread distributes replicas across every online, schedulable agent.
// Mutually exclusive with an explicit NodeID pin. // Mutually exclusive with an explicit NodeID pin.
Spread bool `json:"spread" db:"-"` Spread bool `json:"spread" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"` // PlacementTags restrict auto/spread candidates to agents carrying all
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // 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 // 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 return false
} }
// loadServiceNode fills the pin + spread + the agent's display name for // servicePlacementTags reads the placement_tags column with the lazy pattern.
// responses. 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) { func loadServiceNode(db *database.DB, s *Service) {
var name sql.NullString var name sql.NullString
s.NodeID = serviceNodeID(db, s.ID) s.NodeID = serviceNodeID(db, s.ID)
s.Spread = serviceSpread(db, s.ID) s.Spread = serviceSpread(db, s.ID)
s.PlacementTags = servicePlacementTags(db, s.ID)
if s.NodeID == "" { if s.NodeID == "" {
return return
} }
@@ -188,6 +230,9 @@ type CreateServiceRequest struct {
// Spread distributes replicas across every online, schedulable agent. // Spread distributes replicas across every online, schedulable agent.
// Mutually exclusive with an explicit node pin. // Mutually exclusive with an explicit node pin.
Spread *bool `json:"spread"` 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 // 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 // Spread toggles multi-node replica distribution; pointer so false is
// a real update, not a missing field. // a real update, not a missing field.
Spread *bool `json:"spread"` 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 // handleGetServices retrieves all services for a project
@@ -465,12 +514,17 @@ func handleCreateService(c *gin.Context) {
if req.Spread != nil { if req.Spread != nil {
service.Spread = *req.Spread service.Spread = *req.Spread
} }
service.PlacementTags = normalizePlacementTags(req.PlacementTags)
if req.NodeID != "" { 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive", "code": "VALIDATION"})
return 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 { if nodeErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"}) c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
return return
@@ -488,9 +542,9 @@ func handleCreateService(c *gin.Context) {
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain, git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
healthcheck_path, restart_policy, volumes, healthcheck_path, restart_policy, volumes,
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir, 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, 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, service.ID, service.ProjectID, service.Name, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", service.Command, service.Type, service.Status, service.Image, service.Command, "", service.Command, service.Type, service.Status, service.Image, service.Command,
@@ -500,6 +554,7 @@ func handleCreateService(c *gin.Context) {
service.StaticBuildCmd, service.StaticDir, service.StaticBuildCmd, service.StaticDir,
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""}, sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
service.Spread, service.Spread,
tagsJSON(service.PlacementTags),
service.CreatedAt, service.UpdatedAt, service.CreatedAt, service.UpdatedAt,
) )
@@ -807,13 +862,22 @@ func handleUpdateService(c *gin.Context) {
} else { } else {
existingService.Spread = serviceSpread(db.(*database.DB), serviceID) 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 { if req.NodeID != nil {
nodeValue := strings.TrimSpace(*req.NodeID) nodeValue := strings.TrimSpace(*req.NodeID)
if existingService.Spread && nodeValue != "" && nodeValue != "auto" && nodeValue != "local" { 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "spread and an explicit node pin are mutually exclusive — set spread=false first", "code": "VALIDATION"})
return 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 { if nodeErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"}) c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
return 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"}) 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 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() existingService.UpdatedAt = time.Now()
@@ -838,7 +906,8 @@ func handleUpdateService(c *gin.Context) {
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16, restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
builder = $19, cpu_reserve = $20, memory_reserve = $21, builder = $19, cpu_reserve = $20, memory_reserve = $21,
static_build_cmd = $22, static_dir = $23, 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`, WHERE id = $18`,
existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Name, existingService.Type, existingService.Image, existingService.Command,
existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.Environment, existingService.GitRepo, existingService.GitBranch,
@@ -851,6 +920,7 @@ func handleUpdateService(c *gin.Context) {
existingService.SleepEnabled, existingService.SleepIdleMinutes, existingService.SleepEnabled, existingService.SleepIdleMinutes,
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""}, sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
existingService.Spread, existingService.Spread,
tagsJSON(existingService.PlacementTags),
) )
if err != nil { if err != nil {
+14 -2
View File
@@ -2,6 +2,7 @@ package commands
import ( import (
"fmt" "fmt"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -83,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{
var nodesUpdateCmd = &cobra.Command{ var nodesUpdateCmd = &cobra.Command{
Use: "update <id>", Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune)", Short: "Update a node agent (--name, --auto-prune, --tags)",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
@@ -97,8 +98,18 @@ var nodesUpdateCmd = &cobra.Command{
if cmd.Flags().Changed("auto-prune") { if cmd.Flags().Changed("auto-prune") {
body["auto_prune"], _ = cmd.Flags().GetBool("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 { 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) data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil { if err != nil {
@@ -292,6 +303,7 @@ var nodeTokensRevokeCmd = &cobra.Command{
func init() { func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name") nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune") 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().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)") nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd) nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
@@ -117,6 +117,15 @@ var servicesCreateCmd = &cobra.Command{
if v, _ := cmd.Flags().GetBool("spread"); v { if v, _ := cmd.Flags().GetBool("spread"); v {
body["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) data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
if err != nil { if err != nil {
return err return err
@@ -210,6 +219,16 @@ var servicesUpdateCmd = &cobra.Command{
v, _ := cmd.Flags().GetBool("spread") v, _ := cmd.Flags().GetBool("spread")
body["spread"] = v 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") { if cmd.Flags().Changed("sleep-idle") {
v, _ := cmd.Flags().GetInt("sleep-idle") v, _ := cmd.Flags().GetInt("sleep-idle")
body["sleep_idle_minutes"] = v 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("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.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.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 := servicesUpdateCmd.Flags()
uf.String("name", "", "service name") uf.String("name", "", "service name")
@@ -529,6 +549,7 @@ func init() {
uf.Int("sleep-idle", 0, "idle minutes before sleeping (1-1440)") 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.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.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"} { for _, a := range []string{"start", "stop", "restart", "redeploy", "sleep", "wake"} {
verb := a verb := a
@@ -75,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version, id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) 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 { type CreateAgentParams struct {
@@ -123,6 +123,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.Metadata, &i.Metadata,
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags,
) )
return i, err return i, err
} }
@@ -257,7 +258,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete
} }
const getAgent = `-- name: GetAgent :one 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) { 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.Metadata,
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags,
) )
return i, err return i, err
} }
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one 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 WHERE hostname = $1 AND ip_address = $2
LIMIT 1 LIMIT 1
` `
@@ -313,6 +315,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.Metadata, &i.Metadata,
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags,
) )
return i, err return i, err
} }
@@ -520,7 +523,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
} }
const listAgents = `-- name: ListAgents :many 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) { 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.Metadata,
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -725,6 +729,42 @@ func (q *Queries) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableA
return items, nil 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 const listServiceContainers = `-- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status FROM container_instances SELECT id, name, node_agent_id, status FROM container_instances
WHERE service_id = $1 WHERE service_id = $1
@@ -794,6 +834,36 @@ func (q *Queries) PickLeastLoadedAgent(ctx context.Context) (string, error) {
return id, err 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 const scrubCommandPayload = `-- name: ScrubCommandPayload :exec
UPDATE agent_commands SET payload = $2, updated_at = NOW() WHERE id = $1 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 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 const updateAgent = `-- name: UpdateAgent :one
UPDATE node_agents SET UPDATE node_agents SET
name = $2, name = $2,
@@ -838,7 +922,7 @@ UPDATE node_agents SET
schedulable = $13, schedulable = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 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 { type UpdateAgentParams struct {
@@ -890,6 +974,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.Metadata, &i.Metadata,
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags,
) )
return i, err return i, err
} }
@@ -177,6 +177,7 @@ type NodeAgent struct {
Metadata pqtype.NullRawMessage `json:"metadata"` Metadata pqtype.NullRawMessage `json:"metadata"`
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"`
} }
type Notification struct { type Notification struct {
@@ -277,6 +278,7 @@ type Service struct {
StaticDir string `json:"static_dir"` StaticDir string `json:"static_dir"`
NodeID sql.NullString `json:"node_id"` NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"` Spread bool `json:"spread"`
PlacementTags json.RawMessage `json:"placement_tags"`
} }
type ServiceDomain struct { type ServiceDomain struct {
@@ -7,6 +7,7 @@ package sqlcdb
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -95,6 +96,8 @@ type Querier interface {
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, 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) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]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 // Resource-aware: lowest memory utilisation first, then cpu, then container
// count. Agents without telemetry sort last (NULL → worst score). // count. Agents without telemetry sort last (NULL → worst score).
PickLeastLoadedAgent(ctx context.Context) (string, error) 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) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
@@ -20,6 +20,10 @@ type NodeRunner interface {
// ReconcileSpread distributes the service's replicas across every online, // ReconcileSpread distributes the service's replicas across every online,
// schedulable agent — replica i lands on agents[i % len]. // schedulable agent — replica i lands on agents[i % len].
ReconcileSpread(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) 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") // ControlService fans a lifecycle action ("start"|"stop"|"restart"|"remove")
// out to every replica of the service on the agent. // out to every replica of the service on the agent.
ControlService(ctx context.Context, serviceID, agentID, action string) error ControlService(ctx context.Context, serviceID, agentID, action string) error
@@ -60,6 +60,9 @@ type RuntimeSpec struct {
// Spread distributes replicas across all online, schedulable agents // Spread distributes replicas across all online, schedulable agents
// instead of a single node. Mutually exclusive with NodeID. // instead of a single node. Mutually exclusive with NodeID.
Spread bool 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. // RuntimeContainer describes one live replica.
@@ -171,6 +174,14 @@ func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSp
} }
return de.nodeRunner.ReconcileOnNode(ctx, spec) 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 { if spec.Replicas < 1 {
spec.Replicas = 1 spec.Replicas = 1
} }
+3 -3
View File
@@ -53,7 +53,7 @@ func init() {
// Services // 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_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_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_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_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}) 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_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_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_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 // Domains
register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) 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_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_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_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_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_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}) 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})
@@ -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;
+32
View File
@@ -141,6 +141,38 @@ SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable WHERE status = 'online' AND schedulable
ORDER BY id; 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 -- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status FROM container_instances SELECT id, name, node_agent_id, status FROM container_instances
WHERE service_id = $1; WHERE service_id = $1;
+4 -2
View File
@@ -68,7 +68,8 @@ CREATE TABLE services (
static_build_cmd VARCHAR(255) NOT NULL DEFAULT '', static_build_cmd VARCHAR(255) NOT NULL DEFAULT '',
static_dir VARCHAR(255) NOT NULL DEFAULT '', static_dir VARCHAR(255) NOT NULL DEFAULT '',
node_id VARCHAR(255), 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 ( CREATE TABLE registries (
@@ -196,7 +197,8 @@ CREATE TABLE node_agents (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB, metadata JSONB,
auto_prune BOOLEAN NOT NULL DEFAULT false, 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 ( CREATE TABLE container_instances (
@@ -47,6 +47,7 @@ export function NodeDetailPage() {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmVolumes, setConfirmVolumes] = useState(false); const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false); const [confirmDrain, setConfirmDrain] = useState(false);
const [tagDraft, setTagDraft] = useState<string | null>(null);
const agentQuery = useQuery({ const agentQuery = useQuery({
queryKey: ['node-agent', id], queryKey: ['node-agent', id],
@@ -79,8 +80,8 @@ export function NodeDetailPage() {
queryClient.invalidateQueries({ queryKey: ['usage-agents'] }); queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
}; };
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body), mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body),
onSuccess: () => { setRename(null); invalidate(); }, onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); },
}); });
const pruneMutation = useMutation({ const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }), mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
@@ -177,6 +178,46 @@ export function NodeDetailPage() {
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')} mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
</p> </p>
)} )}
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)] flex items-center gap-1.5 flex-wrap">
tags:
{tagDraft === null ? (
<>
{agent.tags.length > 0
? agent.tags.map((t) => (
<span key={t} className="px-1.5 py-0.5 rounded border border-[var(--border-subtle)] text-[var(--text-secondary)]">{t}</span>
))
: <span className="text-[var(--text-muted)]">none</span>}
{isAdmin && (
<button
onClick={() => setTagDraft(agent.tags.join(', '))}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Edit placement tags — services can require them via placement_tags"
>
<Pencil size={11} />
</button>
)}
</>
) : (
<>
<input
value={tagDraft}
onChange={(e) => 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"
/>
<button
onClick={() => updateMutation.mutate({ tags: tagDraft.split(',').map((t) => t.trim()).filter(Boolean) })}
className="text-[var(--accent-primary)] hover:underline"
>save</button>
<button onClick={() => setTagDraft(null)} className="text-[var(--text-muted)] hover:underline">cancel</button>
</>
)}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
@@ -2452,6 +2452,30 @@ export function ServiceDetailPage() {
<span className="text-[var(--text-tertiary)]">running on {service.nodeName}</span> <span className="text-[var(--text-tertiary)]">running on {service.nodeName}</span>
)} )}
</div> </div>
{!service.nodeId && (
<div className="mt-3 flex items-center gap-2 text-xs">
<label className="text-[var(--text-tertiary)]" htmlFor="placement-tags">
required node tags
</label>
<input
id="placement-tags"
type="text"
placeholder="e.g. eu-west, gpu — empty = any node"
defaultValue={(service.placementTags ?? []).join(', ')}
disabled={updateServiceMutation.isPending}
title="Remote placement only targets nodes carrying every tag. Re-resolved each reconcile — the service follows the tag, not a stored node."
onBlur={(e) => {
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"
/>
</div>
)}
{updateServiceMutation.isError && ( {updateServiceMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p> <p className="mt-2 text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)} )}
+10 -1
View File
@@ -8008,6 +8008,7 @@ export interface paths {
id?: string; id?: string;
name?: string; name?: string;
status?: string; status?: string;
tags?: string[];
}[]; }[];
}; };
}; };
@@ -8104,7 +8105,7 @@ export interface paths {
}; };
/** /**
* Update node agent * 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: { put: {
parameters: { parameters: {
@@ -10944,6 +10945,8 @@ export interface components {
node_name?: string; 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. */ /** @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; 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) */ /** @description Computed public URL (published port or domain) */
public_url?: string; public_url?: string;
/** /**
@@ -11018,6 +11021,8 @@ export interface components {
node_id?: string; node_id?: string;
/** @description Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin */ /** @description Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin */
spread?: boolean; spread?: boolean;
/** @description Restrict remote placement to agents carrying every listed tag; mutually exclusive with an explicit pin or 'local' */
placement_tags?: string[];
}; };
UpdateServiceRequest: { UpdateServiceRequest: {
/** @description Service name */ /** @description Service name */
@@ -11083,6 +11088,8 @@ export interface components {
node_id?: string; 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 */ /** @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; 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: { ServiceDomain: {
/** Format: uuid */ /** Format: uuid */
@@ -12202,6 +12209,8 @@ export interface components {
auto_prune?: boolean; auto_prune?: boolean;
/** @description False on a cordoned/drained node — `auto` placement skips it and new explicit pins are rejected. */ /** @description False on a cordoned/drained node — `auto` placement skips it and new explicit pins are rejected. */
schedulable?: boolean; schedulable?: boolean;
/** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */
tags?: string[];
/** Format: date-time */ /** Format: date-time */
last_heartbeat?: string; last_heartbeat?: string;
/** Format: date-time */ /** Format: date-time */
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md", "path": "guides/CLI.md",
"section": "guides", "section": "guides",
"title": "Containr CLI", "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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> 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 <id> <file.tar.gz|-> 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 <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> 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 <db-id> --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 <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\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 <user@host> 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|anLine truncated "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 <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> 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 <id> <file.tar.gz|-> 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 <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> 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 <db-id> --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 <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\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 <user@host> 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|anLine truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+6 -1
View File
@@ -87,6 +87,7 @@ export type ServiceEntity = {
nodeId?: string; nodeId?: string;
nodeName?: string; nodeName?: string;
spread?: boolean; spread?: boolean;
placementTags?: string[];
publicUrl?: string; publicUrl?: string;
}; };
@@ -94,6 +95,7 @@ export type NodeOption = {
id: string; id: string;
name: string; name: string;
status: string; status: string;
tags?: string[];
}; };
export async function listNodeOptions(): Promise<NodeOption[]> { export async function listNodeOptions(): Promise<NodeOption[]> {
@@ -358,6 +360,7 @@ export type NodeAgentEntity = {
resources: NodeResources; resources: NodeResources;
autoPrune: boolean; autoPrune: boolean;
schedulable: boolean; schedulable: boolean;
tags: string[];
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
lastHeartbeat?: string; lastHeartbeat?: string;
createdAt?: string; createdAt?: string;
@@ -698,6 +701,7 @@ function normalizeService(service: RawService): ServiceEntity | null {
nodeId: service.node_id, nodeId: service.node_id,
nodeName: service.node_name, nodeName: service.node_name,
spread: service.spread, spread: service.spread,
placementTags: service.placement_tags,
publicUrl: service.public_url, publicUrl: service.public_url,
}; };
} }
@@ -1100,6 +1104,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
lastHeartbeat: agent.last_heartbeat, lastHeartbeat: agent.last_heartbeat,
autoPrune: agent.auto_prune ?? false, autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true, schedulable: agent.schedulable ?? true,
tags: Array.isArray(agent.tags) ? agent.tags : [],
metadata: (agent.metadata ?? {}) as Record<string, unknown>, metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at, createdAt: agent.created_at,
updatedAt: agent.updated_at, updatedAt: agent.updated_at,
@@ -1139,7 +1144,7 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents); return normalizeAgentArray(payload.agents);
} }
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise<NodeAgentEntity | null> { export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, { const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
+20 -1
View File
@@ -5107,6 +5107,9 @@ paths:
type: string type: string
status: status:
type: string type: string
tags:
type: array
items: { type: string }
/agents: /agents:
get: get:
@@ -5157,7 +5160,7 @@ paths:
tags: tags:
- Agents - Agents
summary: Update node agent 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: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@@ -7017,6 +7020,10 @@ components:
spread: spread:
type: boolean 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. 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: public_url:
type: string type: string
description: Computed public URL (published port or domain) description: Computed public URL (published port or domain)
@@ -7114,6 +7121,10 @@ components:
spread: spread:
type: boolean type: boolean
description: Distribute replicas across every online, schedulable node agent; mutually exclusive with an explicit node_id pin 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: UpdateServiceRequest:
type: object type: object
@@ -7206,6 +7217,10 @@ components:
spread: spread:
type: boolean 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 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: ServiceDomain:
type: object type: object
@@ -9091,6 +9106,10 @@ components:
description: >- description: >-
False on a cordoned/drained node — `auto` placement skips it and False on a cordoned/drained node — `auto` placement skips it and
new explicit pins are rejected. 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: last_heartbeat:
type: string type: string
format: date-time format: date-time
+9
View File
@@ -152,6 +152,15 @@ containr services update <id> --replicas 4 --spread # replica i → node[i %
containr services update <id> --spread=false # back to single placement containr services update <id> --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 <id> --tags gpu,eu-west
containr services update <id> --placement-tags gpu # only gpu nodes are eligible
```
Remote nodes run registry-pulled images only — git builds, domains Remote nodes run registry-pulled images only — git builds, domains
(Traefik lives on the host), and sleep detection stay local. Start, (Traefik lives on the host), and sleep detection stay local. Start,
stop, restart, redeploy, and delete all fan out to the pinned node's stop, restart, redeploy, and delete all fan out to the pinned node's