Detect nested-container access topology in discovery

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 <container> <command>'. 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.
This commit is contained in:
rcourtman 2026-06-07 21:39:22 +01:00
parent a8833dca2c
commit 3bbbee323f
4 changed files with 158 additions and 11 deletions

View file

@ -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 <guest> -- docker exec <name> ...) —
// 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.
}
}

View file

@ -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 := ""

View file

@ -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 <your-command>.",
containerName, containerName,
)
if cliAccess == "" {
return note
}
return cliAccess + " " + note
}
type knownServiceEvidenceCandidate struct {
Source string
Value string

View file

@ -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)
}
}