---
phase: 04-code-quality
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
  - internal/kube/client.go
autonomous: true
requirements: [QUAL-03]

must_haves:
  truths:
    - "WaitForDeploymentsReady contains no time.Sleep or time.After(2*time.Second) polling loop"
    - "WaitForStatefulSetsReady contains no time.Sleep or time.After(2*time.Second) polling loop"
    - "Both functions use wait.PollUntilContextTimeout with a Backoff or fixed interval"
    - "Both functions still return a descriptive error on timeout"
    - "task test:unit passes"
  artifacts:
    - path: "internal/kube/client.go"
      provides: "WaitForDeploymentsReady and WaitForStatefulSetsReady using wait.PollUntilContextTimeout"
      contains: "PollUntilContextTimeout"
  key_links:
    - from: "internal/kube/client.go"
      to: "k8s.io/apimachinery/pkg/util/wait"
      via: "import and call to wait.PollUntilContextTimeout"
      pattern: "wait\\.PollUntilContextTimeout"
---

<objective>
Replace the manual `for { select { case <-time.After(2*time.Second): } }` polling loops in WaitForDeploymentsReady and WaitForStatefulSetsReady with wait.PollUntilContextTimeout from k8s.io/apimachinery/pkg/util/wait.

Purpose: The fixed 2s sleep burns goroutines waiting with no backoff. PollUntilContextTimeout is the idiomatic k8s-client-go pattern and respects context cancellation natively.
Output: internal/kube/client.go with both wait functions rewritten to use wait.PollUntilContextTimeout; no time.After inside either function.
</objective>

<execution_context>
@/Users/paul/.claude/get-shit-done/workflows/execute-plan.md
@/Users/paul/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md

@internal/kube/client.go
</context>

<interfaces>
<!-- wait.PollUntilContextTimeout signature (k8s.io/apimachinery v0.35.0): -->
```go
// PollUntilContextTimeout tries condition until the context deadline or timeout elapses.
// interval: time between polls
// timeout: max wait time (added as deadline on top of ctx)
// immediate: if true, condition is called before the first sleep
// condition: func(ctx context.Context) (done bool, err error)
func wait.PollUntilContextTimeout(
    ctx context.Context,
    interval, timeout time.Duration,
    immediate bool,
    condition wait.ConditionWithContextFunc,
) error
```

<!-- ConditionWithContextFunc type: -->
```go
type ConditionWithContextFunc func(ctx context.Context) (done bool, err error)
```

<!-- Pattern to use for each resource: -->
```go
import "k8s.io/apimachinery/pkg/util/wait"

const pollInterval = 2 * time.Second
const pollTimeout  = 5 * time.Minute

err := wait.PollUntilContextTimeout(ctx, pollInterval, pollTimeout, true, func(ctx context.Context) (bool, error) {
    resource, err := clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
    if err != nil {
        if apierrors.IsNotFound(err) {
            logger.Warning("Deployment %s/%s not found, retrying...", namespace, name)
            return false, nil  // keep polling
        }
        return false, err  // hard error, stop polling
    }
    // readiness check
    if isReady(resource) {
        return true, nil
    }
    return false, nil
})
if err != nil {
    return fmt.Errorf("timeout waiting for Deployment %s/%s: %w", namespace, name, err)
}
```

<!-- Current readiness predicates to preserve:

Deployment ready when ALL of:
  deployment.Generation <= deployment.Status.ObservedGeneration
  deployment.Status.UpdatedReplicas >= *deployment.Spec.Replicas
  deployment.Status.AvailableReplicas >= *deployment.Spec.Replicas
  deployment.Status.ReadyReplicas >= *deployment.Spec.Replicas

StatefulSet ready when ALL of:
  sts.Generation <= sts.Status.ObservedGeneration
  sts.Status.ReadyReplicas >= *sts.Spec.Replicas
  sts.Status.UpdatedReplicas >= *sts.Spec.Replicas
  sts.Status.CurrentRevision == sts.Status.UpdateRevision

In both cases, if Spec.Replicas == nil → return false, nil (not ready yet, keep polling)
-->
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Rewrite WaitForDeploymentsReady and WaitForStatefulSetsReady with PollUntilContextTimeout</name>
  <files>internal/kube/client.go</files>
  <action>
Add import `"k8s.io/apimachinery/pkg/util/wait"` to the import block in internal/kube/client.go.

Replace the body of WaitForDeploymentsReady (currently lines ~259-331) with:

