diff --git a/internal/actionlifecycle/service.go b/internal/actionlifecycle/service.go index 73b3445d6..8587025c4 100644 --- a/internal/actionlifecycle/service.go +++ b/internal/actionlifecycle/service.go @@ -224,6 +224,37 @@ func (e *ActionNotFoundError) Error() string { return fmt.Sprintf("action %q not found", e.ActionID) } +// lifecycleCapabilitySynonym maps interchangeable lifecycle verbs. Proxmox +// guests advertise "reboot" while container platforms advertise "restart"; +// proposers (and Assistant models) use the words interchangeably. +func lifecycleCapabilitySynonym(name string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "restart": + return "reboot", true + case "reboot": + return "restart", true + } + return "", false +} + +// resolveAdvertisedCapabilityName follows the resource's own vocabulary when +// the requested capability is not advertised but a lifecycle synonym of it +// is. The rewritten name flows into the plan, the audit record, and the +// executor, so every later stage sees the advertised verb. +func resolveAdvertisedCapabilityName(capabilities []unified.ResourceCapability, requested string) string { + if _, found := actionplanner.FindCapability(capabilities, requested); found { + return requested + } + synonym, hasSynonym := lifecycleCapabilitySynonym(requested) + if !hasSynonym { + return requested + } + if _, found := actionplanner.FindCapability(capabilities, synonym); found { + return synonym + } + return requested +} + // CapabilityNotFoundError reports that the resource does not advertise the // requested capability. It unwraps to actionplanner.ErrCapabilityNotFound. type CapabilityNotFoundError struct { @@ -371,6 +402,7 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified if !ok || resource == nil { return unified.ActionPlan{}, &ResourceNotFoundError{ResourceID: req.ResourceID} } + req.CapabilityName = resolveAdvertisedCapabilityName(resource.Capabilities, req.CapabilityName) planner := actionplanner.Planner{} var plan unified.ActionPlan diff --git a/internal/actionlifecycle/service_test.go b/internal/actionlifecycle/service_test.go index 7e3a30bd7..9878c09ef 100644 --- a/internal/actionlifecycle/service_test.go +++ b/internal/actionlifecycle/service_test.go @@ -1010,6 +1010,35 @@ func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) { } } +func TestPlanFollowsAdvertisedLifecycleSynonym(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin)) + + // The fixture advertises only "restart"; a request for the lifecycle + // synonym "reboot" must plan the advertised verb. + req := restartRequest() + req.CapabilityName = "reboot" + plan, err := env.service.Plan(context.Background(), "default", req, testActionActor("requester", "default")) + if err != nil { + t.Fatalf("Plan with lifecycle synonym: %v", err) + } + record, ok, err := env.store.GetActionAudit(plan.ActionID) + if err != nil || !ok { + t.Fatalf("GetActionAudit: ok=%v err=%v", ok, err) + } + if record.Request.CapabilityName != "restart" { + t.Fatalf("persisted capability = %q, want the advertised verb %q", record.Request.CapabilityName, "restart") + } + + // Verbs without an advertised synonym still fail closed. + unknown := restartRequest() + unknown.CapabilityName = "shutdown" + var capErr *CapabilityNotFoundError + if _, err := env.service.Plan(context.Background(), "default", unknown, testActionActor("requester", "default")); !errors.As(err, &capErr) { + t.Fatalf("non-synonym verb error = %v, want CapabilityNotFoundError", err) + } +} + func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))