mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 08:04:53 +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
+878
-26
No files matched your search
+16
-7
@@ -252,18 +252,27 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 7. Phase F — Nodes & infrastructure
|
## 7. Phase F — Nodes & infrastructure
|
||||||
|
|
||||||
- [ ] **SSH bootstrap install** — `containr nodes add --ssh user@host` +
|
- [x] **SSH bootstrap install** — `containr nodes add <user@host>` mints an
|
||||||
UI flow: one SSH session runs agent install (binary + systemd +
|
enroll token, SSHes once (agent/key/default keys), runs
|
||||||
enroll token), SSH closed forever. dflow onboarding UX, agent-model
|
`GET /api/agents/install.sh | bash` remotely, closes the session.
|
||||||
security.
|
`--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/
|
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/
|
||||||
zt0 IPs at register; backend stores + displays. Optional
|
zt0 IPs at register; backend stores + displays. Optional
|
||||||
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
`TAILSCALE_AUTH_KEY` enroll-at-provision later.
|
||||||
- [ ] **Node detail page** — placed services, telemetry history, agent
|
- [ ] **Node detail page** — placed services, telemetry history, agent
|
||||||
version + self-upgrade, **drain/cordon**, remove.
|
version + self-upgrade, **drain/cordon**, remove.
|
||||||
- [ ] **Node housekeeping** — agent `prune` command (docker system
|
- [x] **Node housekeeping** — agent `prune` + `system_df` commands
|
||||||
prune, bounded) + optional schedule (`setServerAutoCleanup` analog —
|
(`docker system prune -af`, volumes excluded unless requested, optional
|
||||||
disk pressure is the #1 small-VPS killer).
|
`--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):
|
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists):
|
||||||
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
pin/spread/affinity, prefer-lowest-usage; deploy engine routes to
|
||||||
`services.node_id` via agent commands (agent already runs containers).
|
`services.node_id` via agent commands (agent already runs containers).
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ ARG VERSION=dev
|
|||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
||||||
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
|
-ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go
|
||||||
|
|
||||||
|
# Node-agent binaries served by the API for SSH/bootstrap installs.
|
||||||
|
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
|
# Build embedded Better Auth runtime dependencies
|
||||||
FROM node:22-alpine AS auth-builder
|
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/main ./main
|
||||||
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
||||||
|
COPY --from=go-builder /agents /agents
|
||||||
COPY --from=auth-builder /auth ./auth
|
COPY --from=auth-builder /auth ./auth
|
||||||
|
|
||||||
RUN chown -R appuser:appgroup /app
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|||||||
@@ -246,11 +246,29 @@ func executeCommand(item command) (string, error) {
|
|||||||
return runDocker("restart", commandTarget(item.Payload))
|
return runDocker("restart", commandTarget(item.Payload))
|
||||||
case "remove_container":
|
case "remove_container":
|
||||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||||
|
case "prune":
|
||||||
|
return dockerPrune(item.Payload)
|
||||||
|
case "system_df":
|
||||||
|
return runDocker("system", "df", "--format", "json")
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
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) {
|
func createContainer(payload map[string]interface{}) (string, error) {
|
||||||
container, ok := payload["container"].(map[string]interface{})
|
container, ok := payload["container"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ type NodeAgent struct {
|
|||||||
Capabilities AgentCapabilities `json:"capabilities"`
|
Capabilities AgentCapabilities `json:"capabilities"`
|
||||||
Resources NodeResources `json:"resources"`
|
Resources NodeResources `json:"resources"`
|
||||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||||
|
AutoPrune bool `json:"auto_prune"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
@@ -234,6 +237,7 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
|||||||
Port: int(row.Port),
|
Port: int(row.Port),
|
||||||
Status: row.Status.String,
|
Status: row.Status.String,
|
||||||
Version: row.Version.String,
|
Version: row.Version.String,
|
||||||
|
AutoPrune: row.AutoPrune,
|
||||||
Metadata: map[string]interface{}{},
|
Metadata: map[string]interface{}{},
|
||||||
}
|
}
|
||||||
if row.LastHeartbeat.Valid {
|
if row.LastHeartbeat.Valid {
|
||||||
@@ -489,6 +493,7 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
|||||||
Resources: rawJSON(agent.Resources),
|
Resources: rawJSON(agent.Resources),
|
||||||
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||||
Metadata: rawJSON(agent.Metadata),
|
Metadata: rawJSON(agent.Metadata),
|
||||||
|
AutoPrune: agent.AutoPrune,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
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("capabilities", &agent.Capabilities)
|
||||||
remarshal("resources", &agent.Resources)
|
remarshal("resources", &agent.Resources)
|
||||||
remarshal("metadata", &agent.Metadata)
|
remarshal("metadata", &agent.Metadata)
|
||||||
|
remarshal("auto_prune", &agent.AutoPrune)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAgent removes an agent
|
// DeleteAgent removes an agent
|
||||||
@@ -803,6 +809,33 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"container": container})
|
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
|
// ExecuteCommand executes a command on an agent
|
||||||
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
agentID := c.Param("id")
|
||||||
@@ -1214,9 +1247,45 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
|||||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
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
|
// SetupAdminRoutes registers dashboard/control agent routes. Command
|
||||||
// history and container details can leak sensitive output, so these stay
|
// history and container details can leak sensitive output, so these stay
|
||||||
// admin-only rather than joining the public read group.
|
// 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.DELETE("/:id/containers/:containerId", h.ContainerAction)
|
||||||
|
|
||||||
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
||||||
|
agents.POST("/:id/prune", h.PruneAgent)
|
||||||
agents.POST("/:id/commands", h.ExecuteCommand)
|
agents.POST("/:id/commands", h.ExecuteCommand)
|
||||||
agents.GET("/:id/commands", h.GetAgentCommands)
|
agents.GET("/:id/commands", h.GetAgentCommands)
|
||||||
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
|
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 (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -61,26 +62,28 @@ func StartAgentHealthSweep(ctx context.Context, db *database.DB) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
type agentHealthRow struct {
|
||||||
cutoff := time.Now().Add(-staleAfter)
|
|
||||||
type agentRow struct {
|
|
||||||
id string
|
id string
|
||||||
name string
|
name string
|
||||||
status string
|
status string
|
||||||
stale bool
|
stale bool
|
||||||
|
autoPrune bool
|
||||||
}
|
}
|
||||||
var agents []agentRow
|
|
||||||
|
func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) {
|
||||||
|
cutoff := time.Now().Add(-staleAfter)
|
||||||
|
var agents []agentHealthRow
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''),
|
`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)
|
FROM node_agents`, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("containr: agent health sweep: %v", err)
|
log.Printf("containr: agent health sweep: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a agentRow
|
var a agentHealthRow
|
||||||
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale); err == nil {
|
if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale, &a.autoPrune); err == nil {
|
||||||
agents = append(agents, a)
|
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)
|
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
|
// StartUpgradeCheck polls GitHub for the latest release once a day and
|
||||||
|
|||||||
@@ -81,6 +81,76 @@ var nodesDeleteCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nodesUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update <id>",
|
||||||
|
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 <id>",
|
||||||
|
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 <id>",
|
||||||
|
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.
|
// Agent tokens (cagt_) onboard new nodes.
|
||||||
var nodeTokensCmd = &cobra.Command{
|
var nodeTokensCmd = &cobra.Command{
|
||||||
Use: "tokens",
|
Use: "tokens",
|
||||||
@@ -145,6 +215,10 @@ var nodeTokensRevokeCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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)
|
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
|
||||||
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesDeleteCmd, nodeTokensCmd)
|
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ INSERT INTO node_agents (
|
|||||||
id, name, hostname, ip_address, port, status, version,
|
id, name, hostname, ip_address, port, status, version,
|
||||||
capabilities, resources, last_heartbeat, metadata
|
capabilities, resources, last_heartbeat, metadata
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAgentParams struct {
|
type CreateAgentParams struct {
|
||||||
@@ -109,6 +109,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -229,7 +230,7 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAgent = `-- name: GetAgent :one
|
const getAgent = `-- name: GetAgent :one
|
||||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
|
||||||
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata 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
|
WHERE hostname = $1 AND ip_address = $2
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -281,6 +283,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -378,6 +381,36 @@ func (q *Queries) GetContainerForAgent(ctx context.Context, arg GetContainerForA
|
|||||||
return i, err
|
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
|
const insertAgentHeartbeat = `-- name: InsertAgentHeartbeat :exec
|
||||||
INSERT INTO agent_heartbeats (
|
INSERT INTO agent_heartbeats (
|
||||||
id, node_agent_id, timestamp, status, resources,
|
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
|
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) {
|
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.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -639,9 +673,10 @@ UPDATE node_agents SET
|
|||||||
resources = $9,
|
resources = $9,
|
||||||
last_heartbeat = $10,
|
last_heartbeat = $10,
|
||||||
metadata = $11,
|
metadata = $11,
|
||||||
|
auto_prune = $12,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata
|
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgentParams struct {
|
type UpdateAgentParams struct {
|
||||||
@@ -656,6 +691,7 @@ type UpdateAgentParams struct {
|
|||||||
Resources pqtype.NullRawMessage `json:"resources"`
|
Resources pqtype.NullRawMessage `json:"resources"`
|
||||||
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
LastHeartbeat sql.NullTime `json:"last_heartbeat"`
|
||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||||
|
AutoPrune bool `json:"auto_prune"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) {
|
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.Resources,
|
||||||
arg.LastHeartbeat,
|
arg.LastHeartbeat,
|
||||||
arg.Metadata,
|
arg.Metadata,
|
||||||
|
arg.AutoPrune,
|
||||||
)
|
)
|
||||||
var i NodeAgent
|
var i NodeAgent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -687,6 +724,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Metadata,
|
&i.Metadata,
|
||||||
|
&i.AutoPrune,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ type NodeAgent struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
Metadata pqtype.NullRawMessage `json:"metadata"`
|
Metadata pqtype.NullRawMessage `json:"metadata"`
|
||||||
|
AutoPrune bool `json:"auto_prune"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type Querier interface {
|
|||||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, 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)
|
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||||
|
|||||||
@@ -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_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
|
||||||
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
|
||||||
|
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name.", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name"}, false})
|
||||||
|
register(spec{"containr_nodes_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
|
// Scaling / HA / security
|
||||||
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false})
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -28,6 +28,7 @@ UPDATE node_agents SET
|
|||||||
resources = $9,
|
resources = $9,
|
||||||
last_heartbeat = $10,
|
last_heartbeat = $10,
|
||||||
metadata = $11,
|
metadata = $11,
|
||||||
|
auto_prune = $12,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
@@ -107,3 +108,8 @@ RETURNING *;
|
|||||||
-- name: UpdateContainerStatus :exec
|
-- name: UpdateContainerStatus :exec
|
||||||
UPDATE container_instances SET status = $2, updated_at = NOW()
|
UPDATE container_instances SET status = $2, updated_at = NOW()
|
||||||
WHERE id = $1;
|
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;
|
||||||
@@ -192,7 +192,8 @@ CREATE TABLE node_agents (
|
|||||||
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
last_heartbeat TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
metadata JSONB
|
metadata JSONB,
|
||||||
|
auto_prune BOOLEAN NOT NULL DEFAULT false
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE container_instances (
|
CREATE TABLE container_instances (
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
listAuditLogs,
|
listAuditLogs,
|
||||||
listAgents,
|
listAgents,
|
||||||
|
pruneAgent,
|
||||||
|
updateAgent,
|
||||||
listBuilds,
|
listBuilds,
|
||||||
listGitProviders,
|
listGitProviders,
|
||||||
listProjects,
|
listProjects,
|
||||||
@@ -293,6 +295,14 @@ export function UsagePage() {
|
|||||||
mutationFn: (id: string) => revokeAgentToken(id),
|
mutationFn: (id: string) => revokeAgentToken(id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
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(
|
const builds = useMemo(
|
||||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
() => (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 totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||||
const agentEndpoint = getAgentPublicBaseUrl();
|
const agentEndpoint = getAgentPublicBaseUrl();
|
||||||
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
const apiBase = getApiBaseUrl().replace(/\/api\/v1$/, '');
|
||||||
CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? '<token>'} \\
|
const installCommand = `curl -fsSL ${apiBase}/api/agents/install.sh | bash -s -- --url ${apiBase} --token ${issuedToken?.token ?? '<token>'}`;
|
||||||
containr-agent`;
|
const sshCommand = `containr nodes add --ssh <user@host>${issuedToken?.token ? '' : ' # run on a host with the CLI'}`;
|
||||||
|
|
||||||
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||||
@@ -606,11 +616,34 @@ containr-agent`;
|
|||||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
||||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => pruneMutation.mutate(agent.id)}
|
||||||
|
disabled={pruneMutation.isPending}
|
||||||
|
title="Enqueue a bounded docker prune on this node"
|
||||||
|
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Prune
|
||||||
|
</button>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agent.autoPrune}
|
||||||
|
onChange={(e) => autoPruneMutation.mutate({ id: agent.id, on: e.target.checked })}
|
||||||
|
className="accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
auto
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
||||||
@@ -666,7 +699,8 @@ containr-agent`;
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
|
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{installCommand}</pre>
|
||||||
|
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap mt-2">{sshCommand}</pre>
|
||||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||||
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
|
||||||
|
|||||||
@@ -8363,6 +8363,157 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 <api> --token <enroll-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": {
|
"/agents/{id}/metrics": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -11749,6 +11900,8 @@ export interface components {
|
|||||||
metadata?: {
|
metadata?: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
/** @description Enqueue a bounded docker prune on this node once per day. */
|
||||||
|
auto_prune?: boolean;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
last_heartbeat?: string;
|
last_heartbeat?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"title": "Containr CLI",
|
||||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\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 <name> <url>\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
Line truncated
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\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 <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n
Line truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -341,6 +341,7 @@ export type NodeAgentEntity = {
|
|||||||
version: string;
|
version: string;
|
||||||
capabilities: AgentCapabilities;
|
capabilities: AgentCapabilities;
|
||||||
resources: NodeResources;
|
resources: NodeResources;
|
||||||
|
autoPrune: boolean;
|
||||||
lastHeartbeat?: string;
|
lastHeartbeat?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -1076,6 +1077,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
lastHeartbeat: agent.last_heartbeat,
|
lastHeartbeat: agent.last_heartbeat,
|
||||||
|
autoPrune: agent.auto_prune ?? false,
|
||||||
createdAt: agent.created_at,
|
createdAt: agent.created_at,
|
||||||
updatedAt: agent.updated_at,
|
updatedAt: agent.updated_at,
|
||||||
};
|
};
|
||||||
@@ -1114,6 +1116,21 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
|
|||||||
return normalizeAgentArray(payload.agents);
|
return normalizeAgentArray(payload.agents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean }): Promise<NodeAgentEntity | null> {
|
||||||
|
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<void> {
|
||||||
|
await requestJson(`/agents/${id}/prune`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
|
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
|
||||||
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
|
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
|
||||||
|
|
||||||
|
|||||||
@@ -5291,6 +5291,93 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MessageResponse'
|
$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 <api> --token <enroll-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:
|
/agents/{id}/metrics:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -8807,6 +8894,9 @@ components:
|
|||||||
metadata:
|
metadata:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
auto_prune:
|
||||||
|
type: boolean
|
||||||
|
description: Enqueue a bounded docker prune on this node once per day.
|
||||||
last_heartbeat:
|
last_heartbeat:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import compose <project-id> <compose.yml|->
|
|||||||
attached under their service name; --var K=V overrides
|
attached under their service name; --var K=V overrides
|
||||||
template variables
|
template variables
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
|
nodes add <user@host> 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
|
scaling status|policies|services|scale
|
||||||
ha status|alerts|health|policies|enable|disable|failover
|
ha status|alerts|health|policies|enable|disable|failover
|
||||||
security scan|vulnerabilities|metrics
|
security scan|vulnerabilities|metrics
|
||||||
|
|||||||
Reference in new issue
Block a user