mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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>
This commit is contained in:
1 parent
401daf69aa
commit
bc93c83cf9
28 files changed
+1565
-48
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -39,6 +40,7 @@ type NodeAgent struct {
|
||||
Resources NodeResources `json:"resources"`
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||
AutoPrune bool `json:"auto_prune"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
@@ -228,17 +230,38 @@ func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListNodeOptions returns the minimal id/name/status list used by the service
|
||||
// placement picker — available to any authenticated user (no hostnames, IPs,
|
||||
// or telemetry).
|
||||
func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
|
||||
rows, err := h.q.ListAgents(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list nodes"})
|
||||
return
|
||||
}
|
||||
options := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
options = append(options, gin.H{
|
||||
"id": row.ID,
|
||||
"name": row.Name,
|
||||
"status": row.Status.String,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"nodes": options})
|
||||
}
|
||||
|
||||
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
||||
agent := NodeAgent{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Hostname: row.Hostname,
|
||||
IPAddress: row.IpAddress,
|
||||
Port: int(row.Port),
|
||||
Status: row.Status.String,
|
||||
Version: row.Version.String,
|
||||
AutoPrune: row.AutoPrune,
|
||||
Metadata: map[string]interface{}{},
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Hostname: row.Hostname,
|
||||
IPAddress: row.IpAddress,
|
||||
Port: int(row.Port),
|
||||
Status: row.Status.String,
|
||||
Version: row.Version.String,
|
||||
AutoPrune: row.AutoPrune,
|
||||
Schedulable: row.Schedulable,
|
||||
Metadata: map[string]interface{}{},
|
||||
}
|
||||
if row.LastHeartbeat.Valid {
|
||||
agent.LastHeartbeat = row.LastHeartbeat.Time
|
||||
@@ -380,6 +403,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
Resources: existing.Resources,
|
||||
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
Metadata: rawJSON(metadata),
|
||||
AutoPrune: existing.AutoPrune,
|
||||
Schedulable: existing.Schedulable,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
@@ -511,6 +536,7 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
||||
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||
Metadata: rawJSON(agent.Metadata),
|
||||
AutoPrune: agent.AutoPrune,
|
||||
Schedulable: agent.Schedulable,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||
@@ -539,6 +565,7 @@ func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
|
||||
remarshal("resources", &agent.Resources)
|
||||
remarshal("metadata", &agent.Metadata)
|
||||
remarshal("auto_prune", &agent.AutoPrune)
|
||||
remarshal("schedulable", &agent.Schedulable)
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent
|
||||
@@ -673,9 +700,40 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
h.updateContainerStatusAfterCommand(ctx, out)
|
||||
}
|
||||
|
||||
// Command payloads can carry registry credentials — scrub them once the
|
||||
// command is settled so secrets don't linger in the queue rows.
|
||||
scrubCommandSecrets(ctx, h.q, commandID, command.Payload)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": out})
|
||||
}
|
||||
|
||||
// scrubCommandSecrets rewrites a completed command's payload with any
|
||||
// registry password blanked. Best-effort — a failed scrub leaves the row.
|
||||
func scrubCommandSecrets(ctx context.Context, q *sqlcdb.Queries, commandID string, payload pqtype.NullRawMessage) {
|
||||
if !payload.Valid || len(payload.RawMessage) == 0 {
|
||||
return
|
||||
}
|
||||
var doc map[string]interface{}
|
||||
if err := json.Unmarshal(payload.RawMessage, &doc); err != nil {
|
||||
return
|
||||
}
|
||||
container, ok := doc["container"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reg, ok := container["registry"].(map[string]interface{})
|
||||
if !ok || reg["password"] == nil {
|
||||
return
|
||||
}
|
||||
reg["password"] = "***"
|
||||
if err := q.ScrubCommandPayload(ctx, sqlcdb.ScrubCommandPayloadParams{
|
||||
ID: commandID,
|
||||
Payload: rawJSON(doc),
|
||||
}); err != nil {
|
||||
log.Printf("agents: scrub command %s payload: %v", commandID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
|
||||
row, err := h.q.GetContainer(ctx, *command.ContainerID)
|
||||
if err != nil {
|
||||
@@ -1329,9 +1387,94 @@ func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
|
||||
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
|
||||
|
||||
agents.GET("/:id/metrics", h.GetAgentMetrics)
|
||||
agents.POST("/:id/cordon", h.CordonAgent)
|
||||
agents.POST("/:id/uncordon", h.UncordonAgent)
|
||||
agents.POST("/:id/drain", h.DrainAgent)
|
||||
agents.POST("/:id/prune", h.PruneAgent)
|
||||
agents.POST("/:id/commands", h.ExecuteCommand)
|
||||
agents.GET("/:id/commands", h.GetAgentCommands)
|
||||
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// setSchedulable flips the node's scheduling gate. Cordoned nodes keep
|
||||
// running their placed replicas but take no new pins or `auto` placements.
|
||||
func (h *NodeAgentHandler) setSchedulable(c *gin.Context, schedulable bool) {
|
||||
id := c.Param("id")
|
||||
if _, err := h.q.GetAgent(c.Request.Context(), id); err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
|
||||
return
|
||||
}
|
||||
if err := h.q.SetAgentSchedulable(c.Request.Context(), sqlcdb.SetAgentSchedulableParams{
|
||||
ID: id, Schedulable: schedulable,
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update node")
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "node", id, map[bool]string{true: "uncordon", false: "cordon"}[schedulable], nil)
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "schedulable": schedulable})
|
||||
}
|
||||
|
||||
// CordonAgent blocks new placements on the node (explicit pins and `auto`).
|
||||
func (h *NodeAgentHandler) CordonAgent(c *gin.Context) { h.setSchedulable(c, false) }
|
||||
|
||||
// UncordonAgent re-opens the node for placement.
|
||||
func (h *NodeAgentHandler) UncordonAgent(c *gin.Context) { h.setSchedulable(c, true) }
|
||||
|
||||
// DrainAgent cordons the node, removes every service container via the agent
|
||||
// command queue, and clears the affected services' pins — they fall back to
|
||||
// the local host on next deploy and can be re-pinned to another node.
|
||||
// Requires the node to be online; an offline node can't acknowledge the
|
||||
// removals.
|
||||
func (h *NodeAgentHandler) DrainAgent(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id := c.Param("id")
|
||||
agent, err := h.q.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Agent not found")
|
||||
return
|
||||
}
|
||||
if agent.Status.String != "online" {
|
||||
respondError(c, http.StatusConflict, "NODE_OFFLINE",
|
||||
"node is not online — reconnect it, or remove the containers manually")
|
||||
return
|
||||
}
|
||||
if err := h.q.SetAgentSchedulable(ctx, sqlcdb.SetAgentSchedulableParams{
|
||||
ID: id, Schedulable: false,
|
||||
}); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to cordon node")
|
||||
return
|
||||
}
|
||||
|
||||
runner := &agentNodeRunner{q: h.q}
|
||||
rows, err := h.q.ListContainersForAgent(ctx, id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list node containers")
|
||||
return
|
||||
}
|
||||
services := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
services[row.ServiceID] = true
|
||||
}
|
||||
var drained int
|
||||
var firstErr error
|
||||
for serviceID := range services {
|
||||
if err := runner.RemoveService(ctx, serviceID, id); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
drained += 1
|
||||
}
|
||||
unpinned, _ := h.q.ClearServiceNodePins(ctx, sql.NullString{String: id, Valid: true})
|
||||
if firstErr != nil {
|
||||
respondError(c, http.StatusBadGateway, "DRAIN_PARTIAL",
|
||||
fmt.Sprintf("drained %d service(s), unpinned %d — one failed: %v", drained, unpinned, firstErr))
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "node", id, "drain", map[string]interface{}{
|
||||
"services": drained, "unpinned": unpinned,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "schedulable": false, "services_drained": drained, "services_unpinned": unpinned})
|
||||
}
|
||||
@@ -421,6 +421,20 @@ func runDeploymentAndSyncWithImage(
|
||||
maintURL = maintenanceURL(db)
|
||||
}
|
||||
|
||||
// Node-pinned services dispatch to the agent's command queue. Builds run
|
||||
// on the local host, so remote placement only works for registry-pulled
|
||||
// images — git-sourced and rollback deploys fail fast with that message.
|
||||
nodeID := serviceNodeID(db, service.ID)
|
||||
if nodeID != "" && (service.GitRepo != "" || imageOverride != "") {
|
||||
failedAt := time.Now()
|
||||
msg := "service is pinned to a remote node but remote nodes only run registry-pulled images (git builds and rollbacks stay local — build-on-node is not yet supported)"
|
||||
_, _ = db.Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
msg, failedAt, dbDeployment.ID,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
deployReq := &deployment.DeploymentRequest{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
@@ -447,6 +461,7 @@ func runDeploymentAndSyncWithImage(
|
||||
Maintenance: maintenanceMode,
|
||||
MaintenanceURL: maintURL,
|
||||
BasicAuthUsers: basicAuthUsers,
|
||||
NodeID: nodeID,
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
)
|
||||
|
||||
// agentNodeRunner implements deployment.NodeRunner by dispatching container
|
||||
// lifecycle commands to a node agent's command queue and waiting for the
|
||||
// reported result. Remote nodes have no Traefik — domains stay local-only
|
||||
// until per-node ingress lands.
|
||||
type agentNodeRunner struct {
|
||||
q *sqlcdb.Queries
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func newAgentNodeRunner(db *database.DB) *agentNodeRunner {
|
||||
return &agentNodeRunner{q: sqlcdb.New(db.DB), db: db}
|
||||
}
|
||||
|
||||
// remoteContainerID is deterministic per (agent, service, replica) so redeploys
|
||||
// update the same inventory row instead of accumulating stale ones.
|
||||
func remoteContainerID(agentID, serviceID string, replica int) string {
|
||||
sum := sha256.Sum256([]byte(agentID + "|" + serviceID + "|" + fmt.Sprint(replica)))
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
|
||||
}
|
||||
|
||||
func remoteContainerName(serviceID string, replica int) string {
|
||||
return fmt.Sprintf("containr-%s-%d", serviceID, replica)
|
||||
}
|
||||
|
||||
func (r *agentNodeRunner) ReconcileOnNode(ctx context.Context, spec deployment.RuntimeSpec) (*deployment.RuntimeState, error) {
|
||||
agent, err := r.q.GetAgent(ctx, spec.NodeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node %s not found", spec.NodeID)
|
||||
}
|
||||
if !agent.Status.Valid || agent.Status.String != "online" {
|
||||
return nil, fmt.Errorf("node %s is %s — heartbeats resume scheduling when it reconnects", agent.Name, agent.Status.String)
|
||||
}
|
||||
if len(spec.Domains) > 0 || spec.Domain != "" {
|
||||
return nil, fmt.Errorf("domains are routed by the local Traefik — remove the service's domains or run it on the local node")
|
||||
}
|
||||
|
||||
replicas := spec.Replicas
|
||||
if replicas < 1 {
|
||||
replicas = 1
|
||||
}
|
||||
|
||||
// Retire replicas above the desired count using inventory rows.
|
||||
prefix := fmt.Sprintf("containr-%s-", spec.ServiceID)
|
||||
existing, _ := r.q.ListContainersForAgent(ctx, spec.NodeID)
|
||||
for _, row := range existing {
|
||||
if row.ServiceID != spec.ServiceID || !strings.HasPrefix(row.Name, prefix) {
|
||||
continue
|
||||
}
|
||||
replica, err := strconv.Atoi(strings.TrimPrefix(row.Name, prefix))
|
||||
if err == nil && replica >= replicas {
|
||||
if cmdErr := r.enqueueAndWait(ctx, spec.NodeID, "", "remove_container",
|
||||
map[string]interface{}{"container_name": row.Name}); cmdErr != nil {
|
||||
return nil, fmt.Errorf("remove stale replica %s: %w", row.Name, cmdErr)
|
||||
}
|
||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: row.ID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "removed"}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
state := &deployment.RuntimeState{Desired: replicas, Status: "running"}
|
||||
for i := 0; i < replicas; i++ {
|
||||
name := remoteContainerName(spec.ServiceID, i)
|
||||
containerID := remoteContainerID(spec.NodeID, spec.ServiceID, i)
|
||||
|
||||
ports := []interface{}{}
|
||||
if spec.Port > 0 {
|
||||
hostPort := spec.PublishedPort // 0 → ephemeral on the node
|
||||
if i > 0 {
|
||||
hostPort = 0 // only replica 0 claims the preferred host port
|
||||
}
|
||||
ports = append(ports, map[string]interface{}{
|
||||
"published": true,
|
||||
"host_port": hostPort,
|
||||
"container_port": spec.Port,
|
||||
"protocol": "tcp",
|
||||
})
|
||||
}
|
||||
volumes := make([]interface{}, 0, len(spec.Volumes))
|
||||
for _, v := range spec.Volumes {
|
||||
volumes = append(volumes, map[string]interface{}{
|
||||
"source": v.Source, "target": v.Destination, "read_only": v.ReadOnly,
|
||||
})
|
||||
}
|
||||
env := map[string]interface{}{}
|
||||
for k, v := range spec.Env {
|
||||
env[k] = v
|
||||
}
|
||||
restart := spec.RestartPolicy
|
||||
if restart == "" {
|
||||
restart = "unless-stopped"
|
||||
}
|
||||
cmd := make([]interface{}, 0, len(spec.Command))
|
||||
for _, arg := range spec.Command {
|
||||
cmd = append(cmd, arg)
|
||||
}
|
||||
container := map[string]interface{}{
|
||||
"name": name,
|
||||
"image": spec.Image,
|
||||
"command": cmd,
|
||||
"environment": env,
|
||||
"registry": r.pullCredentials(spec.ProjectID, spec.Image),
|
||||
"ports": ports,
|
||||
"volumes": volumes,
|
||||
"restart_policy": restart,
|
||||
"memory": spec.MemoryBytes,
|
||||
"cpus": float64(spec.NanoCPUs) / 1e9,
|
||||
"labels": map[string]interface{}{
|
||||
"containr.managed": "true",
|
||||
"containr.project": spec.ProjectID,
|
||||
"containr.service": spec.ServiceID,
|
||||
"containr.replica": fmt.Sprint(i),
|
||||
"containr.node": spec.NodeID,
|
||||
},
|
||||
}
|
||||
|
||||
_ = r.q.UpsertServiceContainer(ctx, sqlcdb.UpsertServiceContainerParams{
|
||||
ID: containerID,
|
||||
Name: name,
|
||||
Image: spec.Image,
|
||||
ProjectID: spec.ProjectID,
|
||||
ServiceID: spec.ServiceID,
|
||||
NodeAgentID: spec.NodeID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "created", "health": "none"}),
|
||||
Resources: rawJSON(map[string]interface{}{"memory": spec.MemoryBytes, "cpus": float64(spec.NanoCPUs) / 1e9}),
|
||||
Ports: rawJSON(ports),
|
||||
Environment: rawJSON(env),
|
||||
Volumes: rawJSON(volumes),
|
||||
Networks: rawJSON([]string{}),
|
||||
RestartPolicy: rawJSON(map[string]interface{}{"name": restart}),
|
||||
HealthCheck: rawJSON(map[string]interface{}{"path": spec.HealthPath}),
|
||||
})
|
||||
|
||||
if err := r.enqueueAndWait(ctx, spec.NodeID, containerID, "create_container",
|
||||
map[string]interface{}{"container": container}); err != nil {
|
||||
return nil, fmt.Errorf("replica %d on %s: %w", i, agent.Name, err)
|
||||
}
|
||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: containerID,
|
||||
Status: rawJSON(map[string]interface{}{"state": "running", "health": "none"}),
|
||||
})
|
||||
state.Containers = append(state.Containers, deployment.RuntimeContainer{
|
||||
ID: containerID, Name: name, State: "running", Replica: i,
|
||||
})
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// pullCredentials resolves the project owner's registry auth for the image
|
||||
// host so private images pull on remote nodes. Empty map → anonymous pull.
|
||||
func (r *agentNodeRunner) pullCredentials(projectID, imageRef string) map[string]interface{} {
|
||||
var ownerID string
|
||||
if err := r.db.QueryRow(
|
||||
`SELECT owner_id FROM projects WHERE id = $1`, projectID,
|
||||
).Scan(&ownerID); err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
auth := registryAuthFor(r.db, ownerID, imageRef)
|
||||
if auth.Username == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"server": auth.ServerAddress,
|
||||
"username": auth.Username,
|
||||
"password": auth.Password,
|
||||
}
|
||||
}
|
||||
|
||||
// ControlService fans a lifecycle action out to every replica row the
|
||||
// service has on the agent. Actions map onto the agent's container commands;
|
||||
// "remove" also tombstones the inventory row.
|
||||
func (r *agentNodeRunner) ControlService(ctx context.Context, serviceID, agentID, action string) error {
|
||||
cmdType := map[string]string{
|
||||
"start": "start_container",
|
||||
"stop": "stop_container",
|
||||
"restart": "restart_container",
|
||||
"remove": "remove_container",
|
||||
}[action]
|
||||
if cmdType == "" {
|
||||
return fmt.Errorf("unsupported action %q", action)
|
||||
}
|
||||
rows, err := r.q.ListContainersForAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed := map[string]interface{}{"state": "removed"}
|
||||
for _, row := range rows {
|
||||
if row.ServiceID != serviceID {
|
||||
continue
|
||||
}
|
||||
if err := r.enqueueAndWait(ctx, agentID, row.ID, cmdType,
|
||||
map[string]interface{}{"container_name": row.Name}); err != nil {
|
||||
return fmt.Errorf("%s %s on node: %w", action, row.Name, err)
|
||||
}
|
||||
if action == "remove" {
|
||||
_ = r.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||
ID: row.ID, Status: rawJSON(removed)})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveService tears down every replica of the service on the agent and
|
||||
// clears the inventory rows.
|
||||
func (r *agentNodeRunner) RemoveService(ctx context.Context, serviceID, agentID string) error {
|
||||
if err := r.ControlService(ctx, serviceID, agentID, "remove"); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.q.DeleteServiceContainersOnAgent(ctx, sqlcdb.DeleteServiceContainersOnAgentParams{
|
||||
NodeAgentID: agentID,
|
||||
ServiceID: serviceID,
|
||||
})
|
||||
}
|
||||
|
||||
// enqueueAndWait pushes a command to the agent's queue and polls for the
|
||||
// reported result. Agents poll on their own interval (≥5s), so this blocks —
|
||||
// it runs inside the deploy queue worker, never on a request path.
|
||||
func (r *agentNodeRunner) enqueueAndWait(ctx context.Context, agentID, containerID, cmdType string, payload map[string]interface{}) error {
|
||||
var cid sql.NullString
|
||||
if containerID != "" {
|
||||
cid = sql.NullString{String: containerID, Valid: true}
|
||||
}
|
||||
cmd, err := r.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||
ID: uuid.New().String(),
|
||||
Type: cmdType,
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: cid,
|
||||
Payload: rawJSON(payload),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(8 * time.Minute) // image pulls on a cold node
|
||||
for {
|
||||
row, err := r.q.GetCommandForAgent(ctx, sqlcdb.GetCommandForAgentParams{
|
||||
ID: cmd.ID,
|
||||
NodeAgentID: agentID,
|
||||
})
|
||||
if err == nil {
|
||||
switch row.Status.String {
|
||||
case "completed":
|
||||
return nil
|
||||
case "failed":
|
||||
if row.Error.Valid && row.Error.String != "" {
|
||||
return fmt.Errorf("agent: %s", row.Error.String)
|
||||
}
|
||||
return fmt.Errorf("agent reported failure")
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("command %s timed out waiting for the agent", cmdType)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveNodePin validates or resolves a node_id request value. "auto" picks
|
||||
// the least-loaded online agent; "" / "local" clears the pin.
|
||||
func resolveNodePin(ctx context.Context, db *database.DB, requested string) (string, error) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if requested == "" || requested == "local" {
|
||||
return "", nil
|
||||
}
|
||||
q := sqlcdb.New(db.DB)
|
||||
if requested == "auto" {
|
||||
id, err := q.PickLeastLoadedAgent(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no online nodes available")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
agent, err := q.GetAgent(ctx, requested)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("node not found")
|
||||
}
|
||||
if !agent.Schedulable {
|
||||
return "", fmt.Errorf("node %s is cordoned — uncordon it before pinning", agent.Name)
|
||||
}
|
||||
return requested, nil
|
||||
}
|
||||
@@ -43,6 +43,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
dockerClient = client
|
||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||
// Node-pinned services dispatch container lifecycle to the agent's
|
||||
// command queue instead of the local Docker socket.
|
||||
deploymentEngine.SetNodeRunner(newAgentNodeRunner(db))
|
||||
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||
syncManagedContainers(dockerClient, db)
|
||||
}
|
||||
@@ -355,6 +358,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
|
||||
// Cross-cutting ops view — active/queued/failed work
|
||||
authed.GET("/operations", handleGetOperations)
|
||||
authed.GET("/nodes/options", agentHandler.ListNodeOptions)
|
||||
authed.GET("/activity", handleGetActivity)
|
||||
authed.GET("/projects/:id/activity", handleGetProjectActivity)
|
||||
|
||||
|
||||
@@ -18,6 +18,38 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// serviceLifecycle routes a start/stop/restart action to the node agent when
|
||||
// the service is pinned, or to the local Docker engine otherwise. Remote
|
||||
// actions block on the agent's poll loop; that matches the synchronous
|
||||
// behavior the local path already has.
|
||||
func serviceLifecycle(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID, action string) error {
|
||||
if nodeID := serviceNodeID(db, serviceID); nodeID != "" {
|
||||
return engine.ControlServiceOnNode(ctx, serviceID.String(), nodeID, action)
|
||||
}
|
||||
switch action {
|
||||
case "start":
|
||||
return engine.StartService(ctx, serviceID.String())
|
||||
case "stop":
|
||||
return engine.StopService(ctx, serviceID.String())
|
||||
case "restart":
|
||||
if err := engine.StopService(ctx, serviceID.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return engine.StartService(ctx, serviceID.String())
|
||||
}
|
||||
return fmt.Errorf("unsupported lifecycle action %q", action)
|
||||
}
|
||||
|
||||
// removeServiceRuntime tears down replicas wherever they run — remote node
|
||||
// when pinned, local Docker otherwise.
|
||||
func removeServiceRuntime(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, serviceID uuid.UUID) {
|
||||
if nodeID := serviceNodeID(db, serviceID); nodeID != "" {
|
||||
_ = engine.RemoveServiceContainersOnNode(ctx, serviceID.String(), nodeID)
|
||||
return
|
||||
}
|
||||
_ = engine.RemoveServiceContainers(ctx, serviceID.String())
|
||||
}
|
||||
|
||||
// serviceRuntimeSpec builds the container spec for a service: stored env vars
|
||||
// merged with ${{Service.KEY}} references resolved across the project.
|
||||
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
|
||||
@@ -37,6 +69,7 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
NodeID: serviceNodeID(db, service.ID),
|
||||
}
|
||||
// Best effort: reuse the host port from the last live deployment so the
|
||||
// public URL survives restarts. Column exists post-migration; older DBs
|
||||
@@ -384,7 +417,7 @@ func handleServiceStart(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "start"); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -402,7 +435,7 @@ func handleServiceStop(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
if err := serviceLifecycle(c.Request.Context(), db, engine, service.ID, "stop"); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -421,11 +454,7 @@ func handleServiceRestart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := engine.StopService(ctx, service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(ctx, service.ID.String()); err != nil {
|
||||
if err := serviceLifecycle(ctx, db, engine, service.ID, "restart"); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -48,8 +49,12 @@ type Service struct {
|
||||
StaticDir string `json:"static_dir,omitempty" db:"static_dir"`
|
||||
SleepEnabled bool `json:"sleep_enabled" db:"-"`
|
||||
SleepIdleMinutes int `json:"sleep_idle_minutes" db:"-"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
// NodeID pins the service to a node agent ("" = local Docker host,
|
||||
// resolved "auto" picks the least-loaded online agent at write time).
|
||||
NodeID string `json:"node_id,omitempty" db:"-"`
|
||||
NodeName string `json:"node_name,omitempty" db:"-"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// loadServiceSleep fills the sleep-mode columns, which live outside the
|
||||
@@ -64,6 +69,28 @@ func loadServiceSleep(db *database.DB, s *Service) {
|
||||
}
|
||||
}
|
||||
|
||||
// serviceNodeID reads the pin column directly — kept out of the wide service
|
||||
// SELECTs so pre-migration schemas keep working.
|
||||
func serviceNodeID(db *database.DB, serviceID uuid.UUID) string {
|
||||
var id sql.NullString
|
||||
if err := db.QueryRow(`SELECT node_id FROM services WHERE id = $1`, serviceID).Scan(&id); err == nil {
|
||||
return id.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadServiceNode fills the pin + the agent's display name for responses.
|
||||
func loadServiceNode(db *database.DB, s *Service) {
|
||||
var name sql.NullString
|
||||
s.NodeID = serviceNodeID(db, s.ID)
|
||||
if s.NodeID == "" {
|
||||
return
|
||||
}
|
||||
if err := db.QueryRow(`SELECT name FROM node_agents WHERE id = $1`, s.NodeID).Scan(&name); err == nil {
|
||||
s.NodeName = name.String
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceVolume is a volume/bind mount applied to every replica.
|
||||
type ServiceVolume struct {
|
||||
Type string `json:"type"` // volume | bind (empty = volume)
|
||||
@@ -141,6 +168,9 @@ type CreateServiceRequest struct {
|
||||
MemoryReserve string `json:"memory_reserve"`
|
||||
StaticBuildCmd string `json:"static_build_cmd"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
// NodeID pins placement to a node agent; "auto" picks the least-loaded
|
||||
// online agent, "local"/"" runs on the Containr host.
|
||||
NodeID string `json:"node_id"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
@@ -170,6 +200,8 @@ type UpdateServiceRequest struct {
|
||||
StaticDir *string `json:"static_dir"`
|
||||
SleepEnabled *bool `json:"sleep_enabled"`
|
||||
SleepIdleMinutes *int `json:"sleep_idle_minutes"`
|
||||
// NodeID pins placement; pointer so "local"/"" explicitly clears the pin.
|
||||
NodeID *string `json:"node_id"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -253,6 +285,7 @@ func handleGetServices(c *gin.Context) {
|
||||
// Reconcile stored status with live container state (no-op without Docker).
|
||||
for i := range services {
|
||||
liveServiceStatus(c, db.(*database.DB), &services[i])
|
||||
loadServiceNode(db.(*database.DB), &services[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||
@@ -409,6 +442,15 @@ func handleCreateService(c *gin.Context) {
|
||||
|
||||
sourceType := inferServiceSourceType(service)
|
||||
|
||||
if req.NodeID != "" {
|
||||
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), req.NodeID)
|
||||
if nodeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
service.NodeID = nodeID
|
||||
}
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
@@ -417,16 +459,18 @@ func handleCreateService(c *gin.Context) {
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, volumes,
|
||||
builder, cpu_reserve, memory_reserve, static_build_cmd, static_dir,
|
||||
created_at, updated_at)
|
||||
node_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26,
|
||||
$27, $28, $29, $30, $31, $32, $33)`,
|
||||
$27, $28, $29, $30, $31, $32, $33, $34)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
volumesJSON, service.Builder, service.CPUReserve, service.MemoryReserve,
|
||||
service.StaticBuildCmd, service.StaticDir, service.CreatedAt, service.UpdatedAt,
|
||||
service.StaticBuildCmd, service.StaticDir,
|
||||
sql.NullString{String: service.NodeID, Valid: service.NodeID != ""},
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -456,7 +500,9 @@ func handleCreateService(c *gin.Context) {
|
||||
|
||||
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
||||
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
||||
"node_id": service.NodeID,
|
||||
})
|
||||
loadServiceNode(db.(*database.DB), &service)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
@@ -547,6 +593,7 @@ func handleGetService(c *gin.Context) {
|
||||
}
|
||||
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||
loadServiceSleep(db.(*database.DB), &service)
|
||||
loadServiceNode(db.(*database.DB), &service)
|
||||
maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID)
|
||||
service.MaintenanceMode = maintenance
|
||||
service.BasicAuth = basicAuthUsernames(basicAuth)
|
||||
@@ -725,6 +772,17 @@ func handleUpdateService(c *gin.Context) {
|
||||
volumesArg = volumesJSON
|
||||
}
|
||||
|
||||
if req.NodeID != nil {
|
||||
nodeID, nodeErr := resolveNodePin(c.Request.Context(), db.(*database.DB), *req.NodeID)
|
||||
if nodeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": nodeErr.Error(), "code": "VALIDATION"})
|
||||
return
|
||||
}
|
||||
existingService.NodeID = nodeID
|
||||
} else {
|
||||
existingService.NodeID = serviceNodeID(db.(*database.DB), serviceID)
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
|
||||
// Update service in database
|
||||
@@ -736,7 +794,7 @@ func handleUpdateService(c *gin.Context) {
|
||||
restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16,
|
||||
builder = $19, cpu_reserve = $20, memory_reserve = $21,
|
||||
static_build_cmd = $22, static_dir = $23,
|
||||
sleep_enabled = $24, sleep_idle_minutes = $25
|
||||
sleep_enabled = $24, sleep_idle_minutes = $25, node_id = $26
|
||||
WHERE id = $18`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
@@ -747,6 +805,7 @@ func handleUpdateService(c *gin.Context) {
|
||||
existingService.Builder, existingService.CPUReserve, existingService.MemoryReserve,
|
||||
existingService.StaticBuildCmd, existingService.StaticDir,
|
||||
existingService.SleepEnabled, existingService.SleepIdleMinutes,
|
||||
sql.NullString{String: existingService.NodeID, Valid: existingService.NodeID != ""},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -816,6 +875,7 @@ func handleUpdateService(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
loadServiceNode(db.(*database.DB), &existingService)
|
||||
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||
}
|
||||
|
||||
@@ -865,7 +925,7 @@ func handleDeleteService(c *gin.Context) {
|
||||
// Remove runtime containers before dropping the row.
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
|
||||
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, serviceID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -637,7 +637,7 @@ func deployTemplateGraph(
|
||||
cleanup := func() {
|
||||
for _, sid := range createdServiceIDs {
|
||||
if engine != nil {
|
||||
_ = engine.RemoveServiceContainers(context.Background(), sid.String())
|
||||
removeServiceRuntime(context.Background(), db, engine, sid)
|
||||
}
|
||||
_, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid)
|
||||
_, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid)
|
||||
|
||||
Reference in new issue
Block a user