diff --git a/cmd/installer/cli/version.go b/cmd/installer/cli/version.go index ae68844c8e..bad1ada950 100644 --- a/cmd/installer/cli/version.go +++ b/cmd/installer/cli/version.go @@ -3,54 +3,50 @@ 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 { cmd := &cobra.Command{ Use: "version", Short: fmt.Sprintf("Show the %s component versions", appTitle), + PreRunE: func(cmd *cobra.Command, args []string) error { + // 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 { + if os.Getenv("ENABLE_V3") == "1" { + return runVersionV3(cmd.Context()) + } + 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 +58,172 @@ 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. +// 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) + + printVersionSection("CLIENT (Binary)", binaryVersions, binaryOrder) + 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() + + 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) + } + _, exists := target[k] + target[k] = v + if !exists { + *keys = append(*keys, k) + } + } +} + +// collectDeployedVersions gathers component versions from the deployed cluster. +// 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, 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, err + } + + secret := &corev1.Secret{} + if err := kcli.Get(ctx, client.ObjectKey{ + Name: fmt.Sprintf("%s-config-values", appSlug), + Namespace: kotsadmNamespace, + }, secret); err != nil { + return componentVersions, err + } + + if appVersion := secret.Labels["app.kubernetes.io/version"]; appVersion != "" { + componentVersions[appSlug] = appVersion + } + + return componentVersions, nil +} + +// 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]) + } +} + +// 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)") +} diff --git a/cmd/installer/cli/version_test.go b/cmd/installer/cli/version_test.go new file mode 100644 index 0000000000..43afdc18c7 --- /dev/null +++ b/cmd/installer/cli/version_test.go @@ -0,0 +1,290 @@ +package cli + +import ( + "bytes" + "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") + } + } +} 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)