mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4458de56b0 | ||
|
|
9de586f177 | ||
|
|
dd5ce80ce2 |
No files matched your search
+32
-4
@@ -289,11 +289,39 @@ and wired refs, one click or one CLI call.
|
|||||||
(drain evicts remote containers via the agent queue + unpins services
|
(drain evicts remote containers via the agent queue + unpins services
|
||||||
so they redeploy locally), `nodes cordon|uncordon|drain` CLI +
|
so they redeploy locally), `nodes cordon|uncordon|drain` CLI +
|
||||||
MCP (drain confirm-gated) + node-detail page controls; `auto` and
|
MCP (drain confirm-gated) + node-detail page controls; `auto` and
|
||||||
explicit pins skip cordoned nodes. Remote nodes run registry-pulled
|
explicit pins skip cordoned nodes. Spread placement shipped:
|
||||||
|
`services.spread` distributes replicas deterministically across every
|
||||||
|
online schedulable agent (replica i → agents[i mod n]), mutually
|
||||||
|
exclusive with a pin; `auto` is now resource-aware — least memory
|
||||||
|
utilisation, then cpu, then container count, with uninstrumented
|
||||||
|
agents sorting last. Lifecycle and `GET /runtime` are
|
||||||
|
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
|
images only — git builds, Traefik domains, and remote sleep are
|
||||||
documented ceilings. Remaining: `scheduling_rules` spread/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, per-node resource-aware `auto` (currently count-based).
|
`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/
|
||||||
|
:platform`, verifies the checksum, sanity-checks `--version`,
|
||||||
|
atomically swaps its own executable, reports the result, then
|
||||||
|
re-execs (`syscall.Exec`) — no supervisor needed. Agents now stamp
|
||||||
|
`main.version` via ldflags at image build and report it at
|
||||||
|
register/heartbeat (`node_agents.version` refreshes on re-register;
|
||||||
|
older agents keep their stored value). Surfaces: `nodes upgrade`
|
||||||
|
CLI, `containr_nodes_upgrade` MCP (confirm-gated), node-detail page
|
||||||
|
button (online-gated). Ceilings: linux amd64/arm64 only; agents on
|
||||||
|
other platforms get a `NO_BINARY` 409.
|
||||||
- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch
|
- [🟡] **Build-on-node** — private registry pulls shipped: the dispatch
|
||||||
payload carries the project owner's `registries` credentials for the
|
payload carries the project owner's `registries` credentials for the
|
||||||
image host; the agent `docker login`s for the pull and logs out after,
|
image host; the agent `docker login`s for the pull and logs out after,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
|||||||
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
|
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
|
||||||
|
|
||||||
# Node-agent binaries served by the API for SSH/bootstrap installs.
|
# Node-agent binaries served by the API for SSH/bootstrap installs.
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /agents/containr-agent-linux-amd64 ./cmd/agent && \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-amd64 ./cmd/agent && \
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /agents/containr-agent-linux-arm64 ./cmd/agent
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o /agents/containr-agent-linux-arm64 ./cmd/agent
|
||||||
|
|
||||||
# Build embedded Better Auth runtime dependencies
|
# Build embedded Better Auth runtime dependencies
|
||||||
FROM node:22-alpine AS auth-builder
|
FROM node:22-alpine AS auth-builder
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -104,7 +108,15 @@ type registerResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// version is stamped at build time via -ldflags "-X main.version=…".
|
||||||
|
// The server reports it on upgrades so agents can show what they run.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "version") {
|
||||||
|
fmt.Printf("containr-agent %s\n", version)
|
||||||
|
return
|
||||||
|
}
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -167,6 +179,7 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
|
|||||||
"ip_address": cfg.IP,
|
"ip_address": cfg.IP,
|
||||||
"port": cfg.Port,
|
"port": cfg.Port,
|
||||||
"capabilities": detectCapabilities(),
|
"capabilities": detectCapabilities(),
|
||||||
|
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
|
||||||
// Always send mesh so a removed interface clears stored state.
|
// Always send mesh so a removed interface clears stored state.
|
||||||
"mesh": detectMeshInterfaces(),
|
"mesh": detectMeshInterfaces(),
|
||||||
}
|
}
|
||||||
@@ -190,7 +203,7 @@ func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, ag
|
|||||||
"container_count": dockerContainerCount(),
|
"container_count": dockerContainerCount(),
|
||||||
"system_load": readSystemLoad(),
|
"system_load": readSystemLoad(),
|
||||||
"uptime": readSystemUptime(),
|
"uptime": readSystemUptime(),
|
||||||
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
|
"version": firstEnv("CONTAINR_AGENT_VERSION", version),
|
||||||
}
|
}
|
||||||
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
||||||
}
|
}
|
||||||
@@ -216,7 +229,13 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, item := range list.Commands {
|
for _, item := range list.Commands {
|
||||||
result, runErr := executeCommand(item)
|
var result, reexecPath string
|
||||||
|
var runErr error
|
||||||
|
if item.Type == "self_upgrade" {
|
||||||
|
result, reexecPath, runErr = selfUpgrade(cfg, item.Payload)
|
||||||
|
} else {
|
||||||
|
result, runErr = executeCommand(item)
|
||||||
|
}
|
||||||
status := "completed"
|
status := "completed"
|
||||||
errText := ""
|
errText := ""
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
@@ -232,6 +251,12 @@ func processCommands(ctx context.Context, client *http.Client, cfg agentConfig,
|
|||||||
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||||
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
||||||
}
|
}
|
||||||
|
// Re-exec AFTER the result posts — the new binary picks up the loop.
|
||||||
|
if reexecPath != "" {
|
||||||
|
if err := syscall.Exec(reexecPath, os.Args, os.Environ()); err != nil {
|
||||||
|
log.Fatalf("re-exec after upgrade failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -257,6 +282,67 @@ func executeCommand(item command) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selfUpgrade downloads the agent binary the server bundles for this
|
||||||
|
// platform, verifies it, swaps it in atomically, and returns the path to
|
||||||
|
// re-exec. On any failure the running binary is left untouched.
|
||||||
|
func selfUpgrade(cfg agentConfig, payload map[string]interface{}) (string, string, error) {
|
||||||
|
platform := firstString(stringValue(payload["platform"]), runtime.GOOS+"-"+runtime.GOARCH)
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("resolve own path: %w", err)
|
||||||
|
}
|
||||||
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||||
|
exe = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/agents/download/%s", cfg.APIURL, platform)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("download %s: %w", platform, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("download %s: server returned %d", platform, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := exe + ".new"
|
||||||
|
out, err := os.OpenFile(newPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("write %s: %w", newPath, err)
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(io.MultiWriter(out, h), resp.Body); err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(newPath)
|
||||||
|
return "", "", fmt.Errorf("download write: %w", err)
|
||||||
|
}
|
||||||
|
out.Close()
|
||||||
|
|
||||||
|
if want := stringValue(payload["sha256"]); want != "" {
|
||||||
|
if got := hex.EncodeToString(h.Sum(nil)); got != want {
|
||||||
|
os.Remove(newPath)
|
||||||
|
return "", "", fmt.Errorf("sha256 mismatch: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: the new binary must at least report its version.
|
||||||
|
if verOut, err := exec.Command(newPath, "--version").CombinedOutput(); err != nil {
|
||||||
|
os.Remove(newPath)
|
||||||
|
return "", "", fmt.Errorf("new binary failed sanity check: %s", strings.TrimSpace(string(verOut)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(newPath, exe); err != nil {
|
||||||
|
os.Remove(newPath)
|
||||||
|
return "", "", fmt.Errorf("swap binary: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("upgraded to %s (%s)", firstString(stringValue(payload["version"]), "latest"), platform), exe, nil
|
||||||
|
}
|
||||||
|
|
||||||
// dockerPrune runs a bounded docker system prune. Volumes are excluded
|
// dockerPrune runs a bounded docker system prune. Volumes are excluded
|
||||||
// unless the payload explicitly opts in — a stray volume prune destroys
|
// unless the payload explicitly opts in — a stray volume prune destroys
|
||||||
// database data.
|
// database data.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -41,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"`
|
||||||
@@ -241,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})
|
||||||
@@ -261,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 {
|
||||||
@@ -275,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +366,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
IPAddress string `json:"ip_address" binding:"required"`
|
IPAddress string `json:"ip_address" binding:"required"`
|
||||||
Port int `json:"port" binding:"required"`
|
Port int `json:"port" binding:"required"`
|
||||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||||
|
Version string `json:"version"`
|
||||||
AuthToken string `json:"auth_token"`
|
AuthToken string `json:"auth_token"`
|
||||||
Mesh map[string]string `json:"mesh"`
|
Mesh map[string]string `json:"mesh"`
|
||||||
}
|
}
|
||||||
@@ -398,7 +412,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
IpAddress: existing.IpAddress,
|
IpAddress: existing.IpAddress,
|
||||||
Port: int32(req.Port),
|
Port: int32(req.Port),
|
||||||
Status: sql.NullString{String: "connecting", Valid: true},
|
Status: sql.NullString{String: "connecting", Valid: true},
|
||||||
Version: existing.Version,
|
// Older agents don't report a version — keep whatever's stored.
|
||||||
|
Version: sql.NullString{String: firstNonEmpty(req.Version, existing.Version.String), Valid: true},
|
||||||
Capabilities: rawJSON(req.Capabilities),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: existing.Resources,
|
Resources: existing.Resources,
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
@@ -450,7 +465,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
IpAddress: req.IPAddress,
|
IpAddress: req.IPAddress,
|
||||||
Port: int32(req.Port),
|
Port: int32(req.Port),
|
||||||
Status: sql.NullString{String: "connecting", Valid: true},
|
Status: sql.NullString{String: "connecting", Valid: true},
|
||||||
Version: sql.NullString{},
|
Version: sql.NullString{String: req.Version, Valid: req.Version != ""},
|
||||||
Capabilities: rawJSON(req.Capabilities),
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
Resources: rawJSON(resources),
|
Resources: rawJSON(resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
@@ -543,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.
|
||||||
@@ -911,6 +945,76 @@ func (h *NodeAgentHandler) PruneAgent(c *gin.Context) {
|
|||||||
c.JSON(http.StatusAccepted, gin.H{"command": command})
|
c.JSON(http.StatusAccepted, gin.H{"command": command})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpgradeAgent enqueues a self_upgrade command: the agent downloads its
|
||||||
|
// replacement binary from this server's bundled /download endpoint,
|
||||||
|
// verifies the sha256, swaps it in, and re-execs. Fails fast when no
|
||||||
|
// bundled binary exists for the agent's architecture.
|
||||||
|
func (h *NodeAgentHandler) UpgradeAgent(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
id := c.Param("id")
|
||||||
|
agent, err := h.q.GetAgent(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if agent.Status.String != "online" {
|
||||||
|
respondError(c, http.StatusConflict, "NODE_OFFLINE", "node is offline — reconnect it before upgrading")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
arch := "amd64"
|
||||||
|
var caps struct {
|
||||||
|
SupportedArchitectures []string `json:"supported_architectures"`
|
||||||
|
}
|
||||||
|
unmarshalRaw(agent.Capabilities, &caps)
|
||||||
|
if len(caps.SupportedArchitectures) > 0 && caps.SupportedArchitectures[0] != "" {
|
||||||
|
arch = caps.SupportedArchitectures[0]
|
||||||
|
}
|
||||||
|
platform := "linux-" + arch // agents only ship for linux today
|
||||||
|
|
||||||
|
dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/agents"
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "containr-agent-"+platform)
|
||||||
|
sum, err := sha256File(path)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusConflict, "NO_BINARY",
|
||||||
|
fmt.Sprintf("no bundled agent binary for %s on this server", platform))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Type: "self_upgrade",
|
||||||
|
NodeAgentID: id,
|
||||||
|
Payload: rawJSON(map[string]interface{}{
|
||||||
|
"platform": platform,
|
||||||
|
"sha256": sum,
|
||||||
|
"version": Version,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to enqueue upgrade")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogAuditWithRequest(c, "node", id, "upgrade", map[string]interface{}{"platform": platform, "version": Version})
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"command": command, "platform": platform, "version": Version})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256File(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExecuteCommand executes a command on an agent
|
// ExecuteCommand executes a command on an agent
|
||||||
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
agentID := c.Param("id")
|
||||||
@@ -1390,6 +1494,7 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
|
|||||||
agents.POST("/:id/cordon", h.CordonAgent)
|
agents.POST("/:id/cordon", h.CordonAgent)
|
||||||
agents.POST("/:id/uncordon", h.UncordonAgent)
|
agents.POST("/:id/uncordon", h.UncordonAgent)
|
||||||
agents.POST("/:id/drain", h.DrainAgent)
|
agents.POST("/:id/drain", h.DrainAgent)
|
||||||
|
agents.POST("/:id/upgrade", h.UpgradeAgent)
|
||||||
agents.POST("/:id/prune", h.PruneAgent)
|
agents.POST("/:id/prune", h.PruneAgent)
|
||||||
agents.POST("/:id/commands", h.ExecuteCommand)
|
agents.POST("/:id/commands", h.ExecuteCommand)
|
||||||
agents.GET("/:id/commands", h.GetAgentCommands)
|
agents.GET("/:id/commands", h.GetAgentCommands)
|
||||||
|
|||||||
@@ -425,9 +425,10 @@ func runDeploymentAndSyncWithImage(
|
|||||||
// on the local host, so remote placement only works for registry-pulled
|
// on the local host, so remote placement only works for registry-pulled
|
||||||
// images — git-sourced and rollback deploys fail fast with that message.
|
// images — git-sourced and rollback deploys fail fast with that message.
|
||||||
nodeID := serviceNodeID(db, service.ID)
|
nodeID := serviceNodeID(db, service.ID)
|
||||||
if nodeID != "" && (service.GitRepo != "" || imageOverride != "") {
|
spread := serviceSpread(db, service.ID)
|
||||||
|
if (nodeID != "" || spread) && (service.GitRepo != "" || imageOverride != "") {
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
msg := "service is pinned to a remote node but remote nodes only run registry-pulled images (git builds and rollbacks stay local — build-on-node is not yet supported)"
|
msg := "service uses remote node placement but remote nodes only run registry-pulled images (git builds and rollbacks stay local — build-on-node is not yet supported)"
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||||
msg, failedAt, dbDeployment.ID,
|
msg, failedAt, dbDeployment.ID,
|
||||||
@@ -462,6 +463,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
MaintenanceURL: maintURL,
|
MaintenanceURL: maintURL,
|
||||||
BasicAuthUsers: basicAuthUsers,
|
BasicAuthUsers: basicAuthUsers,
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
|
Spread: spread,
|
||||||
},
|
},
|
||||||
Trigger: deployment.TriggerConfig{
|
Trigger: deployment.TriggerConfig{
|
||||||
Type: req.Trigger,
|
Type: req.Trigger,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -48,6 +49,88 @@ func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.R
|
|||||||
if !agent.Status.Valid || agent.Status.String != "online" {
|
if !agent.Status.Valid || agent.Status.String != "online" {
|
||||||
return nil, fmt.Errorf("node %s is %s — heartbeats resume scheduling when it reconnects", agent.Name, agent.Status.String)
|
return nil, fmt.Errorf("node %s is %s — heartbeats resume scheduling when it reconnects", agent.Name, agent.Status.String)
|
||||||
}
|
}
|
||||||
|
return r.reconcileOnAgents(ctx, spec, func(int) string { return spec.NodeID })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcileSpread distributes replicas deterministically across online,
|
||||||
|
// schedulable agents: replica i lands on agents[i % len(agents)], agents
|
||||||
|
// 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) {
|
||||||
|
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 {
|
||||||
|
return agents[i%len(agents)].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
func (r *agentNodeRunner) RemoteRuntimeState(ctx context.Context, serviceID string) (*deployment.RuntimeState, error) {
|
||||||
|
rows, err := r.q.ListServiceContainers(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state := &deployment.RuntimeState{Status: "stopped"}
|
||||||
|
running := 0
|
||||||
|
for _, row := range rows {
|
||||||
|
var status struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
unmarshalRaw(row.Status, &status)
|
||||||
|
if status.State == "" || status.State == "removed" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
replica := -1
|
||||||
|
if idx, err := strconv.Atoi(strings.TrimPrefix(row.Name, fmt.Sprintf("containr-%s-", serviceID))); err == nil {
|
||||||
|
replica = idx
|
||||||
|
}
|
||||||
|
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
||||||
|
ID: row.ID, Name: row.Name, State: status.State, Replica: replica,
|
||||||
|
})
|
||||||
|
if status.State == "running" {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if running > 0 {
|
||||||
|
state.Status = "running"
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileOnAgents creates the service's replicas on the agents chosen by
|
||||||
|
// targetFor and retires stale replicas on any agent — including leftovers
|
||||||
|
// from a previous pin or a different spread assignment.
|
||||||
|
func (r *agentNodeRunner) reconcileOnAgents(ctx context.Context, spec deployment.RuntimeSpec, targetFor func(replica int) string) (*deployment.RuntimeState, error) {
|
||||||
if len(spec.Domains) > 0 || spec.Domain != "" {
|
if len(spec.Domains) > 0 || spec.Domain != "" {
|
||||||
return nil, fmt.Errorf("domains are routed by the local Traefik — remove the service's domains or run it on the local node")
|
return nil, fmt.Errorf("domains are routed by the local Traefik — remove the service's domains or run it on the local node")
|
||||||
}
|
}
|
||||||
@@ -57,31 +140,68 @@ func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.R
|
|||||||
replicas = 1
|
replicas = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retire replicas above the desired count using inventory rows.
|
|
||||||
prefix := fmt.Sprintf("containr-%s-", spec.ServiceID)
|
prefix := fmt.Sprintf("containr-%s-", spec.ServiceID)
|
||||||
existing, _ := r.q.ListContainersForAgent(ctx, spec.NodeID)
|
existing, _ := r.q.ListServiceContainers(ctx, spec.ServiceID)
|
||||||
for _, row := range existing {
|
for _, row := range existing {
|
||||||
if row.ServiceID != spec.ServiceID || !strings.HasPrefix(row.Name, prefix) {
|
if !strings.HasPrefix(row.Name, prefix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
replica, err := strconv.Atoi(strings.TrimPrefix(row.Name, prefix))
|
replica, err := strconv.Atoi(strings.TrimPrefix(row.Name, prefix))
|
||||||
if err == nil && replica >= replicas {
|
keep := err == nil && replica < replicas && row.NodeAgentID == targetFor(replica)
|
||||||
if cmdErr := r.enqueueAndWait(ctx, spec.NodeID, "", "remove_container",
|
if keep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cmdErr := r.enqueueAndWait(ctx, row.NodeAgentID, "", "remove_container",
|
||||||
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
|
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
|
||||||
return nil, fmt.Errorf("remove stale replica %s: %w", row.Name, cmdErr)
|
return nil, fmt.Errorf("remove stale replica %s on node %s: %w", row.Name, row.NodeAgentID, cmdErr)
|
||||||
}
|
}
|
||||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Status: rawJSON(map[string]interface{}{"state": "removed"}),
|
Status: rawJSON(map[string]interface{}{"state": "removed"}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
state := &deployment.RuntimeState{Desired: replicas, Status: "running"}
|
state := &deployment.RuntimeState{Desired: replicas, Status: "running"}
|
||||||
for i := 0; i < replicas; i++ {
|
for i := 0; i < replicas; i++ {
|
||||||
|
agentID := targetFor(i)
|
||||||
name := remoteContainerName(spec.ServiceID, i)
|
name := remoteContainerName(spec.ServiceID, i)
|
||||||
containerID := remoteContainerID(spec.NodeID, spec.ServiceID, i)
|
containerID := remoteContainerID(agentID, spec.ServiceID, i)
|
||||||
|
container, env, ports, volumes, restart := r.replicaPayload(spec, agentID, i, name)
|
||||||
|
|
||||||
|
_ = r.q.UpsertServiceContainer(ctx, sqlcdb.UpsertServiceContainerParams{
|
||||||
|
ID: containerID,
|
||||||
|
Name: name,
|
||||||
|
Image: spec.Image,
|
||||||
|
ProjectID: spec.ProjectID,
|
||||||
|
ServiceID: spec.ServiceID,
|
||||||
|
NodeAgentID: agentID,
|
||||||
|
Status: rawJSON(map[string]interface{}{"state": "created", "health": "none"}),
|
||||||
|
Resources: rawJSON(map[string]interface{}{"memory": spec.MemoryBytes, "cpus": float64(spec.NanoCPUs) / 1e9}),
|
||||||
|
Ports: rawJSON(ports),
|
||||||
|
Environment: rawJSON(env),
|
||||||
|
Volumes: rawJSON(volumes),
|
||||||
|
Networks: rawJSON([]string{}),
|
||||||
|
RestartPolicy: rawJSON(map[string]interface{}{"name": restart}),
|
||||||
|
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := r.enqueueAndWait(ctx, agentID, containerID, "create_container",
|
||||||
|
map[string]interface{}{"container": container}); err != nil {
|
||||||
|
return nil, fmt.Errorf("replica %d on node %s: %w", i, agentID, err)
|
||||||
|
}
|
||||||
|
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||||
|
ID: containerID,
|
||||||
|
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
||||||
|
})
|
||||||
|
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
||||||
|
ID: containerID, Name: name, State: "running", Replica: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replicaPayload builds the agent create_container payload for one replica.
|
||||||
|
func (r *agentNodeRunner) replicaPayload(spec deployment.RuntimeSpec, agentID string, i int, name string) (map[string]interface{}, map[string]interface{}, []interface{}, []interface{}, string) {
|
||||||
ports := []interface{}{}
|
ports := []interface{}{}
|
||||||
if spec.Port > 0 {
|
if spec.Port > 0 {
|
||||||
hostPort := spec.PublishedPort // 0 → ephemeral on the node
|
hostPort := spec.PublishedPort // 0 → ephemeral on the node
|
||||||
@@ -113,7 +233,7 @@ func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.R
|
|||||||
for _, arg := range spec.Command {
|
for _, arg := range spec.Command {
|
||||||
cmd = append(cmd, arg)
|
cmd = append(cmd, arg)
|
||||||
}
|
}
|
||||||
container := map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"name": name,
|
"name": name,
|
||||||
"image": spec.Image,
|
"image": spec.Image,
|
||||||
"command": cmd,
|
"command": cmd,
|
||||||
@@ -129,40 +249,9 @@ func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.R
|
|||||||
"containr.project": spec.ProjectID,
|
"containr.project": spec.ProjectID,
|
||||||
"containr.service": spec.ServiceID,
|
"containr.service": spec.ServiceID,
|
||||||
"containr.replica": fmt.Sprint(i),
|
"containr.replica": fmt.Sprint(i),
|
||||||
"containr.node": spec.NodeID,
|
"containr.node": agentID,
|
||||||
},
|
},
|
||||||
}
|
}, env, ports, volumes, restart
|
||||||
|
|
||||||
_ = r.q.UpsertServiceContainer(ctx, sqlcdb.UpsertServiceContainerParams{
|
|
||||||
ID: containerID,
|
|
||||||
Name: name,
|
|
||||||
Image: spec.Image,
|
|
||||||
ProjectID: spec.ProjectID,
|
|
||||||
ServiceID: spec.ServiceID,
|
|
||||||
NodeAgentID: spec.NodeID,
|
|
||||||
Status: rawJSON(map[string]interface{}{"state": "created", "health": "none"}),
|
|
||||||
Resources: rawJSON(map[string]interface{}{"memory": spec.MemoryBytes, "cpus": float64(spec.NanoCPUs) / 1e9}),
|
|
||||||
Ports: rawJSON(ports),
|
|
||||||
Environment: rawJSON(env),
|
|
||||||
Volumes: rawJSON(volumes),
|
|
||||||
Networks: rawJSON([]string{}),
|
|
||||||
RestartPolicy: rawJSON(map[string]interface{}{"name": restart}),
|
|
||||||
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := r.enqueueAndWait(ctx, spec.NodeID, containerID, "create_container",
|
|
||||||
map[string]interface{}{"container": container}); err != nil {
|
|
||||||
return nil, fmt.Errorf("replica %d on %s: %w", i, agent.Name, err)
|
|
||||||
}
|
|
||||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
|
||||||
ID: containerID,
|
|
||||||
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
|
||||||
})
|
|
||||||
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
|
||||||
ID: containerID, Name: name, State: "running", Replica: i,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return state, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pullCredentials resolves the project owner's registry auth for the image
|
// pullCredentials resolves the project owner's registry auth for the image
|
||||||
@@ -279,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
|
||||||
|
|||||||
@@ -18,13 +18,37 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// serviceLifecycle routes a start/stop/restart action to the node agent when
|
// remoteAgentsForService lists every agent holding inventory rows for the
|
||||||
// the service is pinned, or to the local Docker engine otherwise. Remote
|
// service — covers pinned placements, spread replicas, and leftovers from a
|
||||||
// actions block on the agent's poll loop; that matches the synchronous
|
// since-cleared pin.
|
||||||
// behavior the local path already has.
|
func remoteAgentsForService(ctx context.Context, db *database.DB, serviceID uuid.UUID) []string {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT DISTINCT node_agent_id FROM container_instances WHERE service_id = $1`,
|
||||||
|
serviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if rows.Scan(&id) == nil && id != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceLifecycle routes a start/stop/restart action to every agent holding
|
||||||
|
// the service's replicas, then to the local Docker engine. Both run — a
|
||||||
|
// service can't be pinned and local at once, but stale containers from a
|
||||||
|
// cleared pin shouldn't survive a stop.
|
||||||
func serviceLifecycle(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID, action string) error {
|
func serviceLifecycle(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID, action string) error {
|
||||||
if nodeID := serviceNodeID(db, serviceID); nodeID != "" {
|
for _, agentID := range remoteAgentsForService(ctx, db, serviceID) {
|
||||||
return engine.ControlServiceOnNode(ctx, serviceID.String(), nodeID, action)
|
if err := engine.ControlServiceOnNode(ctx, serviceID.String(), agentID, action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
case "start":
|
case "start":
|
||||||
@@ -40,12 +64,11 @@ func serviceLifecycle(ctx context.Context, db *database.DB, engine *deployment.D
|
|||||||
return fmt.Errorf("unsupported lifecycle action %q", action)
|
return fmt.Errorf("unsupported lifecycle action %q", action)
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeServiceRuntime tears down replicas wherever they run — remote node
|
// removeServiceRuntime tears down replicas wherever they run — remote nodes
|
||||||
// when pinned, local Docker otherwise.
|
// (pinned or spread) and the local Docker host.
|
||||||
func removeServiceRuntime(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID) {
|
func removeServiceRuntime(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID) {
|
||||||
if nodeID := serviceNodeID(db, serviceID); nodeID != "" {
|
for _, agentID := range remoteAgentsForService(ctx, db, serviceID) {
|
||||||
_ = engine.RemoveServiceContainersOnNode(ctx, serviceID.String(), nodeID)
|
_ = engine.RemoveServiceContainersOnNode(ctx, serviceID.String(), agentID)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
_ = engine.RemoveServiceContainers(ctx, serviceID.String())
|
_ = engine.RemoveServiceContainers(ctx, serviceID.String())
|
||||||
}
|
}
|
||||||
@@ -70,6 +93,8 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
|||||||
HealthPath: service.HealthCheckPath,
|
HealthPath: service.HealthCheckPath,
|
||||||
RestartPolicy: service.RestartPolicy,
|
RestartPolicy: service.RestartPolicy,
|
||||||
NodeID: serviceNodeID(db, service.ID),
|
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
|
// 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
|
||||||
@@ -374,6 +399,7 @@ func handleGetServiceRuntime(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mergeRemoteRuntime(c.Request.Context(), engine, service.ID.String(), state)
|
||||||
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath)
|
||||||
persistPublishedPort(db, service.ID, state.Ports)
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
if host := requestHostname(c); host != "" {
|
if host := requestHostname(c); host != "" {
|
||||||
@@ -564,6 +590,37 @@ func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeRemoteRuntime folds inventory-backed remote replica state into a
|
||||||
|
// local runtime view so pinned and spread services report their containers.
|
||||||
|
// Remote rows carry deterministic UUIDs, local ones Docker IDs — no overlap.
|
||||||
|
func mergeRemoteRuntime(ctx context.Context, engine *deployment.DeploymentEngine, serviceID string, state *deployment.RuntimeState) {
|
||||||
|
remote, err := engine.RemoteRuntimeState(ctx, serviceID)
|
||||||
|
if err != nil || remote == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running := 0
|
||||||
|
for _, c := range state.Containers {
|
||||||
|
if c.State == "running" {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rc := range remote.Containers {
|
||||||
|
state.Containers = append(state.Containers, rc)
|
||||||
|
if rc.State == "running" {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(state.Containers) == 0:
|
||||||
|
case running == 0:
|
||||||
|
state.Status = "stopped"
|
||||||
|
case running < state.Desired:
|
||||||
|
state.Status = "degraded"
|
||||||
|
default:
|
||||||
|
state.Status = "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// persistPublishedPort records the host port Docker actually bound so later
|
// persistPublishedPort records the host port Docker actually bound so later
|
||||||
// redeploys can reuse it. Idempotent — only writes when the value changed.
|
// redeploys can reuse it. Idempotent — only writes when the value changed.
|
||||||
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
|
func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) {
|
||||||
@@ -587,7 +644,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
|||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
||||||
if err != nil || state == nil || len(state.Containers) == 0 {
|
if err != nil || state == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeRemoteRuntime(ctx, engine, service.ID.String(), state)
|
||||||
|
if len(state.Containers) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if state.Status != service.Status {
|
if state.Status != service.Status {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ type Service struct {
|
|||||||
// resolved "auto" picks the least-loaded online agent at write time).
|
// resolved "auto" picks the least-loaded online agent at write time).
|
||||||
NodeID string `json:"node_id,omitempty" db:"-"`
|
NodeID string `json:"node_id,omitempty" db:"-"`
|
||||||
NodeName string `json:"node_name,omitempty" db:"-"`
|
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:"-"`
|
||||||
|
// 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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -79,10 +85,60 @@ func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadServiceNode fills the pin + the agent's display name for responses.
|
// serviceSpread reads the spread column with the same lazy pattern.
|
||||||
|
func serviceSpread(db *database.DB, serviceID uuid.UUID) bool {
|
||||||
|
var spread bool
|
||||||
|
if err := db.QueryRow(`SELECT COALESCE(spread, false) FROM services WHERE id = $1`, serviceID).Scan(&spread); err == nil {
|
||||||
|
return spread
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
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.PlacementTags = servicePlacementTags(db, s.ID)
|
||||||
if s.NodeID == "" {
|
if s.NodeID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,6 +227,12 @@ type CreateServiceRequest struct {
|
|||||||
// NodeID pins placement to a node agent; "auto" picks the least-loaded
|
// NodeID pins placement to a node agent; "auto" picks the least-loaded
|
||||||
// online agent, "local"/"" runs on the Containr host.
|
// online agent, "local"/"" runs on the Containr host.
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
|
// 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
|
// UpdateServiceRequest represents a request to update a service
|
||||||
@@ -202,6 +264,13 @@ type UpdateServiceRequest struct {
|
|||||||
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
||||||
// NodeID pins placement; pointer so "local"/"" explicitly clears the pin.
|
// NodeID pins placement; pointer so "local"/"" explicitly clears the pin.
|
||||||
NodeID *string `json:"node_id"`
|
NodeID *string `json:"node_id"`
|
||||||
|
// 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
|
// handleGetServices retrieves all services for a project
|
||||||
@@ -442,14 +511,28 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
sourceType := inferServiceSourceType(service)
|
sourceType := inferServiceSourceType(service)
|
||||||
|
|
||||||
|
if req.Spread != nil {
|
||||||
|
service.Spread = *req.Spread
|
||||||
|
}
|
||||||
|
service.PlacementTags = normalizePlacementTags(req.PlacementTags)
|
||||||
if req.NodeID != "" {
|
if req.NodeID != "" {
|
||||||
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID)
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
if !service.Spread {
|
||||||
service.NodeID = nodeID
|
service.NodeID = nodeID
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Insert service into database
|
// Insert service into database
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
@@ -459,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, 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)`,
|
$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,
|
||||||
@@ -470,6 +553,8 @@ func handleCreateService(c *gin.Context) {
|
|||||||
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
||||||
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,
|
||||||
|
tagsJSON(service.PlacementTags),
|
||||||
service.CreatedAt, service.UpdatedAt,
|
service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -772,8 +857,27 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
volumesArg = volumesJSON
|
volumesArg = volumesJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Spread != nil {
|
||||||
|
existingService.Spread = *req.Spread
|
||||||
|
} 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 {
|
if req.NodeID != nil {
|
||||||
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), *req.NodeID)
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -782,6 +886,14 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
existingService.NodeID = serviceNodeID(db.(*database.DB), serviceID)
|
existingService.NodeID = serviceNodeID(db.(*database.DB), serviceID)
|
||||||
}
|
}
|
||||||
|
if existingService.Spread && existingService.NodeID != "" {
|
||||||
|
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()
|
existingService.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -794,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
|
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,
|
||||||
@@ -806,6 +919,8 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||||
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,
|
||||||
|
tagsJSON(existingService.PlacementTags),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -190,6 +201,24 @@ var nodesDrainCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nodesUpgradeCmd = &cobra.Command{
|
||||||
|
Use: "upgrade <id>",
|
||||||
|
Short: "Upgrade the node agent to the server's bundled binary",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/agents/"+args[0]+"/upgrade", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var nodesCommandsCmd = &cobra.Command{
|
var nodesCommandsCmd = &cobra.Command{
|
||||||
Use: "commands <id>",
|
Use: "commands <id>",
|
||||||
Short: "List recent commands sent to a node agent",
|
Short: "List recent commands sent to a node agent",
|
||||||
@@ -274,8 +303,9 @@ 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)
|
||||||
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesCordonCmd, nodesUncordonCmd, nodesDrainCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
|
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesCordonCmd, nodesUncordonCmd, nodesDrainCmd, nodesUpgradeCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,18 @@ var servicesCreateCmd = &cobra.Command{
|
|||||||
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
if v, _ := cmd.Flags().GetInt("replicas"); v > 0 {
|
||||||
body["replicas"] = v
|
body["replicas"] = v
|
||||||
}
|
}
|
||||||
|
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)
|
data, err := c.Do("POST", "/projects/"+args[0]+"/services", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -203,6 +215,20 @@ var servicesUpdateCmd = &cobra.Command{
|
|||||||
v, _ := cmd.Flags().GetBool("sleep")
|
v, _ := cmd.Flags().GetBool("sleep")
|
||||||
body["sleep_enabled"] = v
|
body["sleep_enabled"] = v
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("spread") {
|
||||||
|
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") {
|
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
|
||||||
@@ -495,6 +521,8 @@ func init() {
|
|||||||
f.String("static-cmd", "", "build command for the static builder")
|
f.String("static-cmd", "", "build command for the static builder")
|
||||||
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.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")
|
||||||
@@ -520,6 +548,8 @@ func init() {
|
|||||||
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
|
uf.Bool("sleep", false, "enable scale-to-zero on idle (sleep mode)")
|
||||||
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.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
|
||||||
}
|
}
|
||||||
@@ -691,6 +695,116 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listSchedulableAgents = `-- name: ListSchedulableAgents :many
|
||||||
|
SELECT id, name FROM node_agents
|
||||||
|
WHERE status = 'online' AND schedulable
|
||||||
|
ORDER BY id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListSchedulableAgentsRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listSchedulableAgents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListSchedulableAgentsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListSchedulableAgentsRow
|
||||||
|
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 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
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListServiceContainersRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
NodeAgentID string `json:"node_agent_id"`
|
||||||
|
Status pqtype.NullRawMessage `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListServiceContainers(ctx context.Context, serviceID string) ([]ListServiceContainersRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listServiceContainers, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListServiceContainersRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListServiceContainersRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.NodeAgentID,
|
||||||
|
&i.Status,
|
||||||
|
); 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 pickLeastLoadedAgent = `-- name: PickLeastLoadedAgent :one
|
const pickLeastLoadedAgent = `-- name: PickLeastLoadedAgent :one
|
||||||
SELECT a.id FROM node_agents a
|
SELECT a.id FROM node_agents a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -699,10 +813,20 @@ LEFT JOIN LATERAL (
|
|||||||
ORDER BY h.timestamp DESC LIMIT 1
|
ORDER BY h.timestamp DESC LIMIT 1
|
||||||
) h ON true
|
) h ON true
|
||||||
WHERE a.status = 'online' AND a.schedulable
|
WHERE a.status = 'online' AND a.schedulable
|
||||||
ORDER BY COALESCE(h.container_count, 0) ASC, a.created_at ASC
|
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
|
LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Resource-aware: lowest memory utilisation first, then cpu, then container
|
||||||
|
// count. Agents without telemetry sort last (NULL → worst score).
|
||||||
func (q *Queries) PickLeastLoadedAgent(ctx context.Context) (string, error) {
|
func (q *Queries) PickLeastLoadedAgent(ctx context.Context) (string, error) {
|
||||||
row := q.db.QueryRowContext(ctx, pickLeastLoadedAgent)
|
row := q.db.QueryRowContext(ctx, pickLeastLoadedAgent)
|
||||||
var id string
|
var id string
|
||||||
@@ -710,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
|
||||||
`
|
`
|
||||||
@@ -738,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,
|
||||||
@@ -754,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 {
|
||||||
@@ -806,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 {
|
||||||
@@ -276,6 +277,8 @@ type Service struct {
|
|||||||
StaticBuildCmd string `json:"static_build_cmd"`
|
StaticBuildCmd string `json:"static_build_cmd"`
|
||||||
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"`
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
@@ -94,6 +95,10 @@ type Querier interface {
|
|||||||
// display, when the caller owns or is a member of it, or when the caller is a
|
// display, when the caller owns or is a member of it, or when the caller is a
|
||||||
// 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)
|
||||||
|
// 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)
|
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)
|
||||||
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
ListUserInvites(ctx context.Context) ([]UserInvite, error)
|
||||||
@@ -102,11 +107,17 @@ type Querier interface {
|
|||||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
|
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
|
||||||
|
// 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)
|
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
|
||||||
|
|||||||
@@ -17,11 +17,21 @@ type NodeRunner interface {
|
|||||||
// ReconcileOnNode brings the node's containers in line with the spec and
|
// ReconcileOnNode brings the node's containers in line with the spec and
|
||||||
// returns the resulting runtime state.
|
// returns the resulting runtime state.
|
||||||
ReconcileOnNode(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
|
ReconcileOnNode(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
|
||||||
|
// 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")
|
// 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
|
||||||
// RemoveService tears down every replica of the service on the agent.
|
// RemoveService tears down every replica of the service on the agent.
|
||||||
RemoveService(ctx context.Context, serviceID, agentID string) error
|
RemoveService(ctx context.Context, serviceID, agentID string) error
|
||||||
|
// RemoteRuntimeState reads the service's remote replicas from inventory
|
||||||
|
// across all agents. nil/empty means nothing runs remotely.
|
||||||
|
RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeploymentEngine struct {
|
type DeploymentEngine struct {
|
||||||
@@ -85,6 +95,9 @@ type ServiceConfig struct {
|
|||||||
// host. Remote placement requires a registry-pullable image — builds stay
|
// host. Remote placement requires a registry-pullable image — builds stay
|
||||||
// on the local host (build-on-node is not yet supported).
|
// on the local host (build-on-node is not yet supported).
|
||||||
NodeID string `json:"node_id,omitempty"`
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
// Spread distributes replicas across every online, schedulable agent.
|
||||||
|
// Same registry-image constraint as NodeID.
|
||||||
|
Spread bool `json:"spread,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PortMapping struct {
|
type PortMapping struct {
|
||||||
@@ -376,6 +389,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
|
|||||||
MaintenanceURL: cfg.MaintenanceURL,
|
MaintenanceURL: cfg.MaintenanceURL,
|
||||||
BasicAuthUsers: cfg.BasicAuthUsers,
|
BasicAuthUsers: cfg.BasicAuthUsers,
|
||||||
NodeID: cfg.NodeID,
|
NodeID: cfg.NodeID,
|
||||||
|
Spread: cfg.Spread,
|
||||||
}
|
}
|
||||||
for _, pm := range cfg.PortMappings {
|
for _, pm := range cfg.PortMappings {
|
||||||
if spec.Port == 0 {
|
if spec.Port == 0 {
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ type RuntimeSpec struct {
|
|||||||
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
|
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
|
||||||
// NodeID pins the service to a node agent; "" runs on the local host.
|
// NodeID pins the service to a node agent; "" runs on the local host.
|
||||||
NodeID string
|
NodeID string
|
||||||
|
// 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.
|
// RuntimeContainer describes one live replica.
|
||||||
@@ -156,12 +162,26 @@ func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID
|
|||||||
// otherwise only the replica delta is applied. A pinned NodeID routes the
|
// otherwise only the replica delta is applied. A pinned NodeID routes the
|
||||||
// whole reconcile to that agent's command queue instead of local Docker.
|
// whole reconcile to that agent's command queue instead of local Docker.
|
||||||
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
|
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
|
||||||
|
if spec.Spread && spec.NodeID == "" {
|
||||||
|
if de.nodeRunner == nil {
|
||||||
|
return nil, fmt.Errorf("service has spread placement but remote dispatch is unavailable")
|
||||||
|
}
|
||||||
|
return de.nodeRunner.ReconcileSpread(ctx, spec)
|
||||||
|
}
|
||||||
if spec.NodeID != "" {
|
if spec.NodeID != "" {
|
||||||
if de.nodeRunner == nil {
|
if de.nodeRunner == nil {
|
||||||
return nil, fmt.Errorf("service is pinned to node %s but remote dispatch is unavailable", spec.NodeID)
|
return nil, fmt.Errorf("service is pinned to node %s but remote dispatch is unavailable", spec.NodeID)
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -526,6 +546,15 @@ func (de *DeploymentEngine) RemoveServiceContainersOnNode(ctx context.Context, s
|
|||||||
return de.nodeRunner.RemoveService(ctx, serviceID, nodeID)
|
return de.nodeRunner.RemoveService(ctx, serviceID, nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoteRuntimeState exposes inventory-backed remote container state; nil
|
||||||
|
// runner means no node support was wired at boot.
|
||||||
|
func (de *DeploymentEngine) RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error) {
|
||||||
|
if de.nodeRunner == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return de.nodeRunner.RemoteRuntimeState(ctx, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveProjectContainers deletes every managed container in a project.
|
// RemoveProjectContainers deletes every managed container in a project.
|
||||||
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
||||||
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||||
|
|||||||
@@ -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).", "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).", "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,11 +173,12 @@ 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})
|
||||||
register(spec{"containr_nodes_prune", "Enqueue a bounded docker system prune on a node (admin). Args: id; optional until (e.g. 168h), volumes (danger, off by default).", "POST", "/agents/{id}/prune", []string{"id"}, []string{"until", "volumes"}, true})
|
register(spec{"containr_nodes_prune", "Enqueue a bounded docker system prune on a node (admin). Args: id; optional until (e.g. 168h), volumes (danger, off by default).", "POST", "/agents/{id}/prune", []string{"id"}, []string{"until", "volumes"}, true})
|
||||||
|
register(spec{"containr_nodes_upgrade", "Upgrade a node's agent to the server's bundled binary: downloads over the API, verifies sha256, swaps atomically, re-execs. Node must be online (admin). Args: id.", "POST", "/agents/{id}/upgrade", []string{"id"}, nil, true})
|
||||||
register(spec{"containr_nodes_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
|
register(spec{"containr_nodes_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
|
||||||
|
|
||||||
// Scaling / HA / security
|
// Scaling / HA / security
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS spread BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE services DROP COLUMN IF EXISTS spread;
|
||||||
@@ -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;
|
||||||
@@ -116,6 +116,8 @@ WHERE node_agent_id = $1 AND type = $2
|
|||||||
ORDER BY created_at DESC LIMIT 1;
|
ORDER BY created_at DESC LIMIT 1;
|
||||||
|
|
||||||
-- name: PickLeastLoadedAgent :one
|
-- name: PickLeastLoadedAgent :one
|
||||||
|
-- Resource-aware: lowest memory utilisation first, then cpu, then container
|
||||||
|
-- count. Agents without telemetry sort last (NULL → worst score).
|
||||||
SELECT a.id FROM node_agents a
|
SELECT a.id FROM node_agents a
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT h.container_count FROM agent_heartbeats h
|
SELECT h.container_count FROM agent_heartbeats h
|
||||||
@@ -123,9 +125,58 @@ LEFT JOIN LATERAL (
|
|||||||
ORDER BY h.timestamp DESC LIMIT 1
|
ORDER BY h.timestamp DESC LIMIT 1
|
||||||
) h ON true
|
) h ON true
|
||||||
WHERE a.status = 'online' AND a.schedulable
|
WHERE a.status = 'online' AND a.schedulable
|
||||||
ORDER BY COALESCE(h.container_count, 0) ASC, a.created_at ASC
|
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;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: ListSchedulableAgents :many
|
||||||
|
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;
|
||||||
|
|
||||||
-- name: SetAgentSchedulable :exec
|
-- name: SetAgentSchedulable :exec
|
||||||
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1;
|
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ CREATE TABLE services (
|
|||||||
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
memory_reserve VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
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,
|
||||||
|
placement_tags JSONB NOT NULL DEFAULT '[]'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE registries (
|
CREATE TABLE registries (
|
||||||
@@ -195,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 (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
listAgentContainers,
|
listAgentContainers,
|
||||||
pruneAgent,
|
pruneAgent,
|
||||||
updateAgent,
|
updateAgent,
|
||||||
|
upgradeAgent,
|
||||||
type AgentMetricPoint,
|
type AgentMetricPoint,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
@@ -46,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],
|
||||||
@@ -78,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 }),
|
||||||
@@ -97,6 +99,10 @@ export function NodeDetailPage() {
|
|||||||
mutationFn: () => drainAgent(id),
|
mutationFn: () => drainAgent(id),
|
||||||
onSuccess: () => { setConfirmDrain(false); invalidate(); },
|
onSuccess: () => { setConfirmDrain(false); invalidate(); },
|
||||||
});
|
});
|
||||||
|
const upgradeMutation = useMutation({
|
||||||
|
mutationFn: () => upgradeAgent(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
const agent = agentQuery.data;
|
const agent = agentQuery.data;
|
||||||
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
|
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
|
||||||
@@ -172,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
|
||||||
@@ -215,6 +261,16 @@ export function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
drain
|
drain
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => upgradeMutation.mutate()}
|
||||||
|
disabled={upgradeMutation.isPending || !online}
|
||||||
|
title={online
|
||||||
|
? `Upgrade agent to the server's bundled binary (server ${upgradeMutation.data?.version ?? 'build'}) — the agent restarts itself`
|
||||||
|
: 'Node must be online to upgrade'}
|
||||||
|
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{upgradeMutation.isPending ? 'upgrading…' : 'upgrade agent'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmVolumes(true)}
|
onClick={() => setConfirmVolumes(true)}
|
||||||
disabled={pruneMutation.isPending}
|
disabled={pruneMutation.isPending}
|
||||||
|
|||||||
@@ -2404,10 +2404,12 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
<select
|
<select
|
||||||
value={nodeDraft ?? (service.nodeId || 'local')}
|
value={service.spread ? '__spread__' : (nodeDraft ?? (service.nodeId || 'local'))}
|
||||||
|
disabled={service.spread}
|
||||||
onChange={(e) => setNodeDraft(e.target.value)}
|
onChange={(e) => setNodeDraft(e.target.value)}
|
||||||
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)]"
|
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)] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
{service.spread && <option value="__spread__">All nodes (spread)</option>}
|
||||||
<option value="local">This server (local Docker)</option>
|
<option value="local">This server (local Docker)</option>
|
||||||
<option value="auto">Auto — least-loaded online node</option>
|
<option value="auto">Auto — least-loaded online node</option>
|
||||||
{(nodesQuery.data ?? []).map((n) => (
|
{(nodesQuery.data ?? []).map((n) => (
|
||||||
@@ -2416,6 +2418,17 @@ export function ServiceDetailPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<label className="flex items-center gap-1.5 text-[var(--text-secondary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={service.spread ?? false}
|
||||||
|
disabled={updateServiceMutation.isPending || (!!service.nodeId && !service.spread)}
|
||||||
|
title={service.nodeId && !service.spread ? 'Clear the node pin first (choose "This server")' : undefined}
|
||||||
|
onChange={(e) => updateServiceMutation.mutate({ spread: e.target.checked })}
|
||||||
|
className="accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
spread replicas across nodes
|
||||||
|
</label>
|
||||||
{nodeDraft !== null && (
|
{nodeDraft !== null && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -2439,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -8732,6 +8733,70 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/agents/{id}/upgrade": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Upgrade the node agent
|
||||||
|
* @description Enqueues a `self_upgrade` command. The agent downloads its replacement binary from this server's bundled `/agents/download` endpoint, verifies the sha256, atomically swaps the executable, and re-execs itself — the command result reports before the process swaps. The node must be online, and the server must bundle a binary for the node's architecture (`linux-amd64`/`linux-arm64`).
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Node agent ID */
|
||||||
|
id: components["parameters"]["AgentIdParam"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Upgrade command enqueued */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
command?: Record<string, never>;
|
||||||
|
platform?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Agent not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Node offline or no bundled binary for its architecture */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/agents/{id}/metrics": {
|
"/agents/{id}/metrics": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -10878,6 +10943,10 @@ export interface components {
|
|||||||
node_id?: string;
|
node_id?: string;
|
||||||
/** @description Display name of the pinned node agent (read-only) */
|
/** @description Display name of the pinned node agent (read-only) */
|
||||||
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. */
|
||||||
|
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;
|
||||||
/**
|
/**
|
||||||
@@ -10950,6 +11019,10 @@ export interface components {
|
|||||||
static_dir?: string;
|
static_dir?: string;
|
||||||
/** @description Node agent id to pin to, 'auto' = least-loaded online agent; omit/empty = local host */
|
/** @description Node agent id to pin to, 'auto' = least-loaded online agent; omit/empty = local host */
|
||||||
node_id?: string;
|
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: {
|
UpdateServiceRequest: {
|
||||||
/** @description Service name */
|
/** @description Service name */
|
||||||
@@ -11013,6 +11086,10 @@ export interface components {
|
|||||||
sleep_idle_minutes?: number;
|
sleep_idle_minutes?: number;
|
||||||
/** @description Node agent id to pin to, 'auto' = least-loaded online agent, 'local' = run on the Containr host (clears the pin) */
|
/** @description Node agent id to pin to, 'auto' = least-loaded online agent, 'local' = run on the Containr host (clears the pin) */
|
||||||
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 */
|
||||||
|
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 */
|
||||||
@@ -12132,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 */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export type ServiceEntity = {
|
|||||||
sleepIdleMinutes?: number;
|
sleepIdleMinutes?: number;
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
|
spread?: boolean;
|
||||||
|
placementTags?: string[];
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,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[]> {
|
||||||
@@ -357,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;
|
||||||
@@ -696,6 +700,8 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
|||||||
sleepIdleMinutes: service.sleep_idle_minutes,
|
sleepIdleMinutes: service.sleep_idle_minutes,
|
||||||
nodeId: service.node_id,
|
nodeId: service.node_id,
|
||||||
nodeName: service.node_name,
|
nodeName: service.node_name,
|
||||||
|
spread: service.spread,
|
||||||
|
placementTags: service.placement_tags,
|
||||||
publicUrl: service.public_url,
|
publicUrl: service.public_url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1098,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,
|
||||||
@@ -1137,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),
|
||||||
@@ -1162,6 +1169,12 @@ export async function drainAgent(id: string): Promise<DrainResult> {
|
|||||||
return requestJson<DrainResult>(`/agents/${id}/drain`, { method: 'POST' });
|
return requestJson<DrainResult>(`/agents/${id}/drain`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpgradeResult = { platform: string; version: string };
|
||||||
|
|
||||||
|
export async function upgradeAgent(id: string): Promise<UpgradeResult> {
|
||||||
|
return requestJson<UpgradeResult>(`/agents/${id}/upgrade`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAgent(id: string): Promise<NodeAgentEntity | null> {
|
export async function getAgent(id: string): Promise<NodeAgentEntity | null> {
|
||||||
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
|
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
|
||||||
return payload.agent ? normalizeAgent(payload.agent) : null;
|
return payload.agent ? normalizeAgent(payload.agent) : null;
|
||||||
|
|||||||
+69
-1
@@ -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:
|
||||||
@@ -5503,6 +5506,46 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/agents/{id}/upgrade:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Agents
|
||||||
|
summary: Upgrade the node agent
|
||||||
|
description: >-
|
||||||
|
Enqueues a `self_upgrade` command. The agent downloads its
|
||||||
|
replacement binary from this server's bundled `/agents/download`
|
||||||
|
endpoint, verifies the sha256, atomically swaps the executable, and
|
||||||
|
re-execs itself — the command result reports before the process
|
||||||
|
swaps. The node must be online, and the server must bundle a binary
|
||||||
|
for the node's architecture (`linux-amd64`/`linux-arm64`).
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/AgentIdParam'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Upgrade command enqueued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
command: { type: object }
|
||||||
|
platform: { type: string }
|
||||||
|
version: { type: string }
|
||||||
|
'404':
|
||||||
|
description: Agent not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'409':
|
||||||
|
description: Node offline or no bundled binary for its architecture
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/agents/{id}/metrics:
|
/agents/{id}/metrics:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -6974,6 +7017,13 @@ components:
|
|||||||
node_name:
|
node_name:
|
||||||
type: string
|
type: string
|
||||||
description: Display name of the pinned node agent (read-only)
|
description: Display name of the pinned node agent (read-only)
|
||||||
|
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:
|
public_url:
|
||||||
type: string
|
type: string
|
||||||
description: Computed public URL (published port or domain)
|
description: Computed public URL (published port or domain)
|
||||||
@@ -7068,6 +7118,13 @@ components:
|
|||||||
node_id:
|
node_id:
|
||||||
type: string
|
type: string
|
||||||
description: Node agent id to pin to, 'auto' = least-loaded online agent; omit/empty = local host
|
description: Node agent id to pin to, 'auto' = least-loaded online agent; omit/empty = local host
|
||||||
|
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:
|
UpdateServiceRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -7157,6 +7214,13 @@ components:
|
|||||||
node_id:
|
node_id:
|
||||||
type: string
|
type: string
|
||||||
description: Node agent id to pin to, 'auto' = least-loaded online agent, 'local' = run on the Containr host (clears the pin)
|
description: Node agent id to pin to, 'auto' = least-loaded online agent, 'local' = run on the Containr host (clears the pin)
|
||||||
|
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:
|
ServiceDomain:
|
||||||
type: object
|
type: object
|
||||||
@@ -9042,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
|
||||||
|
|||||||
@@ -143,6 +143,24 @@ containr services update <id> --node auto # least-loaded online node
|
|||||||
containr services update <id> --node local # move back to this server
|
containr services update <id> --node local # move back to this server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`auto` scores nodes by real headroom — lowest memory utilisation, then
|
||||||
|
cpu, then container count. Or spread replicas across every online,
|
||||||
|
schedulable node (mutually exclusive with a pin):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
containr services update <id> --replicas 4 --spread # replica i → node[i % n]
|
||||||
|
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
|
||||||
@@ -154,8 +172,15 @@ Take a node out of rotation or empty it entirely (admin):
|
|||||||
containr nodes cordon <id> # no new pins; running replicas stay
|
containr nodes cordon <id> # no new pins; running replicas stay
|
||||||
containr nodes uncordon <id>
|
containr nodes uncordon <id>
|
||||||
containr nodes drain <id> # cordon + remove its containers + unpin services
|
containr nodes drain <id> # cordon + remove its containers + unpin services
|
||||||
|
containr nodes upgrade <id> # agent pulls its replacement from the server, re-execs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`nodes upgrade` swaps the agent binary for the one bundled in the running
|
||||||
|
server image — the agent downloads it over the API, verifies the sha256 the
|
||||||
|
server computed, sanity-checks it, atomically replaces its own executable,
|
||||||
|
and re-execs. The node must be online and the server must ship a binary for
|
||||||
|
its architecture (`linux-amd64`/`linux-arm64`).
|
||||||
|
|
||||||
## Service domains and access
|
## Service domains and access
|
||||||
|
|
||||||
A service can carry multiple hostnames; the default is mirrored to
|
A service can carry multiple hostnames; the default is mirrored to
|
||||||
|
|||||||
Reference in new issue
Block a user