mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(ai): add canonical truenas app log reads
This commit is contained in:
@@ -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 "`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "<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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user