From 27c854627c84df470b3ad691b30fe5bcf3d1ce4f Mon Sep 17 00:00:00 2001 From: Stefano Ghinelli Date: Wed, 8 Apr 2026 16:11:52 +0200 Subject: [PATCH 1/4] feat: add furyctl get cluster-info command --- cmd/get.go | 1 + cmd/get/cluster-info.go | 360 +++++++++ internal/clusterinfo/collector.go | 763 ++++++++++++++++++ .../clusterinfo/collector_integration_test.go | 151 ++++ internal/clusterinfo/collector_test.go | 452 +++++++++++ internal/clusterinfo/info.go | 64 ++ 6 files changed, 1791 insertions(+) create mode 100644 cmd/get/cluster-info.go create mode 100644 internal/clusterinfo/collector.go create mode 100644 internal/clusterinfo/collector_integration_test.go create mode 100644 internal/clusterinfo/collector_test.go create mode 100644 internal/clusterinfo/info.go diff --git a/cmd/get.go b/cmd/get.go index 07ed34e3e..060812817 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -16,6 +16,7 @@ func NewGetCmd() *cobra.Command { Short: "Get the kubeconfig, available upgrade paths for a cluster or compatible versions to use between SD, providers, furyctl", } + getCmd.AddCommand(get.NewClusterInfoCmd()) getCmd.AddCommand(get.NewKubeconfigCmd()) getCmd.AddCommand(get.NewUpgradePathsCmd()) getCmd.AddCommand(get.NewSupportedVersionsCmd()) diff --git a/cmd/get/cluster-info.go b/cmd/get/cluster-info.go new file mode 100644 index 000000000..947262c78 --- /dev/null +++ b/cmd/get/cluster-info.go @@ -0,0 +1,360 @@ +// Copyright (c) 2017-present SIGHUP s.r.l All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package get + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" + v3 "gopkg.in/yaml.v3" + + "github.com/sighupio/furyctl/internal/analytics" + "github.com/sighupio/furyctl/internal/app" + "github.com/sighupio/furyctl/internal/clusterinfo" + "github.com/sighupio/furyctl/internal/flags" + cobrax "github.com/sighupio/furyctl/internal/x/cobra" + execx "github.com/sighupio/furyctl/internal/x/exec" +) + +const ( + outputFormatText = "text" + outputFormatJSON = "json" + outputFormatYAML = "yaml" + minTableLines = 3 +) + +var errInvalidOutputFormat = errors.New("invalid output format, supported values are: text, json, yaml") + +func NewClusterInfoCmd() *cobra.Command { + var cmdEvent analytics.Event + + clusterInfoCmd := &cobra.Command{ + Args: cobra.NoArgs, + Use: "cluster-info", + Short: "Display cluster information.", + Long: `Display information about the cluster, Kubernetes, and SD status. The command provides a quick overview of the cluster configuration and its current state; its output can be used for analysis and troubleshooting.`, + Example: ` furyctl get cluster-info display cluster info in text format (default) + furyctl get cluster-info --format json display cluster info as JSON + furyctl get cluster-info --format yaml display cluster info as YAML + `, + PreRun: func(cmd *cobra.Command, _ []string) { + cmdEvent = analytics.NewCommandEvent(cobrax.GetFullname(cmd)) + + if err := flags.LoadAndMergeCommandFlags("get"); err != nil { + logrus.Fatalf("failed to load flags from configuration: %v", err) + } + + if err := viper.BindPFlags(cmd.Flags()); err != nil { + logrus.Fatalf("error while binding flags: %v", err) + } + }, + RunE: func(_ *cobra.Command, _ []string) error { + ctn := app.GetContainerInstance() + + tracker := ctn.Tracker() + tracker.Flush() + + binPath := viper.GetString("bin-path") + currentDir := viper.GetString("workdir") + debug := viper.GetBool("debug") + format := viper.GetString("format") + outDir := viper.GetString("outdir") + + execx.Debug = debug + + if format != outputFormatText && format != outputFormatJSON && format != outputFormatYAML { + cmdEvent.AddErrorMessage(errInvalidOutputFormat) + tracker.Track(cmdEvent) + + return errInvalidOutputFormat + } + + kubectlBin := resolveKubectlBin(binPath, outDir) + + collector := clusterinfo.NewCollector(kubectlBin, currentDir) + + info, err := collector.Collect() + if err != nil { + cmdEvent.AddErrorMessage(err) + tracker.Track(cmdEvent) + + return fmt.Errorf("error while collecting cluster information: %w", err) + } + + if err := printInfo(info, format); err != nil { + cmdEvent.AddErrorMessage(err) + tracker.Track(cmdEvent) + + return fmt.Errorf("error while printing cluster information: %w", err) + } + + cmdEvent.AddSuccessMessage("cluster info successfully retrieved") + tracker.Track(cmdEvent) + + return nil + }, + } + + clusterInfoCmd.Flags().StringP( + "bin-path", + "b", + "", + "Path to the folder where all the dependencies' binaries are installed. "+ + "When set, furyctl looks for kubectl inside this folder. "+ + "If not set, kubectl is resolved from PATH.", + ) + + clusterInfoCmd.Flags().StringP( + "format", + "f", + outputFormatText, + "Output format. Supported values: text, json, yaml", + ) + + return clusterInfoCmd +} + +func resolveKubectlBin(binPath, outDir string) string { + searchDir := binPath + + if searchDir == "" && outDir != "" { + searchDir = path.Join(outDir, ".furyctl", "bin") + } + + if searchDir != "" { + pattern := filepath.Join(searchDir, "kubectl", "*", "kubectl") + + matches, err := filepath.Glob(pattern) + if err == nil && len(matches) > 0 { + return matches[len(matches)-1] + } + } + + return "kubectl" +} + +func printInfo(info *clusterinfo.Info, format string) error { + switch format { + case outputFormatJSON: + return printJSON(info) + + case outputFormatYAML: + return printYAML(info) + + default: + // Print plain text directly to stdout to avoid log prefixes and match + // the JSON/YAML behavior (clean pipeable output). + if _, err := fmt.Fprint(os.Stdout, formatText(info)); err != nil { + return fmt.Errorf("error writing output: %w", err) + } + + return nil + } +} + +func printJSON(info *clusterinfo.Info) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + + if err := enc.Encode(info); err != nil { + return fmt.Errorf("error encoding JSON: %w", err) + } + + return nil +} + +func printYAML(info *clusterinfo.Info) error { + enc := v3.NewEncoder(os.Stdout) + defer enc.Close() + + if err := enc.Encode(info); err != nil { + return fmt.Errorf("error encoding YAML: %w", err) + } + + return nil +} + +func formatText(info *clusterinfo.Info) string { + const tabPadding = 2 + + var sb strings.Builder + + w := tabwriter.NewWriter(&sb, 0, 0, tabPadding, ' ', 0) + + _, _ = fmt.Fprintf(w, "%s\t%s\n", "Cluster Name:", info.ClusterName) + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Version:", info.SDVersion) + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Kind:", info.SDKind) + + if info.SDInstallerVersion != "" { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Installer version:", info.SDInstallerVersion) + } + + if len(info.SDUpgradePaths) > 0 { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Upgrade paths:", strings.Join(info.SDUpgradePaths, ", ")) + } else { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Upgrade paths:", "None") + } + + if info.SDOngoingUpgrade != nil { + u := info.SDOngoingUpgrade + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Ongoing upgrade:", fmt.Sprintf("Yes (%s: %s)", u.Phase, u.Status)) + } else { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "SD Ongoing upgrade:", "None") + } + + if info.KubernetesVersion != "" { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "Kubernetes version:", info.KubernetesVersion) + } + + if info.EtcdTopology != "" { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "Etcd topology:", info.EtcdTopology) + } + + if !info.LastConfigurationChange.IsZero() { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "Last configuration change:", + info.LastConfigurationChange.UTC().Format("2006-01-02 15:04:05 (UTC)")) + } + + if info.CustomPatchesPresent { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "Custom Patches present:", "Yes") + } else { + _, _ = fmt.Fprintf(w, "%s\t%s\n", "Custom Patches present:", "None") + } + + _ = w.Flush() + + if len(info.Modules) > 0 { + _, _ = sb.WriteString("\n") + writeModulesTable(&sb, info.Modules) + } + + if info.Plugins != nil { + _, _ = sb.WriteString("\n") + writePluginsTable(&sb, info.Plugins) + } + + if info.Nodes != nil { + _, _ = sb.WriteString("\n") + writeNodesTable(&sb, info.Nodes) + } + + return sb.String() +} + +func writeModulesTable(sb *strings.Builder, modules []clusterinfo.ModuleInfo) { + const tabPadding = 2 + + var buf strings.Builder + + w := tabwriter.NewWriter(&buf, 0, 0, tabPadding, ' ', 0) + + _, _ = fmt.Fprintln(w, "Module\tVersion\tType") + + for _, m := range modules { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", m.Name, m.Version, m.Type) + } + + _ = w.Flush() + + _, _ = sb.WriteString(insertHeaderSeparator(buf.String(), "-")) +} + +func writePluginsTable(sb *strings.Builder, plugins *clusterinfo.PluginsInfo) { + const tabPadding = 2 + + var buf strings.Builder + + w := tabwriter.NewWriter(&buf, 0, 0, tabPadding, ' ', 0) + + _, _ = fmt.Fprintln(w, "Plugin Name\tType") + + for _, name := range plugins.Kustomize { + _, _ = fmt.Fprintf(w, "%s\t%s\n", name, "Kustomize") + } + + for _, name := range plugins.Helm { + _, _ = fmt.Fprintf(w, "%s\t%s\n", name, "Helm") + } + + _ = w.Flush() + + _, _ = sb.WriteString(insertHeaderSeparator(buf.String(), "-")) +} + +func writeNodesTable(sb *strings.Builder, nodes *clusterinfo.NodesSummary) { + const tabPadding = 2 + + var buf strings.Builder + + w := tabwriter.NewWriter(&buf, 0, 0, tabPadding, ' ', 0) + + _, _ = fmt.Fprintln(w, "Node Role\tQty\tvCPU\tRAM(GiB)") + + for _, g := range nodes.Roles { + _, _ = fmt.Fprintf(w, "%s\t%d\t%d\t%s\n", g.Role, g.Quantity, g.VCPU, formatRAM(g.RAMGb)) + } + + _, _ = fmt.Fprintf(w, "Total\t%d\t%d\t%s\n", nodes.Totals.Quantity, nodes.Totals.VCPU, formatRAM(nodes.Totals.RAMGb)) + + _ = w.Flush() + + _, _ = sb.WriteString(insertSeparator(buf.String(), "-", "=")) +} + +// + +func insertHeaderSeparator(table, char string) string { + lines := strings.SplitN(table, "\n", 2) //nolint:mnd // split into header + rest + if len(lines) < 2 { //nolint:mnd // need at least header + data + return table + } + + sepLen := len(strings.TrimRight(lines[0], " ")) + + return lines[0] + "\n" + strings.Repeat(char, sepLen) + "\n" + lines[1] +} + +func insertSeparator(table, headerChar, footerChar string) string { + lines := strings.Split(strings.TrimRight(table, "\n"), "\n") + if len(lines) < minTableLines { + return insertHeaderSeparator(table, headerChar) + } + + sepLen := len(strings.TrimRight(lines[0], " ")) + + var sb strings.Builder + + _, _ = sb.WriteString(lines[0] + "\n") + _, _ = sb.WriteString(strings.Repeat(headerChar, sepLen) + "\n") + + for _, line := range lines[1 : len(lines)-1] { + _, _ = sb.WriteString(line + "\n") + } + + _, _ = sb.WriteString(strings.Repeat(footerChar, sepLen) + "\n") + _, _ = sb.WriteString(lines[len(lines)-1] + "\n") + + return sb.String() +} + +// formatRAM returns a compact string representation of a RAM value in GiB. +// Whole-number GiB values are printed without decimals. +func formatRAM(gb float64) string { + if gb == math.Trunc(gb) { + return fmt.Sprintf("%.0f", gb) + } + + return fmt.Sprintf("%.1f", gb) +} diff --git a/internal/clusterinfo/collector.go b/internal/clusterinfo/collector.go new file mode 100644 index 000000000..dd6f5813d --- /dev/null +++ b/internal/clusterinfo/collector.go @@ -0,0 +1,763 @@ +// Copyright (c) 2017-present SIGHUP s.r.l All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package clusterinfo + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + "reflect" + "sort" + "strings" + "time" + + distroconf "github.com/sighupio/fury-distribution/pkg/apis/config" + "github.com/sighupio/furyctl/configs" + "github.com/sighupio/furyctl/internal/cluster" + "github.com/sighupio/furyctl/internal/tool/kubectl" + "github.com/sighupio/furyctl/internal/upgrade" + execx "github.com/sighupio/furyctl/internal/x/exec" + yamlx "github.com/sighupio/furyctl/pkg/x/yaml" +) + +const ( + furyctlConfigSecret = "furyctl-config" + furyctlKFDSecret = "furyctl-kfd" + upgradeStateConfigMap = "furyctl-upgrade-state" + kubeSystemNamespace = "kube-system" + + roleControlPlane = "control-plane" + roleMaster = "master" + roleNone = "" + ingressNone = "none" + thousandDec = 1000.0 + thousandBin = 1024.0 + milliCPU = 1000 + etcdStacked = "Stacked" + etcdDedicated = "Dedicated" +) + +var ( + ErrSecretNoData = errors.New("secret has no data field") + ErrSecretMissingKey = errors.New("secret missing key") + ErrConfigSecretNotFound = errors.New("furyctl-config secret not found in the cluster") + ErrUpgradeStateNoData = errors.New("upgrade state configmap has no data") + ErrUpgradeStateMissing = errors.New("upgrade state configmap missing state key") +) + +// Collector reads cluster information from the Kubernetes secrets and configmaps +// that furyctl maintains during cluster lifecycle operations. +type Collector struct { + KubectlRunner *kubectl.Runner +} + +// NewCollector creates a Collector using the given kubectl binary and working directory. +func NewCollector(kubectlBin, workDir string) *Collector { + runner := kubectl.NewRunner( + execx.NewStdExecutor(), + kubectl.Paths{ + Kubectl: kubectlBin, + WorkDir: workDir, + }, + false, true, false, + ) + + return &Collector{KubectlRunner: runner} +} + +// Collect gathers all available cluster information and returns a populated struct. +// Failures fetching the Kubernetes version or node list are non-fatal: those fields are +// left empty so the command can still show partial information. +func (c *Collector) Collect() (*Info, error) { + rawConfig, configTimestamp, err := c.fetchSecret(furyctlConfigSecret, "config") + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrConfigSecretNotFound, err) + } + + furyctlConf := distroconf.Furyctl{} + if err := yamlx.UnmarshalV3(rawConfig, &furyctlConf); err != nil { + return nil, fmt.Errorf("error while parsing stored cluster configuration: %w", err) + } + + configMap := map[string]any{} + if err := yamlx.UnmarshalV3(rawConfig, &configMap); err != nil { + return nil, fmt.Errorf("error while parsing stored cluster configuration map: %w", err) + } + + rawSD, _, err := c.fetchSecret(furyctlKFDSecret, "kfd") + if err != nil { + return nil, fmt.Errorf("error while reading KFD YAML file from cluster: %w", err) + } + + sdManifest := distroconf.KFD{} + if err := yamlx.UnmarshalV3(rawSD, &sdManifest); err != nil { + return nil, fmt.Errorf("error while parsing KFD YAML file: %w", err) + } + + info := &Info{ + ClusterName: furyctlConf.Metadata.Name, + SDVersion: furyctlConf.Spec.DistributionVersion, + SDKind: furyctlConf.Kind, + SDInstallerVersion: installerVersion(furyctlConf.Kind, sdManifest), + SDUpgradePaths: computeUpgradePaths(furyctlConf.Kind, furyctlConf.Spec.DistributionVersion), + LastConfigurationChange: configTimestamp, + CustomPatchesPresent: hasCustomPatches(configMap), + Modules: extractModules(configMap, sdManifest, furyctlConf.Kind), + Plugins: extractPlugins(configMap), + EtcdTopology: etcdTopology(furyctlConf.Kind, configMap), + } + + if ongoingUpgrade, upgradeErr := c.fetchOngoingUpgrade(); upgradeErr == nil { + info.SDOngoingUpgrade = ongoingUpgrade + } + + if k8sVersion, versionErr := c.fetchKubernetesVersion(); versionErr == nil { + info.KubernetesVersion = k8sVersion + } + + if nodes, nodesErr := c.fetchNodes(); nodesErr == nil { + info.Nodes = nodes + } + + return info, nil +} + +func (c *Collector) fetchSecret(secretName, dataKey string) ([]byte, time.Time, error) { + out, err := c.KubectlRunner.Get( + true, + kubeSystemNamespace, + "secret", secretName, + "-o", "yaml", + "--show-managed-fields", + ) + if err != nil { + return nil, time.Time{}, fmt.Errorf("error reading secret %s: %w", secretName, err) + } + + raw := map[string]any{} + if err := yamlx.UnmarshalV3([]byte(out), &raw); err != nil { + return nil, time.Time{}, fmt.Errorf("error parsing secret %s: %w", secretName, err) + } + + data, ok := raw["data"].(map[string]any) + if !ok { + return nil, time.Time{}, fmt.Errorf("%w: %s", ErrSecretNoData, secretName) + } + + encoded, ok := data[dataKey].(string) + if !ok { + return nil, time.Time{}, fmt.Errorf("%w %q in %s", ErrSecretMissingKey, dataKey, secretName) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, time.Time{}, fmt.Errorf("error decoding secret %s key %q: %w", secretName, dataKey, err) + } + + return decoded, latestManagedFieldTime(raw), nil +} + +// fetchOngoingUpgrade reads the upgrade state configmap and returns an OngoingUpgrade +// when an upgrade is currently in progress or has a failed phase. +func (c *Collector) fetchOngoingUpgrade() (*OngoingUpgrade, error) { + out, err := c.KubectlRunner.Get( + false, + kubeSystemNamespace, + "cm", upgradeStateConfigMap, + "-o", "yaml", + ) + if err != nil { + return nil, fmt.Errorf("upgrade state configmap not found: %w", err) + } + + raw := map[string]any{} + if err := yamlx.UnmarshalV3([]byte(out), &raw); err != nil { + return nil, fmt.Errorf("error parsing upgrade state: %w", err) + } + + cmData, ok := raw["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("%w", ErrUpgradeStateNoData) + } + + stateYAML, ok := cmData["state"].(string) + if !ok { + return nil, fmt.Errorf("%w", ErrUpgradeStateMissing) + } + + state := &upgrade.State{} + if err := yamlx.UnmarshalV3([]byte(stateYAML), state); err != nil { + return nil, fmt.Errorf("error parsing upgrade state YAML: %w", err) + } + + return upgradeInfoFromState(state), nil +} + +// fetchKubernetesVersion retrieves the Kubernetes server version. +func (c *Collector) fetchKubernetesVersion() (string, error) { + out, err := c.KubectlRunner.Version() + if err != nil { + return "", fmt.Errorf("error getting kubernetes version: %w", err) + } + + type versionInfo struct { + GitVersion string `json:"gitVersion"` + } + + type kubectlVersion struct { + ServerVersion versionInfo `json:"serverVersion"` + } + + var info kubectlVersion + if err := json.Unmarshal([]byte(out), &info); err != nil { + return "", fmt.Errorf("error parsing kubernetes version response: %w", err) + } + + return info.ServerVersion.GitVersion, nil +} + +// fetchNodes summarizes node capacity by role to report cluster shape. +func (c *Collector) fetchNodes() (*NodesSummary, error) { + out, err := c.KubectlRunner.Get( + false, + "all", + "nodes", + "-o", "json", + ) + if err != nil { + return nil, fmt.Errorf("error getting nodes: %w", err) + } + + type nodeResource struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + } + + type nodeStatus struct { + Capacity nodeResource `json:"capacity"` + } + + type nodeMetadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } + + type nodeItem struct { + Metadata nodeMetadata `json:"metadata"` + Status nodeStatus `json:"status"` + } + + type nodeList struct { + Items []nodeItem `json:"items"` + } + + var list nodeList + if err := json.Unmarshal([]byte(out), &list); err != nil { + return nil, fmt.Errorf("error parsing nodes JSON: %w", err) + } + + if len(list.Items) == 0 { + return &NodesSummary{}, nil + } + + groups := map[string]*NodeRoleGroup{} + + var roleOrder []string + + totals := NodeTotals{} + + for _, item := range list.Items { + role := primaryRole(item.Metadata.Labels) + vcpu := parseCPU(item.Status.Capacity.CPU) + ramGb := parseMemoryGb(item.Status.Capacity.Memory) + + if _, exists := groups[role]; !exists { + groups[role] = &NodeRoleGroup{Role: role} + roleOrder = append(roleOrder, role) + } + + groups[role].Quantity++ + groups[role].VCPU += vcpu + groups[role].RAMGb += ramGb + + totals.Quantity++ + totals.VCPU += vcpu + totals.RAMGb += ramGb + } + + sort.Slice(roleOrder, func(i, j int) bool { + return roleSort(roleOrder[i], roleOrder[j]) + }) + + roles := make([]NodeRoleGroup, 0, len(roleOrder)) + for _, r := range roleOrder { + roles = append(roles, *groups[r]) + } + + return &NodesSummary{Roles: roles, Totals: totals}, nil +} + +// latestManagedFieldTime returns the most recent managedFields[].time, +// falling back to creationTimestamp. +func latestManagedFieldTime(raw map[string]any) time.Time { + metadata, ok := raw["metadata"].(map[string]any) + if !ok { + return time.Time{} + } + + if fields, ok := metadata["managedFields"].([]any); ok { + var latest time.Time + + for _, f := range fields { + field, ok := f.(map[string]any) + if !ok { + continue + } + + ts, ok := field["time"].(string) + if !ok { + continue + } + + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + continue + } + + if t.After(latest) { + latest = t + } + } + + if !latest.IsZero() { + return latest + } + } + + if ts, ok := metadata["creationTimestamp"].(string); ok { + t, err := time.Parse(time.RFC3339, ts) + if err == nil { + return t + } + } + + return time.Time{} +} + +// upgradeInfoFromState returns the first pending/failed phase in canonical +// order; nil if all succeeded. +func upgradeInfoFromState(state *upgrade.State) *OngoingUpgrade { + for _, phaseName := range cluster.GetPhasesOrder() { + reflectedPhase := reflect.ValueOf(state.Phases).FieldByName(phaseName) + if !reflectedPhase.IsValid() || reflectedPhase.IsNil() { + continue + } + + status := reflectedPhase.Elem().FieldByName("Status").String() + if status == string(upgrade.PhaseStatusPending) || status == string(upgrade.PhaseStatusFailed) { + return &OngoingUpgrade{ + Status: status, + Phase: cluster.GetPhase(phaseName), + } + } + } + + return nil +} + +func hasCustomPatches(configMap map[string]any) bool { + cp := nestedMap(configMap, "spec", "distribution", "customPatches") + if cp == nil { + return false + } + + for _, v := range cp { + if slice, ok := v.([]any); ok && len(slice) > 0 { + return true + } + } + + return false +} + +// extractModules combines types from furyctl config with versions from the KFD +// YAML. Fixed order keeps output stable; for EKS, an AWS row is appended when +// present. +func extractModules(configMap map[string]any, sd distroconf.KFD, kind string) []ModuleInfo { + modules := nestedMap(configMap, "spec", "distribution", "modules") + + type moduleSpec struct { + name string + version string + typeGetter func(map[string]any) string + } + + specs := []moduleSpec{ + { + name: "Networking", + version: sd.Modules.Networking, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(m, "networking"), "type") + }, + }, + { + name: "Ingress", + version: sd.Modules.Ingress, + typeGetter: ingressType, + }, + { + name: "Monitoring", + version: sd.Modules.Monitoring, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(m, "monitoring"), "type") + }, + }, + { + name: "Logging", + version: sd.Modules.Logging, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(m, "logging"), "type") + }, + }, + { + name: "Tracing", + version: sd.Modules.Tracing, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(m, "tracing"), "type") + }, + }, + { + name: "Policy", + version: sd.Modules.Opa, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(m, "policy"), "type") + }, + }, + { + name: "Auth", + version: sd.Modules.Auth, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(nestedMap(m, "auth"), "provider"), "type") + }, + }, + { + name: "Disaster Recovery", + version: sd.Modules.Dr, + typeGetter: func(m map[string]any) string { + return stringField(nestedMap(m, "dr"), "type") + }, + }, + } + + if sd.Modules.Aws != "" && kind == "EKSCluster" { + specs = append(specs, moduleSpec{ + name: "AWS", + version: sd.Modules.Aws, + typeGetter: func(_ map[string]any) string { return "" }, + }) + } + + result := make([]ModuleInfo, 0, len(specs)) + + for _, s := range specs { + modType := "" + if modules != nil { + modType = s.typeGetter(modules) + } + + result = append(result, ModuleInfo{ + Name: s.name, + Version: s.version, + Type: modType, + }) + } + + return result +} + +// extractPlugins builds the grouped PluginsInfo from the stored configuration, +// separating Kustomize and Helm plugin types. +func extractPlugins(configMap map[string]any) *PluginsInfo { + plugins := nestedMap(configMap, "spec", "plugins") + if plugins == nil { + return nil + } + + result := &PluginsInfo{} + hasAny := false + + if kustomize, ok := plugins["kustomize"].([]any); ok { + for _, item := range kustomize { + if entry, ok := item.(map[string]any); ok { + if name, ok := entry["name"].(string); ok { + result.Kustomize = append(result.Kustomize, name) + hasAny = true + } + } + } + } + + if helm, ok := plugins["helm"].(map[string]any); ok { + if releases, ok := helm["releases"].([]any); ok { + for _, item := range releases { + if entry, ok := item.(map[string]any); ok { + if name, ok := entry["name"].(string); ok { + result.Helm = append(result.Helm, name) + hasAny = true + } + } + } + } + } + + if !hasAny { + return nil + } + + return result +} + +func etcdTopology(kind string, configMap map[string]any) string { + if kind != "OnPremises" { + return "" + } + + etcd := nestedMap(configMap, "spec", "kubernetes", "etcd") + if etcd == nil { + return etcdStacked + } + + hosts, ok := etcd["hosts"].([]any) + if !ok || len(hosts) == 0 { + return etcdStacked + } + + return etcdDedicated +} + +func installerVersion(kind string, sd distroconf.KFD) string { + switch kind { + case "OnPremises": + return sd.Kubernetes.OnPremises.Installer + + case "EKSCluster": + return sd.Kubernetes.Eks.Installer + + default: + return "" + } +} + +// computeUpgradePaths returns the list of available upgrade target versions for the given +// cluster kind and current distribution version, using the embedded upgrade paths filesystem. +func computeUpgradePaths(kind, fromVersion string) []string { + from := strings.TrimPrefix(fromVersion, "v") + + globPattern := fmt.Sprintf("upgrades/%s/%s-*", strings.ToLower(kind), from) + + matches, err := fs.Glob(configs.Tpl, globPattern) + if err != nil || len(matches) == 0 { + return nil + } + + targets := make([]string, 0, len(matches)) + + for _, match := range matches { + info, err := fs.Stat(configs.Tpl, match) + if err != nil || !info.IsDir() { + continue + } + + parts := strings.Split(filepath.Base(match), "-") + to := parts[len(parts)-1] + targets = append(targets, "v"+to) + } + + return targets +} + +// primaryRole returns the display role for a node by inspecting its labels. +// Control-plane and master roles take priority over any other role. +// Otherwise the first role label in alphabetical order is used. +// Returns "" when no role labels are present, consistent with kubectl. +func primaryRole(labels map[string]string) string { + const prefix = "node-role.kubernetes.io/" + + var roles []string + + for k := range labels { + if strings.HasPrefix(k, prefix) { + if role := strings.TrimPrefix(k, prefix); role != "" { + roles = append(roles, role) + } + } + } + + if len(roles) == 0 { + return roleNone + } + + sort.Slice(roles, func(i, j int) bool { return roleSort(roles[i], roles[j]) }) + + return roles[0] +} + +func roleSort(a, b string) bool { + if a == roleControlPlane { + return true + } + + if b == roleControlPlane { + return false + } + + if a == roleMaster { + return true + } + + if b == roleMaster { + return false + } + + if a == roleNone { + return false + } + + if b == roleNone { + return true + } + + return a < b +} + +// parseCPU converts a Kubernetes CPU quantity string (e.g. "4" or "500m") to an integer vCPU count. +func parseCPU(cpu string) int64 { + if cpu == "" { + return 0 + } + + if strings.HasSuffix(cpu, "m") { + var millis int64 + + if _, err := fmt.Sscanf(strings.TrimSuffix(cpu, "m"), "%d", &millis); err != nil { + return 0 + } + + return millis / milliCPU + } + + var cores int64 + + if _, err := fmt.Sscanf(cpu, "%d", &cores); err != nil { + return 0 + } + + return cores +} + +// parseMemoryGb converts a Kubernetes memory quantity string to a GiB value for display. +func parseMemoryGb(mem string) float64 { + if mem == "" { + return 0 + } + + suffixes := []struct { + suffix string + factor float64 + }{ + {"Ki", 1.0 / (thousandBin * thousandBin)}, + {"Mi", 1.0 / thousandBin}, + {"Gi", 1.0}, + {"Ti", thousandBin}, + {"K", 1.0 / (thousandDec * thousandDec)}, + {"M", 1.0 / thousandDec}, + {"G", 1.0}, + {"T", thousandDec}, + } + + for _, s := range suffixes { + if strings.HasSuffix(mem, s.suffix) { + var val float64 + + if _, err := fmt.Sscanf(strings.TrimSuffix(mem, s.suffix), "%f", &val); err != nil { + return 0 + } + + return val * s.factor + } + } + + var val float64 + + if _, err := fmt.Sscanf(mem, "%f", &val); err != nil { + return 0 + } + + return val / (thousandBin * thousandBin * thousandBin) +} + +func nestedMap(m map[string]any, keys ...string) map[string]any { + if m == nil { + return nil + } + + current := m + + for _, k := range keys { + next, ok := current[k].(map[string]any) + if !ok { + return nil + } + + current = next + } + + return current +} + +// ingressType derives a readable ingress type from the modules map. +// Returns a comma-separated list of active types, or "none" if all are disabled. +func ingressType(modules map[string]any) string { + var active []string + + if t := stringField(nestedMap(modules, "ingress", "nginx"), "type"); t != "" && t != ingressNone { + active = append(active, "nginx/"+t) + } + + if t := stringField(nestedMap(modules, "ingress", "haproxy"), "type"); t != "" && t != ingressNone { + active = append(active, "haproxy/"+t) + } + + if byoic := nestedMap(modules, "ingress", "byoic"); byoic != nil { + if enabled, ok := byoic["enabled"].(bool); ok && enabled { + if class := stringField(byoic, "ingressClass"); class != "" { + active = append(active, "byoic/"+class) + } else { + active = append(active, "byoic") + } + } + } + + if len(active) == 0 { + return ingressNone + } + + return strings.Join(active, ", ") +} + +func stringField(m map[string]any, key string) string { + if m == nil { + return "" + } + + if v, ok := m[key].(string); ok { + return v + } + + return "" +} diff --git a/internal/clusterinfo/collector_integration_test.go b/internal/clusterinfo/collector_integration_test.go new file mode 100644 index 000000000..7bf9e1e9a --- /dev/null +++ b/internal/clusterinfo/collector_integration_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2017-present SIGHUP s.r.l All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package clusterinfo_test + +import ( + "encoding/base64" + "fmt" + "os" + "testing" + + "github.com/sighupio/furyctl/internal/clusterinfo" + "github.com/sighupio/furyctl/internal/tool/kubectl" + execx "github.com/sighupio/furyctl/internal/x/exec" +) + +const ( + minimalFuryctlYAML = `apiVersion: kfd.sighup.io/v1alpha2 +kind: OnPremises +metadata: + name: test-cluster +spec: + distributionVersion: v1.34.0 +` + + minimalKFDYAML = `version: v1.34.0 +modules: + auth: v0.6.1 + dr: v3.3.0 + ingress: v5.0.0 + logging: v5.3.0 + monitoring: v4.1.0 + opa: v1.16.0 + networking: v3.1.0 + tracing: v1.4.0 +kubernetes: + onpremises: + version: 1.34.4 + installer: v1.34.4 +` +) + +func TestCollector_Collect(t *testing.T) { + t.Parallel() + + collector := FakeCollector(t) + + info, err := collector.Collect() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if info.ClusterName != "test-cluster" { + t.Errorf("expected ClusterName %q, got %q", "test-cluster", info.ClusterName) + } + + if info.SDVersion != "v1.34.0" { + t.Errorf("expected SDVersion %q, got %q", "v1.34.0", info.SDVersion) + } + + if info.SDKind != "OnPremises" { + t.Errorf("expected SDKind %q, got %q", "OnPremises", info.SDKind) + } + + if info.SDInstallerVersion != "v1.34.4" { + t.Errorf("expected SDInstallerVersion %q, got %q", "v1.34.4", info.SDInstallerVersion) + } + + if info.KubernetesVersion != "v1.34.4" { + t.Errorf("expected KubernetesVersion %q, got %q", "v1.34.4", info.KubernetesVersion) + } +} + +//nolint:paralleltest // TestHelperProcess is a subprocess helper used by execx.NewFakeExecutor, not a real test. +func TestHelperProcess(t *testing.T) { + args := os.Args + + if len(args) < 3 || args[1] != "-test.run=TestHelperProcess" { + return + } + + cmd, subcmd := args[3], args[4] + + switch cmd { + case "kubectl": + switch subcmd { + case "version": + fmt.Fprintf(os.Stdout, `{"clientVersion":{"gitVersion":"v1.34.4"},"serverVersion":{"gitVersion":"v1.34.4"}}`) + + case "get": + if args[5] == "-A" { + // Nodes call: kubectl get -A nodes -o json. + fmt.Fprintf(os.Stdout, `{"items":[{"metadata":{"name":"node-1","labels":{"node-role.kubernetes.io/control-plane":""}},"status":{"capacity":{"cpu":"4","memory":"8Gi"}}}]}`) + + os.Exit(0) + } + + // Namespaced call: kubectl get -n kube-system -o . + if len(args) < 9 { + os.Exit(1) + } + + resourceType := args[7] + resourceName := args[8] + + switch resourceType { + case "secret": + switch resourceName { + case "furyctl-config": + encoded := base64.StdEncoding.EncodeToString([]byte(minimalFuryctlYAML)) + fmt.Fprintf(os.Stdout, "apiVersion: v1\nkind: Secret\nmetadata:\n name: furyctl-config\n namespace: kube-system\ndata:\n config: %s\n", encoded) + + case "furyctl-kfd": + encoded := base64.StdEncoding.EncodeToString([]byte(minimalKFDYAML)) + fmt.Fprintf(os.Stdout, "apiVersion: v1\nkind: Secret\nmetadata:\n name: furyctl-kfd\n namespace: kube-system\ndata:\n kfd: %s\n", encoded) + + default: + os.Exit(1) + } + + default: + os.Exit(1) + } + + default: + os.Exit(1) + } + + default: + fmt.Fprintf(os.Stdout, "command not found") + } + + os.Exit(0) +} + +func FakeCollector(t *testing.T) *clusterinfo.Collector { + t.Helper() + + return &clusterinfo.Collector{ + KubectlRunner: kubectl.NewRunner( + execx.NewFakeExecutor("TestHelperProcess"), + kubectl.Paths{ + Kubectl: "kubectl", + }, + false, + true, + false, + ), + } +} diff --git a/internal/clusterinfo/collector_test.go b/internal/clusterinfo/collector_test.go new file mode 100644 index 000000000..89a525d8f --- /dev/null +++ b/internal/clusterinfo/collector_test.go @@ -0,0 +1,452 @@ +// Copyright (c) 2017-present SIGHUP s.r.l All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build unit + +//nolint:testpackage // white-box tests for internal helpers +package clusterinfo + +import ( + "reflect" + "sort" + "testing" + "time" +) + +func TestPrimaryRole(t *testing.T) { + t.Parallel() + + mk := func(labels ...string) map[string]string { + m := map[string]string{} + for _, r := range labels { + m["node-role.kubernetes.io/"+r] = "" + } + + return m + } + + tests := []struct { + name string + labels map[string]string + want string + }{ + {"control-plane only", mk("control-plane"), "control-plane"}, + {"master only", mk("master"), "master"}, + {"both control-plane and master", mk("control-plane", "master"), "control-plane"}, + {"multiple roles", mk("infra", "worker", "db"), "db"}, + {"no role labels", map[string]string{"foo": "bar"}, ""}, + } + + for _, tt := range tests { + got := primaryRole(tt.labels) + if got != tt.want { + t.Fatalf("%s: primaryRole() = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestRoleSort(t *testing.T) { + t.Parallel() + + input := []string{"", "worker", "master", "infra", "control-plane", "b", "a"} + want := []string{"control-plane", "master", "a", "b", "infra", "worker", ""} + + sort.Slice(input, func(i, j int) bool { return roleSort(input[i], input[j]) }) + + if !reflect.DeepEqual(input, want) { + t.Fatalf("roleSort order = %v, want %v", input, want) + } +} + +func TestParseCPU(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want int64 + }{ + {"4", 4}, + {"500m", 0}, + {"1000m", 1}, + {"1500m", 1}, + {"0", 0}, + {"", 0}, + {"abc", 0}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + if got := parseCPU(tt.in); got != tt.want { + t.Errorf("parseCPU(%q) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +func almostEqual(a, b, tol float64) bool { + if a > b { + return a-b <= tol + } + + return b-a <= tol +} + +func TestParseMemoryGb(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want float64 + }{ + {"1Gi", 1.0}, + {"1024Mi", 1.0}, + {"1048576Ki", 1.0}, + {"2Gi", 2.0}, + {"1Ti", 1024.0}, + {"1T", 1000.0}, + {"1G", 1.0}, + {"1M", 0.001}, + {"", 0.0}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + got := parseMemoryGb(tt.in) + if !almostEqual(got, tt.want, 1e-6) { + t.Errorf("parseMemoryGb(%q) = %f, want %f", tt.in, got, tt.want) + } + }) + } +} + +func TestHasCustomPatches(t *testing.T) { + t.Parallel() + + // Absent. + if hasCustomPatches(map[string]any{}) { + t.Fatal("expected false when customPatches absent") + } + + // Present but empty arrays. + m1 := map[string]any{ + "spec": map[string]any{ + "distribution": map[string]any{ + "customPatches": map[string]any{ + "networking": []any{}, + }, + }, + }, + } + + if hasCustomPatches(m1) { + t.Fatal("expected false when all arrays are empty") + } + + // One non-empty. + m2 := map[string]any{ + "spec": map[string]any{ + "distribution": map[string]any{ + "customPatches": map[string]any{ + "networking": []any{"x"}, + "logging": []any{}, + }, + }, + }, + } + + if !hasCustomPatches(m2) { + t.Fatal("expected true when one array has elements") + } + + // Not a map. + m3 := map[string]any{ + "spec": map[string]any{ + "distribution": map[string]any{ + "customPatches": []any{"oops"}, + }, + }, + } + + if hasCustomPatches(m3) { + t.Fatal("expected false when customPatches is not a map") + } +} + +func TestEtcdTopology(t *testing.T) { + t.Parallel() + + // Non OnPremises. + if got := etcdTopology("EKSCluster", nil); got != "" { + t.Fatalf("expected empty for EKSCluster, got %q", got) + } + + if got := etcdTopology("KFDDistribution", nil); got != "" { + t.Fatalf("expected empty for KFDDistribution, got %q", got) + } + + // OnPremises cases. + if got := etcdTopology("OnPremises", map[string]any{}); got != "Stacked" { + t.Fatalf("expected Stacked when etcd missing, got %q", got) + } + + mEmpty := map[string]any{ + "spec": map[string]any{ + "kubernetes": map[string]any{ + "etcd": map[string]any{ + "hosts": []any{}, + }, + }, + }, + } + + if got := etcdTopology("OnPremises", mEmpty); got != "Stacked" { + t.Fatalf("expected Stacked when hosts empty, got %q", got) + } + + mDedicated := map[string]any{ + "spec": map[string]any{ + "kubernetes": map[string]any{ + "etcd": map[string]any{ + "hosts": []any{"h1"}, + }, + }, + }, + } + + if got := etcdTopology("OnPremises", mDedicated); got != "Dedicated" { + t.Fatalf("expected Dedicated when hosts present, got %q", got) + } +} + +func TestIngressType(t *testing.T) { + t.Parallel() + + // All none/absent. + if got := ingressType(map[string]any{}); got != "none" { + t.Fatalf("expected none, got %q", got) + } + + // Nginx only. + nginx := map[string]any{ + "ingress": map[string]any{ + "nginx": map[string]any{"type": "single"}, + }, + } + + if got := ingressType(nginx); got != "nginx/single" { + t.Fatalf("expected nginx/single, got %q", got) + } + + // Haproxy only. + hap := map[string]any{ + "ingress": map[string]any{ + "haproxy": map[string]any{"type": "dual"}, + }, + } + + if got := ingressType(hap); got != "haproxy/dual" { + t.Fatalf("expected haproxy/dual, got %q", got) + } + + // Both nginx and haproxy. Output order is deterministic because ingressType appends + // nginx, haproxy, byoic in a fixed sequence, not by iterating the input map. + both := map[string]any{ + "ingress": map[string]any{ + "nginx": map[string]any{"type": "single"}, + "haproxy": map[string]any{"type": "dual"}, + }, + } + + if got := ingressType(both); got != "nginx/single, haproxy/dual" { + t.Fatalf("expected combined output, got %q", got) + } + + // Byoic enabled with class. + byoicWithClass := map[string]any{ + "ingress": map[string]any{ + "byoic": map[string]any{"enabled": true, "ingressClass": "custom"}, + }, + } + + if got := ingressType(byoicWithClass); got != "byoic/custom" { + t.Fatalf("expected byoic/custom, got %q", got) + } + + // Byoic enabled without class. + byoicEnabled := map[string]any{ + "ingress": map[string]any{ + "byoic": map[string]any{"enabled": true}, + }, + } + + if got := ingressType(byoicEnabled); got != "byoic" { + t.Fatalf("expected byoic, got %q", got) + } + + // Byoic disabled. + byoicDisabled := map[string]any{ + "ingress": map[string]any{ + "byoic": map[string]any{"enabled": false, "ingressClass": "custom"}, + }, + } + + if got := ingressType(byoicDisabled); got != "none" { + t.Fatalf("expected none when byoic disabled, got %q", got) + } + + // Nginx type none excluded. + nginxNone := map[string]any{ + "ingress": map[string]any{ + "nginx": map[string]any{"type": "none"}, + }, + } + + if got := ingressType(nginxNone); got != "none" { + t.Fatalf("expected none when nginx type is none, got %q", got) + } +} + +func TestExtractPlugins(t *testing.T) { + t.Parallel() + + // No plugins key. + if extractPlugins(map[string]any{}) != nil { + t.Fatal("expected nil when plugins absent") + } + + // Present but empty. + empty := map[string]any{ + "spec": map[string]any{ + "plugins": map[string]any{ + "kustomize": []any{}, + "helm": map[string]any{"releases": []any{}}, + }, + }, + } + + if extractPlugins(empty) != nil { + t.Fatal("expected nil when plugins lists are empty") + } + + // Kustomize only. + kus := map[string]any{ + "spec": map[string]any{ + "plugins": map[string]any{ + "kustomize": []any{ + map[string]any{"name": "a"}, + map[string]any{"name": "b"}, + }, + }, + }, + } + + got := extractPlugins(kus) + + if got == nil || !reflect.DeepEqual(got.Kustomize, []string{"a", "b"}) || got.Helm != nil { + t.Fatalf("unexpected kustomize-only parse: %+v", got) + } + + // Helm only. + helm := map[string]any{ + "spec": map[string]any{ + "plugins": map[string]any{ + "helm": map[string]any{ + "releases": []any{ + map[string]any{"name": "x"}, + map[string]any{"name": "y"}, + }, + }, + }, + }, + } + + got = extractPlugins(helm) + + if got == nil || !reflect.DeepEqual(got.Helm, []string{"x", "y"}) || got.Kustomize != nil { + t.Fatalf("unexpected helm-only parse: %+v", got) + } + + // Both. + both := map[string]any{ + "spec": map[string]any{ + "plugins": map[string]any{ + "kustomize": []any{map[string]any{"name": "k1"}}, + "helm": map[string]any{ + "releases": []any{map[string]any{"name": "h1"}}, + }, + }, + }, + } + + got = extractPlugins(both) + + if got == nil || !reflect.DeepEqual(got.Kustomize, []string{"k1"}) || !reflect.DeepEqual(got.Helm, []string{"h1"}) { + t.Fatalf("unexpected both parse: %+v", got) + } + + // Entries without name ignored. + invalid := map[string]any{ + "spec": map[string]any{ + "plugins": map[string]any{ + "kustomize": []any{map[string]any{"foo": "bar"}}, + }, + }, + } + + if got := extractPlugins(invalid); got != nil { + t.Fatalf("expected nil when only invalid entries present, got: %+v", got) + } +} + +func TestLatestManagedFieldTime(t *testing.T) { + t.Parallel() + + // No metadata. + if !latestManagedFieldTime(map[string]any{}).IsZero() { + t.Fatal("expected zero time when no metadata present") + } + + // ManagedFields with multiple timestamps. + t1, _ := time.Parse(time.RFC3339, "2024-01-01T10:00:00Z") + t2, _ := time.Parse(time.RFC3339, "2024-01-02T10:00:00Z") + raw := map[string]any{ + "metadata": map[string]any{ + "managedFields": []any{ + map[string]any{"time": t1.Format(time.RFC3339)}, + map[string]any{"time": t2.Format(time.RFC3339)}, + }, + }, + } + + got := latestManagedFieldTime(raw) + + if !got.Equal(t2) { + t.Fatalf("expected latest managedFields time %v, got %v", t2, got) + } + + // Malformed managedFields, fallback to creationTimestamp. + ct, _ := time.Parse(time.RFC3339, "2024-02-02T10:00:00Z") + raw2 := map[string]any{ + "metadata": map[string]any{ + "managedFields": []any{map[string]any{"time": "not-a-time"}}, + "creationTimestamp": ct.Format(time.RFC3339), + }, + } + + got = latestManagedFieldTime(raw2) + + if !got.Equal(ct) { + t.Fatalf("expected creationTimestamp fallback %v, got %v", ct, got) + } + + raw3 := map[string]any{"metadata": map[string]any{"creationTimestamp": "bad"}} + + if !latestManagedFieldTime(raw3).IsZero() { + t.Fatal("expected zero when creationTimestamp is malformed") + } +} diff --git a/internal/clusterinfo/info.go b/internal/clusterinfo/info.go new file mode 100644 index 000000000..8051f6d88 --- /dev/null +++ b/internal/clusterinfo/info.go @@ -0,0 +1,64 @@ +// Copyright (c) 2017-present SIGHUP s.r.l All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package clusterinfo + +import "time" + +// Info holds the complete information about an SD cluster and its status. +type Info struct { + ClusterName string `json:"clusterName" yaml:"clusterName"` + SDVersion string `json:"sdVersion" yaml:"sdVersion"` + SDKind string `json:"sdKind" yaml:"sdKind"` + SDInstallerVersion string `json:"sdInstallerVersion,omitempty" yaml:"sdInstallerVersion,omitempty"` + SDUpgradePaths []string `json:"sdUpgradePaths" yaml:"sdUpgradePaths"` + SDOngoingUpgrade *OngoingUpgrade `json:"sdOngoingUpgrade,omitempty" yaml:"sdOngoingUpgrade,omitempty"` + KubernetesVersion string `json:"kubernetesVersion" yaml:"kubernetesVersion"` + EtcdTopology string `json:"etcdTopology,omitempty" yaml:"etcdTopology,omitempty"` + LastConfigurationChange time.Time `json:"lastConfigurationChange" yaml:"lastConfigurationChange"` + CustomPatchesPresent bool `json:"customPatchesPresent" yaml:"customPatchesPresent"` + Modules []ModuleInfo `json:"modules" yaml:"modules"` + Plugins *PluginsInfo `json:"plugins,omitempty" yaml:"plugins,omitempty"` + Nodes *NodesSummary `json:"nodes,omitempty" yaml:"nodes,omitempty"` +} + +// OngoingUpgrade describes an in-progress or failed cluster upgrade. +type OngoingUpgrade struct { + Status string `json:"status" yaml:"status"` + Phase string `json:"phase" yaml:"phase"` +} + +// ModuleInfo describes an SD module with its installed version and type. +type ModuleInfo struct { + Name string `json:"name" yaml:"name"` + Version string `json:"version" yaml:"version"` + Type string `json:"type" yaml:"type"` +} + +// PluginsInfo groups plugin names by their type. +type PluginsInfo struct { + Kustomize []string `json:"kustomize,omitempty" yaml:"kustomize,omitempty"` + Helm []string `json:"helm,omitempty" yaml:"helm,omitempty"` +} + +// NodeRoleGroup holds aggregate capacity for nodes sharing the same role. +type NodeRoleGroup struct { + Role string `json:"role" yaml:"role"` + Quantity int `json:"quantity" yaml:"quantity"` + VCPU int64 `json:"vcpu" yaml:"vcpu"` + RAMGb float64 `json:"ramGb" yaml:"ramGb"` +} + +// NodesSummary groups node capacity by role with aggregate totals. +type NodesSummary struct { + Roles []NodeRoleGroup `json:"roles" yaml:"roles"` + Totals NodeTotals `json:"totals" yaml:"totals"` +} + +// NodeTotals holds the aggregate capacity across all nodes. +type NodeTotals struct { + Quantity int `json:"quantity" yaml:"quantity"` + VCPU int64 `json:"vcpu" yaml:"vcpu"` + RAMGb float64 `json:"ramGb" yaml:"ramGb"` +} From a37330b0f282e72a56b96d42bc1cfcedd3c3715b Mon Sep 17 00:00:00 2001 From: Stefano Ghinelli Date: Tue, 21 Apr 2026 10:20:21 +0200 Subject: [PATCH 2/4] Update cmd/get/cluster-info.go Co-authored-by: Ramiro Algozino --- cmd/get/cluster-info.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/get/cluster-info.go b/cmd/get/cluster-info.go index 947262c78..fae87ed3b 100644 --- a/cmd/get/cluster-info.go +++ b/cmd/get/cluster-info.go @@ -45,7 +45,7 @@ func NewClusterInfoCmd() *cobra.Command { Use: "cluster-info", Short: "Display cluster information.", Long: `Display information about the cluster, Kubernetes, and SD status. The command provides a quick overview of the cluster configuration and its current state; its output can be used for analysis and troubleshooting.`, - Example: ` furyctl get cluster-info display cluster info in text format (default) + Example: ` furyctl get cluster-info display cluster info in text format (default) furyctl get cluster-info --format json display cluster info as JSON furyctl get cluster-info --format yaml display cluster info as YAML `, From 623278296cd8dd3cc363e6312d9ac82100a7ce4f Mon Sep 17 00:00:00 2001 From: Stefano Ghinelli Date: Tue, 21 Apr 2026 10:47:50 +0200 Subject: [PATCH 3/4] chore: add tab-completion --- cmd/get/cluster-info.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/get/cluster-info.go b/cmd/get/cluster-info.go index fae87ed3b..e34cdfac4 100644 --- a/cmd/get/cluster-info.go +++ b/cmd/get/cluster-info.go @@ -123,6 +123,13 @@ func NewClusterInfoCmd() *cobra.Command { "Output format. Supported values: text, json, yaml", ) + // Tab-completion for the "format" flag. + if err := clusterInfoCmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{outputFormatText, outputFormatJSON, outputFormatYAML}, cobra.ShellCompDirectiveDefault + }); err != nil { + logrus.Fatalf("error while registering flag completion: %v", err) + } + return clusterInfoCmd } From 0ec5b2c6a767377199244b501d2cce5df8de1035 Mon Sep 17 00:00:00 2001 From: Stefano Ghinelli Date: Tue, 21 Apr 2026 10:52:24 +0200 Subject: [PATCH 4/4] feat: support immutable kind in etcdTopology --- internal/clusterinfo/collector.go | 63 +++++++++++++++++++++++++- internal/clusterinfo/collector_test.go | 49 ++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/internal/clusterinfo/collector.go b/internal/clusterinfo/collector.go index dd6f5813d..27ef91c12 100644 --- a/internal/clusterinfo/collector.go +++ b/internal/clusterinfo/collector.go @@ -523,10 +523,21 @@ func extractPlugins(configMap map[string]any) *PluginsInfo { } func etcdTopology(kind string, configMap map[string]any) string { - if kind != "OnPremises" { + switch kind { + case "OnPremises": + return onPremisesEtcdTopology(configMap) + + case "Immutable": + return immutableEtcdTopology(configMap) + + default: return "" } +} +// onPremisesEtcdTopology returns Dedicated when spec.kubernetes.etcd.hosts is +// non-empty, otherwise Stacked. +func onPremisesEtcdTopology(configMap map[string]any) string { etcd := nestedMap(configMap, "spec", "kubernetes", "etcd") if etcd == nil { return etcdStacked @@ -540,6 +551,56 @@ func etcdTopology(kind string, configMap map[string]any) string { return etcdDedicated } +// immutableEtcdTopology returns Stacked when etcd members are a subset of +// controlPlane members, otherwise Dedicated. +func immutableEtcdTopology(configMap map[string]any) string { + etcdHosts := memberHostnames(nestedMap(configMap, "spec", "kubernetes", "etcd")) + if len(etcdHosts) == 0 { + return etcdStacked + } + + cpHosts := memberHostnames(nestedMap(configMap, "spec", "kubernetes", "controlPlane")) + + cpSet := make(map[string]struct{}, len(cpHosts)) + for _, h := range cpHosts { + cpSet[h] = struct{}{} + } + + for _, h := range etcdHosts { + if _, ok := cpSet[h]; !ok { + return etcdDedicated + } + } + + return etcdStacked +} + +func memberHostnames(section map[string]any) []string { + if section == nil { + return nil + } + + members, ok := section["members"].([]any) + if !ok { + return nil + } + + hostnames := make([]string, 0, len(members)) + + for _, m := range members { + entry, ok := m.(map[string]any) + if !ok { + continue + } + + if h := stringField(entry, "hostname"); h != "" { + hostnames = append(hostnames, h) + } + } + + return hostnames +} + func installerVersion(kind string, sd distroconf.KFD) string { switch kind { case "OnPremises": diff --git a/internal/clusterinfo/collector_test.go b/internal/clusterinfo/collector_test.go index 89a525d8f..6bc2102ce 100644 --- a/internal/clusterinfo/collector_test.go +++ b/internal/clusterinfo/collector_test.go @@ -221,6 +221,55 @@ func TestEtcdTopology(t *testing.T) { if got := etcdTopology("OnPremises", mDedicated); got != "Dedicated" { t.Fatalf("expected Dedicated when hosts present, got %q", got) } + + // Immutable cases. + if got := etcdTopology("Immutable", map[string]any{}); got != "Stacked" { + t.Fatalf("expected Stacked when etcd missing for Immutable, got %q", got) + } + + mImmutableStacked := map[string]any{ + "spec": map[string]any{ + "kubernetes": map[string]any{ + "controlPlane": map[string]any{ + "members": []any{ + map[string]any{"hostname": "ctrl01"}, + map[string]any{"hostname": "ctrl02"}, + }, + }, + "etcd": map[string]any{ + "members": []any{ + map[string]any{"hostname": "ctrl01"}, + }, + }, + }, + }, + } + + if got := etcdTopology("Immutable", mImmutableStacked); got != "Stacked" { + t.Fatalf("expected Stacked when etcd members are a subset of controlPlane, got %q", got) + } + + mImmutableDedicated := map[string]any{ + "spec": map[string]any{ + "kubernetes": map[string]any{ + "controlPlane": map[string]any{ + "members": []any{ + map[string]any{"hostname": "ctrl01"}, + map[string]any{"hostname": "ctrl02"}, + }, + }, + "etcd": map[string]any{ + "members": []any{ + map[string]any{"hostname": "etcd01"}, + }, + }, + }, + }, + } + + if got := etcdTopology("Immutable", mImmutableDedicated); got != "Dedicated" { + t.Fatalf("expected Dedicated when etcd members differ from controlPlane, got %q", got) + } } func TestIngressType(t *testing.T) {