mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user