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