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 (#50)
* 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>
* feat(nodes): node detail page — telemetry, containers, commands, controls
/nodes/:id (admin): cpu/mem sparklines from heartbeat history
(1h/24h/7d), containers on node, command history, inline rename,
auto-prune toggle, prune-now with volume opt-in, remove. Agent rows
on the Usage page link into it.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(nodes): mesh awareness + node detail page
- agent reports tailscale/netbird/zerotier/wireguard interface IPs at
register (authoritative — absent interfaces clear stored state)
- backend stores under node_agents.metadata.mesh, merged on re-register
- node detail page shows mesh addresses under the primary IP
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7547375cc2
commit
3f066b3b1e
23 files changed
+1410
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -167,6 +167,8 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
|
||||
"ip_address": cfg.IP,
|
||||
"port": cfg.Port,
|
||||
"capabilities": detectCapabilities(),
|
||||
// Always send mesh so a removed interface clears stored state.
|
||||
"mesh": detectMeshInterfaces(),
|
||||
}
|
||||
var response registerResponse
|
||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||
@@ -246,11 +248,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 {
|
||||
@@ -354,6 +374,42 @@ func detectCapabilities() capabilities {
|
||||
}
|
||||
}
|
||||
|
||||
// detectMeshInterfaces reports overlay-network interface IPs (tailscale,
|
||||
// netbird, zerotier, wireguard) so the backend can display reachable
|
||||
// mesh addresses alongside the primary IP.
|
||||
func detectMeshInterfaces() map[string]string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mesh := map[string]string{}
|
||||
for _, iface := range ifaces {
|
||||
name := strings.ToLower(iface.Name)
|
||||
isMesh := strings.HasPrefix(name, "tailscale") || strings.HasPrefix(name, "ts.") ||
|
||||
strings.HasPrefix(name, "netbird") || strings.HasPrefix(name, "wt") ||
|
||||
strings.HasPrefix(name, "zt") || strings.HasPrefix(name, "wg")
|
||||
if !isMesh || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
mesh[iface.Name] = v4.String()
|
||||
} else if _, exists := mesh[iface.Name]; !exists {
|
||||
mesh[iface.Name] = ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
func collectResources(ip string) nodeResources {
|
||||
mem := readMemory()
|
||||
storage := readStorage("/")
|
||||
|
||||
@@ -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 {
|
||||
@@ -327,6 +331,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
Mesh map[string]string `json:"mesh"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -341,12 +346,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
metadata := map[string]interface{}{}
|
||||
// req.Mesh is authoritative when present (newer agents always send it,
|
||||
// possibly empty); nil means an older agent — keep any stored value.
|
||||
if req.Mesh != nil {
|
||||
metadata["mesh"] = req.Mesh
|
||||
}
|
||||
|
||||
// Re-registering an existing host updates it in place.
|
||||
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||
Hostname: req.Hostname,
|
||||
IpAddress: req.IPAddress,
|
||||
}); err == nil {
|
||||
if existing.Metadata.Valid && len(existing.Metadata.RawMessage) > 0 {
|
||||
var prev map[string]interface{}
|
||||
if err := json.Unmarshal(existing.Metadata.RawMessage, &prev); err == nil {
|
||||
for k, v := range prev {
|
||||
if _, ok := metadata[k]; !ok {
|
||||
metadata[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||
ID: existing.ID,
|
||||
Name: req.Name,
|
||||
@@ -358,7 +379,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: existing.Resources,
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: existing.Metadata,
|
||||
Metadata: rawJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
@@ -408,7 +429,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Capabilities: rawJSON(req.Capabilities),
|
||||
Resources: rawJSON(resources),
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: rawJSON(map[string]interface{}{}),
|
||||
Metadata: rawJSON(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||
@@ -489,6 +510,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 +538,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 +826,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 +1264,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 +1329,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
|
||||
|
||||
@@ -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.
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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,
|
||||
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;
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user