From c9b93a334ed3b4ec0012e885de5563f68a858687 Mon Sep 17 00:00:00 2001 From: Steven Crespo Date: Thu, 11 Dec 2025 14:48:29 -0800 Subject: [PATCH 1/6] Add deployed app version to version command Signed-off-by: Steven Crespo --- cmd/installer/cli/version.go | 184 +++++++++++++++--- cmd/installer/cli/version_test.go | 301 ++++++++++++++++++++++++++++++ 2 files changed, 459 insertions(+), 26 deletions(-) create mode 100644 cmd/installer/cli/version_test.go diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index ae68844c8e..c87f54f667 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -3,54 +3,56 @@ package cli import ( "context" "fmt" + "os" "sort" "strings" "github.com/jedib0t/go-pretty/v6/table" "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/extensions" + "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + rcutil "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig/util" "github.com/replicatedhq/embedded-cluster/pkg/versions" "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ) func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { + var rc runtimeconfig.RuntimeConfig + cmd := &cobra.Command{ Use: "version", Short: fmt.Sprintf("Show the %s component versions", appTitle), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Initialize runtime config and set environment variables + // This sets up KUBECONFIG for accessing the cluster + rc = rcutil.InitBestRuntimeConfig(cmd.Context()) + // Ignore SetEnv error - if it fails, cluster access will fail gracefully + // and we'll only show client versions without server versions + _ = rc.SetEnv() + + return nil + }, + PostRun: func(cmd *cobra.Command, args []string) { + rc.Cleanup() + }, RunE: func(cmd *cobra.Command, args []string) error { + if os.Getenv("ENABLE_V3") == "1" { + return runVersionV3(ctx) + } + writer := table.NewWriter() writer.AppendHeader(table.Row{"component", "version"}) - channelRelease := release.GetChannelRelease() - if channelRelease != nil { - writer.AppendRow(table.Row{runtimeconfig.AppSlug(), channelRelease.VersionLabel}) - } - writer.AppendRow(table.Row{"Installer", versions.Version}) - writer.AppendRow(table.Row{"Kubernetes", versions.K0sVersion}) - versionsMap := map[string]string{} - for k, v := range addons.Versions() { - versionsMap[k] = v - } - for k, v := range extensions.Versions() { - versionsMap[k] = v - } + channelRelease := release.GetChannelRelease() + componentVersions, orderedKeys := collectBinaryVersions(channelRelease) - keys := []string{} - for k := range versionsMap { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - version := versionsMap[k] - if !strings.HasPrefix(version, "v") { - version = fmt.Sprintf("v%s", version) - } - writer.AppendRow(table.Row{k, version}) + for _, k := range orderedKeys { + writer.AppendRow(table.Row{k, componentVersions[k]}) } - fmt.Printf("%s\n", writer.Render()) return nil }, @@ -62,3 +64,133 @@ func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { return cmd } + +// runVersionV3 implements the version command behavior for v3 (when ENABLE_V3=1). +// A CLIENT (Binary) section is always displayed, and a SERVER (Deployed) section is conditionally displayed based on cluster accessibility. +func runVersionV3(ctx context.Context) error { + channelRelease := release.GetChannelRelease() + binaryVersions, binaryOrder := collectBinaryVersions(channelRelease) + deployedVersions, hasCluster := collectDeployedVersions(ctx) + + printVersionSection("CLIENT (Binary)", binaryVersions, binaryOrder) + if hasCluster { + fmt.Println() + printVersionSection("SERVER (Deployed)", deployedVersions, nil) + fmt.Println() + } + + return nil +} + +// collectBinaryVersions gathers all component versions from the binary. +// Returns a map of component name to version string, and an ordered slice of keys +// that matches the V2 display order (app, installer, kubernetes, then addons alphabetically). +func collectBinaryVersions(channelRelease *release.ChannelRelease) (map[string]string, []string) { + componentVersions := make(map[string]string) + orderedKeys := []string{} + + // Add app version from binary's channel release (first) + if channelRelease != nil { + appSlug := runtimeconfig.AppSlug() + componentVersions[appSlug] = channelRelease.VersionLabel + orderedKeys = append(orderedKeys, appSlug) + } + + // Add Installer version (second) + componentVersions["Installer"] = versions.Version + orderedKeys = append(orderedKeys, "Installer") + + // Add Kubernetes version with (bundled) suffix (third) + componentVersions["Kubernetes (bundled)"] = versions.K0sVersion + orderedKeys = append(orderedKeys, "Kubernetes (bundled)") + + // Collect addon and extension versions + addonKeys := []string{} + collectAndNormalizeVersions(addons.Versions(), componentVersions, &addonKeys) + collectAndNormalizeVersions(extensions.Versions(), componentVersions, &addonKeys) + + // Sort addon/extension keys alphabetically and append to ordered list + sort.Strings(addonKeys) + orderedKeys = append(orderedKeys, addonKeys...) + + return componentVersions, orderedKeys +} + +// collectAndNormalizeVersions adds versions from source map to target map, normalizing version strings +// to include "v" prefix if missing, and appends keys to the provided slice. +func collectAndNormalizeVersions(source map[string]string, target map[string]string, keys *[]string) { + for k, v := range source { + if !strings.HasPrefix(v, "v") { + v = fmt.Sprintf("v%s", v) + } + target[k] = v + *keys = append(*keys, k) + } +} + +// collectDeployedVersions gathers component versions from the deployed cluster. +// Returns a map of component name to version string and a boolean indicating if cluster is accessible. +// Assumes KUBECONFIG has been set by RuntimeConfig.SetEnv() in PreRunE. +func collectDeployedVersions(ctx context.Context) (map[string]string, bool) { + componentVersions := make(map[string]string) + + kcli, err := kubeutils.KubeClient() + if err != nil { + return componentVersions, false + } + + // Get deployed app version from the config-values secret label + appSlug := runtimeconfig.AppSlug() + kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, kcli) + if err != nil { + return componentVersions, false + } + + secret := &corev1.Secret{} + if err := kcli.Get(ctx, client.ObjectKey{ + Name: fmt.Sprintf("%s-config-values", appSlug), + Namespace: kotsadmNamespace, + }, secret); err != nil { + return componentVersions, false + } + + if appVersion := secret.Labels["app.kubernetes.io/version"]; appVersion != "" { + componentVersions[appSlug] = appVersion + } + + return componentVersions, true +} + +// printVersionSection prints a version section with the given header and component versions. +// If orderedKeys is provided, components are printed in that order. +// If orderedKeys is nil, components are sorted alphabetically. +func printVersionSection(header string, componentVersions map[string]string, orderedKeys []string) { + fmt.Println(header) + fmt.Println(strings.Repeat("-", len(header))) + + // Use provided order or sort alphabetically + var keys []string + if orderedKeys != nil { + keys = orderedKeys + } else { + keys = make([]string, 0, len(componentVersions)) + for k := range componentVersions { + keys = append(keys, k) + } + sort.Strings(keys) + } + + // Find the longest component name for alignment + maxLen := 0 + for _, k := range keys { + if len(k) > maxLen { + maxLen = len(k) + } + } + maxLen += 1 // Add 1 for padding + 1 space in format = 2 total spaces + + // Print each component with proper indentation and alignment + for _, k := range keys { + fmt.Printf(" %-*s %s\n", maxLen, k, componentVersions[k]) + } +} diff --git a/cmd/installer/cli/version_test.go b/cmd/installer/cli/version_test.go new file mode 100644 index 0000000000..4517bc4e11 --- /dev/null +++ b/cmd/installer/cli/version_test.go @@ -0,0 +1,301 @@ +package cli + +import ( + "bytes" + "context" + "io" + "os" + "strings" + "testing" + + "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/stretchr/testify/assert" +) + +func TestCollectBinaryVersions(t *testing.T) { + tests := []struct { + name string + channelRelease *release.ChannelRelease + expectedAppVersion string + expectAppInOrder bool + }{ + { + name: "with channel release", + channelRelease: &release.ChannelRelease{ + VersionLabel: "v1.2.3", + }, + expectedAppVersion: "v1.2.3", + expectAppInOrder: true, + }, + { + name: "without channel release", + channelRelease: nil, + expectAppInOrder: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + componentVersions, orderedKeys := collectBinaryVersions(tt.channelRelease) + + // Verify Installer and Kubernetes are always present + assert.Contains(t, componentVersions, "Installer") + assert.Contains(t, componentVersions, "Kubernetes (bundled)") + + // Verify app version if channel release is provided + if tt.channelRelease != nil { + // First key should be the app slug + assert.Greater(t, len(orderedKeys), 0) + appSlug := orderedKeys[0] + assert.Contains(t, componentVersions, appSlug) + assert.Equal(t, tt.expectedAppVersion, componentVersions[appSlug]) + + // Verify order: app, Installer, Kubernetes (bundled), then addons + assert.GreaterOrEqual(t, len(orderedKeys), 3) + assert.Equal(t, "Installer", orderedKeys[1]) + assert.Equal(t, "Kubernetes (bundled)", orderedKeys[2]) + } else { + // Without channel release, first should be Installer + assert.Greater(t, len(orderedKeys), 0) + assert.Equal(t, "Installer", orderedKeys[0]) + assert.Equal(t, "Kubernetes (bundled)", orderedKeys[1]) + } + + // Verify all keys in map are in ordered list + assert.Len(t, orderedKeys, len(componentVersions)) + }) + } +} + +func TestCollectAndNormalizeVersions(t *testing.T) { + tests := []struct { + name string + source map[string]string + expectedTarget map[string]string + expectedKeys []string + }{ + { + name: "versions with v prefix", + source: map[string]string{ + "component1": "v1.2.3", + "component2": "v2.0.0", + }, + expectedTarget: map[string]string{ + "component1": "v1.2.3", + "component2": "v2.0.0", + }, + expectedKeys: []string{"component1", "component2"}, + }, + { + name: "versions without v prefix", + source: map[string]string{ + "component1": "1.2.3", + "component2": "2.0.0", + }, + expectedTarget: map[string]string{ + "component1": "v1.2.3", + "component2": "v2.0.0", + }, + expectedKeys: []string{"component1", "component2"}, + }, + { + name: "mixed versions", + source: map[string]string{ + "component1": "v1.2.3", + "component2": "2.0.0", + }, + expectedTarget: map[string]string{ + "component1": "v1.2.3", + "component2": "v2.0.0", + }, + expectedKeys: []string{"component1", "component2"}, + }, + { + name: "empty source", + source: map[string]string{}, + expectedTarget: map[string]string{}, + expectedKeys: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := make(map[string]string) + keys := []string{} + + collectAndNormalizeVersions(tt.source, target, &keys) + + // Verify all expected keys are present in target + for k, v := range tt.expectedTarget { + assert.Contains(t, target, k) + assert.Equal(t, v, target[k]) + } + + // Verify keys slice has correct length + assert.Len(t, keys, len(tt.expectedKeys)) + + // Verify all keys are present (order may vary due to map iteration) + for _, expectedKey := range tt.expectedKeys { + assert.Contains(t, keys, expectedKey) + } + }) + } +} + +func TestPrintVersionSection(t *testing.T) { + tests := []struct { + name string + header string + componentVersions map[string]string + orderedKeys []string + expectedContains []string + }{ + { + name: "with ordered keys", + header: "CLIENT (Binary)", + componentVersions: map[string]string{ + "app": "v1.0.0", + "Installer": "v2.0.0", + "Kubernetes": "v1.28.0", + }, + orderedKeys: []string{"app", "Installer", "Kubernetes"}, + expectedContains: []string{ + "CLIENT (Binary)", + "---------------", + "app", + "v1.0.0", + "Installer", + "v2.0.0", + "Kubernetes", + "v1.28.0", + }, + }, + { + name: "without ordered keys (alphabetical)", + header: "SERVER (Deployed)", + componentVersions: map[string]string{ + "zebra": "v1.0.0", + "alpha": "v2.0.0", + "beta": "v3.0.0", + }, + orderedKeys: nil, + expectedContains: []string{ + "SERVER (Deployed)", + "-----------------", + "alpha", + "v2.0.0", + "beta", + "v3.0.0", + "zebra", + "v1.0.0", + }, + }, + { + name: "empty versions", + header: "EMPTY", + componentVersions: map[string]string{}, + orderedKeys: []string{}, + expectedContains: []string{ + "EMPTY", + "-----", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + printVersionSection(tt.header, tt.componentVersions, tt.orderedKeys) + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + // Verify all expected strings are present + for _, expected := range tt.expectedContains { + assert.Contains(t, output, expected, "output should contain %q", expected) + } + + // Verify header underline length matches header + lines := strings.Split(output, "\n") + if len(lines) >= 2 { + assert.Equal(t, len(tt.header), len(strings.TrimSpace(lines[1])), "underline should match header length") + } + + // Verify proper indentation (2 spaces at start of component lines) + for _, line := range lines[2:] { + if strings.TrimSpace(line) != "" { + assert.True(t, strings.HasPrefix(line, " "), "component lines should start with 2 spaces") + } + } + }) + } +} + +func TestPrintVersionSectionSpacing(t *testing.T) { + componentVersions := map[string]string{ + "short": "v1.0.0", + "much-longer": "v2.0.0", + "x": "v3.0.0", + } + orderedKeys := []string{"short", "much-longer", "x"} + + // Capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + printVersionSection("TEST", componentVersions, orderedKeys) + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + lines := strings.Split(output, "\n") + + // Find component lines (skip header and underline) + var componentLines []string + for i := 2; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) != "" { + componentLines = append(componentLines, lines[i]) + } + } + + // Verify spacing between component name and version + // Should be at least 2 spaces between longest name and its version + for _, line := range componentLines { + // Split on multiple spaces to find the gap + parts := strings.Fields(line) + if len(parts) >= 2 { + // Find the position of the version + versionIdx := strings.Index(line, parts[len(parts)-1]) + // Find the position after the component name + componentName := strings.Join(parts[:len(parts)-1], " ") + componentEndIdx := strings.Index(line, componentName) + len(componentName) + + // Calculate spacing + spacing := versionIdx - componentEndIdx + assert.GreaterOrEqual(t, spacing, 2, "spacing should be at least 2 spaces") + } + } +} + +func TestCollectDeployedVersions(t *testing.T) { + // Test that when no cluster is accessible, we return empty map and false + versions, hasCluster := collectDeployedVersions(context.Background()) + + // In test environment without a cluster, should return false + assert.False(t, hasCluster) + // Should return empty map + assert.Empty(t, versions) +} From 1cb03d984b2f3e3a73281d5a032a9fb877fb4709 Mon Sep 17 00:00:00 2001 From: Steven Crespo Date: Fri, 12 Dec 2025 10:40:29 -0800 Subject: [PATCH 2/6] f Signed-off-by: Steven Crespo --- cmd/installer/cli/version.go | 57 +++++++++++++++++++------------ cmd/installer/cli/version_test.go | 11 ------ 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index c87f54f667..f6717fb7f9 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -21,24 +21,15 @@ import ( ) func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { - var rc runtimeconfig.RuntimeConfig - cmd := &cobra.Command{ Use: "version", Short: fmt.Sprintf("Show the %s component versions", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { - // Initialize runtime config and set environment variables - // This sets up KUBECONFIG for accessing the cluster - rc = rcutil.InitBestRuntimeConfig(cmd.Context()) - // Ignore SetEnv error - if it fails, cluster access will fail gracefully - // and we'll only show client versions without server versions - _ = rc.SetEnv() - + // Set KUBECONFIG if cluster exists + // If this fails, cluster access will fail gracefully and we'll only show binary versions + _ = setKubeconfigIfExists() return nil }, - PostRun: func(cmd *cobra.Command, args []string) { - rc.Cleanup() - }, RunE: func(cmd *cobra.Command, args []string) error { if os.Getenv("ENABLE_V3") == "1" { return runVersionV3(ctx) @@ -70,10 +61,10 @@ func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { func runVersionV3(ctx context.Context) error { channelRelease := release.GetChannelRelease() binaryVersions, binaryOrder := collectBinaryVersions(channelRelease) - deployedVersions, hasCluster := collectDeployedVersions(ctx) + deployedVersions := collectDeployedVersions(ctx) printVersionSection("CLIENT (Binary)", binaryVersions, binaryOrder) - if hasCluster { + if len(deployedVersions) > 0 { fmt.Println() printVersionSection("SERVER (Deployed)", deployedVersions, nil) fmt.Println() @@ -128,22 +119,46 @@ func collectAndNormalizeVersions(source map[string]string, target map[string]str } } +// setKubeconfigIfExists sets the KUBECONFIG environment variable if the cluster is installed. +// This function does not require root permissions - it only reads existing files and does not create any directories. +// Returns error if kubeconfig is not found (cluster not installed). +func setKubeconfigIfExists() error { + // Try to discover runtime config from filesystem + rc, err := rcutil.GetRuntimeConfigFromFilesystem() + if err != nil { + // If we can't discover from filesystem, try the default location + rc = runtimeconfig.New(nil) + } + + // Get kubeconfig path from runtime config + kubeconfigPath := rc.PathToKubeConfig() + + // Verify the file exists + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return fmt.Errorf("kubeconfig not found") + } + + // Set KUBECONFIG environment variable + return os.Setenv("KUBECONFIG", kubeconfigPath) +} + // collectDeployedVersions gathers component versions from the deployed cluster. -// Returns a map of component name to version string and a boolean indicating if cluster is accessible. -// Assumes KUBECONFIG has been set by RuntimeConfig.SetEnv() in PreRunE. -func collectDeployedVersions(ctx context.Context) (map[string]string, bool) { +// Returns a map of component name to version string. Returns empty map if cluster is not accessible. +// Expects KUBECONFIG to be set by PreRunE - if not set, will return empty map. +func collectDeployedVersions(ctx context.Context) map[string]string { componentVersions := make(map[string]string) + // Create kube client - requires KUBECONFIG to be set kcli, err := kubeutils.KubeClient() if err != nil { - return componentVersions, false + return componentVersions } // Get deployed app version from the config-values secret label appSlug := runtimeconfig.AppSlug() kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, kcli) if err != nil { - return componentVersions, false + return componentVersions } secret := &corev1.Secret{} @@ -151,14 +166,14 @@ func collectDeployedVersions(ctx context.Context) (map[string]string, bool) { Name: fmt.Sprintf("%s-config-values", appSlug), Namespace: kotsadmNamespace, }, secret); err != nil { - return componentVersions, false + return componentVersions } if appVersion := secret.Labels["app.kubernetes.io/version"]; appVersion != "" { componentVersions[appSlug] = appVersion } - return componentVersions, true + return componentVersions } // printVersionSection prints a version section with the given header and component versions. diff --git a/cmd/installer/cli/version_test.go b/cmd/installer/cli/version_test.go index 4517bc4e11..43afdc18c7 100644 --- a/cmd/installer/cli/version_test.go +++ b/cmd/installer/cli/version_test.go @@ -2,7 +2,6 @@ package cli import ( "bytes" - "context" "io" "os" "strings" @@ -289,13 +288,3 @@ func TestPrintVersionSectionSpacing(t *testing.T) { } } } - -func TestCollectDeployedVersions(t *testing.T) { - // Test that when no cluster is accessible, we return empty map and false - versions, hasCluster := collectDeployedVersions(context.Background()) - - // In test environment without a cluster, should return false - assert.False(t, hasCluster) - // Should return empty map - assert.Empty(t, versions) -} From 47362aab98d86a6bcb7437ac397294942e8a8852 Mon Sep 17 00:00:00 2001 From: Steven Crespo Date: Mon, 15 Dec 2025 14:33:21 -0800 Subject: [PATCH 3/6] f Signed-off-by: Steven Crespo --- cmd/installer/cli/version.go | 89 +++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index f6717fb7f9..4031d643d9 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -25,9 +25,12 @@ func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { Use: "version", Short: fmt.Sprintf("Show the %s component versions", appTitle), PreRunE: func(cmd *cobra.Command, args []string) error { - // Set KUBECONFIG if cluster exists - // If this fails, cluster access will fail gracefully and we'll only show binary versions - _ = setKubeconfigIfExists() + // Only set KUBECONFIG if running as root and a cluster exists + if isRoot() { + rc := rcutil.InitBestRuntimeConfig(cmd.Context()) + _ = rc.SetEnv() + } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -57,18 +60,28 @@ func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { } // runVersionV3 implements the version command behavior for v3 (when ENABLE_V3=1). -// A CLIENT (Binary) section is always displayed, and a SERVER (Deployed) section is conditionally displayed based on cluster accessibility. +// A CLIENT (Binary) section is always displayed. +// A SERVER (Deployed) section shows actual versions if running as root, otherwise shows a message +// indicating that elevated privileges are required. func runVersionV3(ctx context.Context) error { channelRelease := release.GetChannelRelease() binaryVersions, binaryOrder := collectBinaryVersions(channelRelease) - deployedVersions := collectDeployedVersions(ctx) printVersionSection("CLIENT (Binary)", binaryVersions, binaryOrder) - if len(deployedVersions) > 0 { + fmt.Println() + + if !isRoot() { + printServerRequiresSudo() fmt.Println() + return nil + } + + if deployedVersions, err := collectDeployedVersions(ctx); err != nil { + printServerNotAvailable() + } else { printVersionSection("SERVER (Deployed)", deployedVersions, nil) - fmt.Println() } + fmt.Println() return nil } @@ -119,46 +132,23 @@ func collectAndNormalizeVersions(source map[string]string, target map[string]str } } -// setKubeconfigIfExists sets the KUBECONFIG environment variable if the cluster is installed. -// This function does not require root permissions - it only reads existing files and does not create any directories. -// Returns error if kubeconfig is not found (cluster not installed). -func setKubeconfigIfExists() error { - // Try to discover runtime config from filesystem - rc, err := rcutil.GetRuntimeConfigFromFilesystem() - if err != nil { - // If we can't discover from filesystem, try the default location - rc = runtimeconfig.New(nil) - } - - // Get kubeconfig path from runtime config - kubeconfigPath := rc.PathToKubeConfig() - - // Verify the file exists - if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { - return fmt.Errorf("kubeconfig not found") - } - - // Set KUBECONFIG environment variable - return os.Setenv("KUBECONFIG", kubeconfigPath) -} - // collectDeployedVersions gathers component versions from the deployed cluster. -// Returns a map of component name to version string. Returns empty map if cluster is not accessible. -// Expects KUBECONFIG to be set by PreRunE - if not set, will return empty map. -func collectDeployedVersions(ctx context.Context) map[string]string { +// Returns a map of component name to version string and an error if cluster is not accessible. +// Expects KUBECONFIG to be set by PreRunE. +func collectDeployedVersions(ctx context.Context) (map[string]string, error) { componentVersions := make(map[string]string) // Create kube client - requires KUBECONFIG to be set kcli, err := kubeutils.KubeClient() if err != nil { - return componentVersions + return componentVersions, err } // Get deployed app version from the config-values secret label appSlug := runtimeconfig.AppSlug() kotsadmNamespace, err := runtimeconfig.KotsadmNamespace(ctx, kcli) if err != nil { - return componentVersions + return componentVersions, err } secret := &corev1.Secret{} @@ -166,14 +156,14 @@ func collectDeployedVersions(ctx context.Context) map[string]string { Name: fmt.Sprintf("%s-config-values", appSlug), Namespace: kotsadmNamespace, }, secret); err != nil { - return componentVersions + return componentVersions, err } if appVersion := secret.Labels["app.kubernetes.io/version"]; appVersion != "" { componentVersions[appSlug] = appVersion } - return componentVersions + return componentVersions, nil } // printVersionSection prints a version section with the given header and component versions. @@ -209,3 +199,28 @@ func printVersionSection(header string, componentVersions map[string]string, ord fmt.Printf(" %-*s %s\n", maxLen, k, componentVersions[k]) } } + +// isRoot checks if the current process is running with root privileges. +func isRoot() bool { + return os.Geteuid() == 0 +} + +// printServerRequiresSudo prints a message indicating that elevated privileges are required +// to display deployed component versions. +func printServerRequiresSudo() { + header := "SERVER (Deployed)" + fmt.Println(header) + fmt.Println(strings.Repeat("-", len(header))) + fmt.Println(" Not available (requires elevated privileges)") + fmt.Println() + fmt.Println(" Re-run with sudo to display deployed component versions:") + fmt.Printf(" sudo %s version\n", os.Args[0]) +} + +// printServerNotAvailable prints a message indicating that the cluster is not accessible. +func printServerNotAvailable() { + header := "SERVER (Deployed)" + fmt.Println(header) + fmt.Println(strings.Repeat("-", len(header))) + fmt.Println(" Not available (cluster not accessible)") +} From 48c6f49167610c46f7042e5d412f32df3161b7a7 Mon Sep 17 00:00:00 2001 From: Steven Crespo Date: Mon, 15 Dec 2025 15:52:47 -0800 Subject: [PATCH 4/6] f Signed-off-by: Steven Crespo --- cmd/installer/cli/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index 4031d643d9..8060294010 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -35,7 +35,7 @@ func VersionCmd(ctx context.Context, appTitle string) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { if os.Getenv("ENABLE_V3") == "1" { - return runVersionV3(ctx) + return runVersionV3(cmd.Context()) } writer := table.NewWriter() From 8437a9fdf135a8e06faa3171dad287d59938db1e Mon Sep 17 00:00:00 2001 From: Steven Crespo Date: Mon, 15 Dec 2025 17:12:48 -0800 Subject: [PATCH 5/6] f Signed-off-by: Steven Crespo --- cmd/installer/cli/version.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index 8060294010..bad1ada950 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -127,8 +127,11 @@ func collectAndNormalizeVersions(source map[string]string, target map[string]str if !strings.HasPrefix(v, "v") { v = fmt.Sprintf("v%s", v) } + _, exists := target[k] target[k] = v - *keys = append(*keys, k) + if !exists { + *keys = append(*keys, k) + } } } From 5debbaf4eb5568bfc5d77deccf6054d75acbd164 Mon Sep 17 00:00:00 2001 From: Steven Crespo Date: Mon, 15 Dec 2025 17:14:03 -0800 Subject: [PATCH 6/6] f Signed-off-by: Steven Crespo --- e2e/version_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/version_test.go b/e2e/version_test.go index 01962431b9..e2295d76b7 100644 --- a/e2e/version_test.go +++ b/e2e/version_test.go @@ -33,7 +33,7 @@ func TestVersion(t *testing.T) { line := []string{"embedded-cluster", "version"} stdout, stderr, err := tc.RunRegularUserCommandOnNode(t, 0, line) if err != nil { - t.Fatalf("fail to install ssh on node %s: %v", tc.Nodes[0], err) + t.Fatalf("fail to run version command on node %s: %v", tc.Nodes[0], err) } var failed bool output := fmt.Sprintf("%s\n%s", stdout, stderr)