Resolve configuration reads from current inventory

Native container config reads confused missing session discovery with a
missing resource and could reuse stale placement. Read identity and
capability from canonical inventory while preserving explicit query
restrictions and existing action authority.

Keep unavailable providers, unsupported adapters and empty observations
distinct from resource absence, with the tool error bit preserved.
This commit is contained in:
rcourtman
2026-09-06 13:25:00 +01:00
parent 0fcb2ee147
commit 355ac1f0a4
6 changed files with 331 additions and 106 deletions
@@ -1494,3 +1494,68 @@ evidence were exercised. The source-bound receipt is
has type and selector coverage, not an active-route browser claim. Controlled
responses qualify rendering only. Landing checks remain separate from the
unperformed diagnosis, approved/rejected action and recovery qualifications.
### Configuration-read correction plan
A successful canonical container get followed by a false config `not found`
result is a source contract defect. Native configuration reads must use current
canonical inventory for identity and provider capability. Optional session
resolution preserves continuity for later actions, not proof of existence.
Explicit query restrictions must be checked before registering or refreshing a
resource. Unsupported adapters, missing configuration providers, unavailable
placement and empty provider responses remain distinct from missing inventory.
No action validation or native log-read authority changes in this slice.
Regression matrix: TrueNAS config with absent, empty and existing session
context, canonical identity across aliases, explicit query denial without a
provider call, Docker unsupported capability, genuinely missing inventory,
unavailable placement, provider failure and nil provider response. Reproduce
the failing cases before changing runtime code. Run affected Go tools checks
and focused race coverage on pulse-dev.
Browser matrix after the final build: `/patrol` Assistant tool result details
at 1440x1000, 900x1000 and 390x1000, available configuration, unsupported
capability, true missing resource and denied/provider-failed results. Exercise
open/closed details, hover and keyboard focus, Enter/Space, deepest output
scrolling, Escape, and persisted/reloaded evidence. Use captured actual tool
results to qualify rendering without claiming model diagnosis or native
provider integration. No autonomous subscription retry or separately billed
provider request is part of this correction.
### Configuration-read correction qualification
The baseline reproduced absent/empty session failures, stale session placement
and false not-found results after successful canonical gets. The corrected
read path uses canonical resource identity and current provider placement. It
checks explicit query restrictions before registration and preserves an existing
query-only session's action limits. Unsupported configuration, unavailable
provider/placement and nil provider responses carry explicit reasons and the
tool error bit. Unavailable inventory and missing read state also remain failures
rather than evidence of resource absence. Actual inventory absence remains the
existing not-found lookup result.
Fourteen focused contract cases pass with strict resolution enabled. The
existing native-config regression, full tools package and focused race proof
passed on pulse-dev with Go1.26.8. The final-source Pro binary SHA256 is
`552699cdf2e61a4ca1cea2ac5ef4e065735cbd1dbca01665456e184bd4fc3533`.
It is installed only in the existing local dev stack. No production agent or
provider configuration was changed.
Playwright passed on `/patrol` at 1440x1000, 900x1000 and 390x1000. Eight actual
tool results were replayed and inspected, including successful, unavailable,
missing, denied and failed reads. Expanded inputs/outputs, keyboard toggles,
scrolling, Escape, reload and controlled session restoration preserve exact
evidence and error state. Controlled session responses prove rendering and
reload behavior, not server persistence or a new model/native-provider result.
The source-bound browser receipt records those limits. Private artifacts are
under `/Volumes/Development/pulse/tmp/patrol-config-read-contract/` and worker
logs under `/opt/pulse-release-worker/patrol-config-read-proof/`.
PR1935's Docker correction required two legacy partial-total test expectations
to be updated in `0fcb2ee147354de770dfc4b0b9672d8c2c9dceb2`. The focused 55-test
file and scoped hook passed. Its latest CI has no failures and remains pending
completion. The configuration correction still requires its own landing checks.
Real-model retest, temporal/storage interpretation, approved and rejected
action outcomes and independent recovery proof remain open. Autonomous
subscription refusal and separately billed provider approval boundaries remain
unchanged.
+1 -1
View File
@@ -10201,7 +10201,7 @@
},
{
"id": "patrol-assistant-customer-outcome-qualification",
"summary": "The explicit redesign plan and source-bound receipts remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The goal is open. Model judgment owns diagnosis, with observations, hypotheses, proposals, executions and independently verified outcomes kept distinct. The recorded baseline has 127 paid installations, 71 with Patrol enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These are not representative success, false-alarm or missed-problem rates. Shared provenance, history, risk, missing-access and continuity corrections landed through PR1928/1929. Proposal-as-proof and proxy-driven diagnostic completion policy were removed. PR1934 merged the tool-ID and transcript-identity correction at 6b0abc3bee9ffa81f6ab298b5b67ee11369688a0 with all checks passing. Its captured-response browser replay preserves seven healthy and sixteen dependency tool records. The two ordinary subscription Assistant cases took 82.835s and 204.384s. Their primary decisions had useful evidence, but storage exclusion, recovery implications and config-not-found errors remain unqualified. Independent Docker fixtures prove injection, negative controls and deadline cleanup, not completed recovery or model/action competence. The current scoped Docker measurement correction preserves per-direction observation presence, separates corrected history from ambiguous legacy rows, removes image-layer ratios from capacity, and carries optional rates through resource/browser conversion. Legacy workload direction availability, Docker-host first-disk history and network presence remain distinct consumer/source follow-ups. Exact regression, browser and delivery state is recorded in the plan. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant is not autonomous qualification. Separate paid-provider approval remains pending and no paid request occurred. Reliable interpretation, config-read semantics, storage/backup and approved/rejected action outcomes remain required local work. Independent volunteered Pro environments remain a separate wider-readiness gate.",
"summary": "The explicit redesign plan and source-bound receipts remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The goal is open. Model judgment owns diagnosis, with observations, hypotheses, proposals, executions and independently verified outcomes kept distinct. The recorded baseline has 127 paid installations, 71 with Patrol enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These are not representative success, false-alarm or missed-problem rates. Shared provenance, history, risk, missing-access and continuity corrections landed through PR1928/1929. Proposal-as-proof and proxy-driven diagnostic completion policy were removed. PR1934 merged the tool-ID and transcript-identity correction at 6b0abc3bee9ffa81f6ab298b5b67ee11369688a0 with all checks passing. Its captured-response browser replay preserves seven healthy and sixteen dependency tool records. The two ordinary subscription Assistant cases took 82.835s and 204.384s. Their primary decisions had useful evidence, but storage exclusion, recovery implications and config-not-found errors remain unqualified. Independent Docker fixtures prove injection, negative controls and deadline cleanup, not completed recovery or model/action competence. The current scoped Docker measurement correction preserves per-direction observation presence, separates corrected history from ambiguous legacy rows, removes image-layer ratios from capacity, and carries optional rates through resource/browser conversion. Legacy workload direction availability, Docker-host first-disk history and network presence remain distinct consumer/source follow-ups. The current configuration-read correction resolves identity and placement from canonical inventory, preserves explicit query restrictions, and distinguishes unavailable capability/provider observations from missing resources with the shared tool error bit. Fourteen contract cases, full tools package, targeted race checks and captured-result browser rendering pass. Exact regression, browser and delivery state is recorded in the plan. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant is not autonomous qualification. Separate paid-provider approval remains pending and no paid request occurred. Reliable interpretation, real-model retest of config reads, storage/backup and approved/rejected action outcomes remain required local work. Independent volunteered Pro environments remain a separate wider-readiness gate.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-09-05",
@@ -25,6 +25,19 @@ that same result. Successful reads retain their content and execution provenance
## Purpose
Native app-container configuration reads resolve identity, provider and placement
from current canonical inventory. Optional session discovery cannot fabricate a
not-found result or replace current placement with a stale execution target.
Query restrictions on both the supplied reference and canonical identity are
checked before registration, and an existing session's allowed actions are not
expanded by a read. Unsupported adapters, missing providers, incomplete placement
and nil provider observations retain known resource identity and an explicit
unavailability reason with the shared tool error bit. They cannot count as a
successful configuration read. Actual inventory absence remains distinct.
`TestAppContainerConfigObservationContract` exercises these boundaries with
strict resolution enabled. This read correction does not relax action or native
log validation and does not qualify autonomous diagnosis or recovery.
The published Patrol qualification schema must accept the fault injectors used
by the executable catalogue. `TestCatalogFaultInjectorsMatchPublishedSchema`
checks the actual scenario faults against the schema enum, including the
+19 -34
View File
@@ -1,34 +1,21 @@
{
"version": 1,
"base_sha": "6b0abc3bee9ffa81f6ab298b5b67ee11369688a0",
"verified_at": "2026-09-06T11:20:14.502Z",
"base_sha": "0fcb2ee147354de770dfc4b0b9672d8c2c9dceb2",
"verified_at": "2026-09-06T12:13:06.548Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts",
"frontend-modern/src/hooks/useColumnVisibility.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/format.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx": "f183ef158f646d52b9b2ff6c3af028d7b9b993a27542690ad664ab84f93d0d73",
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts": "a3ff0b74581e769db70b12dba94e30324aa52ccbb7242b7513cf82b8f8e2fd5e",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "8957154b965f4e4240e7c19888fada63dcaf518a9e0e3a0e384b184ebaf4e716",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "4342ba59fe9dbd33ae85131a2da45ac7ebeb7efe61457b43ce32640109bd3214",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts": "ae419b49a1d761910612bf95b82e9c6226374c648d48d252259ed70b30b955c0",
"frontend-modern/src/hooks/useColumnVisibility.ts": "0112d88ca35fc0f1f3866070aa0913c706370bd6c0fb954afb7a89a34bd25cf3",
"frontend-modern/src/hooks/useUnifiedResources.ts": "4a434a0c0d2f42c1ee9dbed9ca3353ee5978821df2d9d725db1f4904f7a77b9e",
"frontend-modern/src/types/resource.ts": "a4d45afd0a4797ffe7075e8c4b0890e59f573ea8503a31eee68e22d2843cedd8",
"frontend-modern/src/utils/format.ts": "e0db6c68c1219481a9a3562239b35027916a04b1af6ec8df6fce0534a54ef8db"
"changed_paths": [],
"content_sha256": {},
"backend_content_sha256": {
"internal/ai/tools/tools_query.go": "e9411606cdf96dec84882d0649ee69fb935b36086f0b5682d98fe3ee184f6841"
},
"binary_sha256": "552699cdf2e61a4ca1cea2ac5ef4e065735cbd1dbca01665456e184bd4fc3533",
"rendering_content_sha256": {
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c",
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371",
"frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6"
},
"routes": [
"/docker",
"/patrol",
"/standalone/machines"
"/patrol"
],
"viewports": [
{
@@ -45,15 +32,13 @@
}
],
"states": [
"Live Docker container Overview and retained History against rebuilt Pro backend 59f05f954ff8080bd3e8f3054b2b059281c49172ee771b2e454c255241158a4a. Public history excludes legacy capacity and ambiguous I/O, while new observed rates remain available. Binary hash checked before and after.",
"Controlled Docker history loading, empty/unavailable, observed read zero with absent write, and error states. Controlled host partial read/write in Docker Overview and Machines. Missing directions display a dash and observed zero displays 0 B/s.",
"Controlled Assistant history tool completion on /patrol preserves zero read samples and an empty write series. This qualifies rendering only. Provider calls and infrastructure mutations were blocked. The artificial route warning is not provider health evidence.",
"Machines Disk I/O column enabled from fresh preferences, tooltip open/closed and persisted after reload at desktop. Existing responsive policy hides the column at 900 and 390 pixels. The unused UnifiedResourceHostTableCard has type and selector coverage, not an active-route browser claim."
"Actual configuration tool results captured from the final Go regression run: available config without session context, unsupported adapter, actual missing inventory resource, explicit query denial, nil provider response, provider failure, incomplete placement and unavailable inventory. This backend-only change preserves the tool error bit and exact known resource identity.",
"Eight captured results replayed through the current Assistant stream renderer and restored through controlled session responses after a full page reload. This is rendering and reload proof, not server persistence, native provider integration, model diagnosis or action qualification.",
"The local Pro backend was rebuilt on pulse-dev with Go1.26.8. Binary and source hashes matched before and after Playwright. Non-GET provider and infrastructure requests were blocked. The route warning in this controlled browser does not qualify provider readiness."
],
"interactions": [
"Container selection, Overview/History switching, keyboard focus, chart hover, scroll to deepest history, collapse and reopen, reload and history re-entry at 1440, 900 and 390 pixels. Inspected actual pixels for placement, wrapping and scrolling.",
"Machines View > Columns > Disk I/O, selected state, Escape from nested picker and parent, hover and keyboard focus on I/O tooltip, Escape and outside dismissal, reload preserving enabled column. Docker host Overview inspected at all three widths.",
"Assistant tool details expanded and collapsed with Enter and Space, output scrolled and inspected, and Escape dismissal at all widths. No action or model diagnosis was simulated as qualification.",
"Private final-source evidence: /Volumes/Development/pulse/tmp/patrol-docker-observed-metrics/browser, evidence-states and host-states. All three Playwright scripts passed after the final source edit. Runtime source hashes are recorded in runtime-binding.json."
"Tool details open/closed, hover, keyboard focus, Enter and Space, complete input/output comparison, deepest output scrolling and pixel inspection at desktop, intermediate and narrow widths. Successful reads show completed and unavailable or denied reads show failed.",
"Escape, full reload, session picker selection, reopening each restored result and exact output comparison with preserved success/error state. No model call or infrastructure action was attempted.",
"Private final-source evidence: /Volumes/Development/pulse/tmp/patrol-config-read-contract/browser, tool-evidence.json and runtime-binding.json."
]
}
+78 -71
View File
@@ -5276,110 +5276,117 @@ func (e *PulseToolExecutor) executeGetResourceConfig(ctx context.Context, args m
}
func (e *PulseToolExecutor) executeNativeAppContainerConfig(ctx context.Context, resourceRef string) (CallToolResult, error) {
if e.appContainerConfigProvider == nil {
return NewTextResult("App-container configuration not available."), nil
validation := e.validateResolvedResource(resourceRef, "query", true)
if validation.ErrorMsg != "" {
return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil
}
if validation.Resource != nil && validation.Resource.GetKind() != "app-container" {
return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceRef, validation.Resource.GetKind())), nil
}
if e.unifiedResourceProvider == nil {
return NewErrorResult(fmt.Errorf("current app-container inventory is unavailable")), nil
}
rs, err := e.readStateForControl()
if err != nil {
return NewTextResult("State information not available."), nil
return NewErrorResult(fmt.Errorf("current app-container state is unavailable: %w", err)), nil
}
governance := newGovernedQueryMetadataResolver(rs)
var resource unifiedresources.Resource
var found bool
if validation := e.validateResolvedResource(resourceRef, "query", true); validation.Resource != nil {
if matched, _, ok := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef); ok {
resource = matched
found = true
}
}
resource, providerID, found := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef)
if !found {
var containerID string
resource, containerID, found = findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef)
if !found {
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceRef,
"type": "app-container",
}), nil
}
if reg, ok := resolvedAppContainerRegistration(resource); ok {
e.registerResolvedResourceWithExplicitAccess(reg)
}
_ = containerID
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceRef,
"type": "app-container",
}), nil
}
validation := e.validateResolvedResource(resourceRef, "query", true)
if validation.Resource == nil {
if validation.ErrorMsg != "" {
return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil
}
return NewErrorResult(fmt.Errorf("app-container not found: %s", resourceRef)), nil
// Inventory owns read identity and capability. Optional session discovery
// supplies restrictions and continuity, not proof that the resource exists.
resourceID := canonicalAppContainerID(resource)
canonicalValidation := e.validateResolvedResource(resourceID, "query", true)
if canonicalValidation.ErrorMsg != "" {
return NewErrorResult(fmt.Errorf("%s", canonicalValidation.ErrorMsg)), nil
}
if validation.ErrorMsg != "" {
return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil
if canonicalValidation.Resource != nil && canonicalValidation.Resource.GetKind() != "app-container" {
return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceID, canonicalValidation.Resource.GetKind())), nil
}
resolved := validation.Resource
if resolved.GetKind() != "app-container" {
return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceRef, resolved.GetKind())), nil
platform := canonicalAppContainerAdapter(resource)
unavailable := func(reason, message string) CallToolResult {
return NewJSONResultWithIsError(map[string]interface{}{
"available": false, "reason": reason, "message": message,
"resource_id": resourceID, "type": "app-container", "platform": platform,
}, true)
}
if !strings.EqualFold(strings.TrimSpace(resolved.GetAdapter()), "truenas") {
return NewTextResult("App-container configuration not available."), nil
if platform != "truenas" {
return unavailable("unsupported_adapter", "The resource exists, but its adapter does not support configuration reads."), nil
}
if e.appContainerConfigProvider == nil {
return unavailable("provider_unavailable", "The resource exists, but its configuration provider is unavailable."), nil
}
reg, ok := resolvedAppContainerRegistration(resource)
if !ok {
return unavailable("resource_context_unavailable", "The resource exists, but its current provider identity or placement is incomplete."), nil
}
// Do not overwrite an existing session's allowed actions during a read.
if validation.Resource == nil && canonicalValidation.Resource == nil {
e.registerResolvedResourceWithExplicitAccess(reg)
}
result, err := e.appContainerConfigProvider.GetConfig(ctx, AppContainerConfigRequest{
OrgID: e.orgID,
ResourceID: strings.TrimSpace(resolved.GetResourceID()),
ProviderUID: strings.TrimSpace(resolved.GetProviderUID()),
ResourceID: resourceID,
ProviderUID: providerID,
Name: resourceDisplayName(resource),
Host: strings.TrimSpace(resolved.GetTargetHost()),
Platform: "truenas",
Host: canonicalAppContainerHost(resource),
Platform: platform,
})
if err != nil {
return NewErrorResult(err), nil
}
if result == nil {
return unavailable("empty_provider_response", "The resource exists, but the provider returned no configuration observation."), nil
}
response := EmptyAppContainerConfigResponse()
if result != nil {
response.GovernedResourceMetadata = governance.Resolve(result.Name, result.ResourceID, result.ProviderUID)
response.Type = "app-container"
response.ID = result.ProviderUID
if response.ID == "" {
response.ID = strings.TrimSpace(result.ResourceID)
}
response.Name = result.Name
response.Host = result.Host
response.Platform = result.Platform
response.Status = result.Status
response.Version = result.Version
response.HumanVersion = result.HumanVersion
response.Notes = result.Notes
response.CustomApp = result.CustomApp
response.UpgradeAvailable = result.UpgradeAvailable
response.ImageUpdatesAvailable = result.ImageUpdatesAvailable
response.ContainerCount = result.ContainerCount
response.UsedHostIPs = append([]string{}, result.UsedHostIPs...)
response.Images = append([]string{}, result.Images...)
response.Ports = append([]PortInfo{}, result.Ports...)
response.Networks = append([]NetworkInfo{}, result.Networks...)
response.Mounts = append([]MountInfo{}, result.Mounts...)
response.Containers = append([]AppContainerConfigContainer{}, result.Containers...)
}
response.GovernedResourceMetadata = governance.Resolve(result.Name, result.ResourceID, result.ProviderUID)
response.Type = "app-container"
response.ID = result.ProviderUID
if response.ID == "" {
response.ID = strings.TrimSpace(resolved.GetProviderUID())
response.ID = strings.TrimSpace(result.ResourceID)
}
response.Name = result.Name
response.Host = result.Host
response.Platform = result.Platform
response.Status = result.Status
response.Version = result.Version
response.HumanVersion = result.HumanVersion
response.Notes = result.Notes
response.CustomApp = result.CustomApp
response.UpgradeAvailable = result.UpgradeAvailable
response.ImageUpdatesAvailable = result.ImageUpdatesAvailable
response.ContainerCount = result.ContainerCount
response.UsedHostIPs = append([]string{}, result.UsedHostIPs...)
response.Images = append([]string{}, result.Images...)
response.Ports = append([]PortInfo{}, result.Ports...)
response.Networks = append([]NetworkInfo{}, result.Networks...)
response.Mounts = append([]MountInfo{}, result.Mounts...)
response.Containers = append([]AppContainerConfigContainer{}, result.Containers...)
if response.ID == "" {
response.ID = providerID
}
if response.Name == "" {
response.Name = resolvedResourceDisplayName(resolved)
response.Name = resourceDisplayName(resource)
}
if response.Host == "" {
response.Host = strings.TrimSpace(resolved.GetTargetHost())
response.Host = canonicalAppContainerHost(resource)
}
if response.Platform == "" {
response.Platform = strings.TrimSpace(resolved.GetAdapter())
response.Platform = platform
}
if response.GovernedResourceMetadata.Policy == nil && response.AISafeSummary == "" {
response.GovernedResourceMetadata = governance.Resolve(response.Name, strings.TrimSpace(resolved.GetResourceID()), response.ID)
response.GovernedResourceMetadata = governance.Resolve(response.Name, resourceID, response.ID)
}
return NewJSONResult(response.NormalizeCollections()), nil
@@ -3,13 +3,18 @@ package tools
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type stubAppContainerConfigProvider struct {
calls []AppContainerConfigRequest
result *AppContainerConfigResult
err error
empty bool
}
func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppContainerConfigRequest) (*AppContainerConfigResult, error) {
@@ -17,6 +22,9 @@ func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppCon
if s.err != nil {
return nil, s.err
}
if s.empty {
return nil, nil
}
if s.result == nil {
return &AppContainerConfigResult{
ResourceID: req.ResourceID,
@@ -38,6 +46,153 @@ func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppCon
return &result, nil
}
func TestAppContainerConfigObservationContract(t *testing.T) {
t.Setenv("PULSE_STRICT_RESOLUTION", "true")
for _, tc := range []struct {
name, session, reason, failure string
missing, docker, noProvider, noHost, empty bool
noInventory, noReadState bool
}{
{name: "without session"},
{name: "empty session", session: "empty"},
{name: "discovered session", session: "discovered"},
{name: "stale placement", session: "stale"},
{name: "unsupported adapter", docker: true, reason: "unsupported_adapter"},
{name: "unavailable provider", noProvider: true, reason: "provider_unavailable"},
{name: "missing placement", noHost: true, reason: "resource_context_unavailable"},
{name: "empty provider response", empty: true, reason: "empty_provider_response"},
{name: "provider failed", failure: "provider read failed"},
{name: "resource absent", missing: true},
{name: "inventory unavailable", noInventory: true, failure: "inventory is unavailable"},
{name: "read state unavailable", noReadState: true, failure: "state is unavailable"},
{name: "query denied", session: "denied", failure: "not permitted"},
{name: "canonical query denied through prefix", session: "canonical-denied", failure: "not permitted"},
} {
t.Run(tc.name, func(t *testing.T) {
registry := newTrueNASUnifiedQueryProvider(t)
resource, _, found := findCanonicalAppContainerResource(registry, "nextcloud")
if !found {
t.Fatal("missing canonical fixture")
}
if tc.docker {
resource.TrueNAS = nil
resource.Tags = nil
}
if tc.noHost {
resource.ParentName = ""
resource.Identity.Hostnames = nil
}
provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{resource}}
config := &stubAppContainerConfigProvider{empty: tc.empty}
if tc.failure == "provider read failed" {
config.err = errors.New(tc.failure)
}
cfg := ExecutorConfig{UnifiedResourceProvider: provider, ReadState: registry.ResourceRegistry}
if tc.noInventory {
cfg.UnifiedResourceProvider = nil
}
if tc.noReadState {
cfg.ReadState = nil
}
if !tc.noProvider {
cfg.AppContainerConfigProvider = config
}
executor := NewPulseToolExecutor(cfg)
ref := "Nextcloud"
if tc.session != "" {
resolved := &mockResolvedContext{resources: map[string]ResolvedResourceInfo{}, aliases: map[string]ResolvedResourceInfo{}}
executor.SetResolvedContext(resolved)
switch tc.session {
case "discovered":
reg, ok := resolvedAppContainerRegistration(resource)
if !ok {
t.Fatal("fixture registration unavailable")
}
resolved.AddResolvedResource(reg)
case "stale", "denied", "canonical-denied":
cached := &mockResource{resourceID: resource.ID, kind: "app-container", adapter: "docker", targetHost: "stale-host", providerUID: "stale-id", allowedActions: []string{"query"}}
if tc.session != "stale" {
cached.allowedActions = []string{"logs"}
}
resolved.resources[resource.ID] = cached
if tc.session == "canonical-denied" {
ref = "next"
} else {
resolved.aliases[ref] = cached
}
}
}
if tc.missing {
ref = "absent-container"
}
// Inventory get succeeds independently of optional session state.
if tc.session == "" && !tc.missing && !tc.noInventory && !tc.noReadState {
got, err := executor.executeGetResource(context.Background(), map[string]interface{}{"resource_type": "app-container", "resource_id": ref})
if err != nil || got.IsError || strings.Contains(got.Content[0].Text, "not_found") {
t.Fatalf("canonical get failed: %+v %v", got, err)
}
}
args := map[string]interface{}{"action": "config", "resource_type": "app-container", "resource_id": ref}
result, err := executor.executeQuery(context.Background(), args)
if err != nil {
t.Fatal(err)
}
evidence, err := json.Marshal(map[string]interface{}{"case": tc.name, "input": args, "result": result})
if err != nil {
t.Fatal(err)
}
t.Logf("CONFIG_EVIDENCE %s", evidence)
wantError := tc.failure != "" || tc.reason != ""
if result.IsError != wantError {
t.Fatalf("read error bit=%v, want %v: %+v", result.IsError, wantError, result)
}
if tc.failure != "" {
if !result.IsError || !strings.Contains(result.Content[0].Text, tc.failure) {
t.Fatalf("expected %q failure, got %+v", tc.failure, result)
}
} else {
var response map[string]interface{}
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
t.Fatal(err)
}
switch {
case tc.missing:
if response["error"] != "not_found" {
t.Fatalf("expected true absence, got %+v", response)
}
case tc.reason != "":
if response["available"] != false || response["reason"] != tc.reason || response["resource_id"] != resource.ID {
t.Fatalf("unavailable configuration lost identity or reason: %+v", response)
}
default:
if response["id"] != appContainerProviderID(resource) || response["host"] != canonicalAppContainerHost(resource) || response["platform"] != "truenas" {
t.Fatalf("incorrect config identity: %+v", response)
}
}
}
wantCalls := 1
if tc.missing || tc.docker || tc.noProvider || tc.noHost || tc.noInventory || tc.noReadState || strings.Contains(tc.session, "denied") {
wantCalls = 0
}
if len(config.calls) != wantCalls {
t.Fatalf("provider calls=%d, want %d", len(config.calls), wantCalls)
}
if wantCalls == 1 {
call := config.calls[0]
if call.ResourceID != resource.ID || call.ProviderUID != appContainerProviderID(resource) || call.Host != canonicalAppContainerHost(resource) || call.Platform != "truenas" {
t.Fatalf("request used session identity instead of canonical inventory: %+v", call)
}
}
if tc.session == "stale" {
cached, ok := executor.resolvedContext.GetResolvedResourceByID(resource.ID)
if !ok || strings.Join(cached.GetAllowedActions(), ",") != "query" {
t.Fatal("read expanded existing session action authority")
}
}
})
}
}
func TestExecuteGetResourceConfig_TrueNASAppUsesNativeConfigProvider(t *testing.T) {
provider := newTrueNASUnifiedQueryProvider(t)
resolved := &mockResolvedContext{