From 512b08c5c60a106ef0d72b9b2b62172bf93ad228 Mon Sep 17 00:00:00 2001
From: Christopher Jones <56381413+ntwrknrd@users.noreply.github.com>
Date: Wed, 28 Oct 2026 12:05:42 -0400
Subject: [PATCH 1/2] fix(inventory): write to owning file and rank lookup
names
Provider writes chose the last-merged source for inventory.providers.
,
so any root-config block for a provider captured every later write and
shadowed the included provider file. Prefer the included file that declares
the provider type, and prune the root's now-redundant host, group, or
provider copy after writing the merged value there.
findInventoryHost returned whichever same-named entry the unstable sort put
first, so local hosts listed by a provider too could be treated as
provider-owned and get root auth overrides. Prefer the local definition.
The host catalog filled aliases from Go map iteration with last-writer-wins,
so a short name shared by two hosts resolved randomly between them. Rank
names (full name > explicit alias > derived short name) and break ties by
canonical name. inv set now skips a derived short alias already owned by
another host and rejects a colliding explicit --alias.
---
internal/cli/inv/local.go | 66 ++++++++-
internal/cli/inv/local_test.go | 132 ++++++++++++++++++
internal/config/include.go | 27 ++++
internal/config/root_save_test.go | 99 +++++++++++++
internal/config/writer.go | 82 ++++++++++-
internal/connect/catalog.go | 59 ++++++--
internal/connect/catalog_test.go | 39 ++++++
.../configuration-inventory-credentials.md | 11 ++
8 files changed, 494 insertions(+), 21 deletions(-)
diff --git a/internal/cli/inv/local.go b/internal/cli/inv/local.go
index 8e1d7a2..c3adc3b 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)
@@ -1394,7 +1434,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 {
@@ -1491,7 +1539,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 +1567,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..655f4b3 100644
--- a/internal/cli/inv/local_test.go
+++ b/internal/cli/inv/local_test.go
@@ -1858,3 +1858,135 @@ 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)
+ }
+}
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..9472213 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
@@ -502,22 +513,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..c6f57fc 100644
--- a/internal/connect/catalog_test.go
+++ b/internal/connect/catalog_test.go
@@ -1011,3 +1011,42 @@ 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)
+ }
+ }
+}
diff --git a/skills/nssh/references/configuration-inventory-credentials.md b/skills/nssh/references/configuration-inventory-credentials.md
index 6518a80..45e7471 100644
--- a/skills/nssh/references/configuration-inventory-credentials.md
+++ b/skills/nssh/references/configuration-inventory-credentials.md
@@ -51,6 +51,17 @@ 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.
+
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
From 0af308746fe8c432a7b9909dc4e8bc20a8ce8c05 Mon Sep 17 00:00:00 2001
From: Christopher Jones <56381413+ntwrknrd@users.noreply.github.com>
Date: Wed, 28 Oct 2026 12:16:17 -0400
Subject: [PATCH 2/2] fix(inventory): resolve provider overlays and local
shadowing once
The connect catalog and the inv commands resolved provider hosts with
different rules. The catalog treated provider YAML hosts as overlays but
let a cached provider object replace a local host of the same name; the
inv listing showed each override as a second row beside an unmerged
provider object, so inv set picked between them at random.
Share one rule set in internal/connect: a provider YAML host overlays the
provider object it names, and a local inventory host shadows a provider
object with the same name. Local inventory is operator-owned and provider
state is a refreshable cache, so precedence is decided at read time rather
than by filtering during refresh, and it takes effect without a refresh.
inv list/get/set now see one row per host with overrides applied. inv
status reports shadowed objects and overrides that match no provider
object; inv get shows what a local host shadows.
---
internal/cli/inv/get.go | 34 ++++-
internal/cli/inv/local.go | 94 +++++++++----
internal/cli/inv/local_test.go | 71 ++++++++++
internal/cli/inv/status.go | 34 +++++
internal/connect/catalog.go | 17 ++-
internal/connect/catalog_test.go | 43 ++++++
internal/connect/provider_hosts.go | 133 ++++++++++++++++++
.../configuration-inventory-credentials.md | 8 ++
8 files changed, 390 insertions(+), 44 deletions(-)
create mode 100644 internal/connect/provider_hosts.go
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 c3adc3b..65f0c30 100644
--- a/internal/cli/inv/local.go
+++ b/internal/cli/inv/local.go
@@ -1373,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) {
@@ -1478,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
}
diff --git a/internal/cli/inv/local_test.go b/internal/cli/inv/local_test.go
index 655f4b3..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"
@@ -1990,3 +1992,72 @@ func writeInvTestFile(t *testing.T, path, content string) {
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/connect/catalog.go b/internal/connect/catalog.go
index 9472213..b13da1c 100644
--- a/internal/connect/catalog.go
+++ b/internal/connect/catalog.go
@@ -83,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)
@@ -114,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)
}
}
@@ -162,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
diff --git a/internal/connect/catalog_test.go b/internal/connect/catalog_test.go
index c6f57fc..554314e 100644
--- a/internal/connect/catalog_test.go
+++ b/internal/connect/catalog_test.go
@@ -1050,3 +1050,46 @@ func TestCatalogResolvesSharedLookupNamesDeterministically(t *testing.T) {
}
}
}
+
+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 45e7471..0d8c6bf 100644
--- a/skills/nssh/references/configuration-inventory-credentials.md
+++ b/skills/nssh/references/configuration-inventory-credentials.md
@@ -62,6 +62,14 @@ 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