Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f53b90266 | ||
|
|
a2a6dd7591 | ||
|
|
79c0027833 | ||
|
|
fa7446e5f7 | ||
|
|
92b1c40cb8 | ||
|
|
34503de68e | ||
|
|
14d519d010 |
No files matched your search
@@ -8,6 +8,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_PUBLIC_URL: https://git.zb-server.de
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
@@ -45,15 +48,14 @@ jobs:
|
|||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
|
run: bash scripts/release-notes.sh "$RELEASE_TAG" "$GITEA_PUBLIC_URL/$GITEA_REPOSITORY" > dist/release-notes.md
|
||||||
run: bash scripts/release-notes.sh "$RELEASE_TAG" "$REPOSITORY_URL" > dist/release-notes.md
|
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: create_release
|
id: create_release
|
||||||
env:
|
env:
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
API="$GITEA_PUBLIC_URL/api/v1/repos/$GITEA_REPOSITORY"
|
||||||
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
|
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
|
||||||
|
|
||||||
# Delete existing release for this tag if present
|
# Delete existing release for this tag if present
|
||||||
@@ -98,6 +100,6 @@ jobs:
|
|||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${name}" \
|
"$GITEA_PUBLIC_URL/api/v1/repos/$GITEA_REPOSITORY/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||||
--data-binary "@$file"
|
--data-binary "@$file"
|
||||||
done
|
done
|
||||||
@@ -1,5 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.0.20
|
||||||
|
|
||||||
|
- Configure multiple named NetBox instances and search them together in parallel.
|
||||||
|
- Add, edit, and remove instances in the interactive setup wizard while retaining legacy single-instance configuration support.
|
||||||
|
- Display instance-qualified hostnames and support `instance/hostname` for SSH, completion, aliases, and notes.
|
||||||
|
- Keep credentials, IP resolution, comments, cache entries, and local notes tied to the correct instance.
|
||||||
|
- Preserve partial interactive search results with an outage warning; reject incomplete full refreshes and ambiguous direct connections.
|
||||||
|
- Add `cache refresh --instance NAME` and isolate caches when configured endpoints change.
|
||||||
|
- Add regression tests covering duplicate names and IDs across instances, outages, SSH routing, notes, aliases, and configuration round trips.
|
||||||
|
|
||||||
|
## v0.0.19
|
||||||
|
|
||||||
|
- Update Ultraviolet to commit `a45b7c8`.
|
||||||
|
|
||||||
|
## v0.0.18
|
||||||
|
|
||||||
|
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
|
||||||
|
- Update TUI rendering and keyboard events for Bubble Tea v2 while preserving alternate-screen behavior.
|
||||||
|
- Route paste events to the focused editor, size input fields explicitly, and keep host lists within the terminal height.
|
||||||
|
- Reject ambiguous cached shortcuts and skip duplicate hostnames during cache refresh.
|
||||||
|
- Use paginated, unambiguous host lookups for SSH connections.
|
||||||
|
- Accept shortcut domains with or without a leading dot during cold lookups.
|
||||||
|
- Parse combined SSH options and preserve explicit users and ports supplied through `-o`.
|
||||||
|
- Merge cache changes under a file lock to preserve updates from concurrent processes.
|
||||||
|
- Honor configured resolver strategies during refresh and remove entries that no longer resolve safely.
|
||||||
|
- Check the host kind before reusing a cached IP for a TUI selection.
|
||||||
|
|
||||||
|
## v0.0.17
|
||||||
|
|
||||||
|
- Update Go module dependencies to the latest versions within their existing module paths, including used transitive test dependencies.
|
||||||
|
- Remove obsolete indirect requirements and refresh module checksums with `go mod tidy`.
|
||||||
|
|
||||||
|
## v0.0.16
|
||||||
|
|
||||||
|
- Enable Bash, Zsh, and Fish completion for the configured command alias, such as `j`.
|
||||||
|
- Update or remove generated alias completion files when the command alias changes.
|
||||||
|
|
||||||
|
## v0.0.15
|
||||||
|
|
||||||
|
- Use `https://git.zb-server.de` for release download links and API requests instead of the runner's internal Gitea service URL.
|
||||||
|
|
||||||
## v0.0.14
|
## v0.0.14
|
||||||
|
|
||||||
- Enable Renovate updates for indirect Go modules as well as direct dependencies and CI actions.
|
- Enable Renovate updates for indirect Go modules as well as direct dependencies and CI actions.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Re-run it at any time to change settings without editing the file manually:
|
|||||||
netssh configure
|
netssh configure
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard walks through NetBox connection, SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
||||||
then saves to `~/.config/netssh.yaml`.
|
then saves to `~/.config/netssh.yaml`.
|
||||||
|
|
||||||
### Manual config
|
### Manual config
|
||||||
@@ -142,7 +142,44 @@ shortcuts:
|
|||||||
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
||||||
```
|
```
|
||||||
|
|
||||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
|
|
||||||
|
### Multiple NetBox instances
|
||||||
|
|
||||||
|
Use `netssh configure` to manage instances, or replace the `netbox:` section with a `netboxes:` list:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
netboxes:
|
||||||
|
- name: office
|
||||||
|
url: https://netbox.office.example
|
||||||
|
token: nbt_office-token
|
||||||
|
- name: lab
|
||||||
|
url: https://netbox.lab.example
|
||||||
|
token: nbt_lab-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Names must be unique and contain only letters, digits, underscores, or hyphens. Names are normalized to lowercase. Each instance has its own token and optional `token_version`; SSH, resolver, and shortcut settings apply to all instances. Configure either `netbox:` or `netboxes:`. Existing single-instance files continue to work; the wizard migrates them to a named `default` instance when saved.
|
||||||
|
|
||||||
|
The legacy `NETSSH_NETBOX_URL` and `NETSSH_NETBOX_TOKEN` variables apply to single-instance configuration. They are ignored when the file contains only a named `netboxes:` list.
|
||||||
|
|
||||||
|
Each NetBox keeps its own inventory of devices and virtual machines; either inventory may be empty. The interactive search queries all instances in parallel, including when one already has cached matches. Results show the instance as part of the hostname, for example `office/web01`. If an instance is unavailable, available results and cached hosts remain visible with a warning.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh # search all instances interactively
|
||||||
|
netssh office/web01 # choose an instance explicitly
|
||||||
|
netssh admin@lab/web01 uptime
|
||||||
|
netssh web01 # accepted only if the live lookup is unambiguous
|
||||||
|
netssh cache refresh # refresh all instances
|
||||||
|
netssh cache refresh --instance lab # refresh just one instance
|
||||||
|
netssh notes set office/web01 "Office maintenance"
|
||||||
|
netssh notes show lab/web01 --netbox --kind device
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct connections without an instance prefix, every instance must be reachable to verify uniqueness. A qualified target queries only its own instance and can use a fresh cached IP while other instances are offline. Qualified shortcuts such as `office/web01` also support configured domain suffixes and hyphen folding.
|
||||||
|
|
||||||
|
Cache entries and generated aliases retain the instance name (`office_web01` and `lab_web01`). Named-instance caches live in a configuration-specific subdirectory beneath the configured cache path, so changing an endpoint cannot reuse another server's cached IPs. Run `netssh cache refresh` after adding or changing instances. A failed full refresh leaves the cache and its refresh timestamp unchanged; a per-instance refresh preserves the other instances and does not mark the whole cache as refreshed.
|
||||||
|
|
||||||
|
Local notes remain keyed by the NetBox URL, host kind, and canonical hostname. Migrating a single instance or renaming it preserves its notes. Use `instance/hostname` and `--kind` for uncached local notes; these operations work offline. NetBox comments and interface/IP lookups always use the selected instance's credentials.
|
||||||
|
|
||||||
### Short command alias
|
### Short command alias
|
||||||
|
|
||||||
@@ -169,6 +206,14 @@ it for new shells. The command alias takes priority over a host alias with the
|
|||||||
same name and works even when no servers are cached. Environment override:
|
same name and works even when no servers are cached. Environment override:
|
||||||
`NETSSH_SHELL_ALIAS=j`.
|
`NETSSH_SHELL_ALIAS=j`.
|
||||||
|
|
||||||
|
The command alias also supports shell completion: `j ser<Tab>` suggests cached
|
||||||
|
server names, just like `netssh ser<Tab>`. The wizard, shell hook, installer, and
|
||||||
|
`netssh completion install` maintain the alias completion files automatically.
|
||||||
|
Changing or disabling the alias removes its old generated completion files.
|
||||||
|
Bash requires bash-completion to load user completion files; for Zsh, include
|
||||||
|
`~/.zfunc` in `fpath` before running `compinit` as described below. Fish loads
|
||||||
|
completion files automatically. Open a new terminal after changing the alias.
|
||||||
|
|
||||||
### API tokens
|
### API tokens
|
||||||
|
|
||||||
NetBox supports two token formats:
|
NetBox supports two token formats:
|
||||||
@@ -242,6 +287,8 @@ Resolution order:
|
|||||||
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
||||||
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
||||||
|
|
||||||
|
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
|
||||||
|
|
||||||
| Input | Config | Resolved as |
|
| Input | Config | Resolved as |
|
||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
||||||
@@ -393,6 +440,8 @@ netssh search app- # prints matching hostnames, one per line
|
|||||||
|
|
||||||
Strategies are tried in the configured order; the first to succeed wins.
|
Strategies are tried in the configured order; the first to succeed wins.
|
||||||
|
|
||||||
|
Cache refresh uses the same strategies as a direct connection. To allow the primary IP as a fallback, include `primary_ip` in the strategy list. Hosts that cannot be resolved, or have ambiguous names in the refresh results, are removed from the cache and reported as warnings.
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. |
|
| `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. |
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type connectionTestHost struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PrimaryIP4 *struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
} `json:"primary_ip4,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectionTestServer(t *testing.T, devices, vms []connectionTestHost) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var hosts []connectionTestHost
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/dcim/devices/":
|
||||||
|
hosts = devices
|
||||||
|
case "/api/virtualization/virtual-machines/":
|
||||||
|
hosts = vms
|
||||||
|
case "/api/ipam/ip-addresses/":
|
||||||
|
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := strings.ToLower(r.URL.Query().Get("name__ic"))
|
||||||
|
matched := make([]connectionTestHost, 0)
|
||||||
|
for _, host := range hosts {
|
||||||
|
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||||
|
matched = append(matched, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
start, end := min(offset, len(matched)), min(offset+limit, len(matched))
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"count": len(matched), "results": matched[start:end]})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSSHHostBeyondFirstPage(t *testing.T) {
|
||||||
|
var devices []connectionTestHost
|
||||||
|
for i := range 50 {
|
||||||
|
devices = append(devices, connectionTestHost{ID: i + 1, Name: fmt.Sprintf("web-extra-%02d", i)})
|
||||||
|
}
|
||||||
|
devices = append(devices, connectionTestHost{ID: 51, Name: "web"})
|
||||||
|
srv := connectionTestServer(t, devices, nil)
|
||||||
|
host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", nil)
|
||||||
|
if err != nil || host.ID != 51 {
|
||||||
|
t.Fatalf("host beyond first page not resolved: %+v, %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSSHHostDomainExpansion(t *testing.T) {
|
||||||
|
for _, domain := range []string{"example.com", ".example.com", " .example.com "} {
|
||||||
|
t.Run(domain, func(t *testing.T) {
|
||||||
|
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web.example.com"}}, nil)
|
||||||
|
host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{domain})
|
||||||
|
if err != nil || host.Name != "web.example.com" {
|
||||||
|
t.Fatalf("domain expansion failed: %+v, %v", host, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSSHHostRejectsAmbiguity(t *testing.T) {
|
||||||
|
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web"}, {ID: 2, Name: "web.example.com"}}, []connectionTestHost{{ID: 3, Name: "web"}})
|
||||||
|
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"})
|
||||||
|
if err == nil || errors.Is(err, netbox.ErrHostNotFound) || !strings.Contains(err.Error(), "2 exact matches") {
|
||||||
|
t.Fatalf("ambiguous name accepted or expanded: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSSHHostDoesNotHideAPIErrors(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"})
|
||||||
|
if err == nil || errors.Is(err, netbox.ErrHostNotFound) {
|
||||||
|
t.Fatalf("API failure hidden as missing host: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSSHHostNotFound(t *testing.T) {
|
||||||
|
srv := connectionTestServer(t, nil, nil)
|
||||||
|
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "missing", []string{"example.com"})
|
||||||
|
if !errors.Is(err, netbox.ErrHostNotFound) {
|
||||||
|
t.Fatalf("want missing-host error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheRefreshHonorsResolverAndRemovesUnsafeEntries(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, strategies string
|
||||||
|
wantCached bool
|
||||||
|
}{
|
||||||
|
{"management only", "[management_subnet]", false},
|
||||||
|
{"explicit primary fallback", "[management_subnet, primary_ip]", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}{Address: "192.0.2.1/24"}}
|
||||||
|
srv := connectionTestServer(t, []connectionTestHost{host}, nil)
|
||||||
|
path := prepareRefreshTest(t, srv.URL, tc.strategies)
|
||||||
|
c := cache.New(path, 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, fresh := c.Get("web")
|
||||||
|
if tc.wantCached {
|
||||||
|
if !fresh || got.IP != "192.0.2.1" {
|
||||||
|
t.Fatalf("explicit fallback failed: %+v", got)
|
||||||
|
}
|
||||||
|
} else if got.Name != "" {
|
||||||
|
t.Fatalf("unsafe primary IP retained: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheRefreshSkipsAmbiguousNames(t *testing.T) {
|
||||||
|
host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}{Address: "192.0.2.1/24"}}
|
||||||
|
vm := host
|
||||||
|
vm.ID = 2
|
||||||
|
srv := connectionTestServer(t, []connectionTestHost{host}, []connectionTestHost{vm})
|
||||||
|
path := prepareRefreshTest(t, srv.URL, "[primary_ip]")
|
||||||
|
c := cache.New(path, 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 0 {
|
||||||
|
t.Fatalf("ambiguous host cached: %+v", c.All())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareRefreshTest(t *testing.T, baseURL, strategies string) string {
|
||||||
|
t.Helper()
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", configDir)
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", path)
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", baseURL)
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "token")
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||||
|
data := fmt.Sprintf("resolver:\n strategies: %s\n management_subnets: [10.0.0.0/8]\n", strategies)
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newNetBoxClient(cfg *config.Config) *netbox.Client {
|
||||||
|
if len(cfg.NetBoxes) > 0 {
|
||||||
|
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
|
||||||
|
for _, source := range cfg.NetBoxes {
|
||||||
|
sources = append(sources, netbox.Source{Name: source.Name, URL: source.URL, Token: source.Token, TokenVersion: source.TokenVersion})
|
||||||
|
}
|
||||||
|
return netbox.NewMultiClient(sources)
|
||||||
|
}
|
||||||
|
if cfg.NetBox.URL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) string {
|
||||||
|
return func(target string) string {
|
||||||
|
source, name := netbox.SplitTarget(target)
|
||||||
|
name = shortcuts.Normalize(name, cfg)
|
||||||
|
if qualified && source != "" {
|
||||||
|
return source + "/" + name
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareInstancesTest(t *testing.T, officeURL, labURL string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "")
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||||
|
data := fmt.Sprintf("netboxes:\n - name: office\n url: %q\n token: office-token\n - name: lab\n url: %q\n token: lab-token\nresolver:\n strategies: [primary_ip]\n", officeURL, labURL)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiHost(name, ip string) connectionTestHost {
|
||||||
|
return connectionTestHost{ID: 1, Name: name, PrimaryIP4: &struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}{Address: ip + "/24"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshStoresSameHostnameFromBothInstances(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, ip := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2"} {
|
||||||
|
got, fresh := c.Get(name)
|
||||||
|
if !fresh || got.IP != ip {
|
||||||
|
t.Fatalf("cache source collision: %+v, fresh=%v", got, fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.All()) != 2 || c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("full refresh is incomplete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshFailureAndPerInstanceRefresh(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) }))
|
||||||
|
defer failed.Close()
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, failed.URL)
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
c.Upsert(cache.Entry{Name: "lab/old", Kind: "device", IP: "10.0.0.2"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err == nil {
|
||||||
|
t.Fatal("failed instance marked as full refresh")
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 1 || !c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("partial refresh changed cache or full-refresh stamp")
|
||||||
|
}
|
||||||
|
cmd = rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh", "--instance", "office"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 2 || !c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("per-instance refresh lost other source or updated full stamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalNotesCLIKeepsInstancesSeparate(t *testing.T) {
|
||||||
|
cfg := prepareInstancesTest(t, "https://office.example", "https://lab.example")
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
for _, name := range []string{"office/web", "lab/web"} {
|
||||||
|
c.Upsert(cache.Entry{Name: name, Kind: "device", IP: "10.0.0.1"})
|
||||||
|
}
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run := func(args ...string) (string, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs(append([]string{"notes"}, args...))
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
if _, err := run("set", source+"/web", source+" note"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
got, err := run("show", source+"/web")
|
||||||
|
if err != nil || got != source+" note" {
|
||||||
|
t.Fatalf("CLI notes crossed sources: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := run("set", "web", "wrong note"); err == nil {
|
||||||
|
t.Fatal("ambiguous note target accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiInstanceWrapperHelper(t *testing.T) {
|
||||||
|
if target := os.Getenv("NETSSH_TEST_MULTI_TARGET"); target != "" {
|
||||||
|
runSSHWrapper([]string{target})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapperRoutesQualifiedTargetsAndRejectsAmbiguity(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
c := cache.New(cfg.CachePath(), 3600)
|
||||||
|
if err := c.SetRefreshed(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for target, want := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2", "web": "ambiguous"} {
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
|
||||||
|
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET="+target, "PATH="+bin+":"+os.Getenv("PATH"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if want == "ambiguous" {
|
||||||
|
if err == nil || !strings.Contains(string(out), "2 exact matches") {
|
||||||
|
t.Fatalf("ambiguous connection accepted: %s, %v", out, err)
|
||||||
|
}
|
||||||
|
} else if err != nil || !strings.Contains(string(out), want+"\n") {
|
||||||
|
t.Fatalf("wrong SSH destination for %s: %s, %v", target, out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQualifiedShortcutDoesNotMatchOtherInstance(t *testing.T) {
|
||||||
|
cfg := config.ShortcutsConfig{Domains: []string{".example.com"}, StripHyphens: true}
|
||||||
|
normalize := normalizeTarget(cfg, true)
|
||||||
|
if normalize("office/web-01.example.com") != normalize("office/web01") {
|
||||||
|
t.Fatal("qualified shortcut does not normalize")
|
||||||
|
}
|
||||||
|
if normalize("office/web01") == normalize("lab/web01") {
|
||||||
|
t.Fatal("qualified shortcut lost instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("router", "10.1.0.1")}, []connectionTestHost{multiHost("app", "10.1.0.2")})
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("switch", "10.2.0.1")}, []connectionTestHost{multiHost("test-vm", "10.2.0.2")})
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]struct{ kind, ip string }{
|
||||||
|
"office/router": {"device", "10.1.0.1"}, "office/app": {"vm", "10.1.0.2"},
|
||||||
|
"lab/switch": {"device", "10.2.0.1"}, "lab/test-vm": {"vm", "10.2.0.2"},
|
||||||
|
}
|
||||||
|
if len(c.All()) != len(want) {
|
||||||
|
t.Fatalf("missing inventory entries: %+v", c.All())
|
||||||
|
}
|
||||||
|
for name, entry := range want {
|
||||||
|
got, fresh := c.Get(name)
|
||||||
|
if !fresh || got.Kind != entry.kind || got.IP != entry.ip {
|
||||||
|
t.Fatalf("wrong cached host %s: %+v", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-85
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
@@ -22,7 +23,6 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
|
||||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -65,7 +65,7 @@ func loadConfigOrSetup() *config.Config {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("config: %v", err)
|
fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
||||||
if err := setup.RunWizard(cfg); err != nil {
|
if err := setup.RunWizard(cfg); err != nil {
|
||||||
fatalf("setup: %v", err)
|
fatalf("setup: %v", err)
|
||||||
@@ -102,12 +102,15 @@ func runSSHWrapper(args []string) {
|
|||||||
parsed.DestIdx += 2
|
parsed.DestIdx += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
|
source, _ := netbox.SplitTarget(parsed.Host)
|
||||||
|
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||||
|
|
||||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||||
c.MarkUsed(parsed.Host)
|
c.MarkUsed(parsed.Host)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
connect(entry.IP, parsed, args)
|
connect(entry.IP, parsed, args)
|
||||||
@@ -118,9 +121,13 @@ func runSSHWrapper(args []string) {
|
|||||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||||
lookupHost := parsed.Host
|
lookupHost := parsed.Host
|
||||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
||||||
if shortcutsEnabled {
|
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||||
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
|
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||||
if entry, found, fresh := c.GetByShortcut(lookupHost, normalize); found {
|
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
if fresh {
|
if fresh {
|
||||||
c.MarkUsed(entry.Name)
|
c.MarkUsed(entry.Name)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
@@ -132,50 +139,33 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||||
}
|
}
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := newNetBoxClient(cfg)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := nbClient.Search(ctx, lookupHost, netbox.SearchOptions{})
|
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
||||||
|
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
||||||
|
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
|
||||||
|
if cacheErr != nil {
|
||||||
|
fatalf("%v", cacheErr)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("NetBox search failed: %v", err)
|
fatalf("NetBox: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var target *netbox.HostEntry
|
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||||
for i, e := range entries {
|
c.MarkUsed(target.Target())
|
||||||
if strings.EqualFold(e.Name, lookupHost) {
|
_ = c.Save()
|
||||||
target = &entries[i]
|
connect(cached.IP, parsed, args)
|
||||||
break
|
return
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domain expansion: if still no match and the input has no dots, try appending
|
|
||||||
// each configured domain suffix and re-querying NetBox.
|
|
||||||
if target == nil && !strings.Contains(parsed.Host, ".") && len(cfg.Shortcuts.Domains) > 0 {
|
|
||||||
for _, domain := range cfg.Shortcuts.Domains {
|
|
||||||
expanded := parsed.Host + domain
|
|
||||||
expandedEntries, searchErr := nbClient.Search(ctx, expanded, netbox.SearchOptions{})
|
|
||||||
if searchErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i, e := range expandedEntries {
|
|
||||||
if strings.EqualFold(e.Name, expanded) {
|
|
||||||
target = &expandedEntries[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if target == nil {
|
|
||||||
fatalf("host %q not found in NetBox", parsed.Host)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
@@ -183,18 +173,38 @@ func runSSHWrapper(args []string) {
|
|||||||
fatalf("resolver: %v", err)
|
fatalf("resolver: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ip, err := chain.Resolve(ctx, target, nbClient)
|
ip, err := chain.Resolve(ctx, &target, nbClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
|
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||||
c.MarkUsed(target.Name)
|
c.MarkUsed(target.Target())
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
|
|
||||||
connect(ip, parsed, args)
|
connect(ip, parsed, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||||
|
// targets. Only a genuine missing host permits trying configured suffixes.
|
||||||
|
func findSSHHost(ctx context.Context, client *netbox.Client, name string, domains []string) (netbox.HostEntry, error) {
|
||||||
|
target, err := client.FindHost(ctx, name, "")
|
||||||
|
if !errors.Is(err, netbox.ErrHostNotFound) || strings.Contains(name, ".") {
|
||||||
|
return target, err
|
||||||
|
}
|
||||||
|
for _, domain := range domains {
|
||||||
|
domain = strings.TrimPrefix(strings.TrimSpace(domain), ".")
|
||||||
|
if domain == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, err = client.FindHost(ctx, name+"."+domain, "")
|
||||||
|
if !errors.Is(err, netbox.ErrHostNotFound) {
|
||||||
|
return target, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
||||||
|
}
|
||||||
|
|
||||||
func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
||||||
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip)
|
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip)
|
||||||
if err := internalssh.Exec(newArgs); err != nil {
|
if err := internalssh.Exec(newArgs); err != nil {
|
||||||
@@ -206,18 +216,18 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
|||||||
func runTUI() {
|
func runTUI() {
|
||||||
cfg := loadConfigOrSetup()
|
cfg := loadConfigOrSetup()
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
var nbClient *netbox.Client
|
var nbClient *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.HasNetBox() {
|
||||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient = newNetBoxClient(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m)
|
||||||
final, err := p.Run()
|
final, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("TUI: %v", err)
|
fatalf("TUI: %v", err)
|
||||||
@@ -233,7 +243,7 @@ func runTUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||||
if cached, fresh := c.Get(host.Name); fresh {
|
if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind {
|
||||||
host.IP = cached.IP
|
host.IP = cached.IP
|
||||||
} else {
|
} else {
|
||||||
if nbClient == nil {
|
if nbClient == nil {
|
||||||
@@ -253,7 +263,7 @@ func runTUI() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("IP resolution: %v", err)
|
fatalf("IP resolution: %v", err)
|
||||||
}
|
}
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
@@ -298,7 +308,7 @@ func rootCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveError
|
return nil, cobra.ShellCompDirectiveError
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
var completions []cobra.Completion
|
var completions []cobra.Completion
|
||||||
@@ -388,7 +398,17 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
if err := os.WriteFile(file, script, 0o644); err != nil {
|
if err := os.WriteFile(file, script, 0o644); err != nil {
|
||||||
return fmt.Errorf("writing %s: %w", file, err)
|
return fmt.Errorf("writing %s: %w", file, err)
|
||||||
}
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
fmt.Printf("Completion installed → %s\n%s\n", file, note)
|
fmt.Printf("Completion installed → %s\n%s\n", file, note)
|
||||||
|
if cfg.Shell.Alias != "" {
|
||||||
|
fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -401,9 +421,9 @@ func configureCmd() *cobra.Command {
|
|||||||
Use: "configure",
|
Use: "configure",
|
||||||
Short: "Interactively configure netssh",
|
Short: "Interactively configure netssh",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, _ := config.Load()
|
cfg, err := config.Load()
|
||||||
if cfg == nil {
|
if err != nil {
|
||||||
cfg = &config.Config{}
|
return err
|
||||||
}
|
}
|
||||||
return setup.RunWizard(cfg)
|
return setup.RunWizard(cfg)
|
||||||
},
|
},
|
||||||
@@ -420,7 +440,7 @@ func searchCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -454,7 +474,7 @@ func cacheListCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -480,7 +500,7 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
c.Clear()
|
c.Clear()
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -492,7 +512,7 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cacheRefreshCmd() *cobra.Command {
|
func cacheRefreshCmd() *cobra.Command {
|
||||||
var filterTag, filterKind string
|
var filterTag, filterKind, filterInstance string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "refresh",
|
Use: "refresh",
|
||||||
Short: "Re-fetch all known hosts from NetBox",
|
Short: "Re-fetch all known hosts from NetBox",
|
||||||
@@ -501,21 +521,28 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
return fmt.Errorf("netbox.url is not configured")
|
return fmt.Errorf("netbox.url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := newNetBoxClient(cfg)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
||||||
entries, err := nbClient.SearchAll(ctx, "", opts)
|
query := ""
|
||||||
|
if filterInstance != "" {
|
||||||
|
if !nbClient.MultipleSources() {
|
||||||
|
return fmt.Errorf("--instance requires named netboxes configuration")
|
||||||
|
}
|
||||||
|
query = filterInstance + "/"
|
||||||
|
}
|
||||||
|
entries, err := nbClient.SearchAll(ctx, query, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("NetBox: %w", err)
|
return fmt.Errorf("NetBox: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
@@ -523,32 +550,37 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("resolver: %w", err)
|
return fmt.Errorf("resolver: %w", err)
|
||||||
}
|
}
|
||||||
written := 0
|
written := 0
|
||||||
|
nameCounts := make(map[string]int)
|
||||||
|
for _, e := range entries {
|
||||||
|
nameCounts[strings.ToLower(e.Target())]++
|
||||||
|
}
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
e := &entries[i]
|
e := &entries[i]
|
||||||
|
if nameCounts[strings.ToLower(e.Target())] > 1 {
|
||||||
var ip string
|
c.Delete(e.Target())
|
||||||
if chain != nil {
|
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
|
||||||
if resolved, err := chain.Resolve(ctx, e, nbClient); err == nil {
|
continue
|
||||||
ip = resolved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ip == "" {
|
|
||||||
ip = e.PrimaryIP4
|
|
||||||
}
|
|
||||||
if ip == "" {
|
|
||||||
ip = e.PrimaryIP6
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ip != "" {
|
ip, err := chain.Resolve(ctx, e, nbClient)
|
||||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
if err != nil {
|
||||||
|
// Do not turn a failed configured strategy into a fresh
|
||||||
|
// primary-IP cache hit that bypasses the resolver.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
c.Delete(e.Target())
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||||
written++
|
written++
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if filterTag == "" && filterKind == "" {
|
if filterTag == "" && filterKind == "" && filterInstance == "" {
|
||||||
if err := c.SetRefreshed(); err != nil {
|
if err := c.SetRefreshed(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -564,6 +596,7 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
||||||
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
||||||
|
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +625,7 @@ Or use in a script:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -637,7 +670,7 @@ func shellInitCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
// Regenerate alias files from the current cache every shell start
|
// Regenerate alias files from the current cache every shell start
|
||||||
@@ -698,8 +731,8 @@ After installation, reload your profile or open a new shell.`,
|
|||||||
|
|
||||||
// Generate the aliases file immediately so it is ready on the next shell start.
|
// Generate the aliases file immediately so it is ready on the next shell start.
|
||||||
cfg, cfgErr := config.Load()
|
cfg, cfgErr := config.Load()
|
||||||
if cfgErr == nil && cfg.NetBox.URL != "" {
|
if cfgErr == nil && cfg.HasNetBox() {
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if _ = c.Load(); true {
|
if _ = c.Load(); true {
|
||||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||||
@@ -751,7 +784,7 @@ func hookUninstallCmd() *cobra.Command {
|
|||||||
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
||||||
// has not been fully refreshed within the last 24 hours.
|
// has not been fully refreshed within the last 24 hours.
|
||||||
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !c.NeedsRefresh(24 * time.Hour) {
|
if !c.NeedsRefresh(24 * time.Hour) {
|
||||||
|
|||||||
+17
-7
@@ -34,11 +34,11 @@ func notesCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var client *netbox.Client
|
var client *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.HasNetBox() {
|
||||||
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
client = newNetBoxClient(cfg)
|
||||||
}
|
}
|
||||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -46,13 +46,23 @@ func notesCmd() *cobra.Command {
|
|||||||
if !remote {
|
if !remote {
|
||||||
cached = c.All()
|
cached = c.All()
|
||||||
}
|
}
|
||||||
|
query := host.Name
|
||||||
|
source, rawName := netbox.SplitTarget(query)
|
||||||
|
var matches []cache.Entry
|
||||||
for _, entry := range cached {
|
for _, entry := range cached {
|
||||||
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
|
_, entryName := netbox.SplitTarget(entry.Name)
|
||||||
host.Name = entry.Name
|
if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
|
||||||
host.Kind = entry.Kind
|
matches = append(matches, entry)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return fmt.Errorf("ambiguous hostname %q; use instance/hostname", query)
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
host.Name = matches[0].Name
|
||||||
|
host.Kind = matches[0].Kind
|
||||||
|
}
|
||||||
|
|
||||||
if !remote && host.Kind == "" {
|
if !remote && host.Kind == "" {
|
||||||
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func TestLocalNotesCLI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) {
|
func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) {
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
|||||||
@@ -1,52 +1,53 @@
|
|||||||
module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
||||||
|
|
||||||
go 1.26.3
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
charm.land/bubbles/v2 v2.2.1
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
charm.land/bubbletea/v2 v2.1.0
|
||||||
github.com/charmbracelet/huh v1.0.0
|
charm.land/huh/v2 v2.0.3
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
charm.land/lipgloss/v2 v2.0.6
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
golang.org/x/sys v0.49.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/golden v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/charmbracelet/x/xpty v0.1.4 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-runewidth v0.0.31 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sahilm/fuzzy v0.1.3 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/stretchr/testify v1.12.1 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v1.2.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sync v0.24.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,64 +1,58 @@
|
|||||||
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f h1:D2JpB65eehsnkEwEG401dMBSNYtTDgvKXZZSl7ZCxWI=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
|
||||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/x/exp/golden v0.1.0 h1:qgUxGaHX4I8Fg077vD+54+qFmDWrgO19lTCQLblHWko=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/x/exp/golden v0.1.0/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
|
||||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
github.com/charmbracelet/x/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -67,37 +61,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
@@ -109,24 +91,19 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
Vendored
+106
-16
@@ -2,13 +2,17 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
@@ -20,8 +24,18 @@ type Entry struct {
|
|||||||
LastUsed time.Time `json:"last_used,omitempty"`
|
LastUsed time.Time `json:"last_used,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type changeKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
upsertChange changeKind = iota
|
||||||
|
deleteChange
|
||||||
|
usageChange
|
||||||
|
)
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
dirty map[string]changeKind
|
||||||
|
cleared bool
|
||||||
entries map[string]Entry
|
entries map[string]Entry
|
||||||
path string
|
path string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
@@ -39,6 +53,7 @@ func New(path string, ttlSeconds int) *Cache {
|
|||||||
}
|
}
|
||||||
return &Cache{
|
return &Cache{
|
||||||
entries: make(map[string]Entry),
|
entries: make(map[string]Entry),
|
||||||
|
dirty: make(map[string]changeKind),
|
||||||
path: path,
|
path: path,
|
||||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||||
refreshStamp: stamp,
|
refreshStamp: stamp,
|
||||||
@@ -94,6 +109,8 @@ func (c *Cache) Load() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.dirty = make(map[string]changeKind)
|
||||||
|
c.cleared = false
|
||||||
c.entries = make(map[string]Entry, len(df.Entries))
|
c.entries = make(map[string]Entry, len(df.Entries))
|
||||||
for _, e := range df.Entries {
|
for _, e := range df.Entries {
|
||||||
c.entries[e.Name] = e
|
c.entries[e.Name] = e
|
||||||
@@ -101,23 +118,80 @@ func (c *Cache) Load() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save merges only local changes under a process-wide file lock. Atomic rename
|
||||||
|
// alone prevents torn JSON but cannot prevent lost updates from another process.
|
||||||
func (c *Cache) Save() error {
|
func (c *Cache) Save() error {
|
||||||
c.mu.RLock()
|
c.mu.Lock()
|
||||||
df := diskFormat{Entries: make([]Entry, 0, len(c.entries))}
|
defer c.mu.Unlock()
|
||||||
for _, e := range c.entries {
|
|
||||||
df.Entries = append(df.Entries, e)
|
|
||||||
}
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
lock, err := os.OpenFile(c.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer lock.Close()
|
||||||
|
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
|
||||||
|
|
||||||
|
merged := make(map[string]Entry)
|
||||||
|
if !c.cleared {
|
||||||
|
data, err := os.ReadFile(c.path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
var df diskFormat
|
||||||
|
if err := json.Unmarshal(data, &df); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range df.Entries {
|
||||||
|
merged[e.Name] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, change := range c.dirty {
|
||||||
|
if change == deleteChange {
|
||||||
|
delete(merged, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := c.entries[name]
|
||||||
|
old, exists := merged[name]
|
||||||
|
if change == usageChange && !exists {
|
||||||
|
// A connection finishing after a refresh or clear must not
|
||||||
|
// restore an entry that the other process removed.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
lastUsed := e.LastUsed
|
||||||
|
if change == usageChange || old.CachedAt.After(e.CachedAt) {
|
||||||
|
e = old
|
||||||
|
}
|
||||||
|
if old.LastUsed.After(lastUsed) {
|
||||||
|
lastUsed = old.LastUsed
|
||||||
|
}
|
||||||
|
e.LastUsed = lastUsed
|
||||||
|
}
|
||||||
|
merged[name] = e
|
||||||
|
}
|
||||||
|
df := diskFormat{Entries: make([]Entry, 0, len(merged))}
|
||||||
|
for _, e := range merged {
|
||||||
|
df.Entries = append(df.Entries, e)
|
||||||
|
}
|
||||||
|
sort.Slice(df.Entries, func(i, j int) bool { return df.Entries[i].Name < df.Entries[j].Name })
|
||||||
data, err := json.MarshalIndent(df, "", " ")
|
data, err := json.MarshalIndent(df, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return storage.WriteFile(c.path, data, 0o600)
|
if err := storage.WriteFile(c.path, data, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.entries = merged
|
||||||
|
c.dirty = make(map[string]changeKind)
|
||||||
|
c.cleared = false
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Upsert(e Entry) {
|
func (c *Cache) Upsert(e Entry) {
|
||||||
@@ -127,9 +201,18 @@ func (c *Cache) Upsert(e Entry) {
|
|||||||
e.LastUsed = old.LastUsed
|
e.LastUsed = old.LastUsed
|
||||||
}
|
}
|
||||||
c.entries[e.Name] = e
|
c.entries[e.Name] = e
|
||||||
|
c.dirty[e.Name] = upsertChange
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes an entry when a refresh can no longer resolve it safely.
|
||||||
|
func (c *Cache) Delete(name string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.entries, name)
|
||||||
|
c.dirty[name] = deleteChange
|
||||||
|
}
|
||||||
|
|
||||||
// MarkUsed records the current time as LastUsed for the named entry.
|
// MarkUsed records the current time as LastUsed for the named entry.
|
||||||
// It is a no-op if the entry does not exist.
|
// It is a no-op if the entry does not exist.
|
||||||
func (c *Cache) MarkUsed(name string) {
|
func (c *Cache) MarkUsed(name string) {
|
||||||
@@ -138,6 +221,9 @@ func (c *Cache) MarkUsed(name string) {
|
|||||||
if e, ok := c.entries[name]; ok {
|
if e, ok := c.entries[name]; ok {
|
||||||
e.LastUsed = time.Now()
|
e.LastUsed = time.Now()
|
||||||
c.entries[name] = e
|
c.entries[name] = e
|
||||||
|
if _, changed := c.dirty[name]; !changed {
|
||||||
|
c.dirty[name] = usageChange
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,26 +258,28 @@ func (c *Cache) Search(prefix string) []Entry {
|
|||||||
prefix = strings.ToLower(prefix)
|
prefix = strings.ToLower(prefix)
|
||||||
var out []Entry
|
var out []Entry
|
||||||
for name, e := range c.entries {
|
for name, e := range c.entries {
|
||||||
if strings.HasPrefix(strings.ToLower(name), prefix) {
|
_, hostname, qualified := strings.Cut(strings.ToLower(name), "/")
|
||||||
|
if strings.HasPrefix(strings.ToLower(name), prefix) || (qualified && !strings.Contains(prefix, "/") && strings.HasPrefix(hostname, prefix)) {
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByShortcut scans all entries and returns the first whose normalized name matches
|
// GetByShortcut rejects collisions instead of choosing a random map entry.
|
||||||
// the normalized shortcut. Returns (entry, found, fresh).
|
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool, err error) {
|
||||||
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool) {
|
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
norm := normalize(shortcut)
|
norm := normalize(shortcut)
|
||||||
for _, e := range c.entries {
|
for _, e := range c.entries {
|
||||||
if normalize(e.Name) == norm {
|
if normalize(e.Name) == norm {
|
||||||
isFresh := c.ttl > 0 && time.Since(e.CachedAt) < c.ttl
|
if found {
|
||||||
return e, true, isFresh
|
return Entry{}, false, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", shortcut)
|
||||||
|
}
|
||||||
|
entry, found = e, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Entry{}, false, false
|
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns an entry and reports whether it is still within the TTL.
|
// Get returns an entry and reports whether it is still within the TTL.
|
||||||
@@ -212,6 +300,8 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
|||||||
func (c *Cache) Clear() {
|
func (c *Cache) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.entries = make(map[string]Entry)
|
c.entries = make(map[string]Entry)
|
||||||
|
c.dirty = make(map[string]changeKind)
|
||||||
|
c.cleared = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+17
-5
@@ -314,7 +314,7 @@ func TestGetByShortcut_MatchFresh(t *testing.T) {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
e, found, fresh := c.GetByShortcut("web01", normalize)
|
e, found, fresh, _ := c.GetByShortcut("web01", normalize)
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("expected entry to be found")
|
t.Fatal("expected entry to be found")
|
||||||
}
|
}
|
||||||
@@ -340,7 +340,7 @@ func TestGetByShortcut_MatchStale(t *testing.T) {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
_, found, fresh := c.GetByShortcut("web01", normalize)
|
_, found, fresh, _ := c.GetByShortcut("web01", normalize)
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("expected entry to be found even when stale")
|
t.Fatal("expected entry to be found even when stale")
|
||||||
}
|
}
|
||||||
@@ -353,7 +353,7 @@ func TestGetByShortcut_NotFound(t *testing.T) {
|
|||||||
c := New("", 3600)
|
c := New("", 3600)
|
||||||
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
|
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
|
||||||
|
|
||||||
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
||||||
if found {
|
if found {
|
||||||
t.Error("should not find an entry that does not match the shortcut")
|
t.Error("should not find an entry that does not match the shortcut")
|
||||||
}
|
}
|
||||||
@@ -361,7 +361,7 @@ func TestGetByShortcut_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetByShortcut_EmptyCache(t *testing.T) {
|
func TestGetByShortcut_EmptyCache(t *testing.T) {
|
||||||
c := New("", 3600)
|
c := New("", 3600)
|
||||||
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
||||||
if found {
|
if found {
|
||||||
t.Error("empty cache should return found=false")
|
t.Error("empty cache should return found=false")
|
||||||
}
|
}
|
||||||
@@ -381,7 +381,7 @@ func TestGetByShortcut_MultiDomain(t *testing.T) {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
e, found, _ := c.GetByShortcut("web01", normalize)
|
e, found, _, _ := c.GetByShortcut("web01", normalize)
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("should match entry with second configured domain")
|
t.Fatal("should match entry with second configured domain")
|
||||||
}
|
}
|
||||||
@@ -483,3 +483,15 @@ func TestUpsertPreservesUsageHistory(t *testing.T) {
|
|||||||
t.Fatalf("history lost: %+v", after)
|
t.Fatalf("history lost: %+v", after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
|
||||||
|
c := New("", 3600)
|
||||||
|
c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"})
|
||||||
|
c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"})
|
||||||
|
c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"})
|
||||||
|
for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} {
|
||||||
|
if got := len(c.Search(query)); got != want {
|
||||||
|
t.Errorf("Search(%q)=%d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+182
@@ -0,0 +1,182 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShortcutRejectsAmbiguousTargets(t *testing.T) {
|
||||||
|
c := New("", 3600)
|
||||||
|
c.Upsert(Entry{Name: "web.prod.example", IP: "10.0.0.1"})
|
||||||
|
c.Upsert(Entry{Name: "web.dev.example", IP: "10.0.0.2"})
|
||||||
|
normalize := func(s string) string { return strings.SplitN(s, ".", 2)[0] }
|
||||||
|
for range 100 {
|
||||||
|
entry, found, fresh, err := c.GetByShortcut("web", normalize)
|
||||||
|
if err == nil || found || fresh || entry.Name != "" {
|
||||||
|
t.Fatalf("ambiguous shortcut returned target: %+v, %v, %v, %v", entry, found, fresh, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveMergesIndependentlyLoadedCaches(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||||
|
a, b := New(path, 3600), New(path, 3600)
|
||||||
|
for _, c := range []*Cache{a, b} {
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Upsert(Entry{Name: "a", IP: "10.0.0.1"})
|
||||||
|
b.Upsert(Entry{Name: "b", IP: "10.0.0.2"})
|
||||||
|
for _, c := range []*Cache{a, b} {
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(a.All()) != 2 {
|
||||||
|
t.Fatalf("lost an update: %+v", a.All())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving a stale usage update must retain newer IPs and both usage times.
|
||||||
|
if err := b.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b.MarkUsed("a")
|
||||||
|
used, _ := b.Get("a")
|
||||||
|
a.Upsert(Entry{Name: "a", IP: "10.0.0.3"})
|
||||||
|
if err := a.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := b.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := b.Get("a")
|
||||||
|
if got.IP != "10.0.0.3" || !got.LastUsed.Equal(used.LastUsed) {
|
||||||
|
t.Fatalf("lost refreshed IP or usage: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Delete("a")
|
||||||
|
if err := b.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := a.Get("a"); got.Name != "" {
|
||||||
|
t.Fatalf("deleted entry survived: %+v", got)
|
||||||
|
}
|
||||||
|
a.Clear()
|
||||||
|
if err := a.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := b.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(b.All()) != 0 {
|
||||||
|
t.Fatal("clear merged old entries back in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveAcrossProcesses(t *testing.T) {
|
||||||
|
if path := os.Getenv("NETSSH_TEST_CACHE_WRITER_PATH"); path != "" {
|
||||||
|
c := New(path, 3600)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.Upsert(Entry{Name: os.Getenv("NETSSH_TEST_CACHE_WRITER_NAME"), IP: "10.0.0.1"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||||
|
const writers = 8
|
||||||
|
cmds := make([]*exec.Cmd, writers)
|
||||||
|
for i := range cmds {
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestSaveAcrossProcesses$")
|
||||||
|
cmd.Env = append(os.Environ(), "NETSSH_TEST_CACHE_WRITER_PATH="+path, fmt.Sprintf("NETSSH_TEST_CACHE_WRITER_NAME=host-%d", i))
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmds[i] = cmd
|
||||||
|
}
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := New(path, 3600)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != writers {
|
||||||
|
t.Fatalf("lost concurrent updates: got %d, want %d", len(c.All()), writers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSavePreservesNewerUsageFromDisk(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||||
|
a := New(path, 3600)
|
||||||
|
a.Upsert(Entry{Name: "web", IP: "10.0.0.1", LastUsed: time.Now()})
|
||||||
|
if err := a.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := New(path, 3600)
|
||||||
|
if err := b.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.MarkUsed("web")
|
||||||
|
used, _ := a.Get("web")
|
||||||
|
if err := a.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b.Upsert(Entry{Name: "web", IP: "10.0.0.2"})
|
||||||
|
if err := b.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := b.Get("web")
|
||||||
|
if got.IP != "10.0.0.2" || !got.LastUsed.Equal(used.LastUsed) {
|
||||||
|
t.Fatalf("lost data: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageSaveDoesNotRestoreRemovedHosts(t *testing.T) {
|
||||||
|
for _, clear := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("clear=%v", clear), func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||||
|
a := New(path, 3600)
|
||||||
|
a.Upsert(Entry{Name: "web", IP: "10.0.0.1"})
|
||||||
|
if err := a.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := New(path, 3600)
|
||||||
|
if err := b.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b.MarkUsed("web")
|
||||||
|
if clear {
|
||||||
|
a.Clear()
|
||||||
|
} else {
|
||||||
|
a.Delete("web")
|
||||||
|
}
|
||||||
|
if err := a.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := b.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(b.All()) != 0 {
|
||||||
|
t.Fatalf("usage update restored removed entry: %+v", b.All())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Shell ShellConfig `mapstructure:"shell"`
|
Shell ShellConfig `mapstructure:"shell"`
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
|
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
SSH SSHConfig `mapstructure:"ssh"`
|
SSH SSHConfig `mapstructure:"ssh"`
|
||||||
@@ -24,6 +25,7 @@ type ShellConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
TokenVersion int `mapstructure:"token_version"`
|
TokenVersion int `mapstructure:"token_version"`
|
||||||
@@ -93,6 +95,11 @@ func Load() (*Config, error) {
|
|||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
|
// Legacy environment variables must not invent an additional unnamed source
|
||||||
|
// when a file explicitly configures named instances (including wizard migration).
|
||||||
|
if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") {
|
||||||
|
cfg.NetBox = NetBoxConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -106,6 +113,20 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range cfg.NetBoxes {
|
||||||
|
source := &cfg.NetBoxes[i]
|
||||||
|
source.Name = strings.ToLower(source.Name)
|
||||||
|
if source.TokenVersion == 0 && source.Token != "" {
|
||||||
|
source.TokenVersion = 1
|
||||||
|
if strings.HasPrefix(source.Token, "nbt_") {
|
||||||
|
source.TokenVersion = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Cache.Path == "" {
|
if cfg.Cache.Path == "" {
|
||||||
cacheDir, err := os.UserCacheDir()
|
cacheDir, err := os.UserCacheDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var instanceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||||
|
|
||||||
|
// ValidateNetBoxes keeps source names usable as instance/hostname selectors.
|
||||||
|
func (c *Config) ValidateNetBoxes() error {
|
||||||
|
if len(c.NetBoxes) > 0 && c.NetBox.URL != "" {
|
||||||
|
return fmt.Errorf("configure either netbox or netboxes, not both")
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, source := range c.NetBoxes {
|
||||||
|
if !instanceName.MatchString(source.Name) {
|
||||||
|
return fmt.Errorf("netboxes.name %q must contain only letters, digits, underscores, or hyphens", source.Name)
|
||||||
|
}
|
||||||
|
name := strings.ToLower(source.Name)
|
||||||
|
if seen[name] {
|
||||||
|
return fmt.Errorf("duplicate NetBox instance name %q", source.Name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
u, err := url.Parse(source.URL)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name)
|
||||||
|
}
|
||||||
|
if source.TokenVersion < 0 || source.TokenVersion > 2 {
|
||||||
|
return fmt.Errorf("NetBox %q: token_version must be 0, 1, or 2", source.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
|
||||||
|
|
||||||
|
// CachePath isolates named sources from legacy caches and from changed endpoints.
|
||||||
|
// Rotating a token or reordering the sources does not discard usage history.
|
||||||
|
func (c *Config) CachePath() string {
|
||||||
|
if len(c.NetBoxes) == 0 {
|
||||||
|
return c.Cache.Path
|
||||||
|
}
|
||||||
|
var identities []string
|
||||||
|
for _, source := range c.NetBoxes {
|
||||||
|
identities = append(identities, strings.ToLower(source.Name)+"\x00"+strings.TrimRight(source.URL, "/"))
|
||||||
|
}
|
||||||
|
sort.Strings(identities)
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(identities, "\x00")))
|
||||||
|
return filepath.Join(filepath.Dir(c.Cache.Path), fmt.Sprintf("instances-%x", sum[:8]), filepath.Base(c.Cache.Path))
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadNamedNetBoxes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "https://legacy-env.example")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "legacy-env-token")
|
||||||
|
data := `netboxes:
|
||||||
|
- name: Office
|
||||||
|
url: https://office.example/netbox
|
||||||
|
token: nbt_office
|
||||||
|
- name: lab
|
||||||
|
url: https://lab.example
|
||||||
|
token: legacy-token
|
||||||
|
cache:
|
||||||
|
path: /tmp/netssh-tests/hosts.json
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !cfg.HasNetBox() || len(cfg.NetBoxes) != 2 || cfg.NetBoxes[0].Name != "office" || cfg.NetBoxes[0].TokenVersion != 2 || cfg.NetBoxes[1].TokenVersion != 1 {
|
||||||
|
t.Fatalf("bad instances: %+v", cfg.NetBoxes)
|
||||||
|
}
|
||||||
|
if cfg.NetBox.URL != "" {
|
||||||
|
t.Fatal("legacy environment variables added an unnamed instance")
|
||||||
|
}
|
||||||
|
first := cfg.CachePath()
|
||||||
|
if first == cfg.Cache.Path {
|
||||||
|
t.Fatal("named sources reuse legacy cache")
|
||||||
|
}
|
||||||
|
cfg.NetBoxes[0], cfg.NetBoxes[1] = cfg.NetBoxes[1], cfg.NetBoxes[0]
|
||||||
|
cfg.NetBoxes[0].Token = "rotated"
|
||||||
|
if cfg.CachePath() != first {
|
||||||
|
t.Fatal("reordering/rotating credentials discarded cache")
|
||||||
|
}
|
||||||
|
cfg.NetBoxes[0].URL = "https://other.example"
|
||||||
|
if cfg.CachePath() == first {
|
||||||
|
t.Fatal("changed endpoint reuses old hosts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNetBoxes(t *testing.T) {
|
||||||
|
good := NetBoxConfig{Name: "office", URL: "https://office.example"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
}{
|
||||||
|
{"duplicate", Config{NetBoxes: []NetBoxConfig{good, {Name: "OFFICE", URL: "https://other.example"}}}},
|
||||||
|
{"empty name", Config{NetBoxes: []NetBoxConfig{{URL: "https://office.example"}}}},
|
||||||
|
{"invalid name", Config{NetBoxes: []NetBoxConfig{{Name: "a/b", URL: "https://office.example"}}}},
|
||||||
|
{"missing URL", Config{NetBoxes: []NetBoxConfig{{Name: "office"}}}},
|
||||||
|
{"invalid URL", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: "file:///tmp/netbox"}}}},
|
||||||
|
{"conflicting config", Config{NetBox: good, NetBoxes: []NetBoxConfig{good}}},
|
||||||
|
{"invalid token version", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: good.URL, TokenVersion: 3}}}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := tc.cfg.ValidateNetBoxes(); err == nil {
|
||||||
|
t.Fatal("invalid configuration accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
legacy := Config{NetBox: good, Cache: CacheConfig{Path: "hosts.json"}}
|
||||||
|
if err := legacy.ValidateNetBoxes(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !legacy.HasNetBox() || legacy.CachePath() != "hosts.json" {
|
||||||
|
t.Fatal("legacy behavior changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,14 @@ func WriteAliasFiles(entries []AliasEntry) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
||||||
return nil
|
alias := ""
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Host == "" {
|
||||||
|
alias = entry.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SyncCommandCompletion(alias)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeShAliasFile(entries []AliasEntry) error {
|
func writeShAliasFile(entries []AliasEntry) error {
|
||||||
|
|||||||
@@ -227,3 +227,18 @@ func TestCommandAliasGenerationAndCollision(t *testing.T) {
|
|||||||
t.Fatal(got)
|
t.Fatal(got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAliasesIncludeInstanceName(t *testing.T) {
|
||||||
|
cfg := config.Config{Shortcuts: config.ShortcutsConfig{Domains: []string{".example.com"}}}
|
||||||
|
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "office/web01.example.com"}, {Name: "lab/web01.example.com"}}, cfg)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("missing qualified aliases: %+v", entries)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
got[entry.Name] = entry.Host
|
||||||
|
}
|
||||||
|
if got["office_web01"] != "office/web01.example.com" || got["lab_web01"] != "lab/web01.example.com" {
|
||||||
|
t.Fatalf("alias lost source: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const completionMarker = "# netssh command alias completion — generated automatically"
|
||||||
|
|
||||||
|
// SyncCommandCompletion installs lazy loaders for the configured command alias.
|
||||||
|
// The loaders obtain the canonical completion from the current netssh binary,
|
||||||
|
// so aliases configured by the wizard work without a separate completion install.
|
||||||
|
func SyncCommandCompletion(alias string) error {
|
||||||
|
if err := config.ValidateCommandAlias(alias); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
statePath := filepath.Join(filepath.Dir(AliasesPath()), "completion_alias")
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
previous := strings.TrimSpace(string(data))
|
||||||
|
if err := config.ValidateCommandAlias(previous); err != nil {
|
||||||
|
return fmt.Errorf("invalid completion alias state: %w", err)
|
||||||
|
}
|
||||||
|
if alias == "" && previous == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
if previous != "" && previous != alias {
|
||||||
|
path := commandCompletionPath(shell, previous)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Only delete files owned by netssh; preserve any user replacements.
|
||||||
|
if strings.HasPrefix(string(data), completionMarker+"\n") || strings.HasPrefix(string(data), "#compdef "+previous+"\n"+completionMarker+"\n") {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alias == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shell == "fish" {
|
||||||
|
if _, err := os.Stat(FishConfigDir()); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := commandCompletionPath(shell, alias)
|
||||||
|
script := commandCompletionScript(shell, alias)
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(existing) == script {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := storage.WriteFile(path, []byte(script), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s alias completion: %w", shell, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alias == "" {
|
||||||
|
if err := os.Remove(statePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return storage.WriteFile(statePath, []byte(alias+"\n"), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandCompletionPath(shell, alias string) string {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
||||||
|
case "zsh":
|
||||||
|
return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias)
|
||||||
|
default:
|
||||||
|
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandCompletionScript(shell, alias string) string {
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
source <(netssh completion bash)
|
||||||
|
if [[ $(type -t compopt) == builtin ]]; then
|
||||||
|
complete -o default -F __start_netssh %s
|
||||||
|
else
|
||||||
|
complete -o default -o nospace -F __start_netssh %s
|
||||||
|
fi
|
||||||
|
`, completionMarker, alias, alias)
|
||||||
|
case "zsh":
|
||||||
|
return fmt.Sprintf(`#compdef %s
|
||||||
|
%s
|
||||||
|
source <(netssh completion zsh)
|
||||||
|
compdef _netssh %s
|
||||||
|
_netssh "$@"
|
||||||
|
`, alias, completionMarker, alias)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
netssh completion fish | source
|
||||||
|
complete --command %s --wraps netssh
|
||||||
|
`, completionMarker, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package hook_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func completionEnvironment(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
|
||||||
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
||||||
|
if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"),
|
||||||
|
"zsh": filepath.Join(home, ".zfunc", "_j"),
|
||||||
|
"fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandCompletionTracksAliasChanges(t *testing.T) {
|
||||||
|
paths := completionEnvironment(t)
|
||||||
|
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A manually replaced completion file must survive removal of the old alias.
|
||||||
|
custom := "# user-owned completion\n"
|
||||||
|
if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for shell, path := range paths {
|
||||||
|
if shell == "bash" {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(data) != custom {
|
||||||
|
t.Fatal("user completion was removed")
|
||||||
|
}
|
||||||
|
} else if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale %s completion remains: %v", shell, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"),
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"),
|
||||||
|
filepath.Join(hook.FishConfigDir(), "completions", "k.fish"),
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("disabled completion remains: %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
|
||||||
|
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||||
|
t.Run(shell, func(t *testing.T) {
|
||||||
|
executable, err := exec.LookPath(shell)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("%s unavailable", shell)
|
||||||
|
}
|
||||||
|
paths := completionEnvironment(t)
|
||||||
|
if err := hook.SyncCommandCompletion("j"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fixtures := t.TempDir()
|
||||||
|
root := &cobra.Command{Use: "netssh"}
|
||||||
|
var canonical bytes.Buffer
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
err = root.GenBashCompletionV2(&canonical, true)
|
||||||
|
case "zsh":
|
||||||
|
err = root.GenZshCompletion(&canonical)
|
||||||
|
case "fish":
|
||||||
|
err = root.GenFishCompletion(&canonical, true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Exercise Cobra's actual generated shell code with a deterministic backend.
|
||||||
|
backend := `#!/bin/bash
|
||||||
|
case "$1" in
|
||||||
|
completion) cat "$COMPLETION_FIXTURES/$2" ;;
|
||||||
|
__complete|__completeNoDesc) printf 'server01\n:4\n' ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("COMPLETION_FIXTURES", fixtures)
|
||||||
|
t.Setenv("ALIAS_COMPLETION", paths[shell])
|
||||||
|
t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
var script string
|
||||||
|
var args []string
|
||||||
|
switch shell {
|
||||||
|
case "bash":
|
||||||
|
library := ""
|
||||||
|
for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
library = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if library == "" {
|
||||||
|
t.Skip("bash-completion unavailable")
|
||||||
|
}
|
||||||
|
t.Setenv("BASH_COMPLETION_LIBRARY", library)
|
||||||
|
args = []string{"--noprofile", "--norc", "-c"}
|
||||||
|
script = `source "$BASH_COMPLETION_LIBRARY"
|
||||||
|
shopt -s expand_aliases
|
||||||
|
alias j=netssh
|
||||||
|
_completion_loader j
|
||||||
|
COMP_WORDS=(j ser)
|
||||||
|
COMP_CWORD=1
|
||||||
|
COMP_LINE='j ser'
|
||||||
|
COMP_POINT=5
|
||||||
|
COMP_TYPE=9
|
||||||
|
COMP_KEY=9
|
||||||
|
__start_netssh j ser j
|
||||||
|
printf '%s\n' "${COMPREPLY[@]}"
|
||||||
|
`
|
||||||
|
case "zsh":
|
||||||
|
args = []string{"-f", "-c"}
|
||||||
|
script = `fpath=("$HOME/.zfunc" $fpath)
|
||||||
|
autoload -Uz compinit
|
||||||
|
compinit -u -D
|
||||||
|
alias j=netssh
|
||||||
|
autoload -Uz _j
|
||||||
|
# Capture matches without requiring an interactive ZLE completion widget.
|
||||||
|
_describe() { print -rl -- "${completions[@]}"; }
|
||||||
|
words=(j ser)
|
||||||
|
CURRENT=2
|
||||||
|
_j
|
||||||
|
`
|
||||||
|
case "fish":
|
||||||
|
args = []string{"--no-config", "-c"}
|
||||||
|
script = `alias j netssh
|
||||||
|
set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions"
|
||||||
|
complete --do-complete 'j ser'
|
||||||
|
`
|
||||||
|
}
|
||||||
|
cmd := exec.Command(executable, append(args, script)...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(out), "server01") {
|
||||||
|
t.Fatalf("%s completion: %v\n%s", shell, err, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
sources map[string]*Client
|
||||||
baseURL string
|
baseURL string
|
||||||
token string
|
token string
|
||||||
tokenVersion int
|
tokenVersion int
|
||||||
@@ -38,6 +39,9 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
|||||||
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||||
// Use SearchOptions to restrict by kind or tag.
|
// Use SearchOptions to restrict by kind or tag.
|
||||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
return c.searchSources(ctx, query, opts, false)
|
||||||
|
}
|
||||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||||
}
|
}
|
||||||
@@ -95,6 +99,9 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
|
|||||||
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||||
// Intended for cache refresh; use Search for interactive queries.
|
// Intended for cache refresh; use Search for interactive queries.
|
||||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
return c.searchSources(ctx, query, opts, true)
|
||||||
|
}
|
||||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||||
}
|
}
|
||||||
@@ -146,6 +153,13 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
|||||||
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
||||||
// that need more than just the primary IP.
|
// that need more than just the primary IP.
|
||||||
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
source, host, err := c.sourceForHost(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return source.GetIPs(ctx, host)
|
||||||
|
}
|
||||||
var apiURL string
|
var apiURL string
|
||||||
switch entry.Kind {
|
switch entry.Kind {
|
||||||
case "device":
|
case "device":
|
||||||
@@ -170,6 +184,9 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error)
|
|||||||
|
|
||||||
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
||||||
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost")
|
||||||
|
}
|
||||||
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
||||||
var resp netboxIPListResponse
|
var resp netboxIPListResponse
|
||||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
@@ -351,22 +368,32 @@ func stripPrefix(cidr string) string {
|
|||||||
return cidr
|
return cidr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrHostNotFound distinguishes a missing exact match from API failures or ambiguity.
|
||||||
|
var ErrHostNotFound = errors.New("host not found")
|
||||||
|
|
||||||
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
|
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
|
||||||
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
|
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
|
||||||
if kind != "" && kind != "device" && kind != "vm" {
|
if kind != "" && kind != "device" && kind != "vm" {
|
||||||
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||||
}
|
}
|
||||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||||
|
_, hostname := SplitTarget(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HostEntry{}, err
|
return HostEntry{}, err
|
||||||
}
|
}
|
||||||
var matches []HostEntry
|
var matches []HostEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.EqualFold(e.Name, name) {
|
if strings.EqualFold(e.Name, hostname) {
|
||||||
matches = append(matches, e)
|
matches = append(matches, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
|
||||||
|
}
|
||||||
if len(matches) != 1 {
|
if len(matches) != 1 {
|
||||||
|
if c.sources != nil {
|
||||||
|
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use instance/hostname and --kind to select a unique host", name, len(matches))
|
||||||
|
}
|
||||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||||
}
|
}
|
||||||
return matches[0], nil
|
return matches[0], nil
|
||||||
@@ -389,6 +416,13 @@ func (c *Client) hostURL(host HostEntry) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
source, host, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return source.GetComments(ctx, host)
|
||||||
|
}
|
||||||
u, err := c.hostURL(host)
|
u, err := c.hostURL(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -401,6 +435,13 @@ func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||||
|
if c.sources != nil {
|
||||||
|
source, host, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return source.SetComments(ctx, host, comments, etag)
|
||||||
|
}
|
||||||
u, err := c.hostURL(host)
|
u, err := c.hostURL(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package netbox
|
package netbox
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// SearchOptions filters a Search or SearchAll query.
|
// SearchOptions filters a Search or SearchAll query.
|
||||||
// Zero value means no filtering (return all kinds, no tag filter).
|
// Zero value means no filtering (return all kinds, no tag filter).
|
||||||
type SearchOptions struct {
|
type SearchOptions struct {
|
||||||
@@ -9,6 +11,7 @@ type SearchOptions struct {
|
|||||||
|
|
||||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||||
type HostEntry struct {
|
type HostEntry struct {
|
||||||
|
Source string // named NetBox instance; empty for the legacy single-instance config
|
||||||
ID int
|
ID int
|
||||||
Name string
|
Name string
|
||||||
Kind string // "device" | "vm"
|
Kind string // "device" | "vm"
|
||||||
@@ -58,3 +61,19 @@ type netboxIPListResponse struct {
|
|||||||
} `json:"assigned_object"`
|
} `json:"assigned_object"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Target returns an unambiguous selector for cache, completion, and SSH.
|
||||||
|
func (h HostEntry) Target() string {
|
||||||
|
if h.Source == "" {
|
||||||
|
return h.Name
|
||||||
|
}
|
||||||
|
return h.Source + "/" + h.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitTarget separates an optional instance prefix from a hostname.
|
||||||
|
func SplitTarget(target string) (source, name string) {
|
||||||
|
if source, name, ok := strings.Cut(target, "/"); ok {
|
||||||
|
return strings.ToLower(source), name
|
||||||
|
}
|
||||||
|
return "", target
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source binds a name to its own API client and credentials.
|
||||||
|
type Source struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Token string
|
||||||
|
TokenVersion int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMultiClient(sources []Source) *Client {
|
||||||
|
c := &Client{sources: make(map[string]*Client)}
|
||||||
|
for _, source := range sources {
|
||||||
|
c.sources[strings.ToLower(source.Name)] = NewClient(source.URL, source.Token, source.TokenVersion)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) searchSources(ctx context.Context, query string, opts SearchOptions, all bool) ([]HostEntry, error) {
|
||||||
|
requested, query := SplitTarget(query)
|
||||||
|
if requested != "" {
|
||||||
|
if _, ok := c.sources[requested]; !ok {
|
||||||
|
return nil, fmt.Errorf("unknown NetBox instance %q", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var entries []HostEntry
|
||||||
|
var errs []error
|
||||||
|
started := 0
|
||||||
|
for name, client := range c.sources {
|
||||||
|
if requested != "" && name != requested {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
started++
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string, client *Client) {
|
||||||
|
defer wg.Done()
|
||||||
|
var found []HostEntry
|
||||||
|
var err error
|
||||||
|
if all {
|
||||||
|
found, err = client.SearchAll(ctx, query, opts)
|
||||||
|
} else {
|
||||||
|
found, err = client.Search(ctx, query, opts)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("NetBox %s: %w", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range found {
|
||||||
|
found[i].Source = name
|
||||||
|
}
|
||||||
|
entries = append(entries, found...)
|
||||||
|
}(name, client)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
// Full lookups must see every source to rule out ambiguous hostnames.
|
||||||
|
if len(errs) > 0 && (all || len(errs) == started) {
|
||||||
|
return nil, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||||
|
return entries, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sourceForHost(host HostEntry) (*Client, HostEntry, error) {
|
||||||
|
prefix, name := SplitTarget(host.Name)
|
||||||
|
if prefix != "" {
|
||||||
|
if host.Source != "" && !strings.EqualFold(prefix, host.Source) {
|
||||||
|
return nil, host, fmt.Errorf("conflicting NetBox instance for %q", host.Name)
|
||||||
|
}
|
||||||
|
host.Source, host.Name = prefix, name
|
||||||
|
}
|
||||||
|
if c.sources == nil {
|
||||||
|
return c, host, nil
|
||||||
|
}
|
||||||
|
source, ok := c.sources[strings.ToLower(host.Source)]
|
||||||
|
if !ok {
|
||||||
|
return nil, host, fmt.Errorf("specify a configured NetBox instance as instance/hostname (got %q)", host.Target())
|
||||||
|
}
|
||||||
|
host.Source = strings.ToLower(host.Source)
|
||||||
|
return source, host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostScope identifies a local note by its original NetBox URL and canonical
|
||||||
|
// hostname, so renaming an instance does not move or mix its notes.
|
||||||
|
func (c *Client) HostScope(host HostEntry) (HostEntry, string, error) {
|
||||||
|
source, host, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return host, "", err
|
||||||
|
}
|
||||||
|
return host, source.baseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetIPsWithFilterForHost(ctx context.Context, host HostEntry, filters string) ([]string, error) {
|
||||||
|
source, _, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return source.GetIPsWithFilter(ctx, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MultipleSources() bool { return c.sources != nil }
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Token "+token {
|
||||||
|
t.Errorf("credentials crossed sources: %q, want %q", got, token)
|
||||||
|
http.Error(w, "bad token", 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/dcim/devices/":
|
||||||
|
json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}}))
|
||||||
|
case "/api/virtualization/virtual-machines/":
|
||||||
|
json.NewEncoder(w).Encode(vmListResponse())
|
||||||
|
case "/api/ipam/ip-addresses/":
|
||||||
|
if r.URL.Query().Get("device_id") != "1" {
|
||||||
|
t.Errorf("wrong host ID: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(ipListResponse(ip + "/24"))
|
||||||
|
case "/api/dcim/devices/1/":
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
var data map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if data["comments"] != comment+" updated" {
|
||||||
|
t.Errorf("comment write crossed sources: %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"comments": comment})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourceSearchAndRouting(t *testing.T) {
|
||||||
|
office := sourceTestServer(t, "office-token", "10.0.0.1", "office note")
|
||||||
|
lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note")
|
||||||
|
c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}})
|
||||||
|
ctx := context.Background()
|
||||||
|
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||||
|
if err != nil || len(entries) != 2 {
|
||||||
|
t.Fatalf("combined search: %+v, %v", entries, err)
|
||||||
|
}
|
||||||
|
if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" {
|
||||||
|
t.Fatalf("source lost: %+v", entries)
|
||||||
|
}
|
||||||
|
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||||
|
t.Fatal("ambiguous unqualified hostname accepted")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
host, err := c.FindHost(ctx, tc.name+"/web", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if host.Source != tc.name || host.Name != "web" || host.ID != 1 {
|
||||||
|
t.Fatalf("wrong host identity: %+v", host)
|
||||||
|
}
|
||||||
|
ips, err := c.GetIPs(ctx, host)
|
||||||
|
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||||
|
t.Fatalf("wrong IP source: %v, %v", ips, err)
|
||||||
|
}
|
||||||
|
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0")
|
||||||
|
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||||
|
t.Fatalf("wrong interface source: %v, %v", ips, err)
|
||||||
|
}
|
||||||
|
text, _, err := c.GetComments(ctx, host)
|
||||||
|
if err != nil || text != tc.note {
|
||||||
|
t.Fatalf("wrong comment source: %q, %v", text, err)
|
||||||
|
}
|
||||||
|
// TUI cache items carry a qualified name rather than a separate source.
|
||||||
|
qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"}
|
||||||
|
if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil {
|
||||||
|
t.Fatal("unknown source accepted")
|
||||||
|
}
|
||||||
|
if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil {
|
||||||
|
t.Fatal("unqualified note namespace accepted")
|
||||||
|
}
|
||||||
|
if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil {
|
||||||
|
t.Fatal("IP filter without instance accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourceSearchRunsConcurrently(t *testing.T) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
makeServer := func() *httptest.Server {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wg.Done()
|
||||||
|
both := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(both) }()
|
||||||
|
select {
|
||||||
|
case <-both:
|
||||||
|
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
a, b := makeServer(), makeServer()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}})
|
||||||
|
if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil {
|
||||||
|
t.Fatalf("sources did not run concurrently: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourcePartialFailure(t *testing.T) {
|
||||||
|
good := sourceTestServer(t, "good-token", "10.0.0.1", "note")
|
||||||
|
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) }))
|
||||||
|
defer failed.Close()
|
||||||
|
c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}})
|
||||||
|
ctx := context.Background()
|
||||||
|
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||||
|
if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") {
|
||||||
|
t.Fatalf("partial search lost results or warning: %+v, %v", entries, err)
|
||||||
|
}
|
||||||
|
if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil {
|
||||||
|
t.Fatal("incomplete full search succeeded")
|
||||||
|
}
|
||||||
|
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||||
|
t.Fatal("incomplete search claimed unique host")
|
||||||
|
}
|
||||||
|
if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" {
|
||||||
|
t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) {
|
||||||
|
office := newTestServer(t, map[string]any{
|
||||||
|
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}),
|
||||||
|
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}),
|
||||||
|
})
|
||||||
|
defer office.Close()
|
||||||
|
lab := newTestServer(t, map[string]any{
|
||||||
|
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}),
|
||||||
|
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}),
|
||||||
|
})
|
||||||
|
defer lab.Close()
|
||||||
|
client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||||
|
entries, err := client.SearchAll(context.Background(), "", SearchOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"}
|
||||||
|
if len(entries) != len(want) {
|
||||||
|
t.Fatalf("independent inventories missing: %+v", entries)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if want[entry.Target()] != entry.Kind {
|
||||||
|
t.Fatalf("inventory crossed source or kind: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||||
|
if err != nil || len(vms) != 2 {
|
||||||
|
t.Fatalf("VM filter across instances: %+v, %v", vms, err)
|
||||||
|
}
|
||||||
|
for _, host := range vms {
|
||||||
|
if host.Kind != "vm" {
|
||||||
|
t.Fatalf("device leaked into VM filter: %+v", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm")
|
||||||
|
if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" {
|
||||||
|
t.Fatalf("wrong VM selected: %+v, %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-2
@@ -52,7 +52,11 @@ func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool)
|
|||||||
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||||
return note, err
|
return note, err
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(s.path(host))
|
path, err := s.localPath(host)
|
||||||
|
if err != nil {
|
||||||
|
return note, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return note, nil
|
return note, nil
|
||||||
}
|
}
|
||||||
@@ -67,5 +71,22 @@ func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
|||||||
}
|
}
|
||||||
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||||
}
|
}
|
||||||
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600)
|
path, err := s.localPath(note.Host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return storage.WriteFile(path, []byte(note.Text), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) localPath(host netbox.HostEntry) (string, error) {
|
||||||
|
if s.Client == nil {
|
||||||
|
return s.path(host), nil
|
||||||
|
}
|
||||||
|
host, namespace, err := s.Client.HostScope(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scoped := *s
|
||||||
|
scoped.Namespace = namespace
|
||||||
|
return scoped.path(host), nil
|
||||||
}
|
}
|
||||||
@@ -50,3 +50,35 @@ func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
|||||||
t.Fatal("expected read error")
|
t.Fatal("expected read error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocalNotesSeparateInstancesAndSurviveRename(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: "https://office.example"}, {Name: "lab", URL: "https://lab.example"}})
|
||||||
|
service := &Service{Dir: t.TempDir(), Client: client}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
note := Note{Host: netbox.HostEntry{Name: source + "/web", Kind: "device"}, Text: source + " private note"}
|
||||||
|
if err := service.Write(ctx, note, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
note, err := service.Read(ctx, netbox.HostEntry{Name: source + "/web", Kind: "device"}, false)
|
||||||
|
if err != nil || note.Text != source+" private note" {
|
||||||
|
t.Fatalf("notes crossed instances: %+v, %v", note, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.Client = netbox.NewMultiClient([]netbox.Source{{Name: "renamed", URL: "https://office.example"}})
|
||||||
|
note, err := service.Read(ctx, netbox.HostEntry{Name: "renamed/web", Kind: "device"}, false)
|
||||||
|
if err != nil || note.Text != "office private note" {
|
||||||
|
t.Fatalf("renaming source lost note: %+v, %v", note, err)
|
||||||
|
}
|
||||||
|
// This is also the namespace used before migrating a legacy single NetBox.
|
||||||
|
legacy := &Service{Dir: service.Dir, Namespace: "https://office.example"}
|
||||||
|
note, err = legacy.Read(ctx, netbox.HostEntry{Name: "web", Kind: "device"}, false)
|
||||||
|
if err != nil || note.Text != "office private note" {
|
||||||
|
t.Fatalf("legacy note namespace changed: %+v, %v", note, err)
|
||||||
|
}
|
||||||
|
if err := service.Write(ctx, Note{Host: netbox.HostEntry{Name: "web", Kind: "device"}}, false); err == nil {
|
||||||
|
t.Fatal("unqualified notes accepted for multiple sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVMResolversStayWithinTheirNetBoxInstance(t *testing.T) {
|
||||||
|
sources := make([]netbox.Source, 0, 2)
|
||||||
|
for i, name := range []string{"office", "lab"} {
|
||||||
|
ip := fmt.Sprintf("10.0.0.%d", i+1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("virtual_machine_id") != "7" {
|
||||||
|
t.Errorf("wrong VM filter: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Token "+name {
|
||||||
|
t.Error("VM lookup used another instance's credentials")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"count":1,"results":[{"address":%q}]}`, ip+"/24")
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
sources = append(sources, netbox.Source{Name: name, URL: srv.URL, Token: name})
|
||||||
|
}
|
||||||
|
client := netbox.NewMultiClient(sources)
|
||||||
|
management, err := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, source := range sources {
|
||||||
|
host := &netbox.HostEntry{Source: source.Name, Name: "vm", Kind: "vm", ID: 7}
|
||||||
|
for _, strategy := range []Strategy{management, &InterfaceNameStrategy{name: "mgmt0"}} {
|
||||||
|
ip, err := strategy.Resolve(context.Background(), host, client)
|
||||||
|
if want := fmt.Sprintf("10.0.0.%d", i+1); err != nil || ip != want {
|
||||||
|
t.Fatalf("%s VM resolved through wrong instance: %q, %v", strategy.Name(), ip, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE
|
|||||||
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, err := client.GetIPsWithFilter(ctx, filterParam)
|
ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configureInstances edits a copy; nothing is written until the whole wizard succeeds.
|
||||||
|
func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
||||||
|
sources := append([]config.NetBoxConfig(nil), cfg.NetBoxes...)
|
||||||
|
if len(sources) == 0 && cfg.NetBox.URL != "" {
|
||||||
|
source := cfg.NetBox
|
||||||
|
source.Name = "default"
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
action := "done"
|
||||||
|
var options []huh.Option[string]
|
||||||
|
for _, source := range sources {
|
||||||
|
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
|
||||||
|
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
|
||||||
|
}
|
||||||
|
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
|
||||||
|
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("NetBox instances").Description("All configured instances are searched together.").Options(options...).Value(&action))).Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if action == "done" {
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
|
if action == "add" {
|
||||||
|
source, err := editInstance(config.NetBoxConfig{}, sources, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, source)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
verb, name, _ := strings.Cut(action, ":")
|
||||||
|
for i, source := range sources {
|
||||||
|
if source.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if verb == "edit" {
|
||||||
|
edited, err := editInstance(source, sources, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources[i] = edited
|
||||||
|
} else {
|
||||||
|
remove := false
|
||||||
|
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
sources = append(sources[:i], sources[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editInstance(source config.NetBoxConfig, sources []config.NetBoxConfig, original string) (config.NetBoxConfig, error) {
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().Title("Instance name").Description("Used in selectors such as office/web01.").Value(&source.Name).Validate(func(name string) error {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
candidate := config.Config{NetBoxes: []config.NetBoxConfig{{Name: name, URL: "https://example.invalid"}}}
|
||||||
|
if err := candidate.ValidateNetBoxes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, existing := range sources {
|
||||||
|
if strings.EqualFold(existing.Name, name) && !strings.EqualFold(existing.Name, original) {
|
||||||
|
return fmt.Errorf("instance name %q is already used", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewInput().Title("NetBox URL").Placeholder("https://netbox.example.com").Value(&source.URL).Validate(func(value string) error {
|
||||||
|
return (&config.Config{NetBoxes: []config.NetBoxConfig{{Name: "instance", URL: strings.TrimSpace(value)}}}).ValidateNetBoxes()
|
||||||
|
}),
|
||||||
|
huh.NewInput().Title("NetBox API token").EchoMode(huh.EchoModePassword).Value(&source.Token).Validate(func(value string) error {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
).Title("NetBox connection")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return source, err
|
||||||
|
}
|
||||||
|
source.Name = strings.ToLower(strings.TrimSpace(source.Name))
|
||||||
|
source.URL = strings.TrimRight(strings.TrimSpace(source.URL), "/")
|
||||||
|
source.Token = strings.TrimSpace(source.Token)
|
||||||
|
source.TokenVersion = netbox.TokenVersion(source.Token)
|
||||||
|
if source.TokenVersion == 1 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Note: NetBox %s uses a legacy v1 token. A v2 token starts with nbt_.\n", source.Name)
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
+22
-42
@@ -8,20 +8,25 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"charm.land/huh/v2"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
||||||
// It saves the result to the config file on success.
|
// It saves the result to the config file on success.
|
||||||
func RunWizard(cfg *config.Config) error {
|
func RunWizard(cfg *config.Config) error {
|
||||||
url := cfg.NetBox.URL
|
sources, err := configureInstances(cfg)
|
||||||
token := cfg.NetBox.Token
|
if err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
defaultUser := cfg.SSH.DefaultUser
|
defaultUser := cfg.SSH.DefaultUser
|
||||||
defaultPort := ""
|
defaultPort := ""
|
||||||
if cfg.SSH.DefaultPort > 0 {
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
@@ -43,30 +48,6 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("NetBox URL").
|
|
||||||
Description("e.g. https://netbox.example.com").
|
|
||||||
Placeholder("https://").
|
|
||||||
Value(&url).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return errors.New("required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("NetBox API token").
|
|
||||||
EchoMode(huh.EchoModePassword).
|
|
||||||
Value(&token).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return errors.New("required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
).Title("NetBox connection"),
|
|
||||||
|
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Default SSH user").
|
Title("Default SSH user").
|
||||||
@@ -144,11 +125,6 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
strategies := parseStrategies(strategiesRaw)
|
strategies := parseStrategies(strategiesRaw)
|
||||||
tokenVersion := netbox.TokenVersion(token)
|
|
||||||
if tokenVersion == 1 {
|
|
||||||
fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.")
|
|
||||||
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl, _ := strconv.Atoi(cacheTTL)
|
ttl, _ := strconv.Atoi(cacheTTL)
|
||||||
|
|
||||||
@@ -176,11 +152,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
|
|
||||||
out := config.Config{
|
out := config.Config{
|
||||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||||
NetBox: config.NetBoxConfig{
|
NetBoxes: sources,
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
|
||||||
Token: strings.TrimSpace(token),
|
|
||||||
TokenVersion: tokenVersion,
|
|
||||||
},
|
|
||||||
SSH: config.SSHConfig{
|
SSH: config.SSHConfig{
|
||||||
DefaultUser: strings.TrimSpace(defaultUser),
|
DefaultUser: strings.TrimSpace(defaultUser),
|
||||||
DefaultPort: port,
|
DefaultPort: port,
|
||||||
@@ -207,7 +179,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL)
|
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return fmt.Errorf("updating aliases: %w", err)
|
return fmt.Errorf("updating aliases: %w", err)
|
||||||
}
|
}
|
||||||
@@ -266,16 +238,24 @@ func save(cfg config.Config) error {
|
|||||||
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
path := config.Path()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
if len(cfg.NetBoxes) > 0 {
|
||||||
|
b.WriteString("netboxes:\n")
|
||||||
|
for _, source := range cfg.NetBoxes {
|
||||||
|
fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
b.WriteString("netbox:\n")
|
b.WriteString("netbox:\n")
|
||||||
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
|
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
|
}
|
||||||
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
|
|
||||||
|
|
||||||
b.WriteString("\nresolver:\n")
|
b.WriteString("\nresolver:\n")
|
||||||
b.WriteString(" strategies:\n")
|
b.WriteString(" strategies:\n")
|
||||||
|
|||||||
@@ -386,3 +386,37 @@ func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
|||||||
t.Fatalf("disable: %+v, %v", loaded, err)
|
t.Fatalf("disable: %+v, %v", loaded, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBoxes: []config.NetBoxConfig{
|
||||||
|
{Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2},
|
||||||
|
{Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1},
|
||||||
|
},
|
||||||
|
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||||
|
Cache: config.CacheConfig{TTL: 3600},
|
||||||
|
}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" {
|
||||||
|
t.Fatalf("instances lost during save: %+v", loaded)
|
||||||
|
}
|
||||||
|
for i, want := range cfg.NetBoxes {
|
||||||
|
if loaded.NetBoxes[i] != want {
|
||||||
|
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(config.Path())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatal("credentials are not private")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,5 +38,5 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
|
|||||||
// replaces any remaining dots with underscores so the result is a valid identifier.
|
// replaces any remaining dots with underscores so the result is a valid identifier.
|
||||||
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := Normalize(name, cfg)
|
s := Normalize(name, cfg)
|
||||||
return strings.ReplaceAll(s, ".", "_")
|
return strings.NewReplacer(".", "_", "/", "_").Replace(s)
|
||||||
}
|
}
|
||||||
+39
-35
@@ -4,7 +4,7 @@ import "strings"
|
|||||||
|
|
||||||
// flagsWithArg lists all SSH flags that consume the following argument.
|
// flagsWithArg lists all SSH flags that consume the following argument.
|
||||||
var flagsWithArg = map[byte]bool{
|
var flagsWithArg = map[byte]bool{
|
||||||
'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
|
'B': true, 'P': true, 'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
|
||||||
'F': true, 'I': true, 'i': true, 'J': true, 'L': true,
|
'F': true, 'I': true, 'i': true, 'J': true, 'L': true,
|
||||||
'l': true, 'm': true, 'o': true, 'O': true, 'p': true,
|
'l': true, 'm': true, 'o': true, 'O': true, 'p': true,
|
||||||
'Q': true, 'R': true, 'S': true, 'w': true, 'W': true,
|
'Q': true, 'R': true, 'S': true, 'w': true, 'W': true,
|
||||||
@@ -32,18 +32,16 @@ func Parse(args []string) *ParsedArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
||||||
flag := arg[1]
|
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
|
||||||
if flagsWithArg[flag] {
|
for j := 1; j < len(arg); j++ {
|
||||||
if len(arg) > 2 {
|
if flagsWithArg[arg[j]] {
|
||||||
// argument is attached, e.g. -p2222
|
if j == len(arg)-1 {
|
||||||
i++
|
|
||||||
} else {
|
|
||||||
// argument is the next element, e.g. -p 2222
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,37 +84,43 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPortFlag reports whether a port was specified via -p in args.
|
// HasPortFlag reports whether a port was explicitly supplied.
|
||||||
func HasPortFlag(args []string) bool {
|
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
|
||||||
if parsed := Parse(args); parsed != nil {
|
|
||||||
args = args[:parsed.DestIdx]
|
|
||||||
}
|
|
||||||
for i, a := range args {
|
|
||||||
if a == "-p" && i+1 < len(args) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(a) > 2 && a[0] == '-' && a[1] == 'p' {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasUserFlag reports whether a user was specified via -l in args.
|
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||||
// Used to avoid overriding an explicit -l with the configured default user.
|
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
|
||||||
func HasUserFlag(args []string) bool {
|
|
||||||
if parsed := Parse(args); parsed != nil {
|
func hasOption(args []string, wanted byte, keyword string) bool {
|
||||||
args = args[:parsed.DestIdx]
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
for i, a := range args {
|
for j := 1; j < len(arg); j++ {
|
||||||
if a == "-l" && i+1 < len(args) {
|
flag := arg[j]
|
||||||
|
if !flagsWithArg[flag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := arg[j+1:]
|
||||||
|
if value == "" {
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value = args[i]
|
||||||
|
}
|
||||||
|
if flag == wanted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// handle attached form: -lroot
|
if flag == 'o' {
|
||||||
if len(a) > 2 && a[0] == '-' && a[1] == 'l' {
|
fields := strings.FieldsFunc(value, func(r rune) bool { return r == '=' || r == ' ' || r == '\t' })
|
||||||
|
if len(fields) >= 2 && strings.EqualFold(fields[0], keyword) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseOptionClusters(t *testing.T) {
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"-vp", "2222", "server"},
|
||||||
|
{"-vp2222", "server"},
|
||||||
|
{"-vAl", "admin", "server"},
|
||||||
|
{"-vladmin", "server"},
|
||||||
|
{"-B", "eth0", "server"},
|
||||||
|
{"-P", "production", "server"},
|
||||||
|
} {
|
||||||
|
t.Run(args[0], func(t *testing.T) {
|
||||||
|
assertParsed(t, Parse(args), "server", "", len(args)-1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitSSHOptions(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
user, port bool
|
||||||
|
}{
|
||||||
|
{"cluster port separated", []string{"-vp", "2222", "server"}, false, true},
|
||||||
|
{"cluster user attached", []string{"-vladmin", "server"}, true, false},
|
||||||
|
{"o separated", []string{"-o", "User=admin", "-o", "Port=2222", "server"}, true, true},
|
||||||
|
{"o attached and case insensitive", []string{"-ouSeR=admin", "-oport=2222", "server"}, true, true},
|
||||||
|
{"o whitespace", []string{"-o", " User = admin", "-o", "Port\t2222", "server"}, true, true},
|
||||||
|
{"cluster o", []string{"-voUser=admin", "server"}, true, false},
|
||||||
|
{"option value looks like flag", []string{"-i", "-lroot", "-F", "-p2222", "server"}, false, false},
|
||||||
|
{"remote flags", []string{"server", "-p2222", "-o", "User=admin"}, false, false},
|
||||||
|
{"after double dash", []string{"--", "-lroot", "-p2222"}, false, false},
|
||||||
|
{"missing value", []string{"-vp"}, false, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := HasUserFlag(tc.args); got != tc.user {
|
||||||
|
t.Errorf("HasUserFlag(%v) = %v, want %v", tc.args, got, tc.user)
|
||||||
|
}
|
||||||
|
if got := HasPortFlag(tc.args); got != tc.port {
|
||||||
|
t.Errorf("HasPortFlag(%v) = %v, want %v", tc.args, got, tc.port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tuiSourceServer(t *testing.T, fail bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if fail {
|
||||||
|
http.Error(w, "offline", 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/api/dcim/devices/" {
|
||||||
|
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"web","primary_ip4":{"address":"10.0.0.1/24"}}]}`)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombinedSearchDoesNotStopAtFirstCachedInstance(t *testing.T) {
|
||||||
|
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, false)
|
||||||
|
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||||
|
c := cache.New("", 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "office/web", Kind: "device", IP: "10.0.0.9"})
|
||||||
|
m := New(client, c, "")
|
||||||
|
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||||
|
if msg.err != nil || len(msg.entries) != 2 {
|
||||||
|
t.Fatalf("cache hid another instance: %+v, %v", msg.entries, msg.err)
|
||||||
|
}
|
||||||
|
m.input.SetValue("web")
|
||||||
|
m.seq = 1
|
||||||
|
m.Update(msg)
|
||||||
|
if len(m.list.Items()) != 2 {
|
||||||
|
t.Fatal("combined results not displayed")
|
||||||
|
}
|
||||||
|
for _, item := range m.list.Items() {
|
||||||
|
if !strings.Contains(item.(hostItem).name, "/") {
|
||||||
|
t.Fatal("instance not visible in results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.list.Select(0)
|
||||||
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||||
|
if m.selected == nil || m.selected.Name != "lab/web" {
|
||||||
|
t.Fatalf("selection lost source: %+v", m.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialOutageKeepsLiveAndCachedResultsVisible(t *testing.T) {
|
||||||
|
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, true)
|
||||||
|
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||||
|
c := cache.New("", 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "lab/web", Kind: "device", IP: "10.0.0.2"})
|
||||||
|
m := New(client, c, "")
|
||||||
|
m.input.SetValue("web")
|
||||||
|
m.seq = 1
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||||
|
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||||
|
if len(msg.entries) != 2 || msg.err == nil {
|
||||||
|
t.Fatalf("partial outage lost cache or warning: %+v, %v", msg.entries, msg.err)
|
||||||
|
}
|
||||||
|
m.Update(msg)
|
||||||
|
view := m.View().Content
|
||||||
|
if !strings.Contains(view, "warning:") || !strings.Contains(view, "lab/web") || !strings.Contains(view, "office/web") {
|
||||||
|
t.Fatalf("partial results hidden: %s", view)
|
||||||
|
}
|
||||||
|
if rows := strings.Count(view, "\n"); rows > 24 {
|
||||||
|
t.Fatalf("outage warning overflowed view: %d", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-36
@@ -4,19 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
"charm.land/bubbles/v2/list"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"charm.land/bubbles/v2/textarea"
|
||||||
"github.com/charmbracelet/bubbles/textarea"
|
"charm.land/bubbles/v2/textinput"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
tea "charm.land/bubbletea/v2"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||||
@@ -78,7 +79,7 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
|
|||||||
if index == m.Index() {
|
if index == m.Index() {
|
||||||
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
|
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w, line)
|
fmt.Fprint(w, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- filter ---
|
// --- filter ---
|
||||||
@@ -221,6 +222,7 @@ func (m *Model) loadRecent() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
defer m.resizeList()
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case noteResultMsg:
|
case noteResultMsg:
|
||||||
m.noteBusy = false
|
m.noteBusy = false
|
||||||
@@ -240,18 +242,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
extraRows := 4
|
m.input.SetWidth(max(1, msg.Width-lipgloss.Width(m.input.Prompt)))
|
||||||
if m.filterOpen {
|
m.filterInput.SetWidth(max(1, msg.Width-lipgloss.Width(m.filterInput.Prompt)))
|
||||||
extraRows++
|
m.editInput.SetWidth(max(1, msg.Width-lipgloss.Width("Connect as: ")))
|
||||||
}
|
|
||||||
if m.editMode {
|
|
||||||
extraRows += 2
|
|
||||||
}
|
|
||||||
m.list.SetSize(msg.Width, max(1, msg.Height-extraRows))
|
|
||||||
m.noteInput.SetWidth(max(10, msg.Width-4))
|
m.noteInput.SetWidth(max(10, msg.Width-4))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
if msg.String() == "ctrl+c" {
|
if msg.String() == "ctrl+c" {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -416,21 +413,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil // discard stale result
|
return m, nil // discard stale result
|
||||||
}
|
}
|
||||||
m.loading = false
|
m.loading = false
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.lastResults = msg.entries
|
m.lastResults = msg.entries
|
||||||
m.updateListItems(msg.entries)
|
m.updateListItems(msg.entries)
|
||||||
m.err = nil
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.noteMode {
|
if m.noteMode {
|
||||||
|
if m.noteBusy {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
// Paste and cursor events are separate from key presses in Bubble Tea v2.
|
||||||
|
// Route them to the focused editor as well.
|
||||||
|
if m.editMode {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.editInput, cmd = m.editInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
if m.filterOpen {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||||
|
m.updateListItems(m.lastResults)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
// Forward to search input and restart debounce timer.
|
// Forward to search input and restart debounce timer.
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
@@ -449,7 +458,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) View() string {
|
func (m *Model) View() tea.View {
|
||||||
|
view := tea.NewView(m.viewContent())
|
||||||
|
view.AltScreen = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) viewContent() string {
|
||||||
if m.noteMode {
|
if m.noteMode {
|
||||||
target := "local"
|
target := "local"
|
||||||
if m.noteRemote {
|
if m.noteRemote {
|
||||||
@@ -477,9 +492,15 @@ func (m *Model) View() string {
|
|||||||
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
||||||
} else if m.err != nil {
|
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
|
||||||
} else {
|
} else {
|
||||||
|
if m.err != nil {
|
||||||
|
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
|
||||||
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||||
|
if m.width > 0 {
|
||||||
|
style = style.MaxWidth(m.width)
|
||||||
|
}
|
||||||
|
sb.WriteString(style.Render(warning) + "\n")
|
||||||
|
}
|
||||||
if m.recentMode && m.input.Value() == "" {
|
if m.recentMode && m.input.Value() == "" {
|
||||||
if len(m.list.Items()) > 0 {
|
if len(m.list.Items()) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||||
@@ -516,25 +537,30 @@ func (m *Model) startDebounce() tea.Cmd {
|
|||||||
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||||
opts := m.netboxSearchOpts()
|
opts := m.netboxSearchOpts()
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Return cache hits immediately without a network round-trip.
|
var cached []netbox.HostEntry
|
||||||
if m.cache != nil {
|
if m.cache != nil {
|
||||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
for _, entry := range m.cache.Search(query) {
|
||||||
entries := make([]netbox.HostEntry, len(cached))
|
source, name := netbox.SplitTarget(entry.Name)
|
||||||
for i, c := range cached {
|
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
||||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
|
||||||
}
|
|
||||||
return searchResultMsg{query: query, seq: seq, entries: entries}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
||||||
if m.client == nil {
|
return searchResultMsg{query: query, seq: seq, entries: cached}
|
||||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := m.client.Search(ctx, query, opts)
|
entries, err := m.client.Search(ctx, query, opts)
|
||||||
|
// A cached hit in one instance must not hide live hosts in other instances.
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
seen[e.Target()+"\x00"+e.Kind] = true
|
||||||
|
}
|
||||||
|
for _, e := range cached {
|
||||||
|
if !seen[e.Target()+"\x00"+e.Kind] {
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,9 +585,34 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = e.PrimaryIP6
|
ip = e.PrimaryIP6
|
||||||
}
|
}
|
||||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
|
||||||
}
|
}
|
||||||
m.list.SetItems(items)
|
m.list.SetItems(items)
|
||||||
|
m.resizeList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeList keeps the footer visible when the recent heading or editors change.
|
||||||
|
func (m *Model) resizeList() {
|
||||||
|
if m.width <= 0 || m.height <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
extraRows := 5 // title, blank line, search, footer separator, footer
|
||||||
|
if m.err != nil {
|
||||||
|
extraRows++
|
||||||
|
}
|
||||||
|
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||||
|
extraRows++
|
||||||
|
}
|
||||||
|
if m.filterOpen {
|
||||||
|
extraRows++
|
||||||
|
}
|
||||||
|
if m.editMode {
|
||||||
|
extraRows++
|
||||||
|
}
|
||||||
|
height := max(1, m.height-extraRows)
|
||||||
|
if m.list.Width() != m.width || m.list.Height() != height {
|
||||||
|
m.list.SetSize(m.width, height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// openEdit switches to edit mode for the given list item.
|
// openEdit switches to edit mode for the given list item.
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"testing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
||||||
m := New(nil, nil, "")
|
m := New(nil, nil, "")
|
||||||
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
||||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
|
||||||
if m.editMode || m.input.Value() != "e" {
|
if m.editMode || m.input.Value() != "e" {
|
||||||
t.Fatal("e intercepted")
|
t.Fatal("e intercepted")
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ func TestFailedNoteLoadCannotBeSaved(t *testing.T) {
|
|||||||
m := New(nil, nil, "")
|
m := New(nil, nil, "")
|
||||||
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
||||||
m.noteMode = true
|
m.noteMode = true
|
||||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
_, cmd := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
t.Fatal("save allowed before successful load")
|
t.Fatal("save allowed before successful load")
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,7 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
|||||||
first := hostItem{name: "first", ip: "10.0.0.1"}
|
first := hostItem{name: "first", ip: "10.0.0.1"}
|
||||||
m.openEdit(first)
|
m.openEdit(first)
|
||||||
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
||||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||||
if m.selected == nil || m.selected.Name != "first" {
|
if m.selected == nil || m.selected.Name != "first" {
|
||||||
t.Fatalf("edited host changed: %+v", m.selected)
|
t.Fatalf("edited host changed: %+v", m.selected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestV2PasteFollowsFocusedEditor(t *testing.T) {
|
||||||
|
t.Run("search", func(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
_, cmd := m.Update(tea.PasteMsg{Content: "web-ä"})
|
||||||
|
if m.input.Value() != "web-ä" || m.seq != 1 || cmd == nil {
|
||||||
|
t.Fatalf("paste did not start search: %q, seq %d", m.input.Value(), m.seq)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("connection editor", func(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.openEdit(hostItem{name: "web", ip: "10.0.0.1"})
|
||||||
|
m.editInput.SetValue("")
|
||||||
|
m.Update(tea.PasteMsg{Content: "ops@web:2222"})
|
||||||
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||||
|
if m.selected == nil || m.selected.User != "ops" || m.selected.Port != "2222" {
|
||||||
|
t.Fatalf("paste lost in editor: %+v", m.selected)
|
||||||
|
}
|
||||||
|
if m.input.Value() != "" {
|
||||||
|
t.Fatal("editor paste changed search")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("filter", func(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.lastResults = []netbox.HostEntry{{Name: "vm", Kind: "vm"}, {Name: "device", Kind: "device"}}
|
||||||
|
m.updateListItems(m.lastResults)
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||||
|
m.Update(tea.PasteMsg{Content: "kind:vm"})
|
||||||
|
if m.filterInput.Value() != "kind:vm" || len(m.list.Items()) != 1 {
|
||||||
|
t.Fatalf("paste did not filter: %q, %d results", m.filterInput.Value(), len(m.list.Items()))
|
||||||
|
}
|
||||||
|
if m.input.Value() != "" {
|
||||||
|
t.Fatal("filter paste changed search")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("notes", func(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.noteMode = true
|
||||||
|
m.input.Blur()
|
||||||
|
m.noteInput.Focus()
|
||||||
|
m.Update(tea.PasteMsg{Content: "First line\nSecond line"})
|
||||||
|
if m.noteInput.Value() != "First line\nSecond line" {
|
||||||
|
t.Fatalf("multiline paste lost: %q", m.noteInput.Value())
|
||||||
|
}
|
||||||
|
m.noteBusy = true
|
||||||
|
m.Update(tea.PasteMsg{Content: "must not change busy note"})
|
||||||
|
if m.noteInput.Value() != "First line\nSecond line" {
|
||||||
|
t.Fatal("paste edited note while loading/saving")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2ViewKeepsAlternateScreenInEachMode(t *testing.T) {
|
||||||
|
for _, mode := range []string{"search", "edit", "notes"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||||
|
if mode == "edit" {
|
||||||
|
m.openEdit(hostItem{name: "web"})
|
||||||
|
}
|
||||||
|
if mode == "notes" {
|
||||||
|
m.noteMode = true
|
||||||
|
}
|
||||||
|
view := m.View()
|
||||||
|
if !view.AltScreen || strings.TrimSpace(view.Content) == "" {
|
||||||
|
t.Fatalf("invalid %s view: %+v", mode, view)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2KeyReleaseDoesNotTypeTwice(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
|
||||||
|
m.Update(tea.KeyReleaseMsg{Code: 'e', Text: "e"})
|
||||||
|
if m.input.Value() != "e" {
|
||||||
|
t.Fatalf("key release changed input: %q", m.input.Value())
|
||||||
|
}
|
||||||
|
m.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
|
||||||
|
if m.input.Value() != "e " {
|
||||||
|
t.Fatalf("space not inserted: %q", m.input.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2ResizeShowsFullSearchPromptAndFooter(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||||
|
var entries []netbox.HostEntry
|
||||||
|
for range 30 {
|
||||||
|
entries = append(entries, netbox.HostEntry{Name: "web", Kind: "device"})
|
||||||
|
}
|
||||||
|
m.updateListItems(entries)
|
||||||
|
view := m.View()
|
||||||
|
if m.input.Width() != 98 || m.filterInput.Width() != 92 || m.editInput.Width() != 88 {
|
||||||
|
t.Fatalf("editors were not sized for terminal: search=%d filter=%d edit=%d", m.input.Width(), m.filterInput.Width(), m.editInput.Width())
|
||||||
|
}
|
||||||
|
if !strings.Contains(view.Content, "earch hostname") || !strings.Contains(view.Content, "ctrl+c quit") {
|
||||||
|
t.Fatalf("search prompt or footer missing: %s", view.Content)
|
||||||
|
}
|
||||||
|
assertFits := func() {
|
||||||
|
t.Helper()
|
||||||
|
if rows := strings.Count(m.View().Content, "\n"); rows > 24 {
|
||||||
|
t.Fatalf("view overflows terminal: %d rows", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertFits()
|
||||||
|
m.lastResults = entries
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||||
|
assertFits()
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})
|
||||||
|
assertFits()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user