DevOps

Creación de un CLI interno para desarrolladores en Go para gestionar entornos Kubernetes: de la idea a la distribución

Ruslan Ismailov Publicado 18 min de lectura
C

Por qué un equipo necesita su propio CLI en lugar de un conjunto de scripts

Todo equipo de plataforma en crecimiento acaba enfrentándose a la misma situación: el repositorio scripts/ se convierte en un caos de archivos Bash, targets de Makefile y one-liners de Python. Un nuevo desarrollador pierde un día entero solo para entender qué script ejecutar y en qué orden. Los pipelines de CI/CD duplican lógica que ya existe localmente. Un ingeniero actualiza un script y rompe el entorno de su compañero.

Dolores típicos que conocen bien los ingenieros DevOps y los equipos de plataforma:

  • No existe un único punto de entrada para las operaciones con entornos Kubernetes
  • Los scripts no están documentados y los flags se pasan mediante variables de entorno en orden arbitrario
  • Es imposible probar los scripts sin un clúster real
  • No hay versionado — no queda claro qué versión del script se usa en producción
  • El autocompletado en la terminal no está disponible y los comandos hay que memorizar

La solución es una herramienta CLI interna propia en Go. Un único binario, una interfaz estricta, documentación integrada, facilidad de pruebas y distribución sencilla. Este es exactamente el camino que siguieron equipos de Spotify, Shopify y Netflix al crear CLIs internos de PaaS sobre Kubernetes. En 2026, Go sigue siendo la mejor opción para este tipo de herramientas gracias a su velocidad de compilación, tipado estático y soporte nativo de compilación cruzada.

Elección del stack: Cobra, client-go y kubeconfig

Para construir una herramienta CLI de Kubernetes en Go se utiliza un stack probado:

  • Cobra — framework para construir CLIs con subcomandos anidados, flags y autocompletado. El propio kubectl está construido sobre Cobra.
  • client-go — el cliente oficial de Go para la API de Kubernetes. Permite crear, leer y eliminar cualquier recurso del clúster de forma programática.
  • viper — biblioteca de gestión de configuración compatible con Cobra. Lee archivos YAML, variables de entorno y flags de CLI.
  • kubeconfig — mecanismo estándar de autenticación que soporta múltiples contextos y clústeres.

Inicialización del proyecto:

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

Estructura base del proyecto:

devctl/
├── cmd/
│   ├── root.go        # comando raíz, inicialización de Cobra
│   ├── namespace.go   # comandos para trabajar con namespaces
│   ├── deploy.go      # despliegue de aplicaciones
│   ├── logs.go        # visualización de logs
│   └── version.go     # versión de la herramienta
├── internal/
│   ├── k8s/
│   │   └── client.go  # inicialización de client-go
│   └── config/
│       └── config.go  # carga de configuración
├── main.go
└── go.mod

Comando raíz en 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: "CLI interno para gestionar entornos Kubernetes",
    Long: `devctl — herramienta del equipo de plataforma para crear namespaces,
desplegar aplicaciones y trabajar con logs en clústeres Kubernetes.`,
}

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 (por defecto $HOME/.devctl.yaml)")
    rootCmd.PersistentFlags().String("context", "", "contexto de kubeconfig")
    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()
}

Implementación de comandos: namespace, despliegue, logs

Inicialización de client-go con soporte para kubeconfig

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)
}

Comando de creación de namespace

El comando devctl namespace create <name> crea un entorno aislado para el desarrollador:

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: "Operaciones con namespaces",
}

var namespaceCreateCmd = &cobra.Command{
    Use:   "create [name]",
    Short: "Crear un namespace para el entorno del desarrollador",
    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("no se pudo conectar al clúster: %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 al crear el namespace: %w", err)
        }

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

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

Comando de despliegue de aplicación

El comando devctl deploy --image=myapp:v1.2.3 --namespace=dev-alice crea o actualiza un Deployment:

var deployCmd = &cobra.Command{
    Use:   "deploy",
    Short: "Desplegar una aplicación en 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("el despliegue falló: %w", err)
        }

        fmt.Printf("✓ Aplicación %s desplegada en %s\n", image, ns)
        return nil
    },
}

func init() {
    deployCmd.Flags().String("image", "", "Imagen Docker para desplegar (obligatorio)")
    deployCmd.Flags().String("namespace", "default", "Namespace de destino")
    deployCmd.Flags().Int32("replicas", 1, "Número de réplicas")
    deployCmd.MarkFlagRequired("image")
    rootCmd.AddCommand(deployCmd)
}

Visualización de logs con streaming

El comando devctl logs --namespace=dev-alice --app=myapp --follow implementa streaming de logs mediante client-go:

var logsCmd = &cobra.Command{
    Use:   "logs",
    Short: "Ver los logs de una aplicación",
    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 se encontraron pods para la aplicación '%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
    },
}

Integración con CI/CD: uso del CLI en GitHub Actions

