---
id: w1/m20/t002
title: Metric→replica reconciler (live CPU/mem → spec.replicas within [min,max])
worker: worker1
status: done
estimate: 90m
depends_on: [w1/m20/t001]
---

## Objective

Add a per-service control loop in the operator that reads live CPU/memory utilization and adjusts `spec.replicas` to hold the target, bounded by `[minReplicas, maxReplicas]`. This is the mechanism half of autoscaling — new per-service work alongside (not inside) the m3 node autoscaler.

## Context

- `lego/operator/internal/controller/app_controller.go` reconciles `App` → Deployment/Service/Ingress (mechanism, no DB).
- Utilization is CPU/mem as a % of the tier requests/limits shipped by w1/m8; the snapshot source is metrics-server, history is Prometheus/cAdvisor (`BEX_PROM_URL`) — reuse, don't re-plumb.
- Interactions to respect: m3 moves **nodes** (never `spec.replicas`); m4 scale-to-zero owns the 0↔1 transition (autoscaling's floor is `minReplicas`, which may be 0 when paired with m4).

## Steps

1. When `spec.autoscaling.enabled`, compute desired replicas from current utilization vs target (HPA-style ratio), clamp to `[min,max]`.
2. Apply hysteresis / a stabilization window so the loop doesn't thrash; requeue on an interval.
3. Patch `spec.replicas` (or the Deployment replica count) only when it changes; record the decision in status/events for observability.
4. Leave `enabled:false` services on their fixed `spec.replicas` (today's behavior).

## Files

- `lego/operator/internal/controller/app_controller.go` (+ a small autoscale helper)

## Acceptance criteria

- [ ] A service above target scales up (≤ max) and back down when idle, in envtest with a faked/injected metrics source.
- [ ] Never scales below `minReplicas`; never writes when the desired value is unchanged (no reconcile churn).
- [ ] No interference with m3 node scaling or m4 scale-to-zero.

## Out of scope

- Exposing config over REST/GraphQL/MCP (t003) and the dashboard (t004).
