mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
fix(actions): follow the resource's advertised lifecycle verb
Proxmox guests advertise reboot while container platforms advertise restart, and the Assistant tool schema plus the resolved-resource action lists teach models both words. A pulse_control call with the wrong spelling failed with CapabilityNotFound even though the equivalent capability was advertised. Plan requests now follow the resource's own vocabulary when the requested verb is missing but its lifecycle synonym is advertised; the rewritten name flows into the plan, the audit record and the executor. Non-synonym verbs still fail closed.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user