Uno de los principales argumentos a favor de una herramienta CLI de Kubernetes en Go es la reutilización de un único binario tanto en local como en el pipeline de CI/CD. Esto elimina toda una clase de errores del tipo «en mi máquina funciona».

Ejemplo de workflow de GitHub Actions con 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

Esta integración con CI/CD garantiza la idempotencia de las operaciones y un único punto de cambio: para modificar la lógica de despliegue basta con actualizar el CLI, sin necesidad de tocar todos los pipelines a la vez.

Gestión de secretos y configuración de entornos

Para un CLI interno de PaaS de nivel productivo es imprescindible gestionar los secretos de forma segura. El enfoque recomendado es leer los secretos desde variables de entorno o Kubernetes Secrets, sin almacenarlos nunca en archivos de configuración.

Ejemplo de integración con Kubernetes Secrets mediante 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("secreto '%s' no encontrado en el namespace '%s': %w", name, ns, err)
    }
    return secret.Data, nil
}

Para la configuración de entornos utiliza el archivo ~/.devctl.yaml con perfiles:

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 recoge automáticamente las variables de entorno con el prefijo DEVCTL_, por ejemplo DEVCTL_CONTEXT=staging-cluster, lo que resulta muy cómodo para CI/CD sin necesidad de modificar el archivo de configuración.

Compilación y distribución mediante GitHub Releases e imagen Docker

La distribución es un aspecto clave del developer tooling. El desarrollador debe poder instalar la herramienta con un único comando. Se recomiendan dos canales: binarios a través de GitHub Releases e imagen Docker para CI/CD sin instalación previa.

GoReleaser para la compilación automática

Archivo .goreleaser.yaml:

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

Dockerfile.goreleaser minimalista para una imagen basada en scratch:

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

GitHub Actions para el release:

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 }}

Una vez configurado, el desarrollador instala la herramienta con un único comando:

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

Pruebas de comandos CLI: mocks de la API de Kubernetes

Las pruebas son el punto débil de la mayoría de las herramientas internas. client-go proporciona el paquete k8s.io/client-go/kubernetes/fake para crear un cliente falso sin necesidad de un clúster real:

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("se esperaba creación exitosa, se obtuvo error: %v", err)
    }

    got, err := fakeClient.CoreV1().Namespaces().Get(
        context.Background(), "dev-testuser", metav1.GetOptions{},
    )
    if err != nil {
        t.Fatalf("namespace no encontrado tras la creación: %v", err)
    }
    if got.Labels["managed-by"] != "devctl" {
        t.Errorf("label incorrecto: %s", got.Labels["managed-by"])
    }
}

Para probar los propios comandos de Cobra utiliza el patrón de inyección de dependencias: acepta kubernetes.Interface en lugar del tipo concreto, lo que permite sustituirlo por el cliente falso en las pruebas. Ejecuta los tests con el comando:

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

Buenas prácticas: compatibilidad hacia atrás, versionado y documentación

Versionado y documentación integrada

Cada release debe tener una versión semántica accesible mediante devctl version:

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

Principios clave

  • Compatibilidad hacia atrás de los flags: nunca elimines flags existentes, solo márcalos como deprecated mediante cmd.Flags().MarkDeprecated(). Esto es crítico cuando el CLI se usa en decenas de pipelines de CI/CD.
  • Códigos de salida explícitos: usa os.Exit(1) solo en main.go; en todos los comandos devuelve el error mediante RunE. Esto simplifica las pruebas y la integración.
  • Autocompletado: Cobra genera scripts de autocompletado para bash, zsh y fish con el comando devctl completion zsh > ~/.zsh/_devctl.
  • Structured logging: usa el flag --output=json para salida legible por máquinas en CI/CD y el formato de texto estándar para el desarrollador.
  • Changelog y migraciones: documenta los breaking changes en CHANGELOG.md en cada release menor. Considera el comando devctl migrate para la migración automática de archivos de configuración.
  • Monitoreo de uso: añade telemetría anónima con opt-out explícito para entender qué comandos se usan más y dónde los usuarios encuentran errores.

Conclusión

Crear un CLI interno para desarrolladores en Go es una inversión que se amortiza rápidamente. Un único binario reemplaza el caos de los scripts, acelera el onboarding de nuevos desarrolladores, hace que los pipelines de CI/CD sean reproducibles y testeables. La combinación Cobra + client-go + viper cubre el 90% de las necesidades del equipo de plataforma al trabajar con entornos Kubernetes.

Comienza con tres comandos — namespace create, deploy y logs — y añade funcionalidad de forma iterativa según las peticiones del equipo. Usa GoReleaser para automatizar la distribución y el client-go falso en las pruebas para no depender de un clúster real en CI/CD. Sigue los principios de compatibilidad hacia atrás desde el primer día, y tu CLI interno de PaaS se convertirá en una herramienta de la que el equipo estará orgulloso.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →