feat(ai): add canonical truenas app log reads

This commit is contained in:
rcourtman
2026-03-29 20:13:39 +01:00
parent b0ba88d541
commit 298b23626b
25 changed files with 1023 additions and 11 deletions
@@ -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
@@ -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=<canonical app>`
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
@@ -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
@@ -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
@@ -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.
@@ -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 "`
+34 -2
View File
@@ -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)
@@ -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)
}
}
+42 -1
View File
@@ -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":
@@ -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) {
+85 -8
View File
@@ -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 {
+93
View File
@@ -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)
}
}
+1
View File
@@ -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
}
@@ -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) {
+3
View File
@@ -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 {
+3
View File
@@ -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) {
+4
View File
@@ -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")
}
+90
View File
@@ -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")
}
+49
View File
@@ -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
@@ -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)
+194
View File
@@ -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, &notification); 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 == "<nil>" {
return nil
}
line := AppLogLine{Data: text}
if timestamp, ok := typed["timestamp"]; ok && timestamp != nil {
ts := strings.TrimSpace(fmt.Sprint(timestamp))
if ts != "" && ts != "<nil>" {
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")
+58
View File
@@ -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)
+133
View File
@@ -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 != "" {
+71
View File
@@ -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)
+15
View File
@@ -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