DevOps

Building an Internal Developer CLI in Go for Kubernetes Environment Management: From Idea to Distribution

Ruslan Ismailov Published 18 min read
B

Why Your Team Needs a Dedicated CLI Instead of a Bundle of Scripts

Every growing platform team eventually finds itself in the same situation: the scripts/ repository turns into a chaos of Bash files, Makefile targets, and Python one-liners. A new developer spends an entire day just figuring out which script to run and in what order. CI/CD pipelines duplicate logic that already exists locally. One engineer updates a script and breaks a colleague's environment.

Common pain points familiar to DevOps engineers and platform teams:

  • No single entry point for Kubernetes environment operations
  • Scripts are undocumented, flags are passed via environment variables in arbitrary order
  • Impossible to test scripts without a real cluster
  • No versioning — it's unclear which script version is running in production
  • Terminal autocompletion is unavailable, commands must be memorized

The solution is a custom internal CLI tool built in Go. A single binary, a strict interface, built-in documentation, testability, and straightforward distribution. This is exactly the path taken by teams at Spotify, Shopify, and Netflix when they built internal PaaS CLIs on top of Kubernetes. In 2026, Go remains the best choice for such tools thanks to its compilation speed, static typing, and native cross-compilation support.

Choosing the Stack: Cobra, client-go, and kubeconfig

Building a Go CLI Kubernetes tool relies on a battle-tested stack:

  • Cobra — a framework for building CLIs with nested commands, flags, and autocompletion. kubectl itself is built on Cobra.
  • client-go — the official Kubernetes API Go client. Enables programmatic creation, reading, and deletion of any cluster resources.
  • viper — a configuration management library compatible with Cobra. Reads YAML files, environment variables, and CLI flags.
  • kubeconfig — the standard authentication mechanism supporting multiple contexts and clusters.

Project initialization:

mkdir devctl && cd devctl
go mod init github.com/yourorg/devctl
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get k8s.io/client-go@latest
go get k8s.io/api@latest
go get k8s.io/apimachinery@latest

Base project structure:

devctl/
├── cmd/
│   ├── root.go        # root command, Cobra initialization
│   ├── namespace.go   # namespace commands
│   ├── deploy.go      # application deployment
│   ├── logs.go        # log viewing
│   └── version.go     # tool version
├── internal/
│   ├── k8s/
│   │   └── client.go  # client-go initialization
│   └── config/
│       └── config.go  # configuration loading
├── main.go
└── go.mod

Root command in cmd/root.go:

