Skip to content

Commit b51ac0c

Browse files
Merge pull request #7 from gitcommitankit/phase-4
feat: implement canary rollout logic with PromQL-based threshold eval…
2 parents 4a77d00 + c395736 commit b51ac0c

17 files changed

Lines changed: 8214 additions & 75 deletions

File tree

.agents/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
2. **Reconciler Pattern**:
1010
- Fetch the object first; if not found (`apierrors.IsNotFound`), return `ctrl.Result{}` immediately — it was deleted.
1111
- Always update `status` last, after all child resources are reconciled. Never update status mid-reconcile.
12-
- Use `controllerutil.CreateOrUpdate` for all owned child resources (Deployment, Service, ServiceMonitor, HPA).
12+
- Use `controllerutil.CreateOrUpdate` for all owned child resources (Deployment, Service, ServiceMonitor, HPA, HTTPRoute).
1313
- Requeue transient errors with `ctrl.Result{RequeueAfter: ...}`, not `ctrl.Result{Requeue: true}`.
1414

1515
3. **Owner References & Finalizers**:

.agents/skills/agentrax-context/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ description: Project context and settled architecture decisions for the Agentrax
5656
| Canary | Prometheus unreachable during pause | Fail-safe rollback after 60 s; never hang |
5757
| Canary | Second rollout triggered mid-rollout | Webhook rejects; never run two concurrent canaries on the same `AgentDeployment` |
5858
| Canary | HPA during active canary | Pause stable HPA; no canary HPA; resume only after promote/rollback |
59+
| Canary | Out-of-band deletion of HTTPRoute/Deployment | Self-healed on next reconcile cycle in `Step()` preserving active traffic split |
60+
| Canary | Rollout failed or aborted | Operator sets `RolloutFailed`, preserves `status.canaryVersion`, no retry loop |
5961
| Quota | Two concurrent near-limit creates | In-flight reservation; one wins, one is rejected |
6062
| Quota | Quota lowered below current usage | Set `OverQuota` condition; never forcibly delete existing resources |
63+
| Quota | `TenantQuota` deleted while agents exist | Surface `TenantQuotaNotFound` on `QuotaLimited` condition; never crash |
6164
| MCP | Ungraceful pod termination | TTL/heartbeat expires the entry within one TTL window (default 90 s) |
6265
| MCP | Pod `Ready` but MCP handshake fails | Do not register; surface `MCPHandshakeFailed` condition |
6366
| Deletion | `AgentDeployment` deleted | Finalizer ensures MCP deregistration before `Service` is GC'd |

api/v1alpha1/agentdeployment_types.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,21 @@ type AgentDeploymentStatus struct {
162162
// CanaryWeight is the current percentage of traffic routed to the canary (0–100).
163163
CanaryWeight int32 `json:"canaryWeight,omitempty"`
164164

165+
// CanaryStepIndex is the index of the currently executing rollout step.
166+
// Persisted so the state machine survives operator restarts.
167+
// +optional
168+
CanaryStepIndex int `json:"canaryStepIndex,omitempty"`
169+
170+
// PauseStartedAt records when the current pause step began.
171+
// Used to enforce maximum pause extensions and the fail-safe rollback timeout.
172+
// +optional
173+
PauseStartedAt *metav1.Time `json:"pauseStartedAt,omitempty"`
174+
175+
// PromUnreachableSince records when Prometheus last became unreachable.
176+
// When non-nil and age exceeds FailSafeTimeout, a fail-safe rollback fires.
177+
// +optional
178+
PromUnreachableSince *metav1.Time `json:"promUnreachableSince,omitempty"`
179+
165180
// Registered is true when this agent is currently registered in the MCP registry.
166181
Registered bool `json:"registered,omitempty"`
167182

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/main.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"crypto/tls"
2222
"flag"
2323
"os"
24+
"time"
2425

2526
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
2627
// to ensure that exec-entrypoint and run can make use of them.
@@ -39,10 +40,13 @@ import (
3940
"sigs.k8s.io/controller-runtime/pkg/webhook"
4041

4142
monitoringv1 "github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring/v1"
43+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
4244

4345
agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1"
4446
"github.com/gitcommitankit/agentrax/internal/controller"
47+
"github.com/gitcommitankit/agentrax/internal/metrics"
4548
"github.com/gitcommitankit/agentrax/internal/quota"
49+
"github.com/gitcommitankit/agentrax/internal/rollout"
4650
agentraxwebhook "github.com/gitcommitankit/agentrax/internal/webhook"
4751
// +kubebuilder:scaffold:imports
4852
)
@@ -58,6 +62,7 @@ func init() {
5862
utilruntime.Must(autoscalingv2.AddToScheme(scheme))
5963
utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
6064
utilruntime.Must(monitoringv1.AddToScheme(scheme))
65+
utilruntime.Must(gatewayv1.Install(scheme))
6166

6267
utilruntime.Must(agentraxv1alpha1.AddToScheme(scheme))
6368
// +kubebuilder:scaffold:scheme
@@ -71,6 +76,9 @@ func main() {
7176
var secureMetrics bool
7277
var enableHTTP2 bool
7378
var gpuResourceName string
79+
var prometheusURL string
80+
var gatewayName string
81+
var gatewayNamespace string
7482
var tlsOpts []func(*tls.Config)
7583
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
7684
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
@@ -84,6 +92,13 @@ func main() {
8492
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
8593
flag.StringVar(&gpuResourceName, "gpu-resource-name", quota.DefaultGPUResourceName,
8694
"Kubernetes resource name used to count GPU units in AgentDeployment resource limits.")
95+
flag.StringVar(&prometheusURL, "prometheus-url", "",
96+
"URL of the Prometheus HTTP API (e.g. http://prometheus-operated.monitoring.svc:9090). "+
97+
"Required for Canary rollout strategy; if empty, canary is unavailable.")
98+
flag.StringVar(&gatewayName, "gateway-name", "agentrax-gateway",
99+
"Name of the Gateway API Gateway object used for canary traffic splitting.")
100+
flag.StringVar(&gatewayNamespace, "gateway-namespace", "agentrax-system",
101+
"Namespace of the Gateway API Gateway object used for canary traffic splitting.")
87102
opts := zap.Options{
88103
Development: true,
89104
}
@@ -171,10 +186,29 @@ func main() {
171186
// Shared quota enforcer used by both the webhook validator and TenantQuota reconciler.
172187
quotaEnforcer := quota.NewEnforcer(gpuResourceName)
173188

189+
// Build the CanaryController when --prometheus-url is provided.
190+
// When nil, AgentDeployments with strategy=Canary behave as Recreate.
191+
var canaryController *rollout.Controller
192+
if prometheusURL != "" {
193+
setupLog.Info("canary rollout enabled", "prometheusURL", prometheusURL,
194+
"gatewayName", gatewayName, "gatewayNamespace", gatewayNamespace)
195+
canaryController = &rollout.Controller{
196+
Client: mgr.GetClient(),
197+
Scheme: mgr.GetScheme(),
198+
PromClient: metrics.NewClient(prometheusURL),
199+
GatewayName: gatewayName,
200+
GatewayNamespace: gatewayNamespace,
201+
FailSafeTimeout: 60 * time.Second,
202+
}
203+
} else {
204+
setupLog.Info("canary rollout disabled (no --prometheus-url)")
205+
}
206+
174207
if err = (&controller.AgentDeploymentReconciler{
175-
Client: mgr.GetClient(),
176-
Scheme: mgr.GetScheme(),
177-
GPUResourceName: gpuResourceName,
208+
Client: mgr.GetClient(),
209+
Scheme: mgr.GetScheme(),
210+
GPUResourceName: gpuResourceName,
211+
CanaryController: canaryController,
178212
}).SetupWithManager(mgr); err != nil {
179213
setupLog.Error(err, "unable to create controller", "controller", "AgentDeployment")
180214
os.Exit(1)

config/crd/bases/agentrax.io_agentdeployments.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,11 @@ spec:
377377
status:
378378
description: AgentDeploymentStatus defines the observed state of an AgentDeployment.
379379
properties:
380+
canaryStepIndex:
381+
description: |-
382+
CanaryStepIndex is the index of the currently executing rollout step.
383+
Persisted so the state machine survives operator restarts.
384+
type: integer
380385
canaryVersion:
381386
description: CanaryVersion is the container image tag of the canary
382387
deployment, if one is in progress.
@@ -447,6 +452,12 @@ spec:
447452
description: CurrentReplicas is the number of replicas currently running.
448453
format: int32
449454
type: integer
455+
pauseStartedAt:
456+
description: |-
457+
PauseStartedAt records when the current pause step began.
458+
Used to enforce maximum pause extensions and the fail-safe rollback timeout.
459+
format: date-time
460+
type: string
450461
phase:
451462
description: Phase is the high-level lifecycle phase of this deployment.
452463
enum:
@@ -456,6 +467,12 @@ spec:
456467
- RolloutFailed
457468
- Degraded
458469
type: string
470+
promUnreachableSince:
471+
description: |-
472+
PromUnreachableSince records when Prometheus last became unreachable.
473+
When non-nil and age exceeds FailSafeTimeout, a fail-safe rollback fires.
474+
format: date-time
475+
type: string
459476
registered:
460477
description: Registered is true when this agent is currently registered
461478
in the MCP registry.

0 commit comments

Comments
 (0)