Scope Patrol health queries to resources

This commit is contained in:
rcourtman
2026-08-17 04:40:41 +01:00
parent 31f89bb430
commit 39ceeda604
5 changed files with 118 additions and 24 deletions
@@ -7599,3 +7599,13 @@ evidence for at least one plausible causal peer or dependency. Later
topology/query/discovery resource entries take precedence over an earlier
empty Docker Swarm-service result when determining whether a container peer is
implicated.
Resource-scoped investigation health evidence uses the canonical resource
projection. `pulse_query` with `action=health` and a non-empty `resource_id`
must resolve through the same governed lookup as `action=get`, including the
canonical identity, lifecycle status, provider health, restart count, and
health-check dependency targets available for an app container. It must not
silently ignore the resource selector and return fleet connection counts.
Calls without `resource_id` retain the existing aggregate connection-health
overview for backward compatibility. Provider-native IDs and names remain
lookup aliases; responses preserve the canonical unified-resource ID.
+25 -21
View File
@@ -662,27 +662,28 @@ type TopologySummary struct {
// ResourceResponse is returned by pulse_get_resource
type ResourceResponse struct {
GovernedResourceMetadata
Type string `json:"type"` // "agent", "vm", "system-container", "app-container", "docker-host", "storage"
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Platform string `json:"platform,omitempty"`
Node string `json:"node,omitempty"`
Host string `json:"host,omitempty"`
CPU ResourceCPU `json:"cpu"`
Memory ResourceMemory `json:"memory"`
Disk *ResourceDisk `json:"disk,omitempty"`
OS string `json:"os,omitempty"`
Tags []string `json:"tags"`
Networks []NetworkInfo `json:"networks"`
Ports []PortInfo `json:"ports"`
Mounts []MountInfo `json:"mounts"`
Labels map[string]string `json:"labels"`
LastBackup *time.Time `json:"last_backup,omitempty"`
Image string `json:"image,omitempty"`
Health string `json:"health,omitempty"`
RestartCount int `json:"restart_count,omitempty"`
UpdateAvailable bool `json:"update_available,omitempty"`
Type string `json:"type"` // "agent", "vm", "system-container", "app-container", "docker-host", "storage"
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Platform string `json:"platform,omitempty"`
Node string `json:"node,omitempty"`
Host string `json:"host,omitempty"`
CPU ResourceCPU `json:"cpu"`
Memory ResourceMemory `json:"memory"`
Disk *ResourceDisk `json:"disk,omitempty"`
OS string `json:"os,omitempty"`
Tags []string `json:"tags"`
Networks []NetworkInfo `json:"networks"`
Ports []PortInfo `json:"ports"`
Mounts []MountInfo `json:"mounts"`
Labels map[string]string `json:"labels"`
LastBackup *time.Time `json:"last_backup,omitempty"`
Image string `json:"image,omitempty"`
Health string `json:"health,omitempty"`
HealthcheckTargets []string `json:"healthcheck_targets"`
RestartCount int `json:"restart_count,omitempty"`
UpdateAvailable bool `json:"update_available,omitempty"`
}
func EmptyResourceResponse() ResourceResponse {
@@ -705,6 +706,9 @@ func (r ResourceResponse) NormalizeCollections() ResourceResponse {
if r.Labels == nil {
r.Labels = map[string]string{}
}
if r.HealthcheckTargets == nil {
r.HealthcheckTargets = []string{}
}
for i := range r.Networks {
r.Networks[i] = r.Networks[i].NormalizeCollections()
}
@@ -326,6 +326,11 @@ func TestExecuteGetConnectionHealth(t *testing.T) {
assert.Equal(t, 2, resp.Total)
assert.Equal(t, 1, resp.Connected)
assert.Equal(t, 1, resp.Disconnected)
result, err = exec.executeQuery(ctx, map[string]interface{}{"action": "health"})
require.NoError(t, err)
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
assert.Equal(t, 2, resp.Total, "health without resource_id must retain the connection overview contract")
}
func TestConnectionHealthResponseUsesCanonicalEmptyCollections(t *testing.T) {
+18 -3
View File
@@ -2151,7 +2151,7 @@ func (e *PulseToolExecutor) registerQueryTools() {
e.registry.registerBuiltin(RegisteredTool{
Definition: Tool{
Name: agentcapabilities.PulseQueryToolName,
Description: `Query and search canonical infrastructure resources. Start here to discover systems, workloads, storage, and disks by name. Actions: search, get, config, topology, list, health.`,
Description: `Query and search canonical infrastructure resources. Start here to discover systems, workloads, storage, and disks by name. Actions: search, get, config, topology, list, health. Health returns the connection overview by default, or the canonical resource projection when resource_id is provided.`,
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
@@ -2171,7 +2171,7 @@ func (e *PulseToolExecutor) registerQueryTools() {
},
"resource_id": {
Type: "string",
Description: "Canonical resource identifier (for action: get, config). Provider-native IDs and names are accepted as lookup aliases, but canonical unified resources are returned with their canonical ID.",
Description: "Canonical resource identifier (for action: get, config, or resource-scoped health). Provider-native IDs and names are accepted as lookup aliases, but canonical unified resources are returned with their canonical ID.",
},
"type": {
Type: "string",
@@ -3612,12 +3612,25 @@ func (e *PulseToolExecutor) executeQuery(ctx context.Context, args map[string]in
case "list":
return e.executeListInfrastructure(ctx, args)
case "health":
return e.executeGetConnectionHealth(ctx, args)
return e.executeGetHealth(ctx, args)
default:
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: search, get, config, topology, list, health", action)), nil
}
}
// executeGetHealth preserves the fleet connection-health overview for callers
// that do not select a resource. Once a resource_id is present, health must be
// resolved through the same canonical, governed projection as action=get. This
// prevents a resource-scoped investigation from receiving unrelated aggregate
// connection counts while retaining backward compatibility for overview calls.
func (e *PulseToolExecutor) executeGetHealth(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
resourceID, _ := args["resource_id"].(string)
if strings.TrimSpace(resourceID) != "" {
return e.executeGetResource(ctx, args)
}
return e.executeGetConnectionHealth(ctx, args)
}
func (e *PulseToolExecutor) executeListInfrastructure(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
rs, err := e.readStateForControl()
if err != nil {
@@ -4971,6 +4984,7 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin
if resource.Docker != nil {
response.Image = strings.TrimSpace(resource.Docker.Image)
response.Health = strings.TrimSpace(resource.Docker.Health)
response.HealthcheckTargets = append([]string{}, resource.Docker.HealthcheckTargets...)
response.RestartCount = resource.Docker.RestartCount
response.Labels = resource.Docker.Labels
if update := resource.Docker.UpdateStatus; update != nil && update.UpdateAvailable {
@@ -5065,6 +5079,7 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin
response.Host = hostName
response.Image = container.Image()
response.Health = container.Health()
response.HealthcheckTargets = container.HealthcheckTargets()
response.CPU = ResourceCPU{
Percent: container.CPUPercent(),
}
+60
View File
@@ -1215,6 +1215,66 @@ func TestExecuteQuery_AppContainerPreservesCanonicalResourceIDAcrossAliases(t *t
}
}
func TestExecuteQuery_HealthScopesToCanonicalResourceWhenRequested(t *testing.T) {
const canonicalID = "app-container-97ba2f28c480ed82"
provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{{
ID: canonicalID,
Type: unifiedresources.ResourceTypeAppContainer,
Technology: "docker",
Name: "database",
Status: unifiedresources.StatusOffline,
ParentName: "colima",
Docker: &unifiedresources.DockerData{
ContainerID: strings.Repeat("9", 64),
ContainerState: "exited",
Health: "unhealthy",
HealthcheckTargets: []string{"database.internal"},
RestartCount: 3,
},
}}}
executor := NewPulseToolExecutor(ExecutorConfig{
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
UnifiedResourceProvider: provider,
})
result, err := executor.executeQuery(context.Background(), map[string]interface{}{
"action": "health",
"resource_id": canonicalID,
})
if err != nil {
t.Fatalf("resource-scoped health: %v", err)
}
var response ResourceResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
t.Fatalf("decode resource-scoped health response: %v", err)
}
if response.ID != canonicalID || response.Type != "app-container" || response.Name != "database" {
t.Fatalf("resource identity was not preserved: %+v", response)
}
if response.Status != "exited" || response.Health != "unhealthy" || response.RestartCount != 3 {
t.Fatalf("resource health facts were not preserved: %+v", response)
}
if len(response.HealthcheckTargets) != 1 || response.HealthcheckTargets[0] != "database.internal" {
t.Fatalf("health-check dependency targets were not preserved: %+v", response.HealthcheckTargets)
}
result, err = executor.executeQuery(context.Background(), map[string]interface{}{
"action": "health",
"resource_type": "app-container",
"resource_id": "missing",
})
if err != nil {
t.Fatalf("unknown resource health: %v", err)
}
var notFound map[string]interface{}
if err := json.Unmarshal([]byte(result.Content[0].Text), &notFound); err != nil {
t.Fatalf("decode unknown resource health response: %v", err)
}
if notFound["error"] != "not_found" || notFound["resource_id"] != "missing" {
t.Fatalf("unexpected unknown resource response: %+v", notFound)
}
}
func TestExecuteGetResource_RegistersTrueNASAppContainerForCanonicalControl(t *testing.T) {
provider := newTrueNASUnifiedQueryProvider(t)
resolved := &mockResolvedContext{