From 298b23626b5e6b1a1d802356c287f5ea60bc28f2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 29 Mar 2026 20:13:39 +0100 Subject: [PATCH] feat(ai): add canonical truenas app log reads --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/ai-runtime.md | 12 ++ .../v6/internal/subsystems/api-contracts.md | 6 + .../v6/internal/subsystems/monitoring.md | 6 + .../internal/subsystems/storage-recovery.md | 7 + .../internal/subsystems/unified-resources.md | 11 + internal/ai/chat/service.go | 36 +++- .../ai/chat/service_recent_context_test.go | 34 +++ internal/ai/tools/executor.go | 43 +++- internal/ai/tools/executor_setters_test.go | 8 + internal/ai/tools/tools_read.go | 93 ++++++++- internal/ai/tools/tools_read_test.go | 93 +++++++++ internal/api/ai_handler.go | 1 + .../api/ai_handler_recovery_wiring_test.go | 7 + internal/api/ai_handler_test.go | 3 + internal/api/contract_test.go | 3 + internal/api/router.go | 4 + internal/api/truenas_app_read_provider.go | 90 ++++++++ internal/monitoring/truenas_poller.go | 49 +++++ internal/monitoring/truenas_poller_test.go | 52 +++++ internal/truenas/client.go | 194 ++++++++++++++++++ internal/truenas/client_test.go | 58 ++++++ internal/truenas/provider.go | 133 ++++++++++++ internal/truenas/provider_test.go | 71 +++++++ internal/truenas/types.go | 15 ++ 25 files changed, 1023 insertions(+), 11 deletions(-) create mode 100644 internal/api/truenas_app_read_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 ee707c80d..592c00194 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -433,6 +433,11 @@ for API-backed platforms. TrueNAS may surface Assistant control and runtime insight through the backend-owned platform connection and polling path, but adjacent lifecycle flows must not start treating a unified-agent install as the required bootstrap for provider-backed TrueNAS operations. +That same agent-optional rule also covers Assistant diagnostics. Provider- +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 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 9f19a0326..ccb467cf0 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -158,6 +158,13 @@ API-backed control actions such as TrueNAS app start/stop/restart on the shared `pulse_control` tool with `type="resource"` and native audited execution, instead of adding provider-local control tools or bypassing the shared approval and policy model. +That same AI tool ownership now also includes canonical resource-native +diagnostics. `internal/ai/tools/tools_read.go`, +`internal/ai/tools/executor.go`, and `internal/api/router.go` must keep +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 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 @@ -279,6 +286,11 @@ API-backed platforms such as TrueNAS. The runtime must expose canonical the shared unified-resource model instead of falling back to Proxmox- or Docker-local enumerations when a platform projects onto canonical host, storage, disk, or workload contracts. +That same runtime contract applies to resource-native diagnostics. When +resolved context points at an API-backed canonical `app-container` such as a +TrueNAS app, chat/runtime prompt hints and tool execution must route log reads +through `resource_id` on `pulse_read` rather than inventing agent-host hints +for platforms that are not reached through the unified agent. Unified AI context should follow the same rule: storage summaries may mention canonical storage pools and physical disks that need attention, but must not mislabel lower-topology storage resources such as TrueNAS datasets as diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 70942d498..e97a0ec93 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -587,6 +587,12 @@ helpers must not introduce a parallel public `/api/truenas/apps/...` control surface; provider-backed app control for Pulse Assistant stays behind the shared AI runtime tool contract unless this API contract changes in the same slice. +That same negative-space rule also applies to assistant diagnostics. Wiring +native TrueNAS app log reads into `internal/api/router.go`, +`internal/api/ai_handler.go`, or adjacent backend helpers must not introduce a +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. 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 8c7887fe1..51a3dc82d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -251,6 +251,12 @@ refresh semantics. `internal/truenas/provider.go` and actions through the owned TrueNAS runtime and refresh cached records and recovery ingest immediately afterward, so assistant-driven app control does not rely on stale provider state or ad hoc config-local action paths. +That same monitoring boundary now also owns canonical TrueNAS app log reads. +`internal/truenas/client.go`, `internal/truenas/provider.go`, and +`internal/monitoring/truenas_poller.go` must read bounded app-container logs +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 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 b0da47fe7..bfa0fd32f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -81,6 +81,7 @@ querying, and the operator-facing storage health presentation layer. 20. Keep shared `internal/api/` Patrol transport and alert-trigger edits feature-isolated: Patrol-specific recency fields, callback fan-out, or alert-bridge wiring changes must not leak into recovery queries, storage links, or recovery-adjacent install/setup flows unless this contract changes in the same slice. 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. ## Forbidden Paths @@ -1524,3 +1525,9 @@ and recovery ingest after a control event, but storage and recovery surfaces must continue to consume the resulting canonical recovery points instead of growing a second recovery-local control transport or action-specific payload contract. +That same boundary also owns the line between recovery data and assistant +diagnostics. Backend-native TrueNAS app log reads 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 log transport or +diagnostic payload contract 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 ffcc45379..fdf184ed3 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -191,6 +191,11 @@ canonical `agent`, `app-container`, `storage`, and `physical-disk` resources through those shared contracts, filtering dataset-topology storage children out of any `storage-pool` presentation instead of inventing TrueNAS-local assistant types or mislabeling datasets as pools. +That same canonical app-container rule now also governs diagnostics. Assistant +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. 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`, @@ -1080,6 +1085,12 @@ routing. `internal/unifiedresources/resolve.go`, containers while preserving adapter-specific routing. Reusing shared `DockerData` for workload metadata must not misclassify a TrueNAS app as a Docker-routed control target. +That same resolved-context ownership also governs read diagnostics. When +`internal/ai/tools/tools_read.go` executes `pulse_read action="logs"` against +a canonical `app-container` resource, TrueNAS-backed app containers must +route through adapter-aware native read providers keyed by canonical +`resource_id`, not through Docker host/container routing or agent-host +fallbacks. Typed view accessors for linked topology IDs must also return canonical trimmed values. Callers must not observe `" node-99 "` or `" agent-123 "` diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index a0061c880..58a031782 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -47,6 +47,7 @@ type ( MCPDiskHealthProvider = tools.DiskHealthProvider MCPUpdatesProvider = tools.UpdatesProvider MCPAppContainerActionProvider = tools.AppContainerActionProvider + MCPAppContainerReadProvider = tools.AppContainerReadProvider AgentProfileManager = tools.AgentProfileManager FindingsManager = tools.FindingsManager MetadataUpdater = tools.MetadataUpdater @@ -1242,6 +1243,14 @@ func (s *Service) SetAppContainerActionProvider(provider MCPAppContainerActionPr } } +func (s *Service) SetAppContainerReadProvider(provider MCPAppContainerReadProvider) { + s.mu.Lock() + defer s.mu.Unlock() + if s.executor != nil { + s.executor.SetAppContainerReadProvider(provider) + } +} + func (s *Service) SetDiscoveryProvider(provider MCPDiscoveryProvider) { s.mu.Lock() defer s.mu.Unlock() @@ -1458,7 +1467,10 @@ func (s *Service) injectRecentContextIfNeeded(prompt, sessionID string, messages var lines []string primaryName := "" + primaryResourceID := "" primaryTarget := "" + primaryKind := "" + primaryAdapter := "" for _, resourceID := range recentIDs { res, ok := resolvedCtx.GetResourceByID(resourceID) if !ok || res == nil { @@ -1484,10 +1496,16 @@ func (s *Service) injectRecentContextIfNeeded(prompt, sessionID string, messages lines = append(lines, "- "+label) if primaryName == "" { primaryName = res.Name + primaryResourceID = res.ResourceID primaryTarget = res.TargetHost + primaryKind = kind + primaryAdapter = res.Adapter if primaryName == "" { primaryName = label } + if primaryResourceID == "" { + primaryResourceID = resourceID + } } } @@ -1500,7 +1518,15 @@ func (s *Service) injectRecentContextIfNeeded(prompt, sessionID string, messages primaryName = primary } targetHint := "" - if primaryTarget != "" { + if primaryKind == "app-container" && strings.EqualFold(strings.TrimSpace(primaryAdapter), "truenas") { + target := primaryName + if target == "" { + target = primaryResourceID + } + if target != "" { + targetHint = fmt.Sprintf(" Use resource_id=\"%s\".", target) + } + } else if primaryTarget != "" { targetHint = fmt.Sprintf(" Use target_host=\"%s\".", primaryTarget) } summary := fmt.Sprintf("Context: The most recently referenced resource is %s. If the user says \"it/its/that\", assume they mean this resource unless they specify otherwise. Do not ask for clarification unless the user names a different resource.%s", primary, targetHint) @@ -1512,7 +1538,13 @@ func (s *Service) injectRecentContextIfNeeded(prompt, sessionID string, messages lowerPrompt := strings.ToLower(prompt) if strings.Contains(lowerPrompt, "log") || strings.Contains(lowerPrompt, "journal") { rewrite := fmt.Sprintf("Show logs for %s (last 50 lines).", primaryName) - if primaryTarget != "" { + if primaryKind == "app-container" && strings.EqualFold(strings.TrimSpace(primaryAdapter), "truenas") { + target := primaryName + if target == "" { + target = primaryResourceID + } + summary += fmt.Sprintf("\nInstruction: %s Use pulse_read action=logs resource_id=\"%s\" lines=50.", rewrite, target) + } else if primaryTarget != "" { summary += fmt.Sprintf("\nInstruction: %s Use pulse_read action=logs target_host=\"%s\" lines=50.", rewrite, primaryTarget) } else { summary += fmt.Sprintf("\nInstruction: %s Use pulse_read action=logs target_host=\"%s\" lines=50.", rewrite, primaryName) diff --git a/internal/ai/chat/service_recent_context_test.go b/internal/ai/chat/service_recent_context_test.go index 9975f0b06..b8bddcde3 100644 --- a/internal/ai/chat/service_recent_context_test.go +++ b/internal/ai/chat/service_recent_context_test.go @@ -131,3 +131,37 @@ func TestInjectRecentContextIfNeeded_PrimaryNameFallback(t *testing.T) { t.Fatalf("expected log instruction to use primary name, got: %s", content) } } + +func TestInjectRecentContextIfNeeded_UsesResourceIDHintForTrueNASAppLogs(t *testing.T) { + store, err := NewSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + session, err := store.Create() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + resolved := store.GetResolvedContext(session.ID) + resource := &ResolvedResource{ + ResourceID: "app-container:truenas-main:nextcloud", + Name: "Nextcloud", + Kind: "app-container", + Node: "truenas-main", + TargetHost: "truenas-main", + Adapter: "truenas", + } + resolved.AddResourceWithExplicitAccess(resource.Name, resource) + + messages := []Message{{Role: "user", Content: "show its logs"}} + service := &Service{} + service.injectRecentContextIfNeeded("show its logs", session.ID, messages, store) + + content := messages[0].Content + if !strings.Contains(content, "Use resource_id=\"Nextcloud\".") { + t.Fatalf("expected resource_id hint, got: %s", content) + } + if !strings.Contains(content, "Use pulse_read action=logs resource_id=\"Nextcloud\" lines=50.") { + t.Fatalf("expected resource-targeted log instruction, got: %s", content) + } +} diff --git a/internal/ai/tools/executor.go b/internal/ai/tools/executor.go index d7217b5a8..ecd6ea3c6 100644 --- a/internal/ai/tools/executor.go +++ b/internal/ai/tools/executor.go @@ -196,6 +196,38 @@ type AppContainerActionProvider interface { ExecuteAction(ctx context.Context, req AppContainerActionRequest) (*AppContainerActionResult, error) } +// AppContainerReadRequest describes a canonical read-only diagnostic request +// against an API-backed app-container resource. +type AppContainerReadRequest struct { + OrgID string + ResourceID string + ProviderUID string + Name string + Host string + Platform string + Container string + Lines int +} + +// AppContainerReadResult captures a bounded read-only diagnostic result for an +// API-backed app-container. +type AppContainerReadResult struct { + ResourceID string + ProviderUID string + Name string + Host string + Platform string + Container string + Lines int + Output string +} + +// AppContainerReadProvider executes canonical read-only diagnostics for +// API-backed app-container resources such as TrueNAS-managed applications. +type AppContainerReadProvider interface { + ReadLogs(ctx context.Context, req AppContainerReadRequest) (*AppContainerReadResult, error) +} + // DiscoveryProvider provides AI-powered infrastructure discovery type DiscoveryProvider interface { GetDiscovery(id string) (*ResourceDiscoveryInfo, error) @@ -401,6 +433,7 @@ type ExecutorConfig struct { // Optional providers - unified resources UnifiedResourceProvider UnifiedResourceProvider AppContainerActionProvider AppContainerActionProvider + AppContainerReadProvider AppContainerReadProvider ActionAuditStore unifiedresources.ResourceStore // Optional typed read access to current infrastructure state. // When provided, tool handlers should prefer this over models.StateSnapshot iteration. @@ -453,6 +486,7 @@ type PulseToolExecutor struct { // Unified resources provider unifiedResourceProvider UnifiedResourceProvider appContainerActionProvider AppContainerActionProvider + appContainerReadProvider AppContainerReadProvider actionAuditStore unifiedresources.ResourceStore // Typed state reader. Nil means "legacy-only": tools must fall back to StateSnapshot access. readState unifiedresources.ReadState @@ -528,6 +562,7 @@ func NewPulseToolExecutor(cfg ExecutorConfig) *PulseToolExecutor { discoveryProvider: cfg.DiscoveryProvider, unifiedResourceProvider: cfg.UnifiedResourceProvider, appContainerActionProvider: cfg.AppContainerActionProvider, + appContainerReadProvider: cfg.AppContainerReadProvider, actionAuditStore: cfg.ActionAuditStore, readState: cfg.ReadState, controlLevel: cfg.ControlLevel, @@ -736,6 +771,12 @@ func (e *PulseToolExecutor) SetAppContainerActionProvider(provider AppContainerA e.appContainerActionProvider = provider } +// SetAppContainerReadProvider sets the provider used for canonical native +// app-container read-only diagnostics. +func (e *PulseToolExecutor) SetAppContainerReadProvider(provider AppContainerReadProvider) { + e.appContainerReadProvider = provider +} + // SetActionAuditStore sets the durable store used to persist action audit and lifecycle events. func (e *PulseToolExecutor) SetActionAuditStore(store unifiedresources.ResourceStore) { e.actionAuditStore = store @@ -809,7 +850,7 @@ func (e *PulseToolExecutor) isToolAvailable(name string) bool { case "pulse_alerts": return e.alertProvider != nil || e.findingsProvider != nil || e.findingsManager != nil || e.hasReadState() case "pulse_read": - return e.agentServer != nil + return e.agentServer != nil || (e.appContainerReadProvider != nil && e.hasReadState()) case "pulse_control": return (e.agentServer != nil || e.appContainerActionProvider != nil) && e.hasReadState() case "pulse_file_edit": diff --git a/internal/ai/tools/executor_setters_test.go b/internal/ai/tools/executor_setters_test.go index 319d979bc..fa8e445b3 100644 --- a/internal/ai/tools/executor_setters_test.go +++ b/internal/ai/tools/executor_setters_test.go @@ -146,6 +146,14 @@ func TestPulseToolExecutor_IsToolAvailable(t *testing.T) { }) assert.True(t, execWithUnifiedReadState.isToolAvailable("pulse_pmg")) assert.True(t, execWithUnifiedReadState.isToolAvailable("pulse_kubernetes")) + assert.False(t, execWithUnifiedReadState.isToolAvailable("pulse_read")) + + execWithNativeRead := NewPulseToolExecutor(ExecutorConfig{ + UnifiedResourceProvider: adapter, + ReadState: unifiedresources.NewRegistry(nil), + AppContainerReadProvider: &stubAppContainerReadProvider{}, + }) + assert.True(t, execWithNativeRead.isToolAvailable("pulse_read")) } func TestPulseToolExecutor_GetReadStatePrefersUnifiedResourceProvider(t *testing.T) { diff --git a/internal/ai/tools/tools_read.go b/internal/ai/tools/tools_read.go index 7a425093c..cc58e021d 100644 --- a/internal/ai/tools/tools_read.go +++ b/internal/ai/tools/tools_read.go @@ -16,7 +16,7 @@ func (e *PulseToolExecutor) registerReadTools() { e.registry.Register(RegisteredTool{ Definition: Tool{ Name: "pulse_read", - Description: `Execute read-only operations on infrastructure (exec, file, find, tail, logs). Rejects write commands. target_host routes to host, system container, or VM by name.`, + Description: `Execute read-only operations on infrastructure (exec, file, find, tail, logs). Rejects write commands. Use target_host for agent-routed reads, or resource_id for API-backed native resource logs such as supported TrueNAS app-containers.`, InputSchema: InputSchema{ Type: "object", Properties: map[string]PropertySchema{ @@ -27,7 +27,11 @@ func (e *PulseToolExecutor) registerReadTools() { }, "target_host": { Type: "string", - Description: "Hostname to read from (host, system container name, or VM name)", + Description: "For agent-routed reads: hostname to read from (host, system container name, or VM name)", + }, + "resource_id": { + Type: "string", + Description: "For native API-backed resource logs: discovered resource name or canonical resource ID from pulse_query", }, "command": { Type: "string", @@ -52,7 +56,7 @@ func (e *PulseToolExecutor) registerReadTools() { }, "container": { Type: "string", - Description: "Container name. For logs with source=docker, or for exec/file/tail to run inside a Docker container on target_host.", + Description: "Container name. For logs with source=docker, for exec/file/tail inside a Docker container on target_host, or for native app logs to choose a specific service/container within the app.", }, "unit": { Type: "string", @@ -67,7 +71,7 @@ func (e *PulseToolExecutor) registerReadTools() { Description: "For logs/tail: filter output by pattern", }, }, - Required: []string{"action", "target_host"}, + Required: []string{"action"}, }, }, Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) { @@ -362,16 +366,14 @@ func (e *PulseToolExecutor) executeReadLogs(ctx context.Context, args map[string source, _ := args["source"].(string) source = strings.ToLower(strings.TrimSpace(source)) targetHost, _ := args["target_host"].(string) + resourceRef, _ := args["resource_id"].(string) + resourceRef = strings.TrimSpace(resourceRef) container, _ := args["container"].(string) unit, _ := args["unit"].(string) since, _ := args["since"].(string) grepPattern, _ := args["grep"].(string) lines := intArg(args, "lines", 100) - if targetHost == "" { - return NewErrorResult(fmt.Errorf("target_host is required")), nil - } - // Cap lines if lines > 1000 { lines = 1000 @@ -380,6 +382,13 @@ func (e *PulseToolExecutor) executeReadLogs(ctx context.Context, args map[string lines = 100 } + if resourceRef != "" { + return e.executeNativeAppContainerReadLogs(ctx, resourceRef, container, lines) + } + if targetHost == "" { + return NewErrorResult(fmt.Errorf("target_host is required when resource_id is not provided")), nil + } + var command string // If source is omitted, infer from provided identifiers: @@ -449,6 +458,74 @@ func (e *PulseToolExecutor) executeReadLogs(ctx context.Context, args map[string }) } +func (e *PulseToolExecutor) executeNativeAppContainerReadLogs(ctx context.Context, resourceRef, container string, lines int) (CallToolResult, error) { + if e.appContainerReadProvider == nil { + return NewErrorResult(fmt.Errorf("native app-container read provider is not available")), nil + } + + validation := e.validateResolvedResource(resourceRef, "query", false) + if validation.IsBlocked() { + return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil + } + if validation.Resource == nil { + if validation.ErrorMsg != "" { + return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil + } + return NewErrorResult(fmt.Errorf("resource '%s' has not been discovered in this session. Use pulse_query action=search to find it first", resourceRef)), nil + } + if validation.ErrorMsg != "" { + log.Warn(). + Str("resource", resourceRef). + Str("validation_error", validation.ErrorMsg). + Msg("[ReadLogs] Continuing with discovered resource despite validation warning") + } + + resolved := validation.Resource + if resolved.GetKind() != "app-container" { + return NewErrorResult(fmt.Errorf("resource '%s' of kind %q does not support native logs through pulse_read", resourceRef, resolved.GetKind())), nil + } + if !strings.EqualFold(strings.TrimSpace(resolved.GetAdapter()), "truenas") { + return NewErrorResult(fmt.Errorf("resource '%s' uses unsupported read adapter %q", resourceRef, resolved.GetAdapter())), nil + } + + resourceName := resolvedResourceDisplayName(resolved) + readResult, err := e.appContainerReadProvider.ReadLogs(ctx, AppContainerReadRequest{ + OrgID: e.orgID, + ResourceID: strings.TrimSpace(resolved.GetResourceID()), + ProviderUID: strings.TrimSpace(resolved.GetProviderUID()), + Name: resourceName, + Host: strings.TrimSpace(resolved.GetTargetHost()), + Platform: "truenas", + Container: strings.TrimSpace(container), + Lines: lines, + }) + if err != nil { + return NewErrorResult(err), nil + } + if readResult == nil { + return NewTextResult(fmt.Sprintf("No logs found for app '%s'.", resourceName)), nil + } + + resourceName = strings.TrimSpace(readResult.Name) + if resourceName == "" { + resourceName = resolvedResourceDisplayName(resolved) + } + + title := fmt.Sprintf("Logs from app '%s'", resourceName) + if containerName := strings.TrimSpace(readResult.Container); containerName != "" { + title = fmt.Sprintf("%s (container '%s')", title, containerName) + } + if readResult.Lines > 0 { + title = fmt.Sprintf("%s (last %d lines)", title, readResult.Lines) + } + + output := strings.TrimSpace(readResult.Output) + if output == "" { + return NewTextResult(title + ":\n(no output)"), nil + } + return NewTextResult(fmt.Sprintf("%s:\n%s", title, output)), nil +} + // truncateCommand truncates a command for display/logging func truncateCommand(cmd string, maxLen int) string { if len(cmd) <= maxLen { diff --git a/internal/ai/tools/tools_read_test.go b/internal/ai/tools/tools_read_test.go index 433b097f3..f09ac83c8 100644 --- a/internal/ai/tools/tools_read_test.go +++ b/internal/ai/tools/tools_read_test.go @@ -11,6 +11,33 @@ import ( "github.com/stretchr/testify/require" ) +type stubAppContainerReadProvider struct { + calls []AppContainerReadRequest + result *AppContainerReadResult + err error +} + +func (s *stubAppContainerReadProvider) ReadLogs(_ context.Context, req AppContainerReadRequest) (*AppContainerReadResult, error) { + s.calls = append(s.calls, req) + if s.err != nil { + return nil, s.err + } + if s.result == nil { + return &AppContainerReadResult{ + ResourceID: req.ResourceID, + ProviderUID: req.ProviderUID, + Name: req.Name, + Host: req.Host, + Platform: req.Platform, + Container: req.Container, + Lines: req.Lines, + Output: "ok", + }, nil + } + result := *s.result + return &result, nil +} + func TestPulseToolExecutor_ExecuteReadLogs_Fallbacks(t *testing.T) { ctx := context.Background() @@ -136,3 +163,69 @@ func TestPulseToolExecutor_ExecuteReadRejectsLegacyAppContainerArg(t *testing.T) require.NotEmpty(t, result.Content) assert.Contains(t, result.Content[0].Text, "app_container is no longer supported; use app-container") } + +func TestPulseToolExecutor_ListTools_IncludesPulseReadForNativeAppReadProvider(t *testing.T) { + provider := newTrueNASUnifiedQueryProvider(t) + exec := NewPulseToolExecutor(ExecutorConfig{ + UnifiedResourceProvider: provider, + ReadState: provider.ResourceRegistry, + AppContainerReadProvider: &stubAppContainerReadProvider{}, + }) + + tools := exec.ListTools() + assert.True(t, containsTool(tools, "pulse_read")) +} + +func TestExecuteReadLogs_TrueNASAppUsesNativeReadProvider(t *testing.T) { + provider := newTrueNASUnifiedQueryProvider(t) + resolved := &mockResolvedContext{ + resources: make(map[string]ResolvedResourceInfo), + aliases: make(map[string]ResolvedResourceInfo), + } + readProvider := &stubAppContainerReadProvider{ + result: &AppContainerReadResult{ + ResourceID: "app-container:truenas-main:nextcloud", + ProviderUID: "nextcloud", + Name: "Nextcloud", + Host: "truenas-main", + Platform: "truenas", + Container: "nextcloud", + Lines: 25, + Output: "2026-03-29T18:00:00Z ready\n2026-03-29T18:01:00Z serving", + }, + } + + exec := NewPulseToolExecutor(ExecutorConfig{ + UnifiedResourceProvider: provider, + ReadState: provider.ResourceRegistry, + AppContainerReadProvider: readProvider, + }) + exec.SetResolvedContext(resolved) + + if _, err := exec.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 := exec.executeReadLogs(context.Background(), map[string]interface{}{ + "action": "logs", + "resource_id": "Nextcloud", + "container": "nextcloud", + "lines": 25, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + require.NotEmpty(t, result.Content) + assert.Contains(t, result.Content[0].Text, "Logs from app 'Nextcloud' (container 'nextcloud') (last 25 lines):") + assert.Contains(t, result.Content[0].Text, "serving") + + if len(readProvider.calls) != 1 { + t.Fatalf("expected one native app read call, got %+v", readProvider.calls) + } + call := readProvider.calls[0] + if call.OrgID != "default" || call.ProviderUID != "nextcloud" || call.Host != "truenas-main" || call.Container != "nextcloud" || call.Lines != 25 { + t.Fatalf("unexpected native app read request: %+v", call) + } +} diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 858774ff3..77cd3cdd0 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -67,6 +67,7 @@ type AIService interface { SetDiscoveryProvider(provider chat.MCPDiscoveryProvider) SetUnifiedResourceProvider(provider chat.MCPUnifiedResourceProvider) SetAppContainerActionProvider(provider chat.MCPAppContainerActionProvider) + SetAppContainerReadProvider(provider chat.MCPAppContainerReadProvider) UpdateControlSettings(cfg *config.AIConfig) GetBaseURL() string } diff --git a/internal/api/ai_handler_recovery_wiring_test.go b/internal/api/ai_handler_recovery_wiring_test.go index 228652973..b16f822b1 100644 --- a/internal/api/ai_handler_recovery_wiring_test.go +++ b/internal/api/ai_handler_recovery_wiring_test.go @@ -16,6 +16,7 @@ import ( type capturingAIService struct { running bool appContainerActionProvider chat.MCPAppContainerActionProvider + appContainerReadProvider chat.MCPAppContainerReadProvider } func (s *capturingAIService) Start(ctx context.Context) error { s.running = true; return nil } @@ -80,6 +81,9 @@ func (s *capturingAIService) SetUnifiedResourceProvider(provider chat.MCPUnified func (s *capturingAIService) SetAppContainerActionProvider(provider chat.MCPAppContainerActionProvider) { s.appContainerActionProvider = provider } +func (s *capturingAIService) SetAppContainerReadProvider(provider chat.MCPAppContainerReadProvider) { + s.appContainerReadProvider = provider +} func (s *capturingAIService) UpdateControlSettings(cfg *config.AIConfig) {} func (s *capturingAIService) GetBaseURL() string { return "" } @@ -94,6 +98,9 @@ func TestWireAIChatDependenciesForService_WiresTrueNASAppActionProvider(t *testi if service.appContainerActionProvider == nil { t.Fatal("expected TrueNAS app action provider to be wired into AI chat service") } + if service.appContainerReadProvider == nil { + t.Fatal("expected TrueNAS app read 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 17a40e4e9..d5a57cdc3 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -155,6 +155,9 @@ func (m *MockAIService) SetUnifiedResourceProvider(provider chat.MCPUnifiedResou func (m *MockAIService) SetAppContainerActionProvider(provider chat.MCPAppContainerActionProvider) { m.Called(provider) } +func (m *MockAIService) SetAppContainerReadProvider(provider chat.MCPAppContainerReadProvider) { + m.Called(provider) +} func (m *MockAIService) UpdateControlSettings(cfg *config.AIConfig) { m.Called(cfg) } func (m *MockAIService) GetBaseURL() string { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 017b9eb08..3dc7f5679 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -76,6 +76,9 @@ func TestContract_WireAIChatDependencies_WiresTrueNASAppActionProvider(t *testin if service.appContainerActionProvider == nil { t.Fatal("expected TrueNAS app action provider to be wired into AI chat dependencies") } + if service.appContainerReadProvider == nil { + t.Fatal("expected TrueNAS app read 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 bc4a36a16..6e9011dd2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2419,6 +2419,10 @@ func (r *Router) wireAIChatDependenciesForService(ctx context.Context, service A service.SetAppContainerActionProvider(provider) log.Debug().Msg("AI chat: App-container action provider wired") } + if provider := newTrueNASAppReadProvider(r.trueNASPoller); provider != nil { + service.SetAppContainerReadProvider(provider) + log.Debug().Msg("AI chat: App-container read provider wired") + } log.Info().Str("org_id", orgID).Msg("AI chat MCP tool providers wired") } diff --git a/internal/api/truenas_app_read_provider.go b/internal/api/truenas_app_read_provider.go new file mode 100644 index 000000000..7df12ddaf --- /dev/null +++ b/internal/api/truenas_app_read_provider.go @@ -0,0 +1,90 @@ +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 trueNASAppReadProvider struct { + poller *monitoring.TrueNASPoller +} + +func newTrueNASAppReadProvider(poller *monitoring.TrueNASPoller) tools.AppContainerReadProvider { + if poller == nil { + return nil + } + return &trueNASAppReadProvider{poller: poller} +} + +func (p *trueNASAppReadProvider) ReadLogs(ctx context.Context, req tools.AppContainerReadRequest) (*tools.AppContainerReadResult, error) { + if p == nil || p.poller == nil { + return nil, fmt.Errorf("truenas app read provider is unavailable") + } + + appID := strings.TrimSpace(req.ProviderUID) + if appID == "" { + appID = strings.TrimSpace(req.Name) + } + result, err := p.poller.ReadAppLogs(ctx, req.OrgID, req.Host, appID, req.Container, req.Lines) + if err != nil { + return nil, err + } + + readResult := &tools.AppContainerReadResult{ + 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), + Container: strings.TrimSpace(req.Container), + Lines: req.Lines, + } + if result != nil { + if id := strings.TrimSpace(result.App.ID); id != "" { + readResult.ProviderUID = id + } + if name := strings.TrimSpace(result.App.Name); name != "" { + readResult.Name = name + } + if host := strings.TrimSpace(result.Host); host != "" { + readResult.Host = host + } + if containerName := strings.TrimSpace(result.Container.ServiceName); containerName != "" { + readResult.Container = containerName + } else if containerID := strings.TrimSpace(result.Container.ID); containerID != "" { + readResult.Container = containerID + } + if result.TailLines > 0 { + readResult.Lines = result.TailLines + } + readResult.Output = formatTrueNASAppLogOutput(result.Lines) + } + if readResult.Platform == "" { + readResult.Platform = "truenas" + } + return readResult, nil +} + +func formatTrueNASAppLogOutput(lines []truenas.AppLogLine) string { + if len(lines) == 0 { + return "" + } + formatted := make([]string, 0, len(lines)) + for _, line := range lines { + text := strings.TrimSpace(line.Data) + if text == "" { + continue + } + if timestamp := strings.TrimSpace(line.Timestamp); timestamp != "" { + formatted = append(formatted, fmt.Sprintf("%s %s", timestamp, text)) + continue + } + formatted = append(formatted, text) + } + return strings.Join(formatted, "\n") +} diff --git a/internal/monitoring/truenas_poller.go b/internal/monitoring/truenas_poller.go index 21b90b744..e6333403b 100644 --- a/internal/monitoring/truenas_poller.go +++ b/internal/monitoring/truenas_poller.go @@ -579,6 +579,55 @@ func (p *TrueNASPoller) ControlApp(ctx context.Context, orgID, host, appID, acti return nil, fmt.Errorf("truenas app %q was not found for org %q", appID, orgID) } +// ReadAppLogs executes a canonical bounded log read for one tenant-scoped +// TrueNAS-managed app-container resource. +func (p *TrueNASPoller) ReadAppLogs(ctx context.Context, orgID, host, appID, containerRef string, tailLines int) (*truenas.AppLogResult, error) { + if p == nil { + return nil, fmt.Errorf("truenas poller is nil") + } + if !truenas.IsFeatureEnabled() { + return nil, fmt.Errorf("truenas integration is disabled") + } + + orgID = strings.TrimSpace(orgID) + if orgID == "" { + orgID = "default" + } + host = strings.TrimSpace(host) + appID = strings.TrimSpace(appID) + if appID == "" { + return nil, fmt.Errorf("truenas app id is required") + } + + type providerEntry struct { + connectionID string + provider *truenas.Provider + } + + 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() + if _, ok := findTrueNASAppSnapshot(currentSnapshot, host, appID); !ok { + continue + } + return entry.provider.ReadAppLogs(ctx, appID, containerRef, tailLines) + } + + return nil, fmt.Errorf("truenas app %q was not found for org %q", appID, orgID) +} + func cloneIngestRecords(records []unifiedresources.IngestRecord) []unifiedresources.IngestRecord { if len(records) == 0 { return nil diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go index 87eb9b95b..1e0d8ff10 100644 --- a/internal/monitoring/truenas_poller_test.go +++ b/internal/monitoring/truenas_poller_test.go @@ -253,6 +253,13 @@ type pollerControlFetcher struct { snapshot *truenas.FixtureSnapshot startCalls []string stopCalls []string + logReads []pollerLogReadCall +} + +type pollerLogReadCall struct { + appName string + containerID string + tailLines int } func (f *pollerControlFetcher) Fetch(context.Context) (*truenas.FixtureSnapshot, error) { @@ -288,6 +295,17 @@ func (f *pollerControlFetcher) StopApp(_ context.Context, appID string) error { return nil } +func (f *pollerControlFetcher) ReadAppLogs(_ context.Context, appName, containerID string, tailLines int) ([]truenas.AppLogLine, error) { + f.logReads = append(f.logReads, pollerLogReadCall{ + appName: appName, + containerID: containerID, + tailLines: tailLines, + }) + return []truenas.AppLogLine{ + {Timestamp: "2026-03-29T18:00:00Z", Data: "ready"}, + }, nil +} + func TestTrueNASPollerControlAppRefreshesCachedRecords(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) @@ -328,6 +346,40 @@ func TestTrueNASPollerControlAppRefreshesCachedRecords(t *testing.T) { } } +func TestTrueNASPollerReadAppLogsUsesTenantScopedProvider(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.ReadAppLogs(context.Background(), "default", "truenas-main", "nextcloud", "", 20) + if err != nil { + t.Fatalf("ReadAppLogs() error = %v", err) + } + if result == nil || result.App.Name != "Nextcloud" { + t.Fatalf("expected Nextcloud log result, got %+v", result) + } + if result.Container.ID != "nextcloud-web-1" { + t.Fatalf("expected canonical primary container, got %+v", result.Container) + } + if len(fetcher.logReads) != 1 { + t.Fatalf("expected one log read, got %+v", fetcher.logReads) + } + if call := fetcher.logReads[0]; call.appName != "nextcloud" || call.containerID != "nextcloud-web-1" || call.tailLines != 20 { + t.Fatalf("unexpected log read call: %+v", call) + } +} + func TestTrueNASPollerHandlesConnectionAddRemove(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) diff --git a/internal/truenas/client.go b/internal/truenas/client.go index c441947dd..568c1dcf2 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -28,6 +28,12 @@ const defaultRealtimeIntervalSeconds = 2 const defaultAppStatsIntervalSeconds = defaultRealtimeIntervalSeconds +const defaultAppLogInitialWait = 2 * time.Second + +const defaultAppLogIdleWait = 250 * time.Millisecond + +const maxAppLogTailLines = 500 + // ClientConfig configures the TrueNAS REST API client. type ClientConfig struct { Host string @@ -472,6 +478,55 @@ func (c *Client) StopApp(ctx context.Context, appID string) error { return c.executeAppAction(ctx, "app.stop", appID) } +// GetAppLogs retrieves a bounded tail of one TrueNAS app container log stream +// through the canonical JSON-RPC event path. +func (c *Client) GetAppLogs(ctx context.Context, appName, containerID string, tailLines int) ([]AppLogLine, error) { + appName = strings.TrimSpace(appName) + containerID = strings.TrimSpace(containerID) + if appName == "" { + return nil, fmt.Errorf("truenas app name is required") + } + if containerID == "" { + return nil, fmt.Errorf("truenas container id is required") + } + if tailLines <= 0 { + tailLines = 100 + } + if tailLines > maxAppLogTailLines { + tailLines = maxAppLogTailLines + } + + conn, err := c.dialRPC(ctx) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + + rpc := trueNASRPCClient{ + conn: conn, + nextID: 1, + } + if err := rpc.authenticate(ctx, c.config); err != nil { + return nil, err + } + + subscriptionArgs := map[string]any{ + "app_name": appName, + "container_id": containerID, + "tail_lines": tailLines, + } + subscriptionJSON, err := json.Marshal(subscriptionArgs) + if err != nil { + return nil, fmt.Errorf("marshal truenas app log subscription: %w", err) + } + subscriptionName := fmt.Sprintf("app.container_log_follow:%s", string(subscriptionJSON)) + if err := rpc.call(ctx, "core.subscribe", []any{subscriptionName}, nil); err != nil { + return nil, err + } + + return rpc.readAppLogEvents(ctx, tailLines) +} + func (c *Client) executeAppAction(ctx context.Context, method, appID string) error { appID = strings.TrimSpace(appID) if appID == "" { @@ -830,6 +885,11 @@ type trueNASAppBlkIOStats struct { Write int64 `json:"write"` } +type trueNASAppLogNotification struct { + Collection string `json:"collection"` + Fields any `json:"fields"` +} + type trueNASRealtimeNotification struct { Collection string `json:"collection"` Fields map[string]any `json:"fields"` @@ -1007,6 +1067,140 @@ func (c *trueNASRPCClient) readAppStatsEvent(ctx context.Context, intervalSecond } } +func (c *trueNASRPCClient) readAppLogEvents(ctx context.Context, tailLines int) ([]AppLogLine, error) { + if c == nil || c.conn == nil { + return nil, fmt.Errorf("truenas rpc connection is nil") + } + if tailLines <= 0 { + tailLines = 100 + } + if tailLines > maxAppLogTailLines { + tailLines = maxAppLogTailLines + } + + initialDeadline := time.Now().Add(defaultAppLogInitialWait) + idleDeadline := time.Time{} + lines := make([]AppLogLine, 0, tailLines) + + for { + deadline := initialDeadline + if !idleDeadline.IsZero() { + deadline = idleDeadline + } + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + _ = c.conn.SetReadDeadline(deadline) + + var message trueNASRPCResponse + if err := c.conn.ReadJSON(&message); err != nil { + if isTimeoutError(err) { + return trimAppLogLines(lines, tailLines), nil + } + return nil, fmt.Errorf("read truenas rpc app.container_log_follow notification: %w", err) + } + if message.Method == "" { + if message.Error != nil { + return nil, fmt.Errorf("truenas rpc app.container_log_follow failed: code=%d message=%q", message.Error.Code, strings.TrimSpace(message.Error.Message)) + } + continue + } + if len(message.Params) == 0 { + continue + } + + var notification trueNASAppLogNotification + if err := json.Unmarshal(message.Params, ¬ification); err == nil && (notification.Collection != "" || notification.Fields != nil) { + if notification.Collection != "" && !strings.HasPrefix(strings.TrimSpace(notification.Collection), "app.container_log_follow") { + continue + } + appended := appendAppLogLines(lines, notification.Fields) + if len(appended) > len(lines) { + lines = appended + if len(lines) >= tailLines { + return trimAppLogLines(lines, tailLines), nil + } + idleDeadline = time.Now().Add(defaultAppLogIdleWait) + } + continue + } + + var raw any + if err := json.Unmarshal(message.Params, &raw); err != nil { + return nil, fmt.Errorf("decode truenas app.container_log_follow notification: %w", err) + } + appended := appendAppLogLines(lines, raw) + if len(appended) > len(lines) { + lines = appended + if len(lines) >= tailLines { + return trimAppLogLines(lines, tailLines), nil + } + idleDeadline = time.Now().Add(defaultAppLogIdleWait) + } + } +} + +func appendAppLogLines(lines []AppLogLine, raw any) []AppLogLine { + for _, line := range extractAppLogLines(raw) { + if strings.TrimSpace(line.Data) == "" { + continue + } + lines = append(lines, line) + } + return lines +} + +func extractAppLogLines(raw any) []AppLogLine { + switch typed := raw.(type) { + case nil: + return nil + case []any: + var lines []AppLogLine + for _, entry := range typed { + lines = append(lines, extractAppLogLines(entry)...) + } + return lines + case map[string]any: + if fields, ok := typed["fields"]; ok { + return extractAppLogLines(fields) + } + if data, ok := typed["data"]; ok { + text := strings.TrimSpace(fmt.Sprint(data)) + if text == "" || text == "" { + return nil + } + line := AppLogLine{Data: text} + if timestamp, ok := typed["timestamp"]; ok && timestamp != nil { + ts := strings.TrimSpace(fmt.Sprint(timestamp)) + if ts != "" && ts != "" { + line.Timestamp = ts + } + } + return []AppLogLine{line} + } + return nil + default: + return nil + } +} + +func trimAppLogLines(lines []AppLogLine, tailLines int) []AppLogLine { + if len(lines) == 0 { + return nil + } + if tailLines > 0 && len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + out := make([]AppLogLine, len(lines)) + copy(out, lines) + return out +} + +func isTimeoutError(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + func (c *trueNASRPCClient) getSystemTemperatures(ctx context.Context) (map[string]float64, error) { if c == nil || c.conn == nil { return nil, fmt.Errorf("truenas rpc connection is nil") diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index debdec3c8..70d25b5c2 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -314,6 +314,64 @@ func TestStartAndStopAppUseRPCMethods(t *testing.T) { } } +func TestGetAppLogsUsesRPCSubscription(t *testing.T) { + server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) { + authReq := readRPCRequest(t, conn) + if authReq.Method != "auth.login_with_api_key" { + t.Fatalf("expected api-key auth method, got %q", authReq.Method) + } + writeRPCResult(t, conn, authReq.ID, true) + + subscribeReq := readRPCRequest(t, conn) + if subscribeReq.Method != "core.subscribe" { + t.Fatalf("expected core.subscribe, got %q", subscribeReq.Method) + } + params, ok := subscribeReq.Params.([]any) + if !ok || len(params) != 1 { + t.Fatalf("expected one subscription param, got %#v", subscribeReq.Params) + } + subscriptionName, _ := params[0].(string) + if !strings.HasPrefix(subscriptionName, "app.container_log_follow:") { + t.Fatalf("expected app.container_log_follow subscription, got %q", subscriptionName) + } + if !strings.Contains(subscriptionName, "\"app_name\":\"nextcloud\"") || !strings.Contains(subscriptionName, "\"container_id\":\"nextcloud-web-1\"") { + t.Fatalf("expected subscription args for nextcloud-web-1, got %q", subscriptionName) + } + writeRPCResult(t, conn, subscribeReq.ID, "sub-logs") + writeRPCNotification(t, conn, "collection_update", map[string]any{ + "collection": "app.container_log_follow:{\"app_name\":\"nextcloud\",\"container_id\":\"nextcloud-web-1\",\"tail_lines\":2}", + "fields": map[string]any{ + "data": "ready", + "timestamp": "2026-03-29T18:00:00Z", + }, + }) + writeRPCNotification(t, conn, "collection_update", map[string]any{ + "collection": "app.container_log_follow:{\"app_name\":\"nextcloud\",\"container_id\":\"nextcloud-web-1\",\"tail_lines\":2}", + "fields": map[string]any{ + "data": "serving", + "timestamp": "2026-03-29T18:01:00Z", + }, + }) + time.Sleep(defaultAppLogIdleWait + 100*time.Millisecond) + }) + t.Cleanup(server.Close) + + client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"}) + lines, err := client.GetAppLogs(context.Background(), "nextcloud", "nextcloud-web-1", 2) + if err != nil { + t.Fatalf("GetAppLogs() error = %v", err) + } + if len(lines) != 2 { + t.Fatalf("expected two log lines, got %+v", lines) + } + if lines[0].Timestamp != "2026-03-29T18:00:00Z" || lines[0].Data != "ready" { + t.Fatalf("unexpected first log line: %+v", lines[0]) + } + if lines[1].Data != "serving" { + t.Fatalf("unexpected second log line: %+v", lines[1]) + } +} + func TestGetSystemTelemetryFromRPC(t *testing.T) { server := newMockServerWithRPC(t, defaultAPIResponses(), nil, func(t *testing.T, conn *websocket.Conn) { authReq := readRPCRequest(t, conn) diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index f94fae914..50aed0eac 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -52,6 +52,10 @@ type appActionFetcher interface { StopApp(ctx context.Context, appID string) error } +type appReadFetcher interface { + ReadAppLogs(ctx context.Context, appName, containerID string, tailLines int) ([]AppLogLine, error) +} + // APIFetcher loads snapshots from the live TrueNAS API client. type APIFetcher struct { Client *Client @@ -87,6 +91,13 @@ func (f *APIFetcher) StopApp(ctx context.Context, appID string) error { return f.Client.StopApp(ctx, appID) } +func (f *APIFetcher) ReadAppLogs(ctx context.Context, appName, containerID string, tailLines int) ([]AppLogLine, error) { + if f == nil || f.Client == nil { + return nil, fmt.Errorf("truenas api fetcher client is nil") + } + return f.Client.GetAppLogs(ctx, appName, containerID, tailLines) +} + // FixtureFetcher loads snapshots from static fixture data. type FixtureFetcher struct { Snapshot FixtureSnapshot @@ -187,6 +198,43 @@ func (p *Provider) ControlApp(ctx context.Context, appID, action string) (*Fixtu return p.Snapshot(), nil } +// ReadAppLogs returns a bounded recent log tail for one TrueNAS app container. +func (p *Provider) ReadAppLogs(ctx context.Context, appID, containerRef string, tailLines int) (*AppLogResult, error) { + if p == nil { + return nil, fmt.Errorf("truenas provider is nil") + } + reader, ok := p.fetcher.(appReadFetcher) + if !ok { + return nil, fmt.Errorf("truenas provider fetcher does not support app log reads") + } + + snapshot := p.Snapshot() + app, err := findAppInSnapshot(snapshot, appID) + if err != nil { + return nil, err + } + container, err := selectAppLogContainer(*app, containerRef) + if err != nil { + return nil, err + } + + lines, err := reader.ReadAppLogs(ctx, appCanonicalID(*app), container.ID, tailLines) + if err != nil { + return nil, err + } + + result := &AppLogResult{ + App: *app, + Container: container, + Lines: trimAppLogResultLines(lines, tailLines), + TailLines: tailLines, + } + 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 { @@ -984,6 +1032,91 @@ func appCanonicalID(app App) string { return appDisplayName(app) } +func findAppInSnapshot(snapshot *FixtureSnapshot, appID string) (*App, error) { + if snapshot == nil { + return nil, fmt.Errorf("truenas snapshot is unavailable") + } + appID = strings.TrimSpace(appID) + if appID == "" { + return nil, fmt.Errorf("truenas app id is required") + } + for i := range snapshot.Apps { + app := &snapshot.Apps[i] + if strings.EqualFold(appCanonicalID(*app), appID) || strings.EqualFold(strings.TrimSpace(app.Name), appID) { + return app, nil + } + } + return nil, fmt.Errorf("truenas app %q was not found", appID) +} + +func selectAppLogContainer(app App, containerRef string) (AppContainer, error) { + if len(app.Containers) == 0 { + return AppContainer{}, fmt.Errorf("truenas app %q does not expose any runtime containers for log reads", appDisplayName(app)) + } + containerRef = strings.TrimSpace(containerRef) + if containerRef != "" { + for _, container := range app.Containers { + if strings.EqualFold(strings.TrimSpace(container.ID), containerRef) || + strings.EqualFold(strings.TrimSpace(container.ServiceName), containerRef) { + return container, nil + } + } + return AppContainer{}, fmt.Errorf("truenas app %q does not have container %q. Available containers: %s", appDisplayName(app), containerRef, availableAppLogContainers(app.Containers)) + } + if len(app.Containers) == 1 { + return app.Containers[0], nil + } + + canonicalAppID := normalizeAppStatsKey(appCanonicalID(app)) + for _, container := range app.Containers { + if normalizeAppStatsKey(container.ServiceName) == canonicalAppID { + return container, nil + } + } + for _, container := range app.Containers { + if strings.EqualFold(strings.TrimSpace(container.State), "running") { + return container, nil + } + } + return AppContainer{}, fmt.Errorf("truenas app %q has multiple containers. Specify container using one of: %s", appDisplayName(app), availableAppLogContainers(app.Containers)) +} + +func availableAppLogContainers(containers []AppContainer) string { + if len(containers) == 0 { + return "" + } + options := make([]string, 0, len(containers)) + for _, container := range containers { + label := strings.TrimSpace(container.ServiceName) + if label == "" { + label = strings.TrimSpace(container.ID) + } + if label == "" { + continue + } + if id := strings.TrimSpace(container.ID); id != "" && !strings.EqualFold(id, label) { + label = fmt.Sprintf("%s (%s)", label, id) + } + options = append(options, label) + } + if len(options) == 0 { + return "" + } + return strings.Join(options, ", ") +} + +func trimAppLogResultLines(lines []AppLogLine, tailLines int) []AppLogLine { + if len(lines) == 0 { + return nil + } + if tailLines > 0 && len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + out := make([]AppLogLine, len(lines)) + copy(out, lines) + return out +} + func primaryAppImage(app App) string { if len(app.Images) > 0 { if image := strings.TrimSpace(app.Images[0]); image != "" { diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index 421f448df..e7838f297 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -41,6 +41,13 @@ type controllableStubFetcher struct { snapshot *FixtureSnapshot startCalls []string stopCalls []string + logReads []appLogReadCall +} + +type appLogReadCall struct { + appName string + containerID string + tailLines int } func (s *controllableStubFetcher) Fetch(context.Context) (*FixtureSnapshot, error) { @@ -73,6 +80,18 @@ func (s *controllableStubFetcher) StopApp(_ context.Context, appID string) error return nil } +func (s *controllableStubFetcher) ReadAppLogs(_ context.Context, appName, containerID string, tailLines int) ([]AppLogLine, error) { + s.logReads = append(s.logReads, appLogReadCall{ + appName: appName, + containerID: containerID, + tailLines: tailLines, + }) + return []AppLogLine{ + {Timestamp: "2026-03-29T18:00:00Z", Data: "ready"}, + {Timestamp: "2026-03-29T18:01:00Z", Data: "serving"}, + }, nil +} + func TestFixtureFetcherReturnsSnapshotCopy(t *testing.T) { fixtures := DefaultFixtures() fetcher := &FixtureFetcher{Snapshot: fixtures} @@ -187,6 +206,58 @@ func TestProviderControlAppUsesFetcherAndRefreshesSnapshot(t *testing.T) { } } +func TestProviderReadAppLogsUsesCanonicalPrimaryContainer(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.ReadAppLogs(context.Background(), "nextcloud", "", 25) + if err != nil { + t.Fatalf("ReadAppLogs() error = %v", err) + } + if result == nil { + t.Fatal("expected app log result") + } + if result.Host != "truenas-main" || result.App.Name != "Nextcloud" { + t.Fatalf("unexpected app log result identity: %+v", result) + } + if result.Container.ServiceName != "nextcloud" || result.Container.ID != "nextcloud-web-1" { + t.Fatalf("expected canonical primary container, got %+v", result.Container) + } + if len(fetcher.logReads) != 1 { + t.Fatalf("expected one log read, got %+v", fetcher.logReads) + } + if call := fetcher.logReads[0]; call.appName != "nextcloud" || call.containerID != "nextcloud-web-1" || call.tailLines != 25 { + t.Fatalf("unexpected log read call: %+v", call) + } +} + +func TestProviderReadAppLogsSupportsExplicitServiceSelection(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.ReadAppLogs(context.Background(), "nextcloud", "redis", 10) + if err != nil { + t.Fatalf("ReadAppLogs() error = %v", err) + } + if result == nil { + t.Fatal("expected app log result") + } + if result.Container.ServiceName != "redis" || result.Container.ID != "nextcloud-redis-1" { + t.Fatalf("expected explicit redis container selection, got %+v", result.Container) + } + if len(fetcher.logReads) != 1 || fetcher.logReads[0].containerID != "nextcloud-redis-1" { + t.Fatalf("unexpected log read calls: %+v", fetcher.logReads) + } +} + func TestProviderRefreshPreservesLastSnapshotOnError(t *testing.T) { initial := DefaultFixtures() provider := NewProvider(initial) diff --git a/internal/truenas/types.go b/internal/truenas/types.go index 0cdc914ab..af1cf81c8 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -148,6 +148,21 @@ type AppContainer struct { VolumeMounts []AppVolume } +// AppLogLine stores one bounded log entry returned by the TrueNAS app log API. +type AppLogLine struct { + Timestamp string + Data string +} + +// AppLogResult captures a bounded log read for one TrueNAS app container. +type AppLogResult struct { + Host string + App App + Container AppContainer + Lines []AppLogLine + TailLines int +} + // AppVolume describes a bind or named volume mount exposed by a TrueNAS app. type AppVolume struct { Source string