mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
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:
parent
a8833dca2c
commit
3bbbee323f
4 changed files with 158 additions and 11 deletions
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue