From 3bbbee323f511e8f70bb44f88515ea845f3e650b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Jun 2026 21:39:22 +0100 Subject: [PATCH] Detect nested-container access topology in discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fast-path nailed identity but emitted the bare-guest access path, so for a service running in Docker inside an LXC/VM (e.g. Home Assistant Container) it told the Assistant to 'pct exec' into the guest shell — wrong: the service is one layer deeper. Access topology is index-level 'how to reach it' (only a probe can know a service runs in a nested container), so re-add a LIGHT nested-container probe (docker ps names+images, not the deep enumeration) to the LXC/VM surface sets. When a nested container matches the identified service, layer cli_access: '... docker exec '. Identity stays instant from the name; the access path is corrected by one cheap command when the agent is connected, and falls back to bare-guest guidance when not. Verified live: HA LXC 101 (homeassistant container under Docker, alongside watchtower) now yields cli_access including 'docker exec homeassistant', in ~1s. Tests cover the match (HA by name, postgres by image), non-matches (watchtower, unrelated service, no docker), and the cli_access layering. --- internal/servicediscovery/commands.go | 36 ++++++--- internal/servicediscovery/service.go | 16 ++++ internal/servicediscovery/service_identity.go | 74 +++++++++++++++++++ .../servicediscovery/service_identity_test.go | 43 ++++++++++- 4 files changed, 158 insertions(+), 11 deletions(-) diff --git a/internal/servicediscovery/commands.go b/internal/servicediscovery/commands.go index 51053808c..935d427a3 100644 --- a/internal/servicediscovery/commands.go +++ b/internal/servicediscovery/commands.go @@ -101,12 +101,20 @@ func getSystemContainerCommands() []DiscoveryCommand { Categories: []string{"service"}, Optional: true, }, - // Surface discovery only: identity + how-to-reach. Deep enumeration - // (installed packages, config-file trees, hardware/GPU, bind mounts, - // disk, cron) is intentionally omitted — it bloats the evidence payload - // and slows AI analysis, and it re-derives what the Assistant already - // knows about standard services. Specifics are fetched on demand via - // agent commands when actually needed. See discovery-assistant-goal. + { + Name: "nested_containers", + Command: "docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null | head -20 || true", + Description: "Nested Docker containers — access topology (how the service runs)", + Categories: []string{"container"}, + Optional: true, + }, + // Surface discovery only: identity + how-to-reach. The nested-container + // probe stays because whether the service runs in a Docker container + // CHANGES the access path (pct exec -- docker exec ...) — + // that is index-level, not encyclopedia. Deep enumeration (installed + // packages, config-file trees, hardware/GPU, bind mounts, disk, cron) is + // omitted; the Assistant knows standard layouts and fetches specifics on + // demand. See discovery-assistant-goal. } } @@ -148,10 +156,18 @@ func getVMCommands() []DiscoveryCommand { Categories: []string{"service"}, Optional: true, }, - // Surface discovery only — identity + how-to-reach. Deep enumeration - // (disk, nested docker + bind mounts, hardware/GPU) is omitted and - // fetched on demand via agent commands when needed. See - // getSystemContainerCommands / discovery-assistant-goal. + { + Name: "nested_containers", + Command: "docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null | head -20 || true", + Description: "Nested Docker containers — access topology (how the service runs)", + Categories: []string{"container"}, + Optional: true, + }, + // Surface discovery only — identity + how-to-reach (incl. whether the + // service runs in a nested Docker container, which changes the access + // path). Deep enumeration (disk, bind mounts, hardware/GPU) is omitted + // and fetched on demand. See getSystemContainerCommands / + // discovery-assistant-goal. } } diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index b085ca28b..a21ec81bb 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -1918,6 +1918,22 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (* } applyKnownServiceIdentity(discovery, req, analysisReq.Metadata, analysisReq.CommandOutputs) + // Access topology: if the identified service actually runs in a nested Docker + // container (e.g. Home Assistant Container inside an LXC), layer the access + // path so the Assistant enters the container instead of the bare guest shell. + // This is index-level "how to reach it" — only a probe can know it. + if req.ResourceType == ResourceTypeSystemContainer || req.ResourceType == ResourceTypeVM { + if probe, ok := analysisReq.CommandOutputs["nested_containers"]; ok { + if name := nestedContainerForService(probe, discovery.ServiceType, discovery.ServiceName); name != "" { + discovery.CLIAccess = withNestedDockerAccess(discovery.CLIAccess, name) + log.Debug(). + Str("id", discovery.ID). + Str("container", name). + Msg("Service runs in a nested Docker container; layered access path") + } + } + } + // Suggest web interface URL based on service type and external IP. // If no URL can be inferred, capture diagnostics for logs and UI. urlSuggestionDiagnostic := "" diff --git a/internal/servicediscovery/service_identity.go b/internal/servicediscovery/service_identity.go index a36db54ed..ee168daae 100644 --- a/internal/servicediscovery/service_identity.go +++ b/internal/servicediscovery/service_identity.go @@ -196,6 +196,80 @@ func surfaceIdentityResponse(identity knownServiceIdentity, evidence string) *AI } } +// knownServiceAliases returns the name aliases for a service type from the +// identity table (falling back to the type itself), used to match a service +// against nested container names/images. +func knownServiceAliases(serviceType string) []string { + for _, identity := range knownServiceIdentities { + if identity.ServiceType == serviceType { + return append([]string{identity.ServiceType, identity.ServiceName}, identity.Aliases...) + } + } + return []string{serviceType} +} + +// nestedContainerForService inspects the nested-container probe output +// ("name|image" lines) and returns the name of a Docker container that appears +// to run the identified service. This is access topology: when the service runs +// in a nested container, the access path must be layered (enter the container), +// not the bare guest shell. Empty string if nothing matches. +func nestedContainerForService(probeOutput, serviceType, serviceName string) string { + probeOutput = strings.TrimSpace(probeOutput) + if probeOutput == "" { + return "" + } + aliases := append(knownServiceAliases(serviceType), serviceName) + normalizedAliases := make([]string, 0, len(aliases)) + for _, alias := range aliases { + if na := normalizeKnownServiceEvidence(alias); na != "" { + normalizedAliases = append(normalizedAliases, na) + } + } + if len(normalizedAliases) == 0 { + return "" + } + + for _, line := range strings.Split(probeOutput, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 2) + name := strings.TrimSpace(parts[0]) + if name == "" { + continue + } + image := "" + if len(parts) > 1 { + image = strings.TrimSpace(parts[1]) + } + normalizedName := normalizeKnownServiceEvidence(name) + normalizedImage := normalizeKnownServiceEvidence(image) + for _, na := range normalizedAliases { + if (normalizedName != "" && strings.Contains(normalizedName, na)) || + (normalizedImage != "" && strings.Contains(normalizedImage, na)) { + return name + } + } + } + return "" +} + +// withNestedDockerAccess rewrites cli_access guidance so the Assistant enters +// the nested Docker container that actually runs the service, rather than the +// bare guest shell. +func withNestedDockerAccess(cliAccess, containerName string) string { + cliAccess = strings.TrimSpace(cliAccess) + note := fmt.Sprintf( + "The service runs inside a Docker container named %q on this guest — to reach the service itself, prefix commands with: docker exec %s .", + containerName, containerName, + ) + if cliAccess == "" { + return note + } + return cliAccess + " " + note +} + type knownServiceEvidenceCandidate struct { Source string Value string diff --git a/internal/servicediscovery/service_identity_test.go b/internal/servicediscovery/service_identity_test.go index daa22a730..293d2564e 100644 --- a/internal/servicediscovery/service_identity_test.go +++ b/internal/servicediscovery/service_identity_test.go @@ -1,6 +1,9 @@ package servicediscovery -import "testing" +import ( + "strings" + "testing" +) func TestInferSurfaceIdentity(t *testing.T) { cases := []struct { @@ -51,3 +54,41 @@ func TestSurfaceIdentityResponseIsIdentityOnly(t *testing.T) { t.Fatalf("surface response must carry no deep paths/facts, got %+v", resp) } } + +func TestNestedContainerForService(t *testing.T) { + // Mirrors the real HA-in-LXC topology on delly: homeassistant + watchtower. + haProbe := "homeassistant|ghcr.io/home-assistant/home-assistant:stable\nwatchtower|containrrr/watchtower" + cases := []struct { + name string + probe string + serviceType string + serviceName string + want string + }{ + {"HA matches by container name", haProbe, "home-assistant", "Home Assistant", "homeassistant"}, + {"postgres matches by image", "db|postgres:16-alpine", "postgresql", "PostgreSQL", "db"}, + {"unrelated service does not match", haProbe, "redis", "Redis", ""}, + {"no nested containers", "", "home-assistant", "Home Assistant", ""}, + {"watchtower alone is not the service", "watchtower|containrrr/watchtower", "home-assistant", "Home Assistant", ""}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := nestedContainerForService(tc.probe, tc.serviceType, tc.serviceName) + if got != tc.want { + t.Fatalf("nestedContainerForService = %q, want %q", got, tc.want) + } + }) + } +} + +func TestWithNestedDockerAccess(t *testing.T) { + base := "Use pulse_control with target_host matching this container's hostname." + out := withNestedDockerAccess(base, "homeassistant") + if !strings.Contains(out, base) { + t.Fatalf("expected base guidance preserved, got %q", out) + } + if !strings.Contains(out, "docker exec homeassistant") { + t.Fatalf("expected layered docker exec path, got %q", out) + } +}