From b58cf27add7cef6458a6a60cc246e8d6d0bf7642 Mon Sep 17 00:00:00 2001 From: jabbott-iii Date: Sat, 19 Sep 2026 17:54:18 -0700 Subject: [PATCH 1/3] stash --- frontend/package-lock.json | 85 -------------------------------------- 1 file changed, 85 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9793127..e494504 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -311,9 +311,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -330,9 +327,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -349,9 +343,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -368,9 +359,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -387,9 +375,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -406,9 +391,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -425,9 +407,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -444,9 +423,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -463,9 +439,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -488,9 +461,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -513,9 +483,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -538,9 +505,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -563,9 +527,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -588,9 +549,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -613,9 +571,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -638,9 +593,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -902,9 +854,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -921,9 +870,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -940,9 +886,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -959,9 +902,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1723,9 +1663,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1740,9 +1677,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1757,9 +1691,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1774,9 +1705,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1791,9 +1719,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1808,9 +1733,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1825,9 +1747,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1842,9 +1761,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5385,7 +5301,6 @@ "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, "funding": [ { "type": "opencollective", From a18f20224ec34ee14157dd3deed41bdc7eef05cd Mon Sep 17 00:00:00 2001 From: jabbott-iii Date: Sat, 19 Sep 2026 21:57:00 -0700 Subject: [PATCH 2/3] feat: make local kubeconfig auto-discovery explicit, countable, and opt-out --- SECURITY.md | 1 + cmd/infracanvas/cmd/serve.go | 29 +++-- cmd/infracanvas/cmd/start.go | 39 ++++-- frontend/app/page.tsx | 89 ++++++++++++- frontend/types/index.ts | 6 + internal/models/snapshot.go | 18 ++- pkg/actions/executor.go | 8 +- pkg/actions/kubernetes.go | 46 ++----- pkg/agent/ws_agent.go | 16 ++- pkg/discovery/kubernetes/discovery.go | 72 +++++------ pkg/discovery/kubernetes/resolver.go | 85 +++++++++++++ pkg/discovery/kubernetes/resolver_test.go | 148 ++++++++++++++++++++++ pkg/orchestrator/orchestrator.go | 41 ++++-- pkg/output/formatter_test.go | 34 +++++ pkg/output/graph.go | 20 ++- 15 files changed, 524 insertions(+), 128 deletions(-) create mode 100644 pkg/discovery/kubernetes/resolver.go create mode 100644 pkg/discovery/kubernetes/resolver_test.go diff --git a/SECURITY.md b/SECURITY.md index 03a1bd1..91b6c2c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,6 +54,7 @@ Treat the URL+token like an SSH key for the box. - **No persistent storage of graph state**: graphs are recomputed every refresh; the relay does not persist snapshots between restarts. The one exception is Clusters (see below), which by design persists a credential locally so the connection survives a restart. - **Pair codes**: generated by the agent for legacy shared-relay deployments. In the default local serve-mode the browser auto-pairs from loopback and pair codes are unused. - **Clusters (kubeconfig direct-connect)**: an uploaded kubeconfig is written to `/clusters/.kubeconfig` at `0600`, readable only by the user running `infracanvas serve`, and is **never transmitted anywhere**: it's read directly by the same process serving your dashboard. Deleting a cluster from the UI deletes this file. Anyone with shell access to the box as that user (or root) can read it, same as they already could read `~/.kube/config` or any other credential on the machine; this introduces no new privilege beyond what local shell access already grants. +- **Local kubeconfig auto-discovery**: by default, the host agent checks in-cluster config first, then local kubeconfig (`$KUBECONFIG` or `~/.kube/config`) and can auto-connect the current context. Disable this path with `--discover-local-kubeconfig=false` or `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false`. This does not disable Docker discovery, and it does not affect explicit Clusters connections added from the UI. - **Read-only mode.** `--read-only` blocks every mutating action, terminal/exec session, and (as of v0.18.1) cluster connect/disconnect, at the relay before it reaches the agent or any credential-executing code path. Viewing topology and logs still works. Use this for public demos. - **Per-cluster read-only (v0.19.0+).** Independent of the global `--read-only` flag, each Clusters connection has its own read-only toggle (set it when connecting, or flip it later), so you can run some clusters view-only while others stay fully interactive on the same dashboard. Enforced at the same relay layer as global read-only mode. - **Permission preview (v0.19.0+).** Before you commit to connecting a kubeconfig, `POST /api/clusters/preview` (surfaced in the Add Cluster dialog) runs a handful of `SelfSubjectAccessReview` checks against it (a standard Kubernetes API any authenticated credential can call about its own permissions) and shows what it can actually do (view, exec, restart, scale, read Secrets) before anything is persisted or connected. diff --git a/cmd/infracanvas/cmd/serve.go b/cmd/infracanvas/cmd/serve.go index e28f3d1..dec523e 100644 --- a/cmd/infracanvas/cmd/serve.go +++ b/cmd/infracanvas/cmd/serve.go @@ -16,7 +16,6 @@ import ( "syscall" "time" - "github.com/spf13/cobra" "infracanvas/pkg/agent" "infracanvas/pkg/audit" "infracanvas/pkg/clustermgr" @@ -24,6 +23,8 @@ import ( "infracanvas/pkg/server" "infracanvas/pkg/tunnel" "infracanvas/pkg/webui" + + "github.com/spf13/cobra" ) const ( @@ -32,14 +33,15 @@ const ( ) var ( - servePort int - servePrivate bool - serveNoTunnel bool - serveReadOnly bool - serveUIToken string - serveAgentToken string - serveScope []string - serveRefresh int + servePort int + servePrivate bool + serveNoTunnel bool + serveReadOnly bool + serveUIToken string + serveAgentToken string + serveScope []string + serveRefresh int + serveDiscoverLocalKubeconfig bool ) var serveCmd = &cobra.Command{ @@ -65,7 +67,9 @@ and it appears in the sidebar's machine list. Environment variables: INFRACANVAS_UI_TOKEN Auth token (default: random per run) INFRACANVAS_AGENT_TOKEN Join token other VMs use (default: random per run) - INFRACANVAS_SCOPE Discovery scopes (default: host,docker,kubernetes)`, + INFRACANVAS_SCOPE Discovery scopes (default: host,docker,kubernetes) + INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG + Override --discover-local-kubeconfig`, RunE: runServe, } @@ -79,6 +83,7 @@ func init() { serveCmd.Flags().StringVar(&serveAgentToken, "agent-token", "", "Override the join token other VMs use to connect") serveCmd.Flags().StringSliceVar(&serveScope, "discover", []string{"host", "docker", "lxd", "kubernetes"}, "Discovery scopes") serveCmd.Flags().IntVar(&serveRefresh, "refresh", 30, "Seconds between discovery refreshes") + serveCmd.Flags().BoolVar(&serveDiscoverLocalKubeconfig, "discover-local-kubeconfig", true, "Auto-discover local kubeconfig contexts when in-cluster config is unavailable") } func runServe(cmd *cobra.Command, args []string) error { @@ -217,6 +222,10 @@ func runServe(cmd *cobra.Command, args []string) error { cfg.RefreshSeconds = serveRefresh cfg.EnableRedaction = true cfg.QuietPairBanner = true + cfg.AutoDiscoverLocalKubeconfig = serveDiscoverLocalKubeconfig + if v, ok := parseBoolEnv("INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG"); ok { + cfg.AutoDiscoverLocalKubeconfig = v + } for { if ctx.Err() != nil { diff --git a/cmd/infracanvas/cmd/start.go b/cmd/infracanvas/cmd/start.go index 3a93b46..bd4d88d 100644 --- a/cmd/infracanvas/cmd/start.go +++ b/cmd/infracanvas/cmd/start.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "os" + "strconv" - "github.com/spf13/cobra" "infracanvas/pkg/agent" "infracanvas/pkg/discovery/lxd" + + "github.com/spf13/cobra" ) // containsStr reports whether s is present in xs. @@ -21,11 +23,12 @@ func containsStr(xs []string, s string) bool { } var ( - backendURL string - agentToken string - refreshSeconds int - tlsInsecure bool - noRedact bool + backendURL string + agentToken string + refreshSeconds int + tlsInsecure bool + noRedact bool + startDiscoverLocalKubeconfig bool ) var startCmd = &cobra.Command{ @@ -43,9 +46,10 @@ Run this on the machine you want to observe: Then open the canvas UI and enter the displayed pair code. Environment variables: - INFRACANVAS_BACKEND Override --backend - INFRACANVAS_SCOPE Override --scope (comma-separated) - INFRACANVAS_TOKEN Override --token (shared secret)`, + INFRACANVAS_BACKEND Override --backend + INFRACANVAS_SCOPE Override --scope (comma-separated) + INFRACANVAS_TOKEN Override --token (shared secret) + INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG Override --discover-local-kubeconfig`, RunE: runStart, } @@ -57,6 +61,7 @@ func init() { startCmd.Flags().IntVar(&refreshSeconds, "refresh", 30, "Seconds between refresh cycles") startCmd.Flags().BoolVar(&tlsInsecure, "tls-insecure", false, "Skip TLS certificate verification") startCmd.Flags().BoolVar(&noRedact, "no-redact", false, "Disable sensitive data redaction") + startCmd.Flags().BoolVar(&startDiscoverLocalKubeconfig, "discover-local-kubeconfig", true, "Auto-discover local kubeconfig contexts when in-cluster config is unavailable") } func runStart(cmd *cobra.Command, args []string) error { @@ -83,6 +88,10 @@ func runStart(cmd *cobra.Command, args []string) error { if envScope := os.Getenv("INFRACANVAS_SCOPE"); envScope != "" { cfg.Scope = splitComma(envScope) } + cfg.AutoDiscoverLocalKubeconfig = startDiscoverLocalKubeconfig + if v, ok := parseBoolEnv("INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG"); ok { + cfg.AutoDiscoverLocalKubeconfig = v + } // Zero-config LXC/LXD/Incus: the install script pins a fixed scope // (host,docker,kubernetes), so auto-append "lxd" when a local LXD/Incus @@ -131,3 +140,15 @@ func splitOn(s string, sep rune) []string { parts = append(parts, s[start:]) return parts } + +func parseBoolEnv(key string) (bool, bool) { + v := os.Getenv(key) + if v == "" { + return false, false + } + parsed, err := strconv.ParseBool(v) + if err != nil { + return false, false + } + return parsed, true +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 097aba2..329e0d7 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -13,6 +13,7 @@ import type { SessionInfo } from '@/types' const LOCAL_KEY = 'local' const SESSIONS_POLL_MS = 10_000 +const LOCAL_KUBECONFIG_NOTICE_KEY = 'ic-local-kubeconfig-autodiscovery-notice-v1' // The websocket/store key for a machine: the hub's own agent uses the // 'local' alias, remote agents their session id. @@ -644,6 +645,76 @@ function Sidebar({ vm, view, onViewChange, machines, clusters, activeKey, onSele function MainContent({ vm, vmKey, view, onSwitchToCanvas }: { vm: any; vmKey: string; view: View; onSwitchToCanvas: () => void }) { const isLoading = !vm || vm.status === 'connecting' || vm.status === 'paired' const isError = vm?.status === 'error' + const localKubeconfigAuto = vm?.graph?.snapshot?.localKubeconfigAutoDiscovery + const [showLocalKubeconfigNotice, setShowLocalKubeconfigNotice] = useState(false) + const [connectedLocalContexts, setConnectedLocalContexts] = useState(0) + + useEffect(() => { + const shouldShow = vmKey === LOCAL_KEY && + !!localKubeconfigAuto?.enabled && + !!localKubeconfigAuto?.ran && + (localKubeconfigAuto?.discoveredContexts ?? 0) > 0 && + (localKubeconfigAuto?.connectedContexts ?? 0) > 0 + + if (!shouldShow || typeof window === 'undefined') { + setShowLocalKubeconfigNotice(false) + return + } + if (window.localStorage.getItem(LOCAL_KUBECONFIG_NOTICE_KEY) === '1') { + setShowLocalKubeconfigNotice(false) + return + } + + setConnectedLocalContexts(localKubeconfigAuto.connectedContexts) + setShowLocalKubeconfigNotice(true) + }, [ + vmKey, + localKubeconfigAuto?.enabled, + localKubeconfigAuto?.ran, + localKubeconfigAuto?.discoveredContexts, + localKubeconfigAuto?.connectedContexts, + ]) + + const dismissLocalKubeconfigNotice = () => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(LOCAL_KUBECONFIG_NOTICE_KEY, '1') + } + setShowLocalKubeconfigNotice(false) + } + + const notice = showLocalKubeconfigNotice ? ( +
+
+ Connected to {connectedLocalContexts} local Kubernetes context{connectedLocalContexts === 1 ? '' : 's'} from your kubeconfig. +
+ +
+ ) : null if (isError) { return @@ -657,6 +728,7 @@ function MainContent({ vm, vmKey, view, onSwitchToCanvas }: { vm: any; vmKey: st if (isLoading) return return (
+ {notice}
) @@ -665,12 +737,17 @@ function MainContent({ vm, vmKey, view, onSwitchToCanvas }: { vm: any; vmKey: st if (isLoading || !vm?.graph) return return ( - +
+ {notice} +
+ +
+
) } diff --git a/frontend/types/index.ts b/frontend/types/index.ts index d6ac355..bcf2ea9 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -61,6 +61,12 @@ export interface GraphSnapshot { hostId: string timestamp: string collectionDuration: number + localKubeconfigAutoDiscovery: { + enabled: boolean + ran: boolean + discoveredContexts: number + connectedContexts: number + } } export interface GraphStats { diff --git a/internal/models/snapshot.go b/internal/models/snapshot.go index 6d10090..6e20a03 100644 --- a/internal/models/snapshot.go +++ b/internal/models/snapshot.go @@ -41,10 +41,20 @@ type InfraSnapshot struct { // SnapshotMetadata contains metadata about the snapshot collection type SnapshotMetadata struct { - CollectionDuration time.Duration `json:"collection_duration"` - Scope []string `json:"scope"` - Errors []CollectionError `json:"errors,omitempty"` - PermissionIssues []string `json:"permission_issues,omitempty"` + CollectionDuration time.Duration `json:"collection_duration"` + Scope []string `json:"scope"` + Errors []CollectionError `json:"errors,omitempty"` + PermissionIssues []string `json:"permission_issues,omitempty"` + LocalKubeconfigAutoDiscovery LocalKubeconfigAutoDiscovery `json:"local_kubeconfig_auto_discovery"` +} + +// LocalKubeconfigAutoDiscovery describes local kubeconfig auto-discovery +// behavior for this snapshot. +type LocalKubeconfigAutoDiscovery struct { + Enabled bool `json:"enabled"` + Ran bool `json:"ran"` + DiscoveredContexts int `json:"discovered_contexts"` + ConnectedContexts int `json:"connected_contexts"` } // CollectionError represents an error that occurred during collection diff --git a/pkg/actions/executor.go b/pkg/actions/executor.go index 84e41ee..c25f9d6 100644 --- a/pkg/actions/executor.go +++ b/pkg/actions/executor.go @@ -29,6 +29,12 @@ type ActionExecutor struct { // NewActionExecutor creates a new action executor func NewActionExecutor() (*ActionExecutor, error) { + return NewActionExecutorWithLocalKubeconfigAutoDiscovery(true) +} + +// NewActionExecutorWithLocalKubeconfigAutoDiscovery creates a new action +// executor and controls whether local kubeconfig auto-discovery is enabled. +func NewActionExecutorWithLocalKubeconfigAutoDiscovery(localKubeconfigAutoDiscovery bool) (*ActionExecutor, error) { hostExec := NewHostExecutor() dockerExec, err := NewDockerExecutor() @@ -37,7 +43,7 @@ func NewActionExecutor() (*ActionExecutor, error) { dockerExec = nil } - k8sExec, err := NewKubernetesExecutor() + k8sExec, err := NewKubernetesExecutorWithLocalKubeconfigAutoDiscovery(localKubeconfigAutoDiscovery) if err != nil { // Kubernetes may not be available, that's okay k8sExec = nil diff --git a/pkg/actions/kubernetes.go b/pkg/actions/kubernetes.go index d9c3cd2..d32ba37 100644 --- a/pkg/actions/kubernetes.go +++ b/pkg/actions/kubernetes.go @@ -3,17 +3,16 @@ package actions import ( "context" "fmt" - "os" - "path/filepath" "time" + k8sdiscovery "infracanvas/pkg/discovery/kubernetes" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" ) // KubernetesExecutor handles actions on Kubernetes resources @@ -26,7 +25,14 @@ type KubernetesExecutor struct { // NewKubernetesExecutor creates a new Kubernetes executor, resolving the // kubeconfig from the local host. func NewKubernetesExecutor() (*KubernetesExecutor, error) { - config, err := getKubeConfig() + return NewKubernetesExecutorWithLocalKubeconfigAutoDiscovery(true) +} + +// NewKubernetesExecutorWithLocalKubeconfigAutoDiscovery creates a new +// Kubernetes executor and controls whether local kubeconfig auto-discovery is +// allowed when in-cluster config is unavailable. +func NewKubernetesExecutorWithLocalKubeconfigAutoDiscovery(localKubeconfigAutoDiscovery bool) (*KubernetesExecutor, error) { + config, _, err := k8sdiscovery.ResolveKubeConfig(localKubeconfigAutoDiscovery) if err != nil { return nil, fmt.Errorf("failed to get kubeconfig: %w", err) } @@ -56,38 +62,6 @@ func NewKubernetesExecutorFromConfig(config *rest.Config) (*KubernetesExecutor, }, nil } -// getKubeConfig attempts to load kubeconfig from multiple sources -func getKubeConfig() (*rest.Config, error) { - // 1. Try in-cluster config first - config, err := rest.InClusterConfig() - if err == nil { - return config, nil - } - - // 2. Try KUBECONFIG environment variable - kubeconfigPath := os.Getenv("KUBECONFIG") - if kubeconfigPath != "" { - config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) - if err == nil { - return config, nil - } - } - - // 3. Try default kubeconfig location - homeDir, err := os.UserHomeDir() - if err == nil { - defaultKubeconfig := filepath.Join(homeDir, ".kube", "config") - if _, err := os.Stat(defaultKubeconfig); err == nil { - config, err := clientcmd.BuildConfigFromFlags("", defaultKubeconfig) - if err == nil { - return config, nil - } - } - } - - return nil, fmt.Errorf("unable to load kubeconfig from any source") -} - // ValidateAction validates a Kubernetes action func (k *KubernetesExecutor) ValidateAction(action *Action) error { if action.Target.EntityID == "" { diff --git a/pkg/agent/ws_agent.go b/pkg/agent/ws_agent.go index 448ca82..aa8d26e 100644 --- a/pkg/agent/ws_agent.go +++ b/pkg/agent/ws_agent.go @@ -35,6 +35,9 @@ type WSConfig struct { RefreshSeconds int // how often to re-discover and send diffs (default 30) TLSInsecure bool EnableRedaction bool + // AutoDiscoverLocalKubeconfig controls whether local kubeconfig contexts are + // auto-discovered when in-cluster config is unavailable. + AutoDiscoverLocalKubeconfig bool // QuietPairBanner suppresses the "Pair code: …" stdout banner. Used when // the agent runs in-process under `infracanvas serve`, where pair codes // are irrelevant (local auto-pair). @@ -73,10 +76,11 @@ type WSConfig struct { // DefaultWSConfig returns sensible defaults. func DefaultWSConfig() *WSConfig { return &WSConfig{ - BackendURL: "ws://localhost:8080", - Scope: []string{"host", "docker"}, - RefreshSeconds: 30, - EnableRedaction: true, + BackendURL: "ws://localhost:8080", + Scope: []string{"host", "docker"}, + RefreshSeconds: 30, + EnableRedaction: true, + AutoDiscoverLocalKubeconfig: true, } } @@ -166,9 +170,9 @@ func NewWSAgent(cfg *WSConfig) (*WSAgent, error) { cfg.Scope = []string{"host", "docker"} } - orch := orchestrator.NewOrchestrator(cfg.EnableRedaction) + orch := orchestrator.NewOrchestratorWithLocalKubeconfigAutoDiscovery(cfg.EnableRedaction, cfg.AutoDiscoverLocalKubeconfig) - executor, err := actions.NewActionExecutor() + executor, err := actions.NewActionExecutorWithLocalKubeconfigAutoDiscovery(cfg.AutoDiscoverLocalKubeconfig) if err != nil { log.Printf("[agent] Warning: action executor init failed: %v", err) } diff --git a/pkg/discovery/kubernetes/discovery.go b/pkg/discovery/kubernetes/discovery.go index 61fe5b7..2ec6493 100644 --- a/pkg/discovery/kubernetes/discovery.go +++ b/pkg/discovery/kubernetes/discovery.go @@ -3,35 +3,46 @@ package kubernetes import ( "context" "fmt" - "os" - "path/filepath" "time" "infracanvas/internal/models" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" ) // Discovery implements Kubernetes-level infrastructure discovery type Discovery struct { - clientset *kubernetes.Clientset - config *rest.Config - cache *Cache + clientset *kubernetes.Clientset + config *rest.Config + cache *Cache + connectedContexts int } // NewDiscovery creates a new Kubernetes discovery instance, resolving the // kubeconfig from the local host (in-cluster config, $KUBECONFIG, or // ~/.kube/config). func NewDiscovery() (*Discovery, error) { - config, err := getKubeConfig() + config, _, err := ResolveKubeConfig(true) if err != nil { return nil, fmt.Errorf("failed to get kubeconfig: %w", err) } return NewDiscoveryFromConfig(config) } +// NewDiscoveryWithLocalKubeconfigAutoDiscovery creates a new Kubernetes +// discovery instance and returns details about local kubeconfig +// auto-discovery behavior. +func NewDiscoveryWithLocalKubeconfigAutoDiscovery(enabled bool) (*Discovery, models.LocalKubeconfigAutoDiscovery, error) { + config, info, err := ResolveKubeConfig(enabled) + if err != nil { + return nil, info, fmt.Errorf("failed to get kubeconfig: %w", err) + } + d, err := NewDiscoveryFromConfig(config) + return d, info, err +} + // NewDiscoveryFromConfig creates a Discovery against an explicit *rest.Config // instead of resolving one from the local host. Used for Clusters connections // (an uploaded kubeconfig, parsed via clientcmd.RESTConfigFromKubeConfig) — @@ -73,44 +84,13 @@ func NewDiscoveryFromConfig(config *rest.Config) (*Discovery, error) { } return &Discovery{ - clientset: clientset, - config: config, - cache: NewCache(30 * time.Second), + clientset: clientset, + config: config, + cache: NewCache(30 * time.Second), + connectedContexts: 0, }, nil } -// getKubeConfig attempts to load kubeconfig from multiple sources -func getKubeConfig() (*rest.Config, error) { - // 1. Try in-cluster config first - config, err := rest.InClusterConfig() - if err == nil { - return config, nil - } - - // 2. Try KUBECONFIG environment variable - kubeconfigPath := os.Getenv("KUBECONFIG") - if kubeconfigPath != "" { - config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) - if err == nil { - return config, nil - } - } - - // 3. Try default kubeconfig location - homeDir, err := os.UserHomeDir() - if err == nil { - defaultKubeconfig := filepath.Join(homeDir, ".kube", "config") - if _, err := os.Stat(defaultKubeconfig); err == nil { - config, err := clientcmd.BuildConfigFromFlags("", defaultKubeconfig) - if err == nil { - return config, nil - } - } - } - - return nil, fmt.Errorf("unable to load kubeconfig from any source") -} - // IsAvailable checks if Kubernetes is available and accessible func (d *Discovery) IsAvailable() bool { if d.clientset == nil { @@ -134,6 +114,7 @@ func (d *Discovery) IsAvailable() bool { // DiscoverAll performs a complete Kubernetes discovery func (d *Discovery) DiscoverAll() (*models.Cluster, []models.Node, []models.Namespace, []models.Deployment, []models.StatefulSet, []models.DaemonSet, []models.Job, []models.CronJob, []models.Pod, []models.K8sService, []models.Ingress, []models.ConfigMap, []models.Secret, []models.PersistentVolumeClaim, []models.PersistentVolume, []models.StorageClass, []models.Event, error) { + d.connectedContexts = 0 if !d.IsAvailable() { return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("Kubernetes is not available") } @@ -232,9 +213,16 @@ func (d *Discovery) DiscoverAll() (*models.Cluster, []models.Node, []models.Name return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, pvs, storageclasses, nil, fmt.Errorf("failed to get events: %w", err) } + d.connectedContexts = 1 return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, pvs, storageclasses, events, nil } +// ConnectedContexts returns how many local kubeconfig contexts were +// successfully connected in the last discovery pass. +func (d *Discovery) ConnectedContexts() int { + return d.connectedContexts +} + // InvalidateCache invalidates all cached Kubernetes data func (d *Discovery) InvalidateCache() { if d.cache != nil { diff --git a/pkg/discovery/kubernetes/resolver.go b/pkg/discovery/kubernetes/resolver.go new file mode 100644 index 0000000..3884ad1 --- /dev/null +++ b/pkg/discovery/kubernetes/resolver.go @@ -0,0 +1,85 @@ +package kubernetes + +import ( + "fmt" + "os" + "path/filepath" + + "infracanvas/internal/models" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +var inClusterConfig = rest.InClusterConfig + +// ResolveKubeConfig resolves Kubernetes config from in-cluster config first, +// then optional local kubeconfig auto-discovery. +func ResolveKubeConfig(localKubeconfigAutoDiscovery bool) (*rest.Config, models.LocalKubeconfigAutoDiscovery, error) { + info := models.LocalKubeconfigAutoDiscovery{ + Enabled: localKubeconfigAutoDiscovery, + } + + // In-cluster config is not local kubeconfig auto-discovery. + config, err := inClusterConfig() + if err == nil { + return config, info, nil + } + + if !localKubeconfigAutoDiscovery { + return nil, info, fmt.Errorf("local kubeconfig auto-discovery is disabled") + } + + info.Ran = true + config, discovered, err := loadLocalKubeconfigConfig() + info.DiscoveredContexts = discovered + if err != nil { + return nil, info, err + } + return config, info, nil +} + +func loadLocalKubeconfigConfig() (*rest.Config, int, error) { + paths, err := localKubeconfigPaths() + if err != nil { + return nil, 0, err + } + + rules := &clientcmd.ClientConfigLoadingRules{Precedence: paths} + cfg, err := rules.Load() + if err != nil { + return nil, 0, fmt.Errorf("load local kubeconfig: %w", err) + } + + discovered := len(cfg.Contexts) + if discovered == 0 { + return nil, 0, fmt.Errorf("local kubeconfig has no contexts") + } + + clientCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{}) + restCfg, err := clientCfg.ClientConfig() + if err != nil { + return nil, discovered, fmt.Errorf("build local kubeconfig client: %w", err) + } + + return restCfg, discovered, nil +} + +func localKubeconfigPaths() ([]string, error) { + if raw := os.Getenv("KUBECONFIG"); raw != "" { + paths := filepath.SplitList(raw) + if len(paths) > 0 { + return paths, nil + } + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolve home dir: %w", err) + } + defaultKubeconfig := filepath.Join(homeDir, ".kube", "config") + if _, err := os.Stat(defaultKubeconfig); err != nil { + return nil, fmt.Errorf("no local kubeconfig found") + } + return []string{defaultKubeconfig}, nil +} diff --git a/pkg/discovery/kubernetes/resolver_test.go b/pkg/discovery/kubernetes/resolver_test.go new file mode 100644 index 0000000..937a66c --- /dev/null +++ b/pkg/discovery/kubernetes/resolver_test.go @@ -0,0 +1,148 @@ +package kubernetes + +import ( + "os" + "path/filepath" + "testing" + + "k8s.io/client-go/rest" +) + +func TestResolveKubeConfig_DisabledLocalAutoDiscovery(t *testing.T) { + origInCluster := inClusterConfig + inClusterConfig = func() (*rest.Config, error) { + return nil, os.ErrNotExist + } + defer func() { inClusterConfig = origInCluster }() + + _, info, err := ResolveKubeConfig(false) + if err == nil { + t.Fatal("expected error when local kubeconfig auto-discovery is disabled") + } + if info.Enabled { + t.Fatal("expected local kubeconfig auto-discovery to be disabled") + } + if info.Ran { + t.Fatal("expected local kubeconfig discovery not to run when disabled") + } + if info.DiscoveredContexts != 0 { + t.Fatalf("expected 0 discovered contexts, got %d", info.DiscoveredContexts) + } +} + +func TestResolveKubeConfig_CountsLocalContexts(t *testing.T) { + origInCluster := inClusterConfig + inClusterConfig = func() (*rest.Config, error) { + return nil, os.ErrNotExist + } + defer func() { inClusterConfig = origInCluster }() + + tempDir := t.TempDir() + kubeconfigPath := filepath.Join(tempDir, "config") + writeKubeconfig(t, kubeconfigPath, `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://cluster-a.example.com + name: cluster-a +- cluster: + server: https://cluster-b.example.com + name: cluster-b +contexts: +- context: + cluster: cluster-a + user: user-a + name: context-a +- context: + cluster: cluster-b + user: user-b + name: context-b +current-context: context-a +users: +- name: user-a + user: + token: token-a +- name: user-b + user: + token: token-b +`) + t.Setenv("KUBECONFIG", kubeconfigPath) + + _, info, err := ResolveKubeConfig(true) + if err != nil { + t.Fatalf("ResolveKubeConfig returned error: %v", err) + } + if !info.Enabled { + t.Fatal("expected local kubeconfig auto-discovery to be enabled") + } + if !info.Ran { + t.Fatal("expected local kubeconfig discovery to run") + } + if info.DiscoveredContexts != 2 { + t.Fatalf("expected 2 discovered contexts, got %d", info.DiscoveredContexts) + } + if info.ConnectedContexts != 0 { + t.Fatalf("expected 0 connected contexts before discovery success, got %d", info.ConnectedContexts) + } +} + +func TestResolveKubeConfig_KubeconfigPathListCountsMergedContexts(t *testing.T) { + origInCluster := inClusterConfig + inClusterConfig = func() (*rest.Config, error) { + return nil, os.ErrNotExist + } + defer func() { inClusterConfig = origInCluster }() + + tempDir := t.TempDir() + kubeconfigPathA := filepath.Join(tempDir, "config-a") + kubeconfigPathB := filepath.Join(tempDir, "config-b") + writeKubeconfig(t, kubeconfigPathA, `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://cluster-a.example.com + name: cluster-a +contexts: +- context: + cluster: cluster-a + user: user-a + name: context-a +users: +- name: user-a + user: + token: token-a +`) + writeKubeconfig(t, kubeconfigPathB, `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://cluster-b.example.com + name: cluster-b +contexts: +- context: + cluster: cluster-b + user: user-b + name: context-b +current-context: context-b +users: +- name: user-b + user: + token: token-b +`) + + t.Setenv("KUBECONFIG", kubeconfigPathA+string(os.PathListSeparator)+kubeconfigPathB) + _, info, err := ResolveKubeConfig(true) + if err != nil { + t.Fatalf("ResolveKubeConfig returned error: %v", err) + } + if info.DiscoveredContexts != 2 { + t.Fatalf("expected merged context count 2, got %d", info.DiscoveredContexts) + } +} + +func writeKubeconfig(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } +} diff --git a/pkg/orchestrator/orchestrator.go b/pkg/orchestrator/orchestrator.go index ed334c2..ac35b65 100644 --- a/pkg/orchestrator/orchestrator.go +++ b/pkg/orchestrator/orchestrator.go @@ -14,24 +14,35 @@ import ( "infracanvas/pkg/discovery/lxd" "infracanvas/pkg/health" "infracanvas/pkg/relationships" + "k8s.io/client-go/rest" ) // Orchestrator coordinates discovery across multiple layers type Orchestrator struct { - hostDiscovery *host.Discovery - dockerDiscovery *docker.Discovery - kubernetesDiscovery *kubernetes.Discovery - lxdDiscovery *lxd.Discovery - relationshipBuilder *relationships.Builder - healthCalculator *health.Calculator - redactor *redactor.Redactor + hostDiscovery *host.Discovery + dockerDiscovery *docker.Discovery + kubernetesDiscovery *kubernetes.Discovery + localKubeconfigAutoDiscovery models.LocalKubeconfigAutoDiscovery + lxdDiscovery *lxd.Discovery + relationshipBuilder *relationships.Builder + healthCalculator *health.Calculator + redactor *redactor.Redactor } // NewOrchestrator creates a new discovery orchestrator func NewOrchestrator(enableRedaction bool) *Orchestrator { + return NewOrchestratorWithLocalKubeconfigAutoDiscovery(enableRedaction, true) +} + +// NewOrchestratorWithLocalKubeconfigAutoDiscovery creates a new discovery +// orchestrator and controls whether local kubeconfig auto-discovery is enabled. +func NewOrchestratorWithLocalKubeconfigAutoDiscovery(enableRedaction bool, localKubeconfigAutoDiscovery bool) *Orchestrator { return &Orchestrator{ - hostDiscovery: host.NewDiscovery(), + hostDiscovery: host.NewDiscovery(), + localKubeconfigAutoDiscovery: models.LocalKubeconfigAutoDiscovery{ + Enabled: localKubeconfigAutoDiscovery, + }, relationshipBuilder: relationships.NewBuilder(), healthCalculator: health.NewCalculator(), redactor: redactor.NewRedactor(enableRedaction), @@ -66,8 +77,9 @@ func (o *Orchestrator) Discover(ctx context.Context, scope []string) (*models.In Entities: make(map[string]models.Entity), Relations: []models.Relation{}, Metadata: models.SnapshotMetadata{ - Scope: scope, - Errors: []models.CollectionError{}, + Scope: scope, + Errors: []models.CollectionError{}, + LocalKubeconfigAutoDiscovery: o.localKubeconfigAutoDiscovery, }, } @@ -278,7 +290,10 @@ func (o *Orchestrator) discoverKubernetes(snapshot *models.InfraSnapshot, mu *sy // Initialize Kubernetes discovery if not already done if o.kubernetesDiscovery == nil { var err error - o.kubernetesDiscovery, err = kubernetes.NewDiscovery() + var info models.LocalKubeconfigAutoDiscovery + o.kubernetesDiscovery, info, err = kubernetes.NewDiscoveryWithLocalKubeconfigAutoDiscovery(o.localKubeconfigAutoDiscovery.Enabled) + o.localKubeconfigAutoDiscovery = info + snapshot.Metadata.LocalKubeconfigAutoDiscovery = info if err != nil { return fmt.Errorf("failed to initialize Kubernetes discovery: %w", err) } @@ -301,6 +316,10 @@ func (o *Orchestrator) discoverKubernetes(snapshot *models.InfraSnapshot, mu *sy if cluster != nil { snapshot.Entities[cluster.ID] = cluster } + if o.localKubeconfigAutoDiscovery.Ran && o.localKubeconfigAutoDiscovery.DiscoveredContexts > 0 { + o.localKubeconfigAutoDiscovery.ConnectedContexts = o.kubernetesDiscovery.ConnectedContexts() + snapshot.Metadata.LocalKubeconfigAutoDiscovery = o.localKubeconfigAutoDiscovery + } // Add nodes for i := range nodes { diff --git a/pkg/output/formatter_test.go b/pkg/output/formatter_test.go index 4ff1ca4..4fe92df 100644 --- a/pkg/output/formatter_test.go +++ b/pkg/output/formatter_test.go @@ -126,6 +126,40 @@ func TestJSONFormatter(t *testing.T) { } } +func TestGraphFormatter_LocalKubeconfigAutoDiscovery(t *testing.T) { + snapshot := createTestSnapshot() + snapshot.Metadata.LocalKubeconfigAutoDiscovery = models.LocalKubeconfigAutoDiscovery{ + Enabled: true, + Ran: true, + DiscoveredContexts: 3, + ConnectedContexts: 1, + } + + formatter := &GraphFormatter{} + raw, err := formatter.Format(snapshot) + if err != nil { + t.Fatalf("GraphFormatter.Format() error = %v", err) + } + + var out GraphOutput + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("failed to unmarshal graph output: %v", err) + } + + if !out.Snapshot.LocalKubeconfigAutoDiscovery.Enabled { + t.Fatal("expected local kubeconfig auto-discovery to be enabled in graph snapshot") + } + if !out.Snapshot.LocalKubeconfigAutoDiscovery.Ran { + t.Fatal("expected local kubeconfig auto-discovery to be marked as ran") + } + if out.Snapshot.LocalKubeconfigAutoDiscovery.DiscoveredContexts != 3 { + t.Fatalf("expected discovered contexts 3, got %d", out.Snapshot.LocalKubeconfigAutoDiscovery.DiscoveredContexts) + } + if out.Snapshot.LocalKubeconfigAutoDiscovery.ConnectedContexts != 1 { + t.Fatalf("expected connected contexts 1, got %d", out.Snapshot.LocalKubeconfigAutoDiscovery.ConnectedContexts) + } +} + func TestJSONFormatterCompact(t *testing.T) { snapshot := createTestSnapshot() formatter := &JSONFormatter{PrettyPrint: false} diff --git a/pkg/output/graph.go b/pkg/output/graph.go index fe6cf91..d79499e 100644 --- a/pkg/output/graph.go +++ b/pkg/output/graph.go @@ -20,9 +20,17 @@ type GraphOutput struct { } type GraphSnapshot struct { - HostID string `json:"hostId"` - Timestamp string `json:"timestamp"` - CollectionDuration float64 `json:"collectionDuration"` // in seconds + HostID string `json:"hostId"` + Timestamp string `json:"timestamp"` + CollectionDuration float64 `json:"collectionDuration"` // in seconds + LocalKubeconfigAutoDiscovery GraphLocalKubeconfigAutoDiscovery `json:"localKubeconfigAutoDiscovery"` +} + +type GraphLocalKubeconfigAutoDiscovery struct { + Enabled bool `json:"enabled"` + Ran bool `json:"ran"` + DiscoveredContexts int `json:"discoveredContexts"` + ConnectedContexts int `json:"connectedContexts"` } type GraphNode struct { @@ -63,6 +71,12 @@ func (f *GraphFormatter) Format(snapshot *models.InfraSnapshot) ([]byte, error) HostID: snapshot.HostID, Timestamp: snapshot.Timestamp.Format("2006-01-02T15:04:05Z07:00"), CollectionDuration: snapshot.Metadata.CollectionDuration.Seconds(), + LocalKubeconfigAutoDiscovery: GraphLocalKubeconfigAutoDiscovery{ + Enabled: snapshot.Metadata.LocalKubeconfigAutoDiscovery.Enabled, + Ran: snapshot.Metadata.LocalKubeconfigAutoDiscovery.Ran, + DiscoveredContexts: snapshot.Metadata.LocalKubeconfigAutoDiscovery.DiscoveredContexts, + ConnectedContexts: snapshot.Metadata.LocalKubeconfigAutoDiscovery.ConnectedContexts, + }, }, Nodes: []GraphNode{}, Edges: []GraphEdge{}, From 45ed438827010ed0959e4ece005adbc988a27fcd Mon Sep 17 00:00:00 2001 From: jabbott-iii Date: Sat, 19 Sep 2026 22:02:36 -0700 Subject: [PATCH 3/3] docs: fixed readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8648a83..9347536 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ cd InfraCanvas && make all Requires Go 1.21+ and Node.js 20+. Open the URL it prints. `--private` binds `127.0.0.1` only, `--no-tunnel` skips Cloudflare entirely; nothing leaves your machine. -- **Have Docker or a local Kubernetes context** (kind, minikube, Docker Desktop) on this machine? You're already looking at its live topology, nothing else to configure. +- **Have Docker or a local Kubernetes context** (kind, minikube, Docker Desktop) on this machine? InfraCanvas auto-discovers local kubeconfig contexts (`$KUBECONFIG` or `~/.kube/config`) and connects to the current context at startup. To skip local kubeconfig auto-discovery, run `infracanvas serve --discover-local-kubeconfig=false` (or set `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false`). - **Want to point it at a real cluster?** Click **+** next to **Clusters** in the sidebar and drop a kubeconfig. The dashboard talks to that cluster's API server directly, the same way `kubectl` does; the file is read into memory by this same local process and never transmitted anywhere. A picker appears if it has multiple contexts. [Full details](#clusters-kubernetes-with-zero-install), or expand below if you don't have a kubeconfig handy.
@@ -163,6 +163,9 @@ curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/ins # Read-only: viewers can look, not touch; public demos and dashboards on a TV curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --read-only +# Disable local kubeconfig auto-discovery (keeps Docker auto-discovery on) +INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false infracanvas serve + # Agent-only: join this VM to an existing hub (see Multiple VMs below) curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --join --token