Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
### Serena (MCP — Semantic Code Intelligence)

This project supports **Serena** configured as an MCP server.
Serena exposes `gopls`-backed semantic tools via the Model Context Protocol. Note that Go and `gopls` must be installed and available on `PATH` before use.
Serena exposes `gopls`-backed semantic tools via the Model Context Protocol.
The project is indexed and the config lives at `.serena/project.yml`.

**Configuration & Setup:**
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/agentrax-context/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ description: Project context and settled architecture decisions for the Agentrax
| `internal/scaling/` | HPA generation and quota-capped scaling logic. |
| `internal/registry/` | MCP registrar, registry HTTP handler, TTL sweep. |
| `internal/quota/` | Quota arithmetic and in-flight reservation. Shared by webhook and TenantQuota reconciler. |
| `internal/webhook/` | Validating and mutating admission webhooks. Lives here (not `api/`) to import `internal/quota` without creating an import cycle. |
| `internal/metrics/` | Shared Prometheus client plumbing used by rollout and scaling. |

## Where the hard logic lives
Expand Down
50 changes: 50 additions & 0 deletions api/v1alpha1/error_rate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1alpha1

import (
"fmt"
"math"
"strconv"
)

// ParseErrorRate parses a percentage string like "2%" and returns the float64
// value (e.g. 0.02 for "2%"). Returns an error if the format is invalid.
// Used by the validating webhook and the rollout threshold evaluator.
func ParseErrorRate(s string) (float64, error) {
if len(s) == 0 {
return 0, fmt.Errorf("empty error rate string")
}
if s[len(s)-1] != '%' {
return 0, fmt.Errorf("error rate must end with '%%': got %q", s)
}
// strconv.ParseFloat rejects trailing garbage (e.g. "5x") and leading
// whitespace (e.g. " 5"), unlike fmt.Sscanf which silently ignores them.
pct, err := strconv.ParseFloat(s[:len(s)-1], 64)
if err != nil {
return 0, fmt.Errorf("parsing error rate %q: %w", s, err)
}
// Reject non-finite values (NaN, ±Inf) that ParseFloat may return for
// inputs like "NaN" or "Inf".
if math.IsNaN(pct) || math.IsInf(pct, 0) {
return 0, fmt.Errorf("error rate %q is not a finite number", s)
}
if pct < 0 || pct > 100 {
return 0, fmt.Errorf("error rate %q out of range [0, 100]", s)
}
return pct / 100.0, nil
}
70 changes: 70 additions & 0 deletions api/v1alpha1/error_rate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
Copyright 2026.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v1alpha1_test

import (
"testing"

agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1"
)

