# Building ferctl top: Kubernetes resource usage vs requests and limits

> **Series:** Platform engineering with Go | **Topics:** Go, Kubernetes, Cobra, client-go, metrics-server, Platform Engineering

*This is part of the* ***Platform Engineering with Go*** *series. This post builds on the Cobra CLI patterns from post 4 and client-go from post 3.* [*Read post 4 first*](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) *if you haven't yet.*

* * *

## kubectl top tells you what's happening. It doesn't tell you how close to the edge you are.

In [post 3](https://ferztyle.me/talking-to-kubernetes-from-go-a-practical-client-go-guide) and [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide), we built a health reporter and learned how to structure a Go CLI with Cobra. Now we put both together into something with real operational value.

```bash
kubectl top pods -n production

NAME                      CPU(cores)   MEMORY(bytes)
go-api-7d6b9f8c4-xk2pq   240m         490Mi
go-api-7d6b9f8c4-mn9rt   180m         210Mi
go-api-7d6b9f8c4-p8wvz   200m         198Mi
```

That first pod is using 490Mi of memory. Is that fine or is that a problem? Without knowing the limit, you can't tell. You'd have to run `kubectl describe pod go-api-7d6b9f8c4-xk2pq`, find the resources section, do the mental arithmetic, and repeat for every pod you care about.

`ferctl top` does all of that in one command:

```bash
ferctl top -n production

NAMESPACE    NAME                      CPU USE  CPU REQ  CPU LIM  CPU%  MEM USE  MEM REQ  MEM LIM  MEM%  STATUS
production   go-api-7d6b9f8c4-xk2pq   240m     250m     500m     48%   490Mi    256Mi    512Mi    95%   !! CRITICAL
production   go-api-7d6b9f8c4-mn9rt   180m     250m     500m     36%   210Mi    256Mi    512Mi    41%      OK
production   go-api-7d6b9f8c4-p8wvz   200m     250m     500m     40%   198Mi    256Mi    512Mi    38%      OK
```

One pod is at 95% of its memory limit. In production, that's a page waiting to happen. `ferctl top` catches it before it becomes an incident.

* * *

## What you'll learn

*   How to extend the Cobra CLI structure from [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) with a real subcommand
    
*   How to query the metrics-server API using `k8s.io/metrics`
    
*   How to correlate live metrics with pod specs to show usage vs limits
    
*   How to implement configurable near-limit warnings
    
*   How to format clean aligned output with `tabwriter`
    
*   How to verify the tool against your real minikube cluster
    

* * *

## Prerequisites

*   Posts 1–4 read; client-go patterns from [post 3](https://ferztyle.me/talking-to-kubernetes-from-go-a-practical-client-go-guide), Cobra CLI structure from [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide)
    
*   minikube running with metrics-server enabled (from [post 2](https://ferztyle.me/setting-up-a-local-kubernetes-cluster-with-minikube))
    
*   Understand what limits and requests are in Kubernetes (from [post1](https://ferztyle.me/kubernetes-resource-requests-and-limits-explained-scheduling-throttling-and-oomkill))
    
*   Go 1.26.3 installed
    
*   The `go-api` deployment from [post 2](https://ferztyle.me/setting-up-a-local-kubernetes-cluster-with-minikube) running in your cluster
    

* * *

## Project structure

As always, go to this [post](https://ferztyle.me/go-packages-and-modules-explained) if you don't know how to create a structure for your Golang project.

> Also, you can find the completed repo [here](https://github.com/FerRiosCosta/ferctl)

```plaintext
ferctl/
├── go.mod
├── Makefile
├── cmd/
│   └── ferctl/
│       └── main.go                  ← 3 lines — just calls Execute()
└── internal/
    ├── kubernetes/
    │   ├── client.go                ← KubeClient interface + real implementation
    │   └── metrics.go               ← MetricsClient interface + real implementation
    ├── top/
    │   ├── command.go               ← Cobra command — flags and wiring only
    │   ├── runner.go                ← business logic — fetches and correlates data
    │   ├── output.go                ← tabwriter formatting
    │   └── types.go                 ← PodRow and shared types
    └── threshold/
        └── threshold.go             ← near-limit warning logic — isolated and reusable
```

Each file has one reason to change, a design decision that will pay off in the next post when we add a namespace summary and JSON output without touching any of the existing files.

Here's the `go.mod`:

```plaintext
module github.com/FerRiosCosta/ferctl

go 1.26

require (
    github.com/spf13/cobra      v1.10.2
    k8s.io/api                  v0.35.1
    k8s.io/apimachinery         v0.35.1
    k8s.io/client-go            v0.35.1
    k8s.io/metrics              v0.35.1
)
```

* * *

## Installing the dependencies

Before writing any code, initialize the module and install all dependencies. Run these commands inside your project directory:

```bash
mkdir ferctl && cd ferctl
go mod init github.com/FerRiosCosta/ferctl

# Install Cobra, the CLI framework
go get github.com/spf13/cobra@v1.10.2

# Install the Kubernetes packages, all pinned to match your server version
go get k8s.io/client-go@v0.35.1
go get k8s.io/api@v0.35.1
go get k8s.io/apimachinery@v0.35.1

# Install the metrics package, separate from client-go, explained below
go get k8s.io/metrics@v0.35.1

# Fetch all transitive dependencies and clean up
go get ./...
go mod tidy
```

### What is go get ./...?

You'll notice we run `go get ./...` after installing the individual packages. Here's what it does and why it matters.

`./...` means "every package in this module, recursively", `.` is the current directory, and `...` expands to all subdirectories.

```plaintext
go get ./...
      │
      ▼
Scans every .go file in your module
      │
      ▼
Finds every import, including transitive ones
(packages that your packages depend on, that you never imported directly)
      │
      ▼
Downloads missing packages and adds them to go.mod and go.sum
```

When you run individual `go get` commands, Go downloads those specific packages, but doesn't always fetch everything they depend on internally. `k8s.io/client-go` itself imports `k8s.io/klog/v2`, `github.com/spf13/pflag`, `golang.org/x/net`, and many others. Those transitive dependencies might not be in your `go.sum` yet.

Without `go get ./...` you might see errors like:

```plaintext
missing go.sum entry for module providing package k8s.io/klog/v2
```

That's `k8s.io/klog/v2`, a logging package that `k8s.io/client-go` uses internally. You never imported it directly, but Go needs its checksum in `go.sum` to verify the build. `go get ./...` catches it automatically.

### What is go mod tidy?

`go mod tidy` and `go get ./...` are often confused because they're usually run together. They do different things:

```plaintext
go get ./...   → ADDS missing dependencies
               → fetches everything your code needs

go mod tidy    → CLEANS UP
               → removes dependencies that are no longer used
               → verifies go.sum is complete and consistent
```

Think of it this way:

*   `go get ./...` — *"make sure everything I need is downloaded"*
    
*   `go mod tidy` — *"remove anything I don't need and make sure go.sum is complete"*
    

Running both together is the safest way to ensure your module is in a clean, consistent state. Always run `go mod tidy` last; it's the final cleanup step.

> You don't need to run `go mod tidy` immediately; better to have all your code written and then run it.

### Why is k8s.io/metrics a separate package?

The `k8s.io/metrics` package is not part of `k8s.io/client-go`. It's a separate package because metrics-server is an extension API, not a core Kubernetes API. The Kubernetes API server doesn't collect resource usage itself; it delegates that to metrics-server, which exposes its data through a separate API endpoint. That's why it needs its own client package.

Before writing any code, verify metrics-server is running and collecting data:

```bash
kubectl top pods -n default

# NAME                      CPU(cores)   MEMORY(bytes)
# go-api-7d6b9f8c4-xk2pq   2m           11Mi
```

If you see `error: Metrics API not available`, metrics-server isn't ready yet. Wait 60 seconds and try again. If it's not installed, enable it:

```bash
minikube addons enable metrics-server
```

* * *

## The entry point

```go
// cmd/ferctl/main.go
package main

import "github.com/FerRiosCosta/ferctl/internal/top"

// main is intentionally minimal, all wiring happens in internal packages.
func main() {
    top.Execute()
}
```

* * *

## The Kubernetes client

The `KubeClient` interface is the same pattern from [post 3](https://ferztyle.me/talking-to-kubernetes-from-go-a-practical-client-go-guide). We need one additional method, `ListPods` to fetch resource requests and limits from pod specs:

```go
// internal/kubernetes/client.go
package kubernetes

import (
    "context"
    "fmt"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
    "k8s.io/client-go/tools/clientcmd"
)

// KubeClient defines the Kubernetes operations ferctl uses.
// Wrapping the Clientset behind an interface keeps ferctl testable
// without a real cluster.
type KubeClient interface {
    // ListPods returns all pods in the given namespace.
    // An empty namespace string returns pods from all namespaces.
    ListPods(ctx context.Context, namespace string) (*corev1.PodList, error)
}

// client is the real implementation backed by the official Kubernetes Clientset.
// Unexported, callers always use the KubeClient interface.
type client struct {
    clientset kubernetes.Interface
}

// NewClient creates a KubeClient from the kubeconfig at the given path.
// Tries in-cluster config first, then falls back to kubeconfig file.
func NewClient(kubeconfigPath string) (KubeClient, error) {
    config, err := buildConfig(kubeconfigPath)
    if err != nil {
        return nil, fmt.Errorf("build kubeconfig: %w", err)
    }
    clientset, err := kubernetes.NewForConfig(config)
    if err != nil {
        return nil, fmt.Errorf("create clientset: %w", err)
    }
    return &client{clientset: clientset}, nil
}

// buildConfig tries in-cluster config first (inside a pod), then
// falls back to the kubeconfig file on disk (local development).
func buildConfig(kubeconfigPath string) (*rest.Config, error) {
    if config, err := rest.InClusterConfig(); err == nil {
        return config, nil
    }
    return clientcmd.BuildConfigFromFlags("", kubeconfigPath)
}

func (c *client) ListPods(ctx context.Context, namespace string) (*corev1.PodList, error) {
    pods, err := c.clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
    if err != nil {
        return nil, fmt.Errorf("list pods namespace=%q: %w", namespace, err)
    }
    return pods, nil
}
```

* * *

## The metrics client

The metrics client queries the metrics-server API for real-time CPU and memory usage. We define our own `PodMetrics` type rather than exposing the raw metrics API type, this decouples the rest of ferctl from the metrics API's exact structure:

```go
// internal/kubernetes/metrics.go
package kubernetes

import (
    "context"
    "fmt"

    "k8s.io/apimachinery/pkg/api/resource"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    metrics "k8s.io/metrics/pkg/client/clientset/versioned"
)

// PodMetrics holds real-time resource usage for a single pod.
// We define our own type here rather than exposing the raw metrics API
// type, this decouples the rest of ferctl from the metrics API's
// exact structure and makes the fake trivial to build.
type PodMetrics struct {
    Name      string
    Namespace string
    CPU       resource.Quantity // current CPU usage in millicores
    Memory    resource.Quantity // current memory usage in bytes
}

// MetricsClient defines the metrics-server operations ferctl uses.
type MetricsClient interface {
    // ListPodMetrics returns current CPU and memory usage for all pods
    // in the given namespace. An empty namespace returns all namespaces.
    ListPodMetrics(ctx context.Context, namespace string) ([]PodMetrics, error)
}

// metricsClient is the real implementation backed by the metrics-server API.
type metricsClient struct {
    clientset metrics.Interface
}

// NewMetricsClient creates a MetricsClient connected to the metrics-server.
func NewMetricsClient(kubeconfigPath string) (MetricsClient, error) {
    config, err := buildConfig(kubeconfigPath)
    if err != nil {
        return nil, fmt.Errorf("build kubeconfig for metrics: %w", err)
    }
    clientset, err := metrics.NewForConfig(config)
    if err != nil {
        return nil, fmt.Errorf("create metrics clientset: %w", err)
    }
    return &metricsClient{clientset: clientset}, nil
}

// ListPodMetrics fetches current resource usage from the metrics-server
// and maps it to our own PodMetrics type.
func (m *metricsClient) ListPodMetrics(ctx context.Context, namespace string) ([]PodMetrics, error) {
    list, err := m.clientset.MetricsV1beta1().PodMetricses(namespace).List(ctx, metav1.ListOptions{})
    if err != nil {
        return nil, fmt.Errorf("list pod metrics namespace=%q: %w", namespace, err)
    }

    result := make([]PodMetrics, 0, len(list.Items))
    for _, item := range list.Items {
        // Sum CPU and memory across all containers in the pod.
        // A pod can have multiple containers, sidecars, init containers,
        // and we want the total resource usage, not just the main container.
        var totalCPU, totalMemory resource.Quantity
        for _, container := range item.Containers {
            totalCPU.Add(*container.Usage.Cpu())
            totalMemory.Add(*container.Usage.Memory())
        }

        result = append(result, PodMetrics{
            Name:      item.Name,
            Namespace: item.Namespace,
            CPU:       totalCPU,
            Memory:    totalMemory,
        })
    }

    return result, nil
}
```

* * *

## The threshold package

Warning logic lives in its own package; isolated, independently testable, and reusable across any future ferctl command that needs near-limit detection:

```go
// internal/threshold/threshold.go
package threshold

import "k8s.io/apimachinery/pkg/api/resource"

// DefaultWarningPercent is the usage percentage above which a pod
// is considered near its limit and should be flagged.
const DefaultWarningPercent = 80

// Status represents how close a pod's usage is to its configured limit.
type Status int

const (
    StatusOK       Status = iota // usage is comfortably below the limit
    StatusWarning                // usage is above the warning threshold
    StatusCritical               // usage is at or above 95% of the limit
)

// String returns a human-readable label for display in the output table.
func (s Status) String() string {
    switch s {
    case StatusWarning:
        return "!  WARNING"
    case StatusCritical:
        return "!! CRITICAL"
    default:
        return "   OK"
    }
}

// Check returns the Status of a resource given its current usage and limit.
// If the limit is zero (not set), returns StatusOK, we can't warn without
// a limit to compare against.
func Check(usage, limit resource.Quantity, warningPercent int) Status {
    if limit.IsZero() {
        return StatusOK
    }

    usageVal := usage.MilliValue()
    limitVal := limit.MilliValue()

    if limitVal == 0 {
        return StatusOK
    }

    percent := (usageVal * 100) / limitVal

    switch {
    case percent >= 95:
        return StatusCritical
    case percent >= int64(warningPercent):
        return StatusWarning
    default:
        return StatusOK
    }
}

// Percent returns usage as a percentage of the limit.
// Returns 0 if the limit is zero; avoids division by zero.
func Percent(usage, limit resource.Quantity) int64 {
    if limit.IsZero() {
        return 0
    }

    usageVal := usage.MilliValue()
    limitVal := limit.MilliValue()

    if limitVal == 0 {
        return 0
    }

    return (usageVal * 100) / limitVal
}
```

* * *

## The shared types

```go
// internal/top/types.go
package top

import (
    "k8s.io/apimachinery/pkg/api/resource"

    "github.com/FerRiosCosta/ferctl/internal/threshold"
)

// PodRow represents one row in the ferctl top output table.
// It's the intermediate data type between data fetching (runner.go)
// and formatting (output.go). Keeping it separate means the output
// layer can be swapped, tabwriter today, JSON in post 6, without
// touching the runner at all.
type PodRow struct {
    Namespace  string
    Name       string
    CPUUsage   resource.Quantity
    CPURequest resource.Quantity
    CPULimit   resource.Quantity
    CPUPercent int64
    CPUStatus  threshold.Status
    MemUsage   resource.Quantity
    MemRequest resource.Quantity
    MemLimit   resource.Quantity
    MemPercent int64
    MemStatus  threshold.Status
}
```

* * *

## The runner, correlating pods and metrics

The runner is the business logic layer. It fetches pod specs and metrics independently, correlates them by namespace and name, and returns a slice of `PodRow` ready for display. It knows nothing about Cobra or tabwriter, just data in, data out:

```go
// internal/top/runner.go
package top

import (
    "context"
    "fmt"

    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/resource"

    k8s "github.com/FerRiosCosta/ferctl/internal/kubernetes"
    "github.com/FerRiosCosta/ferctl/internal/threshold"
)

// Config holds everything the runner needs to execute.
// Using a struct instead of individual parameters makes it easy
// to add new options in the future without changing the function signature.
type Config struct {
    Namespace      string
    AllNamespaces  bool
    WarningPercent int
}

// Runner fetches and correlates Kubernetes resource data.
// It depends on interfaces, never on concrete client-go types,
// so it works identically with real clients and fakes.
type Runner struct {
    kube    k8s.KubeClient
    metrics k8s.MetricsClient
}

// NewRunner creates a Runner with its dependencies injected.
func NewRunner(kube k8s.KubeClient, metrics k8s.MetricsClient) *Runner {
    return &Runner{kube: kube, metrics: metrics}
}

// Run fetches pod specs and metrics, correlates them, and returns
// a slice of PodRows ready for the output formatter.
func (r *Runner) Run(ctx context.Context, cfg Config) ([]PodRow, error) {
    namespace := cfg.Namespace
    if cfg.AllNamespaces {
        // Empty string means all namespaces in the Kubernetes API.
        namespace = ""
    }

    // Fetch pod specs, gives us requests and limits.
    pods, err := r.kube.ListPods(ctx, namespace)
    if err != nil {
        return nil, fmt.Errorf("fetch pods: %w", err)
    }

    // Fetch live resource usage from metrics-server.
    podMetrics, err := r.metrics.ListPodMetrics(ctx, namespace)
    if err != nil {
        return nil, fmt.Errorf("fetch metrics: %w", err)
    }

    // Build a lookup map from pod metrics for O(1) correlation.
    // Key format: "namespace/name", uniquely identifies a pod in the cluster.
    metricsMap := make(map[string]k8s.PodMetrics, len(podMetrics))
    for _, m := range podMetrics {
        key := m.Namespace + "/" + m.Name
        metricsMap[key] = m
    }

    rows := make([]PodRow, 0, len(pods.Items))
    for _, pod := range pods.Items {
        // Skip non-running pods, they won't have metrics from metrics-server.
        if pod.Status.Phase != corev1.PodRunning {
            continue
        }

        // Aggregate requests and limits across all containers in the pod.
        cpuReq, cpuLim, memReq, memLim := aggregateResources(&pod)

        row := PodRow{
            Namespace:  pod.Namespace,
            Name:       pod.Name,
            CPURequest: cpuReq,
            CPULimit:   cpuLim,
            MemRequest: memReq,
            MemLimit:   memLim,
        }

        // Look up live metrics for this pod.
        // Newly started pods may not have metrics yet, handle gracefully
        // by leaving usage fields at their zero values.
        key := pod.Namespace + "/" + pod.Name
        if m, ok := metricsMap[key]; ok {
            row.CPUUsage   = m.CPU
            row.CPUPercent = threshold.Percent(m.CPU, cpuLim)
            row.CPUStatus  = threshold.Check(m.CPU, cpuLim, cfg.WarningPercent)
            row.MemUsage   = m.Memory
            row.MemPercent = threshold.Percent(m.Memory, memLim)
            row.MemStatus  = threshold.Check(m.Memory, memLim, cfg.WarningPercent)
        }

        rows = append(rows, row)
    }

    return rows, nil
}

// aggregateResources sums CPU and memory requests and limits across
// all containers in a pod, including sidecars and init containers.
// This matches how Kubernetes accounts for pod resource usage.
func aggregateResources(pod *corev1.Pod) (cpuReq, cpuLim, memReq, memLim resource.Quantity) {
    for _, container := range pod.Spec.Containers {
        res := container.Resources
        if req := res.Requests.Cpu(); req != nil {
            cpuReq.Add(*req)
        }
        if lim := res.Limits.Cpu(); lim != nil {
            cpuLim.Add(*lim)
        }
        if req := res.Requests.Memory(); req != nil {
            memReq.Add(*req)
        }
        if lim := res.Limits.Memory(); lim != nil {
            memLim.Add(*lim)
        }
    }
    return
}
```

* * *

## The output formatter

```go
// internal/top/output.go
package top

import (
    "fmt"
    "io"
    "text/tabwriter"

    "k8s.io/apimachinery/pkg/api/resource"

    "github.com/FerRiosCosta/ferctl/internal/threshold"
)

// PrintTable writes the pod rows as an aligned table to w.
// Accepting io.Writer instead of os.Stdout directly means this
// function is testable, tests can pass a bytes.Buffer and inspect output.
func PrintTable(w io.Writer, rows []PodRow) {
    // tabwriter aligns columns by padding with spaces.
    // Arguments: output, minwidth, tabwidth, padding, padchar, flags.
    tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
    defer tw.Flush()

    // Print the header row, \t is the column separator tabwriter uses.
    fmt.Fprintln(tw, "NAMESPACE\tNAME\tCPU USE\tCPU REQ\tCPU LIM\tCPU%\tMEM USE\tMEM REQ\tMEM LIM\tMEM%\tSTATUS")

    for _, row := range rows {
        // The overall row status is the worse of CPU and memory status.
        // If either is CRITICAL, the whole row is CRITICAL.
        status := worstStatus(row.CPUStatus, row.MemStatus)

        fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%d%%\t%s\t%s\t%s\t%d%%\t%s\n",
            row.Namespace,
            row.Name,
            formatQuantity(row.CPUUsage, "m"),    // CPU in millicores
            formatQuantity(row.CPURequest, "m"),
            formatQuantity(row.CPULimit, "m"),
            row.CPUPercent,
            formatQuantity(row.MemUsage, "Mi"),   // memory in mebibytes
            formatQuantity(row.MemRequest, "Mi"),
            formatQuantity(row.MemLimit, "Mi"),
            row.MemPercent,
            status.String(),
        )
    }
}

// worstStatus returns the more severe of two threshold statuses.
// CRITICAL > WARNING > OK, we always surface the worst condition.
func worstStatus(a, b threshold.Status) threshold.Status {
    if a > b {
        return a
    }
    return b
}

// formatQuantity converts a resource.Quantity to a readable string.
// unit controls the suffix: "m" for millicores, "Mi" for mebibytes.
func formatQuantity(q resource.Quantity, unit string) string {
    switch unit {
    case "m":
        return fmt.Sprintf("%dm", q.MilliValue())
    case "Mi":
        // Convert bytes to mebibytes (1 MiB = 1024 * 1024 bytes)
        return fmt.Sprintf("%dMi", q.Value()/(1024*1024))
    default:
        return q.String()
    }
}
```

* * *

## The Cobra command

In [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) we built the full Cobra foundation, root command, persistent flags, `RunE`, `SilenceUsage`, and the `init()` wiring pattern. This file follows exactly the same structure. If anything here looks unfamiliar, [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) covers it in depth.

The key difference from the `k8s-info` example in [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) is that ferctl has two clients to initialize (Kubernetes and metrics) and passes them into a dedicated runner rather than doing the work inline in `RunE`. The Cobra layer stays thin; it only handles flags and wiring.

```go
// internal/top/command.go
package top

import (
    "context"
    "fmt"
    "os"

    "github.com/spf13/cobra"

    k8s "github.com/FerRiosCosta/ferctl/internal/kubernetes"
    "github.com/FerRiosCosta/ferctl/internal/threshold"
)

// flags holds the parsed flag values across all subcommands.
// Using a struct keeps flag handling tidy as the CLI grows.
type flags struct {
    namespace      string
    allNamespaces  bool
    kubeconfig     string
    warningPercent int
}

var f flags

// rootCmd is the base command, every subcommand is registered on it.
var rootCmd = &cobra.Command{
    Use:   "ferctl",
    Short: "Platform tooling for your Kubernetes cluster",
    Long:  "ferctl wraps common Kubernetes workflows into team-friendly commands.",
    // SilenceUsage prevents Cobra from printing the full usage text on
    // every error, covered in post 4. Add it to every root command.
    SilenceUsage: true,
}

// topCmd is the "ferctl top" subcommand.
var topCmd = &cobra.Command{
    Use:   "top",
    Short: "Show resource usage vs requests and limits",
    Long: `Show CPU and memory usage alongside configured requests and limits
for every running pod. Flags pods approaching their limits.`,
    Example: `  ferctl top -n default
  ferctl top --all-namespaces
  ferctl top -n default --warning-percent 70`,
    RunE: func(cmd *cobra.Command, args []string) error {
        ctx := context.Background()

        // Build the Kubernetes client, real implementation using kubeconfig.
        kube, err := k8s.NewClient(f.kubeconfig)
        if err != nil {
            return fmt.Errorf("connect to cluster: %w", err)
        }

        // Build the metrics client, connects to metrics-server.
        metricsClient, err := k8s.NewMetricsClient(f.kubeconfig)
        if err != nil {
            return fmt.Errorf("connect to metrics-server: %w", err)
        }

        // Inject both clients into the runner, business logic is
        // completely decoupled from how clients are created.
        runner := NewRunner(kube, metricsClient)

        rows, err := runner.Run(ctx, Config{
            Namespace:      f.namespace,
            AllNamespaces:  f.allNamespaces,
            WarningPercent: f.warningPercent,
        })
        if err != nil {
            return err
        }

        if len(rows) == 0 {
            fmt.Println("no running pods found")
            return nil
        }

        PrintTable(os.Stdout, rows)
        return nil
    },
}

func init() {
    // --kubeconfig is a persistent flag, inherited by all subcommands.
    // Defaults to the standard kubectl location (~/.kube/config).
    // You almost never need to type it, the default handles it automatically.
    rootCmd.PersistentFlags().StringVar(
        &f.kubeconfig,
        "kubeconfig",
        os.Getenv("HOME")+"/.kube/config",
        "path to kubeconfig file",
    )

    // Flags specific to the top subcommand, local flags, not persistent.
    topCmd.Flags().StringVarP(&f.namespace, "namespace", "n", "default", "namespace to query")
    topCmd.Flags().BoolVar(&f.allNamespaces, "all-namespaces", false, "query all namespaces")
    topCmd.Flags().IntVar(
        &f.warningPercent,
        "warning-percent",
        threshold.DefaultWarningPercent,
        "usage % above which to show a warning (default 80)",
    )

    rootCmd.AddCommand(topCmd)
}

// Execute is the public entry point called from main.go.
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
```

* * *

## The Makefile

[Post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) covered what a Makefile is, how targets and dependencies work, and the tab character rule. This Makefile follows the same pattern, the only addition is a `test` target and the git version embedding introduced in [post 4's](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) Makefile:

```makefile
BINARY  := ferctl
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")

.PHONY: build install test clean

# -X embeds the git version into the binary at build time, same pattern as post 4.
build:
	go build \
	  -ldflags="-s -w -X github.com/FerRiosCosta/ferctl/internal/top.Version=$(VERSION)" \
	  -o $(BINARY) \
	  ./cmd/ferctl/...

# install depends on build — runs build first automatically.
install: build
	cp $(BINARY) /usr/local/bin/$(BINARY)
	@echo "installed $(BINARY) to /usr/local/bin"

# test runs the full test suite.
# -v: verbose output  -count=1: no caching  -race: detect race conditions.
test:
	go test ./... -v -count=1 -race

clean:
	rm -f $(BINARY)
```

Build and install:

```bash
make install

# Verify it's available
ferctl --help
```

* * *

## Verifying against minikube

### Healthy cluster

```bash
ferctl top -n default

NAMESPACE   NAME                      CPU USE  CPU REQ  CPU LIM  CPU%  MEM USE  MEM REQ  MEM LIM  MEM%  STATUS
default     go-api-7d6b9f8c4-xk2pq   2m       50m      100m     2%    11Mi     32Mi     64Mi     17%      OK
default     go-api-7d6b9f8c4-mn9rt   2m       50m      100m     2%    10Mi     32Mi     64Mi     15%      OK
default     go-api-7d6b9f8c4-p8wvz   2m       50m      100m     2%    11Mi     32Mi     64Mi     17%      OK
```

### All namespaces

```bash
ferctl top --all-namespaces

NAMESPACE     NAME                                CPU USE  CPU REQ  CPU LIM  CPU%  MEM USE  MEM REQ  MEM LIM  MEM%  STATUS
default       go-api-7d6b9f8c4-xk2pq             2m       50m      100m     2%    11Mi     32Mi     64Mi     17%      OK
kube-system   coredns-5dd5756b68-7wvzp            4m       100m     0m       0%    14Mi     70Mi     170Mi    8%       OK
kube-system   metrics-server-7746f77f97-xswk9     4m       100m     0m       0%    20Mi     200Mi    0Mi      0%       OK
```

### Triggering a warning

Use the `memory-hog.yaml` from [post 4](https://ferztyle.me/building-go-clis-with-cobra-a-practical-guide) to create a pod that's under memory pressure:

```bash
kubectl apply -f memory-hog.yaml

# Wait for CrashLoopBackOff
kubectl get pods -w
```

Lower the warning threshold to catch it:

```bash
ferctl top -n default --warning-percent 50

NAMESPACE   NAME                      CPU USE  CPU REQ  CPU LIM  CPU%  MEM USE  MEM REQ  MEM LIM  MEM%  STATUS
default     go-api-7d6b9f8c4-xk2pq   2m       50m      100m     2%    11Mi     32Mi     64Mi     17%      OK
default     memory-hog                8m       0m       10Mi     0%    9Mi      10Mi     10Mi     90%   !! CRITICAL
```

`memory-hog` is at 90% of its memory limit, `ferctl top` catches it immediately as CRITICAL. The `go-api` pods show as OK.

Clean up:

```bash
kubectl delete pod memory-hog
```

* * *

## Common gotchas

**metrics-server not running**

If metrics-server isn't available, `ferctl top` returns a clear error immediately:

```plaintext
Error: fetch metrics: list pod metrics namespace="default":
the server is currently unable to handle the request
```

Enable it on minikube and wait 60 seconds:

```bash
minikube addons enable metrics-server
```

**Newly started pods have no metrics**

metrics-server scrapes data on a ~60 second interval. A pod that started less than a minute ago won't appear in metrics output. `ferctl top` handles this gracefully, it shows the pod with its requests and limits but leaves CPU% and MEM% at 0% rather than panicking on a missing key.

**Metrics lag under burst traffic**

metrics-server shows a point-in-time snapshot, not an average. A pod that just spiked and recovered may look fine by the time you check. For trending data over time, Prometheus with `container_memory_working_set_bytes` is more reliable. `ferctl top` is for current state visibility, not historical analysis.

**Multi-container pods, sidecars count**

`ferctl top` sums CPU and memory across all containers in a pod, including sidecars like logging agents or service mesh proxies. If a sidecar is consuming unexpected resources, it shows up in the totals. Use `kubectl top pod <name> --containers` to see per-container breakdown.

**Pods without limits show 0%**

If a pod has no memory limit set (`limits.memory` not configured), `ferctl top` shows `0Mi` for MEM LIM and `0%` for MEM%, not a division error. This is intentional: we can't calculate a percentage without a known limit. This is also a signal, pods without limits set are `BestEffort` QoS class and are evicted first under node pressure (as covered in [post 1](https://ferztyle.me/kubernetes-resource-requests-and-limits-explained-scheduling-throttling-and-oomkill)).

* * *

## Summary

`ferctl top` is what the series has been building toward; the Kubernetes theory from post 1, the local cluster from post 2, the client-go patterns from post 3, and the Cobra CLI foundation from post 4, all combined into a single tool with real operational value.

Three things to take away:

*   The `PodRow` intermediate type is what decouples the runner from the output layer, swapping tabwriter for JSON in post 6 is a 20-line change because of this design decision
    
*   The `threshold` package is isolated for a reason, reusable across any future ferctl command that needs near-limit detection
    
*   Pods without limits show `0%`, which is itself a useful signal that resource configuration is incomplete
    

* * *

## What's next

In post 6 we extend ferctl with two new features: a namespace-level resource summary for capacity planning, and `--output=json` for CI pipelines and scripting. The project structure we built here handles both additions without touching any existing files.

* * *

## Let's connect

One of the best parts of writing in public is the people you meet along the way, engineers at different stages of their journey, working on similar problems from completely different angles.

If something in this post resonated, if you spotted a bug, or if you just want to talk Go, Kubernetes, platform engineering, jazz, or jiu-jitsu. I'm always happy to hear from you.

*   [LinkedIn](https://www.linkedin.com/in/ferrios/)
    
*   [GitHub](https://github.com/yourname)
    
*   [Twitter / X](https://x.com/yourhandle)
    

*Building from Asunción, Paraguay 🇵🇾*
