Add PurgeComponent RPC to the agent service that removes stale registry entries for components that are both gone (observed state is removed, unknown, or exited) and unwanted (not in any current service definition). Refuses to purge components with running or stopped containers. When all components of a service are purged, the service row is deleted too. Supports --dry-run to preview without modifying the database. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
156 lines
4.5 KiB
Go
156 lines
4.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
mcpv1 "git.wntrmute.dev/kyle/mcp/gen/mcp/v1"
|
|
"git.wntrmute.dev/kyle/mcp/internal/registry"
|
|
)
|
|
|
|
// PurgeComponent removes stale registry entries for components that are both
|
|
// gone (observed state is removed/unknown/exited) and unwanted (not in any
|
|
// current service definition). It never touches running containers.
|
|
func (a *Agent) PurgeComponent(ctx context.Context, req *mcpv1.PurgeRequest) (*mcpv1.PurgeResponse, error) {
|
|
a.Logger.Info("PurgeComponent",
|
|
"service", req.GetService(),
|
|
"component", req.GetComponent(),
|
|
"dry_run", req.GetDryRun(),
|
|
)
|
|
|
|
// Build a set of defined service/component pairs for quick lookup.
|
|
defined := make(map[string]bool, len(req.GetDefinedComponents()))
|
|
for _, dc := range req.GetDefinedComponents() {
|
|
defined[dc] = true
|
|
}
|
|
|
|
// Determine which services to examine.
|
|
var services []registry.Service
|
|
if req.GetService() != "" {
|
|
svc, err := registry.GetService(a.DB, req.GetService())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get service %q: %w", req.GetService(), err)
|
|
}
|
|
services = []registry.Service{*svc}
|
|
} else {
|
|
var err error
|
|
services, err = registry.ListServices(a.DB)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list services: %w", err)
|
|
}
|
|
}
|
|
|
|
var results []*mcpv1.PurgeResult
|
|
|
|
for _, svc := range services {
|
|
components, err := registry.ListComponents(a.DB, svc.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list components for %q: %w", svc.Name, err)
|
|
}
|
|
|
|
// If a specific component was requested, filter to just that one.
|
|
if req.GetComponent() != "" {
|
|
var filtered []registry.Component
|
|
for _, c := range components {
|
|
if c.Name == req.GetComponent() {
|
|
filtered = append(filtered, c)
|
|
}
|
|
}
|
|
components = filtered
|
|
}
|
|
|
|
for _, comp := range components {
|
|
result := a.evaluatePurge(svc.Name, &comp, defined, req.GetDryRun())
|
|
results = append(results, result)
|
|
}
|
|
|
|
// If all components of this service were purged (not dry-run),
|
|
// check if the service should be cleaned up too.
|
|
if !req.GetDryRun() {
|
|
remaining, err := registry.ListComponents(a.DB, svc.Name)
|
|
if err != nil {
|
|
a.Logger.Warn("failed to check remaining components", "service", svc.Name, "err", err)
|
|
continue
|
|
}
|
|
if len(remaining) == 0 {
|
|
if err := registry.DeleteService(a.DB, svc.Name); err != nil {
|
|
a.Logger.Warn("failed to delete empty service", "service", svc.Name, "err", err)
|
|
} else {
|
|
a.Logger.Info("purged empty service", "service", svc.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return &mcpv1.PurgeResponse{Results: results}, nil
|
|
}
|
|
|
|
// purgeableStates are observed states that indicate a component's container
|
|
// is gone and the registry entry can be safely removed.
|
|
var purgeableStates = map[string]bool{
|
|
"removed": true,
|
|
"unknown": true,
|
|
"exited": true,
|
|
}
|
|
|
|
// evaluatePurge checks whether a single component is eligible for purge and,
|
|
// if not in dry-run mode, deletes it.
|
|
func (a *Agent) evaluatePurge(service string, comp *registry.Component, defined map[string]bool, dryRun bool) *mcpv1.PurgeResult {
|
|
key := service + "/" + comp.Name
|
|
|
|
// Safety: refuse to purge components with a live container.
|
|
if !purgeableStates[comp.ObservedState] {
|
|
return &mcpv1.PurgeResult{
|
|
Service: service,
|
|
Component: comp.Name,
|
|
Purged: false,
|
|
Reason: fmt.Sprintf("observed=%s, container still exists", comp.ObservedState),
|
|
}
|
|
}
|
|
|
|
// Don't purge components that are still in service definitions.
|
|
if defined[key] {
|
|
return &mcpv1.PurgeResult{
|
|
Service: service,
|
|
Component: comp.Name,
|
|
Purged: false,
|
|
Reason: "still in service definitions",
|
|
}
|
|
}
|
|
|
|
reason := fmt.Sprintf("observed=%s, not in service definitions", comp.ObservedState)
|
|
|
|
if dryRun {
|
|
return &mcpv1.PurgeResult{
|
|
Service: service,
|
|
Component: comp.Name,
|
|
Purged: true,
|
|
Reason: reason,
|
|
}
|
|
}
|
|
|
|
// Delete events first (events table has no FK to components).
|
|
if err := registry.DeleteComponentEvents(a.DB, service, comp.Name); err != nil {
|
|
a.Logger.Warn("failed to delete events during purge", "service", service, "component", comp.Name, "err", err)
|
|
}
|
|
|
|
// Delete the component (CASCADE handles ports, volumes, cmd).
|
|
if err := registry.DeleteComponent(a.DB, service, comp.Name); err != nil {
|
|
return &mcpv1.PurgeResult{
|
|
Service: service,
|
|
Component: comp.Name,
|
|
Purged: false,
|
|
Reason: fmt.Sprintf("delete failed: %v", err),
|
|
}
|
|
}
|
|
|
|
a.Logger.Info("purged component", "service", service, "component", comp.Name, "reason", reason)
|
|
|
|
return &mcpv1.PurgeResult{
|
|
Service: service,
|
|
Component: comp.Name,
|
|
Purged: true,
|
|
Reason: reason,
|
|
}
|
|
}
|