Compare commits

...
Author SHA1 Message Date
Tomáš DvořákandDevin ae7e6b4991 feat(cli): containr migrate dflow + fix railway service types (#53)
dflow importer pages the Payload REST collections (users API-Key
auth) and recreates projects/services: app→git web services, docker
→image, database→typed image with DATABASE_URL for external
providers; variables (empty encrypted values flagged), bind volumes,
and domains carried over; soft-deleted docs excluded.

Also fixes the Railway migrator posting type "image"/"git" — the
create API only accepts web|worker|database|cron and requires an
environment, so every service create 400'd.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 14:37:26 +02:00
Tomáš DvořákandDevin ee43064c5c feat(migrate): containr migrate railway — GraphQL import of services/vars/domains (#51)
--token (or RAILWAY_TOKEN) reads the Railway project via backboard
GraphQL: services resolve to git-repo or image services with command +
replicas, variables and service/custom domains follow. --dry-run prints
the plan; --into targets an existing project. Both account (Bearer) and
project tokens (Project-Access-Token header) work.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:48:36 +02:00
Tomáš DvořákandDevin e054457156 feat(templates): remote template registry via CONTAINR_TEMPLATE_REGISTRY_URL (#52)
- GET /templates merges a remote Containr catalog as source:"registry"
  (5s timeout, 60s cache, offline-safe)
- GET /templates/:id falls through to the registry
- Deploy of a registry template materializes a deterministic private
  copy for the deployer — repeat deploys reuse it
- Templates page badges registry entries; openapi + env documented

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:46:35 +02:00
Tomáš DvořákandDevin 3f066b3b1e 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>
2026-10-09 12:41:26 +02:00
31 changed files with 2593 additions and 43 deletions

No files matched your search

+6
View File
@@ -47,6 +47,12 @@ SECRETS_KEY=
CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence. # Optional rotation list (comma-separated). When set, this takes precedence.
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
# Bundled agent binaries dir served at /api/agents/download/:platform.
# CONTAINR_AGENT_BIN_DIR=/agents
# Optional remote template catalog (another Containr instance). Its
# templates merge into GET /templates as source:"registry" and
# materialize locally on deploy. The bundled catalog works without it.
# CONTAINR_TEMPLATE_REGISTRY_URL=https://templates.example.com
CORS_ORIGINS=http://localhost:3000,http://localhost:8080 CORS_ORIGINS=http://localhost:3000,http://localhost:8080
# Optional build-time API URL overrides. Leave empty for the default # Optional build-time API URL overrides. Leave empty for the default
# same-origin setup - the frontend nginx proxies /api to the backend, which # same-origin setup - the frontend nginx proxies /api to the backend, which
+42 -21
View File
@@ -252,18 +252,31 @@ 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
- [ ] **Mesh awareness** — agent detects + reports tailscale0/netbird0/ same command. Agent binaries ship in the image under `/agents` and are
zt0 IPs at register; backend stores + displays. Optional served at `/api/agents/download/{linux-amd64,linux-arm64}` (install
`TAILSCALE_AUTH_KEY` enroll-at-provision later. script falls back to GH releases). `cli/commands/nodes_add.go`,
- [ ] **Node detail page** — placed services, telemetry history, agent `api/assets/install-agent.sh`, `agents.go:ServeInstallScript`/`ServeAgentBinary`.
version + self-upgrade, **drain/cordon**, remove. - [x] **Mesh awareness** — agent reports tailscale/netbird/wireguard/
- [ ] **Node housekeeping** — agent `prune` command (docker system zerotier interface IPs at register (`mesh` field, authoritative on
prune, bounded) + optional schedule (`setServerAutoCleanup` analog — re-register), stored in `node_agents.metadata.mesh`, shown on the node
disk pressure is the #1 small-VPS killer). detail page. `TAILSCALE_AUTH_KEY` enroll-at-provision still open.
- [x] **Node detail page** — `/nodes/:id` (admin): heartbeat telemetry
(cpu/mem sparklines over 1h/24h/7d via `GET /agents/:id/metrics`),
containers, command history, rename, auto-prune toggle, prune-now,
remove. Drain/cordon and self-upgrade deferred — they need
`services.node_id` routing which lands with multi-node scheduling.
- [x] **Node housekeeping** — agent `prune` + `system_df` commands
(`docker system prune -af`, volumes excluded unless requested, optional
`--until`). `node_agents.auto_prune` + the health sweep enqueue a prune
at most once per 24h per node. Surfaces: `POST /agents/:id/prune`,
`nodes update --auto-prune` / `nodes prune [--volumes --until]` /
`nodes commands`, MCP `containr_nodes_update|prune|commands`
(confirm-gated), Usage-page per-node Prune button + auto toggle.
`cmd/agent/main.go`, `notification_producers.go:scheduleAutoPrune`.
- [ ] **Multi-node scheduling** — wire `scheduling_rules` (table exists): - [ ] **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).
@@ -380,19 +393,27 @@ and wired refs, one click or one CLI call.
## 9. Phase H — Migration & ecosystem ## 9. Phase H — Migration & ecosystem
- [ ] **Railway migrator** — `containr migrate railway --token --project - [x] **Railway migrator** — `containr migrate railway --token --project
--dry-run`: Railway GraphQL → services/vars/domains → recreate via --environment --into --dry-run`: Railway GraphQL (backboard) →
PAT. Landing-page pitch: "off Railway in one command." services (git-repo/image + command + replicas), variables, custom +
- [ ] **dflow importer** — `containr migrate dflow` via Payload REST service domains → recreates via the Containr API. Project tokens and
export or Mongo dump: servers→nodes, projects, services(+vars/ account tokens both supported. `cli/commands/migrate.go`.
domains/volumes), databases, templates. Onboards dflow refugees. - [x] **dflow importer** — `containr migrate dflow --url --api-key
--project --into --dry-run`: pages the Payload REST collections
(users API-Key auth) → services (app→git/web, docker→image,
database→typed image + DATABASE_URL for external providers),
variables (empty encrypted values flagged), bind volumes, domains.
Soft-deleted services excluded. `cli/commands/migrate_dflow.go`.
- [x] **`containr import compose`** — `POST /projects/:id/import-compose` - [x] **`containr import compose`** — `POST /projects/:id/import-compose`
deploys a compose file straight into a project; CLI `import compose`, deploys a compose file straight into a project; CLI `import compose`,
MCP tool, workspace import modal; project delete cascades to MCP tool, workspace import modal; project delete cascades to
graph-provisioned managed databases (`database_services.project_id`). graph-provisioned managed databases (`database_services.project_id`).
- [ ] **Template registry** — optional `CONTAINR_TEMPLATE_REGISTRY_URL` - [x] **Template registry** — `CONTAINR_TEMPLATE_REGISTRY_URL` merges a
fetch/publish; bundled catalog works offline (dflow's self-contained remote Containr catalog into `GET /templates` as `source:"registry"`
principle — nothing phones home). (60s cache, dead registry never blocks local). `GET /templates/:id`
falls through to the registry; deploy materializes a deterministic
`reg-<hash>` private copy for the deployer so re-deploys don't dup.
`api/templateregistry.go`, `.env.example`.
## 9b. Phase I — Companion surfaces ## 9b. Phase I — Companion surfaces
+5
View File
@@ -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
+56
View File
@@ -167,6 +167,8 @@ func register(ctx context.Context, client *http.Client, cfg agentConfig) (string
"ip_address": cfg.IP, "ip_address": cfg.IP,
"port": cfg.Port, "port": cfg.Port,
"capabilities": detectCapabilities(), "capabilities": detectCapabilities(),
// Always send mesh so a removed interface clears stored state.
"mesh": detectMeshInterfaces(),
} }
var response registerResponse var response registerResponse
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil { 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)) 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 {
@@ -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 { func collectResources(ip string) nodeResources {
mem := readMemory() mem := readMemory()
storage := readStorage("/") storage := readStorage("/")
+89 -2
View File
@@ -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 {
@@ -327,6 +331,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Port int `json:"port" binding:"required"` Port int `json:"port" binding:"required"`
Capabilities AgentCapabilities `json:"capabilities" binding:"required"` Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
AuthToken string `json:"auth_token"` AuthToken string `json:"auth_token"`
Mesh map[string]string `json:"mesh"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -341,12 +346,28 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
} }
ctx := c.Request.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. // Re-registering an existing host updates it in place.
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{ if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
Hostname: req.Hostname, Hostname: req.Hostname,
IpAddress: req.IPAddress, IpAddress: req.IPAddress,
}); err == nil { }); 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{ updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: existing.ID, ID: existing.ID,
Name: req.Name, Name: req.Name,
@@ -358,7 +379,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Capabilities: rawJSON(req.Capabilities), Capabilities: rawJSON(req.Capabilities),
Resources: existing.Resources, Resources: existing.Resources,
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: existing.Metadata, Metadata: rawJSON(metadata),
}) })
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"})
@@ -408,7 +429,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Capabilities: rawJSON(req.Capabilities), Capabilities: rawJSON(req.Capabilities),
Resources: rawJSON(resources), Resources: rawJSON(resources),
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: rawJSON(map[string]interface{}{}), Metadata: rawJSON(metadata),
}) })
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) 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), 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 +538,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 +826,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 +1264,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 +1329,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
@@ -0,0 +1,186 @@
package api
// Template registry — CONTAINR_TEMPLATE_REGISTRY_URL points at another
// Containr instance (or any server answering the same template shape):
// GET {base}/api/v1/templates → {"templates": [ServiceTemplate…]}
// GET {base}/api/v1/templates/:id → {"template": …, "config": …, "variables": …}
// Registry entries merge into the catalog as source:"registry"; deploying
// one materializes it as a user template for the deployer first. The
// bundled catalog always works offline — a dead registry never blocks it.
import (
"containr/internal/database/sqlcdb"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
const registryCacheTTL = 60 * time.Second
var registryCache struct {
mu sync.Mutex
fetchedAt time.Time
templates []ServiceTemplate
}
func registryBaseURL() string {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("CONTAINR_TEMPLATE_REGISTRY_URL")), "/")
base = strings.TrimSuffix(base, "/api/v1")
return base
}
// fetchRegistryTemplates lists the remote catalog, cached for
// registryCacheTTL. Failures degrade to an empty list — never fatal.
func fetchRegistryTemplates(ctx context.Context) []ServiceTemplate {
if registryBaseURL() == "" {
return nil
}
registryCache.mu.Lock()
fresh := time.Since(registryCache.fetchedAt) < registryCacheTTL && registryCache.templates != nil
cached := registryCache.templates
registryCache.mu.Unlock()
if fresh {
return cached
}
var templates []ServiceTemplate
var payload struct {
Templates []ServiceTemplate `json:"templates"`
}
if err := registryGet(ctx, "/api/v1/templates", &payload); err == nil {
templates = payload.Templates
}
for i := range templates {
templates[i].Source = "registry"
}
registryCache.mu.Lock()
registryCache.fetchedAt = time.Now()
registryCache.templates = templates
registryCache.mu.Unlock()
return templates
}
// fetchRegistryTemplate fetches one remote template's full document
// (template + config + variables). nil when the registry lacks it.
func fetchRegistryTemplate(ctx context.Context, id string) *ServiceTemplate {
base := registryBaseURL()
if base == "" {
return nil
}
var payload struct {
Template ServiceTemplate `json:"template"`
Config json.RawMessage `json:"config"`
Variables []TemplateVariable `json:"variables"`
}
if err := registryGet(ctx, "/api/v1/templates/"+id, &payload); err != nil {
return nil
}
t := payload.Template
if t.ID == "" {
t.ID = id
}
t.Source = "registry"
// The detail endpoint returns config/variables decoded; store them back
// as raw JSON so they can persist unmodified into a local copy.
if len(payload.Config) > 0 {
t.Config = string(payload.Config)
}
if payload.Variables != nil {
if raw, err := json.Marshal(payload.Variables); err == nil {
t.Variables = string(raw)
}
}
return &t
}
func registryGet(ctx context.Context, path string, out interface{}) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryBaseURL()+path, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errRegistry{resp.StatusCode}
}
return json.NewDecoder(resp.Body).Decode(out)
}
type errRegistry struct{ status int }
func (e errRegistry) Error() string { return "template registry status " + http.StatusText(e.status) }
// respondRegistryTemplate answers GET /templates/:id for a registry-only
// template with the same envelope shape as local templates.
func respondRegistryTemplate(c *gin.Context, t *ServiceTemplate) {
config := json.RawMessage(t.Config)
if len(config) == 0 {
config = json.RawMessage(`{}`)
}
variables := json.RawMessage(t.Variables)
if len(variables) == 0 {
variables = json.RawMessage(`[]`)
}
c.JSON(http.StatusOK, gin.H{
"template": t,
"config": config,
"variables": variables,
})
}
// materializeRegistryTemplate copies a registry template into the local
// catalog as a private template owned by the deployer, so the normal
// deploy path (and future re-deploys) work without the registry. The
// local id is deterministic per (registry, template, user) so repeat
// deploys reuse the same copy instead of accumulating duplicates.
func materializeRegistryTemplate(c *gin.Context, q *sqlcdb.Queries, userID, templateID string) (sqlcdb.ServiceTemplate, error) {
ownerUUID, err := uuid.Parse(userID)
if err != nil {
return sqlcdb.ServiceTemplate{}, err
}
sum := sha256.Sum256([]byte(registryBaseURL() + "|" + templateID + "|" + userID))
localID := "reg-" + hex.EncodeToString(sum[:8])
if existing, err := q.GetServiceTemplateByID(c.Request.Context(), localID); err == nil {
return existing, nil
}
rt := fetchRegistryTemplate(c.Request.Context(), templateID)
if rt == nil {
return sqlcdb.ServiceTemplate{}, sql.ErrNoRows
}
variables := pqtype.NullRawMessage{}
if rt.Variables != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(rt.Variables), Valid: true}
}
if err := q.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: localID,
Name: rt.Name,
Description: nullableText(rt.Description),
Category: rt.Category,
Logo: nullableText(rt.Logo),
Config: json.RawMessage(rt.Config),
Variables: variables,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: false,
}); err != nil {
return sqlcdb.ServiceTemplate{}, err
}
return q.GetServiceTemplateByID(c.Request.Context(), localID)
}
+28
View File
@@ -31,6 +31,8 @@ type ServiceTemplate struct {
IsOfficial bool `json:"is_official" db:"is_official"` IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"` OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"` IsPublic bool `json:"is_public" db:"is_public"`
// Source marks catalog origin: "" (local/official) or "registry".
Source string `json:"source,omitempty" db:"-"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
} }
@@ -97,10 +99,24 @@ func handleGetTemplates(c *gin.Context) {
} }
templates := make([]ServiceTemplate, 0, len(templateRows)) templates := make([]ServiceTemplate, 0, len(templateRows))
localIDs := make(map[string]bool, len(templateRows))
for _, row := range templateRows { for _, row := range templateRows {
localIDs[row.ID] = true
templates = append(templates, mapSQLCTemplate(row)) templates = append(templates, mapSQLCTemplate(row))
} }
// Merge the configured remote registry (CONTAINR_TEMPLATE_REGISTRY_URL).
// Registry ids never shadow local templates.
for _, t := range fetchRegistryTemplates(ctx) {
if localIDs[t.ID] {
continue
}
if category != "" && t.Category != category {
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates}) c.JSON(http.StatusOK, gin.H{"templates": templates})
} }
@@ -112,6 +128,11 @@ func handleGetTemplate(c *gin.Context) {
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID) row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
// Registry fallback — remote templates materialize read-only.
if rt := fetchRegistryTemplate(c.Request.Context(), templateID); rt != nil {
respondRegistryTemplate(c, rt)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return return
} }
@@ -190,12 +211,19 @@ func handleCreateFromTemplate(c *gin.Context) {
templateRow, err := queries.GetServiceTemplateByID(ctx, templateID) templateRow, err := queries.GetServiceTemplateByID(ctx, templateID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
// Registry template — materialize a local copy for the
// deployer, then continue down the normal deploy path.
row, mErr := materializeRegistryTemplate(c, queries, userID, templateID)
if mErr != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return return
} }
templateRow = row
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return return
} }
}
template := mapSQLCTemplate(templateRow) template := mapSQLCTemplate(templateRow)
if template.ID == "" || !templateVisibleTo(c, template) { if template.ID == "" || !templateVisibleTo(c, template) {
@@ -0,0 +1,374 @@
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
)
const railwayAPI = "https://backboard.railway.com/graphql/v2"
// railwaySource mirrors the Railway serviceInstance.source union.
type railwaySource struct {
Repo string `json:"repo"`
Image string `json:"image"`
}
type railwayServicePlan struct {
ID string
Name string
Source railwaySource
Command string
Replicas int
Domains []string
Variables map[string]string
}
// MigrateCmd imports workloads from other platforms.
var MigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Import workloads from other platforms",
}
var migrateRailwayCmd = &cobra.Command{
Use: "railway",
Short: "Migrate a Railway project into Containr",
Long: `Pulls a Railway project's services, variables, and domains via the
public GraphQL API and recreates them in Containr.
containr migrate railway --token <railway-token> --project <id> --dry-run
containr migrate railway --token <railway-token> --project <id> --into <project-id>
Token: account/workspace token (Authorization: Bearer) or a project token.
--into defaults to creating a project named after the Railway project.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
token, _ := cmd.Flags().GetString("token")
if token == "" {
token = os.Getenv("RAILWAY_TOKEN")
}
if token == "" {
return &APIError{Message: "pass --token or set RAILWAY_TOKEN", ExitCode: ExitError}
}
projectID, _ := cmd.Flags().GetString("project")
envName, _ := cmd.Flags().GetString("environment")
into, _ := cmd.Flags().GetString("into")
dryRun, _ := cmd.Flags().GetBool("dry-run")
projectName, envID, services, err := railwayProject(projectID, envName, token)
if err != nil {
return err
}
if len(services) == 0 {
return &APIError{Message: "no services found in that Railway project/environment", ExitCode: ExitError}
}
plans := make([]railwayServicePlan, 0, len(services))
for _, svc := range services {
plan, err := railwayServiceDetail(svc, envID, token)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: %v — skipping\n", svc["name"], err)
continue
}
plans = append(plans, plan)
}
if len(plans) == 0 {
return &APIError{Message: "no migratable services", ExitCode: ExitError}
}
fmt.Printf("Railway project %q → %d service(s)\n", projectName, len(plans))
for _, p := range plans {
src := p.Source.Image
if src == "" {
src = railwayRepoURL(p.Source.Repo)
}
fmt.Printf(" %s src=%s replicas=%d vars=%d domains=%d\n",
p.Name, src, max(p.Replicas, 1), len(p.Variables), len(p.Domains))
}
if dryRun {
fmt.Println("\nDry run — pass without --dry-run to create these resources.")
return nil
}
c, err := client()
if err != nil {
return err
}
target := into
if target == "" {
data, err := c.Do("POST", "/projects", map[string]interface{}{
"name": projectName,
"description": "Imported from Railway",
})
if err != nil {
return err
}
p, _ := unwrapObject(data, "project")
target = str(p, "id")
if target == "" || target == "-" {
return &APIError{Message: "project created but id missing from response", ExitCode: ExitError}
}
fmt.Printf("Created project %s (%s)\n", projectName, target)
}
for _, p := range plans {
body := map[string]interface{}{
"name": p.Name,
"type": "web",
"environment": "production",
}
if p.Source.Image != "" {
body["image"] = p.Source.Image
} else if repo := railwayRepoURL(p.Source.Repo); repo != "" {
body["git_repo"] = repo
} else {
fmt.Fprintf(os.Stderr, "warning: %s has no source — skipping\n", p.Name)
continue
}
if p.Command != "" {
body["command"] = p.Command
}
if p.Replicas > 1 {
body["replicas"] = p.Replicas
}
data, err := c.Do("POST", "/projects/"+target+"/services", body)
if err != nil {
fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err)
continue
}
svc, _ := unwrapObject(data, "service")
svcID := str(svc, "id")
fmt.Printf(" created service %s (%s)\n", p.Name, svcID)
if len(p.Variables) > 0 && svcID != "" && svcID != "-" {
vars := make([]map[string]interface{}, 0, len(p.Variables))
for k, v := range p.Variables {
vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false})
}
if _, err := c.Do("PUT", "/services/"+svcID+"/variables",
map[string]interface{}{"variables": vars}); err != nil {
fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err)
}
}
for _, domain := range p.Domains {
if svcID == "" || svcID == "-" {
break
}
if _, err := c.Do("POST", "/services/"+svcID+"/domains",
map[string]interface{}{"domain": domain}); err != nil {
fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err)
}
}
}
fmt.Println("Done. Railway deploy triggers are not migrated — redeploy each service when ready.")
return nil
},
}
// railwayGraphQL posts a query; Railway returns 200 with an errors array on
// partial failures — treat errors as fatal, the migration needs clean data.
func railwayGraphQL(token, query string, variables map[string]interface{}) (map[string]interface{}, error) {
body, _ := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
req, err := http.NewRequest(http.MethodPost, railwayAPI, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Project tokens use a dedicated header; account/workspace tokens use Bearer.
if strings.HasPrefix(token, "rw_pat") || strings.HasPrefix(token, "rw_pt") {
req.Header.Set("Project-Access-Token", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
var out struct {
Data map[string]interface{} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("railway response not JSON (status %d)", resp.StatusCode)
}
if len(out.Errors) > 0 {
return nil, fmt.Errorf("railway: %s", out.Errors[0].Message)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("railway status %d", resp.StatusCode)
}
return out.Data, nil
}
// railwayProject resolves the project (by id, or sole accessible project) and
// returns its name, the target environment id, and its services.
func railwayProject(projectID, envName, token string) (string, string, []map[string]interface{}, error) {
if projectID == "" {
data, err := railwayGraphQL(token,
`query { projects(first: 100) { edges { node { id name } } } }`, nil)
if err != nil {
return "", "", nil, err
}
projects := edgesOf(data, "projects")
if len(projects) == 1 {
projectID = str(projects[0], "id")
} else {
for _, p := range projects {
fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name"))
}
return "", "", nil, &APIError{
Message: "multiple projects — pass --project <id> (listed above)",
ExitCode: ExitError,
}
}
}
data, err := railwayGraphQL(token, `query ($id: String!) {
project(id: $id) {
name
environments { edges { node { id name } } }
services { edges { node { id name } } }
}
}`, map[string]interface{}{"id": projectID})
if err != nil {
return "", "", nil, err
}
project, _ := data["project"].(map[string]interface{})
if project == nil {
return "", "", nil, &APIError{Message: "railway project not found", ExitCode: ExitNotFound}
}
envs := edgesOf(project, "environments")
envID := ""
for _, e := range envs {
if str(e, "name") == envName {
envID = str(e, "id")
break
}
}
if envID == "" && len(envs) == 1 {
envID = str(envs[0], "id")
}
if envID == "" {
names := make([]string, 0, len(envs))
for _, e := range envs {
names = append(names, str(e, "name"))
}
return "", "", nil, &APIError{
Message: fmt.Sprintf("environment %q not found (have: %s)", envName, strings.Join(names, ", ")),
ExitCode: ExitError,
}
}
return str(project, "name"), envID, edgesOf(project, "services"), nil
}
// railwayServiceDetail pulls source, command, replicas, domains, and
// variables for one service in the target environment.
func railwayServiceDetail(svc map[string]interface{}, envID, token string) (railwayServicePlan, error) {
plan := railwayServicePlan{
ID: str(svc, "id"),
Name: str(svc, "name"),
}
data, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
serviceInstance(serviceId: $sid, environmentId: $eid) {
startCommand
numReplicas
source { repo image }
domains { serviceDomains { domain } customDomains { domain } }
}
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
if err != nil {
return plan, err
}
inst, _ := data["serviceInstance"].(map[string]interface{})
if inst == nil {
return plan, fmt.Errorf("no service instance in this environment")
}
if src, ok := inst["source"].(map[string]interface{}); ok {
plan.Source = railwaySource{Repo: str(src, "repo"), Image: str(src, "image")}
}
if cmd, ok := inst["startCommand"].(string); ok {
plan.Command = cmd
}
if n, ok := inst["numReplicas"].(float64); ok {
plan.Replicas = int(n)
}
if domains, ok := inst["domains"].(map[string]interface{}); ok {
for _, group := range []string{"serviceDomains", "customDomains"} {
if list, ok := domains[group].([]interface{}); ok {
for _, d := range list {
if m, ok := d.(map[string]interface{}); ok {
if dom := str(m, "domain"); dom != "" && dom != "-" {
plan.Domains = append(plan.Domains, dom)
}
}
}
}
}
}
vdata, err := railwayGraphQL(token, `query ($sid: String!, $eid: String!) {
variables(serviceId: $sid, environmentId: $eid)
}`, map[string]interface{}{"sid": plan.ID, "eid": envID})
if err == nil {
if vars, ok := vdata["variables"].(map[string]interface{}); ok {
plan.Variables = map[string]string{}
for k, v := range vars {
if s, ok := v.(string); ok {
// Railway reference variables (${{…}}) resolve at deploy
// time — carry them literally so they stay visible.
plan.Variables[k] = s
}
}
}
}
return plan, nil
}
// railwayRepoURL normalizes Railway's "owner/repo" to a cloneable https URL.
func railwayRepoURL(repo string) string {
repo = strings.TrimSpace(repo)
if repo == "" || repo == "-" {
return ""
}
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
return repo
}
return "https://github.com/" + repo
}
func edgesOf(obj map[string]interface{}, key string) []map[string]interface{} {
conn, _ := obj[key].(map[string]interface{})
edges, _ := conn["edges"].([]interface{})
out := make([]map[string]interface{}, 0, len(edges))
for _, e := range edges {
if m, ok := e.(map[string]interface{}); ok {
if node, ok := m["node"].(map[string]interface{}); ok {
out = append(out, node)
}
}
}
return out
}
func init() {
migrateRailwayCmd.Flags().String("token", "", "Railway API token (or RAILWAY_TOKEN env)")
migrateRailwayCmd.Flags().String("project", "", "Railway project id (lists yours when omitted and >1 exists)")
migrateRailwayCmd.Flags().String("environment", "production", "Railway environment name")
migrateRailwayCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
migrateRailwayCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
MigrateCmd.AddCommand(migrateRailwayCmd)
}
@@ -0,0 +1,441 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/cobra"
)
// dflow runs on Payload CMS — every collection is a plain REST resource under
// /api/<slug> returning {docs, page, totalPages, hasNextPage}. Auth is a
// Payload API key on the users collection: "Authorization: users API-Key <k>".
type dflowServicePlan struct {
ID string
Name string
Description string
Type string // web | database
Image string
GitRepo string
GitBranch string
BuildPath string
Port int
Variables map[string]string
Domains []string
Volumes []map[string]string
}
var migrateDflowCmd = &cobra.Command{
Use: "dflow",
Short: "Migrate a dflow (Payload CMS) project into Containr",
Long: `Pulls a dflow project's services, variables, volumes, and domains via the
Payload REST API and recreates them in Containr.
containr migrate dflow --url https://dflow.example.com --api-key <key> --dry-run
containr migrate dflow --url https://dflow.example.com --api-key <key> --project <id>
Auth: a Payload API key from a dflow admin user (users API-Key). Encrypted
fields (variables, git tokens) only come back decrypted if the API key belongs
to a user with access — empty values are flagged so nothing is silently lost.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
base, _ := cmd.Flags().GetString("url")
if base == "" {
base = os.Getenv("DFLOW_URL")
}
if base == "" {
return &APIError{Message: "pass --url or set DFLOW_URL", ExitCode: ExitError}
}
base = strings.TrimSuffix(base, "/")
key, _ := cmd.Flags().GetString("api-key")
if key == "" {
key = os.Getenv("DFLOW_API_KEY")
}
if key == "" {
return &APIError{Message: "pass --api-key or set DFLOW_API_KEY", ExitCode: ExitError}
}
projectID, _ := cmd.Flags().GetString("project")
into, _ := cmd.Flags().GetString("into")
dryRun, _ := cmd.Flags().GetBool("dry-run")
projectName, services, err := dflowProject(base, key, projectID)
if err != nil {
return err
}
if len(services) == 0 {
return &APIError{Message: "no services found in that dflow project", ExitCode: ExitError}
}
plans := make([]dflowServicePlan, 0, len(services))
for _, svc := range services {
plan, warn := dflowServicePlanOf(svc)
for _, w := range warn {
fmt.Fprintf(os.Stderr, " warning: %s: %s\n", plan.Name, w)
}
plans = append(plans, plan)
}
fmt.Printf("dflow project %q → %d service(s)\n", projectName, len(plans))
for _, p := range plans {
src := p.Image
if src == "" {
src = p.GitRepo
}
fmt.Printf(" %s type=%s src=%s vars=%d domains=%d volumes=%d\n",
p.Name, p.Type, src, len(p.Variables), len(p.Domains), len(p.Volumes))
}
if dryRun {
fmt.Println("\nDry run — pass without --dry-run to create these resources.")
return nil
}
c, err := client()
if err != nil {
return err
}
target := into
if target == "" {
data, err := c.Do("POST", "/projects", map[string]interface{}{
"name": projectName,
"description": "Imported from dflow",
})
if err != nil {
return err
}
p, _ := unwrapObject(data, "project")
target = str(p, "id")
if target == "" || target == "-" {
return &APIError{Message: "project created but id missing from response", ExitCode: ExitError}
}
fmt.Printf("Created project %s (%s)\n", projectName, target)
}
for _, p := range plans {
body := map[string]interface{}{
"name": p.Name,
"type": p.Type,
"environment": "production",
}
if p.Image != "" {
body["image"] = p.Image
}
if p.GitRepo != "" {
body["git_repo"] = p.GitRepo
}
if p.GitBranch != "" {
body["git_branch"] = p.GitBranch
}
if p.BuildPath != "" {
body["build_path"] = p.BuildPath
}
if p.Port > 0 {
body["port"] = p.Port
}
if len(p.Volumes) > 0 {
vols := make([]map[string]interface{}, 0, len(p.Volumes))
for _, v := range p.Volumes {
vols = append(vols, map[string]interface{}{
"type": "bind", "source": v["source"], "target": v["target"],
})
}
body["volumes"] = vols
}
data, err := c.Do("POST", "/projects/"+target+"/services", body)
if err != nil {
fmt.Fprintf(os.Stderr, "error: create %s: %v\n", p.Name, err)
continue
}
svc, _ := unwrapObject(data, "service")
svcID := str(svc, "id")
fmt.Printf(" created service %s (%s)\n", p.Name, svcID)
if len(p.Variables) > 0 && svcID != "" && svcID != "-" {
vars := make([]map[string]interface{}, 0, len(p.Variables))
for k, v := range p.Variables {
vars = append(vars, map[string]interface{}{"key": k, "value": v, "is_secret": false})
}
if _, err := c.Do("PUT", "/services/"+svcID+"/variables",
map[string]interface{}{"variables": vars}); err != nil {
fmt.Fprintf(os.Stderr, " warning: variables for %s: %v\n", p.Name, err)
}
}
for _, domain := range p.Domains {
if svcID == "" || svcID == "-" {
break
}
if _, err := c.Do("POST", "/services/"+svcID+"/domains",
map[string]interface{}{"domain": domain}); err != nil {
fmt.Fprintf(os.Stderr, " warning: domain %s: %v\n", domain, err)
}
}
}
fmt.Println("Done. Git credentials, deployment history, and server topology are not migrated — reconnect repos and redeploy each service when ready.")
return nil
},
}
// dflowGet performs an authenticated Payload REST call.
func dflowGet(base, apiKey, path string, q url.Values) (map[string]interface{}, error) {
u := base + "/api/" + path
if len(q) > 0 {
u += "?" + q.Encode()
}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "users API-Key "+apiKey)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, &APIError{Message: "dflow rejected the API key — needs an admin user's users API-Key", ExitCode: ExitAuth}
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("dflow response not JSON (status %d)", resp.StatusCode)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("dflow status %d", resp.StatusCode)
}
return out, nil
}
// dflowCollection pages through a Payload collection.
func dflowCollection(base, apiKey, collection string, q url.Values) ([]map[string]interface{}, error) {
var docs []map[string]interface{}
for page := 1; ; page++ {
params := url.Values{}
for k, v := range q {
params[k] = v
}
params.Set("limit", "100")
params.Set("depth", "0")
params.Set("page", fmt.Sprint(page))
data, err := dflowGet(base, apiKey, collection, params)
if err != nil {
return nil, err
}
list, _ := data["docs"].([]interface{})
for _, d := range list {
if m, ok := d.(map[string]interface{}); ok {
docs = append(docs, m)
}
}
next, _ := data["hasNextPage"].(bool)
if !next {
return docs, nil
}
}
}
// dflowProject resolves the project (by id, or sole project) and returns its
// name plus its services.
func dflowProject(base, apiKey, projectID string) (string, []map[string]interface{}, error) {
if projectID == "" {
projects, err := dflowCollection(base, apiKey, "projects", nil)
if err != nil {
return "", nil, err
}
if len(projects) == 1 {
projectID = str(projects[0], "id")
} else {
for _, p := range projects {
fmt.Fprintf(os.Stderr, " %s %s\n", str(p, "id"), str(p, "name"))
}
return "", nil, &APIError{
Message: "multiple projects — pass --project <id> (listed above)",
ExitCode: ExitError,
}
}
}
projects, err := dflowCollection(base, apiKey, "projects",
url.Values{"where[id][equals]": {projectID}})
if err != nil {
return "", nil, err
}
if len(projects) == 0 {
return "", nil, &APIError{Message: "dflow project not found", ExitCode: ExitNotFound}
}
name := str(projects[0], "name")
q := url.Values{
"where[project][equals]": {projectID},
"where[deletedAt][exists]": {"false"},
"where[_status][not_equals]": {"draft"},
}
services, err := dflowCollection(base, apiKey, "services", q)
if err != nil {
return "", nil, err
}
return name, services, nil
}
// dflowServicePlanOf maps a Payload service doc onto a Containr create plan.
func dflowServicePlanOf(svc map[string]interface{}) (dflowServicePlan, []string) {
var warn []string
plan := dflowServicePlan{
ID: str(svc, "id"),
Name: str(svc, "name"),
Description: str(svc, "description"),
Variables: map[string]string{},
}
switch str(svc, "type") {
case "app":
plan.Type = "web"
settings := dflowProviderSettings(svc)
plan.GitRepo = dflowRepoURL(str(svc, "providerType"), str(settings, "owner"), str(settings, "repository"))
plan.GitBranch = str(settings, "branch")
plan.BuildPath = str(settings, "buildPath")
plan.Port = int(num(settings, "port"))
if plan.GitRepo == "" {
warn = append(warn, "no repository settings — importing with no source")
}
case "docker":
plan.Type = "web"
d, _ := svc["dockerDetails"].(map[string]interface{})
plan.Image = normalizeDockerImage(str(d, "url"))
for _, p := range listOf(d, "ports") {
if plan.Port == 0 {
plan.Port = int(num(p, "containerPort"))
}
}
if plan.Image == "" {
warn = append(warn, "no docker image url — importing with no source")
}
case "database":
plan.Type = "database"
d, _ := svc["databaseDetails"].(map[string]interface{})
dbType := str(d, "type")
version := str(d, "version")
if dbType != "" {
plan.Image = dbType + ":" + firstOf(version, "latest")
}
if str(d, "provider") == "external" {
if conn := str(d, "connectionUrl"); conn != "" {
plan.Variables["DATABASE_URL"] = conn
} else if str(d, "host") != "" {
warn = append(warn, "external database has host but no connectionUrl — set DATABASE_URL manually")
}
}
default:
plan.Type = "web"
warn = append(warn, fmt.Sprintf("unknown type %q — imported as web", str(svc, "type")))
}
for _, v := range listOf(svc, "variables") {
k, val := str(v, "key"), str(v, "value")
if k == "" {
continue
}
if val == "" {
warn = append(warn, fmt.Sprintf("variable %s came back empty (encrypted field?)", k))
}
plan.Variables[k] = val
}
for _, d := range listOf(svc, "domains") {
if dom := str(d, "domain"); dom != "" && dom != "-" {
plan.Domains = append(plan.Domains, dom)
}
}
for _, v := range listOf(svc, "volumes") {
host, container := str(v, "hostPath"), str(v, "containerPath")
if host != "" && container != "" {
plan.Volumes = append(plan.Volumes, map[string]string{"source": host, "target": container})
}
}
return plan, warn
}
// dflowProviderSettings picks the populated <provider>Settings group.
func dflowProviderSettings(svc map[string]interface{}) map[string]interface{} {
for _, key := range []string{"githubSettings", "gitlabSettings", "bitbucketSettings", "giteaSettings", "azureSettings"} {
if g, ok := svc[key].(map[string]interface{}); ok && str(g, "repository") != "" {
return g
}
}
return nil
}
// dflowRepoURL builds a cloneable https URL for the known git providers.
func dflowRepoURL(providerType, owner, repo string) string {
repo = strings.TrimSuffix(strings.TrimSpace(repo), ".git")
owner = strings.TrimSpace(owner)
if repo == "" {
return ""
}
if strings.Contains(repo, "://") || strings.HasPrefix(repo, "git@") {
return repo
}
switch providerType {
case "gitlab":
return "https://gitlab.com/" + owner + "/" + repo
case "bitbucket":
return "https://bitbucket.org/" + owner + "/" + repo
case "azureDevOps":
return "https://dev.azure.com/" + owner + "/_git/" + repo
case "gitea":
return "" // host unknown — user must reconnect the repo
default:
if owner == "" {
return ""
}
return "https://github.com/" + owner + "/" + repo
}
}
// normalizeDockerImage fixes common dflow registry URL typos (ghrc://).
func normalizeDockerImage(image string) string {
image = strings.TrimSpace(image)
if strings.HasPrefix(image, "ghrc://") {
return "ghcr.io/" + strings.TrimPrefix(image, "ghrc://")
}
return image
}
func listOf(m map[string]interface{}, key string) []map[string]interface{} {
raw, _ := m[key].([]interface{})
out := make([]map[string]interface{}, 0, len(raw))
for _, item := range raw {
if v, ok := item.(map[string]interface{}); ok {
out = append(out, v)
}
}
return out
}
func num(m map[string]interface{}, key string) float64 {
v, _ := m[key].(float64)
return v
}
func firstOf(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
func init() {
migrateDflowCmd.Flags().String("url", "", "dflow base URL (or DFLOW_URL env)")
migrateDflowCmd.Flags().String("api-key", "", "dflow admin API key (or DFLOW_API_KEY env)")
migrateDflowCmd.Flags().String("project", "", "dflow project id (lists yours when omitted and >1 exists)")
migrateDflowCmd.Flags().String("into", "", "existing Containr project id (default: create one)")
migrateDflowCmd.Flags().Bool("dry-run", false, "print the plan without creating anything")
MigrateCmd.AddCommand(migrateDflowCmd)
}
@@ -0,0 +1,122 @@
package commands
import "testing"
func TestRailwayRepoURL(t *testing.T) {
cases := map[string]string{
"owner/repo": "https://github.com/owner/repo",
"https://gitlab.com/a/b": "https://gitlab.com/a/b",
"[email protected]:a/b.git": "[email protected]:a/b.git",
"": "",
"-": "",
" spaced/x ": "https://github.com/spaced/x",
}
for in, want := range cases {
if got := railwayRepoURL(in); got != want {
t.Errorf("railwayRepoURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestEdgesOf(t *testing.T) {
obj := map[string]interface{}{
"services": map[string]interface{}{
"edges": []interface{}{
map[string]interface{}{"node": map[string]interface{}{"id": "a", "name": "web"}},
map[string]interface{}{"node": map[string]interface{}{"id": "b", "name": "api"}},
},
},
}
nodes := edgesOf(obj, "services")
if len(nodes) != 2 || str(nodes[0], "name") != "web" {
t.Fatalf("edgesOf returned %+v", nodes)
}
if got := edgesOf(obj, "missing"); len(got) != 0 {
t.Fatalf("edgesOf(missing) = %+v, want empty", got)
}
if got := edgesOf(map[string]interface{}{"services": nil}, "services"); len(got) != 0 {
t.Fatalf("edgesOf(nil conn) = %+v, want empty", got)
}
}
func TestDflowServicePlanOf(t *testing.T) {
appSvc := map[string]interface{}{
"id": "s1",
"name": "api",
"type": "app",
"providerType": "github",
"githubSettings": map[string]interface{}{
"owner": "acme", "repository": "api", "branch": "main",
"buildPath": "/", "port": float64(8080),
},
"variables": []interface{}{
map[string]interface{}{"key": "FOO", "value": "bar"},
map[string]interface{}{"key": "EMPTY", "value": ""},
},
"domains": []interface{}{
map[string]interface{}{"domain": "api.example.com"},
},
"volumes": []interface{}{
map[string]interface{}{"hostPath": "/data", "containerPath": "/app/data"},
},
}
plan, warn := dflowServicePlanOf(appSvc)
if plan.Type != "web" || plan.GitRepo != "https://github.com/acme/api" || plan.Port != 8080 {
t.Fatalf("app plan = %+v", plan)
}
if plan.Variables["FOO"] != "bar" || len(plan.Domains) != 1 || len(plan.Volumes) != 1 {
t.Fatalf("plan detail = %+v", plan)
}
if len(warn) != 1 { // EMPTY value flagged
t.Fatalf("warnings = %v", warn)
}
dbSvc := map[string]interface{}{
"id": "s2", "name": "pg", "type": "database",
"databaseDetails": map[string]interface{}{
"type": "postgres", "version": "16", "provider": "external",
"connectionUrl": "postgres://u:p@h:5432/db",
},
}
dplan, _ := dflowServicePlanOf(dbSvc)
if dplan.Type != "database" || dplan.Image != "postgres:16" {
t.Fatalf("db plan = %+v", dplan)
}
if dplan.Variables["DATABASE_URL"] != "postgres://u:p@h:5432/db" {
t.Fatalf("db vars = %+v", dplan.Variables)
}
dockerSvc := map[string]interface{}{
"id": "s3", "name": "img", "type": "docker",
"dockerDetails": map[string]interface{}{
"url": "ghrc://acme/web:v1",
"ports": []interface{}{
map[string]interface{}{"hostPort": float64(80), "containerPort": float64(3000), "scheme": "http"},
},
},
}
iplan, _ := dflowServicePlanOf(dockerSvc)
if iplan.Image != "ghcr.io/acme/web:v1" || iplan.Port != 3000 {
t.Fatalf("docker plan = %+v", iplan)
}
}
func TestDflowRepoURL(t *testing.T) {
cases := []struct {
provider, owner, repo, want string
}{
{"github", "acme", "api", "https://github.com/acme/api"},
{"gitlab", "acme", "api", "https://gitlab.com/acme/api"},
{"bitbucket", "acme", "api", "https://bitbucket.org/acme/api"},
{"gitea", "acme", "api", ""},
{"github", "acme", "api.git", "https://github.com/acme/api"},
{"github", "", "api", ""},
{"github", "acme", "", ""},
{"github", "acme", "https://git.example.com/a/b", "https://git.example.com/a/b"},
}
for _, c := range cases {
if got := dflowRepoURL(c.provider, c.owner, c.repo); got != c.want {
t.Errorf("dflowRepoURL(%q,%q,%q) = %q, want %q", c.provider, c.owner, c.repo, got, c.want)
}
}
}
+75 -1
View File
@@ -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)
}
+1
View File
@@ -94,6 +94,7 @@ func init() {
rootCmd.AddCommand(commands.BannersCmd) rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
rootCmd.AddCommand(commands.MigrateCmd)
} }
func initConfig() { func initConfig() {
@@ -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)
+3
View File
@@ -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;
+6
View File
@@ -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;
+2 -1
View File
@@ -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 (
+2
View File
@@ -26,6 +26,7 @@ import {
ComponentShowcase, ComponentShowcase,
GitHubAppCallbackPage, GitHubAppCallbackPage,
} from '@/features/secondary/pages'; } from '@/features/secondary/pages';
import { NodeDetailPage } from '@/features/nodes/NodeDetailPage';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages'; import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session'; import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode'; import { useDemoMode } from '@/lib/demo-mode';
@@ -168,6 +169,7 @@ export default function App() {
{/* Platform owner only */} {/* Platform owner only */}
<Route element={<AdminRequired />}> <Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/nodes/:id" element={<NodeDetailPage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
@@ -0,0 +1,393 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
deleteAgent,
getAgent,
getAgentMetrics,
listAgentCommands,
listAgentContainers,
pruneAgent,
updateAgent,
type AgentMetricPoint,
} from '@/lib/api-client';
import { getCurrentUserProfile } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { formatBytes, formatRelative } from '@/lib/time';
import { DemoRestricted } from '@/shared/components';
import {
ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle,
} from 'lucide-react';
const TIME_RANGES = [
{ label: '1h', value: '1h' },
{ label: '24h', value: '24h' },
{ label: '7d', value: '168h' },
];
export function NodeDetailPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
const [timeRange, setTimeRange] = useState('24h');
const [rename, setRename] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmVolumes, setConfirmVolumes] = useState(false);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
queryFn: () => getAgent(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const metricsQuery = useQuery({
queryKey: ['node-metrics', id, timeRange],
queryFn: () => getAgentMetrics(id, timeRange),
enabled: !isDemoMode && id !== '',
refetchInterval: 30000,
});
const containersQuery = useQuery({
queryKey: ['node-containers', id],
queryFn: () => listAgentContainers(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const commandsQuery = useQuery({
queryKey: ['node-commands', id],
queryFn: () => listAgentCommands(id),
enabled: !isDemoMode && id !== '',
refetchInterval: 15000,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['node-agent', id] });
queryClient.invalidateQueries({ queryKey: ['node-commands', id] });
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
};
const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body),
onSuccess: () => { setRename(null); invalidate(); },
});
const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
onSuccess: () => { setConfirmVolumes(false); invalidate(); },
});
const deleteMutation = useMutation({
mutationFn: () => deleteAgent(id),
onSuccess: () => navigate('/usage'),
});
const agent = agentQuery.data;
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
const containers = containersQuery.data ?? [];
const commands = commandsQuery.data ?? [];
if (isDemoMode) return <DemoRestricted feature="Nodes" />;
if (agentQuery.isLoading) {
return <div className="px-8 py-10 text-xs text-[var(--text-muted)]">Loading node…</div>;
}
if (!agent) {
return (
<div className="px-8 py-10">
<p className="text-sm text-[var(--text-secondary)]">Node not found.</p>
<Link to="/usage" className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">← back to usage</Link>
</div>
);
}
const online = agent.status === 'online' || agent.status === 'connecting';
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<div className="flex items-center gap-2 v-mono text-[11px] text-[var(--text-tertiary)] mb-2">
<Link to="/usage" className="hover:text-[var(--text-primary)] flex items-center gap-1">
<ArrowLeft size={12} /> usage
</Link>
<ChevronRight size={10} />
<span className="text-[var(--text-primary)]">{agent.name}</span>
</div>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="v-title">
{rename === null ? agent.name : rename}
{isAdmin && rename === null && (
<button
onClick={() => setRename(agent.name)}
className="ml-2 inline-block align-middle text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Rename node"
>
<Pencil size={14} />
</button>
)}
<span className="v-cursor">_</span>
</h1>
{rename !== null && (
<div className="mt-2 flex items-center gap-2">
<input
value={rename}
onChange={(e) => setRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ name: rename });
if (e.key === 'Escape') setRename(null);
}}
autoFocus
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]"
/>
<button onClick={() => updateMutation.mutate({ name: rename })} className="v-mono text-[11px] text-[var(--accent-primary)] hover:underline">save</button>
<button onClick={() => setRename(null)} className="v-mono text-[11px] text-[var(--text-muted)] hover:underline">cancel</button>
</div>
)}
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
{agent.hostname} · {agent.ipAddress}:{agent.port}
{agent.version ? ` · v${agent.version}` : ''}
{agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}
</p>
{meshAddresses(agent).length > 0 && (
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)]">
mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}
</p>
)}
</div>
<div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
? 'border-[var(--success)]/40 text-[var(--success)]'
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{isAdmin && (
<>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
<input
type="checkbox"
checked={agent.autoPrune}
onChange={(e) => updateMutation.mutate({ auto_prune: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
auto-prune
</label>
<button
onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
prune now
</button>
<button
onClick={() => setConfirmDelete(true)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--error)]/40 text-[var(--error)] hover:bg-[var(--error)]/10"
>
remove
</button>
</>
)}
</div>
</div>
</div>
</div>
<div className="px-8 py-6 space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
<Stat label="load 1m" value={latest ? latest.system_load.load_1m.toFixed(2) : '—'} sub={latest ? `5m ${latest.system_load.load_5m.toFixed(2)} · 15m ${latest.system_load.load_15m.toFixed(2)}` : ''} />
<Stat label="containers" value={latest ? String(latest.container_count) : String(containers.length)} sub="reported by agent" />
</div>
<section className="panel p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)]">telemetry</h2>
<div className="flex gap-1">
{TIME_RANGES.map((r) => (
<button
key={r.value}
onClick={() => setTimeRange(r.value)}
className={`v-mono text-[11px] px-2.5 py-1 rounded-md border ${timeRange === r.value
? 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'}`}
>
{r.label}
</button>
))}
</div>
</div>
{metricsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading metrics…</p>
) : metrics.length < 2 ? (
<p className="text-xs text-[var(--text-muted)]">Not enough heartbeat history yet — the agent reports every few seconds once connected.</p>
) : (
<div className="grid gap-6 md:grid-cols-2">
<Sparkline label="cpu %" points={metrics} value={(m) => m.cpu.usage_percent} />
<Sparkline label="memory %" points={metrics} value={(m) => m.memory.usage_percent} />
</div>
)}
</section>
<div className="grid gap-6 lg:grid-cols-2">
<section className="panel p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">containers</h2>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{containers.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No containers reported on this node.</p>
)}
{containers.map((c) => (
<div key={c.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-[var(--text-primary)]">{c.name ?? c.id}</p>
<p className="truncate v-mono text-[11px] text-[var(--text-tertiary)]">{c.image}</p>
</div>
<span className="v-mono text-[11px] text-[var(--text-secondary)]">{c.status ?? '—'}</span>
</div>
))}
</div>
</section>
<section className="panel p-0">
<h2 className="v-mono text-[11px] uppercase tracking-wider text-[var(--text-tertiary)] px-5 pt-4 pb-3">command history</h2>
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
{commands.length === 0 && (
<p className="px-5 py-6 text-xs text-[var(--text-muted)]">No commands sent to this node yet.</p>
)}
{commands.slice(0, 15).map((cmd) => (
<div key={cmd.id} className="flex items-center justify-between gap-3 px-5 py-3">
<div className="flex items-center gap-2 min-w-0">
{cmd.status === 'completed'
? <CheckCircle2 size={13} className="text-[var(--success)] shrink-0" />
: cmd.status === 'failed'
? <XCircle size={13} className="text-[var(--error)] shrink-0" />
: <Loader2 size={13} className="text-[var(--text-muted)] animate-spin shrink-0" />}
<span className="v-mono text-[11px] text-[var(--text-primary)]">{cmd.type}</span>
{cmd.error && <span className="truncate text-[11px] text-[var(--error)]">{cmd.error}</span>}
</div>
<span className="v-mono text-[11px] text-[var(--text-muted)] whitespace-nowrap">
{cmd.created_at ? formatRelative(cmd.created_at) : ''}
</span>
</div>
))}
</div>
</section>
</div>
</div>
{confirmVolumes && (
<ConfirmModal
title="Prune this node?"
body={`Runs a bounded docker system prune on ${agent.name}. Unused images, stopped containers, and networks are removed.`}
confirmLabel="prune"
danger={false}
extra={
<label className="flex items-center gap-2 text-xs text-[var(--text-secondary)] cursor-pointer">
<input type="checkbox" id="prune-volumes" className="accent-[var(--error)]" />
also prune volumes (can delete database data)
</label>
}
onCancel={() => setConfirmVolumes(false)}
onConfirm={() => {
const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false;
pruneMutation.mutate(volumes);
}}
pending={pruneMutation.isPending}
/>
)}
{confirmDelete && (
<ConfirmModal
title={`Remove ${agent.name}?`}
body="The agent record and its history are deleted. Containers running on the host are not stopped — uninstall the agent there separately."
confirmLabel="remove node"
danger
onCancel={() => setConfirmDelete(false)}
onConfirm={() => deleteMutation.mutate()}
pending={deleteMutation.isPending}
/>
)}
</div>
);
}
function meshAddresses(agent: { metadata: Record<string, unknown> }): [string, string][] {
const mesh = agent.metadata?.mesh;
if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return [];
return Object.entries(mesh as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '');
}
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="panel p-4">
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
<p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{value}</p>
{sub && <p className="mt-0.5 v-mono text-[11px] text-[var(--text-muted)]">{sub}</p>}
</div>
);
}
function Sparkline({ label, points, value }: {
label: string;
points: AgentMetricPoint[];
value: (m: AgentMetricPoint) => number;
}) {
const w = 560;
const h = 90;
const vals = points.map(value);
const max = Math.max(100, ...vals);
const step = w / Math.max(points.length - 1, 1);
const polyline = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 6)).toFixed(1)}`).join(' ');
return (
<div>
<p className="v-mono text-[10px] uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
{label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}%
</p>
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-24 rounded-md border border-[var(--border-subtle)] bg-[var(--bg-secondary)]">
<polyline points={polyline} fill="none" stroke="var(--accent-primary)" strokeWidth="1.5" />
</svg>
</div>
);
}
function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onConfirm, pending }: {
title: string;
body: string;
confirmLabel: string;
danger: boolean;
extra?: React.ReactNode;
onCancel: () => void;
onConfirm: () => void;
pending: boolean;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="panel w-full max-w-sm p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
<p className="mt-2 text-xs text-[var(--text-secondary)]">{body}</p>
{extra && <div className="mt-3">{extra}</div>}
<div className="mt-5 flex justify-end gap-2">
<button onClick={onCancel} className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)]">
cancel
</button>
<button
onClick={onConfirm}
disabled={pending}
className={`v-mono text-[11px] px-3 py-1.5 rounded-md border disabled:opacity-50 ${danger
? 'border-[var(--error)]/50 text-[var(--error)] hover:bg-[var(--error)]/10'
: 'border-[var(--accent-primary)]/50 text-[var(--accent-primary)] hover:bg-[var(--accent-primary)]/10'}`}
>
{pending ? 'working…' : confirmLabel}
</button>
</div>
</div>
</div>
);
}
+39 -5
View File
@@ -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.';
@@ -603,14 +613,37 @@ containr-agent`;
agents.slice(0, 5).map((agent) => ( agents.slice(0, 5).map((agent) => (
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"> <div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p> <Link to={`/nodes/${agent.id}`} className="truncate text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)]">{agent.name}</Link>
<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>
@@ -644,6 +644,11 @@ export function TemplatesPage() {
Public Public
</span> </span>
) : null} ) : null}
{template.source === 'registry' ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] text-[10px] font-semibold">
Registry
</span>
) : null}
</div> </div>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span <span
+159
View File
@@ -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;
@@ -10943,6 +11094,8 @@ export interface components {
owner_id?: string | null; owner_id?: string | null;
/** @description Whether a user-created template is published to the public catalog */ /** @description Whether a user-created template is published to the public catalog */
is_public?: boolean; is_public?: boolean;
/** @description Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first. */
source?: string;
/** /**
* Format: date-time * Format: date-time
* @description Creation timestamp * @description Creation timestamp
@@ -11749,6 +11902,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 */
@@ -11873,6 +12028,10 @@ export interface components {
capabilities: components["schemas"]["AgentCapabilities"]; capabilities: components["schemas"]["AgentCapabilities"];
/** @description May also be sent via the X-Agent-Token header */ /** @description May also be sent via the X-Agent-Token header */
auth_token?: string; auth_token?: string;
/** @description Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist. */
mesh?: {
[key: string]: string;
};
}; };
AgentHeartbeat: { AgentHeartbeat: {
agent_id?: string; agent_id?: string;
+1 -1
View File
@@ -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\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|anLine truncated
}, },
{ {
"path": "guides/CLOUDFLARE_SETUP.md", "path": "guides/CLOUDFLARE_SETUP.md",
+74
View File
@@ -181,6 +181,7 @@ export type TemplateEntity = {
isOfficial: boolean; isOfficial: boolean;
ownerId: string | null; ownerId: string | null;
isPublic: boolean; isPublic: boolean;
source?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
}; };
@@ -341,6 +342,8 @@ export type NodeAgentEntity = {
version: string; version: string;
capabilities: AgentCapabilities; capabilities: AgentCapabilities;
resources: NodeResources; resources: NodeResources;
autoPrune: boolean;
metadata: Record<string, unknown>;
lastHeartbeat?: string; lastHeartbeat?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -749,6 +752,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
isOfficial: Boolean(template.is_official), isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null, ownerId: template.owner_id ?? null,
isPublic: Boolean(template.is_public), isPublic: Boolean(template.is_public),
source: template.source,
createdAt: template.created_at, createdAt: template.created_at,
updatedAt: template.updated_at, updatedAt: template.updated_at,
}; };
@@ -1076,6 +1080,8 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
}, },
}, },
lastHeartbeat: agent.last_heartbeat, lastHeartbeat: agent.last_heartbeat,
autoPrune: agent.auto_prune ?? false,
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at, createdAt: agent.created_at,
updatedAt: agent.updated_at, updatedAt: agent.updated_at,
}; };
@@ -1114,6 +1120,74 @@ 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 async function getAgent(id: string): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
return payload.agent ? normalizeAgent(payload.agent) : null;
}
export async function deleteAgent(id: string): Promise<void> {
await requestJson(`/agents/${id}`, { method: 'DELETE' });
}
export type AgentMetricPoint = {
timestamp: string;
cpu: { usage: number; usage_percent: number; cores: number };
memory: { usage: number; usage_percent: number; limit: number; available: number };
system_load: { load_1m: number; load_5m: number; load_15m: number };
container_count: number;
};
export async function getAgentMetrics(id: string, timeRange = '24h'): Promise<AgentMetricPoint[]> {
const payload = await requestJson<{ metrics?: AgentMetricPoint[] }>(
`/agents/${id}/metrics?time_range=${encodeURIComponent(timeRange)}`,
);
return payload.metrics ?? [];
}
export type AgentContainer = {
id?: string;
name?: string;
image?: string;
status?: string;
service_id?: string;
created_at?: string;
};
export async function listAgentContainers(id: string): Promise<AgentContainer[]> {
const payload = await requestJson<{ containers?: AgentContainer[] }>(`/agents/${id}/containers`);
return payload.containers ?? [];
}
export type AgentCommand = {
id?: string;
type?: string;
status?: string;
result?: string;
error?: string;
created_at?: string;
completed_at?: string;
};
export async function listAgentCommands(id: string): Promise<AgentCommand[]> {
const payload = await requestJson<{ commands?: AgentCommand[] }>(`/agents/${id}/commands`);
return payload.commands ?? [];
}
export type AgentAuthToken = components['schemas']['AgentAuthToken']; export type AgentAuthToken = components['schemas']['AgentAuthToken'];
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated']; export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
+98
View File
@@ -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:
@@ -7393,6 +7480,9 @@ components:
is_public: is_public:
type: boolean type: boolean
description: Whether a user-created template is published to the public catalog description: Whether a user-created template is published to the public catalog
source:
type: string
description: 'Catalog origin: absent for local/official templates; "registry" for entries merged from CONTAINR_TEMPLATE_REGISTRY_URL. Deploying a registry template materializes a local copy first.'
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -8807,6 +8897,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
@@ -9040,6 +9133,11 @@ components:
auth_token: auth_token:
type: string type: string
description: May also be sent via the X-Agent-Token header description: May also be sent via the X-Agent-Token header
mesh:
type: object
additionalProperties:
type: string
description: Overlay-network interface IPs (tailscale/netbird/zerotier/wireguard). Stored under agent metadata.mesh; authoritative — send an empty object when no mesh interfaces exist.
AgentHeartbeat: AgentHeartbeat:
type: object type: object
+4
View File
@@ -72,6 +72,10 @@ 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
migrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan
migrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan
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