feat(scheduling): replica spread + resource-aware auto placement (#56)

services.spread distributes replicas across every online, schedulable
agent (replica i → agents[i mod n], deterministic). auto placement now
orders by real headroom — memory utilisation, cpu usage, then container
count — instead of count alone.

Lifecycle (start/stop/restart/delete) and GET /runtime are now driven
by container_instances inventory, so remote replicas are visible and
tear down wherever they run — covers spread, pins, and stale pin
leftovers. Spread and explicit pins are mutually exclusive.

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

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 17:03:26 +02:00
1 parent bc93c83cf9
commit dd5ce80ce2
21 files changed
+469 -100

No files matched your search

+10
View File
@@ -17,11 +17,17 @@ type NodeRunner interface {
// ReconcileOnNode brings the node's containers in line with the spec and
// returns the resulting runtime state.
ReconcileOnNode(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
// ReconcileSpread distributes the service's replicas across every online,
// schedulable agent — replica i lands on agents[i % len].
ReconcileSpread(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error)
// ControlService fans a lifecycle action ("start"|"stop"|"restart"|"remove")
// out to every replica of the service on the agent.
ControlService(ctx context.Context, serviceID, agentID, action string) error
// RemoveService tears down every replica of the service on the agent.
RemoveService(ctx context.Context, serviceID, agentID string) error
// RemoteRuntimeState reads the service's remote replicas from inventory
// across all agents. nil/empty means nothing runs remotely.
RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error)
}
type DeploymentEngine struct {
@@ -85,6 +91,9 @@ type ServiceConfig struct {
// host. Remote placement requires a registry-pullable image — builds stay
// on the local host (build-on-node is not yet supported).
NodeID string `json:"node_id,omitempty"`
// Spread distributes replicas across every online, schedulable agent.
// Same registry-image constraint as NodeID.
Spread bool `json:"spread,omitempty"`
}
type PortMapping struct {
@@ -376,6 +385,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo
MaintenanceURL: cfg.MaintenanceURL,
BasicAuthUsers: cfg.BasicAuthUsers,
NodeID: cfg.NodeID,
Spread: cfg.Spread,
}
for _, pm := range cfg.PortMappings {
if spec.Port == 0 {
@@ -57,6 +57,9 @@ type RuntimeSpec struct {
BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated
// NodeID pins the service to a node agent; "" runs on the local host.
NodeID string
// Spread distributes replicas across all online, schedulable agents
// instead of a single node. Mutually exclusive with NodeID.
Spread bool
}
// RuntimeContainer describes one live replica.
@@ -156,6 +159,12 @@ func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID
// otherwise only the replica delta is applied. A pinned NodeID routes the
// whole reconcile to that agent's command queue instead of local Docker.
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
if spec.Spread && spec.NodeID == "" {
if de.nodeRunner == nil {
return nil, fmt.Errorf("service has spread placement but remote dispatch is unavailable")
}
return de.nodeRunner.ReconcileSpread(ctx, spec)
}
if spec.NodeID != "" {
if de.nodeRunner == nil {
return nil, fmt.Errorf("service is pinned to node %s but remote dispatch is unavailable", spec.NodeID)
@@ -526,6 +535,15 @@ func (de *DeploymentEngine) RemoveServiceContainersOnNode(ctx context.Context, s
return de.nodeRunner.RemoveService(ctx, serviceID, nodeID)
}
// RemoteRuntimeState exposes inventory-backed remote container state; nil
// runner means no node support was wired at boot.
func (de *DeploymentEngine) RemoteRuntimeState(ctx context.Context, serviceID string) (*RuntimeState, error) {
if de.nodeRunner == nil {
return nil, nil
}
return de.nodeRunner.RemoteRuntimeState(ctx, serviceID)
}
// RemoveProjectContainers deletes every managed container in a project.
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(