diff --git a/internal/cli/inv/get.go b/internal/cli/inv/get.go index fe91105..b4da7d9 100644 --- a/internal/cli/inv/get.go +++ b/internal/cli/inv/get.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/ntwrknrd/nssh/internal/config" + "github.com/ntwrknrd/nssh/internal/connect" "github.com/ntwrknrd/nssh/internal/inventory" "github.com/ntwrknrd/nssh/internal/ui" "github.com/spf13/cobra" @@ -47,16 +48,41 @@ func runGet(hostName string) error { } meta := metadataForHost(host, cfg, config.DefaultPaths(), index) auth := effectiveInventoryAuth(cfg, host.Host, meta.Group) + nsshRows := []inventoryDisplayRow{ + {Label: "Provider", Value: meta.Owner}, + {Label: "Group", Value: valueOrDash(meta.Group)}, + } + shadowRows, err := shadowedProviderRows(cfg, config.DefaultPaths(), host.Host) + if err != nil { + return err + } + nsshRows = append(nsshRows, shadowRows...) printInventoryDisplaySections(inventoryDisplaySections( inventoryHostSSHDisplayRows(host.Host, host.HostName, valueOrDash(host.User()), host.Port()), - append([]inventoryDisplayRow{ - {Label: "Provider", Value: meta.Owner}, - {Label: "Group", Value: valueOrDash(meta.Group)}, - }, inventoryAuthDisplayRows(auth)...), + append(nsshRows, inventoryAuthDisplayRows(auth)...), )) return nil } +// shadowedProviderRows names provider objects that this local host replaces. +func shadowedProviderRows(cfg *config.Config, paths *config.Paths, host string) ([]inventoryDisplayRow, error) { + states, err := inventoryProviderStates(paths) + if err != nil { + return nil, err + } + var rows []inventoryDisplayRow + for _, finding := range connect.ProviderHostFindings(cfg, states) { + if finding.ShadowedBy != host { + continue + } + rows = append(rows, inventoryDisplayRow{ + Label: "Shadows", + Value: fmt.Sprintf("%s %s (move local settings to inventory.providers.%s.hosts)", finding.Provider, finding.Host, finding.Provider), + }) + } + return rows, nil +} + func valueOrDash(v string) string { if v == "" { return "-" diff --git a/internal/cli/inv/local.go b/internal/cli/inv/local.go index 8e1d7a2..65f0c30 100644 --- a/internal/cli/inv/local.go +++ b/internal/cli/inv/local.go @@ -181,7 +181,24 @@ func upsertLocalHostYAML(cfg *config.Config, paths *config.Paths, patch hostPatc host.SSH.Options["ProxyJump"] = config.NewSSHOptionString(strings.TrimSpace(patch.ProxyJump)) } host.SSH = config.MergeSSH(host.SSH, config.SSHHostConfig{Options: patch.Identity}) - host.Aliases = mergeHostAliases(hostName, host.Aliases, autoLocalHostAliases(hostName), patch.Aliases) + claimed, err := inventoryNameOwners(cfg, paths, hostName) + if err != nil { + return err + } + for _, alias := range patch.Aliases { + if owner := claimed[strings.TrimSpace(alias)]; owner != "" { + return fmt.Errorf("alias %q already identifies host %q", strings.TrimSpace(alias), owner) + } + } + autoAliases := make([]string, 0, 1) + for _, alias := range autoLocalHostAliases(hostName) { + if owner := claimed[alias]; owner != "" { + ui.Warning("Skipped alias %q: it already identifies %s", alias, owner) + continue + } + autoAliases = append(autoAliases, alias) + } + host.Aliases = mergeHostAliases(hostName, host.Aliases, autoAliases, patch.Aliases) if patch.PortSet { host.Port = patch.Port } @@ -247,6 +264,29 @@ func autoLocalHostAliases(host string) []string { return []string{short} } +// inventoryNameOwners maps every name that resolves to another inventory host +// (its name, aliases, and short name) to that host. +func inventoryNameOwners(cfg *config.Config, paths *config.Paths, exclude string) (map[string]string, error) { + hosts, err := inventoryHosts(cfg, paths) + if err != nil { + return nil, err + } + owners := make(map[string]string) + for _, entry := range hosts { + if entry == nil || entry.Host == exclude { + continue + } + names := append([]string{entry.Host}, entry.Patterns...) + names = append(names, autoLocalHostAliases(entry.Host)...) + for _, name := range names { + if name = strings.TrimSpace(name); name != "" && name != exclude && owners[name] == "" { + owners[name] = entry.Host + } + } + } + return owners, nil +} + func mergeHostAliases(host string, aliasSets ...[]string) []string { host = strings.TrimSpace(host) aliases := make([]string, 0) @@ -1333,34 +1373,70 @@ func inventoryHosts(cfg *config.Config, paths *config.Paths) ([]*sshconfig.HostE if err := cfg.Inventory.Validate(); err != nil { return nil, err } + states, err := inventoryProviderStates(paths) + if err != nil { + return nil, err + } + // Provider YAML hosts overlay their provider objects; list them on their + // own only when no provider object matches, so each host appears once. + orphaned := make(map[string]map[string]bool) + for _, finding := range connect.ProviderHostFindings(cfg, states) { + if finding.Orphaned { + if orphaned[finding.Provider] == nil { + orphaned[finding.Provider] = make(map[string]bool) + } + orphaned[finding.Provider][finding.Host] = true + } + } + stateProviders := make(map[string]bool, len(states)) + for _, state := range states { + stateProviders[state.Provider] = true + } for providerName, provider := range cfg.Inventory.Providers { for name, hostCfg := range provider.Hosts { + if provider.Type != config.ProviderLocal && stateProviders[providerName] && !orphaned[providerName][name] { + continue + } hosts = append(hosts, hostEntryFromInventoryHost(cfg, paths, providerName, name, hostCfg)) } } - if paths.StateDir != "" && samePath(paths.StateDir, config.DefaultPaths().StateDir) { - stateNames, err := inventory.ListProviderStates() + for _, state := range states { + for _, host := range state.Objects { + if entry := hostEntryFromProviderState(cfg, paths, state, host); entry != nil { + hosts = append(hosts, entry) + } + } + } + sort.SliceStable(hosts, func(i, j int) bool { + if hosts[i].Host != hosts[j].Host { + return hosts[i].Host < hosts[j].Host + } + return hosts[i].SourceFile < hosts[j].SourceFile + }) + return hosts, nil +} + +// inventoryProviderStates loads cached provider objects for the default state +// directory; other state directories (tests) have no provider cache. +func inventoryProviderStates(paths *config.Paths) ([]*inventory.ProviderState, error) { + if paths.StateDir == "" || !samePath(paths.StateDir, config.DefaultPaths().StateDir) { + return nil, nil + } + names, err := inventory.ListProviderStates() + if err != nil { + return nil, err + } + states := make([]*inventory.ProviderState, 0, len(names)) + for _, name := range names { + state, err := inventory.LoadProviderState(name) if err != nil { return nil, err } - for _, providerName := range stateNames { - state, err := inventory.LoadProviderState(providerName) - if err != nil { - return nil, err - } - if state == nil { - continue - } - for _, host := range state.Objects { - entry := hostEntryFromProviderState(paths, state.Provider, host) - if entry != nil { - hosts = append(hosts, entry) - } - } + if state != nil { + states = append(states, state) } } - sort.Slice(hosts, func(i, j int) bool { return hosts[i].Host < hosts[j].Host }) - return hosts, nil + return states, nil } func inventoryProxyHostNames(cfg *config.Config, paths *config.Paths, exclude string) ([]string, error) { @@ -1394,7 +1470,15 @@ func findInventoryHost(cfg *config.Config, paths *config.Paths, pattern string) if err != nil { return nil, err } - return sshconfig.FindHostByPattern(hosts, pattern), nil + host := sshconfig.FindHostByPattern(hosts, pattern) + // A host defined in local inventory is local-owned even when a provider + // cache lists the same name; otherwise edits would land in the wrong file. + if host != nil && cfg != nil { + if hostCfg, ok := cfg.Inventory.Providers[config.ProviderLocal].Hosts[host.Host]; ok { + return hostEntryFromInventoryHost(cfg, paths, config.ProviderLocal, host.Host, hostCfg), nil + } + } + return host, nil } func hostEntryFromInventoryHost(cfg *config.Config, paths *config.Paths, providerName, name string, hostCfg config.InventoryHostConfig) *sshconfig.HostEntry { @@ -1430,24 +1514,20 @@ func inventoryHostSSHHostName(name string, hostCfg config.InventoryHostConfig) s return strings.TrimSpace(name) } -func hostEntryFromProviderState(paths *config.Paths, providerName string, host *inventory.ProviderHost) *sshconfig.HostEntry { - if host == nil { +func hostEntryFromProviderState(cfg *config.Config, paths *config.Paths, state *inventory.ProviderState, host *inventory.ProviderHost) *sshconfig.HostEntry { + data := connect.ResolveProviderStateHost(cfg, state, host) + if data == nil { return nil } - name := host.HostName - if name == "" { - name = host.Host - } - port := host.Port + port := data.Port if port == 0 { port = 22 } - entry := sshconfig.CreateHostEntry(name, host.HostName, host.Username, port, host.AuthMode == config.AuthModePassword, localFilePath(paths, inventory.ProviderIncludeFile(providerName))) - if len(host.Patterns) > 0 { - entry.Patterns = slices.Clone(host.Patterns) - entry.Host = entry.Patterns[0] - } - inventory.SetLocalHostGroup(entry, host.Group) + groupID := inventoryHostGroupID(state.Provider, data.Group) + auth := cfg.ResolveInventoryAuth(config.InventoryAuthContext{Host: data.Hostname, Provider: state.Provider, Group: groupID}) + entry := sshconfig.CreateHostEntry(data.Canonical, data.Hostname, data.Username, port, auth.AuthMode == config.AuthModePassword, localFilePath(paths, inventory.ProviderIncludeFile(state.Provider))) + entry.Patterns = mergeHostAliases("", []string{data.Canonical}, data.Aliases) + inventory.SetLocalHostGroup(entry, groupID) return entry } @@ -1491,7 +1571,17 @@ func saveLocalProviderInventory(cfg *config.Config, paths *config.Paths) error { if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { return fmt.Errorf("create inventory dir: %w", err) } - return os.WriteFile(target, []byte(text), 0600) + if err := os.WriteFile(target, []byte(text), 0600); err != nil { + return err + } + return config.PruneRootInventoryProviderShadow(localRootConfigPath(paths), cfg, config.ProviderLocal) +} + +func localRootConfigPath(paths *config.Paths) string { + if paths == nil { + paths = config.DefaultPaths() + } + return paths.ConfigFile } func saveLocalProviderHostInventory(cfg *config.Config, paths *config.Paths, hostName string) error { @@ -1509,7 +1599,7 @@ func saveLocalProviderHostInventory(cfg *config.Config, paths *config.Paths, hos return err } if patched { - return nil + return config.PruneRootInventoryProviderShadow(localRootConfigPath(paths), cfg, config.ProviderLocal, "hosts", hostName) } return saveLocalProviderInventory(cfg, paths) } diff --git a/internal/cli/inv/local_test.go b/internal/cli/inv/local_test.go index 8f4d512..883d7f4 100644 --- a/internal/cli/inv/local_test.go +++ b/internal/cli/inv/local_test.go @@ -4,8 +4,10 @@ import ( "context" "os" "path/filepath" + "slices" "strings" "testing" + "time" "github.com/ntwrknrd/nssh/internal/config" "github.com/ntwrknrd/nssh/internal/connect" @@ -1858,3 +1860,204 @@ func TestImportedIdentityIsUsedByProbeAndSavedHost(t *testing.T) { t.Fatalf("identity lost: %+v", opts) } } + +func TestUpsertLocalHostWritesOwningIncludeWhenRootShadowsLocalProvider(t *testing.T) { + tmp := t.TempDir() + configDir := filepath.Join(tmp, "nssh") + paths := &config.Paths{ConfigDir: configDir, ConfigFile: filepath.Join(configDir, "config.yaml"), BackupDir: filepath.Join(tmp, "backups")} + localPath := filepath.Join(configDir, "inventory", "local.yaml") + writeInvTestFile(t, paths.ConfigFile, ` +include: + - inventory/*.yaml +inventory: + providers: + local: + type: local + hosts: + old01.lab.local: + group: lab +`) + writeInvTestFile(t, localPath, ` +inventory: + providers: + local: + type: local + groups: + lab: {} +`) + cfg, err := config.Load(paths.ConfigFile) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if err := upsertLocalHost(cfg, paths, hostPatch{Host: "new01.lab.local", Group: "local/lab"}); err != nil { + t.Fatalf("upsert: %v", err) + } + + localText, err := os.ReadFile(localPath) + if err != nil { + t.Fatalf("read local: %v", err) + } + if !strings.Contains(string(localText), "new01.lab.local:") { + t.Fatalf("new host missing from owning include:\n%s", localText) + } + rootText, err := os.ReadFile(paths.ConfigFile) + if err != nil { + t.Fatalf("read root: %v", err) + } + if strings.Contains(string(rootText), "new01.lab.local") { + t.Fatalf("new host written to root config:\n%s", rootText) + } +} + +func TestUpsertLocalHostSkipsShortAliasClaimedByAnotherHost(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{Inventory: config.InventoryConfig{ + Providers: map[string]config.InventoryProviderConfig{ + config.ProviderLocal: { + Type: config.ProviderLocal, + Groups: map[string]config.GroupConfig{"lab": {}}, + Hosts: map[string]config.InventoryHostConfig{ + "clab01.lab.local": {Group: "lab", Aliases: []string{"clab01"}}, + }, + }, + }, + }} + paths := &config.Paths{ConfigDir: filepath.Join(tmp, "nssh"), ConfigFile: filepath.Join(tmp, "nssh", "config.yaml"), BackupDir: filepath.Join(tmp, "backups")} + + if err := upsertLocalHost(cfg, paths, hostPatch{Host: "clab01.ldap.lab.local", Group: "local/lab"}); err != nil { + t.Fatalf("upsert: %v", err) + } + if got := cfg.Inventory.Providers[config.ProviderLocal].Hosts["clab01.ldap.lab.local"].Aliases; len(got) != 0 { + t.Fatalf("aliases = %v, want none", got) + } +} + +func TestUpsertLocalHostRejectsAliasClaimedByAnotherHost(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{Inventory: config.InventoryConfig{ + Providers: map[string]config.InventoryProviderConfig{ + config.ProviderLocal: { + Type: config.ProviderLocal, + Groups: map[string]config.GroupConfig{"lab": {}}, + Hosts: map[string]config.InventoryHostConfig{ + "edge01.lab.local": {Group: "lab", Aliases: []string{"edge"}}, + }, + }, + }, + }} + paths := &config.Paths{ConfigDir: filepath.Join(tmp, "nssh"), ConfigFile: filepath.Join(tmp, "nssh", "config.yaml"), BackupDir: filepath.Join(tmp, "backups")} + + err := upsertLocalHost(cfg, paths, hostPatch{Host: "edge02.lab.local", Group: "local/lab", Aliases: []string{"edge"}}) + if err == nil || !strings.Contains(err.Error(), `alias "edge" already identifies host "edge01.lab.local"`) { + t.Fatalf("err = %v, want alias collision", err) + } +} + +func TestFindInventoryHostPrefersLocalDefinition(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{Inventory: config.InventoryConfig{ + Providers: map[string]config.InventoryProviderConfig{ + config.ProviderLocal: { + Type: config.ProviderLocal, + Groups: map[string]config.GroupConfig{"lab": {}}, + Hosts: map[string]config.InventoryHostConfig{"sw1.lab.local": {Group: "lab"}}, + }, + "netbox-prod": { + Type: config.ProviderNetBox, + Groups: map[string]config.GroupConfig{"lab": {}}, + Hosts: map[string]config.InventoryHostConfig{"sw1.lab.local": {Group: "lab"}}, + }, + }, + }} + paths := &config.Paths{ConfigDir: filepath.Join(tmp, "nssh"), ConfigFile: filepath.Join(tmp, "nssh", "config.yaml"), BackupDir: filepath.Join(tmp, "backups")} + + for range 20 { + host, err := findInventoryHost(cfg, paths, "sw1.lab.local") + if err != nil { + t.Fatalf("find: %v", err) + } + if owner := metadataForHost(host, cfg, paths, nil).Owner; owner != config.ProviderLocal { + t.Fatalf("owner = %q, want local", owner) + } + } +} + +func writeInvTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestInventoryHostsListsEachProviderHostOnce(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + tmp := t.TempDir() + paths := &config.Paths{ConfigDir: filepath.Join(tmp, "nssh"), ConfigFile: filepath.Join(tmp, "nssh", "config.yaml"), StateDir: config.DefaultPaths().StateDir} + cfg := &config.Config{Inventory: config.InventoryConfig{ + Providers: map[string]config.InventoryProviderConfig{ + config.ProviderLocal: { + Type: config.ProviderLocal, + Groups: map[string]config.GroupConfig{"lab": {}}, + Hosts: map[string]config.InventoryHostConfig{"edge01.example.com": {Group: "lab"}}, + }, + "netbox-prod": { + Type: config.ProviderNetBox, + Groups: map[string]config.GroupConfig{"cbb": {Auth: config.InventoryAuthConfig{Username: "netops"}}}, + Hosts: map[string]config.InventoryHostConfig{ + "edge02.example.com": {Group: "cbb", Port: 2202, Aliases: []string{"e2"}}, + "gone.example.com": {Group: "cbb"}, + }, + }, + }, + }} + if err := inventory.SaveProviderState(&inventory.ProviderState{ + Version: inventory.StateVersion, + Provider: "netbox-prod", + Type: config.ProviderNetBox, + Objects: map[string]*inventory.ProviderHost{ + "1": {ObjectID: "1", Host: "edge01.example.com", HostName: "edge01.example.com", Group: "netbox-prod/cbb"}, + "2": {ObjectID: "2", Host: "edge02.example.com", HostName: "edge02.example.com", Group: "netbox-prod/cbb"}, + }, + }); err != nil { + t.Fatalf("save state: %v", err) + } + + hosts, err := inventoryHosts(cfg, paths) + if err != nil { + t.Fatalf("inventoryHosts: %v", err) + } + rows := make(map[string][]*sshconfig.HostEntry) + for _, host := range hosts { + rows[host.Host] = append(rows[host.Host], host) + } + for _, name := range []string{"edge01.example.com", "edge02.example.com", "gone.example.com"} { + if len(rows[name]) != 1 { + t.Fatalf("%s rows = %d, want 1 (all: %v)", name, len(rows[name]), hosts) + } + } + if owner := metadataForHost(rows["edge01.example.com"][0], cfg, paths, nil).Owner; owner != config.ProviderLocal { + t.Fatalf("edge01 owner = %q, want local", owner) + } + edge02 := rows["edge02.example.com"][0] + if edge02.Port() != "2202" || edge02.User() != "netops" || !slices.Contains(edge02.Patterns, "e2") { + t.Fatalf("edge02 missing overlay: port=%s user=%s patterns=%v", edge02.Port(), edge02.User(), edge02.Patterns) + } + + text, err := renderStatusTree(cfg, paths, "netbox-prod", time.Now().UTC()) + if err != nil { + t.Fatalf("renderStatusTree: %v", err) + } + for _, want := range []string{ + "edge01.example.com: shadowed by local host edge01.example.com", + "gone.example.com: override matches no netbox-prod object", + } { + if !strings.Contains(ui.StripANSI(text), want) { + t.Fatalf("status missing %q:\n%s", want, text) + } + } +} diff --git a/internal/cli/inv/status.go b/internal/cli/inv/status.go index 6f95ae4..74f4c8f 100644 --- a/internal/cli/inv/status.go +++ b/internal/cli/inv/status.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ntwrknrd/nssh/internal/config" + "github.com/ntwrknrd/nssh/internal/connect" "github.com/ntwrknrd/nssh/internal/inventory" "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" "github.com/ntwrknrd/nssh/internal/ui" @@ -164,6 +165,7 @@ func writeStatusProvider(b *strings.Builder, snapshot statusProviderSnapshot, de fmt.Fprintf(b, " %s %s\n", statusLabel("last error:"), statusMaybeError(snapshot.LastError)) } fmt.Fprintf(b, " %s %s\n", statusLabel("hosts:"), pluralizeHosts(snapshot.Hosts)) + writeStatusProviderFindings(b, snapshot, detailed) fmt.Fprintf(b, " %s\n", statusSection("groups:")) if len(snapshot.Groups) == 0 { fmt.Fprintf(b, " %s\n", statusDash("-")) @@ -174,6 +176,34 @@ func writeStatusProvider(b *strings.Builder, snapshot statusProviderSnapshot, de } } +// writeStatusProviderFindings reports provider objects hidden by local hosts +// and provider host overrides that match no provider object. +func writeStatusProviderFindings(b *strings.Builder, snapshot statusProviderSnapshot, detailed bool) { + if len(snapshot.Findings) == 0 { + return + } + if !detailed { + shadowed, orphaned := 0, 0 + for _, finding := range snapshot.Findings { + if finding.Orphaned { + orphaned++ + } else { + shadowed++ + } + } + fmt.Fprintf(b, " %s %d shadowed by local, %d unmatched overrides\n", statusLabel("findings:"), shadowed, orphaned) + return + } + fmt.Fprintf(b, " %s\n", statusSection("findings:")) + for _, finding := range snapshot.Findings { + if finding.Orphaned { + fmt.Fprintf(b, " %s: override matches no %s object\n", finding.Host, snapshot.Name) + continue + } + fmt.Fprintf(b, " %s: shadowed by local host %s; move its settings to inventory.providers.%s.hosts\n", finding.Host, finding.ShadowedBy, snapshot.Name) + } +} + func writeStatusGroup(b *strings.Builder, group statusProviderGroup, detailed bool) { fmt.Fprintf(b, " %s\n", statusGroupName(group.Name)) fmt.Fprintf(b, " %s %s\n", statusLabel("hosts:"), pluralizeHosts(group.Hosts)) @@ -276,6 +306,7 @@ type statusProviderSnapshot struct { OutputFile string Hosts int Groups []statusProviderGroup + Findings []connect.ProviderHostFinding } func localStatusSnapshot(cfg *config.Config, paths *config.Paths) (statusProviderSnapshot, bool, error) { @@ -314,6 +345,7 @@ func externalStatusSnapshot( cache := "missing" lastError := "-" var groupCounts map[string]int + var findings []connect.ProviderHostFinding hosts := 0 if err != nil { if inventory.IsUnsupportedStateVersion(err) { @@ -332,6 +364,7 @@ func externalStatusSnapshot( lastError = state.LastError } groupCounts = providerGroupCounts(state) + findings = connect.ProviderHostFindings(cfg, []*inventory.ProviderState{state}) } return statusProviderSnapshot{ Name: name, @@ -341,6 +374,7 @@ func externalStatusSnapshot( OutputFile: localFilePath(paths, inventory.ProviderIncludeFile(name)), Hosts: hosts, Groups: statusProviderGroups(cfg, paths, name, groupCounts), + Findings: findings, } } diff --git a/internal/config/include.go b/internal/config/include.go index 32471ac..a879250 100644 --- a/internal/config/include.go +++ b/internal/config/include.go @@ -279,7 +279,14 @@ func (c *Config) InventoryGroupSource(provider, group string) string { return source } +// InventoryProviderSource returns the file that owns a provider's inventory. +// An included file that declares the provider type owns it even when the root +// config also has a block for that provider; otherwise the root block would +// capture every later write and shadow the included file. func (c *Config) InventoryProviderSource(provider string) string { + if owner := c.inventoryProviderTypeInclude(provider); owner != "" { + return owner + } source := c.sourceFor("inventory", "providers", provider) if source == "" { source = c.sourceFor("inventory", "provider", provider) @@ -287,6 +294,26 @@ func (c *Config) InventoryProviderSource(provider string) string { return source } +func (c *Config) inventoryProviderTypeInclude(provider string) string { + if c == nil || c.document == nil { + return "" + } + for _, file := range c.document.files { + if sameConfigPath(file, c.document.path) { + continue + } + table, err := readYAMLMap(file) + if err != nil { + continue + } + if tablePathDefined(table, "inventory", "providers", provider, "type") || + tablePathDefined(table, "inventory", "provider", provider, "type") { + return file + } + } + return "" +} + func (c *Config) CredentialProviderSource(provider string) string { return c.sourceFor("credential", "provider", provider) } diff --git a/internal/config/root_save_test.go b/internal/config/root_save_test.go index b7a0276..0ddaf06 100644 --- a/internal/config/root_save_test.go +++ b/internal/config/root_save_test.go @@ -159,3 +159,102 @@ func TestMarshalSparseWritesProviderRequestTimeout(t *testing.T) { t.Fatalf("sparse config missing provider_request_timeout:\n%s", got) } } + +func TestSaveInventoryProviderHostIgnoresRootShadowAndPrunesIt(t *testing.T) { + tmp := t.TempDir() + rootPath := filepath.Join(tmp, "config.yaml") + providerPath := filepath.Join(tmp, "inventory", "netbox-prod.yaml") + writeConfigFile(t, rootPath, ` +include: + - inventory/*.yaml +logging: + audit: + enabled: true +inventory: + providers: + netbox-prod: + hosts: + sw1.example.com: + group: cbb +`) + writeConfigFile(t, providerPath, ` +inventory: + providers: + netbox-prod: + type: netbox + groups: + cbb: + match: + domain_suffix: [.example.com] +`) + cfg, err := Load(rootPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.InventoryProviderSource("netbox-prod"); !sameConfigPath(got, providerPath) { + t.Fatalf("InventoryProviderSource = %q, want %q", got, providerPath) + } + provider := cfg.Inventory.Providers["netbox-prod"] + host := provider.Hosts["sw1.example.com"] + host.SSH.Compatibility.Kex = "diffie-hellman-group14-sha1" + provider.Hosts["sw1.example.com"] = host + cfg.Inventory.Providers["netbox-prod"] = provider + cfg.Inventory.Provider["netbox-prod"] = provider + + if err := SaveInventoryProviderHost(rootPath, cfg, "netbox-prod", "sw1.example.com"); err != nil { + t.Fatalf("SaveInventoryProviderHost: %v", err) + } + providerText, err := os.ReadFile(providerPath) + if err != nil { + t.Fatalf("read provider: %v", err) + } + for _, want := range []string{"sw1.example.com:", "group: cbb", "kex: diffie-hellman-group14-sha1"} { + if !strings.Contains(string(providerText), want) { + t.Fatalf("provider file missing %q:\n%s", want, providerText) + } + } + rootText, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("read root: %v", err) + } + if strings.Contains(string(rootText), "inventory:") || !strings.Contains(string(rootText), "audit:") { + t.Fatalf("root config should drop only the shadowed inventory:\n%s", rootText) + } + reloaded, err := Load(rootPath) + if err != nil { + t.Fatalf("reload: %v", err) + } + if got := reloaded.Inventory.Providers["netbox-prod"].Hosts["sw1.example.com"].SSH.Compatibility.Kex; got != "diffie-hellman-group14-sha1" { + t.Fatalf("reloaded kex = %q", got) + } +} + +func TestInventoryProviderSourceKeepsRootOwnedProvider(t *testing.T) { + tmp := t.TempDir() + rootPath := filepath.Join(tmp, "config.yaml") + writeConfigFile(t, rootPath, ` +inventory: + providers: + local: + type: local + groups: + lab: {} +`) + cfg, err := Load(rootPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.InventoryProviderSource(ProviderLocal); !sameConfigPath(got, rootPath) { + t.Fatalf("InventoryProviderSource = %q, want root %q", got, rootPath) + } + if err := PruneRootInventoryProviderShadow(rootPath, cfg, ProviderLocal); err != nil { + t.Fatalf("prune: %v", err) + } + text, err := os.ReadFile(rootPath) + if err != nil { + t.Fatalf("read root: %v", err) + } + if !strings.Contains(string(text), "lab:") { + t.Fatalf("root-owned provider was pruned:\n%s", text) + } +} diff --git a/internal/config/writer.go b/internal/config/writer.go index 54f6be0..3572f09 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -39,7 +39,14 @@ func SaveInventoryGroup(path string, cfg *Config, groupID string) error { if err := setInventoryGroupInRoot(root, cfg, groupID); err != nil { return err } - return saveInventoryProviderTable(targetPath, root) + if err := saveInventoryProviderTable(targetPath, root); err != nil { + return err + } + providerName, groupName, err := ParseInventoryGroupID(groupID) + if err != nil { + return err + } + return PruneRootInventoryProviderShadow(path, cfg, providerName, "groups", groupName) } func SaveInventoryGroupAndHostAuth(path string, cfg *Config, groupID, host string) error { @@ -85,7 +92,78 @@ func SaveInventoryProviderHost(path string, cfg *Config, providerName, host stri if err := setInventoryProviderHostInRoot(root, cfg, providerName, host); err != nil { return err } - return saveInventoryProviderTable(targetPath, root) + if err := saveInventoryProviderTable(targetPath, root); err != nil { + return err + } + return PruneRootInventoryProviderShadow(path, cfg, providerName, "hosts", host) +} + +// PruneRootInventoryProviderShadow removes a root-config entry under +// inventory.providers. after the merged value was written to the +// provider's owning include file, so the stale root copy cannot override it. +// With no key, it removes the root config's whole block for the provider. +func PruneRootInventoryProviderShadow(rootPath string, cfg *Config, providerName string, key ...string) error { + targetPath := cfg.InventoryProviderSource(providerName) + if rootPath == "" || targetPath == "" || sameConfigPath(rootPath, targetPath) { + return nil + } + if _, err := os.Stat(rootPath); os.IsNotExist(err) { + return nil + } + root, err := readYAMLMap(rootPath) + if err != nil { + return err + } + pruned := false + for _, providers := range []string{"providers", "provider"} { + for _, parts := range shadowKeyVariants(key) { + path := append([]string{"inventory", providers, providerName}, parts...) + if !tablePathDefined(root, path...) { + continue + } + deleteMapPath(root, path) + pruneEmptyMapPath(root, path[:len(path)-1]) + pruned = true + } + } + if !pruned { + return nil + } + return saveRootTable(rootPath, root) +} + +func shadowKeyVariants(key []string) [][]string { + if len(key) == 0 { + return [][]string{nil} + } + variants := [][]string{key} + alias := map[string]string{"hosts": "host", "groups": "group"}[key[0]] + if alias != "" { + variants = append(variants, append([]string{alias}, key[1:]...)) + } + return variants +} + +// pruneEmptyMapPath deletes each table along parts, deepest first, while empty. +func pruneEmptyMapPath(root map[string]any, parts []string) { + for i := len(parts); i > 0; i-- { + if table, ok := mapAtPath(root, parts[:i]); !ok || len(table) > 0 { + return + } + deleteMapPath(root, parts[:i]) + } +} + +func mapAtPath(root map[string]any, parts []string) (map[string]any, bool) { + current := root + for _, part := range parts { + next, ok := asMap(current[part]) + if !ok { + return nil, false + } + current = next + } + return current, true } func inventoryGroupSavePath(rootPath string, cfg *Config, groupID string) (string, error) { diff --git a/internal/connect/catalog.go b/internal/connect/catalog.go index da86847..b13da1c 100644 --- a/internal/connect/catalog.go +++ b/internal/connect/catalog.go @@ -2,6 +2,7 @@ package connect import ( "fmt" + "log/slog" "slices" "strings" @@ -11,9 +12,19 @@ import ( ) type HostCatalog struct { - hosts map[string]*ResolvedHostData - aliases map[string]string -} + hosts map[string]*ResolvedHostData + aliases map[string]string + aliasRank map[string]int +} + +// Lookup-name precedence: a full host name beats an explicit alias, which beats +// a derived short name. Equal-rank names shared by different hosts resolve to +// the lexically first canonical host so lookup never depends on map order. +const ( + aliasRankShort = iota + 1 + aliasRankExplicit + aliasRankFull +) type ResolvedHostData struct { Query string @@ -72,10 +83,8 @@ func buildHostCatalog(cfg *config.Config, states []*inventory.ProviderState) (*H cfg = config.DefaultConfig() } cat := &HostCatalog{hosts: make(map[string]*ResolvedHostData), aliases: make(map[string]string)} - providers := cfg.Inventory.Providers - if len(providers) == 0 { - providers = cfg.Inventory.Provider - } + providers := catalogProviders(cfg) + local := localHostNames(cfg) localTimer := connector.StartTiming(connector.TimingCatalogLocalHosts) for providerName, provider := range providers { provider = normalizeCatalogProvider(provider) @@ -103,6 +112,9 @@ func buildHostCatalog(cfg *config.Config, states []*inventory.ProviderState) (*H continue } data := resolvedHostFromState(cfg, state, provider, host) + if _, shadowed := shadowingLocalHost(local, data); shadowed { + continue + } cat.add(data) } } @@ -151,11 +163,9 @@ func resolvedHostFromState(cfg *config.Config, state *inventory.ProviderState, p providerName = state.Provider } group := shortGroup(host.Group) - overlay := provider.Hosts[host.HostName] - if overlay.Group == "" { - if byHost, ok := provider.Hosts[host.Host]; ok { - overlay = byHost - } + var overlay config.InventoryHostConfig + if key, ok := providerOverlayKey(provider, host); ok { + overlay = provider.Hosts[key] } if overlay.Group != "" { group = overlay.Group @@ -502,22 +512,40 @@ func (c *HostCatalog) add(host *ResolvedHostData) { canonical := strings.TrimSpace(host.Canonical) host.Canonical = canonical c.hosts[canonical] = host - c.aliases[canonical] = canonical - if short := shortName(canonical); short != "" { - c.aliases[short] = canonical + c.addAlias(canonical, canonical, aliasRankFull) + c.addAlias(shortName(canonical), canonical, aliasRankShort) + c.addAlias(shortName(host.Hostname), canonical, aliasRankShort) + c.addAlias(host.Hostname, canonical, aliasRankFull) + for _, alias := range host.Aliases { + c.addAlias(alias, canonical, aliasRankExplicit) } - if short := shortName(host.Hostname); short != "" { - c.aliases[short] = canonical +} + +func (c *HostCatalog) addAlias(name, canonical string, rank int) { + name = strings.TrimSpace(name) + if name == "" { + return } - if hostname := strings.TrimSpace(host.Hostname); hostname != "" { - c.aliases[hostname] = canonical + if c.aliases == nil { + c.aliases = make(map[string]string) } - for _, alias := range host.Aliases { - alias = strings.TrimSpace(alias) - if alias != "" { - c.aliases[alias] = canonical - } + if c.aliasRank == nil { + c.aliasRank = make(map[string]int) + } + current, exists := c.aliases[name] + currentRank := c.aliasRank[name] + if current == canonical { + c.aliasRank[name] = max(rank, currentRank) + return + } + if exists && rank == currentRank { + slog.Debug("inventory lookup name is shared by hosts", "name", name, "hosts", []string{current, canonical}) + } + if exists && (rank < currentRank || (rank == currentRank && current < canonical)) { + return } + c.aliases[name] = canonical + c.aliasRank[name] = rank } func (c *HostCatalog) Find(query string) (*ResolvedHostData, bool) { diff --git a/internal/connect/catalog_test.go b/internal/connect/catalog_test.go index f283acc..554314e 100644 --- a/internal/connect/catalog_test.go +++ b/internal/connect/catalog_test.go @@ -1011,3 +1011,85 @@ func TestKeyModeOverridesInheritedPasswordPolicyInOpenSSH(t *testing.T) { } } } + +func TestCatalogResolvesSharedLookupNamesDeterministically(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Inventory.Provider = nil + cfg.Inventory.Providers = map[string]config.InventoryProviderConfig{ + config.ProviderLocal: { + Type: config.ProviderLocal, + Hosts: map[string]config.InventoryHostConfig{ + "clab01.ldap.lab.local": {}, + "clab01.lab.local": {Aliases: []string{"clab01"}}, + "node.b.local": {}, + "node.a.local": {}, + "node": {Aliases: []string{"bare"}}, + }, + }, + } + + for range 20 { + cat := buildCatalogForTest(t, cfg, nil) + for query, want := range map[string]string{ + "clab01": "clab01.lab.local", // explicit alias beats derived short name + "node": "node", // full host name beats derived short names + "bare": "node", + } { + host, ok := cat.Find(query) + if !ok || host.Canonical != want { + t.Fatalf("Find(%q) = %v, want %s", query, host, want) + } + } + } + + delete(cfg.Inventory.Providers[config.ProviderLocal].Hosts, "node") + for range 20 { + host, ok := buildCatalogForTest(t, cfg, nil).Find("node") + if !ok || host.Canonical != "node.a.local" { + t.Fatalf("Find(node) = %v, want node.a.local", host) + } + } +} + +func TestCatalogLocalHostShadowsProviderObject(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Inventory.Provider = nil + cfg.Inventory.Providers = map[string]config.InventoryProviderConfig{ + config.ProviderLocal: { + Type: config.ProviderLocal, + Hosts: map[string]config.InventoryHostConfig{"edge01.example.com": {Port: 2201}}, + }, + "netbox-prod": { + Type: config.ProviderNetBox, + Groups: map[string]config.GroupConfig{"cbb": {}}, + Hosts: map[string]config.InventoryHostConfig{ + "edge02.example.com": {Group: "cbb", Port: 2202}, + "gone.example.com": {Group: "cbb"}, + }, + }, + } + state := &inventory.ProviderState{ + Provider: "netbox-prod", + Type: config.ProviderNetBox, + Objects: map[string]*inventory.ProviderHost{ + "1": {ObjectID: "1", Host: "edge01.example.com", HostName: "edge01.example.com", Group: "netbox-prod/cbb"}, + "2": {ObjectID: "2", Host: "edge02.example.com", HostName: "edge02.example.com", Group: "netbox-prod/cbb"}, + }, + } + + cat := buildCatalogForTest(t, cfg, []*inventory.ProviderState{state}) + if host, ok := cat.Find("edge01"); !ok || host.Provider != config.ProviderLocal || host.Port != 2201 { + t.Fatalf("Find(edge01) = %#v, want local host", host) + } + if host, ok := cat.Find("edge02"); !ok || host.Provider != "netbox-prod" || host.Port != 2202 { + t.Fatalf("Find(edge02) = %#v, want overlaid provider host", host) + } + + want := []ProviderHostFinding{ + {Provider: "netbox-prod", Host: "edge01.example.com", ShadowedBy: "edge01.example.com"}, + {Provider: "netbox-prod", Host: "gone.example.com", Orphaned: true}, + } + if got := ProviderHostFindings(cfg, []*inventory.ProviderState{state}); !reflect.DeepEqual(got, want) { + t.Fatalf("findings = %#v, want %#v", got, want) + } +} diff --git a/internal/connect/provider_hosts.go b/internal/connect/provider_hosts.go new file mode 100644 index 0000000..a58edc5 --- /dev/null +++ b/internal/connect/provider_hosts.go @@ -0,0 +1,133 @@ +package connect + +import ( + "sort" + + "github.com/ntwrknrd/nssh/internal/config" + "github.com/ntwrknrd/nssh/internal/inventory" +) + +// Provider-host rules shared by the connect catalog and inventory commands so +// both see one host per name: +// - A host in a provider's YAML hosts table overlays the provider object it +// names; it is not a second host. +// - A local inventory host shadows a provider object with the same name. +// Local inventory is operator-owned; provider state is a refreshable cache. + +// ProviderHostFinding describes a provider entry that does not appear as its +// own host. +type ProviderHostFinding struct { + Provider string + Host string + // ShadowedBy names the local host that replaces this provider object. + ShadowedBy string + // Orphaned reports a provider YAML host override with no provider object. + Orphaned bool +} + +// ResolveProviderStateHost resolves a provider object with its YAML overlay, +// or returns nil when a local inventory host shadows it. +func ResolveProviderStateHost(cfg *config.Config, state *inventory.ProviderState, host *inventory.ProviderHost) *ResolvedHostData { + if cfg == nil || state == nil || host == nil { + return nil + } + provider := normalizeCatalogProvider(catalogProviders(cfg)[state.Provider]) + data := resolvedHostFromState(cfg, state, provider, host) + if _, shadowed := shadowingLocalHost(localHostNames(cfg), data); shadowed { + return nil + } + return data +} + +// ProviderHostFindings lists provider objects shadowed by local hosts and +// provider YAML host overrides that match no provider object. +func ProviderHostFindings(cfg *config.Config, states []*inventory.ProviderState) []ProviderHostFinding { + if cfg == nil { + return nil + } + providers := catalogProviders(cfg) + local := localHostNames(cfg) + var findings []ProviderHostFinding + for _, state := range states { + if state == nil { + continue + } + providerCfg, ok := providers[state.Provider] + if !ok { + continue + } + provider := normalizeCatalogProvider(providerCfg) + matched := make(map[string]bool) + for _, host := range state.Objects { + if host == nil { + continue + } + if key, ok := providerOverlayKey(provider, host); ok { + matched[key] = true + } + data := resolvedHostFromState(cfg, state, provider, host) + if owner, shadowed := shadowingLocalHost(local, data); shadowed { + findings = append(findings, ProviderHostFinding{Provider: state.Provider, Host: data.Canonical, ShadowedBy: owner}) + } + } + for name := range provider.Hosts { + if !matched[name] { + findings = append(findings, ProviderHostFinding{Provider: state.Provider, Host: name, Orphaned: true}) + } + } + } + sort.Slice(findings, func(i, j int) bool { + if findings[i].Provider != findings[j].Provider { + return findings[i].Provider < findings[j].Provider + } + return findings[i].Host < findings[j].Host + }) + return findings +} + +// providerOverlayKey returns the provider YAML host that overlays a provider +// object: a grouped entry by address first, then by host name, then by address. +func providerOverlayKey(provider config.InventoryProviderConfig, host *inventory.ProviderHost) (string, bool) { + if overlay, ok := provider.Hosts[host.HostName]; ok && overlay.Group != "" { + return host.HostName, true + } + if _, ok := provider.Hosts[host.Host]; ok { + return host.Host, true + } + if _, ok := provider.Hosts[host.HostName]; ok { + return host.HostName, true + } + return "", false +} + +func catalogProviders(cfg *config.Config) map[string]config.InventoryProviderConfig { + if len(cfg.Inventory.Providers) == 0 { + return cfg.Inventory.Provider + } + return cfg.Inventory.Providers +} + +func localHostNames(cfg *config.Config) map[string]bool { + names := make(map[string]bool) + for _, provider := range catalogProviders(cfg) { + if normalizeCatalogProvider(provider).Type != config.ProviderLocal { + continue + } + for name := range provider.Hosts { + names[name] = true + } + } + return names +} + +func shadowingLocalHost(local map[string]bool, data *ResolvedHostData) (string, bool) { + if data == nil { + return "", false + } + for _, name := range []string{data.Canonical, data.Hostname} { + if name != "" && local[name] { + return name, true + } + } + return "", false +} diff --git a/skills/nssh/references/configuration-inventory-credentials.md b/skills/nssh/references/configuration-inventory-credentials.md index 6518a80..0d8c6bf 100644 --- a/skills/nssh/references/configuration-inventory-credentials.md +++ b/skills/nssh/references/configuration-inventory-credentials.md @@ -51,6 +51,25 @@ The host `group` key is optional. Ungrouped local hosts are valid and inherit inventory/provider defaults plus host-level auth, SSH, and highlight settings, but they do not inherit any group settings. +nssh writes a provider's hosts and groups to the included file that declares +that provider's `type`, even when the root config also has a block for the +provider. After a write, it removes the root config's now-redundant copy of +that host, group, or provider so the root cannot shadow the included file. + +Host lookup names rank in this order: full host names, explicit aliases, and +then derived short names (the first DNS label). When hosts share a name of +equal rank, the lexically first host wins. `nssh inv set` skips a derived +short alias that already identifies another host and rejects an explicit +`--alias` collision. + +A host under `inventory.providers..hosts` overlays the provider +object with that name; `nssh inv` commands and connections show it as one +host. A local inventory host shadows a provider object with the same name, so +local settings win without a refresh. To keep a device provider-managed while +changing its settings, use a provider host override instead of a local host. +`nssh inv status ` lists shadowed objects and overrides that match +no provider object, and `nssh inv get` shows what a local host shadows. + External inventory providers write non-secret state under `~/.local/state/nssh/inventory/providers/.json`. Provider groups, auth mappings, SSH options, and host overrides live in provider-scoped YAML