mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Scope Patrol health queries to resources
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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), ¬Found); 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{
|
||||
|
||||
Reference in New Issue
Block a user