mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Make workload discovery repair known service results
This commit is contained in:
@@ -902,6 +902,11 @@ profile and assignment columns, but embedded table framing must route through
|
||||
lifecycle surfaces may expose the controls, but route-level authority must
|
||||
require `settings:write` plus the Discovery enablement gate, not
|
||||
`monitoring:write`, enrollment state, or any fleet lifecycle shortcut.
|
||||
Forced workload discovery also remains API/service-owned for endpoint
|
||||
identity: lifecycle surfaces must not fill a Proxmox parent node or agent
|
||||
name into workload trigger hostnames when the operator did not provide one,
|
||||
because the discovery service resolves VM and system-container names from
|
||||
canonical state before suggesting URLs or classifying known services.
|
||||
The editor's probe step calls the aggregator probe endpoint and
|
||||
dispatches the detected or manually-selected type into a credential
|
||||
slot; it must not bypass the probe endpoint or fabricate probe
|
||||
|
||||
@@ -254,7 +254,7 @@ runtime cost control, and shared AI transport surfaces.
|
||||
divergence must be sourced from canonical `ReadState.DockerHosts()` views,
|
||||
with model-shaped data limited to the watcher adapter input rather than
|
||||
direct `StateSnapshot.DockerHosts` reads in the Patrol run loop.
|
||||
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops. `internal/api/ai_handlers.go` must preserve an explicitly supplied `discovery_interval_hours: 0` as the manual-only setting and may only apply the 24-hour default when discovery is enabled without an explicit interval payload. Discovery analysis remains a Pulse tool-led model workflow: Pulse supplies agent/API/metrics evidence and cache orchestration, while the selected model provides the intelligence. Background, settings-triggered, or drawer-triggered discovery progress must describe that discovery analysis directly and must not imply a live Pulse Assistant chat transcript unless the run is actually executing inside the chat surface. Settings-triggered manual discovery is an explicit operator refresh and must not open, append to, or masquerade as a Pulse Assistant session. Assistant and Patrol access to discovery must stay behind the governed `pulse_discovery` tool, including an explicit forced refresh action for known resources, while settings-level manual runs must use the canonical `/api/discovery/run` new/changed/stale sweep rather than a frontend-only shortcut.
|
||||
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops. `internal/api/ai_handlers.go` must preserve an explicitly supplied `discovery_interval_hours: 0` as the manual-only setting and may only apply the 24-hour default when discovery is enabled without an explicit interval payload. Discovery analysis remains a Pulse tool-led model workflow: Pulse supplies agent/API/metrics evidence and cache orchestration, while the selected model provides the intelligence. Background, settings-triggered, or drawer-triggered discovery progress must describe that discovery analysis directly and must not imply a live Pulse Assistant chat transcript unless the run is actually executing inside the chat surface. Settings-triggered manual discovery is an explicit operator refresh and must not open, append to, or masquerade as a Pulse Assistant session. Assistant and Patrol access to discovery must stay behind the governed `pulse_discovery` tool, including an explicit forced refresh action for known resources, while settings-level manual runs must use the canonical `/api/discovery/run` new/changed/stale/repairable sweep rather than a frontend-only shortcut. Fresh-but-unidentified service-discovery records are not complete when canonical resource metadata, stored facts, or safe command evidence can deterministically identify a known workload and endpoint; cached reads and the canonical sweep must repair those records instead of presenting `Unknown Service` as fresh.
|
||||
5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
|
||||
External provider-bound unified-resource context must enforce the same
|
||||
data-handling policy the export audit records: `local-only` resources are
|
||||
|
||||
@@ -175,6 +175,13 @@ summary response shapes used by the live discovery service. Live service data
|
||||
remains primary when a service is configured; mock fixtures may supplement or
|
||||
stand in for that data only in mock mode, and they must not expose raw command
|
||||
output or bypass the normal non-admin redaction path.
|
||||
Forced discovery trigger requests must preserve workload identity at the API
|
||||
boundary. `POST /api/discovery/{type}/{target}/{id}` may use the route target
|
||||
as a hostname fallback only for host-agent discovery; VM, system-container,
|
||||
Docker, and Kubernetes workload triggers must leave hostname empty unless the
|
||||
caller supplied one so the discovery service resolves the workload name and
|
||||
endpoint from canonical resource state instead of mistaking the parent node for
|
||||
the workload endpoint.
|
||||
|
||||
Source-specific backup artifact routes are canonical API payload boundaries.
|
||||
`/api/backups/pve` owns Proxmox VE task, storage-archive, and guest-snapshot
|
||||
|
||||
@@ -974,7 +974,12 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
The same workload-discovery settings section must expose a manual
|
||||
"Run discovery now" action wired through `/api/discovery/run` when
|
||||
workload discovery is enabled in manual-only mode, while resource-drawer
|
||||
discovery remains the forced single-resource refresh path.
|
||||
discovery remains the forced single-resource refresh path. The collapsed
|
||||
section and run-action copy must make automatic scheduling visible by
|
||||
distinguishing `Auto <interval>`, `Manual only`, and `Off`, and the run
|
||||
action must describe whether it is executing the scheduled sweep or a
|
||||
one-off manual-only sweep rather than implying recurring scans were
|
||||
enabled.
|
||||
Assistant-only controls inside the shared shell, such
|
||||
as execution permissions and session maintenance, must stay explicitly
|
||||
labeled as Pulse Assistant controls, while Patrol schedule and autonomy
|
||||
|
||||
@@ -473,6 +473,12 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
commands, adjacent storage/recovery surfaces must also inherit the
|
||||
API/runtime gate: `settings:write` plus enabled Discovery are required
|
||||
before command-backed refresh, and `monitoring:write` remains insufficient.
|
||||
If the shared discovery boundary repairs a fresh unknown workload record
|
||||
into a known service identity and endpoint candidate from canonical resource
|
||||
metadata, stored facts, or safe command evidence, storage and recovery may
|
||||
consume the repaired context only as read-only explanation; that repair does
|
||||
not create backup visibility, restore authority, storage ownership, or a
|
||||
recovery-local endpoint contract.
|
||||
That same adjacent `internal/api/` boundary also keeps public hosted signup
|
||||
commercial-only: storage and recovery surfaces must not infer tenant
|
||||
existence, email issuance, or readiness from `/api/public/signup` response
|
||||
|
||||
@@ -56,8 +56,8 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
<Show when={state.form.discoveryEnabled}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 rounded">
|
||||
{state.form.discoveryIntervalHours > 0
|
||||
? `${state.form.discoveryIntervalHours}h`
|
||||
: 'Manual'}
|
||||
? `Auto ${state.form.discoveryIntervalHours}h`
|
||||
: 'Manual only'}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!state.form.discoveryEnabled}>
|
||||
@@ -118,17 +118,19 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
</FormSelect>
|
||||
<p class="text-[10px] text-muted ml-32 pl-3">
|
||||
{state.form.discoveryIntervalHours === 0
|
||||
? 'Workload discovery runs only when you click "Run discovery now" here or "Run Discovery" on a resource'
|
||||
: 'Workload discovery will automatically re-scan resources at this interval'}
|
||||
? 'Automatic workload scans are off. Only manual refreshes will run.'
|
||||
: 'Automatic workload scans will run at this interval.'}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded border border-border bg-surface-alt px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p class="text-[10px] text-muted sm:flex-1">
|
||||
{state.form.discoveryEnabled
|
||||
? 'Runs the new, changed, and stale workload sweep used by the schedule.'
|
||||
: 'Runs a one-time workload discovery refresh without changing the schedule.'}
|
||||
{state.form.discoveryEnabled && state.form.discoveryIntervalHours > 0
|
||||
? 'Runs the pending workload sweep used by the schedule.'
|
||||
: state.form.discoveryEnabled
|
||||
? 'Manual-only mode: runs the pending workload sweep once without enabling recurring scans.'
|
||||
: 'Runs a one-time workload discovery refresh without changing the schedule.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -498,9 +498,11 @@ describe('AISettings workload discovery persistence', () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Workload Discovery 24h/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Workload Discovery Auto 24h/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery 24h/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Auto 24h/i }));
|
||||
const intervalSelect = screen.getByLabelText('Scan Interval');
|
||||
expect(intervalSelect).toHaveValue('24');
|
||||
|
||||
@@ -532,10 +534,10 @@ describe('AISettings workload discovery persistence', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Workload Discovery Manual/i }),
|
||||
screen.getByRole('button', { name: /Workload Discovery Manual only/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Manual/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Manual only/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run discovery now/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -596,15 +598,15 @@ describe('AISettings workload discovery persistence', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Workload Discovery Manual/i }),
|
||||
screen.getByRole('button', { name: /Workload Discovery Manual only/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Manual/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Manual only/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run discovery now/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationInfoMock).toHaveBeenCalledWith(
|
||||
'Discovery refresh finished: no new, changed, or stale workloads.',
|
||||
'Discovery refresh finished: no new, changed, stale, or repairable workloads.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,8 +344,19 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiSettingsStateSource).toContain('discoveryRunRunning');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('state.handleRunDiscoveryRefresh()');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Run discovery now');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Auto ${state.form.discoveryIntervalHours}h');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Manual only');
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Runs a one-time workload discovery refresh without changing the schedule.',
|
||||
'Automatic workload scans will run at this interval.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Automatic workload scans are off. Only manual refreshes will run.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Runs the pending workload sweep used by the schedule.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Manual-only mode: runs the pending workload sweep once without enabling recurring scans.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain("fetch('/api/discovery/run");
|
||||
});
|
||||
|
||||
@@ -1012,7 +1012,9 @@ export const useAISettingsState = () => {
|
||||
`Discovery refresh finished: ${result.discovered_count} refreshed, ${result.failed_count} failed.`,
|
||||
);
|
||||
} else if (result.candidate_count === 0) {
|
||||
notificationStore.info('Discovery refresh finished: no new, changed, or stale workloads.');
|
||||
notificationStore.info(
|
||||
'Discovery refresh finished: no new, changed, stale, or repairable workloads.',
|
||||
);
|
||||
} else {
|
||||
notificationStore.success(
|
||||
`Discovery refresh finished: ${result.discovered_count} workload${result.discovered_count === 1 ? '' : 's'} refreshed.`,
|
||||
|
||||
@@ -8886,6 +8886,28 @@ func TestContract_DiscoveryCommandScanRoutesRequireSettingsWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_WorkloadDiscoveryTriggerLeavesHostnameForServiceResolution(t *testing.T) {
|
||||
source, err := os.ReadFile(filepath.Clean("discovery_handlers.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("read discovery handlers: %v", err)
|
||||
}
|
||||
text := string(source)
|
||||
|
||||
for _, required := range []string{
|
||||
"Hostname: reqBody.Hostname",
|
||||
"resourceType == servicediscovery.ResourceTypeAgent",
|
||||
"req.Hostname = targetID",
|
||||
"Workloads should resolve their own display name from state",
|
||||
} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Fatalf("discovery trigger lost workload hostname contract marker %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "if req.Hostname == \"\" {\n\t\treq.Hostname = targetID") {
|
||||
t.Fatal("discovery trigger must not use the route target as a generic workload hostname fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_AutoRegisterRequestJSONSnapshot(t *testing.T) {
|
||||
payload := AutoRegisterRequest{
|
||||
Type: "pve",
|
||||
|
||||
@@ -579,8 +579,10 @@ func (h *DiscoveryHandlers) HandleTriggerDiscovery(w http.ResponseWriter, r *htt
|
||||
Force: reqBody.Force,
|
||||
}
|
||||
|
||||
// If hostname not provided, use target ID as a fallback.
|
||||
if req.Hostname == "" {
|
||||
// If hostname not provided for host-agent discovery, use target ID as a
|
||||
// fallback. Workloads should resolve their own display name from state so a
|
||||
// parent node is not mistaken for the workload endpoint.
|
||||
if req.Hostname == "" && resourceType == servicediscovery.ResourceTypeAgent {
|
||||
req.Hostname = targetID
|
||||
}
|
||||
|
||||
|
||||
@@ -662,8 +662,12 @@ func (s *Service) runDiscoveryRefresh(ctx context.Context, mode string) (Discove
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("fetch stale resources: %w", err)
|
||||
}
|
||||
repairableResources, err := s.getKnownServiceRepairCandidates()
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("fetch repairable discoveries: %w", err)
|
||||
}
|
||||
|
||||
candidates := make(map[string]struct{}, len(changedResources)+len(staleResources))
|
||||
candidates := make(map[string]struct{}, len(changedResources)+len(staleResources)+len(repairableResources))
|
||||
for _, id := range changedResources {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
candidates[id] = struct{}{}
|
||||
@@ -674,6 +678,11 @@ func (s *Service) runDiscoveryRefresh(ctx context.Context, mode string) (Discove
|
||||
candidates[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, id := range repairableResources {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
candidates[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
summary.ChangedCount = len(changedResources)
|
||||
summary.StaleCount = len(staleResources)
|
||||
@@ -695,8 +704,9 @@ func (s *Service) runDiscoveryRefresh(ctx context.Context, mode string) (Discove
|
||||
Str("mode", mode).
|
||||
Int("changed", len(changedResources)).
|
||||
Int("stale", len(staleResources)).
|
||||
Int("repairable", len(repairableResources)).
|
||||
Int("total", len(resourceIDs)).
|
||||
Msg("Running discovery refresh for changed/stale resources")
|
||||
Msg("Running discovery refresh for changed, stale, or repairable resources")
|
||||
|
||||
for _, id := range resourceIDs {
|
||||
if ctx.Err() != nil {
|
||||
@@ -717,7 +727,6 @@ func (s *Service) runDiscoveryRefresh(ctx context.Context, mode string) (Discove
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
TargetID: targetID,
|
||||
Hostname: targetID,
|
||||
})
|
||||
if err != nil {
|
||||
summary.FailedCount++
|
||||
@@ -740,6 +749,74 @@ func (s *Service) runDiscoveryRefresh(ctx context.Context, mode string) (Discove
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) getKnownServiceRepairCandidates() ([]string, error) {
|
||||
discoveries, err := s.store.List()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list discoveries for repair scan: %w", err)
|
||||
}
|
||||
|
||||
var candidates []string
|
||||
for _, discovery := range discoveries {
|
||||
if discovery == nil {
|
||||
continue
|
||||
}
|
||||
req := discoveryRequestForStoredDiscovery(discovery)
|
||||
if req.ResourceType == "" || req.TargetID == "" || req.ResourceID == "" {
|
||||
continue
|
||||
}
|
||||
if s.discoveryNeedsKnownServiceRepair(discovery, req) {
|
||||
candidates = append(candidates, discovery.ID)
|
||||
}
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func discoveryRequestForStoredDiscovery(discovery *ResourceDiscovery) DiscoveryRequest {
|
||||
if discovery == nil {
|
||||
return DiscoveryRequest{}
|
||||
}
|
||||
|
||||
req := DiscoveryRequest{
|
||||
ResourceType: discovery.ResourceType,
|
||||
TargetID: canonicalDiscoveryTargetID(discovery),
|
||||
ResourceID: discovery.ResourceID,
|
||||
Hostname: discovery.Hostname,
|
||||
}
|
||||
if req.ResourceType != "" && req.TargetID != "" && req.ResourceID != "" {
|
||||
return req
|
||||
}
|
||||
|
||||
resourceType, targetID, resourceID, err := ParseResourceID(discovery.ID)
|
||||
if err != nil {
|
||||
return req
|
||||
}
|
||||
if req.ResourceType == "" {
|
||||
req.ResourceType = resourceType
|
||||
}
|
||||
if req.TargetID == "" {
|
||||
req.TargetID = targetID
|
||||
}
|
||||
if req.ResourceID == "" {
|
||||
req.ResourceID = resourceID
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func (s *Service) discoveryNeedsKnownServiceRepair(discovery *ResourceDiscovery, req DiscoveryRequest) bool {
|
||||
if discovery == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var metadata map[string]any
|
||||
if s.hasStateAccess() {
|
||||
metadata = s.getResourceMetadata(req)
|
||||
if req.Hostname == "" {
|
||||
req.Hostname = stringMetadataValue(metadata, "name", "hostname", "display_name")
|
||||
}
|
||||
}
|
||||
return knownServiceIdentityWouldImprove(discovery, req, metadata, discovery.RawCommandOutput)
|
||||
}
|
||||
|
||||
// getSnapshot returns the current infrastructure state from ReadState.
|
||||
// Returns an empty snapshot and false if ReadState is not yet configured.
|
||||
func (s *Service) getSnapshot() (StateSnapshot, bool) {
|
||||
@@ -778,7 +855,10 @@ func (s *Service) snapshotFromReadState(rs unifiedresources.ReadState) StateSnap
|
||||
Node: v.Node(),
|
||||
Status: string(v.Status()),
|
||||
Instance: v.Instance(),
|
||||
OSName: v.OSName(),
|
||||
OSVersion: v.OSVersion(),
|
||||
IPAddresses: v.IPAddresses(),
|
||||
Template: v.Template(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -792,7 +872,11 @@ func (s *Service) snapshotFromReadState(rs unifiedresources.ReadState) StateSnap
|
||||
Node: v.Node(),
|
||||
Status: string(v.Status()),
|
||||
Instance: v.Instance(),
|
||||
OSTemplate: v.OSTemplate(),
|
||||
OSName: v.OSName(),
|
||||
IsOCI: v.IsOCI(),
|
||||
IPAddresses: v.IPAddresses(),
|
||||
Template: v.Template(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1592,6 +1676,9 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
} else if time.Since(existing.DiscoveredAt) > s.maxDiscoveryAge {
|
||||
needsDiscovery = true
|
||||
reason = "discovery too old"
|
||||
} else if s.discoveryNeedsKnownServiceRepair(existing, req) {
|
||||
needsDiscovery = true
|
||||
reason = "known service identity repair"
|
||||
}
|
||||
|
||||
// Return cached discovery if still valid
|
||||
@@ -1671,6 +1758,9 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
// Add metadata if available
|
||||
if s.hasStateAccess() {
|
||||
analysisReq.Metadata = s.getResourceMetadata(req)
|
||||
if analysisReq.Hostname == "" {
|
||||
analysisReq.Hostname = stringMetadataValue(analysisReq.Metadata, "name", "hostname", "display_name")
|
||||
}
|
||||
}
|
||||
|
||||
// Build prompt and analyze
|
||||
@@ -1709,7 +1799,7 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
}
|
||||
|
||||
// Resolve hostname from metadata if not provided in request
|
||||
hostname := req.Hostname
|
||||
hostname := analysisReq.Hostname
|
||||
if hostname == "" && analysisReq.Metadata != nil {
|
||||
if name, ok := analysisReq.Metadata["name"].(string); ok && name != "" {
|
||||
hostname = name
|
||||
@@ -1790,6 +1880,7 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
discovery.DiscoveredAt = existing.DiscoveredAt
|
||||
}
|
||||
}
|
||||
applyKnownServiceIdentity(discovery, req, analysisReq.Metadata, analysisReq.CommandOutputs)
|
||||
|
||||
// Suggest web interface URL based on service type and external IP.
|
||||
// If no URL can be inferred, capture diagnostics for logs and UI.
|
||||
@@ -1956,6 +2047,12 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
||||
metadata["name"] = c.Name
|
||||
metadata["status"] = c.Status
|
||||
metadata["vmid"] = c.VMID
|
||||
metadata["os_template"] = c.OSTemplate
|
||||
metadata["os_name"] = c.OSName
|
||||
metadata["is_oci"] = c.IsOCI
|
||||
if len(c.Tags) > 0 {
|
||||
metadata["tags"] = c.Tags
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1965,6 +2062,11 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
||||
metadata["name"] = vm.Name
|
||||
metadata["status"] = vm.Status
|
||||
metadata["vmid"] = vm.VMID
|
||||
metadata["os_name"] = vm.OSName
|
||||
metadata["os_version"] = vm.OSVersion
|
||||
if len(vm.Tags) > 0 {
|
||||
metadata["tags"] = vm.Tags
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1973,6 +2075,7 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
||||
if host.AgentID == requestTargetID || host.Hostname == requestTargetID {
|
||||
for _, c := range host.Containers {
|
||||
if c.Name == req.ResourceID {
|
||||
metadata["name"] = c.Name
|
||||
metadata["image"] = c.Image
|
||||
metadata["status"] = c.Status
|
||||
// Filter sensitive labels before sending to AI
|
||||
@@ -2006,6 +2109,21 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
||||
return metadata
|
||||
}
|
||||
|
||||
func stringMetadataValue(metadata map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value, ok := metadata[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
if trimmed := strings.TrimSpace(text); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getResourceExternalIP retrieves the external IP address for a resource from the state.
|
||||
// For system containers/VMs, this is the first IP from the Proxmox guest agent.
|
||||
// For Docker containers, this is the Docker host's IP/hostname.
|
||||
@@ -2023,6 +2141,9 @@ func (s *Service) getResourceExternalIP(req DiscoveryRequest) string {
|
||||
if len(c.IPAddresses) > 0 {
|
||||
return c.IPAddresses[0]
|
||||
}
|
||||
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, c.Name, req.Hostname); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -2032,6 +2153,9 @@ func (s *Service) getResourceExternalIP(req DiscoveryRequest) string {
|
||||
if len(vm.IPAddresses) > 0 {
|
||||
return vm.IPAddresses[0]
|
||||
}
|
||||
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, vm.Name, req.Hostname); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -2051,6 +2175,9 @@ func (s *Service) getResourceExternalIP(req DiscoveryRequest) string {
|
||||
if len(vm.IPAddresses) > 0 {
|
||||
return vm.IPAddresses[0]
|
||||
}
|
||||
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, vm.Name, req.Hostname); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range snap.Containers {
|
||||
@@ -2058,6 +2185,9 @@ func (s *Service) getResourceExternalIP(req DiscoveryRequest) string {
|
||||
if len(c.IPAddresses) > 0 {
|
||||
return c.IPAddresses[0]
|
||||
}
|
||||
if candidate := firstResourceHostnameCandidate(req.ResourceID, requestTargetID, c.Name, req.Hostname); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
case ResourceTypeAgent:
|
||||
@@ -2100,6 +2230,21 @@ func isURLHostCandidate(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func firstResourceHostnameCandidate(resourceID, targetID string, candidates ...string) string {
|
||||
resourceID = strings.TrimSpace(resourceID)
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
for _, candidate := range candidates {
|
||||
trimmed := strings.TrimSpace(candidate)
|
||||
if trimmed == "" || trimmed == resourceID || trimmed == targetID {
|
||||
continue
|
||||
}
|
||||
if isURLHostCandidate(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatURLSuggestionDiagnostic(primaryCode, primaryDetail, fallbackCode, fallbackDetail string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if primaryCode != "" {
|
||||
@@ -2756,9 +2901,62 @@ func (s *Service) upgradeCLIAccessIfNeeded(d *ResourceDiscovery) {
|
||||
upgraded = true
|
||||
}
|
||||
|
||||
req := discoveryRequestForStoredDiscovery(d)
|
||||
var metadata map[string]any
|
||||
if s.hasStateAccess() {
|
||||
metadata = s.getResourceMetadata(req)
|
||||
if req.Hostname == "" {
|
||||
req.Hostname = stringMetadataValue(metadata, "name", "hostname", "display_name")
|
||||
}
|
||||
}
|
||||
if changed, _ := applyKnownServiceIdentity(d, req, metadata, d.RawCommandOutput); changed {
|
||||
upgraded = true
|
||||
}
|
||||
if s.refreshSuggestedURLFromState(d, req) {
|
||||
upgraded = true
|
||||
}
|
||||
|
||||
_ = upgraded // Suppress unused variable warning if logging is disabled
|
||||
}
|
||||
|
||||
func (s *Service) refreshSuggestedURLFromState(d *ResourceDiscovery, req DiscoveryRequest) bool {
|
||||
if d == nil || d.SuggestedURL != "" || !s.hasStateAccess() {
|
||||
return false
|
||||
}
|
||||
|
||||
externalIP := s.getResourceExternalIP(req)
|
||||
if externalIP == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
url, code, detail := suggestWebURLWithReason(d, externalIP)
|
||||
if url == "" {
|
||||
url, code, detail = s.suggestHostManagementURLWithReason(req, externalIP)
|
||||
}
|
||||
if url == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
changed := false
|
||||
if d.SuggestedURL != url {
|
||||
d.SuggestedURL = url
|
||||
changed = true
|
||||
}
|
||||
if d.SuggestedURLSourceCode != code {
|
||||
d.SuggestedURLSourceCode = code
|
||||
changed = true
|
||||
}
|
||||
if d.SuggestedURLSourceDetail != detail {
|
||||
d.SuggestedURLSourceDetail = detail
|
||||
changed = true
|
||||
}
|
||||
if d.SuggestedURLDiagnostic != "" {
|
||||
d.SuggestedURLDiagnostic = ""
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// lookupHostnameFromState finds the hostname/name for a resource from state
|
||||
func (s *Service) lookupHostnameFromState(resourceType ResourceType, hostID, resourceID string, snap StateSnapshot) string {
|
||||
switch resourceType {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package servicediscovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type knownServiceIdentity struct {
|
||||
ServiceType string
|
||||
ServiceName string
|
||||
Category ServiceCategory
|
||||
Aliases []string
|
||||
Confidence float64
|
||||
}
|
||||
|
||||
var knownServiceIdentities = []knownServiceIdentity{
|
||||
{
|
||||
ServiceType: "esphome",
|
||||
ServiceName: "ESPHome",
|
||||
Category: CategoryHomeAuto,
|
||||
Aliases: []string{"esphome", "esp-home", "esp home"},
|
||||
Confidence: 0.85,
|
||||
},
|
||||
}
|
||||
|
||||
func applyKnownServiceIdentity(
|
||||
discovery *ResourceDiscovery,
|
||||
req DiscoveryRequest,
|
||||
metadata map[string]any,
|
||||
commandOutputs map[string]string,
|
||||
) (bool, string) {
|
||||
identity, evidence, ok := inferKnownServiceIdentity(discovery, req, metadata, commandOutputs)
|
||||
if !ok || discovery == nil {
|
||||
return false, ""
|
||||
}
|
||||
if !shouldApplyKnownServiceIdentity(discovery, identity) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
changed := false
|
||||
if strings.TrimSpace(discovery.ServiceType) != identity.ServiceType {
|
||||
discovery.ServiceType = identity.ServiceType
|
||||
changed = true
|
||||
}
|
||||
if isLowValueServiceIdentity(discovery.ServiceName) {
|
||||
discovery.ServiceName = identity.ServiceName
|
||||
changed = true
|
||||
}
|
||||
if discovery.Category == "" || discovery.Category == CategoryUnknown {
|
||||
discovery.Category = identity.Category
|
||||
changed = true
|
||||
}
|
||||
if discovery.Confidence < identity.Confidence {
|
||||
discovery.Confidence = identity.Confidence
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed && evidence != "" {
|
||||
note := fmt.Sprintf("Known service identity inferred from %s.", evidence)
|
||||
discovery.AIReasoning = appendDiscoveryReasoningNote(discovery.AIReasoning, note)
|
||||
}
|
||||
|
||||
return changed, evidence
|
||||
}
|
||||
|
||||
func shouldApplyKnownServiceIdentity(discovery *ResourceDiscovery, identity knownServiceIdentity) bool {
|
||||
if discovery == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
identityType := normalizeKnownServiceEvidence(identity.ServiceType)
|
||||
identityName := normalizeKnownServiceEvidence(identity.ServiceName)
|
||||
serviceType := normalizeKnownServiceEvidence(discovery.ServiceType)
|
||||
serviceName := normalizeKnownServiceEvidence(discovery.ServiceName)
|
||||
|
||||
if serviceType != "" && serviceType != identityType && !isLowValueServiceIdentity(discovery.ServiceType) {
|
||||
return false
|
||||
}
|
||||
if serviceName != "" && serviceName != identityName && !isLowValueServiceIdentity(discovery.ServiceName) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func knownServiceIdentityWouldImprove(
|
||||
discovery *ResourceDiscovery,
|
||||
req DiscoveryRequest,
|
||||
metadata map[string]any,
|
||||
commandOutputs map[string]string,
|
||||
) bool {
|
||||
if discovery == nil {
|
||||
return false
|
||||
}
|
||||
copy := cloneResourceDiscovery(discovery)
|
||||
changed, _ := applyKnownServiceIdentity(copy, req, metadata, commandOutputs)
|
||||
return changed
|
||||
}
|
||||
|
||||
func inferKnownServiceIdentity(
|
||||
discovery *ResourceDiscovery,
|
||||
req DiscoveryRequest,
|
||||
metadata map[string]any,
|
||||
commandOutputs map[string]string,
|
||||
) (knownServiceIdentity, string, bool) {
|
||||
candidates := knownServiceEvidenceCandidates(discovery, req, metadata, commandOutputs)
|
||||
for _, identity := range knownServiceIdentities {
|
||||
aliases := append([]string{identity.ServiceType, identity.ServiceName}, identity.Aliases...)
|
||||
for _, candidate := range candidates {
|
||||
normalizedCandidate := normalizeKnownServiceEvidence(candidate.Value)
|
||||
if normalizedCandidate == "" {
|
||||
continue
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
normalizedAlias := normalizeKnownServiceEvidence(alias)
|
||||
if normalizedAlias == "" {
|
||||
continue
|
||||
}
|
||||
if normalizedCandidate == normalizedAlias ||
|
||||
strings.Contains(normalizedCandidate, normalizedAlias) {
|
||||
return identity, candidate.Source, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return knownServiceIdentity{}, "", false
|
||||
}
|
||||
|
||||
type knownServiceEvidenceCandidate struct {
|
||||
Source string
|
||||
Value string
|
||||
}
|
||||
|
||||
func knownServiceEvidenceCandidates(
|
||||
discovery *ResourceDiscovery,
|
||||
req DiscoveryRequest,
|
||||
metadata map[string]any,
|
||||
commandOutputs map[string]string,
|
||||
) []knownServiceEvidenceCandidate {
|
||||
candidates := []knownServiceEvidenceCandidate{
|
||||
{Source: "resource hostname", Value: req.Hostname},
|
||||
{Source: "resource id", Value: req.ResourceID},
|
||||
}
|
||||
if discovery != nil {
|
||||
candidates = append(candidates,
|
||||
knownServiceEvidenceCandidate{Source: "stored hostname", Value: discovery.Hostname},
|
||||
knownServiceEvidenceCandidate{Source: "stored service type", Value: discovery.ServiceType},
|
||||
knownServiceEvidenceCandidate{Source: "stored service name", Value: discovery.ServiceName},
|
||||
)
|
||||
for _, fact := range discovery.Facts {
|
||||
candidates = append(candidates,
|
||||
knownServiceEvidenceCandidate{Source: "discovered fact " + fact.Key, Value: fact.Key},
|
||||
knownServiceEvidenceCandidate{Source: "discovered fact " + fact.Key, Value: fact.Value},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range metadata {
|
||||
candidates = appendAnyEvidenceCandidate(candidates, "metadata "+key, value)
|
||||
}
|
||||
for key, output := range commandOutputs {
|
||||
candidates = append(candidates, knownServiceEvidenceCandidate{
|
||||
Source: "command output " + key,
|
||||
Value: output,
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func appendAnyEvidenceCandidate(
|
||||
candidates []knownServiceEvidenceCandidate,
|
||||
source string,
|
||||
value any,
|
||||
) []knownServiceEvidenceCandidate {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return append(candidates, knownServiceEvidenceCandidate{Source: source, Value: typed})
|
||||
case []string:
|
||||
for _, item := range typed {
|
||||
candidates = append(candidates, knownServiceEvidenceCandidate{Source: source, Value: item})
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
candidates = appendAnyEvidenceCandidate(candidates, source, item)
|
||||
}
|
||||
case map[string]string:
|
||||
for key, item := range typed {
|
||||
candidates = append(candidates,
|
||||
knownServiceEvidenceCandidate{Source: source + " " + key, Value: key},
|
||||
knownServiceEvidenceCandidate{Source: source + " " + key, Value: item},
|
||||
)
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
candidates = appendAnyEvidenceCandidate(candidates, source+" "+key, item)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func normalizeKnownServiceEvidence(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"_", " ",
|
||||
"-", " ",
|
||||
".", " ",
|
||||
"/", " ",
|
||||
"\\", " ",
|
||||
":", " ",
|
||||
"@", " ",
|
||||
)
|
||||
normalized = replacer.Replace(normalized)
|
||||
return strings.Join(strings.Fields(normalized), " ")
|
||||
}
|
||||
|
||||
func isLowValueServiceIdentity(value string) bool {
|
||||
normalized := normalizeKnownServiceEvidence(value)
|
||||
if normalized == "" {
|
||||
return true
|
||||
}
|
||||
switch normalized {
|
||||
case "detected",
|
||||
"app",
|
||||
"application",
|
||||
"container",
|
||||
"generic host",
|
||||
"host",
|
||||
"linux",
|
||||
"lxc",
|
||||
"service",
|
||||
"system container",
|
||||
"unknown",
|
||||
"unknown app",
|
||||
"unknown application",
|
||||
"unknown container",
|
||||
"unknown host",
|
||||
"unknown service",
|
||||
"unknown system container",
|
||||
"unknown virtual machine",
|
||||
"unknown vm",
|
||||
"unknown workload",
|
||||
"virtual machine",
|
||||
"vm",
|
||||
"workload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appendDiscoveryReasoningNote(reasoning, note string) string {
|
||||
reasoning = strings.TrimSpace(reasoning)
|
||||
note = strings.TrimSpace(note)
|
||||
if note == "" || strings.Contains(reasoning, note) {
|
||||
return reasoning
|
||||
}
|
||||
if reasoning == "" {
|
||||
return note
|
||||
}
|
||||
return reasoning + " " + note
|
||||
}
|
||||
@@ -293,6 +293,8 @@ func readStateFromSnapshot(snap StateSnapshot) unifiedresources.ReadState {
|
||||
Node: vm.Node,
|
||||
Status: vm.Status,
|
||||
Instance: vm.Instance,
|
||||
OSName: vm.OSName,
|
||||
OSVersion: vm.OSVersion,
|
||||
IPAddresses: vm.IPAddresses,
|
||||
})
|
||||
}
|
||||
@@ -304,6 +306,9 @@ func readStateFromSnapshot(snap StateSnapshot) unifiedresources.ReadState {
|
||||
Node: ct.Node,
|
||||
Status: ct.Status,
|
||||
Instance: ct.Instance,
|
||||
OSTemplate: ct.OSTemplate,
|
||||
OSName: ct.OSName,
|
||||
IsOCI: ct.IsOCI,
|
||||
IPAddresses: ct.IPAddresses,
|
||||
})
|
||||
}
|
||||
@@ -1875,6 +1880,183 @@ func TestService_DiscoverResource_URLSuggestionDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DiscoverResource_InfersESPHomeLXCFromStateName(t *testing.T) {
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore error: %v", err)
|
||||
}
|
||||
store.crypto = nil
|
||||
|
||||
state := StateSnapshot{
|
||||
Containers: []Container{
|
||||
{
|
||||
VMID: 102,
|
||||
Name: "esphome",
|
||||
Node: "pve1",
|
||||
Status: "running",
|
||||
OSTemplate: "debian-12-standard",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service := NewService(store, nil, DefaultConfig())
|
||||
service.SetReadState(readStateFromSnapshot(state))
|
||||
service.SetAIAnalyzer(&stubAnalyzer{
|
||||
response: `{"service_type":"unknown","service_name":"Unknown Container","service_version":"","category":"unknown","cli_access":"pct exec 102 -- /bin/bash","facts":[],"config_paths":[],"data_paths":[],"log_paths":[],"ports":[],"confidence":0,"reasoning":"metadata was inconclusive"}`,
|
||||
})
|
||||
|
||||
discovery, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
|
||||
ResourceType: ResourceTypeSystemContainer,
|
||||
TargetID: "pve1",
|
||||
ResourceID: "102",
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverResource error: %v", err)
|
||||
}
|
||||
if discovery.ServiceType != "esphome" {
|
||||
t.Fatalf("expected service type esphome, got %q", discovery.ServiceType)
|
||||
}
|
||||
if discovery.ServiceName != "ESPHome" {
|
||||
t.Fatalf("expected service name ESPHome, got %q", discovery.ServiceName)
|
||||
}
|
||||
if discovery.Category != CategoryHomeAuto {
|
||||
t.Fatalf("expected home automation category, got %q", discovery.Category)
|
||||
}
|
||||
if discovery.Hostname != "esphome" {
|
||||
t.Fatalf("expected hostname from LXC state name, got %q", discovery.Hostname)
|
||||
}
|
||||
if discovery.SuggestedURL != "http://esphome:6052" {
|
||||
t.Fatalf("expected ESPHome dashboard suggestion, got %q", discovery.SuggestedURL)
|
||||
}
|
||||
if discovery.SuggestedURLDiagnostic != "" {
|
||||
t.Fatalf("expected no URL diagnostic, got %q", discovery.SuggestedURLDiagnostic)
|
||||
}
|
||||
if discovery.Confidence < 0.85 {
|
||||
t.Fatalf("expected deterministic confidence floor, got %v", discovery.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetDiscovery_RepairsCachedESPHomeUnknownResult(t *testing.T) {
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore error: %v", err)
|
||||
}
|
||||
store.crypto = nil
|
||||
|
||||
state := StateSnapshot{
|
||||
Containers: []Container{
|
||||
{VMID: 102, Name: "esphome", Node: "pve1", Status: "running"},
|
||||
},
|
||||
}
|
||||
id := MakeResourceID(ResourceTypeSystemContainer, "pve1", "102")
|
||||
if err := store.Save(&ResourceDiscovery{
|
||||
ID: id,
|
||||
ResourceType: ResourceTypeSystemContainer,
|
||||
TargetID: "pve1",
|
||||
ResourceID: "102",
|
||||
ServiceType: "unknown",
|
||||
ServiceName: "Unknown Service",
|
||||
Category: CategoryUnknown,
|
||||
Confidence: 0,
|
||||
SuggestedURLDiagnostic: "no host or IP candidate available",
|
||||
CLIAccessVersion: CLIAccessVersion,
|
||||
FingerprintSchemaVersion: FingerprintSchemaVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(store, nil, DefaultConfig())
|
||||
service.SetReadState(readStateFromSnapshot(state))
|
||||
|
||||
discovery, err := service.GetDiscoveryByResource(ResourceTypeSystemContainer, "pve1", "102")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDiscoveryByResource error: %v", err)
|
||||
}
|
||||
if discovery == nil {
|
||||
t.Fatalf("expected cached discovery")
|
||||
}
|
||||
if discovery.ServiceType != "esphome" || discovery.ServiceName != "ESPHome" {
|
||||
t.Fatalf("expected repaired ESPHome identity, got type=%q name=%q", discovery.ServiceType, discovery.ServiceName)
|
||||
}
|
||||
if discovery.SuggestedURL != "http://esphome:6052" {
|
||||
t.Fatalf("expected repaired ESPHome URL, got %q", discovery.SuggestedURL)
|
||||
}
|
||||
if discovery.SuggestedURLDiagnostic != "" {
|
||||
t.Fatalf("expected repaired URL diagnostic to be cleared, got %q", discovery.SuggestedURLDiagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_RunManualDiscoveryRefreshRepairsFreshUnknownKnownService(t *testing.T) {
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore error: %v", err)
|
||||
}
|
||||
store.crypto = nil
|
||||
|
||||
state := StateSnapshot{
|
||||
Containers: []Container{
|
||||
{VMID: 102, Name: "esphome", Node: "pve1", Status: "running"},
|
||||
},
|
||||
}
|
||||
service := NewService(store, nil, DefaultConfig())
|
||||
service.SetReadState(readStateFromSnapshot(state))
|
||||
service.SetCommandScanningEnabled(true)
|
||||
analyzer := &stubAnalyzer{
|
||||
response: `{"service_type":"unknown","service_name":"Unknown Service","service_version":"","category":"unknown","cli_access":"pct exec 102 -- /bin/bash","facts":[],"config_paths":[],"data_paths":[],"log_paths":[],"ports":[],"confidence":0,"reasoning":"metadata was inconclusive"}`,
|
||||
}
|
||||
service.SetAIAnalyzer(analyzer)
|
||||
|
||||
service.collectFingerprints(context.Background())
|
||||
id := MakeResourceID(ResourceTypeSystemContainer, "pve1", "102")
|
||||
fp, err := store.GetFingerprint(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFingerprint error: %v", err)
|
||||
}
|
||||
if fp == nil {
|
||||
t.Fatalf("expected LXC fingerprint")
|
||||
}
|
||||
if err := store.Save(&ResourceDiscovery{
|
||||
ID: id,
|
||||
ResourceType: ResourceTypeSystemContainer,
|
||||
TargetID: "pve1",
|
||||
ResourceID: "102",
|
||||
Hostname: "esphome",
|
||||
ServiceType: "unknown",
|
||||
ServiceName: "Unknown Service",
|
||||
Category: CategoryUnknown,
|
||||
Confidence: 0,
|
||||
Fingerprint: fp.Hash,
|
||||
FingerprintedAt: fp.GeneratedAt,
|
||||
FingerprintSchemaVersion: fp.SchemaVersion,
|
||||
CLIAccessVersion: CLIAccessVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("Save discovery error: %v", err)
|
||||
}
|
||||
|
||||
summary, err := service.RunManualDiscoveryRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("RunManualDiscoveryRefresh error: %v", err)
|
||||
}
|
||||
if summary.CandidateCount != 1 || summary.DiscoveredCount != 1 || summary.FailedCount != 0 {
|
||||
t.Fatalf("expected one repaired candidate, got %+v", summary)
|
||||
}
|
||||
if analyzer.calls != 1 {
|
||||
t.Fatalf("expected one analyzer call, got %d", analyzer.calls)
|
||||
}
|
||||
|
||||
discovery, err := store.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get discovery error: %v", err)
|
||||
}
|
||||
if discovery.ServiceType != "esphome" || discovery.ServiceName != "ESPHome" {
|
||||
t.Fatalf("expected stored repaired ESPHome discovery, got type=%q name=%q", discovery.ServiceType, discovery.ServiceName)
|
||||
}
|
||||
if discovery.SuggestedURL != "http://esphome:6052" {
|
||||
t.Fatalf("expected stored ESPHome URL, got %q", discovery.SuggestedURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DiscoverResource_URLSuggestionSource_Primary(t *testing.T) {
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
|
||||
@@ -36,6 +36,8 @@ var webServiceDefaults = map[string]webServiceDefault{
|
||||
// Home Automation
|
||||
"home-assistant": {8123, "http", ""},
|
||||
"homeassistant": {8123, "http", ""},
|
||||
"esphome": {6052, "http", ""},
|
||||
"esp-home": {6052, "http", ""},
|
||||
"openhab": {8080, "http", ""},
|
||||
"domoticz": {8080, "http", ""},
|
||||
"node-red": {1880, "http", ""},
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestSuggestWebURL(t *testing.T) {
|
||||
hostIP: "192.168.1.100",
|
||||
wantURL: "http://192.168.1.100:8123",
|
||||
},
|
||||
{
|
||||
name: "esphome dashboard",
|
||||
discovery: &ResourceDiscovery{
|
||||
ServiceType: "esphome",
|
||||
Category: CategoryHomeAuto,
|
||||
},
|
||||
hostIP: "192.168.1.60",
|
||||
wantURL: "http://192.168.1.60:6052",
|
||||
},
|
||||
{
|
||||
name: "service type with underscores (normalized)",
|
||||
discovery: &ResourceDiscovery{
|
||||
|
||||
@@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 372,
|
||||
"line": 379,
|
||||
"heading_line": 114,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user