feat(ai): add canonical truenas app config reads

This commit is contained in:
rcourtman
2026-03-29 20:36:43 +01:00
parent a5162d96cb
commit 56c14ca19f
23 changed files with 922 additions and 172 deletions
@@ -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
@@ -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=<canonical app>`
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=<canonical app>` 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
@@ -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
@@ -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
@@ -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.
@@ -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`,
+9
View File
@@ -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()
+78
View File
@@ -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"`
+56 -6
View File
@@ -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
+210 -79
View File
@@ -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)
@@ -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)
}
}
+3 -3
View File
@@ -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",
})
+8
View File
@@ -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 {
@@ -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) {
+3
View File
@@ -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)
}
+3
View File
@@ -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) {
+4
View File
@@ -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")
}
+151
View File
@@ -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
}
+102 -76
View File
@@ -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
@@ -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)
+20
View File
@@ -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 {
+26
View File
@@ -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)
+7
View File
@@ -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