P2.2-P2.9, P3.2-P3.10, P4.1-P4.3: Complete Phases 2, 3, and 4

11 work units built in parallel and merged:

Agent handlers (Phase 2):
- P2.2 Deploy: pull images, stop/remove/run containers, update registry
- P2.3 Lifecycle: stop/start/restart with desired_state tracking
- P2.4 Status: list (registry), live check (runtime), get status (drift+events)
- P2.5 Sync: receive desired state, reconcile unmanaged containers
- P2.6 File transfer: push/pull scoped to /srv/<service>/, path validation
- P2.7 Adopt: match <service>-* containers, derive component names
- P2.8 Monitor: continuous watch loop, drift/flap alerting, event pruning
- P2.9 Snapshot: VACUUM INTO database backup command

CLI commands (Phase 3):
- P3.2 Login, P3.3 Deploy, P3.4 Stop/Start/Restart
- P3.5 List/Ps/Status, P3.6 Sync, P3.7 Adopt
- P3.8 Service show/edit/export, P3.9 Push/Pull, P3.10 Node list/add/remove

Deployment artifacts (Phase 4):
- Systemd units (agent service + backup timer)
- Example configs (CLI + agent)
- Install script (idempotent)

All packages: build, vet, lint (0 issues), test (all pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:21:18 -07:00
parent d7cc970133
commit 8f913ddf9b
33 changed files with 3593 additions and 62 deletions

139
internal/agent/deploy.go Normal file
View File

@@ -0,0 +1,139 @@
package agent
import (
"context"
"database/sql"
"errors"
"fmt"
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
"git.wntrmute.dev/kyle/mcp/internal/registry"
"git.wntrmute.dev/kyle/mcp/internal/runtime"
)
// Deploy deploys a service (or a single component of it) to this node.
func (a *Agent) Deploy(ctx context.Context, req *mcpv1.DeployRequest) (*mcpv1.DeployResponse, error) {
spec := req.GetService()
if spec == nil {
return nil, fmt.Errorf("deploy: missing service spec")
}
serviceName := spec.GetName()
a.Logger.Info("deploying", "service", serviceName)
if err := ensureService(a.DB, serviceName, spec.GetActive()); err != nil {
return nil, fmt.Errorf("deploy: ensure service %q: %w", serviceName, err)
}
components := spec.GetComponents()
if target := req.GetComponent(); target != "" {
var filtered []*mcpv1.ComponentSpec
for _, cs := range components {
if cs.GetName() == target {
filtered = append(filtered, cs)
}
}
components = filtered
}
var results []*mcpv1.ComponentResult
for _, cs := range components {
result := a.deployComponent(ctx, serviceName, cs)
results = append(results, result)
}
return &mcpv1.DeployResponse{Results: results}, nil
}
// deployComponent handles the full deploy lifecycle for a single component.
func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcpv1.ComponentSpec) *mcpv1.ComponentResult {
compName := cs.GetName()
containerName := serviceName + "-" + compName
a.Logger.Info("deploying component", "service", serviceName, "component", compName)
regComp := &registry.Component{
Name: compName,
Service: serviceName,
Image: cs.GetImage(),
Network: cs.GetNetwork(),
UserSpec: cs.GetUser(),
Restart: cs.GetRestart(),
DesiredState: "running",
Version: runtime.ExtractVersion(cs.GetImage()),
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
}
if err := ensureComponent(a.DB, regComp); err != nil {
return &mcpv1.ComponentResult{
Name: compName,
Error: fmt.Sprintf("ensure component: %v", err),
}
}
if err := a.Runtime.Pull(ctx, cs.GetImage()); err != nil {
return &mcpv1.ComponentResult{
Name: compName,
Error: fmt.Sprintf("pull image: %v", err),
}
}
_ = a.Runtime.Stop(ctx, containerName) // may not exist yet
_ = a.Runtime.Remove(ctx, containerName) // may not exist yet
runSpec := runtime.ContainerSpec{
Name: containerName,
Image: cs.GetImage(),
Network: cs.GetNetwork(),
User: cs.GetUser(),
Restart: cs.GetRestart(),
Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(),
}
if err := a.Runtime.Run(ctx, runSpec); err != nil {
_ = registry.UpdateComponentState(a.DB, serviceName, compName, "", "removed")
return &mcpv1.ComponentResult{
Name: compName,
Error: fmt.Sprintf("run container: %v", err),
}
}
if err := registry.UpdateComponentState(a.DB, serviceName, compName, "running", "running"); err != nil {
a.Logger.Warn("failed to update component state", "service", serviceName, "component", compName, "err", err)
}
return &mcpv1.ComponentResult{
Name: compName,
Success: true,
}
}
// ensureService creates the service if it does not exist, or updates its
// active flag if it does.
func ensureService(db *sql.DB, name string, active bool) error {
_, err := registry.GetService(db, name)
if errors.Is(err, sql.ErrNoRows) {
return registry.CreateService(db, name, active)
}
if err != nil {
return err
}
return registry.UpdateServiceActive(db, name, active)
}
// ensureComponent creates the component if it does not exist, or updates its
// spec if it does.
func ensureComponent(db *sql.DB, c *registry.Component) error {
_, err := registry.GetComponent(db, c.Service, c.Name)
if errors.Is(err, sql.ErrNoRows) {
c.ObservedState = "unknown"
return registry.CreateComponent(db, c)
}
if err != nil {
return err
}
return registry.UpdateComponentSpec(db, c)
}