```go
func WaitForDeploymentsReady(ctx context.Context, clientset *kubernetes.Clientset, namespace string, deploymentNames []string) error {
    logger.Info("Waiting for Deployments in namespace '%s' to be ready: %s", namespace, strings.Join(deploymentNames, ", "))
    for _, deploymentName := range deploymentNames {
        logger.Debug("Waiting for Deployment %s/%s to become ready...", namespace, deploymentName)
        err := wait.PollUntilContextTimeout(ctx, 2*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) {
            deployment, err := clientset.AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{})
            if err != nil {
                if apierrors.IsNotFound(err) {
                    logger.Warning("Deployment %s/%s not found, retrying...", namespace, deploymentName)
                    return false, nil
                }
                return false, fmt.Errorf("error getting Deployment %s/%s: %w", namespace, deploymentName, err)
            }
            if deployment.Spec.Replicas == nil {
                logger.Debug("Deployment %s/%s spec.replicas not set yet, retrying...", namespace, deploymentName)
                return false, nil
            }
            desired := *deployment.Spec.Replicas
            logger.Debug("Deployment %s/%s: Ready=%d/%d, Updated=%d, Available=%d",
                namespace, deploymentName,
                deployment.Status.ReadyReplicas, desired,
                deployment.Status.UpdatedReplicas, deployment.Status.AvailableReplicas)
            if deployment.Generation <= deployment.Status.ObservedGeneration &&
                deployment.Status.UpdatedReplicas >= desired &&
                deployment.Status.AvailableReplicas >= desired &&
                deployment.Status.ReadyReplicas >= desired {
                logger.Info("Deployment %s/%s is ready.", namespace, deploymentName)
                return true, nil
            }
            return false, nil
        })
        if err != nil {
            return fmt.Errorf("timeout waiting for Deployment %s/%s to be ready: %w", namespace, deploymentName, err)
        }
    }
    logger.Info("All specified Deployments in namespace %s are ready.", namespace)
    return nil
}
```

Replace the body of WaitForStatefulSetsReady (currently lines ~333-407) with:

```go
func WaitForStatefulSetsReady(ctx context.Context, clientset *kubernetes.Clientset, namespace string, stsNames []string) error {
    logger.Info("Waiting for StatefulSets in namespace '%s' to be ready: %s", namespace, strings.Join(stsNames, ", "))
    for _, stsName := range stsNames {
        logger.Debug("Waiting for StatefulSet %s/%s to become ready...", namespace, stsName)
        err := wait.PollUntilContextTimeout(ctx, 2*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) {
            sts, err := clientset.AppsV1().StatefulSets(namespace).Get(ctx, stsName, metav1.GetOptions{})
            if err != nil {
                if apierrors.IsNotFound(err) {
                    logger.Warning("StatefulSet %s/%s not found, retrying...", namespace, stsName)
                    return false, nil
                }
                return false, fmt.Errorf("error getting StatefulSet %s/%s: %w", namespace, stsName, err)
            }
            if sts.Spec.Replicas == nil {
                logger.Debug("StatefulSet %s/%s spec.replicas not set yet, retrying...", namespace, stsName)
                return false, nil
            }
            desired := *sts.Spec.Replicas
            logger.Debug("StatefulSet %s/%s: Ready=%d/%d, Updated=%d, CurrentRevision=%s, UpdateRevision=%s",
                namespace, stsName,
                sts.Status.ReadyReplicas, desired,
                sts.Status.UpdatedReplicas,
                sts.Status.CurrentRevision, sts.Status.UpdateRevision)
            if sts.Generation <= sts.Status.ObservedGeneration &&
                sts.Status.ReadyReplicas >= desired &&
                sts.Status.UpdatedReplicas >= desired &&
                sts.Status.CurrentRevision == sts.Status.UpdateRevision {
                logger.Info("StatefulSet %s/%s is ready.", namespace, stsName)
                return true, nil
            }
            return false, nil
        })
        if err != nil {
            return fmt.Errorf("timeout waiting for StatefulSet %s/%s to be ready: %w", namespace, stsName, err)
        }
    }
    logger.Info("All specified StatefulSets in namespace %s are ready.", namespace)
    return nil
}
```

Remove the now-unused `context.Background()` calls that appeared in the old timeout branches (no longer needed — PollUntilContextTimeout handles context propagation internally).

After editing, verify `time.After` no longer appears inside either function:
```bash
grep -n "time\.After\|time\.Sleep" internal/kube/client.go
```
Expected: zero matches in the two wait functions (WaitForNamespaceActive still uses time.NewTicker — that is acceptable and must not be changed).
  </action>
  <verify>
    <automated>cd /Users/paul/Workspace/kubeasy/kubeasy-cli && go build ./internal/kube/... && task test:unit</automated>
  </verify>
  <done>
`go build ./internal/kube/...` exits 0. `task test:unit` passes. `grep -n "time\.After" internal/kube/client.go` returns zero matches inside WaitForDeploymentsReady or WaitForStatefulSetsReady. `grep -n "PollUntilContextTimeout" internal/kube/client.go` returns exactly 2 matches.
  </done>
</task>

</tasks>

<verification>
```bash
cd /Users/paul/Workspace/kubeasy/kubeasy-cli

# PollUntilContextTimeout used twice (once per function)
grep -c "PollUntilContextTimeout" internal/kube/client.go
# Expected: 2

# No fixed-sleep polling in the two wait functions
grep -n "time\.After\|time\.Sleep" internal/kube/client.go
# Expected: zero results (WaitForNamespaceActive uses NewTicker which is fine)

# wait import present
grep "util/wait" internal/kube/client.go
# Expected: one match

go build ./...
task test:unit
```
</verification>

<success_criteria>
- WaitForDeploymentsReady and WaitForStatefulSetsReady use wait.PollUntilContextTimeout
- No time.After fixed-sleep loop inside either function
- Original readiness predicates (generation, replicas, revision checks) preserved
- `go build ./...` exits 0
- `task test:unit` passes
</success_criteria>

<output>
After completion, create `.planning/phases/04-code-quality/04-03-SUMMARY.md`
</output>
