From 4848d1f6f070affb2092655340076f9d4728e820 Mon Sep 17 00:00:00 2001
From: Tomas Dvorak
Date: Fri, 9 Oct 2026 12:15:36 +0200
Subject: [PATCH] =?UTF-8?q?feat(nodes):=20SSH=20bootstrap=20+=20housekeepi?=
=?UTF-8?q?ng=20=E2=80=94=20install=20script,=20bundled=20binaries,=20auto?=
=?UTF-8?q?-prune?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- `containr nodes add ` mints an enroll token, SSHes once,
runs the install script remotely; --print emits the one-liner
- Public /api/agents/install.sh + /api/agents/download/{platform};
agent binaries built into the image under /agents
- Agent-side `prune` (bounded docker system prune, volumes opt-in)
and `system_df` commands
- node_agents.auto_prune + health sweep enqueues daily prune;
offline/online transitions notify admins
- CLI nodes update/prune/commands + MCP tools (prune confirm-gated)
- Usage page: install one-liner, per-node Prune + auto toggle
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
ROADMAP2.md | 23 +-
app/backend/Dockerfile | 5 +
app/backend/cmd/agent/main.go | 18 ++
app/backend/internal/api/agents.go | 70 ++++++
.../internal/api/assets/install-agent.sh | 82 +++++++
.../internal/api/notification_producers.go | 49 +++-
app/backend/internal/cli/commands/nodes.go | 76 ++++++-
.../internal/cli/commands/nodes_add.go | 214 ++++++++++++++++++
.../internal/database/sqlcdb/agents.sql.go | 48 +++-
.../internal/database/sqlcdb/models.go | 1 +
.../internal/database/sqlcdb/querier.go | 1 +
app/backend/internal/mcpserver/server.go | 3 +
.../20261107000000_agent_housekeeping.sql | 5 +
app/backend/sqlc/queries/agents.sql | 6 +
app/backend/sqlc/schema.sql | 3 +-
app/frontend/src/features/secondary/pages.tsx | 48 +++-
app/frontend/src/generated/api-types.ts | 153 +++++++++++++
app/frontend/src/generated/docs-snapshot.ts | 2 +-
app/frontend/src/lib/api-client.ts | 17 ++
docs/api/openapi.yaml | 90 ++++++++
docs/guides/CLI.md | 2 +
21 files changed, 884 insertions(+), 32 deletions(-)
create mode 100644 app/backend/internal/api/assets/install-agent.sh
create mode 100644 app/backend/internal/cli/commands/nodes_add.go
create mode 100644 app/backend/migrations_goose/20261107000000_agent_housekeeping.sql
diff --git a/ROADMAP2.md b/ROADMAP2.md
index 3f46297..da51153 100644
--- a/ROADMAP2.md
+++ b/ROADMAP2.md
@@ -252,18 +252,27 @@ and wired refs, one click or one CLI call.
## 7. Phase F — Nodes & infrastructure
-- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
- UI flow: one SSH session runs agent install (binary + systemd +
- enroll token), SSH closed forever. dflow onboarding UX, agent-model
- security.
+- [x] **SSH bootstrap install** — `containr nodes add ` mints an
+ enroll token, SSHes once (agent/key/default keys), runs
+ `GET /api/agents/install.sh | bash` remotely, closes the session.
+ `--print` emits the one-liner for manual runs; the Usage page shows the
+ same command. Agent binaries ship in the image under `/agents` and are
+ served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
+ script falls back to GH releases). `cli/commands/nodes_add.go`,
+ `api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
zt0 IPs at register; backend stores + displays. Optional
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
- [ ] **Node detail page** — placed services, telemetry history, agent
version + self-upgrade, **drain/cordon**, remove.
-- [ ] **Node housekeeping** — agent `prune` command (docker system
- prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
- disk pressure is the #1 small-VPS killer).
+- [x] **Node housekeeping** — agent `prune` + `system_df` commands
+ (`docker system prune -af`, volumes excluded unless requested, optional
+ `--until`). `node_agents.auto_prune` + the health sweep enqueue a prune
+ at most once per 24h per node. Surfaces: `POST /agents/:id/prune`,
+ `nodes update --auto-prune` / `nodes prune [--volumes --until]` /
+ `nodes commands`, MCP `containr_nodes_update|prune|commands`
+ (confirm-gated), Usage-page per-node Prune button + auto toggle.
+ `cmd/agent/main.go`, `notification_producers.go:scheduleAutoPrune`.
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
`services.node_id` via agent commands (agent already runs containers).
diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile
index 8b34b91..2c28db1 100644
--- a/app/backend/Dockerfile
+++ b/app/backend/Dockerfile
@@ -13,6 +13,10 @@ ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
+# 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 && \
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /agents/containr-agent-linux-arm64 ./cmd/agent
+
# Build embedded Better Auth runtime dependencies
FROM node:22-alpine AS auth-builder
@@ -35,6 +39,7 @@ WORKDIR /app
COPY --from=go-builder /app/main ./main
COPY --from=go-builder /app/migrations_goose ./migrations_goose
+COPY --from=go-builder /agents /agents
COPY --from=auth-builder /auth ./auth
RUN chown -R appuser:appgroup /app
diff --git a/app/backend/cmd/agent/main.go b/app/backend/cmd/agent/main.go
index 9d88336..a9fa547 100644
--- a/app/backend/cmd/agent/main.go
+++ b/app/backend/cmd/agent/main.go
@@ -246,11 +246,29 @@ func executeCommand(item command) (string, error) {
return runDocker("restart", commandTarget(item.Payload))
case "remove_container":
return runDocker("rm", "-f", commandTarget(item.Payload))
+ case "prune":
+ return dockerPrune(item.Payload)
+ case "system_df":
+ return runDocker("system", "df", "--format", "json")
default:
return "", fmt.Errorf("unsupported command type %q", item.Type)
}
}
+// dockerPrune runs a bounded docker system prune. Volumes are excluded
+// unless the payload explicitly opts in — a stray volume prune destroys
+// database data.
+func dockerPrune(payload map[string]interface{}) (string, error) {
+ args := []string{"system", "prune", "-af"}
+ if boolValue(payload["volumes"]) {
+ args = append(args, "--volumes")
+ }
+ if until := stringValue(payload["until"]); until != "" {
+ args = append(args, "--filter", fmt.Sprintf("until=%s", until))
+ }
+ return runDocker(args...)
+}
+
func createContainer(payload map[string]interface{}) (string, error) {
container, ok := payload["container"].(map[string]interface{})
if !ok {
diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go
index fbe58b9..76dc3c3 100644
--- a/app/backend/internal/api/agents.go
+++ b/app/backend/internal/api/agents.go
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"crypto/subtle"
"database/sql"
+ _ "embed"
"encoding/hex"
"encoding/json"
"errors"
@@ -13,6 +14,7 @@ import (
"math"
"net/http"
"os"
+ "path/filepath"
"strings"
"time"
@@ -36,6 +38,7 @@ type NodeAgent struct {
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
+ AutoPrune bool `json:"auto_prune"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"`
@@ -234,6 +237,7 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
+ AutoPrune: row.AutoPrune,
Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
@@ -489,6 +493,7 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
Resources: rawJSON(agent.Resources),
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
Metadata: rawJSON(agent.Metadata),
+ AutoPrune: agent.AutoPrune,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
@@ -516,6 +521,7 @@ func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
remarshal("capabilities", &agent.Capabilities)
remarshal("resources", &agent.Resources)
remarshal("metadata", &agent.Metadata)
+ remarshal("auto_prune", &agent.AutoPrune)
}
// DeleteAgent removes an agent
@@ -803,6 +809,33 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"container": container})
}
+// PruneAgent enqueues a bounded `docker system prune -af` command. Volumes
+// stay unless the caller explicitly opts in.
+func (h *NodeAgentHandler) PruneAgent(c *gin.Context) {
+ agentID := c.Param("id")
+ if _, err := h.q.GetAgent(c.Request.Context(), agentID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
+ return
+ }
+ var req struct {
+ Until string `json:"until"` // e.g. "168h" — only prune objects older than this
+ Volumes bool `json:"volumes"` // destructive — off by default
+ }
+ _ = c.ShouldBindJSON(&req)
+
+ command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
+ ID: uuid.New().String(),
+ Type: "prune",
+ NodeAgentID: agentID,
+ Payload: rawJSON(map[string]interface{}{"until": req.Until, "volumes": req.Volumes}),
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue prune"})
+ return
+ }
+ c.JSON(http.StatusAccepted, gin.H{"command": command})
+}
+
// ExecuteCommand executes a command on an agent
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
agentID := c.Param("id")
@@ -1214,9 +1247,45 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
agents.POST("/heartbeat", h.SendHeartbeat)
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
+
+ // Bootstrap surface — the install script and agent binaries are
+ // public; the enroll token is the actual secret.
+ agents.GET("/install.sh", h.ServeInstallScript)
+ agents.GET("/download/:platform", h.ServeAgentBinary)
}
}
+//go:embed assets/install-agent.sh
+var agentInstallScript string
+
+// ServeInstallScript returns the agent bootstrap script. The script
+// downloads the binary from this instance first, GH releases second.
+func (h *NodeAgentHandler) ServeInstallScript(c *gin.Context) {
+ c.Data(http.StatusOK, "text/x-sh", []byte(agentInstallScript))
+}
+
+// ServeAgentBinary streams a bundled agent binary. Images ship the
+// linux binaries under CONTAINR_AGENT_BIN_DIR (default /agents).
+func (h *NodeAgentHandler) ServeAgentBinary(c *gin.Context) {
+ platform := c.Param("platform")
+ if platform != "linux-amd64" && platform != "linux-arm64" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported platform"})
+ return
+ }
+ dir := os.Getenv("CONTAINR_AGENT_BIN_DIR")
+ if dir == "" {
+ dir = "/agents"
+ }
+ path := filepath.Join(dir, "containr-agent-"+platform)
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ c.JSON(http.StatusNotFound, gin.H{"error": "no bundled binary for " + platform})
+ return
+ }
+ c.Header("Content-Disposition", `attachment; filename="containr-agent"`)
+ c.File(path)
+}
+
// SetupAdminRoutes registers dashboard/control agent routes. Command
// history and container details can leak sensitive output, so these stay
// admin-only rather than joining the public read group.
@@ -1243,6 +1312,7 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
agents.GET("/:id/metrics", h.GetAgentMetrics)
+ agents.POST("/:id/prune", h.PruneAgent)
agents.POST("/:id/commands", h.ExecuteCommand)
agents.GET("/:id/commands", h.GetAgentCommands)
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
diff --git a/app/backend/internal/api/assets/install-agent.sh b/app/backend/internal/api/assets/install-agent.sh
new file mode 100644
index 0000000..30863dd
--- /dev/null
+++ b/app/backend/internal/api/assets/install-agent.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Containr node agent installer.
+# curl -fsSL /api/agents/install.sh | bash -s -- --url --token
+#
+# One-shot: downloads the agent binary from the Containr instance (or GH
+# releases as fallback), writes a systemd unit, starts it. SSH is never
+# needed again — the agent heartbeats and polls commands outbound.
+set -euo pipefail
+
+API_URL=""
+TOKEN=""
+AGENT_NAME=""
+VERSION=""
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --url) API_URL="$2"; shift 2 ;;
+ --token) TOKEN="$2"; shift 2 ;;
+ --name) AGENT_NAME="$2"; shift 2 ;;
+ --version) VERSION="$2"; shift 2 ;;
+ *) echo "unknown flag: $1" >&2; exit 2 ;;
+ esac
+done
+
+[ -n "$API_URL" ] || { echo "--url is required" >&2; exit 2; }
+[ -n "$TOKEN" ] || { echo "--token is required (create one: containr nodes tokens create)" >&2; exit 2; }
+API_URL="${API_URL%/}"
+API_URL="${API_URL%/api/v1}"
+API_URL="${API_URL%/api}"
+
+info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
+err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; }
+
+[ "$(id -u)" = "0" ] || { err "run as root (sudo bash -s -- --url … --token …)"; exit 1; }
+command -v docker >/dev/null 2>&1 || { err "docker not found"; exit 1; }
+command -v curl >/dev/null 2>&1 || { err "curl not found"; exit 1; }
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64|amd64) ARCH=amd64 ;;
+ aarch64|arm64) ARCH=arm64 ;;
+ *) err "unsupported architecture: $ARCH"; exit 1 ;;
+esac
+
+BIN=/usr/local/bin/containr-agent
+info "downloading containr-agent (linux/$ARCH)"
+if ! curl -fsSL "$API_URL/api/agents/download/linux-$ARCH" -o "$BIN" 2>/dev/null; then
+ info "instance has no bundled binary — trying GitHub releases"
+ REF="${VERSION:-latest}"
+ if [ "$REF" = "latest" ]; then
+ DL="https://github.com/Dvorinka/Containr/releases/latest/download/containr-agent-linux-$ARCH"
+ else
+ DL="https://github.com/Dvorinka/Containr/releases/download/$REF/containr-agent-linux-$ARCH"
+ fi
+ curl -fsSL "$DL" -o "$BIN" || { err "agent download failed"; exit 1; }
+fi
+chmod +x "$BIN"
+
+UNIT=/etc/systemd/system/containr-agent.service
+info "writing systemd unit"
+cat > "$UNIT" <",
+ Short: "Update a node agent (--name, --auto-prune)",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ body := map[string]interface{}{}
+ if cmd.Flags().Changed("name") {
+ body["name"], _ = cmd.Flags().GetString("name")
+ }
+ if cmd.Flags().Changed("auto-prune") {
+ body["auto_prune"], _ = cmd.Flags().GetBool("auto-prune")
+ }
+ if len(body) == 0 {
+ return &APIError{Message: "nothing to update — pass --name or --auto-prune", ExitCode: ExitError}
+ }
+ data, err := c.Do("PUT", "/agents/"+args[0], body)
+ if err != nil {
+ return err
+ }
+ PrintRaw(data)
+ return nil
+ },
+}
+
+var nodesPruneCmd = &cobra.Command{
+ Use: "prune ",
+ Short: "Enqueue a bounded docker system prune on a node",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ volumes, _ := cmd.Flags().GetBool("volumes")
+ if volumes && !Confirm("Prune INCLUDING volumes? This can delete database data.") {
+ return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
+ }
+ until, _ := cmd.Flags().GetString("until")
+ data, err := c.Do("POST", "/agents/"+args[0]+"/prune",
+ map[string]interface{}{"until": until, "volumes": volumes})
+ if err != nil {
+ return err
+ }
+ PrintRaw(data)
+ return nil
+ },
+}
+
+var nodesCommandsCmd = &cobra.Command{
+ Use: "commands ",
+ Short: "List recent commands sent to a node agent",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+ data, err := c.Do("GET", "/agents/"+args[0]+"/commands", nil)
+ if err != nil {
+ return err
+ }
+ PrintRaw(data)
+ return nil
+ },
+}
+
// Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{
Use: "tokens",
@@ -145,6 +215,10 @@ var nodeTokensRevokeCmd = &cobra.Command{
}
func init() {
+ nodesUpdateCmd.Flags().String("name", "", "node display name")
+ nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
+ nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
+ nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
- NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
+ NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
}
diff --git a/app/backend/internal/cli/commands/nodes_add.go b/app/backend/internal/cli/commands/nodes_add.go
new file mode 100644
index 0000000..971c60a
--- /dev/null
+++ b/app/backend/internal/cli/commands/nodes_add.go
@@ -0,0 +1,214 @@
+package commands
+
+// SSH node bootstrap — one SSH session installs the agent (binary +
+// systemd + enroll token), then SSH is never needed again. SSH keys
+// stay client-side; the server only sees the resulting agent.
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/agent"
+ "golang.org/x/term"
+)
+
+var (
+ nodeSSHKey string
+ nodeSSHName string
+ nodeSSHPrint bool
+)
+
+var nodesAddCmd = &cobra.Command{
+ Use: "add <[user@]host[:port]>",
+ Short: "Bootstrap a node agent over SSH (one session, then closed)",
+ Long: `Installs the containr agent on a remote host:
+
+ 1. mints a single-use enroll token via the API
+ 2. SSHes in (agent socket, --key, or default key files)
+ 3. runs the instance's install script remotely
+ 4. closes the session — the agent heartbeats outbound from then on
+
+Use --print to skip SSH and emit the remote one-liner for manual runs.`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := client()
+ if err != nil {
+ return err
+ }
+
+ // Mint the enroll token first — SSH failures then don't leave
+ // partial state on the remote.
+ body := map[string]interface{}{"label": "bootstrap"}
+ if nodeSSHName != "" {
+ body["label"] = "bootstrap:" + nodeSSHName
+ }
+ tokenData, err := c.Do("POST", "/agent-tokens", body)
+ if err != nil {
+ return err
+ }
+ tokenObj, err := unwrapObject(tokenData, "token")
+ if err != nil {
+ return err
+ }
+ token := str(tokenObj, "token")
+ if token == "" {
+ return &APIError{Message: "enroll token missing from response", ExitCode: ExitError}
+ }
+
+ // Agent bootstrap endpoints live at /api/agents (no /v1) — strip
+ // the API version suffix off the client base.
+ apiRoot := strings.TrimSuffix(c.BaseURL, "/api/v1")
+ installCmd := fmt.Sprintf(
+ `curl -fsSL %q | bash -s -- --url %q --token %q --name %q`,
+ apiRoot+"/api/agents/install.sh", apiRoot, token, firstNonEmptyStr(nodeSSHName, nodeSSHTarget(args[0]).host),
+ )
+
+ if nodeSSHPrint {
+ fmt.Println(installCmd)
+ return nil
+ }
+
+ target := nodeSSHTarget(args[0])
+ if err := sshBootstrap(target, installCmd); err != nil {
+ return err
+ }
+ fmt.Printf("agent installing on %s — it should appear in `containr nodes list` within ~15s\n", target.host)
+ return nil
+ },
+}
+
+type sshTarget struct {
+ user string
+ host string
+ port string
+}
+
+func nodeSSHTarget(raw string) sshTarget {
+ t := sshTarget{port: "22"}
+ rest := raw
+ if at := strings.LastIndex(rest, "@"); at >= 0 {
+ t.user = rest[:at]
+ rest = rest[at+1:]
+ }
+ if colon := strings.LastIndex(rest, ":"); colon >= 0 && !strings.Contains(rest, "]") {
+ t.host, t.port = rest[:colon], rest[colon+1:]
+ } else {
+ t.host = rest
+ }
+ if t.user == "" {
+ if u, err := user.Current(); err == nil {
+ t.user = u.Username
+ }
+ }
+ return t
+}
+
+func sshAuthMethods(keyPath string) []ssh.AuthMethod {
+ var methods []ssh.AuthMethod
+
+ // ssh-agent first — matches how people actually manage keys.
+ if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
+ if conn, err := net.Dial("unix", sock); err == nil {
+ defer conn.Close()
+ methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
+ }
+ }
+
+ var keyFiles []string
+ if keyPath != "" {
+ keyFiles = []string{keyPath}
+ } else if home, err := os.UserHomeDir(); err == nil {
+ for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
+ p := filepath.Join(home, ".ssh", name)
+ if _, err := os.Stat(p); err == nil {
+ keyFiles = append(keyFiles, p)
+ }
+ }
+ }
+ for _, p := range keyFiles {
+ if signer, err := sshPrivateKey(p); err == nil {
+ methods = append(methods, ssh.PublicKeys(signer))
+ }
+ }
+ return methods
+}
+
+func sshPrivateKey(path string) (ssh.Signer, error) {
+ pem, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ signer, err := ssh.ParsePrivateKey(pem)
+ if err != nil {
+ var missing *ssh.PassphraseMissingError
+ if errors.As(err, &missing) {
+ fmt.Fprintf(os.Stderr, "passphrase for %s: ", path)
+ pass, readErr := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ if readErr != nil {
+ return nil, readErr
+ }
+ return ssh.ParsePrivateKeyWithPassphrase(pem, pass)
+ }
+ return nil, err
+ }
+ return signer, nil
+}
+
+func sshBootstrap(t sshTarget, remoteCmd string) error {
+ config := &ssh.ClientConfig{
+ User: t.user,
+ Auth: sshAuthMethods(nodeSSHKey),
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(), // jarvis: TOFU would be better; ssh_known_hosts parsing is the upgrade path
+ Timeout: 15 * time.Second,
+ }
+ if len(config.Auth) == 0 {
+ return &APIError{Message: "no SSH auth methods (ssh-agent empty, no usable keys)", ExitCode: ExitError}
+ }
+
+ conn, err := ssh.Dial("tcp", net.JoinHostPort(t.host, t.port), config)
+ if err != nil {
+ return &APIError{Message: fmt.Sprintf("ssh connect: %v", err), ExitCode: ExitError}
+ }
+ defer conn.Close()
+
+ session, err := conn.NewSession()
+ if err != nil {
+ return err
+ }
+ defer session.Close()
+ session.Stdout = os.Stdout
+ session.Stderr = os.Stderr
+
+ // Root shells run directly; otherwise escalate via sudo. NOPASSWD
+ // sudo is assumed — a password prompt here means the operator should
+ // use --print and run the command themselves.
+ if t.user == "root" {
+ return session.Run(remoteCmd)
+ }
+ return session.Run("sudo -n " + remoteCmd)
+}
+
+func firstNonEmptyStr(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func init() {
+ nodesAddCmd.Flags().StringVar(&nodeSSHKey, "key", "", "SSH private key path (defaults: agent, ~/.ssh/id_*)")
+ nodesAddCmd.Flags().StringVar(&nodeSSHName, "name", "", "node display name (defaults to remote hostname)")
+ nodesAddCmd.Flags().BoolVar(&nodeSSHPrint, "print", false, "print the remote install command instead of running SSH")
+ NodesCmd.AddCommand(nodesAddCmd)
+}
diff --git a/app/backend/internal/database/sqlcdb/agents.sql.go b/app/backend/internal/database/sqlcdb/agents.sql.go
index c021683..8976f82 100644
--- a/app/backend/internal/database/sqlcdb/agents.sql.go
+++ b/app/backend/internal/database/sqlcdb/agents.sql.go
@@ -63,7 +63,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
-RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
+RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
`
type CreateAgentParams struct {
@@ -109,6 +109,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
+ &i.AutoPrune,
)
return i, err
}
@@ -229,7 +230,7 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
}
const getAgent = `-- name: GetAgent :one
-SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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 FROM node_agents WHERE id = $1
`
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -249,12 +250,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
+ &i.AutoPrune,
)
return i, err
}
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
-SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata FROM node_agents
+SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune FROM node_agents
WHERE hostname = $1 AND ip_address = $2
LIMIT 1
`
@@ -281,6 +283,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
+ &i.AutoPrune,
)
return i, err
}
@@ -378,6 +381,36 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
return i, err
}
+const getLastAgentCommandByType = `-- name: GetLastAgentCommandByType :one
+SELECT id, type, node_agent_id, container_id, payload, status, result, error, created_at, updated_at, completed_at FROM agent_commands
+WHERE node_agent_id = $1 AND type = $2
+ORDER BY created_at DESC LIMIT 1
+`
+
+type GetLastAgentCommandByTypeParams struct {
+ NodeAgentID string `json:"node_agent_id"`
+ Type string `json:"type"`
+}
+
+func (q *Queries) GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) {
+ row := q.db.QueryRowContext(ctx, getLastAgentCommandByType, arg.NodeAgentID, arg.Type)
+ var i AgentCommand
+ err := row.Scan(
+ &i.ID,
+ &i.Type,
+ &i.NodeAgentID,
+ &i.ContainerID,
+ &i.Payload,
+ &i.Status,
+ &i.Result,
+ &i.Error,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.CompletedAt,
+ )
+ return i, err
+}
+
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
INSERT INTO agent_heartbeats (
id, node_agent_id, timestamp, status, resources,
@@ -458,7 +491,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
}
const listAgents = `-- name: ListAgents :many
-SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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 FROM node_agents ORDER BY created_at ASC
`
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -484,6 +517,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
+ &i.AutoPrune,
); err != nil {
return nil, err
}
@@ -639,9 +673,10 @@ UPDATE node_agents SET
resources = $9,
last_heartbeat = $10,
metadata = $11,
+ auto_prune = $12,
updated_at = NOW()
WHERE id = $1
-RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
+RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
`
type UpdateAgentParams struct {
@@ -656,6 +691,7 @@ type UpdateAgentParams struct {
Resources pqtype.NullRawMessage `json:"resources"`
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
Metadata pqtype.NullRawMessage `json:"metadata"`
+ AutoPrune bool `json:"auto_prune"`
}
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
@@ -671,6 +707,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
arg.Resources,
arg.LastHeartbeat,
arg.Metadata,
+ arg.AutoPrune,
)
var i NodeAgent
err := row.Scan(
@@ -687,6 +724,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.CreatedAt,
&i.UpdatedAt,
&i.Metadata,
+ &i.AutoPrune,
)
return i, err
}
diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go
index d3c0c36..bed7c64 100644
--- a/app/backend/internal/database/sqlcdb/models.go
+++ b/app/backend/internal/database/sqlcdb/models.go
@@ -175,6 +175,7 @@ type NodeAgent struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Metadata pqtype.NullRawMessage `json:"metadata"`
+ AutoPrune bool `json:"auto_prune"`
}
type Notification struct {
diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go
index c8784ed..4f7bb29 100644
--- a/app/backend/internal/database/sqlcdb/querier.go
+++ b/app/backend/internal/database/sqlcdb/querier.go
@@ -58,6 +58,7 @@ type Querier interface {
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
+ GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error)
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go
index ca7aaea..16dfdf3 100644
--- a/app/backend/internal/mcpserver/server.go
+++ b/app/backend/internal/mcpserver/server.go
@@ -173,6 +173,9 @@ func init() {
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
+ register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name.", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name"}, false})
+ register(spec{"containr_nodes_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_commands", "List recent commands sent to a node agent (admin). Args: id.", "GET", "/agents/{id}/commands", []string{"id"}, nil, false})
// Scaling / HA / security
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
diff --git a/app/backend/migrations_goose/20261107000000_agent_housekeeping.sql b/app/backend/migrations_goose/20261107000000_agent_housekeeping.sql
new file mode 100644
index 0000000..813c90f
--- /dev/null
+++ b/app/backend/migrations_goose/20261107000000_agent_housekeeping.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS auto_prune BOOLEAN NOT NULL DEFAULT false;
+
+-- +goose Down
+ALTER TABLE node_agents DROP COLUMN IF EXISTS auto_prune;
diff --git a/app/backend/sqlc/queries/agents.sql b/app/backend/sqlc/queries/agents.sql
index a7b7a5b..9d5df7d 100644
--- a/app/backend/sqlc/queries/agents.sql
+++ b/app/backend/sqlc/queries/agents.sql
@@ -28,6 +28,7 @@ UPDATE node_agents SET
resources = $9,
last_heartbeat = $10,
metadata = $11,
+ auto_prune = $12,
updated_at = NOW()
WHERE id = $1
RETURNING *;
@@ -107,3 +108,8 @@ RETURNING *;
-- name: UpdateContainerStatus :exec
UPDATE container_instances SET status = $2, updated_at = NOW()
WHERE id = $1;
+
+-- name: GetLastAgentCommandByType :one
+SELECT * FROM agent_commands
+WHERE node_agent_id = $1 AND type = $2
+ORDER BY created_at DESC LIMIT 1;
diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql
index 5f3d386..237f554 100644
--- a/app/backend/sqlc/schema.sql
+++ b/app/backend/sqlc/schema.sql
@@ -192,7 +192,8 @@ CREATE TABLE node_agents (
last_heartbeat TIMESTAMP WITH TIME ZONE,
created_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
);
CREATE TABLE container_instances (
diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx
index 87b8328..ef94363 100644
--- a/app/frontend/src/features/secondary/pages.tsx
+++ b/app/frontend/src/features/secondary/pages.tsx
@@ -11,6 +11,8 @@ import {
getCurrentUserProfile,
listAuditLogs,
listAgents,
+ pruneAgent,
+ updateAgent,
listBuilds,
listGitProviders,
listProjects,
@@ -293,6 +295,14 @@ export function UsagePage() {
mutationFn: (id: string) => revokeAgentToken(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
});
+ const pruneMutation = useMutation({
+ mutationFn: (id: string) => pruneAgent(id),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
+ });
+ const autoPruneMutation = useMutation({
+ mutationFn: ({ id, on }: { id: string; on: boolean }) => updateAgent(id, { auto_prune: on }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['usage-agents'] }),
+ });
const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
@@ -377,9 +387,9 @@ export function UsagePage() {
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
const agentEndpoint = getAgentPublicBaseUrl();
- const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
-CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? ''} \\
-containr-agent`;
+ const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
+ const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? ''}`;
+ const sshCommand = `containr nodes add --ssh ${issuedToken?.token ? '' : ' # run on a host with the CLI'}`;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
@@ -606,9 +616,32 @@ containr-agent`;
{agent.name}
{agent.hostname} · {agent.ipAddress}:{agent.port}
-
-
{agent.status}
-
{formatBytes(agent.resources.memory.available)} free
+
+ {isAdmin && (
+ <>
+
+
+ >
+ )}
+
+
{agent.status}
+
{formatBytes(agent.resources.memory.available)} free
+
))
@@ -666,7 +699,8 @@ containr-agent`;
)}
- {connectCommand}
+ {installCommand}
+ {sshCommand}
Agent endpoint: {agentEndpoint}
Use an issued token above, or set CONTAINR_AGENT_AUTH_TOKENS on the backend.
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts
index dcc1031..baf70af 100644
--- a/app/frontend/src/generated/api-types.ts
+++ b/app/frontend/src/generated/api-types.ts
@@ -8363,6 +8363,157 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/agents/install.sh": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Agent install script
+ * @description Public bootstrap script — `curl | bash -s -- --url
--token `.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Bourne shell installer */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/x-sh": string;
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/agents/download/{platform}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Download a bundled agent binary
+ * @description platform: linux-amd64 | linux-arm64. Bundled in release images; 404 when absent (install script falls back to GH releases).
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ platform: "linux-amd64" | "linux-arm64";
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Agent binary */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/octet-stream": string;
+ };
+ };
+ /** @description No bundled binary for platform */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/agents/{id}/prune": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Enqueue a bounded docker prune on a node
+ * @description Queues a `prune` command — `docker system prune -af`, volumes excluded unless requested.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Node agent ID */
+ id: components["parameters"]["AgentIdParam"];
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Only prune objects older than this duration (e.g. "168h"). */
+ until?: string;
+ /** @description Also prune volumes — destructive, off by default. */
+ volumes?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description Prune command queued */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ command?: components["schemas"]["AgentCommand"];
+ };
+ };
+ };
+ /** @description Agent not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/agents/{id}/metrics": {
parameters: {
query?: never;
@@ -11749,6 +11900,8 @@ export interface components {
metadata?: {
[key: string]: unknown;
};
+ /** @description Enqueue a bounded docker prune on this node once per day. */
+ auto_prune?: boolean;
/** Format: date-time */
last_heartbeat?: string;
/** Format: date-time */
diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts
index 41498b9..ee8749a 100644
--- a/app/frontend/src/generated/docs-snapshot.ts
+++ b/app/frontend/src/generated/docs-snapshot.ts
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
- "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity enriched activity feed; --severity/--category/\n --resource/--project/--limit filters\nbanners list|create|update|delete announcement banners (admin);\n active banners print on `auth status`\ninvites list|create|revoke team invite links (admin); create\n prints the one-time acceptance URL\nadmin impersonate mint a 15-min token acting as a user\nadmin github-app status|manifest|convert\n self-provision the instance GitHub App\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n"
+ "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nactivity enriched activity feed; --severity/--category/\n --resource/--project/--limit filters\nshell interactive PTY shell inside the service\n container (bash→sh fallback; resizes follow)\nbanners list|create|update|delete announcement banners (admin);\n active banners print on `auth status`\ninvites list|create|revoke team invite links (admin); create\n prints the one-time acceptance URL\nadmin impersonate mint a 15-min token acting as a user\nadmin github-app status|manifest|convert\n self-provision the instance GitHub App\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n"
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts
index 37a7458..433de84 100644
--- a/app/frontend/src/lib/api-client.ts
+++ b/app/frontend/src/lib/api-client.ts
@@ -341,6 +341,7 @@ export type NodeAgentEntity = {
version: string;
capabilities: AgentCapabilities;
resources: NodeResources;
+ autoPrune: boolean;
lastHeartbeat?: string;
createdAt?: string;
updatedAt?: string;
@@ -1076,6 +1077,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
},
},
lastHeartbeat: agent.last_heartbeat,
+ autoPrune: agent.auto_prune ?? false,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
};
@@ -1114,6 +1116,21 @@ export async function listAgents(): Promise {
return normalizeAgentArray(payload.agents);
}
+export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise {
+ const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(body),
+ });
+ return payload.agent ? normalizeAgent(payload.agent) : null;
+}
+
+export async function pruneAgent(id: string, body?: { until?: string; volumes?: boolean }): Promise {
+ await requestJson(`/agents/${id}/prune`, {
+ method: 'POST',
+ body: JSON.stringify(body ?? {}),
+ });
+}
+
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e9db1dd..57ed2e6 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -5291,6 +5291,93 @@ paths:
schema:
$ref: '#/components/schemas/MessageResponse'
+ /api/agents/install.sh:
+ servers:
+ - url: http://localhost:8080
+ get:
+ tags:
+ - Agents
+ summary: Agent install script
+ description: Public bootstrap script — `curl | bash -s -- --url --token `.
+ security: []
+ responses:
+ '200':
+ description: Bourne shell installer
+ content:
+ text/x-sh:
+ schema:
+ type: string
+
+ /api/agents/download/{platform}:
+ servers:
+ - url: http://localhost:8080
+ get:
+ tags:
+ - Agents
+ summary: Download a bundled agent binary
+ description: "platform: linux-amd64 | linux-arm64. Bundled in release images; 404 when absent (install script falls back to GH releases)."
+ security: []
+ parameters:
+ - name: platform
+ in: path
+ required: true
+ schema:
+ type: string
+ enum: [linux-amd64, linux-arm64]
+ responses:
+ '200':
+ description: Agent binary
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ '404':
+ description: No bundled binary for platform
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /agents/{id}/prune:
+ post:
+ tags:
+ - Agents
+ summary: Enqueue a bounded docker prune on a node
+ description: Queues a `prune` command — `docker system prune -af`, volumes excluded unless requested.
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/AgentIdParam'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ until:
+ type: string
+ description: Only prune objects older than this duration (e.g. "168h").
+ volumes:
+ type: boolean
+ description: Also prune volumes — destructive, off by default.
+ responses:
+ '202':
+ description: Prune command queued
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ command:
+ $ref: '#/components/schemas/AgentCommand'
+ '404':
+ description: Agent not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
/agents/{id}/metrics:
get:
tags:
@@ -8807,6 +8894,9 @@ components:
metadata:
type: object
additionalProperties: true
+ auto_prune:
+ type: boolean
+ description: Enqueue a bounded docker prune on this node once per day.
last_heartbeat:
type: string
format: date-time
diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md
index 2fccb5e..dfd3978 100644
--- a/docs/guides/CLI.md
+++ b/docs/guides/CLI.md
@@ -72,6 +72,8 @@ import compose
attached under their service name; --var K=V overrides
template variables
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
+nodes add SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)
+nodes update|prune|commands rename/auto-prune, bounded docker prune, command history
scaling status|policies|services|scale
ha status|alerts|health|policies|enable|disable|failover
security scan|vulnerabilities|metrics