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:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 16:19:12 +02:00
1 parent 401daf69aa
commit bc93c83cf9
28 files changed
+1565 -48

No files matched your search

@@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
cordonAgent,
deleteAgent,
drainAgent,
getAgent,
getAgentMetrics,
listAgentCommands,
@@ -43,6 +45,7 @@ export function NodeDetailPage() {
const [rename, setRename] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
@@ -86,6 +89,14 @@ export function NodeDetailPage() {
mutationFn: () => deleteAgent(id),
onSuccess: () => navigate('/usage'),
});
const cordonMutation = useMutation({
mutationFn: (cordoned: boolean) => cordonAgent(id, cordoned),
onSuccess: invalidate,
});
const drainMutation = useMutation({
mutationFn: () => drainAgent(id),
onSuccess: () => { setConfirmDrain(false); invalidate(); },
});
const agent = agentQuery.data;
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
@@ -168,6 +179,11 @@ export function NodeDetailPage() {
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{!agent.schedulable && (
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
cordoned
</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">
@@ -179,6 +195,26 @@ export function NodeDetailPage() {
/>
auto-prune
</label>
<button
onClick={() => cordonMutation.mutate(agent.schedulable)}
disabled={cordonMutation.isPending}
title={agent.schedulable
? 'Block new placements — running replicas stay'
: 'Re-open for placement'}
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"
>
{agent.schedulable ? 'cordon' : 'uncordon'}
</button>
<button
onClick={() => setConfirmDrain(true)}
disabled={drainMutation.isPending || !online}
title={online
? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
: 'Node must be online to drain'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50"
>
drain
</button>
<button
onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending}
@@ -302,6 +338,17 @@ export function NodeDetailPage() {
pending={pruneMutation.isPending}
/>
)}
{confirmDrain && (
<ConfirmModal
title={`Drain ${agent.name}?`}
body="Every service container on this node is removed and the affected services are unpinned — they redeploy on the local host until re-pinned elsewhere. The node stays cordoned."
confirmLabel="drain node"
danger
onCancel={() => setConfirmDrain(false)}
onConfirm={() => drainMutation.mutate()}
pending={drainMutation.isPending}
/>
)}
{confirmDelete && (
<ConfirmModal
title={`Remove ${agent.name}?`}