diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index 6bd8353cda..0318b44100 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -275,6 +275,7 @@ the live set. - `list_resource_capabilities` (List resource capabilities, `GET /api/agent/resource-capabilities/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array. - `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources. - `list_recent_changes` (List recent changes, `GET /api/agent/changes`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return what Pulse observed changing across the fleet, newest first: state transitions, restarts, configuration and relationship changes, alerts fired, acknowledged, snoozed and resolved, and commands or runbooks run. One read for 'what happened since last night?'; optional filters (since, before, resourceId, kind, sourceType) narrow it. Only changes Pulse observed are listed, so a gap is not proof that nothing happened. Results are capped, and truncated says when more matched. Command text, output and runbook messages are redacted for monitoring-read API tokens unless the token also has ai:execute. +- `get_metric_history` (Get metric history, `GET /api/agent/metric-history`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return how one resource's metrics moved over a window, from the history Pulse records while it monitors: 'did this disk fill gradually or suddenly?', 'was memory climbing before the restart?'. Pass the canonical resourceId from get_fleet_context, list_recent_changes or get_resource_context. Omit metric to get every metric the resource records, which also tells you their names. Points are averaged into about maxPoints buckets (window edges can add one), each with min and max. Windows longer than the install's licensed history (7 days on Community) return a license_required error. - `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output. **Operator state (per-resource intent):** diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ac884a0ad1..73b61b7355 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -2749,6 +2749,9 @@ agents may read what changed across the fleet, but `command_executed` and `commandRedacted:true`, the command text, output, and caller details dropped, and a command's reason cut to its status. A change record is history, never command disclosure, install-token scope, or execution authority. +Metric history at `/api/agent/metric-history` (`get_metric_history`) is the +same read-only boundary: a resource's recorded metric series for a window, +never install, enrollment or command authority. The `alert.fired`, `alert.resolved` and `resource.status_changed` events on `/api/agent/events` are the push form of the same records and the same diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 589756684b..add91660f4 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -2086,6 +2086,17 @@ changes: it does not reconstruct events Pulse did not see, and command, output and runbook message content is redacted for tokens without `ai:execute`. Integer filters reach the route because the shared projection forwards integral JSON numbers as query parameters. +`get_metric_history` is the manifest-owned metric-history read for external +agents (`GET /api/agent/metric-history`, `monitoring:read`). It takes the +canonical resource id the other agent reads return, resolves the resource's +`MetricsTarget` through the registry, and delegates to the chart history read +(`handleMetricsHistory`), so an agent and the history charts share tiers, +fallbacks, downsampling and the licensed history limit; a window beyond that +limit returns the same `license_required` error. It counts as resource-context +external-agent activity rather than a new telemetry activity. A range outside +`MetricHistoryRanges` or a non-positive `maxPoints` is a 400. `maxPoints` (capped +at 200) is the downsampling target, not an output bound: short windows and +fallback reads can return native samples, as the chart route does. than in `cmd/pulse-mcp` or an MCP-only inventory registry. They must enter through `ManifestSurfaceResourceCapabilities`, `ListMCPManifestSurfaceResourcesHTTP`, and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 67ce1881bd..c5fb67d299 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -6802,6 +6802,27 @@ projection layer forwards integral JSON numbers as integer query parameters so manifest integer filters such as `limit` reach the route; fractional or out-of-range numbers are dropped like objects. +`/api/agent/metric-history` (`get_metric_history`, scope +`monitoring:read`) is the agent-consumable "how did it move?" read for +one resource. It takes the canonical `resourceId` (required), an +optional `metric` (omitted returns every recorded metric keyed by +name), `range` from `MetricHistoryRanges` (default 24h) and +`maxPoints` (default 60, capped at 200; a downsampling target, not an +output bound, so short windows and fallback reads can return native +samples). It resolves the resource's `MetricsTarget` through the +registry (`ResourceRegistry.MetricsTarget`, as the resources API does) +and delegates to +`handleMetricsHistory`, so its payload, tiers, fallbacks and licensed +history limit are exactly the chart history route's: a window beyond +the install's licensed history returns 402 `license_required`. An +unknown resource, or one without a `MetricsTarget`, is a 404 with a +reason; a missing `resourceId`, unknown range or non-positive +`maxPoints` is a 400. The response's `resourceId` is the metrics-store +id, which can differ from the canonical id requested. +`normalizeMetricsHistoryResourceType` accepts the registry's Kubernetes +target kinds (`pod`, `k8s-cluster`, `k8s-node`, `k8s-deployment`) as the +`k8s` store type, so a target passes through unchanged for every caller. + `/api/agent/resource-capabilities/{id}` is the agent-consumable structured capability surface for a single resource: the governed capabilities it advertises (name, type, approval level, platform, and diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index c96cb0c2f2..4379701ca2 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -3770,3 +3770,18 @@ command metadata, so a monitoring-read token learns nothing from an event that it could not already read through `list_recent_changes`. Proof: `TestContract_AgentChangeEventsStayInsideTheirOrganization` in `internal/api/contract_test.go`. + +### Agent metric history reuses the chart route's authority (29 October 2026) + +`/api/agent/metric-history` (`get_metric_history`) requires `monitoring:read`, +the same scope as `/api/metrics-store/history`. It resolves the canonical +resource id through the request organization's registry and hands a cloned +request, with the organization context intact, to `handleMetricsHistory`, so +tenant resolution, the licensed history limit and the response are the chart +route's own. The rewritten query carries only resourceType, resourceId, range, +maxPoints and metric; any other parameter the caller sent is dropped. A +monitoring-read token therefore reads nothing through it that it could not +already read from the chart route. `normalizeMetricsHistoryResourceType` now +also accepts the registry's Kubernetes target kinds (`pod`, `k8s-cluster`, +`k8s-node`, `k8s-deployment`) as the `k8s` store type; that widens which +spellings reach the same store type, not who may read it. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index db2c414ea1..d9de2d2914 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -4731,6 +4731,11 @@ readable without a name, so the timeline's recovery posture is exactly the `resource_changes` table's: whatever survives a restart or restore is what agents read, with no timeline-specific rehydration or recovery artifact. +Metric history at `/api/agent/metric-history` (`get_metric_history`) adds no +persistence either. It resolves a resource's existing `MetricsTarget` and reads +through the same history path the charts use (the metrics store, then the +in-memory and live fallbacks), so its recovery posture is the metrics store's. + The live `alert.fired`, `alert.resolved` and `resource.status_changed` agent events add no persistence. They are published in memory after the monitor adapter has recorded the matching `resource_changes` row, and per-resource diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 7f4dbc3ecc..1ea9e21dc6 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -108,6 +108,44 @@ func recentChangesOutputSchema() json.RawMessage { }) } +// MetricHistoryRanges are the windows get_metric_history accepts. They match +// the history chart ranges, so an agent and the drawer read the same tiers. +var MetricHistoryRanges = []string{"30m", "1h", "6h", "12h", "24h", "7d", "30d", "90d"} + +const ( + MetricHistoryDefaultRange = "24h" + MetricHistoryDefaultMaxPoints = 60 + MetricHistoryMaxPoints = 200 +) + +func metricHistoryInputSchema() json.RawMessage { + return agentObjectInputSchema([]string{ResourceIDArgumentName}, map[string]any{ + ResourceIDArgumentName: stringOption("Canonical resource id from get_fleet_context, list_recent_changes or get_resource_context."), + "metric": stringOption("One recorded metric, such as cpu, memory, disk, netin, netout, diskread or diskwrite (storage records usage, used, avail and total). Omit to get every metric the resource records."), + "range": stringEnumOption(MetricHistoryRanges, "Window ending now. Defaults to "+MetricHistoryDefaultRange+". Windows beyond the install's licensed history return license_required."), + "maxPoints": map[string]any{ + "type": "integer", + "minimum": 1, + "maximum": MetricHistoryMaxPoints, + "description": fmt.Sprintf("Target number of points per metric; window edges can add one. Defaults to %d; values above %d are capped. Short windows may return native samples instead.", MetricHistoryDefaultMaxPoints, MetricHistoryMaxPoints), + }, + }) +} + +func metricHistoryOutputSchema() json.RawMessage { + return agentObjectOutputSchema([]string{"resourceType", "resourceId", "range", "start", "end", "source"}, map[string]any{ + "resourceType": stringOption("Metrics-store type of the resource."), + "resourceId": stringOption("Metrics-store id of the resource, which can differ from the canonical id that was requested."), + "metric": stringOption("The requested metric, when one was given."), + "range": stringOption("Effective window."), + "start": integerOption("Window start, Unix milliseconds."), + "end": integerOption("Window end, Unix milliseconds."), + "points": objectArrayOption("Series for the requested metric, oldest first: timestamp (Unix milliseconds), value (bucket average), min and max."), + "metrics": map[string]any{"type": "object", "description": "Every recorded metric when none was requested, keyed by metric name, each an array of points shaped like points."}, + "source": stringOption("Where the points came from: store (persistent history), memory (recent in-memory history while the store fills), live or mock_synthetic."), + }) +} + func operationsLoopStatusOutputSchema() json.RawMessage { return agentObjectOutputSchema([]string{"nextAction", "progressLabel", "steps", "patrolEvidenceCount", "patrolIssueEvidenceCount", "activeFindingCount", "pendingApprovalCount", "governedActionCount", "approvedDecisionCount", "rejectedDecisionCount", "verifiedOutcomeCount", "operationsLoopStarterCount", "assistantOperationsLoopStarterCount", "patrolOperationsLoopStarterCount", "patrolControlOperationsLoopStarterCount", "patrolControlCompletedOperationsLoopCount", "patrolControlResolvedOperationsLoopCount", "patrolControlValueState", "patrolAutonomyOperationsLoopStarterCount", "patrolAutonomyCompletedOperationsLoopCount", "patrolAutonomyResolvedOperationsLoopCount", "patrolAutonomyValueState", "proActivationOperationsLoopStarterCount", "proActivationCompletedOperationsLoopCount", "proActivationResolvedOperationsLoopCount", "proActivationValueProofState", "mcpOperationsLoopStarterCount", "externalAgentReady", "windowStart", "generatedAt"}, map[string]any{ "nextAction": stringEnumOption([]string{"run_patrol", "review_findings", "open_assistant", "review_approvals", "open_mcp", "complete"}, "Next recommended Patrol work action an agent or UI should take."), @@ -892,6 +930,20 @@ var canonicalManifest = Manifest{ InputSchema: recentChangesInputSchema(), OutputSchema: recentChangesOutputSchema(), }, + { + Name: GetMetricHistoryCapabilityName, + Title: "Get metric history", + Description: "Return how one resource's metrics moved over a window, from the history Pulse records while it monitors: 'did this disk fill gradually or suddenly?', 'was memory climbing before the restart?'. Pass the canonical resourceId from get_fleet_context, list_recent_changes or get_resource_context. Omit metric to get every metric the resource records, which also tells you their names. Points are averaged into about maxPoints buckets (window edges can add one), each with min and max. Windows longer than the install's licensed history (7 days on Community) return a license_required error.", + Category: "context", + Method: http.MethodGet, + Path: MetricHistoryCapabilityPath, + Scope: agentCapabilityScopeMonitoringRead, + ActionMode: agentCapabilityActionModeRead, + ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly, + ResponseShape: "MetricsHistory", + InputSchema: metricHistoryInputSchema(), + OutputSchema: metricHistoryOutputSchema(), + }, { Name: OperationsLoopStatusCapabilityName, Title: "Get Patrol work status", @@ -1231,6 +1283,7 @@ func canonicalPulseMCPSurfaceToolNames() []string { ListResourceCapabilitiesCapabilityName, FleetContextCapabilityName, ListRecentChangesCapabilityName, + GetMetricHistoryCapabilityName, OperationsLoopStatusCapabilityName, ListNodesCapabilityName, AddNodeCapabilityName, diff --git a/internal/agentcapabilities/projection.go b/internal/agentcapabilities/projection.go index 424266d684..51c4aa4a17 100644 --- a/internal/agentcapabilities/projection.go +++ b/internal/agentcapabilities/projection.go @@ -39,6 +39,11 @@ const ( // resource changes across the fleet: the "what changed?" companion to the // fleet triage and per-resource context reads. ListRecentChangesCapabilityName = "list_recent_changes" + // GetMetricHistoryCapabilityName is the manifest-owned read of one + // resource's recorded metric series: how it moved, not only where it is. + GetMetricHistoryCapabilityName = "get_metric_history" + // MetricHistoryCapabilityPath is the manifest-owned metric history route. + MetricHistoryCapabilityPath = "/api/agent/metric-history" // FleetContextCapabilityPath is the manifest-owned fleet triage route. FleetContextCapabilityPath = "/api/agent/fleet-context" // RecentChangesCapabilityPath is the manifest-owned fleet change route. diff --git a/internal/api/agent_activity_telemetry.go b/internal/api/agent_activity_telemetry.go index 127d0956f6..4ba0c52713 100644 --- a/internal/api/agent_activity_telemetry.go +++ b/internal/api/agent_activity_telemetry.go @@ -44,7 +44,8 @@ func externalAgentCapabilityActivity(capabilityName string) (string, string, boo var activity string switch strings.TrimSpace(capabilityName) { case agentcapabilities.ResourceContextCapabilityName, - agentcapabilities.ListResourceCapabilitiesCapabilityName: + agentcapabilities.ListResourceCapabilitiesCapabilityName, + agentcapabilities.GetMetricHistoryCapabilityName: activity = config.ExternalAgentActivityResourceContext case agentcapabilities.FleetContextCapabilityName, agentcapabilities.ListRecentChangesCapabilityName: diff --git a/internal/api/agent_capabilities_test.go b/internal/api/agent_capabilities_test.go index f316ca48c3..3022852788 100644 --- a/internal/api/agent_capabilities_test.go +++ b/internal/api/agent_capabilities_test.go @@ -206,6 +206,7 @@ func TestAgentCapabilitiesManifest_ScopesMatchAPIAuthConstants(t *testing.T) { agentcapabilities.ListResourceCapabilitiesCapabilityName: config.ScopeMonitoringRead, agentcapabilities.FleetContextCapabilityName: config.ScopeMonitoringRead, agentcapabilities.ListRecentChangesCapabilityName: config.ScopeMonitoringRead, + agentcapabilities.GetMetricHistoryCapabilityName: config.ScopeMonitoringRead, agentcapabilities.OperationsLoopStatusCapabilityName: config.ScopeMonitoringRead, "list_nodes": config.ScopeSettingsRead, "add_node": config.ScopeSettingsWrite, diff --git a/internal/api/agent_metric_history.go b/internal/api/agent_metric_history.go new file mode 100644 index 0000000000..cae15f4a9f --- /dev/null +++ b/internal/api/agent_metric_history.go @@ -0,0 +1,98 @@ +package api + +import ( + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" +) + +// handleAgentMetricHistory serves get_metric_history. Agents hold canonical +// resource ids, while the metrics store keys history by each resource's +// MetricsTarget. This resolves one into the other and hands the read to +// handleMetricsHistory, which owns tiers, fallbacks, downsampling and the +// licensed history limit, so an agent and the history charts read the same +// series under the same entitlement. +func (r *Router) handleAgentMetricHistory(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + query, problem := parseAgentMetricHistoryQuery(req.URL.Query()) + if problem != "" { + http.Error(w, problem, http.StatusBadRequest) + return + } + if r.resourceHandlers == nil { + http.Error(w, "Resource inventory unavailable", http.StatusServiceUnavailable) + return + } + registry, err := r.resourceHandlers.buildRegistry(GetOrgID(req.Context())) + if err != nil || registry == nil { + http.Error(w, "Resource inventory unavailable", http.StatusServiceUnavailable) + return + } + _, canonicalID, ok := registry.GetByReference(query.resourceID) + if !ok || canonicalID == "" { + http.Error(w, "Unknown resourceId: use a canonical id from get_fleet_context", http.StatusNotFound) + return + } + // The registry derives the target from the resource's source mappings, + // as it does for the resources API; a stored resource may carry none. + metricsTarget := registry.MetricsTarget(canonicalID) + if metricsTarget == nil || metricsTarget.ResourceType == "" || metricsTarget.ResourceID == "" { + http.Error(w, "Pulse records no metric history for this resource", http.StatusNotFound) + return + } + + target := url.Values{} + target.Set("resourceType", metricsTarget.ResourceType) + target.Set("resourceId", metricsTarget.ResourceID) + target.Set("range", query.timeRange) + target.Set("maxPoints", strconv.Itoa(query.maxPoints)) + if query.metric != "" { + target.Set("metric", query.metric) + } + delegated := req.Clone(req.Context()) + delegated.URL.RawQuery = target.Encode() + r.handleMetricsHistory(w, delegated) +} + +type agentMetricHistoryQuery struct { + resourceID string + metric string + timeRange string + maxPoints int +} + +// parseAgentMetricHistoryQuery reads the get_metric_history inputs. Malformed +// values are a 400 with a reason rather than a silent default, so an agent +// learns what it sent wrong. +func parseAgentMetricHistoryQuery(values url.Values) (agentMetricHistoryQuery, string) { + query := agentMetricHistoryQuery{ + resourceID: strings.TrimSpace(values.Get(agentcapabilities.ResourceIDArgumentName)), + metric: strings.TrimSpace(values.Get("metric")), + timeRange: agentcapabilities.MetricHistoryDefaultRange, + maxPoints: agentcapabilities.MetricHistoryDefaultMaxPoints, + } + if query.resourceID == "" { + return query, "resourceId is required" + } + if raw := strings.ToLower(strings.TrimSpace(values.Get("range"))); raw != "" { + if !slices.Contains(agentcapabilities.MetricHistoryRanges, raw) { + return query, "range must be one of " + strings.Join(agentcapabilities.MetricHistoryRanges, ", ") + } + query.timeRange = raw + } + if raw := strings.TrimSpace(values.Get("maxPoints")); raw != "" { + maxPoints, err := strconv.Atoi(raw) + if err != nil || maxPoints < 1 { + return query, "maxPoints must be a positive integer" + } + query.maxPoints = min(maxPoints, agentcapabilities.MetricHistoryMaxPoints) + } + return query, "" +} diff --git a/internal/api/agent_metric_history_test.go b/internal/api/agent_metric_history_test.go new file mode 100644 index 0000000000..400276b680 --- /dev/null +++ b/internal/api/agent_metric_history_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// metricHistoryFixture serves one running VM through both the registry an +// agent addresses (canonical id vm:101) and the monitor the history read uses +// (metrics-store id pve1:node1:101). The VM's only history is its live point. +func metricHistoryFixture(t *testing.T, resources []unified.Resource) *Router { + t.Helper() + state := models.NewState() + state.UpdateVMsForInstance("pve1", []models.VM{{ + ID: "pve1:node1:101", VMID: 101, Name: "db-01", Node: "node1", Instance: "pve1", + Status: "running", Type: "qemu", CPU: 0.42, + Memory: models.Memory{Usage: 55.0}, Disk: models.Disk{Usage: 33.0}, + }}) + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "state", state) + setUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour)) + + mtp := config.NewMultiTenantPersistence(t.TempDir()) + if _, err := mtp.GetPersistence("default"); err != nil { + t.Fatalf("init persistence: %v", err) + } + h, _, _ := fleetFixtureHandlers(t, resources) + return &Router{monitor: monitor, licenseHandlers: NewLicenseHandlers(mtp, false), resourceHandlers: h.resources} +} + +var metricHistoryVM = unified.Resource{ + ID: "vm:101", Type: unified.ResourceTypeVM, Name: "db-01", + MetricsTarget: &unified.MetricsTarget{ResourceType: "vm", ResourceID: "pve1:node1:101"}, +} + +func getAgentMetricHistory(router *Router, values url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, agentcapabilities.MetricHistoryCapabilityPath+"?"+values.Encode(), nil) + rec := httptest.NewRecorder() + router.handleAgentMetricHistory(rec, req) + return rec +} + +func TestAgentMetricHistory_ResolvesCanonicalIDToTheChartSeries(t *testing.T) { + router := metricHistoryFixture(t, []unified.Resource{metricHistoryVM}) + rec := getAgentMetricHistory(router, url.Values{"resourceId": {"vm:101"}, "metric": {"cpu"}}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var resp metricsHistoryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ResourceId != "pve1:node1:101" || resp.Source != "live" || len(resp.Points) != 1 || math.Abs(resp.Points[0].Value-42.0) > 0.001 { + t.Fatalf("resp = %+v; want the VM's live cpu point from its metrics target", resp) + } +} + +func TestAgentMetricHistory_PassesTheLicensedHistoryLimitThrough(t *testing.T) { + router := metricHistoryFixture(t, []unified.Resource{metricHistoryVM}) + rec := getAgentMetricHistory(router, url.Values{"resourceId": {"vm:101"}, "metric": {"cpu"}, "range": {"30d"}}) + if rec.Code != http.StatusPaymentRequired || !strings.Contains(rec.Body.String(), "license_required") { + t.Fatalf("30d on Community: status = %d, body=%s; want the chart read's license_required", rec.Code, rec.Body.String()) + } +} + +// The registry names Kubernetes targets by kind (pod, k8s-node, ...), while +// the history store keeps them under one k8s type. A pod target must reach the +// history read rather than fail as an unsupported type. +func TestAgentMetricHistory_KubernetesTargetsReachTheHistoryRead(t *testing.T) { + pod := unified.Resource{ + ID: "pod:1", Type: unified.ResourceTypePod, Name: "api-0", + MetricsTarget: &unified.MetricsTarget{ResourceType: string(unified.ResourceTypePod), ResourceID: "k8s:prod:pod:default/api-0"}, + } + // The fixture monitor has no persistent store and Kubernetes has no + // in-memory fallback, so the read itself answers 503; before the store + // type accepted target kinds it was a 400 for an unsupported type. + router := metricHistoryFixture(t, []unified.Resource{pod}) + rec := getAgentMetricHistory(router, url.Values{"resourceId": {"pod:1"}, "metric": {"cpu"}}) + if rec.Code == http.StatusBadRequest || strings.Contains(rec.Body.String(), "unsupported resourceType") { + t.Fatalf("pod target rejected before the history read: status = %d, body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAgentMetricHistory_RejectsWhatItCannotServe(t *testing.T) { + noHistory := unified.Resource{ID: "app:1", Type: unified.ResourceTypeAppContainer, Name: "no-history"} + router := metricHistoryFixture(t, []unified.Resource{metricHistoryVM, noHistory}) + for _, tc := range []struct { + values url.Values + status int + }{ + {url.Values{}, http.StatusBadRequest}, + {url.Values{"resourceId": {"vm:101"}, "range": {"2y"}}, http.StatusBadRequest}, + {url.Values{"resourceId": {"vm:101"}, "maxPoints": {"0"}}, http.StatusBadRequest}, + {url.Values{"resourceId": {"vm:101"}, "maxPoints": {"ten"}}, http.StatusBadRequest}, + {url.Values{"resourceId": {"vm:999"}}, http.StatusNotFound}, + {url.Values{"resourceId": {"app:1"}}, http.StatusNotFound}, + } { + if rec := getAgentMetricHistory(router, tc.values); rec.Code != tc.status { + t.Errorf("%s: status = %d, body=%s; want %d", tc.values.Encode(), rec.Code, rec.Body.String(), tc.status) + } + } +} + +func TestAgentMetricHistoryQuery_DefaultsAndCaps(t *testing.T) { + query, problem := parseAgentMetricHistoryQuery(url.Values{"resourceId": {" vm:101 "}}) + if problem != "" || query.resourceID != "vm:101" || query.timeRange != agentcapabilities.MetricHistoryDefaultRange || query.maxPoints != agentcapabilities.MetricHistoryDefaultMaxPoints { + t.Fatalf("defaults: %+v %q", query, problem) + } + query, problem = parseAgentMetricHistoryQuery(url.Values{"resourceId": {"vm:101"}, "range": {"7D"}, "maxPoints": {"100000"}}) + if problem != "" || query.timeRange != "7d" || query.maxPoints != agentcapabilities.MetricHistoryMaxPoints { + t.Fatalf("caps: %+v %q", query, problem) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 43b18e39d7..e68489355f 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -28662,20 +28662,27 @@ func TestContract_AgentRecentChangesJSONSnapshot(t *testing.T) { } // TestContract_PulseMCPPublishesRecentChangesAsReadOnlyTool pins that MCP -// clients see list_recent_changes as a monitoring-read GET on the manifest -// route, not only the REST surface. +// clients see the event-record reads (list_recent_changes and +// get_metric_history) as monitoring-read GETs on their manifest routes, not +// only on the REST surface. func TestContract_PulseMCPPublishesRecentChangesAsReadOnlyTool(t *testing.T) { - manifest := agentcapabilities.CanonicalManifest() - for _, capability := range agentcapabilities.ManifestSurfaceToolCapabilities(manifest, agentcapabilities.SurfaceIDPulseMCP) { - if capability.Name != agentcapabilities.ListRecentChangesCapabilityName { + published := map[string]agentcapabilities.Capability{} + for _, capability := range agentcapabilities.ManifestSurfaceToolCapabilities(agentcapabilities.CanonicalManifest(), agentcapabilities.SurfaceIDPulseMCP) { + published[capability.Name] = capability + } + for name, path := range map[string]string{ + agentcapabilities.ListRecentChangesCapabilityName: agentcapabilities.RecentChangesCapabilityPath, + agentcapabilities.GetMetricHistoryCapabilityName: agentcapabilities.MetricHistoryCapabilityPath, + } { + capability, ok := published[name] + if !ok { + t.Errorf("Pulse MCP surface does not publish %s", name) continue } - if capability.Method != http.MethodGet || capability.Path != agentcapabilities.RecentChangesCapabilityPath || capability.Scope != config.ScopeMonitoringRead { - t.Fatalf("list_recent_changes = %s %s (%s); want GET %s (%s)", capability.Method, capability.Path, capability.Scope, agentcapabilities.RecentChangesCapabilityPath, config.ScopeMonitoringRead) + if capability.Method != http.MethodGet || capability.Path != path || capability.Scope != config.ScopeMonitoringRead { + t.Errorf("%s = %s %s (%s); want GET %s (%s)", name, capability.Method, capability.Path, capability.Scope, path, config.ScopeMonitoringRead) } - return } - t.Fatalf("Pulse MCP surface does not publish %s", agentcapabilities.ListRecentChangesCapabilityName) } // Change events reach only subscribers in the organization whose monitor diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index f940093786..95c646de40 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -302,6 +302,7 @@ var dynamicRouteAllowlist = []string{ "agentcapabilities.OperationsLoopStatusCompatibilityPath", "agentcapabilities.PatrolControlStatusCapabilityPath", "agentcapabilities.RecentChangesCapabilityPath", + "agentcapabilities.MetricHistoryCapabilityPath", } var publicRouteAllowlist = []string{ diff --git a/internal/api/router.go b/internal/api/router.go index f0f86c532c..3693dfe91a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -7492,7 +7492,10 @@ func normalizeMetricsHistoryResourceType(input string) (responseType string, run return "agent", "agent", []string{"agent", "node"}, nil case "disk": return "disk", "disk", []string{"disk"}, nil - case "k8s": + case "k8s", "pod", "k8s-cluster", "k8s-node", "k8s-deployment": + // Kubernetes history lives under one store type. The registry's + // MetricsTarget names the specific kind, so accept those too and let + // every caller pass a target through unchanged. return "k8s", "k8s", []string{"k8s"}, nil case "vm": return "vm", "vm", []string{"vm"}, nil diff --git a/internal/api/router_misc_additional_test.go b/internal/api/router_misc_additional_test.go index 34dd145eb9..2428bd5559 100644 --- a/internal/api/router_misc_additional_test.go +++ b/internal/api/router_misc_additional_test.go @@ -77,6 +77,10 @@ func TestNormalizeMetricsHistoryResourceType_ContainerCanonicalTypes(t *testing. wantRuntime: "app-container", wantStore: []string{"dockerContainer", "docker"}, }, + {name: "pod target", input: "pod", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}}, + {name: "k8s cluster target", input: "k8s-cluster", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}}, + {name: "k8s node target", input: "k8s-node", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}}, + {name: "k8s deployment target", input: "k8s-deployment", wantResponse: "k8s", wantRuntime: "k8s", wantStore: []string{"k8s"}}, } for _, tt := range tests { diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index bd08cdc6bb..6af10d6fb7 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -127,6 +127,12 @@ func (r *Router) registerMonitoringResourceRoutes( agentcapabilities.ListRecentChangesCapabilityName, r.agentContextHandler.HandleRecentChanges, )))) + // Agent-consumable metric history — "how did it move?" for one resource, + // served by the same history read the charts use. + r.mux.HandleFunc(agentcapabilities.MetricHistoryCapabilityPath, RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity( + agentcapabilities.GetMetricHistoryCapabilityName, + r.handleAgentMetricHistory, + )))) // Content-safe Patrol control status: the shared Patrol, Assistant, // governed action, verification, Patrol control proof, and optional MCP // readiness projection for native UI and external agents.