Pulse/internal/servicediscovery/types.go
rcourtman 3ea3f0f827 feat(discovery): auto-suggest web interface URLs for discovered services
Add deterministic URL suggestion based on service type and external IP:

- Add SuggestedURL field to ResourceDiscovery type (Go + TypeScript)
- Create url_suggestion.go with 60+ service defaults (Jellyfin, Plex,
  Home Assistant, Grafana, Proxmox, etc.)
- Support HTTPS services, custom paths (/web, /dashboard/, /admin)
- Fall back to discovered ports for unknown services
- Add UI in DiscoveryTab with "Use this" button to populate URL input
- Add comprehensive unit tests for URL suggestion logic

Suggestion only appears when no custom URL is saved. User clicks
"Use this" to populate the input, then "Save" to confirm.
2026-02-03 16:49:57 +00:00

316 lines
14 KiB
Go

// Package discovery provides AI-powered infrastructure discovery capabilities.
// It discovers services, versions, configurations, and CLI access methods
// for VMs, LXCs, Docker containers, Kubernetes pods, and hosts.
package servicediscovery
import (
"fmt"
"time"
)
// ResourceType identifies the type of infrastructure resource.
type ResourceType string
const (
ResourceTypeVM ResourceType = "vm"
ResourceTypeLXC ResourceType = "lxc"
ResourceTypeDocker ResourceType = "docker"
ResourceTypeK8s ResourceType = "k8s"
ResourceTypeHost ResourceType = "host"
ResourceTypeDockerVM ResourceType = "docker_vm" // Docker on a VM
ResourceTypeDockerLXC ResourceType = "docker_lxc" // Docker in an LXC
)
// FactCategory categorizes discovery facts.
type FactCategory string
const (
FactCategoryVersion FactCategory = "version"
FactCategoryConfig FactCategory = "config"
FactCategoryService FactCategory = "service"
FactCategoryPort FactCategory = "port"
FactCategoryHardware FactCategory = "hardware"
FactCategoryNetwork FactCategory = "network"
FactCategoryStorage FactCategory = "storage"
FactCategoryDependency FactCategory = "dependency"
FactCategorySecurity FactCategory = "security"
)
// ServiceCategory categorizes the type of service discovered.
type ServiceCategory string
const (
CategoryDatabase ServiceCategory = "database"
CategoryWebServer ServiceCategory = "web_server"
CategoryCache ServiceCategory = "cache"
CategoryMessageQueue ServiceCategory = "message_queue"
CategoryMonitoring ServiceCategory = "monitoring"
CategoryBackup ServiceCategory = "backup"
CategoryNVR ServiceCategory = "nvr"
CategoryStorage ServiceCategory = "storage"
CategoryContainer ServiceCategory = "container"
CategoryVirtualizer ServiceCategory = "virtualizer"
CategoryNetwork ServiceCategory = "network"
CategorySecurity ServiceCategory = "security"
CategoryMedia ServiceCategory = "media"
CategoryHomeAuto ServiceCategory = "home_automation"
CategoryUnknown ServiceCategory = "unknown"
)
// ResourceDiscovery is the main data model for discovered resource information.
type ResourceDiscovery struct {
// Identity
ID string `json:"id"` // Unique ID: "lxc:minipc:101"
ResourceType ResourceType `json:"resource_type"` // vm, lxc, docker, k8s, host
ResourceID string `json:"resource_id"` // 101, container-name, etc.
HostID string `json:"host_id"` // Proxmox node name or host agent ID
Hostname string `json:"hostname"` // Human-readable host name
// AI-discovered info
ServiceType string `json:"service_type"` // frigate, postgres, pbs
ServiceName string `json:"service_name"` // Human-readable name
ServiceVersion string `json:"service_version"` // v0.13.2
Category ServiceCategory `json:"category"` // nvr, database, backup
CLIAccess string `json:"cli_access"` // pct exec 101 -- ...
// Deep discovery facts
Facts []DiscoveryFact `json:"facts"`
ConfigPaths []string `json:"config_paths"`
DataPaths []string `json:"data_paths"`
LogPaths []string `json:"log_paths"`
Ports []PortInfo `json:"ports"`
DockerMounts []DockerBindMount `json:"docker_mounts,omitempty"` // Docker container bind mounts (source->dest)
// User-added (also encrypted)
UserNotes string `json:"user_notes"`
UserSecrets map[string]string `json:"user_secrets"` // tokens, creds
// Metadata
Confidence float64 `json:"confidence"` // 0-1 confidence score
AIReasoning string `json:"ai_reasoning"` // AI explanation
DiscoveredAt time.Time `json:"discovered_at"` // First discovery
UpdatedAt time.Time `json:"updated_at"` // Last update
ScanDuration int64 `json:"scan_duration"` // Scan duration in ms
// Fingerprint tracking for just-in-time discovery
Fingerprint string `json:"fingerprint,omitempty"` // Hash when discovery was done
FingerprintedAt time.Time `json:"fingerprinted_at,omitempty"` // When fingerprint was captured
FingerprintSchemaVersion int `json:"fingerprint_schema_version,omitempty"` // Schema version when fingerprint was captured
CLIAccessVersion int `json:"cli_access_version,omitempty"` // Version of CLI access pattern format
// Raw data for debugging/re-analysis
RawCommandOutput map[string]string `json:"raw_command_output,omitempty"`
// Auto-suggested web interface URL based on service type and discovered ports
SuggestedURL string `json:"suggested_url,omitempty"`
}
// DiscoveryFact represents a single discovered fact about a resource.
type DiscoveryFact struct {
Category FactCategory `json:"category"` // version, config, service, port
Key string `json:"key"` // e.g., "coral_tpu", "mqtt_broker"
Value string `json:"value"` // e.g., "/dev/apex_0", "mosquitto:1883"
Source string `json:"source"` // command that found this
Confidence float64 `json:"confidence"` // 0-1 confidence for this fact
DiscoveredAt time.Time `json:"discovered_at"`
}
// PortInfo represents information about a listening port.
type PortInfo struct {
Port int `json:"port"`
Protocol string `json:"protocol"` // tcp, udp
Process string `json:"process"` // process name
Address string `json:"address"` // bind address
}
// DockerBindMount represents a Docker bind mount with source and destination paths.
// This is critical for knowing where to actually edit files - the source path on the
// host filesystem, not the destination path inside the container.
type DockerBindMount struct {
ContainerName string `json:"container_name"` // Docker container name
Source string `json:"source"` // Host path (where to actually write files)
Destination string `json:"destination"` // Container path (what the service sees)
Type string `json:"type,omitempty"` // Mount type: bind, volume, tmpfs
ReadOnly bool `json:"read_only,omitempty"` // Whether mount is read-only
}
// MakeResourceID creates a standardized resource ID.
func MakeResourceID(resourceType ResourceType, hostID, resourceID string) string {
return fmt.Sprintf("%s:%s:%s", resourceType, hostID, resourceID)
}
// ParseResourceID parses a resource ID into its components.
func ParseResourceID(id string) (resourceType ResourceType, hostID, resourceID string, err error) {
var parts [3]string
count := 0
start := 0
for i, c := range id {
if c == ':' {
if count < 2 {
parts[count] = id[start:i]
count++
start = i + 1
}
}
}
if count == 2 {
parts[2] = id[start:]
return ResourceType(parts[0]), parts[1], parts[2], nil
}
return "", "", "", fmt.Errorf("invalid resource ID format: %s", id)
}
// DiscoveryRequest represents a request to discover a resource.
type DiscoveryRequest struct {
ResourceType ResourceType `json:"resource_type"`
ResourceID string `json:"resource_id"`
HostID string `json:"host_id"`
Hostname string `json:"hostname"`
Force bool `json:"force"` // Force re-scan even if recent
}
// DiscoveryStatus represents the status of a discovery scan.
type DiscoveryStatus string
const (
DiscoveryStatusPending DiscoveryStatus = "pending"
DiscoveryStatusRunning DiscoveryStatus = "running"
DiscoveryStatusCompleted DiscoveryStatus = "completed"
DiscoveryStatusFailed DiscoveryStatus = "failed"
DiscoveryStatusNotStarted DiscoveryStatus = "not_started"
)
// DiscoveryProgress represents the progress of an ongoing discovery.
type DiscoveryProgress struct {
ResourceID string `json:"resource_id"`
Status DiscoveryStatus `json:"status"`
CurrentStep string `json:"current_step"`
CurrentCommand string `json:"current_command,omitempty"`
TotalSteps int `json:"total_steps"`
CompletedSteps int `json:"completed_steps"`
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
PercentComplete float64 `json:"percent_complete,omitempty"`
StartedAt time.Time `json:"started_at"`
Error string `json:"error,omitempty"`
}
// AIProviderInfo describes the AI provider being used for discovery analysis.
type AIProviderInfo struct {
Provider string `json:"provider"` // e.g., "anthropic", "openai", "ollama"
Model string `json:"model"` // e.g., "claude-haiku-4-5", "gpt-4o"
IsLocal bool `json:"is_local"` // true for ollama (local models)
Label string `json:"label"` // Human-readable label, e.g., "Local (Ollama)" or "Cloud (Anthropic)"
}
// DiscoveryInfo provides metadata about the discovery system configuration.
type DiscoveryInfo struct {
AIProvider *AIProviderInfo `json:"ai_provider,omitempty"` // Current AI provider info
Commands []DiscoveryCommand `json:"commands,omitempty"` // Commands that will be run
CommandCategories []string `json:"command_categories,omitempty"` // Unique categories of commands
}
// UpdateNotesRequest represents a request to update user notes.
type UpdateNotesRequest struct {
UserNotes string `json:"user_notes"`
UserSecrets map[string]string `json:"user_secrets,omitempty"`
}
// DiscoverySummary provides a summary of discoveries for listing.
type DiscoverySummary struct {
ID string `json:"id"`
ResourceType ResourceType `json:"resource_type"`
ResourceID string `json:"resource_id"`
HostID string `json:"host_id"`
Hostname string `json:"hostname"`
ServiceType string `json:"service_type"`
ServiceName string `json:"service_name"`
ServiceVersion string `json:"service_version"`
Category ServiceCategory `json:"category"`
Confidence float64 `json:"confidence"`
HasUserNotes bool `json:"has_user_notes"`
UpdatedAt time.Time `json:"updated_at"`
Fingerprint string `json:"fingerprint,omitempty"` // Current fingerprint
NeedsDiscovery bool `json:"needs_discovery"` // True if fingerprint changed
}
// ToSummary converts a full discovery to a summary.
func (d *ResourceDiscovery) ToSummary() DiscoverySummary {
return DiscoverySummary{
ID: d.ID,
ResourceType: d.ResourceType,
ResourceID: d.ResourceID,
HostID: d.HostID,
Hostname: d.Hostname,
ServiceType: d.ServiceType,
ServiceName: d.ServiceName,
ServiceVersion: d.ServiceVersion,
Category: d.Category,
Confidence: d.Confidence,
HasUserNotes: d.UserNotes != "",
UpdatedAt: d.UpdatedAt,
Fingerprint: d.Fingerprint,
NeedsDiscovery: false, // Will be set by caller if fingerprint changed
}
}
// AIAnalysisRequest is sent to the AI for analysis.
type AIAnalysisRequest struct {
ResourceType ResourceType `json:"resource_type"`
ResourceID string `json:"resource_id"`
HostID string `json:"host_id"`
Hostname string `json:"hostname"`
CommandOutputs map[string]string `json:"command_outputs"`
ExistingFacts []DiscoveryFact `json:"existing_facts,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"` // Image, labels, etc.
}
// AIAnalysisResponse is returned by the AI.
type AIAnalysisResponse struct {
ServiceType string `json:"service_type"`
ServiceName string `json:"service_name"`
ServiceVersion string `json:"service_version"`
Category ServiceCategory `json:"category"`
CLIAccess string `json:"cli_access"`
Facts []DiscoveryFact `json:"facts"`
ConfigPaths []string `json:"config_paths"`
DataPaths []string `json:"data_paths"`
LogPaths []string `json:"log_paths"`
Ports []PortInfo `json:"ports"`
Confidence float64 `json:"confidence"`
Reasoning string `json:"reasoning"`
}
// ContainerFingerprint captures the key metadata that indicates a container changed.
// This is used for just-in-time discovery - only running discovery when something
// actually changed rather than on a fixed timer.
// FingerprintSchemaVersion is incremented when the fingerprint algorithm changes.
// This prevents mass rediscovery when we add new fields to the fingerprint hash.
// Old fingerprints with different schema versions are treated as "schema changed"
// rather than "container changed", allowing for more controlled migration.
const FingerprintSchemaVersion = 3 // v3: Removed IP addresses (DHCP churn caused false positives)
// CLIAccessVersion is incremented when the CLI access pattern format changes.
// When a discovery has an older version, its CLIAccess field is regenerated
// to use the new instructional format.
const CLIAccessVersion = 2 // v2: Changed from shell commands to pulse_control instructions
type ContainerFingerprint struct {
ResourceID string `json:"resource_id"`
HostID string `json:"host_id"`
Hash string `json:"hash"` // SHA256 of metadata (truncated to 16 chars)
SchemaVersion int `json:"schema_version"` // Version of fingerprint algorithm
GeneratedAt time.Time `json:"generated_at"`
// Components that went into the hash (for debugging)
ImageID string `json:"image_id,omitempty"`
ImageName string `json:"image_name,omitempty"`
Ports []string `json:"ports,omitempty"`
MountPaths []string `json:"mount_paths,omitempty"`
EnvKeys []string `json:"env_keys,omitempty"` // Keys only, not values (security)
CreatedAt string `json:"created_at,omitempty"` // Container creation time
}
// IsSchemaOutdated returns true if this fingerprint was created with an older schema.
func (fp *ContainerFingerprint) IsSchemaOutdated() bool {
return fp.SchemaVersion < FingerprintSchemaVersion
}