From 56c14ca19f3e02d58475bc4dfb5708031cd28cc3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 29 Mar 2026 20:36:43 +0100 Subject: [PATCH] feat(ai): add canonical truenas app config reads --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/ai-runtime.md | 13 +- .../v6/internal/subsystems/api-contracts.md | 7 + .../v6/internal/subsystems/monitoring.md | 6 + .../internal/subsystems/storage-recovery.md | 7 + .../internal/subsystems/unified-resources.md | 6 + internal/ai/chat/service.go | 9 + internal/ai/tools/data_types.go | 78 +++++ internal/ai/tools/executor.go | 62 +++- internal/ai/tools/tools_query.go | 289 +++++++++++++----- internal/ai/tools/tools_query_config_test.go | 154 ++++++++++ internal/ai/tools/tools_query_test.go | 6 +- internal/api/ai_handler.go | 8 + .../api/ai_handler_recovery_wiring_test.go | 21 +- internal/api/ai_handler_test.go | 3 + internal/api/contract_test.go | 3 + internal/api/router.go | 4 + internal/api/truenas_app_config_provider.go | 151 +++++++++ internal/monitoring/truenas_poller.go | 178 ++++++----- internal/monitoring/truenas_poller_test.go | 31 ++ internal/truenas/provider.go | 20 ++ internal/truenas/provider_test.go | 26 ++ internal/truenas/types.go | 7 + 23 files changed, 922 insertions(+), 172 deletions(-) create mode 100644 internal/ai/tools/tools_query_config_test.go create mode 100644 internal/api/truenas_app_config_provider.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 592c00194..b38040b36 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -438,6 +438,11 @@ backed TrueNAS app log reads may route through shared AI/runtime wiring on the platform connection and poller path, but lifecycle-adjacent setup/install flows must not reframe those diagnostics as requiring unified-agent host install before TrueNAS becomes operational in Pulse. +That same agent-optional rule also covers Assistant configuration reads. +Provider-backed TrueNAS app config may route through shared AI/runtime wiring +on the platform connection and poller path, but lifecycle-adjacent +setup/install flows must not reframe those config reads as requiring +unified-agent host install before TrueNAS becomes operational in Pulse. That same infrastructure workspace boundary now also owns the first-run handoff copy for new operators. `InfrastructureWorkspace.tsx` must tell a new Pulse user to start with `Install on a host` to add the first monitored diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index ccb467cf0..751d6670a 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -165,6 +165,13 @@ API-backed app log reads such as TrueNAS app-container logs on the shared `pulse_read` tool with `action="logs"` and `resource_id=` instead of requiring `target_host` for non-agent platforms or adding a provider-local log-read tool. +That same AI tool ownership now also includes canonical resource-native +configuration reads. `internal/ai/tools/tools_query.go`, +`internal/ai/tools/executor.go`, and `internal/api/router.go` must keep +API-backed app configuration reads such as TrueNAS app-container runtime +shape on the shared `pulse_query` tool with `action="config"` and +`resource_id=` instead of forcing those resources through the +guest-config shim or adding a provider-local config tool. That same AI tool ownership also applies to recovery-backed storage reads. When `internal/ai/tools/adapters.go` returns recovery points with malformed persisted metadata omitted at the shared recovery-store boundary, the storage @@ -298,7 +305,11 @@ top-level pools. That same requirement includes `pulse_query action=config`: guest-config payloads must carry canonical resource policy metadata, and config-fact extraction must not persist raw guest hostnames when governed redaction covers -hostname or platform identity fields. +hostname or platform identity fields. The same `action=config` contract now +also applies to API-backed canonical `app-container` resources such as +TrueNAS apps: runtime routing must resolve the shared resource identity first +and then read native config through the owned provider path rather than +falling back to guest semantics. Outbound model-bound context exports now also belong to this runtime boundary. When the AI service assembles unified-resource context for a model request, it must record a durable export audit with the active destination diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a61ddc5b0..bf77d0b6a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -595,6 +595,13 @@ native TrueNAS app log reads into `internal/api/router.go`, parallel public `/api/truenas/apps/.../logs` surface; provider-backed app log reads for Pulse Assistant stay behind the shared `pulse_read` runtime tool contract unless this API contract changes in the same slice. +That same negative-space rule also applies to assistant configuration reads. +Wiring native TrueNAS app config into `internal/api/router.go`, +`internal/api/ai_handler.go`, or adjacent backend helpers must not introduce a +parallel public `/api/truenas/apps/.../config` surface; provider-backed app +config for Pulse Assistant stays behind the shared `pulse_query +action="config"` runtime tool contract unless this API contract changes in the +same slice. The monitored-system ledger contract now also carries a canonical grouping explanation payload. `/api/license/monitored-system-ledger` must expose the shared monitored-system explanation summary, sanitized grouping reasons, and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 51a3dc82d..2ff8d1761 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -257,6 +257,12 @@ That same monitoring boundary now also owns canonical TrueNAS app log reads. through the owned `/api/current` JSON-RPC runtime and tenant-scoped poller selection path, so assistant-driven diagnostics do not depend on the unified agent or a parallel config-local read path. +That same monitoring boundary now also owns canonical TrueNAS app +configuration reads. `internal/truenas/provider.go` and +`internal/monitoring/truenas_poller.go` must serve API-backed app-container +runtime/config shape through the same tenant-scoped provider snapshot and app +selection path used for control and logs, so assistant config reads do not +fork into a separate ad hoc fetch path or stale config cache. That same monitoring boundary now also owns API-backed TrueNAS system telemetry for the top-level NAS host. `internal/truenas/client.go` must ingest `reporting.realtime` through the official `/api/current` JSON-RPC websocket diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bfa0fd32f..bf97a78c2 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -82,6 +82,7 @@ querying, and the operator-facing storage health presentation layer. 21. Keep provider-backed recovery onboarding on the adjacent platform-connections contract. When `internal/api/` grows or changes TrueNAS connection CRUD, masked-secret preservation, or similar provider setup flows, storage and recovery may consume the resulting recovery points but must not absorb that connection-management ownership into storage/recovery-local handlers or page flows. 22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape. 23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape. +24. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload. ## Forbidden Paths @@ -1531,3 +1532,9 @@ AI/runtime wiring and the poller's provider selection path, but storage and recovery surfaces must not grow a second recovery-local log transport or diagnostic payload contract just because those reads can inform operator investigation. +That same boundary also owns the line between recovery data and assistant +configuration reads. Backend-native TrueNAS app config may route through +shared AI/runtime wiring and the poller's provider selection path, but storage +and recovery surfaces must not grow a second recovery-local config transport +or provider-shaped configuration payload just because those reads can inform +operator investigation. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index fdf184ed3..06d0d7338 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -196,6 +196,12 @@ runtime paths such as `pulse_read` must resolve API-backed TrueNAS apps through the shared canonical `app-container` identity and `resource_id` contract instead of reintroducing host-local container routing assumptions for platforms that do not use the unified agent as their primary runtime path. +That same canonical app-container rule now also governs configuration reads. +Assistant runtime paths such as `pulse_query action="config"` must resolve +API-backed TrueNAS apps through the same canonical `app-container` identity +and `resource_id` contract, then project native runtime/config shape through +the shared app-container payload rather than forcing those resources through +guest-config routing or inventing a TrueNAS-local config type. The canonical resource timeline now also owns durable incident-response facts that materially changed resource investigation state. `ResourceChange` kinds such as `alert_fired`, `alert_acknowledged`, `alert_unacknowledged`, diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 58a031782..c37669842 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -44,6 +44,7 @@ type ( MCPMetricsHistoryProvider = tools.MetricsHistoryProvider MCPBackupProvider = tools.BackupProvider MCPGuestConfigProvider = tools.GuestConfigProvider + MCPAppContainerConfigProvider = tools.AppContainerConfigProvider MCPDiskHealthProvider = tools.DiskHealthProvider MCPUpdatesProvider = tools.UpdatesProvider MCPAppContainerActionProvider = tools.AppContainerActionProvider @@ -1163,6 +1164,14 @@ func (s *Service) SetGuestConfigProvider(provider MCPGuestConfigProvider) { } } +func (s *Service) SetAppContainerConfigProvider(provider MCPAppContainerConfigProvider) { + s.mu.Lock() + defer s.mu.Unlock() + if s.executor != nil { + s.executor.SetAppContainerConfigProvider(provider) + } +} + func (s *Service) SetDiskHealthProvider(provider MCPDiskHealthProvider) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/ai/tools/data_types.go b/internal/ai/tools/data_types.go index 9a2181ae0..ab8f6ed7a 100644 --- a/internal/ai/tools/data_types.go +++ b/internal/ai/tools/data_types.go @@ -755,6 +755,84 @@ type GuestDiskConfig struct { Value string `json:"value"` } +// AppContainerConfigResponse is returned by pulse_query action=config for +// API-backed app-container resources. +type AppContainerConfigResponse struct { + GovernedResourceMetadata + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host,omitempty"` + Platform string `json:"platform,omitempty"` + Status string `json:"status,omitempty"` + Version string `json:"version,omitempty"` + HumanVersion string `json:"human_version,omitempty"` + Notes string `json:"notes,omitempty"` + CustomApp bool `json:"custom_app,omitempty"` + UpgradeAvailable bool `json:"upgrade_available,omitempty"` + ImageUpdatesAvailable bool `json:"image_updates_available,omitempty"` + ContainerCount int `json:"container_count,omitempty"` + UsedHostIPs []string `json:"used_host_ips"` + Images []string `json:"images"` + Ports []PortInfo `json:"ports"` + Networks []NetworkInfo `json:"networks"` + Mounts []MountInfo `json:"mounts"` + Containers []AppContainerConfigContainer `json:"containers"` +} + +func EmptyAppContainerConfigResponse() AppContainerConfigResponse { + return AppContainerConfigResponse{}.NormalizeCollections() +} + +func (r AppContainerConfigResponse) NormalizeCollections() AppContainerConfigResponse { + if r.UsedHostIPs == nil { + r.UsedHostIPs = []string{} + } + if r.Images == nil { + r.Images = []string{} + } + if r.Ports == nil { + r.Ports = []PortInfo{} + } + if r.Networks == nil { + r.Networks = []NetworkInfo{} + } + if r.Mounts == nil { + r.Mounts = []MountInfo{} + } + if r.Containers == nil { + r.Containers = []AppContainerConfigContainer{} + } + for i := range r.Networks { + r.Networks[i] = r.Networks[i].NormalizeCollections() + } + for i := range r.Containers { + r.Containers[i] = r.Containers[i].NormalizeCollections() + } + return r +} + +// AppContainerConfigContainer summarizes one runtime container inside a +// canonical app-container configuration response. +type AppContainerConfigContainer struct { + ID string `json:"id"` + Service string `json:"service,omitempty"` + Image string `json:"image,omitempty"` + State string `json:"state,omitempty"` + Ports []PortInfo `json:"ports"` + Mounts []MountInfo `json:"mounts"` +} + +func (c AppContainerConfigContainer) NormalizeCollections() AppContainerConfigContainer { + if c.Ports == nil { + c.Ports = []PortInfo{} + } + if c.Mounts == nil { + c.Mounts = []MountInfo{} + } + return c +} + // ResourceCPU describes CPU usage type ResourceCPU struct { Percent float64 `json:"percent"` diff --git a/internal/ai/tools/executor.go b/internal/ai/tools/executor.go index ecd6ea3c6..8c99f0cfc 100644 --- a/internal/ai/tools/executor.go +++ b/internal/ai/tools/executor.go @@ -152,6 +152,47 @@ type GuestConfigProvider interface { GetGuestConfig(guestType, instance, node string, vmID int) (map[string]interface{}, error) } +// AppContainerConfigRequest describes a canonical configuration read against an +// API-backed app-container resource. +type AppContainerConfigRequest struct { + OrgID string + ResourceID string + ProviderUID string + Name string + Host string + Platform string +} + +// AppContainerConfigResult captures canonical configuration/state metadata for +// an API-backed app-container resource. +type AppContainerConfigResult struct { + ResourceID string + ProviderUID string + Name string + Host string + Platform string + Status string + Version string + HumanVersion string + Notes string + CustomApp bool + UpgradeAvailable bool + ImageUpdatesAvailable bool + ContainerCount int + UsedHostIPs []string + Images []string + Ports []PortInfo + Networks []NetworkInfo + Mounts []MountInfo + Containers []AppContainerConfigContainer +} + +// AppContainerConfigProvider executes canonical configuration reads for +// API-backed app-container resources such as TrueNAS-managed applications. +type AppContainerConfigProvider interface { + GetConfig(ctx context.Context, req AppContainerConfigRequest) (*AppContainerConfigResult, error) +} + // DiskHealthProvider provides disk health information from host agents type DiskHealthProvider interface { GetHosts() []*unifiedresources.HostView @@ -413,9 +454,10 @@ type ExecutorConfig struct { ConnectionHealth ConnectionHealthProvider RecoveryPointsProvider RecoveryPointsProvider - GuestConfigProvider GuestConfigProvider - DiskHealthProvider DiskHealthProvider - UpdatesProvider UpdatesProvider + GuestConfigProvider GuestConfigProvider + AppContainerConfigProvider AppContainerConfigProvider + DiskHealthProvider DiskHealthProvider + UpdatesProvider UpdatesProvider // Optional providers - management MetadataUpdater MetadataUpdater @@ -466,9 +508,10 @@ type PulseToolExecutor struct { // Paged recovery points access for snapshot/backup tools. recoveryPointsProvider RecoveryPointsProvider - guestConfigProvider GuestConfigProvider - diskHealthProvider DiskHealthProvider - updatesProvider UpdatesProvider + guestConfigProvider GuestConfigProvider + appContainerConfigProvider AppContainerConfigProvider + diskHealthProvider DiskHealthProvider + updatesProvider UpdatesProvider // Management providers metadataUpdater MetadataUpdater @@ -551,6 +594,7 @@ func NewPulseToolExecutor(cfg ExecutorConfig) *PulseToolExecutor { recoveryPointsProvider: cfg.RecoveryPointsProvider, guestConfigProvider: cfg.GuestConfigProvider, + appContainerConfigProvider: cfg.AppContainerConfigProvider, diskHealthProvider: cfg.DiskHealthProvider, updatesProvider: cfg.UpdatesProvider, metadataUpdater: cfg.MetadataUpdater, @@ -725,6 +769,12 @@ func (e *PulseToolExecutor) SetGuestConfigProvider(provider GuestConfigProvider) e.guestConfigProvider = provider } +// SetAppContainerConfigProvider sets the provider used for canonical native +// app-container configuration reads. +func (e *PulseToolExecutor) SetAppContainerConfigProvider(provider AppContainerConfigProvider) { + e.appContainerConfigProvider = provider +} + // SetDiskHealthProvider sets the disk health provider func (e *PulseToolExecutor) SetDiskHealthProvider(provider DiskHealthProvider) { e.diskHealthProvider = provider diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 7bc5ffc7f..c12ebd49e 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -2021,7 +2021,7 @@ func (e *PulseToolExecutor) registerQueryTools() { }, "resource_type": { Type: "string", - Description: "Resource type. For get: 'system', 'vm', 'system-container', 'app-container'. For config: 'vm' or 'system-container'. For search: use type or resource_type with canonical values.", + Description: "Resource type. For get: 'system', 'vm', 'system-container', 'app-container'. For config: 'vm', 'system-container', or supported API-backed 'app-container'. For search: use type or resource_type with canonical values.", Enum: []string{"system", "vm", "system-container", "app-container", "node", "docker-host", "storage-pool", "physical-disk"}, }, "resource_id": { @@ -2330,6 +2330,33 @@ func canonicalAppContainerHost(resource unifiedresources.Resource) string { return "" } +func matchCanonicalAppContainerResource(resource unifiedresources.Resource, resourceID string) (string, bool) { + query := strings.TrimSpace(resourceID) + if query == "" { + return "", false + } + containerID := canonicalAppContainerID(resource) + if strings.EqualFold(containerID, query) || + strings.EqualFold(resourceDisplayName(resource), query) || + strings.EqualFold(strings.TrimSpace(resource.ID), query) || + strings.HasPrefix(strings.ToLower(containerID), strings.ToLower(query)) { + return containerID, true + } + return "", false +} + +func findCanonicalAppContainerResource(provider UnifiedResourceProvider, resourceID string) (unifiedresources.Resource, string, bool) { + if provider == nil { + return unifiedresources.Resource{}, "", false + } + for _, resource := range provider.GetByType(unifiedresources.ResourceTypeAppContainer) { + if containerID, ok := matchCanonicalAppContainerResource(resource, resourceID); ok { + return resource, containerID, true + } + } + return unifiedresources.Resource{}, "", false +} + func guestExecutorActions() []string { return []string{"query", "get", "logs", "console", "exec", "start", "stop", "shutdown", "restart", "delete"} } @@ -2584,7 +2611,7 @@ func (e *PulseToolExecutor) executeQuery(ctx context.Context, args map[string]in case "get": return e.executeGetResource(ctx, args) case "config": - return e.executeGetGuestConfig(ctx, args) + return e.executeGetResourceConfig(ctx, args) case "topology": return e.executeGetTopology(ctx, args) case "list": @@ -3858,83 +3885,73 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin }), nil case "app-container": - if e.unifiedResourceProvider != nil { - for _, resource := range e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeAppContainer) { - containerID := canonicalAppContainerID(resource) - if !strings.EqualFold(containerID, strings.TrimSpace(resourceID)) && - !strings.EqualFold(resourceDisplayName(resource), strings.TrimSpace(resourceID)) && - !strings.EqualFold(strings.TrimSpace(resource.ID), strings.TrimSpace(resourceID)) && - !strings.HasPrefix(strings.ToLower(containerID), strings.ToLower(strings.TrimSpace(resourceID))) { - continue - } - - response := EmptyResourceResponse() - response.GovernedResourceMetadata = governance.Resolve(resourceDisplayName(resource), resource.ID, containerID) - response.Type = "app-container" - response.ID = containerID - response.Name = resourceDisplayName(resource) - response.Status = canonicalAppContainerState(resource) - response.Platform = canonicalResourcePlatform(resource) - response.Host = canonicalAppContainerHost(resource) - response.CPU = ResourceCPU{ - Percent: metricPercent(resourceMetric(resource, "cpu")), - } - response.Memory = ResourceMemory{ - Percent: metricPercent(resourceMetric(resource, "memory")), - UsedGB: metricUsedGB(resourceMetric(resource, "memory")), - TotalGB: metricTotalGB(resourceMetric(resource, "memory")), - } - if diskMetric := resourceMetric(resource, "disk"); diskMetric != nil { - response.Disk = &ResourceDisk{ - Percent: metricPercent(diskMetric), - UsedGB: metricUsedGB(diskMetric), - TotalGB: metricTotalGB(diskMetric), - } - } - response.Tags = append([]string{}, resource.Tags...) - if resource.Docker != nil { - response.Image = strings.TrimSpace(resource.Docker.Image) - response.Health = strings.TrimSpace(resource.Docker.Health) - response.RestartCount = resource.Docker.RestartCount - response.Labels = resource.Docker.Labels - if update := resource.Docker.UpdateStatus; update != nil && update.UpdateAvailable { - response.UpdateAvailable = true - } - for _, p := range resource.Docker.Ports { - response.Ports = append(response.Ports, PortInfo{ - Private: p.PrivatePort, - Public: p.PublicPort, - Protocol: p.Protocol, - IP: p.IP, - }) - } - for _, n := range resource.Docker.Networks { - addresses := make([]string, 0, 2) - if n.IPv4 != "" { - addresses = append(addresses, n.IPv4) - } - if n.IPv6 != "" { - addresses = append(addresses, n.IPv6) - } - response.Networks = append(response.Networks, NetworkInfo{ - Name: n.Name, - Addresses: addresses, - }) - } - for _, m := range resource.Docker.Mounts { - response.Mounts = append(response.Mounts, MountInfo{ - Source: m.Source, - Destination: m.Destination, - ReadWrite: !strings.EqualFold(strings.TrimSpace(m.Mode), "ro"), - }) - } - } - - if reg, ok := resolvedAppContainerRegistration(resource); ok { - e.registerResolvedResourceWithExplicitAccess(reg) - } - return NewJSONResult(response.NormalizeCollections()), nil + if resource, containerID, ok := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceID); ok { + response := EmptyResourceResponse() + response.GovernedResourceMetadata = governance.Resolve(resourceDisplayName(resource), resource.ID, containerID) + response.Type = "app-container" + response.ID = containerID + response.Name = resourceDisplayName(resource) + response.Status = canonicalAppContainerState(resource) + response.Platform = canonicalResourcePlatform(resource) + response.Host = canonicalAppContainerHost(resource) + response.CPU = ResourceCPU{ + Percent: metricPercent(resourceMetric(resource, "cpu")), } + response.Memory = ResourceMemory{ + Percent: metricPercent(resourceMetric(resource, "memory")), + UsedGB: metricUsedGB(resourceMetric(resource, "memory")), + TotalGB: metricTotalGB(resourceMetric(resource, "memory")), + } + if diskMetric := resourceMetric(resource, "disk"); diskMetric != nil { + response.Disk = &ResourceDisk{ + Percent: metricPercent(diskMetric), + UsedGB: metricUsedGB(diskMetric), + TotalGB: metricTotalGB(diskMetric), + } + } + response.Tags = append([]string{}, resource.Tags...) + if resource.Docker != nil { + response.Image = strings.TrimSpace(resource.Docker.Image) + response.Health = strings.TrimSpace(resource.Docker.Health) + response.RestartCount = resource.Docker.RestartCount + response.Labels = resource.Docker.Labels + if update := resource.Docker.UpdateStatus; update != nil && update.UpdateAvailable { + response.UpdateAvailable = true + } + for _, p := range resource.Docker.Ports { + response.Ports = append(response.Ports, PortInfo{ + Private: p.PrivatePort, + Public: p.PublicPort, + Protocol: p.Protocol, + IP: p.IP, + }) + } + for _, n := range resource.Docker.Networks { + addresses := make([]string, 0, 2) + if n.IPv4 != "" { + addresses = append(addresses, n.IPv4) + } + if n.IPv6 != "" { + addresses = append(addresses, n.IPv6) + } + response.Networks = append(response.Networks, NetworkInfo{ + Name: n.Name, + Addresses: addresses, + }) + } + for _, m := range resource.Docker.Mounts { + response.Mounts = append(response.Mounts, MountInfo{ + Source: m.Source, + Destination: m.Destination, + ReadWrite: !strings.EqualFold(strings.TrimSpace(m.Mode), "ro"), + }) + } + } + + if reg, ok := resolvedAppContainerRegistration(resource); ok { + e.registerResolvedResourceWithExplicitAccess(reg) + } + return NewJSONResult(response.NormalizeCollections()), nil } dockerHostsByID := make(map[string]*unifiedresources.DockerHostView) @@ -4086,9 +4103,10 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin } } -func (e *PulseToolExecutor) executeGetGuestConfig(_ context.Context, args map[string]interface{}) (CallToolResult, error) { +func (e *PulseToolExecutor) executeGetResourceConfig(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { resourceType, _ := args["resource_type"].(string) resourceID, _ := args["resource_id"].(string) + resourceType = canonicalQueryResourceType(resourceType) if resourceType == "" { return NewErrorResult(fmt.Errorf("resource_type is required")), nil @@ -4096,6 +4114,9 @@ func (e *PulseToolExecutor) executeGetGuestConfig(_ context.Context, args map[st if resourceID == "" { return NewErrorResult(fmt.Errorf("resource_id is required")), nil } + if resourceType == "app-container" { + return e.executeNativeAppContainerConfig(ctx, strings.TrimSpace(resourceID)) + } if e.guestConfigProvider == nil { return NewTextResult("Guest configuration not available."), nil } @@ -4157,6 +4178,116 @@ func (e *PulseToolExecutor) executeGetGuestConfig(_ context.Context, args map[st return NewJSONResult(response.NormalizeCollections()), nil } +func (e *PulseToolExecutor) executeNativeAppContainerConfig(ctx context.Context, resourceRef string) (CallToolResult, error) { + if e.appContainerConfigProvider == nil { + return NewTextResult("App-container configuration not available."), nil + } + + rs, err := e.readStateForControl() + if err != nil { + return NewTextResult("State information not available."), 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 + } + } + 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 + } + + 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 + } + if validation.ErrorMsg != "" { + return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil + } + resolved := validation.Resource + if resolved.GetKind() != "app-container" { + return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceRef, resolved.GetKind())), nil + } + if !strings.EqualFold(strings.TrimSpace(resolved.GetAdapter()), "truenas") { + return NewTextResult("App-container configuration not available."), nil + } + + result, err := e.appContainerConfigProvider.GetConfig(ctx, AppContainerConfigRequest{ + OrgID: e.orgID, + ResourceID: strings.TrimSpace(resolved.GetResourceID()), + ProviderUID: strings.TrimSpace(resolved.GetProviderUID()), + Name: resourceDisplayName(resource), + Host: strings.TrimSpace(resolved.GetTargetHost()), + Platform: "truenas", + }) + if err != nil { + return NewErrorResult(err), 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...) + } + if response.ID == "" { + response.ID = strings.TrimSpace(resolved.GetProviderUID()) + } + if response.Name == "" { + response.Name = resolvedResourceDisplayName(resolved) + } + if response.Host == "" { + response.Host = strings.TrimSpace(resolved.GetTargetHost()) + } + if response.Platform == "" { + response.Platform = strings.TrimSpace(resolved.GetAdapter()) + } + if response.GovernedResourceMetadata.Policy == nil && response.AISafeSummary == "" { + response.GovernedResourceMetadata = governance.Resolve(response.Name, strings.TrimSpace(resolved.GetResourceID()), response.ID) + } + + return NewJSONResult(response.NormalizeCollections()), nil +} + func resolveGuestFromReadState(rs unifiedresources.ReadState, resourceType, resourceID string) (guestType string, vmID int, name, node, instance string, err error) { resourceType = canonicalQueryResourceType(resourceType) resourceID = strings.TrimSpace(resourceID) diff --git a/internal/ai/tools/tools_query_config_test.go b/internal/ai/tools/tools_query_config_test.go new file mode 100644 index 000000000..b6f338a3e --- /dev/null +++ b/internal/ai/tools/tools_query_config_test.go @@ -0,0 +1,154 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" +) + +type stubAppContainerConfigProvider struct { + calls []AppContainerConfigRequest + result *AppContainerConfigResult + err error +} + +func (s *stubAppContainerConfigProvider) GetConfig(_ context.Context, req AppContainerConfigRequest) (*AppContainerConfigResult, error) { + s.calls = append(s.calls, req) + if s.err != nil { + return nil, s.err + } + if s.result == nil { + return &AppContainerConfigResult{ + ResourceID: req.ResourceID, + ProviderUID: req.ProviderUID, + Name: req.Name, + Host: req.Host, + Platform: req.Platform, + Status: "running", + ContainerCount: 1, + UsedHostIPs: []string{}, + Images: []string{}, + Ports: []PortInfo{}, + Networks: []NetworkInfo{}, + Mounts: []MountInfo{}, + Containers: []AppContainerConfigContainer{}, + }, nil + } + result := *s.result + return &result, nil +} + +func TestExecuteGetResourceConfig_TrueNASAppUsesNativeConfigProvider(t *testing.T) { + provider := newTrueNASUnifiedQueryProvider(t) + resolved := &mockResolvedContext{ + resources: make(map[string]ResolvedResourceInfo), + aliases: make(map[string]ResolvedResourceInfo), + } + configProvider := &stubAppContainerConfigProvider{ + result: &AppContainerConfigResult{ + ResourceID: "app-container:truenas-main:nextcloud", + ProviderUID: "nextcloud", + Name: "Nextcloud", + Host: "truenas-main", + Platform: "truenas", + Status: "running", + Version: "1.0.3", + HumanVersion: "29.0.7", + Notes: "Team cloud and file sync", + UpgradeAvailable: true, + ImageUpdatesAvailable: true, + ContainerCount: 2, + UsedHostIPs: []string{"0.0.0.0"}, + Images: []string{ + "docker.io/library/nextcloud:29.0.7", + "docker.io/library/redis:7.2", + }, + Ports: []PortInfo{{ + Private: 443, + Public: 30443, + Protocol: "tcp", + IP: "0.0.0.0", + }}, + Networks: []NetworkInfo{{ + Name: "ix-nextcloud_default", + }}, + Mounts: []MountInfo{{ + Source: "/mnt/tank/apps/nextcloud", + Destination: "/var/www/html", + ReadWrite: true, + }}, + Containers: []AppContainerConfigContainer{{ + ID: "nextcloud-web-1", + Service: "nextcloud", + Image: "docker.io/library/nextcloud:29.0.7", + State: "running", + Ports: []PortInfo{{ + Private: 443, + Public: 30443, + Protocol: "tcp", + IP: "0.0.0.0", + }}, + Mounts: []MountInfo{{ + Source: "/mnt/tank/apps/nextcloud", + Destination: "/var/www/html", + ReadWrite: true, + }}, + }}, + }, + } + + executor := NewPulseToolExecutor(ExecutorConfig{ + UnifiedResourceProvider: provider, + ReadState: provider.ResourceRegistry, + AppContainerConfigProvider: configProvider, + }) + executor.SetResolvedContext(resolved) + + if _, err := executor.executeGetResource(context.Background(), map[string]interface{}{ + "resource_type": "app-container", + "resource_id": "nextcloud", + }); err != nil { + t.Fatalf("seed resolved context: unexpected error: %v", err) + } + + result, err := executor.executeGetResourceConfig(context.Background(), map[string]interface{}{ + "resource_type": "app-container", + "resource_id": "Nextcloud", + }) + if err != nil { + t.Fatalf("executeGetResourceConfig(app-container): unexpected error: %v", err) + } + if result.IsError { + t.Fatalf("expected success result, got %+v", result) + } + + var response AppContainerConfigResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil { + t.Fatalf("decode app-container config response: %v", err) + } + if response.Type != "app-container" || response.ID != "nextcloud" || response.Name != "Nextcloud" { + t.Fatalf("unexpected config identity: %+v", response) + } + if response.Platform != "truenas" || response.Host != "truenas-main" || response.Status != "running" { + t.Fatalf("unexpected config placement/state: %+v", response) + } + if response.Policy == nil { + t.Fatal("expected governed policy metadata on app config response") + } + if response.AISafeSummary == "" { + t.Fatal("expected aiSafeSummary on app config response") + } + if len(response.Containers) != 1 || response.Containers[0].Service != "nextcloud" { + t.Fatalf("unexpected app container config shape: %+v", response.Containers) + } + if len(configProvider.calls) != 1 { + t.Fatalf("expected one native config call, got %+v", configProvider.calls) + } + call := configProvider.calls[0] + if call.OrgID != "default" || call.ProviderUID != "nextcloud" || call.Host != "truenas-main" || call.Platform != "truenas" { + t.Fatalf("unexpected native app config request: %+v", call) + } + if call.ResourceID == "" { + t.Fatalf("expected canonical resource id in native app config request, got %+v", call) + } +} diff --git a/internal/ai/tools/tools_query_test.go b/internal/ai/tools/tools_query_test.go index 25379e51f..a2b988598 100644 --- a/internal/ai/tools/tools_query_test.go +++ b/internal/ai/tools/tools_query_test.go @@ -1396,7 +1396,7 @@ func TestExecuteGetResource_MissingArgs(t *testing.T) { } } -func TestExecuteGetGuestConfig_SystemContainerUsesCanonicalResolution(t *testing.T) { +func TestExecuteGetResourceConfig_SystemContainerUsesCanonicalResolution(t *testing.T) { guestCfg := &mockGuestConfigProvider{ config: map[string]interface{}{ "hostname": "ct1", @@ -1416,7 +1416,7 @@ func TestExecuteGetGuestConfig_SystemContainerUsesCanonicalResolution(t *testing GuestConfigProvider: guestCfg, }) - result, _ := executor.executeGetGuestConfig(context.Background(), map[string]interface{}{ + result, _ := executor.executeGetResourceConfig(context.Background(), map[string]interface{}{ "resource_type": "system-container", "resource_id": "ct1", }) @@ -1458,7 +1458,7 @@ func TestExecuteGetGuestConfig_RejectsLegacyResourceTypes(t *testing.T) { for _, resourceType := range []string{"lxc", "host"} { t.Run(resourceType, func(t *testing.T) { - result, _ := executor.executeGetGuestConfig(context.Background(), map[string]interface{}{ + result, _ := executor.executeGetResourceConfig(context.Background(), map[string]interface{}{ "resource_type": resourceType, "resource_id": "ct1", }) diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 77cd3cdd0..c27a9cf7f 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -56,6 +56,7 @@ type AIService interface { SetMetricsHistory(provider chat.MCPMetricsHistoryProvider) SetAgentProfileManager(manager chat.AgentProfileManager) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) + SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) SetBackupProvider(provider chat.MCPBackupProvider) SetDiskHealthProvider(provider chat.MCPDiskHealthProvider) SetUpdatesProvider(provider chat.MCPUpdatesProvider) @@ -1234,6 +1235,13 @@ func (h *AIHandler) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) } } +// SetAppContainerConfigProvider sets the native app-container config provider for MCP tools +func (h *AIHandler) SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) { + if svc := h.getDefaultService(); svc != nil { + svc.SetAppContainerConfigProvider(provider) + } +} + // SetBackupProvider sets the backup provider for MCP tools func (h *AIHandler) SetBackupProvider(provider chat.MCPBackupProvider) { if svc := h.getDefaultService(); svc != nil { diff --git a/internal/api/ai_handler_recovery_wiring_test.go b/internal/api/ai_handler_recovery_wiring_test.go index b16f822b1..a555c4ab1 100644 --- a/internal/api/ai_handler_recovery_wiring_test.go +++ b/internal/api/ai_handler_recovery_wiring_test.go @@ -15,6 +15,7 @@ import ( type capturingAIService struct { running bool + appContainerConfigProvider chat.MCPAppContainerConfigProvider appContainerActionProvider chat.MCPAppContainerActionProvider appContainerReadProvider chat.MCPAppContainerReadProvider } @@ -61,13 +62,16 @@ func (s *capturingAIService) UnrevertSession(ctx context.Context, sessionID stri func (s *capturingAIService) AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error { return nil } -func (s *capturingAIService) SetAlertProvider(provider chat.MCPAlertProvider) {} -func (s *capturingAIService) SetFindingsProvider(provider chat.MCPFindingsProvider) {} -func (s *capturingAIService) SetBaselineProvider(provider chat.MCPBaselineProvider) {} -func (s *capturingAIService) SetPatternProvider(provider chat.MCPPatternProvider) {} -func (s *capturingAIService) SetMetricsHistory(provider chat.MCPMetricsHistoryProvider) {} -func (s *capturingAIService) SetAgentProfileManager(manager chat.AgentProfileManager) {} -func (s *capturingAIService) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) {} +func (s *capturingAIService) SetAlertProvider(provider chat.MCPAlertProvider) {} +func (s *capturingAIService) SetFindingsProvider(provider chat.MCPFindingsProvider) {} +func (s *capturingAIService) SetBaselineProvider(provider chat.MCPBaselineProvider) {} +func (s *capturingAIService) SetPatternProvider(provider chat.MCPPatternProvider) {} +func (s *capturingAIService) SetMetricsHistory(provider chat.MCPMetricsHistoryProvider) {} +func (s *capturingAIService) SetAgentProfileManager(manager chat.AgentProfileManager) {} +func (s *capturingAIService) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) {} +func (s *capturingAIService) SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) { + s.appContainerConfigProvider = provider +} func (s *capturingAIService) SetBackupProvider(provider chat.MCPBackupProvider) {} func (s *capturingAIService) SetDiskHealthProvider(provider chat.MCPDiskHealthProvider) {} func (s *capturingAIService) SetUpdatesProvider(provider chat.MCPUpdatesProvider) {} @@ -101,6 +105,9 @@ func TestWireAIChatDependenciesForService_WiresTrueNASAppActionProvider(t *testi if service.appContainerReadProvider == nil { t.Fatal("expected TrueNAS app read provider to be wired into AI chat service") } + if service.appContainerConfigProvider == nil { + t.Fatal("expected TrueNAS app config provider to be wired into AI chat service") + } } func TestAIHandlerStart_WiresRecoveryPointsProviderForDefaultChatService(t *testing.T) { diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index d5a57cdc3..2383ec580 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -128,6 +128,9 @@ func (m *MockAIService) SetBackupProvider(provider chat.MCPBackupProvider) { m.C func (m *MockAIService) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) { m.Called(provider) } +func (m *MockAIService) SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) { + m.Called(provider) +} func (m *MockAIService) SetDiskHealthProvider(provider chat.MCPDiskHealthProvider) { m.Called(provider) } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 3dc7f5679..e3057a1d3 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -79,6 +79,9 @@ func TestContract_WireAIChatDependencies_WiresTrueNASAppActionProvider(t *testin if service.appContainerReadProvider == nil { t.Fatal("expected TrueNAS app read provider to be wired into AI chat dependencies") } + if service.appContainerConfigProvider == nil { + t.Fatal("expected TrueNAS app config provider to be wired into AI chat dependencies") + } } func TestContract_SSOTestRejectsMetadataURLWithUserinfo(t *testing.T) { diff --git a/internal/api/router.go b/internal/api/router.go index 6e9011dd2..ed67d9bba 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2423,6 +2423,10 @@ func (r *Router) wireAIChatDependenciesForService(ctx context.Context, service A service.SetAppContainerReadProvider(provider) log.Debug().Msg("AI chat: App-container read provider wired") } + if provider := newTrueNASAppConfigProvider(r.trueNASPoller); provider != nil { + service.SetAppContainerConfigProvider(provider) + log.Debug().Msg("AI chat: App-container config provider wired") + } log.Info().Str("org_id", orgID).Msg("AI chat MCP tool providers wired") } diff --git a/internal/api/truenas_app_config_provider.go b/internal/api/truenas_app_config_provider.go new file mode 100644 index 000000000..92b3d8613 --- /dev/null +++ b/internal/api/truenas_app_config_provider.go @@ -0,0 +1,151 @@ +package api + +import ( + "context" + "fmt" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/truenas" +) + +type trueNASAppConfigProvider struct { + poller *monitoring.TrueNASPoller +} + +func newTrueNASAppConfigProvider(poller *monitoring.TrueNASPoller) tools.AppContainerConfigProvider { + if poller == nil { + return nil + } + return &trueNASAppConfigProvider{poller: poller} +} + +func (p *trueNASAppConfigProvider) GetConfig(ctx context.Context, req tools.AppContainerConfigRequest) (*tools.AppContainerConfigResult, error) { + if p == nil || p.poller == nil { + return nil, fmt.Errorf("truenas app config provider is unavailable") + } + + appID := strings.TrimSpace(req.ProviderUID) + if appID == "" { + appID = strings.TrimSpace(req.Name) + } + result, err := p.poller.GetAppConfig(ctx, req.OrgID, req.Host, appID) + if err != nil { + return nil, err + } + + configResult := &tools.AppContainerConfigResult{ + ResourceID: strings.TrimSpace(req.ResourceID), + ProviderUID: strings.TrimSpace(req.ProviderUID), + Name: strings.TrimSpace(req.Name), + Host: strings.TrimSpace(req.Host), + Platform: strings.TrimSpace(req.Platform), + } + if result != nil { + if id := strings.TrimSpace(result.App.ID); id != "" { + configResult.ProviderUID = id + } + if name := strings.TrimSpace(result.App.Name); name != "" { + configResult.Name = name + } + if host := strings.TrimSpace(result.Host); host != "" { + configResult.Host = host + } + configResult.Status = strings.ToLower(strings.TrimSpace(result.App.State)) + configResult.Version = strings.TrimSpace(result.App.Version) + configResult.HumanVersion = strings.TrimSpace(result.App.HumanVersion) + configResult.Notes = strings.TrimSpace(result.App.Notes) + configResult.CustomApp = result.App.CustomApp + configResult.UpgradeAvailable = result.App.UpgradeAvailable + configResult.ImageUpdatesAvailable = result.App.ImageUpdatesAvailable + configResult.ContainerCount = result.App.ContainerCount + if configResult.ContainerCount <= 0 { + configResult.ContainerCount = len(result.App.Containers) + } + configResult.UsedHostIPs = append([]string{}, result.App.UsedHostIPs...) + configResult.Images = append([]string{}, result.App.Images...) + configResult.Ports = mapTrueNASAppPortsToToolPorts(result.App.UsedPorts) + configResult.Networks = mapTrueNASAppNetworksToToolNetworks(result.App.Networks) + configResult.Mounts = mapTrueNASAppVolumesToToolMounts(result.App.Volumes) + configResult.Containers = mapTrueNASAppContainersToToolContainers(result.App.Containers) + } + if configResult.Platform == "" { + configResult.Platform = "truenas" + } + return configResult, nil +} + +func mapTrueNASAppPortsToToolPorts(ports []truenas.AppPort) []tools.PortInfo { + if len(ports) == 0 { + return nil + } + out := make([]tools.PortInfo, 0, len(ports)) + for _, port := range ports { + protocol := strings.ToLower(strings.TrimSpace(port.Protocol)) + if len(port.HostPorts) == 0 { + out = append(out, tools.PortInfo{ + Private: port.ContainerPort, + Protocol: protocol, + }) + continue + } + for _, hostPort := range port.HostPorts { + out = append(out, tools.PortInfo{ + Private: port.ContainerPort, + Public: hostPort.HostPort, + Protocol: protocol, + IP: strings.TrimSpace(hostPort.HostIP), + }) + } + } + return out +} + +func mapTrueNASAppNetworksToToolNetworks(networks []truenas.AppNetwork) []tools.NetworkInfo { + if len(networks) == 0 { + return nil + } + out := make([]tools.NetworkInfo, 0, len(networks)) + for _, network := range networks { + name := strings.TrimSpace(network.Name) + if name == "" { + name = strings.TrimSpace(network.ID) + } + out = append(out, tools.NetworkInfo{Name: name}) + } + return out +} + +func mapTrueNASAppVolumesToToolMounts(volumes []truenas.AppVolume) []tools.MountInfo { + if len(volumes) == 0 { + return nil + } + out := make([]tools.MountInfo, 0, len(volumes)) + for _, volume := range volumes { + out = append(out, tools.MountInfo{ + Source: strings.TrimSpace(volume.Source), + Destination: strings.TrimSpace(volume.Destination), + ReadWrite: !strings.EqualFold(strings.TrimSpace(volume.Mode), "ro"), + }) + } + return out +} + +func mapTrueNASAppContainersToToolContainers(containers []truenas.AppContainer) []tools.AppContainerConfigContainer { + if len(containers) == 0 { + return nil + } + out := make([]tools.AppContainerConfigContainer, 0, len(containers)) + for _, container := range containers { + out = append(out, tools.AppContainerConfigContainer{ + ID: strings.TrimSpace(container.ID), + Service: strings.TrimSpace(container.ServiceName), + Image: strings.TrimSpace(container.Image), + State: strings.ToLower(strings.TrimSpace(container.State)), + Ports: mapTrueNASAppPortsToToolPorts(container.PortConfig), + Mounts: mapTrueNASAppVolumesToToolMounts(container.VolumeMounts), + }) + } + return out +} diff --git a/internal/monitoring/truenas_poller.go b/internal/monitoring/truenas_poller.go index e6333403b..51cf7b685 100644 --- a/internal/monitoring/truenas_poller.go +++ b/internal/monitoring/truenas_poller.go @@ -39,6 +39,11 @@ type TrueNASPoller struct { interval time.Duration } +type trueNASPollerProviderEntry struct { + connectionID string + provider *truenas.Provider +} + // NewTrueNASPoller builds a new TrueNAS poller with the provided poll interval. func NewTrueNASPoller(multiTenant *config.MultiTenantPersistence, interval time.Duration, recoveryManager *recoverymanager.Manager) *TrueNASPoller { if interval <= 0 { @@ -515,68 +520,44 @@ func (p *TrueNASPoller) ControlApp(ctx context.Context, orgID, host, appID, acti return nil, fmt.Errorf("truenas app id is required") } - type providerEntry struct { - connectionID string - provider *truenas.Provider + entry, currentApp, err := p.findProviderEntryForApp(orgID, host, appID) + if err != nil { + return nil, err } - p.mu.Lock() - entries := make([]providerEntry, 0, len(p.providersByOrg[orgID])) - for connectionID, provider := range p.providersByOrg[orgID] { - if provider == nil { - continue - } - entries = append(entries, providerEntry{ - connectionID: connectionID, - provider: provider, - }) - } - p.mu.Unlock() - - for _, entry := range entries { - currentSnapshot := entry.provider.Snapshot() - currentApp, ok := findTrueNASAppSnapshot(currentSnapshot, host, appID) - if !ok { - continue - } - - var nextSnapshot *truenas.FixtureSnapshot - var err error - switch action { - case "start", "stop": - nextSnapshot, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), action) - case "restart": - if trueNASAppRunning(currentApp) { - if _, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), "stop"); err != nil { - return nil, err - } + var nextSnapshot *truenas.FixtureSnapshot + switch action { + case "start", "stop": + nextSnapshot, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), action) + case "restart": + if trueNASAppRunning(currentApp) { + if _, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), "stop"); err != nil { + return nil, err } - nextSnapshot, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), "start") - default: - return nil, fmt.Errorf("unsupported truenas app action %q", action) - } - if err != nil { - return nil, err } + nextSnapshot, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), "start") + default: + return nil, fmt.Errorf("unsupported truenas app action %q", action) + } + if err != nil { + return nil, err + } - records := entry.provider.Records() - p.mu.Lock() - if p.cachedRecordsByOrg[orgID] == nil { - p.cachedRecordsByOrg[orgID] = make(map[string][]unifiedresources.IngestRecord) - } - p.cachedRecordsByOrg[orgID][entry.connectionID] = cloneIngestRecords(records) - p.mu.Unlock() - p.ingestRecoveryPoints(ctx, orgID, entry.connectionID, entry.provider) + records := entry.provider.Records() + p.mu.Lock() + if p.cachedRecordsByOrg[orgID] == nil { + p.cachedRecordsByOrg[orgID] = make(map[string][]unifiedresources.IngestRecord) + } + p.cachedRecordsByOrg[orgID][entry.connectionID] = cloneIngestRecords(records) + p.mu.Unlock() + p.ingestRecoveryPoints(ctx, orgID, entry.connectionID, entry.provider) - if updatedApp, ok := findTrueNASAppSnapshot(nextSnapshot, host, appID); ok { - appCopy := *updatedApp - return &appCopy, nil - } - appCopy := *currentApp + if updatedApp, ok := findTrueNASAppSnapshot(nextSnapshot, host, appID); ok { + appCopy := *updatedApp return &appCopy, nil } - - return nil, fmt.Errorf("truenas app %q was not found for org %q", appID, orgID) + appCopy := *currentApp + return &appCopy, nil } // ReadAppLogs executes a canonical bounded log read for one tenant-scoped @@ -599,33 +580,46 @@ func (p *TrueNASPoller) ReadAppLogs(ctx context.Context, orgID, host, appID, con return nil, fmt.Errorf("truenas app id is required") } - type providerEntry struct { - connectionID string - provider *truenas.Provider + entry, _, err := p.findProviderEntryForApp(orgID, host, appID) + if err != nil { + return nil, err + } + return entry.provider.ReadAppLogs(ctx, appID, containerRef, tailLines) +} + +// GetAppConfig reads the current TrueNAS app configuration/runtime shape for +// one tenant-scoped API-backed app-container resource. +func (p *TrueNASPoller) GetAppConfig(ctx context.Context, orgID, host, appID string) (*truenas.AppConfigResult, error) { + if p == nil { + return nil, fmt.Errorf("truenas poller is nil") + } + if !truenas.IsFeatureEnabled() { + return nil, fmt.Errorf("truenas integration is disabled") } - p.mu.Lock() - entries := make([]providerEntry, 0, len(p.providersByOrg[orgID])) - for connectionID, provider := range p.providersByOrg[orgID] { - if provider == nil { - continue - } - entries = append(entries, providerEntry{ - connectionID: connectionID, - provider: provider, - }) + orgID = strings.TrimSpace(orgID) + if orgID == "" { + orgID = "default" } - p.mu.Unlock() - - for _, entry := range entries { - currentSnapshot := entry.provider.Snapshot() - if _, ok := findTrueNASAppSnapshot(currentSnapshot, host, appID); !ok { - continue - } - return entry.provider.ReadAppLogs(ctx, appID, containerRef, tailLines) + host = strings.TrimSpace(host) + appID = strings.TrimSpace(appID) + if appID == "" { + return nil, fmt.Errorf("truenas app id is required") } - return nil, fmt.Errorf("truenas app %q was not found for org %q", appID, orgID) + entry, currentApp, err := p.findProviderEntryForApp(orgID, host, appID) + if err != nil { + return nil, err + } + + result, err := entry.provider.GetAppConfig(ctx, trueNASAppCanonicalID(*currentApp)) + if err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("truenas app %q configuration is unavailable", appID) + } + return result, nil } func cloneIngestRecords(records []unifiedresources.IngestRecord) []unifiedresources.IngestRecord { @@ -637,6 +631,38 @@ func cloneIngestRecords(records []unifiedresources.IngestRecord) []unifiedresour return cloned } +func (p *TrueNASPoller) providerEntriesForOrg(orgID string) []trueNASPollerProviderEntry { + if p == nil { + return nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + entries := make([]trueNASPollerProviderEntry, 0, len(p.providersByOrg[orgID])) + for connectionID, provider := range p.providersByOrg[orgID] { + if provider == nil { + continue + } + entries = append(entries, trueNASPollerProviderEntry{ + connectionID: connectionID, + provider: provider, + }) + } + return entries +} + +func (p *TrueNASPoller) findProviderEntryForApp(orgID, host, appID string) (trueNASPollerProviderEntry, *truenas.App, error) { + for _, entry := range p.providerEntriesForOrg(orgID) { + currentSnapshot := entry.provider.Snapshot() + currentApp, ok := findTrueNASAppSnapshot(currentSnapshot, host, appID) + if ok { + return entry, currentApp, nil + } + } + return trueNASPollerProviderEntry{}, nil, fmt.Errorf("truenas app %q was not found for org %q", appID, orgID) +} + func findTrueNASAppSnapshot(snapshot *truenas.FixtureSnapshot, host, appID string) (*truenas.App, bool) { if snapshot == nil { return nil, false diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go index 1e0d8ff10..62c767449 100644 --- a/internal/monitoring/truenas_poller_test.go +++ b/internal/monitoring/truenas_poller_test.go @@ -380,6 +380,37 @@ func TestTrueNASPollerReadAppLogsUsesTenantScopedProvider(t *testing.T) { } } +func TestTrueNASPollerGetAppConfigUsesTenantScopedProvider(t *testing.T) { + previous := truenas.IsFeatureEnabled() + truenas.SetFeatureEnabled(true) + t.Cleanup(func() { truenas.SetFeatureEnabled(previous) }) + + fixtures := truenas.DefaultFixtures() + fetcher := &pollerControlFetcher{snapshot: &fixtures} + provider := truenas.NewLiveProvider(fetcher) + if err := provider.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + poller := NewTrueNASPoller(nil, 0, nil) + poller.providersByOrg["default"] = map[string]*truenas.Provider{"conn-1": provider} + poller.cachedRecordsByOrg["default"] = map[string][]unifiedresources.IngestRecord{"conn-1": provider.Records()} + + result, err := poller.GetAppConfig(context.Background(), "default", "truenas-main", "nextcloud") + if err != nil { + t.Fatalf("GetAppConfig() error = %v", err) + } + if result == nil || result.App.Name != "Nextcloud" { + t.Fatalf("expected Nextcloud config result, got %+v", result) + } + if result.Host != "truenas-main" { + t.Fatalf("expected config host truenas-main, got %+v", result) + } + if len(result.App.Containers) != 2 { + t.Fatalf("expected canonical app runtime shape, got %+v", result.App.Containers) + } +} + func TestTrueNASPollerHandlesConnectionAddRemove(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 50aed0eac..9e05a74bc 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -235,6 +235,26 @@ func (p *Provider) ReadAppLogs(ctx context.Context, appID, containerRef string, return result, nil } +// GetAppConfig returns the current application configuration/runtime shape for +// one TrueNAS app from the provider snapshot. +func (p *Provider) GetAppConfig(_ context.Context, appID string) (*AppConfigResult, error) { + if p == nil { + return nil, fmt.Errorf("truenas provider is nil") + } + + snapshot := p.Snapshot() + app, err := findAppInSnapshot(snapshot, appID) + if err != nil { + return nil, err + } + + result := &AppConfigResult{App: *app} + if snapshot != nil { + result.Host = strings.TrimSpace(snapshot.System.Hostname) + } + return result, nil +} + // Close releases resources held by the active fetcher, if supported. func (p *Provider) Close() { if p == nil || p.fetcher == nil { diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index e7838f297..7da3dbeea 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -258,6 +258,32 @@ func TestProviderReadAppLogsSupportsExplicitServiceSelection(t *testing.T) { } } +func TestProviderGetAppConfigReturnsCanonicalApp(t *testing.T) { + fixtures := DefaultFixtures() + fetcher := &controllableStubFetcher{snapshot: &fixtures} + provider := NewLiveProvider(fetcher) + if err := provider.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + result, err := provider.GetAppConfig(context.Background(), "nextcloud") + if err != nil { + t.Fatalf("GetAppConfig() error = %v", err) + } + if result == nil { + t.Fatal("expected app config result") + } + if result.Host != "truenas-main" { + t.Fatalf("expected host truenas-main, got %+v", result) + } + if result.App.ID != "nextcloud" || result.App.Name != "Nextcloud" { + t.Fatalf("unexpected app config payload: %+v", result.App) + } + if len(result.App.Containers) != 2 { + t.Fatalf("expected full app container runtime shape, got %+v", result.App.Containers) + } +} + func TestProviderRefreshPreservesLastSnapshotOnError(t *testing.T) { initial := DefaultFixtures() provider := NewProvider(initial) diff --git a/internal/truenas/types.go b/internal/truenas/types.go index af1cf81c8..c3a479c5a 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -163,6 +163,13 @@ type AppLogResult struct { TailLines int } +// AppConfigResult captures the current configuration/runtime shape of one +// TrueNAS application. +type AppConfigResult struct { + Host string + App App +} + // AppVolume describes a bind or named volume mount exposed by a TrueNAS app. type AppVolume struct { Source string