028 Project 28: Kubernetes Rollout Checker

Updated

September 8, 2026

028 Build a Kubernetes Rollout Checker

Check whether a Deployment is fully rolled out: desired replicas match updated and ready counts. Exit 0 when healthy, 1 when pending, 2 on usage/API errors—ideal for CI gates after kubectl apply.

kubeconfig -> Get Deployment -> compare desired/updated/ready -> exit code

Problem statement

kroll -name DEPLOY [-n NS] [-wait DURATION] [-interval D]
  • One-shot check by default
  • Optional -wait polls until healthy or timeout
  • Print a one-line status for logs

Acceptance criteria

  • Reads Deployment status fields correctly
  • Exit 0 only when ready == desired && updated == desired (and desired > 0 or allow 0)
  • Exit 1 when still rolling
  • Exit 2 on bad flags / API errors
  • Optional wait loop with timeout

Setup

mkdir kroll && cd kroll
go mod init example.com/kroll
go get k8s.io/client-go@latest
go get k8s.io/apimachinery@latest
go mod tidy
# go 1.27

Full main.go

package main

import (
    "context"
    "flag"
    "fmt"
    "os"
    "path/filepath"
    "time"

    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/client-go/util/homedir"
)

type status struct {
    Desired int32
    Updated int32
    Ready   int32
    Avail   int32
}

func (s status) Healthy() bool {
    return s.Ready == s.Desired && s.Updated == s.Desired && s.Avail == s.Desired
}

func getStatus(ctx context.Context, cli *kubernetes.Clientset, ns, name string) (status, error) {
    d, err := cli.AppsV1().Deployments(ns).Get(ctx, name, metav1.GetOptions{})
    if err != nil {
        return status{}, err
    }
    var desired int32 = 1
    if d.Spec.Replicas != nil {
        desired = *d.Spec.Replicas
    }
    return status{
        Desired: desired,
        Updated: d.Status.UpdatedReplicas,
        Ready:   d.Status.ReadyReplicas,
        Avail:   d.Status.AvailableReplicas,
    }, nil
}

func main() {
    var kubeconfig string
    if home := homedir.HomeDir(); home != "" {
        kubeconfig = filepath.Join(home, ".kube", "config")
    }
    kc := flag.String("kubeconfig", kubeconfig, "kubeconfig path")
    name := flag.String("name", "", "deployment name")
    ns := flag.String("n", "default", "namespace")
    wait := flag.Duration("wait", 0, "max wait for healthy (0=check once)")
    interval := flag.Duration("interval", 2*time.Second, "poll interval")
    flag.Parse()

    if *name == "" {
        fmt.Fprintln(os.Stderr, "usage: kroll -name DEPLOY [-n NS] [-wait 2m]")
        os.Exit(2)
    }

    cfg, err := clientcmd.BuildConfigFromFlags("", *kc)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(2)
    }
    cli, err := kubernetes.NewForConfig(cfg)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(2)
    }

    deadline := time.Now().Add(*wait)
    for {
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        st, err := getStatus(ctx, cli, *ns, *name)
        cancel()
        if err != nil {
            fmt.Fprintln(os.Stderr, "get deployment:", err)
            os.Exit(2)
        }

        fmt.Printf("deployment=%s/%s desired=%d updated=%d ready=%d available=%d\n",
            *ns, *name, st.Desired, st.Updated, st.Ready, st.Avail)

        if st.Healthy() {
            fmt.Println("rollout: healthy")
            os.Exit(0)
        }
        fmt.Println("rollout: pending")

        if *wait == 0 || time.Now().After(deadline) {
            if *wait > 0 {
                fmt.Fprintln(os.Stderr, "timeout waiting for rollout")
            }
            os.Exit(1)
        }
        time.Sleep(*interval)
    }
}

Run and verification

kubectl create deploy roll-demo --image=nginx:1.27 --replicas=2
go run . -name roll-demo -n default
go run . -name roll-demo -wait 60s -interval 1s

kubectl set image deploy/roll-demo nginx=nginx:1.27-alpine
go run . -name roll-demo -wait 2m

Tests

Unit-test status.Healthy without a cluster:

package main

import "testing"

func TestHealthy(t *testing.T) {
    if !(status{2, 2, 2, 2}).Healthy() {
        t.Fatal("expected healthy")
    }
    if (status{2, 1, 1, 1}).Healthy() {
        t.Fatal("expected pending")
    }
}

Stretch goals

  1. Also check DeploymentProgressing condition.
  2. Support StatefulSet / DaemonSet.
  3. JSON output for CI annotations.
  4. Fail if UnavailableReplicas > 0 after deadline.

Pitfalls

Pitfall Fix
Ignoring AvailableReplicas include in healthy check
Nil Spec.Replicas default 1 per API conventions
Wait without timeout always bound wait
Wrong exit codes for CI document 0/1/2

Learning goals

  • Deployment status fields for rollout gates
  • Polling with timeout for CI
  • Scriptable exit codes