package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "devctl",
    Short: "Internal CLI for managing Kubernetes environments",
    Long: `devctl — a platform team tool for creating namespaces,
deploying applications, and working with logs in Kubernetes clusters.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.devctl.yaml)")
    rootCmd.PersistentFlags().String("context", "", "kubeconfig context")
    viper.BindPFlag("context", rootCmd.PersistentFlags().Lookup("context"))
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, _ := os.UserHomeDir()
        viper.AddConfigPath(home)
        viper.SetConfigName(".devctl")
    }
    viper.AutomaticEnv()
    viper.ReadInConfig()
}

Implementing Commands: Namespace, Deploy, Logs

Initializing client-go with kubeconfig Support

package k8s

import (
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
)

func NewClient(kubeContext string) (*kubernetes.Clientset, error) {
    loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
    configOverrides := &clientcmd.ConfigOverrides{}
    if kubeContext != "" {
        configOverrides.CurrentContext = kubeContext
    }
    config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
        loadingRules,
        configOverrides,
    ).ClientConfig()
    if err != nil {
        return nil, err
    }
    return kubernetes.NewForConfig(config)
}

Namespace Creation Command

The command devctl namespace create <name> creates an isolated developer environment:

package cmd

import (
    "context"
    "fmt"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "github.com/yourorg/devctl/internal/k8s"
)

var namespaceCmd = &cobra.Command{
    Use:   "namespace",
    Short: "Namespace operations",
}

var namespaceCreateCmd = &cobra.Command{
    Use:   "create [name]",
    Short: "Create a namespace for a developer environment",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        name := args[0]
        kubeCtx := viper.GetString("context")

        client, err := k8s.NewClient(kubeCtx)
        if err != nil {
            return fmt.Errorf("failed to connect to cluster: %w", err)
        }

        ns := &corev1.Namespace{
            ObjectMeta: metav1.ObjectMeta{
                Name: name,
                Labels: map[string]string{
                    "managed-by": "devctl",
                    "environment": "dev",
                },
            },
        }

        _, err = client.CoreV1().Namespaces().Create(
            context.Background(), ns, metav1.CreateOptions{},
        )
        if err != nil {
            return fmt.Errorf("error creating namespace: %w", err)
        }

        fmt.Printf("✓ Namespace '%s' created\n", name)
        return nil
    },
}

func init() {
    namespaceCmd.AddCommand(namespaceCreateCmd)
    rootCmd.AddCommand(namespaceCmd)
}

Application Deployment Command

The command devctl deploy --image=myapp:v1.2.3 --namespace=dev-alice creates or updates a Deployment:

var deployCmd = &cobra.Command{
    Use:   "deploy",
    Short: "Deploy an application to Kubernetes",
    RunE: func(cmd *cobra.Command, args []string) error {
        image, _ := cmd.Flags().GetString("image")
        ns, _ := cmd.Flags().GetString("namespace")
        replicas, _ := cmd.Flags().GetInt32("replicas")
        kubeCtx := viper.GetString("context")

        client, err := k8s.NewClient(kubeCtx)
        if err != nil {
            return err
        }

        dep := buildDeployment(image, ns, replicas)
        _, err = client.AppsV1().Deployments(ns).Apply(
            context.Background(), dep, metav1.ApplyOptions{FieldManager: "devctl"},
        )
        if err != nil {
            return fmt.Errorf("deploy failed: %w", err)
        }

        fmt.Printf("✓ Application %s deployed to %s\n", image, ns)
        return nil
    },
}

func init() {
    deployCmd.Flags().String("image", "", "Docker image to deploy (required)")
    deployCmd.Flags().String("namespace", "default", "Target namespace")
    deployCmd.Flags().Int32("replicas", 1, "Number of replicas")
    deployCmd.MarkFlagRequired("image")
    rootCmd.AddCommand(deployCmd)
}

Log Viewing with Streaming

The command devctl logs --namespace=dev-alice --app=myapp --follow implements log streaming via client-go:

var logsCmd = &cobra.Command{
    Use:   "logs",
    Short: "View application logs",
    RunE: func(cmd *cobra.Command, args []string) error {
        ns, _ := cmd.Flags().GetString("namespace")
        app, _ := cmd.Flags().GetString("app")
        follow, _ := cmd.Flags().GetBool("follow")
        kubeCtx := viper.GetString("context")

        client, err := k8s.NewClient(kubeCtx)
        if err != nil {
            return err
        }

        pods, err := client.CoreV1().Pods(ns).List(context.Background(), metav1.ListOptions{
            LabelSelector: fmt.Sprintf("app=%s", app),
        })
        if err != nil || len(pods.Items) == 0 {
            return fmt.Errorf("no pods found for application '%s'", app)
        }

        podName := pods.Items[0].Name
        req := client.CoreV1().Pods(ns).GetLogs(podName, &corev1.PodLogOptions{
            Follow: follow,
        })
        stream, err := req.Stream(context.Background())
        if err != nil {
            return err
        }
        defer stream.Close()
        _, err = io.Copy(os.Stdout, stream)
        return err
    },
}

CI/CD Integration: Using the CLI in GitHub Actions

One of the strongest arguments for a Go CLI Kubernetes tool is the ability to reuse the same binary both locally and in CI/CD pipelines. This eliminates the entire class of "works on my machine" errors.

Example GitHub Actions workflow using devctl:

name: Deploy to Dev

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Download devctl
        run: |
          curl -sSL https://github.com/yourorg/devctl/releases/latest/download/devctl-linux-amd64 \
            -o /usr/local/bin/devctl
          chmod +x /usr/local/bin/devctl

      - name: Configure kubeconfig
        run: |
          echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > $HOME/.kube/config

      - name: Create namespace if not exists
        run: devctl namespace create dev-${{ github.actor }} --context=dev-cluster

      - name: Deploy application
        run: |
          devctl deploy \
            --image=ghcr.io/yourorg/myapp:${{ github.sha }} \
            --namespace=dev-${{ github.actor }} \
            --context=dev-cluster

      - name: Verify deployment
        run: devctl status --namespace=dev-${{ github.actor }} --wait=60s

This CI/CD integration ensures idempotent operations and a single point of change: to update deployment logic, you only need to update the CLI rather than all pipelines simultaneously.

Secrets and Environment Configuration Management

A production-grade internal PaaS CLI requires secure secrets management. The recommended approach is to read secrets from environment variables or Kubernetes Secrets, never storing them in configuration files.

Example integration with Kubernetes Secrets via client-go:

func GetSecret(client *kubernetes.Clientset, ns, name string) (map[string][]byte, error) {
    secret, err := client.CoreV1().Secrets(ns).Get(
        context.Background(), name, metav1.GetOptions{},
    )
    if err != nil {
        return nil, fmt.Errorf("secret '%s' not found in namespace '%s': %w", name, ns, err)
    }
    return secret.Data, nil
}

For environment configuration, use a ~/.devctl.yaml file with profiles:

default_context: dev-cluster
environments:
  dev:
    context: dev-cluster
    registry: ghcr.io/yourorg
    default_namespace: dev
  staging:
    context: staging-cluster
    registry: ghcr.io/yourorg
    default_namespace: staging

Viper automatically picks up environment variables with the DEVCTL_ prefix, such as DEVCTL_CONTEXT=staging-cluster, which is convenient for CI/CD without modifying the configuration file.

Building and Distributing via GitHub Releases and Docker Image

Distribution is a key aspect of developer tooling. A developer should be able to install the tool with a single command. Two distribution channels are recommended: binaries via GitHub Releases and a Docker image for CI/CD environments without local installation.

GoReleaser for Automated Builds

The .goreleaser.yaml file:

project_name: devctl

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X github.com/yourorg/devctl/cmd.Version={{.Version}}
      - -X github.com/yourorg/devctl/cmd.CommitHash={{.Commit}}
      - -X github.com/yourorg/devctl/cmd.BuildDate={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"

checksum:
  name_template: checksums.txt

dockers:
  - image_templates:
      - ghcr.io/yourorg/devctl:{{ .Tag }}
      - ghcr.io/yourorg/devctl:latest
    dockerfile: Dockerfile.goreleaser

A minimal Dockerfile.goreleaser for a scratch-based image:

FROM scratch
COPY devctl /devctl
ENTRYPOINT ["/devctl"]

GitHub Actions workflow for releases:

name: Release
on:
  push:
    tags: ['v*']
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - uses: goreleaser/goreleaser-action@v5
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Once configured, developers install the tool with a single command:

curl -sSL https://github.com/yourorg/devctl/releases/latest/download/devctl-darwin-arm64.tar.gz | tar -xz
mv devctl /usr/local/bin/

Testing CLI Commands: Mocking the Kubernetes API

Testing is the weak point of most internal tooling. client-go provides the k8s.io/client-go/kubernetes/fake package for creating a fake client without a real cluster:

package cmd_test

import (
    "testing"
    "context"

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

func TestNamespaceCreate(t *testing.T) {
    fakeClient := fake.NewSimpleClientset()

    ns := &corev1.Namespace{
        ObjectMeta: metav1.ObjectMeta{
            Name: "dev-testuser",
            Labels: map[string]string{
                "managed-by": "devctl",
            },
        },
    }
    _, err := fakeClient.CoreV1().Namespaces().Create(
        context.Background(), ns, metav1.CreateOptions{},
    )
    if err != nil {
        t.Fatalf("expected successful creation, got error: %v", err)
    }

    got, err := fakeClient.CoreV1().Namespaces().Get(
        context.Background(), "dev-testuser", metav1.GetOptions{},
    )
    if err != nil {
        t.Fatalf("namespace not found after creation: %v", err)
    }
    if got.Labels["managed-by"] != "devctl" {
        t.Errorf("unexpected label value: %s", got.Labels["managed-by"])
    }
}

For testing Cobra commands themselves, use a dependency injection pattern: accept kubernetes.Interface instead of a concrete type, allowing you to substitute a fake client in tests. Run tests with:

go test ./... -v -race -count=1

Best Practices: Backward Compatibility, Versioning, and Documentation

Versioning and Built-in Documentation

Every release should have a semantic version accessible via devctl version:

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "devctl version",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("devctl version %s (commit: %s, built: %s)\n",
            Version, CommitHash, BuildDate)
    },
}

Key Principles

  • Flag backward compatibility: never remove existing flags — only mark them as deprecated using cmd.Flags().MarkDeprecated(). This is critical when the CLI is used across dozens of CI/CD pipelines.
  • Explicit exit codes: use os.Exit(1) only in main.go; in all commands, return errors via RunE. This simplifies testing and integration.
  • Autocompletion: Cobra generates autocompletion scripts for bash, zsh, and fish using devctl completion zsh > ~/.zsh/_devctl.
  • Structured logging: use a --output=json flag for machine-readable output in CI/CD and standard text format for developers.
  • Changelog and migrations: document breaking changes in CHANGELOG.md with every minor release. Consider a devctl migrate command for automatic configuration file migration.
  • Usage monitoring: add anonymous telemetry with an explicit opt-out to understand which commands are used most frequently and where users encounter errors.

Conclusion

Building an internal Developer CLI in Go is an investment that pays off quickly. A single binary replaces script chaos, accelerates onboarding for new developers, and makes CI/CD pipelines reproducible and testable. The Cobra + client-go + viper combination covers 90% of a platform team's needs when working with Kubernetes environments.

Start with three commands — namespace create, deploy, and logs — and iteratively add functionality based on team requests. Use GoReleaser to automate distribution and the fake client-go in tests to avoid depending on a real cluster in CI/CD. Follow backward compatibility principles from day one, and your internal PaaS CLI will become a tool your team is proud of.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →