mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(nodes): SSH bootstrap + housekeeping — install script, bundled binaries, auto-prune
- `containr nodes add <user@host>` 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>
This commit is contained in:
1 parent
7547375cc2
commit
4848d1f6f0
21 files changed
+884
-32
No files matched your search
@@ -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)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Containr node agent installer.
|
||||
# curl -fsSL <api>/api/agents/install.sh | bash -s -- --url <api> --token <enroll-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" <<UNIT
|
||||
[Unit]
|
||||
Description=Containr node agent
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Environment=CONTAINR_API_URL=$API_URL
|
||||
Environment=CONTAINR_AGENT_AUTH_TOKEN=$TOKEN
|
||||
Environment=CONTAINR_AGENT_NAME=${AGENT_NAME:-$(hostname)}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
ExecStart=$BIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now containr-agent
|
||||
info "agent installed and started — it should appear in Containr within ~15s"
|
||||
@@ -7,6 +7,7 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -61,26 +62,28 @@ func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
|
||||
}()
|
||||
}
|
||||
|
||||
type agentHealthRow struct {
|
||||
id string
|
||||
name string
|
||||
status string
|
||||
stale bool
|
||||
autoPrune bool
|
||||
}
|
||||
|
||||
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||
cutoff := time.Now().Add(-staleAfter)
|
||||
type agentRow struct {
|
||||
id string
|
||||
name string
|
||||
status string
|
||||
stale bool
|
||||
}
|
||||
var agents []agentRow
|
||||
var agents []agentHealthRow
|
||||
rows, err := db.Query(
|
||||
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
||||
COALESCE(last_heartbeat < $1, TRUE)
|
||||
COALESCE(last_heartbeat < $1, TRUE), auto_prune
|
||||
FROM node_agents`, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("containr: agent health sweep: %v", err)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var a agentRow
|
||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale); err == nil {
|
||||
var a agentHealthRow
|
||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
|
||||
agents = append(agents, a)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +107,32 @@ func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||
fmt.Sprintf("Agent %s resumed heartbeating.", a.name), "agent", a.id)
|
||||
}
|
||||
}
|
||||
|
||||
scheduleAutoPrune(db, agents)
|
||||
}
|
||||
|
||||
// scheduleAutoPrune enqueues a `prune` command once per day for agents
|
||||
// with auto_prune enabled. Disk pressure is the most common small-VPS
|
||||
// failure — this is the dflow setServerAutoCleanup analog.
|
||||
func scheduleAutoPrune(db *database.DB, agents []agentHealthRow) {
|
||||
q := sqlcdb.New(db.DB)
|
||||
for _, a := range agents {
|
||||
if !a.autoPrune || a.stale {
|
||||
continue
|
||||
}
|
||||
last, err := q.GetLastAgentCommandByType(context.Background(),
|
||||
sqlcdb.GetLastAgentCommandByTypeParams{NodeAgentID: a.id, Type: "prune"})
|
||||
if err == nil && last.CreatedAt.Valid && time.Since(last.CreatedAt.Time) < 24*time.Hour {
|
||||
continue
|
||||
}
|
||||
if _, err := q.CreateCommand(context.Background(), sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: "prune",
|
||||
NodeAgentID: a.id,
|
||||
}); err != nil {
|
||||
log.Printf("containr: auto-prune enqueue failed for agent %s: %v", a.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartUpgradeCheck polls GitHub for the latest release once a day and
|
||||
|
||||
Reference in new issue
Block a user