func TestParseErrorRate(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want float64
wantErr bool
}{
{"2%", 0.02, false},
{"100%", 1.0, false},
{"0%", 0.0, false},
{"0.5%", 0.005, false},
{"", 0, true},
{"5", 0, true},
{"-1%", 0, true},
{"101%", 0, true},
{"abc%", 0, true},
// trailing garbage — strconv.ParseFloat must reject these
{"5x%", 0, true},
// non-finite numeric input — caught by math.IsNaN / math.IsInf guard
{"NaN%", 0, true},
{"Inf%", 0, true},
{"-Inf%", 0, true},
// leading whitespace — strconv.ParseFloat must reject " 5"
{" 5%", 0, true},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for _, tc := range tests {
tc := tc
t.Run(tc.input, func(t *testing.T) {
t.Parallel()
got, err := agentraxv1alpha1.ParseErrorRate(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ParseErrorRate(%q) error=%v, wantErr=%v", tc.input, err, tc.wantErr)
}
if err == nil && abs(got-tc.want) > 1e-9 {
t.Errorf("ParseErrorRate(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}

func abs(f float64) float64 {
if f < 0 {
return -f
}
return f
}
17 changes: 15 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import (

agentraxv1alpha1 "github.com/gitcommitankit/agentrax/api/v1alpha1"
"github.com/gitcommitankit/agentrax/internal/controller"
"github.com/gitcommitankit/agentrax/internal/quota"
agentraxwebhook "github.com/gitcommitankit/agentrax/internal/webhook"
// +kubebuilder:scaffold:imports
)

Expand All @@ -59,6 +61,7 @@ func main() {
var probeAddr string
var secureMetrics bool
var enableHTTP2 bool
var gpuResourceName string
var tlsOpts []func(*tls.Config)
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
Expand All @@ -70,6 +73,8 @@ func main() {
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
flag.StringVar(&gpuResourceName, "gpu-resource-name", quota.DefaultGPUResourceName,
"Kubernetes resource name used to count GPU units in AgentDeployment resource limits.")
opts := zap.Options{
Development: true,
}
Expand Down Expand Up @@ -145,6 +150,9 @@ func main() {
os.Exit(1)
}

// Shared quota enforcer used by both the webhook validator and TenantQuota reconciler.
quotaEnforcer := quota.NewEnforcer(gpuResourceName)

if err = (&controller.AgentDeploymentReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Expand All @@ -153,12 +161,17 @@ func main() {
os.Exit(1)
}
if err = (&controller.TenantQuotaReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Enforcer: quotaEnforcer,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "TenantQuota")
os.Exit(1)
}
if err = agentraxwebhook.SetupAgentDeploymentWebhookWithManager(mgr, quotaEnforcer); err != nil {
setupLog.Error(err, "unable to register webhook", "webhook", "AgentDeployment")
os.Exit(1)
}
// +kubebuilder:scaffold:builder

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Expand Down
10 changes: 8 additions & 2 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ rules:
- agentrax.io
resources:
- agentdeployments
- tenantquotas
verbs:
- create
- delete
Expand All @@ -21,7 +20,6 @@ rules:
- agentrax.io
resources:
- agentdeployments/finalizers
- tenantquotas/finalizers
verbs:
- update
- apiGroups:
Expand All @@ -33,6 +31,14 @@ rules:
- get
- patch
- update
- apiGroups:
- agentrax.io
resources:
- tenantquotas
verbs:
- get
- list
- watch
- apiGroups:
- apiextensions.k8s.io
resources:
Expand Down
3 changes: 3 additions & 0 deletions config/webhook/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
resources:
- manifests.yaml
- service.yaml
52 changes: 52 additions & 0 deletions config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: mutating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /mutate-agentrax-io-v1alpha1-agentdeployment
failurePolicy: Fail
name: magentdeployment.kb.io
rules:
- apiGroups:
- agentrax.io
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- agentdeployments
sideEffects: None
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: validating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /validate-agentrax-io-v1alpha1-agentdeployment
failurePolicy: Fail
name: vagentdeployment.kb.io
rules:
- apiGroups:
- agentrax.io
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- agentdeployments
sideEffects: None
13 changes: 13 additions & 0 deletions config/webhook/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
apiVersion: v1
kind: Service
metadata:
name: webhook-service
namespace: system
spec:
ports:
- port: 443
protocol: TCP
targetPort: 9443
selector:
control-plane: controller-manager
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/gitcommitankit/agentrax
go 1.22.0

require (
github.com/go-logr/logr v1.4.2
github.com/onsi/ginkgo/v2 v2.19.0
github.com/onsi/gomega v1.33.1
github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.75.0
Expand All @@ -26,7 +27,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
Expand Down Expand Up @@ -84,6 +84,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand All @@ -96,4 +97,4 @@ require (
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
)
20 changes: 20 additions & 0 deletions internal/controller/agentdeployment_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,28 @@ const (
testNginxImage = "nginx:latest"
)

// ensureTenantQuota creates the TenantQuota "team-test" in the given namespace
// if it does not already exist. Phase 2 webhooks require spec.tenantRef to
// resolve to a real TenantQuota before admitting an AgentDeployment.
func ensureTenantQuota(namespace string) {
tq := &agentraxv1alpha1.TenantQuota{
ObjectMeta: metav1.ObjectMeta{Name: "team-test", Namespace: namespace},
Spec: agentraxv1alpha1.TenantQuotaSpec{
MaxAgents: 100,
MaxGPUs: 0,
MaxTotalReplicas: 300,
MaxReplicasPerAgent: 10,
},
}
err := k8sClient.Create(ctx, tq)
Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(),
"ensuring TenantQuota team-test in %s: %v", namespace, err)
}

// createAgentDeployment is a test helper that creates a minimal AgentDeployment
// and returns its NamespacedName.
func createAgentDeployment(name, namespace, image string, port, minReplicas int32) types.NamespacedName {
ensureTenantQuota(namespace)
ad := &agentraxv1alpha1.AgentDeployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Expand Down Expand Up @@ -647,6 +666,7 @@ var _ = Describe("AgentDeployment Controller", func() {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-env-args"}}
err := k8sClient.Create(ctx, ns)
Expect(err == nil || apierrors.IsAlreadyExists(err)).To(BeTrue(), "creating namespace test-env-args: %v", err)
ensureTenantQuota("test-env-args")

ad := &agentraxv1alpha1.AgentDeployment{
ObjectMeta: metav1.ObjectMeta{
Expand Down
Loading
Loading