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