mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
refactor(api): drop dead body of retired HandleRunCommand
The raw-command endpoint answers 410 Gone unconditionally; the old implementation below the retirement return was unreachable (govet) and kept normalizeRunCommandApprovalTarget, normalizeAIExecuteTargetType, and AIRunCommandRequest alive with no live callers. Remove them and the tests that only exercised the removed helpers. Contract-Neutral: remove unreachable dead code below the 410 retirement return in HandleRunCommand; endpoint behavior unchanged
This commit is contained in:
+2
-162
@@ -3584,10 +3584,6 @@ type AIExecuteRequest struct {
|
||||
UseCase string `json:"use_case,omitempty"` // "chat" or "patrol"
|
||||
}
|
||||
|
||||
func normalizeAIExecuteTargetType(raw string) string {
|
||||
return agentcapabilities.NormalizeActionTargetType(raw)
|
||||
}
|
||||
|
||||
func normalizeAndValidateAIExecuteTargetType(raw string) (string, error) {
|
||||
return agentcapabilities.NormalizeAndValidateOptionalActionTargetType(raw)
|
||||
}
|
||||
@@ -4196,170 +4192,14 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R
|
||||
// 'done' event is sent by the defer above
|
||||
}
|
||||
|
||||
// AIRunCommandRequest is the request body for POST /api/ai/run-command
|
||||
type AIRunCommandRequest struct {
|
||||
Command string `json:"command"`
|
||||
ApprovalID string `json:"approval_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
RunOnHost bool `json:"run_on_host"`
|
||||
VMID string `json:"vmid,omitempty"`
|
||||
TargetHost string `json:"target_host,omitempty"` // Explicit host for routing
|
||||
}
|
||||
|
||||
// HandleRunCommand executes a single approved command (POST /api/ai/run-command)
|
||||
// HandleRunCommand is the retired raw-command endpoint (POST /api/ai/run-command).
|
||||
// It always answers 410 Gone; clients must use advertised typed resource actions.
|
||||
func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusGone, agentcapabilities.AgentErrCodeRawCommandRetired, "Raw command execution is retired. Use an advertised typed resource action.")
|
||||
return
|
||||
|
||||
// Require authentication
|
||||
if !CheckAuth(h.getConfig(r.Context()), w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
bodyBytes, readErr := io.ReadAll(r.Body)
|
||||
if readErr != nil {
|
||||
log.Error().Err(readErr).Msg("Failed to read request body")
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Debug().Int("body_len", len(bodyBytes)).Msg("run-command request received")
|
||||
|
||||
var req AIRunCommandRequest
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to decode JSON body")
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Command) == "" {
|
||||
http.Error(w, "Command is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ApprovalID) == "" {
|
||||
http.Error(w, "approval_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
approvalTargetType, approvalTargetID, targetErr := normalizeRunCommandApprovalTarget(req)
|
||||
if targetErr != nil {
|
||||
http.Error(w, targetErr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Gated for Patrol fix actions (Pro feature). Request shape is validated before the
|
||||
// entitlement check so clients get deterministic 400s for malformed calls.
|
||||
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
|
||||
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Patrol fix actions require Pulse Pro")
|
||||
return
|
||||
}
|
||||
|
||||
store := approval.GetStore()
|
||||
if store == nil {
|
||||
http.Error(w, "Approval store not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := approval.NormalizeOrgID(GetOrgID(r.Context()))
|
||||
approvalReq, ok := store.GetApproval(req.ApprovalID)
|
||||
if !ok || !approval.BelongsToOrg(approvalReq, orgID) {
|
||||
http.Error(w, "Approval request not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if approvalReq.Plan == nil || strings.TrimSpace(approvalReq.Plan.ActionID) == "" {
|
||||
http.Error(w, "Approval authorization is incomplete", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
approvedHash := strings.TrimSpace(approvalReq.CommandHash)
|
||||
if approvedHash == "" {
|
||||
approvedHash = approval.ComputeCommandHash(approvalReq.Command, approvalReq.TargetType, approvalReq.TargetID)
|
||||
}
|
||||
if approvedHash != approval.ComputeCommandHash(req.Command, approvalTargetType, approvalTargetID) {
|
||||
http.Error(w, "Approval command does not match", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("command_hash", agentexec.ComputeCommandApprovalHash(req.Command, approvalTargetType, approvalTargetID)).
|
||||
Str("approval_id", req.ApprovalID).
|
||||
Str("target_type", approvalTargetType).
|
||||
Str("target_id", approvalTargetID).
|
||||
Bool("run_on_host", req.RunOnHost).
|
||||
Str("target_host", req.TargetHost).
|
||||
Msg("Executing approved command")
|
||||
|
||||
// Execute with timeout (5 minutes for long-running commands)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := h.GetAIService(r.Context()).RunCommand(ctx, ai.RunCommandRequest{
|
||||
Command: req.Command,
|
||||
ApprovalID: req.ApprovalID,
|
||||
TargetType: approvalTargetType,
|
||||
TargetID: approvalTargetID,
|
||||
RunOnHost: req.RunOnHost,
|
||||
VMID: req.VMID,
|
||||
TargetHost: strings.ToLower(strings.TrimSpace(req.TargetHost)),
|
||||
OrgID: orgID,
|
||||
ActionID: approvalReq.Plan.ActionID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to execute command")
|
||||
http.Error(w, "Failed to execute command", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, resp); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write run command response")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRunCommandApprovalTarget(req AIRunCommandRequest) (string, string, error) {
|
||||
targetType := normalizeAIExecuteTargetType(req.TargetType)
|
||||
targetID := strings.TrimSpace(req.TargetID)
|
||||
targetHost := strings.ToLower(strings.TrimSpace(req.TargetHost))
|
||||
|
||||
if req.RunOnHost {
|
||||
targetType = agentcapabilities.ActionTargetTypeAgent
|
||||
if targetHost == "" {
|
||||
return "", "", errors.New("target_host is required when run_on_host is true")
|
||||
}
|
||||
targetID = targetHost
|
||||
}
|
||||
|
||||
if targetType == "" {
|
||||
targetType = agentcapabilities.ActionTargetTypeAgent
|
||||
}
|
||||
if !agentcapabilities.IsActionTargetType(targetType) {
|
||||
return "", "", fmt.Errorf("unsupported target_type %q (allowed: %s)", targetType, agentcapabilities.ActionTargetTypeAllowedDescription())
|
||||
}
|
||||
|
||||
if (targetType == agentcapabilities.ActionTargetTypeSystemContainer || targetType == agentcapabilities.ActionTargetTypeVM) && strings.TrimSpace(req.VMID) != "" {
|
||||
targetID = strings.TrimSpace(req.VMID)
|
||||
}
|
||||
|
||||
if targetType == agentcapabilities.ActionTargetTypeAgent {
|
||||
if targetID == "" {
|
||||
targetID = targetHost
|
||||
}
|
||||
if targetID == "" {
|
||||
return "", "", errors.New("target_id or target_host is required for agent commands")
|
||||
}
|
||||
targetID = strings.ToLower(targetID)
|
||||
}
|
||||
|
||||
if targetID == "" {
|
||||
return "", "", fmt.Errorf("target_id is required for target_type '%s'", targetType)
|
||||
}
|
||||
|
||||
return targetType, targetID, nil
|
||||
}
|
||||
|
||||
// maxGuestIDLength is the maximum allowed length for guest_id across all
|
||||
|
||||
@@ -1386,28 +1386,6 @@ func TestHandleExecuteStream_RejectsLegacyHostTargetType(t *testing.T) {
|
||||
require.Contains(t, rec.Body.String(), "Invalid target_type")
|
||||
}
|
||||
|
||||
func TestNormalizeAIExecuteTargetType_StrictCanonicalV6(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{in: "system-container", want: "system-container"},
|
||||
{in: "SYSTEM-CONTAINER", want: "system-container"},
|
||||
{in: "container", want: "container"},
|
||||
{in: "system_container", want: "system_container"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := normalizeAIExecuteTargetType(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("normalizeAIExecuteTargetType(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
if shared := agentcapabilities.NormalizeActionTargetType(tt.in); got != shared {
|
||||
t.Fatalf("normalizeAIExecuteTargetType(%q) = %q, shared contract returned %q", tt.in, got, shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAndValidateAIExecuteTargetType_StrictCanonicalV6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -2628,73 +2606,6 @@ func TestHandleRunCommand_InvalidBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRunCommandApprovalTarget_CanonicalizesAgentTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req AIRunCommandRequest
|
||||
wantTyp string
|
||||
wantID string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "host target type rejected",
|
||||
req: AIRunCommandRequest{
|
||||
TargetType: "host",
|
||||
TargetID: "Node-1",
|
||||
},
|
||||
wantErr: "unsupported target_type",
|
||||
},
|
||||
{
|
||||
name: "run_on_host forces agent target",
|
||||
req: AIRunCommandRequest{
|
||||
TargetType: "vm",
|
||||
RunOnHost: true,
|
||||
TargetHost: "PVE-A",
|
||||
},
|
||||
wantTyp: "agent",
|
||||
wantID: "pve-a",
|
||||
},
|
||||
{
|
||||
name: "system-container target preserved",
|
||||
req: AIRunCommandRequest{
|
||||
TargetType: "system-container",
|
||||
TargetID: "201",
|
||||
},
|
||||
wantTyp: "system-container",
|
||||
wantID: "201",
|
||||
},
|
||||
{
|
||||
name: "legacy container alias rejected",
|
||||
req: AIRunCommandRequest{
|
||||
TargetType: "container",
|
||||
TargetID: "201",
|
||||
},
|
||||
wantErr: "unsupported target_type",
|
||||
},
|
||||
{
|
||||
name: "missing target host when run_on_host",
|
||||
req: AIRunCommandRequest{
|
||||
RunOnHost: true,
|
||||
},
|
||||
wantErr: "target_host is required when run_on_host is true",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotType, gotID, err := normalizeRunCommandApprovalTarget(tt.req)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantTyp, gotType)
|
||||
require.Equal(t, tt.wantID, gotID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRunCommand_RequiresApprovalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user