Creación de un CLI interno para desarrolladores en Go para gestionar entornos Kubernetes: de la idea a la distribución
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
kubectlestá 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 enmain.go; en todos los comandos devuelve el error medianteRunE. 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=jsonpara 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.mden cada release menor. Considera el comandodevctl migratepara 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í →