Make workload discovery repair known service results

This commit is contained in:
rcourtman
2026-06-05 09:21:53 +01:00
parent 182e317acf
commit 6bbbf185bb
17 changed files with 745 additions and 25 deletions
@@ -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.`,
+22
View File
@@ -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",
+4 -2
View File
@@ -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
}
+202 -4
View File
@@ -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
}
+182
View File
@@ -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,
}
],