Files
Containr/app/backend/internal/cli/commands/nodes.go
T
Tomáš DvořákandDevin bc93c83cf9 feat(scheduling): multi-node service placement + remote lifecycle (#54)
* feat(scheduling): multi-node service placement + remote container lifecycle

services.node_id pins a service to a node agent: explicit id, "auto"
(least-loaded online node), or "local"/empty for the local Docker host.

- DeploymentEngine gains a NodeRunner seam; ReconcileService routes
  pinned services to the agent's command queue (create_container with
  image/env/command/ports/volumes/labels/resource limits), tracked in
  container_instances with deterministic per-replica ids.
- Stop/start/restart/delete fan out to the pinned node — no more leaked
  remote containers on service delete.
- Agent create_container is idempotent (rm -f first) and honors restart
  policy, labels, memory/cpus, published+ephemeral ports, volumes, env.
- GET /nodes/options exposes id/name/status to authed users; full agent
  management stays admin-only.
- CLI services create/update --node, MCP node_id docs, OpenAPI
  node_id/node_name + /nodes/options, service-detail Placement section.

Ceilings: remote nodes run registry-pulled images only — git builds,
Traefik domains, and remote sleep detection stay local until
build-on-node and per-node ingress land.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(scheduling): node cordon/drain + schedulable gate

- node_agents.schedulable column; "auto" placement and explicit pins skip
  cordoned nodes; already-pinned services keep running (reconcile is
  unaffected — cordon is not eviction).
- POST /agents/:id/cordon|uncordon|drain (admin). Drain cordons, removes
  every service container via the agent queue, and clears the services'
  pins so they redeploy on the local host. Requires the node online —
  an offline node can't acknowledge removals.
- CLI nodes cordon|uncordon|drain (drain confirm-gated), MCP
  containr_nodes_cordon|uncordon|drain, OpenAPI paths + schedulable
  field, node-detail page cordon badge + drain/cordon controls.
- Fix: re-registering an agent no longer resets auto_prune.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(scheduling): remote registry pulls + post-run health check

- Dispatch payload carries the project owner's registry credentials for
  the image host; the agent docker-logins for the pull and logs out
  after, so private images deploy to remote nodes.
- Completed command payloads get their registry password scrubbed —
  secrets don't linger in agent_commands rows.
- Agent verifies the container is still running ~1.5s after docker run;
  crash-loops now fail the deployment with the container's last log
  lines instead of reporting "running" optimistically.

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 16:19:12 +02:00

282 lines
7.0 KiB
Go

package commands
import (
"fmt"
"github.com/spf13/cobra"
)
// NodesCmd manages node agents (admin).
var NodesCmd = &cobra.Command{
Use: "nodes",
Aliases: []string{"node", "agents"},
Short: "Manage node agents (admin)",
}
var nodesListCmd = &cobra.Command{
Use: "list",
Short: "List registered node agents",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "agents")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, a := range items {
rows = append(rows, []string{
str(a, "id"), str(a, "name", "hostname"), str(a, "status"), str(a, "version"), relTime(a, "last_heartbeat_at"),
})
}
printRows(data, []string{"ID", "NAME", "STATUS", "VERSION", "HEARTBEAT"}, rows)
return nil
},
}
var nodesGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Show 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], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Remove a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Remove node agent %s?", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
if _, err := c.Do("DELETE", "/agents/"+args[0], nil); err != nil {
return err
}
if JSONMode() {
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
}
fmt.Printf("Removed agent %s\n", args[0])
return nil
},
}
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 nodesCordonCmd = &cobra.Command{
Use: "cordon <id>",
Short: "Stop new placements on a node (running replicas stay)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/agents/"+args[0]+"/cordon", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesUncordonCmd = &cobra.Command{
Use: "uncordon <id>",
Short: "Re-open a node for placement",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/agents/"+args[0]+"/uncordon", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesDrainCmd = &cobra.Command{
Use: "drain <id>",
Short: "Cordon a node, remove its service containers, unpin affected services",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
if !Confirm(fmt.Sprintf("Drain node %s? Its service containers are removed and services unpinned (they redeploy locally).", args[0])) {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
data, err := c.Do("POST", "/agents/"+args[0]+"/drain", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodesCommandsCmd = &cobra.Command{
Use: "commands <id>",
Short: "List recent commands sent to a node agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agents/"+args[0]+"/commands", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// Agent tokens (cagt_) onboard new nodes.
var nodeTokensCmd = &cobra.Command{
Use: "tokens",
Short: "Manage agent onboarding tokens",
}
var nodeTokensIssueCmd = &cobra.Command{
Use: "issue [label]",
Short: "Issue an onboarding token (shown once)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
body := map[string]interface{}{}
if len(args) > 0 {
body["label"] = args[0]
}
data, err := c.Do("POST", "/agent-tokens", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensListCmd = &cobra.Command{
Use: "list",
Short: "List onboarding tokens",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/agent-tokens", nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var nodeTokensRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an onboarding token",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("DELETE", "/agent-tokens/"+args[0], nil)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
NodesCmd.AddCommand(nodesListCmd, nodesGetCmd, nodesUpdateCmd, nodesCordonCmd, nodesUncordonCmd, nodesDrainCmd, nodesPruneCmd, nodesCommandsCmd, nodesDeleteCmd, nodeTokensCmd)
}