From 4476ce13cdbb843e2e815de62f081ce4ba9e58d4 Mon Sep 17 00:00:00 2001 From: Jaana Dogan Date: Sat, 31 Oct 2026 19:33:38 -0700 Subject: [PATCH] Move to substrate v0.6 APIs, dial the atenet router over mTLS Bump go.ate.dev/substrate to v0.6.1-0.20261030223608-6ddb1934dd75 and switch to its relocated packages: ateapipb is now go.ate.dev/substrate/api/ateapi/v1 and the WorkerPool types are in go.ate.dev/substrate/api/infra/k8s/ate/v1. ate-env-api: - New -atenet-credential-bundle and -atenet-ca-file flags. When set, router connections use mTLS (ate.RouterTLSCredentials), presenting the pod identity certificate; the bundle is re-read on every handshake so rotated certificates are picked up. Empty keeps plaintext. - ate.Options.RouterCreds carries the router transport credentials. ate-env manifest: - The API Deployment dials the router at atenet-router.ate-system.svc over mTLS, with a podCertificate projection for its identity and a ClusterTrustBundle projection for the router's serving CA. - The WorkerPool is labeled workload=ate-env and the ActorTemplate selects it with workerSelector, replacing sandboxConfig. - Resource limits use the new ResourceRequirements shape (cpuMillicores, memoryBytes). Signed-off-by: Jaana Dogan --- clients/go/client_test.go | 2 +- cmd/ate-env-api/main.go | 12 +++ cmd/ate-env/manifest.go | 86 +++++++++++++++---- cmd/ate-env/manifest_test.go | 54 ++++-------- go.mod | 8 +- go.sum | 12 +-- internal/apiservice/imagetemplate.go | 2 +- internal/apiservice/imagetemplate_test.go | 12 +-- internal/apiservice/server.go | 5 +- internal/apiservice/server_test.go | 2 +- internal/apiservice/wakeretry.go | 2 +- internal/apiservice/wakeretry_test.go | 2 +- internal/ate/client.go | 17 +++- internal/ate/client_test.go | 2 +- internal/ate/router_tls.go | 72 ++++++++++++++++ .../internaltest/fakecontrol/fakecontrol.go | 16 +--- internal/mcp/server_test.go | 2 +- 17 files changed, 211 insertions(+), 97 deletions(-) create mode 100644 internal/ate/router_tls.go diff --git a/clients/go/client_test.go b/clients/go/client_test.go index 56d46170..bbf76509 100644 --- a/clients/go/client_test.go +++ b/clients/go/client_test.go @@ -32,7 +32,7 @@ import ( "github.com/agent-substrate/env/internal/internaltest/fakerouter" "github.com/agent-substrate/env/internal/mcp" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc" ) diff --git a/cmd/ate-env-api/main.go b/cmd/ate-env-api/main.go index ad1a5f0b..d550b9e5 100644 --- a/cmd/ate-env-api/main.go +++ b/cmd/ate-env-api/main.go @@ -31,6 +31,7 @@ import ( "github.com/agent-substrate/env/internal/mcp" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" ) func main() { @@ -38,6 +39,8 @@ func main() { listen = flag.String("listen", "0.0.0.0:7777", "address to serve the API on") ateapi = flag.String("ateapi", ate.DefaultControlAddr, "address of the ateapi gRPC control plane") atenet = flag.String("atenet", ate.DefaultRouterAddr, "address of the atenet HTTP router") + atenetCredBundle = flag.String("atenet-credential-bundle", "", "PEM credential bundle (cert+key) presented as the client certificate to the atenet router; empty dials the router in plaintext") + atenetCAFile = flag.String("atenet-ca-file", "", "PEM trust bundle for the atenet router's serving certificate; required with -atenet-credential-bundle") hostSuffix = flag.String("host-suffix", ate.DefaultHostSuffix, "atenet router host suffix for actor routing") skipVerify = flag.Bool("skip-verify", true, "skip TLS certificate verification on the control plane connection") tokenFile = flag.String("ateapi-token-file", "", "file with a bearer token for the control plane, e.g. a projected ServiceAccount token with the ateapi audience") @@ -46,9 +49,18 @@ func main() { ) flag.Parse() + var routerCreds credentials.TransportCredentials + if *atenetCredBundle != "" { + var err error + if routerCreds, err = ate.RouterTLSCredentials(*atenetCredBundle, *atenetCAFile); err != nil { + log.Fatalf("loading atenet router credentials: %v", err) + } + } + client, err := ate.New(ate.Options{ ControlAddr: *ateapi, RouterAddr: *atenet, + RouterCreds: routerCreds, HostSuffix: *hostSuffix, SkipVerify: *skipVerify, BearerTokenFile: *tokenFile, diff --git a/cmd/ate-env/manifest.go b/cmd/ate-env/manifest.go index 56f68482..81bbc037 100644 --- a/cmd/ate-env/manifest.go +++ b/cmd/ate-env/manifest.go @@ -21,9 +21,10 @@ import ( "io" "github.com/agent-substrate/env/internal/apiservice" + "github.com/agent-substrate/env/internal/ate" "github.com/spf13/cobra" - atev1alpha1 "go.ate.dev/substrate/pkg/api/v1alpha1" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" + atev1 "go.ate.dev/substrate/api/infra/k8s/ate/v1" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" appsv1 "k8s.io/api/apps/v1" @@ -31,6 +32,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" "sigs.k8s.io/yaml" ) @@ -42,6 +44,10 @@ const apiName = "ate-env-api" // one, so a pool declared with it is schedulable on a stock install. const sandboxConfigName = "gvisor-default" +// workerPoolLabels label the WorkerPool, and the ActorTemplate's +// workerSelector matches them so actors only land on this pool's workers. +var workerPoolLabels = map[string]string{"workload": "ate-env"} + // Default compute limits for each actor. Substrate requires both on every // ActorTemplate since substrate#2285: the scheduler books them against worker // capacity. Two CPUs and 4 GiB match the Python client's ResourceLimits @@ -361,17 +367,18 @@ func buildNamespace(cfg manifestConfig) *corev1.Namespace { } } -func buildWorkerPool(cfg manifestConfig) *atev1alpha1.WorkerPool { - return &atev1alpha1.WorkerPool{ +func buildWorkerPool(cfg manifestConfig) *atev1.WorkerPool { + return &atev1.WorkerPool{ TypeMeta: metav1.TypeMeta{ - APIVersion: atev1alpha1.GroupVersion.String(), + APIVersion: atev1.GroupVersion.String(), Kind: "WorkerPool", }, ObjectMeta: metav1.ObjectMeta{ Name: cfg.workerPool, Namespace: cfg.namespace, + Labels: workerPoolLabels, }, - Spec: atev1alpha1.WorkerPoolSpec{ + Spec: atev1.WorkerPoolSpec{ Replicas: cfg.replicas, WorkerImage: cfg.workerImage, // The CRD requires exactly one sandbox class per pool, and @@ -379,9 +386,9 @@ func buildWorkerPool(cfg manifestConfig) *atev1alpha1.WorkerPool { // so keep this in step with buildActorTemplate. The configRef is // the SandboxConfig the workers run; the template names the same // one in sandboxConfig.configName. - SandboxClasses: []atev1alpha1.WorkerPoolSandboxClass{{ - Name: atev1alpha1.SandboxClassGvisor, // the template's sandboxConfig.sandboxClass is the same string - ConfigRef: &atev1alpha1.SandboxConfigReference{Name: sandboxConfigName}, + SandboxClasses: []atev1.WorkerPoolSandboxClass{{ + Name: atev1.SandboxClassGvisor, // the template's sandboxConfig.sandboxClass is the same string + ConfigRef: &atev1.SandboxConfigReference{Name: sandboxConfigName}, }}, }, } @@ -419,17 +426,21 @@ func buildActorTemplate(cfg templateConfig) *ateapipb.ActorTemplate { // schema marks the field required, so say it explicitly. PreferredFidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY, }, - SandboxConfig: &ateapipb.SandboxConfig{ - SandboxClass: string(atev1alpha1.SandboxClassGvisor), - ConfigName: sandboxConfigName, - }, - Resources: &ateapipb.Resources{Limits: []*ateapipb.Limits{ - {Name: "cpu", Quantity: orDefault(cfg.cpu, defaultActorCPU)}, - {Name: "memory", Quantity: orDefault(cfg.memory, defaultActorMemory)}, + WorkerSelector: &ateapipb.Selector{MatchLabels: workerPoolLabels}, + Resources: &ateapipb.ResourceRequirements{Limits: &ateapipb.Resources{ + CpuMillicores: quantity(cfg.cpu, defaultActorCPU).MilliValue(), + MemoryBytes: quantity(cfg.memory, defaultActorMemory).Value(), }}, } } +// quantity parses v, or def if v is empty. validateResources has already +// checked v. +func quantity(v, def string) *resource.Quantity { + q := resource.MustParse(orDefault(v, def)) + return &q +} + func orDefault(v, def string) string { if v == "" { return def @@ -476,17 +487,30 @@ func buildAPIDeployment(cfg manifestConfig) *appsv1.Deployment { Containers: []corev1.Container{{ Name: apiName, Image: cfg.apiImage, - // ateapi/atenet default to the in-cluster - // Substrate service addresses. + // ateapi defaults to the in-cluster Substrate + // service address. The atenet router only admits + // mTLS clients with an allowed pod identity, so + // ate-env-api presents its pod certificate. Args: []string{ "-listen", fmt.Sprintf("0.0.0.0:%d", cfg.apiPort), "-ateapi-token-file=/var/run/secrets/ateapi/token", + "-atenet=" + ate.RouterServerName + ":443", + "-atenet-credential-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem", + "-atenet-ca-file=/run/servicedns-ca/trust-bundle.pem", }, Ports: []corev1.ContainerPort{{ContainerPort: cfg.apiPort}}, VolumeMounts: []corev1.VolumeMount{{ Name: "ateapi-token", MountPath: "/var/run/secrets/ateapi", ReadOnly: true, + }, { + Name: "podidentity", + MountPath: "/run/podidentity.podcert.ate.dev", + ReadOnly: true, + }, { + Name: "servicedns-ca", + MountPath: "/run/servicedns-ca", + ReadOnly: true, }}, ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ @@ -510,6 +534,32 @@ func buildAPIDeployment(cfg manifestConfig) *appsv1.Deployment { }}, }, }, + }, { + Name: "podidentity", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + PodCertificate: &corev1.PodCertificateProjection{ + SignerName: "podidentity.podcert.ate.dev/identity", + KeyType: "ECDSAP256", + CredentialBundlePath: "credential-bundle.pem", + }, + }}, + }, + }, + }, { + Name: "servicedns-ca", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + ClusterTrustBundle: &corev1.ClusterTrustBundleProjection{ + SignerName: ptr.To("servicedns.podcert.ate.dev/identity"), + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"podcert.ate.dev/canarying": "live"}}, + Path: "trust-bundle.pem", + }, + }}, + }, + }, }}, }, }, diff --git a/cmd/ate-env/manifest_test.go b/cmd/ate-env/manifest_test.go index 5353d139..bf8a2582 100644 --- a/cmd/ate-env/manifest_test.go +++ b/cmd/ate-env/manifest_test.go @@ -20,8 +20,8 @@ import ( "testing" "github.com/agent-substrate/env/internal/apiservice" - atev1alpha1 "go.ate.dev/substrate/pkg/api/v1alpha1" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" + atev1 "go.ate.dev/substrate/api/infra/k8s/ate/v1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" ) @@ -96,16 +96,13 @@ func TestBuildManifests(t *testing.T) { t.Errorf("namespace label %s = %q, want \"true\" (ate-system admits API clients by it)", apiAccessLabel, got) } - pool := objs[1].(*atev1alpha1.WorkerPool) + pool := objs[1].(*atev1.WorkerPool) if pool.Namespace != cfg.namespace || pool.Name != "default-template-workerpool" { t.Errorf("workerpool = %s/%s, want %s/default-template-workerpool", pool.Namespace, pool.Name, cfg.namespace) } if pool.Spec.Replicas != 3 || pool.Spec.WorkerImage != cfg.workerImage { t.Errorf("workerpool spec = %+v, want replicas 3 and worker image %q", pool.Spec, cfg.workerImage) } - if len(pool.Labels) != 0 { - t.Errorf("workerpool labels = %v, want empty", pool.Labels) - } if pool.Spec.Template != nil { t.Errorf("workerpool template = %v, want nil", pool.Spec.Template) } @@ -113,15 +110,19 @@ func TestBuildManifests(t *testing.T) { t.Fatalf("workerpool sandboxClasses = %+v, want exactly one", pool.Spec.SandboxClasses) } sc := pool.Spec.SandboxClasses[0] - if sc.Name != atev1alpha1.SandboxClassGvisor || sc.ConfigRef == nil || sc.ConfigRef.Name != sandboxConfigName { + if sc.Name != atev1.SandboxClassGvisor || sc.ConfigRef == nil || sc.ConfigRef.Name != sandboxConfigName { t.Errorf("workerpool sandboxClasses[0] = %+v, want gvisor with configRef %q", sc, sandboxConfigName) } - // The pool's class and the template's are the same string since - // substrate#2586 made the proto field a string. A pool of the wrong class - // never hosts the template's actors. - tmplClass := buildActorTemplate(testTemplateConfig()).GetSandboxConfig().GetSandboxClass() - if tmplClass != string(sc.Name) { - t.Errorf("workerpool sandbox class %q != template sandbox class %q", sc.Name, tmplClass) + // The template selects workers by the pool's labels; a pool it does not + // select never hosts its actors. + sel := buildActorTemplate(testTemplateConfig()).GetWorkerSelector().GetMatchLabels() + if len(sel) == 0 { + t.Error("template worker selector is empty, want the pool's labels") + } + for k, v := range sel { + if pool.Labels[k] != v { + t.Errorf("workerpool labels %v do not match template worker selector %v", pool.Labels, sel) + } } deployment := objs[2].(*appsv1.Deployment) @@ -165,38 +166,21 @@ func TestBuildActorTemplate(t *testing.T) { if got := template.GetSnapshotConfig().GetGoldenSnapshotConfig(); got != nil { t.Errorf("golden snapshot config = %v, want nil (omitted means enabled)", got) } - if got := template.GetSandboxConfig().GetConfigName(); got != sandboxConfigName { - t.Errorf("sandbox config name = %q, want %q (must match the WorkerPool configRef)", got, sandboxConfigName) - } - if template.GetWorkerSelector() != nil { - t.Errorf("worker selector = %v, want nil", template.WorkerSelector) - } probe := template.Containers[0].GetWakeupProbe() if probe.GetHttpGet() == nil || probe.GetHttpGet().GetPath() != "/readyz" || probe.GetHttpGet().GetPort() != 80 { t.Errorf("wakeupProbe = %+v, want HTTP GET /readyz on port 80", probe) } // Substrate rejects an ActorTemplate without both limits (substrate#2285). - if got := limits(template); got["cpu"] != defaultActorCPU || got["memory"] != defaultActorMemory || len(got) != 2 { - t.Errorf("resources.limits = %v, want cpu=%s memory=%s", got, defaultActorCPU, defaultActorMemory) - } - if got := template.GetSandboxConfig().GetSandboxClass(); got != "gvisor" { - t.Errorf("sandbox class = %q, want gvisor (Substrate accepts gvisor or microvm)", got) - } -} - -func limits(tmpl *ateapipb.ActorTemplate) map[string]string { - m := map[string]string{} - for _, l := range tmpl.GetResources().GetLimits() { - m[l.GetName()] = l.GetQuantity() + if got := template.GetResources().GetLimits(); got.GetCpuMillicores() != 2000 || got.GetMemoryBytes() != 4<<30 { + t.Errorf("resources.limits = %v, want 2000 millicores and 4Gi", got) } - return m } func TestBuildActorTemplateResources(t *testing.T) { cfg := testTemplateConfig() cfg.cpu, cfg.memory = "500m", "1Gi" - if got := limits(buildActorTemplate(cfg)); got["cpu"] != "500m" || got["memory"] != "1Gi" { - t.Errorf("resources.limits = %v, want cpu=500m memory=1Gi", got) + if got := buildActorTemplate(cfg).GetResources().GetLimits(); got.GetCpuMillicores() != 500 || got.GetMemoryBytes() != 1<<30 { + t.Errorf("resources.limits = %v, want 500 millicores and 1Gi", got) } } @@ -368,7 +352,7 @@ func TestManifestTemplateCommandExecution(t *testing.T) { if !strings.Contains(out, "image: example.com/guest@sha256:aaaa") || !strings.Contains(out, "storageLocation: gs://bucket/prefix/") { t.Errorf("expected guest image and storage location in output:\n%s", out) } - for _, want := range []string{"sandboxClass: gvisor", "name: cpu\n quantity: \"4\"", "name: memory\n quantity: 8Gi"} { + for _, want := range []string{"workload: ate-env", "cpuMillicores: \"4000\"", "memoryBytes: \"8589934592\""} { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } diff --git a/go.mod b/go.mod index f611624d..7de68e4c 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,12 @@ require ( github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/spf13/cobra v1.10.2 - go.ate.dev/substrate v0.5.1-0.20261016164544-3a2ca6235748 + go.ate.dev/substrate v0.6.1-0.20261030223608-6ddb1934dd75 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 - k8s.io/api v0.37.1 - k8s.io/apimachinery v0.37.1 + k8s.io/api v0.37.0 + k8s.io/apimachinery v0.37.0 + k8s.io/utils v0.0.0-20260626114624-be93311217bd sigs.k8s.io/yaml v1.6.0 ) @@ -38,7 +39,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect - k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect sigs.k8s.io/controller-runtime v0.24.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index edf211fe..4cfb2541 100644 --- a/go.sum +++ b/go.sum @@ -109,8 +109,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -go.ate.dev/substrate v0.5.1-0.20261016164544-3a2ca6235748 h1:QrTauJhnfn0FL0t/GLzVo4bmeVUYMT6e4x8QSaQF8s0= -go.ate.dev/substrate v0.5.1-0.20261016164544-3a2ca6235748/go.mod h1:EktaeScxg724tF4cjtLV8kdz2xN3FH8NOTk/A95jSBY= +go.ate.dev/substrate v0.6.1-0.20261030223608-6ddb1934dd75 h1:tzl0PWr3PrzAts2HrKztaMK6q3xaDmzwQARl3JI80xU= +go.ate.dev/substrate v0.6.1-0.20261030223608-6ddb1934dd75/go.mod h1:sS0tm65aYf+xIkkl7mTSQAS2P8tXM5xUOTnLXIE4tvc= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -147,12 +147,12 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM= -k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M= +k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= -k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE= -k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw= +k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ= k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= diff --git a/internal/apiservice/imagetemplate.go b/internal/apiservice/imagetemplate.go index a90fde73..adbf0548 100644 --- a/internal/apiservice/imagetemplate.go +++ b/internal/apiservice/imagetemplate.go @@ -21,7 +21,7 @@ import ( "regexp" "strings" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/protobuf/proto" ) diff --git a/internal/apiservice/imagetemplate_test.go b/internal/apiservice/imagetemplate_test.go index f7444eea..fb43a68d 100644 --- a/internal/apiservice/imagetemplate_test.go +++ b/internal/apiservice/imagetemplate_test.go @@ -18,7 +18,7 @@ import ( "strings" "testing" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/protobuf/proto" ) @@ -39,11 +39,8 @@ func modeABase() *ateapipb.ActorTemplate { WakeupProbe: &ateapipb.WakeupProbe{HttpGet: &ateapipb.HttpGetAction{Path: "/readyz", Port: 80}}, }}, SnapshotConfig: &ateapipb.SnapshotConfig{StorageLocation: "gs://b/p/", PreferredFidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY}, - SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: "gvisor", ConfigName: "gvisor-default"}, - Resources: &ateapipb.Resources{Limits: []*ateapipb.Limits{ - {Name: "cpu", Quantity: "2"}, {Name: "memory", Quantity: "4Gi"}, - }}, - Status: &ateapipb.ActorTemplateStatus{}, + Resources: &ateapipb.ResourceRequirements{Limits: &ateapipb.Resources{CpuMillicores: 2000, MemoryBytes: 4 << 30}}, + Status: &ateapipb.ActorTemplateStatus{}, } } @@ -110,10 +107,9 @@ func TestDeriveImageTemplateFromGuestImageBase(t *testing.T) { // Everything else carries over. if !proto.Equal(got.GetWorkerSelector(), base.GetWorkerSelector()) || !proto.Equal(got.GetSnapshotConfig(), base.GetSnapshotConfig()) || - !proto.Equal(got.GetSandboxConfig(), base.GetSandboxConfig()) || !proto.Equal(c.GetWakeupProbe(), base.GetContainers()[0].GetWakeupProbe()) || len(c.GetEnv()) != 1 { - t.Error("worker selector, snapshots, sandbox config, readyz or env did not carry over") + t.Error("worker selector, snapshots, readyz or env did not carry over") } } diff --git a/internal/apiservice/server.go b/internal/apiservice/server.go index aa911b1c..a305be7c 100644 --- a/internal/apiservice/server.go +++ b/internal/apiservice/server.go @@ -26,10 +26,9 @@ import ( "github.com/agent-substrate/env/internal/ate" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -423,7 +422,7 @@ func (s *Server) guestConn(ctx context.Context, atespace, id string) (*grpc.Clie targetActor := atespace + "/" + id conn, err := grpc.NewClient( s.routerAddr, - grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithTransportCredentials(s.client.RouterCreds()), grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { ctx = metadata.AppendToOutgoingContext(ctx, ate.TargetActorHeader, targetActor) return invoker(ctx, method, req, reply, cc, opts...) diff --git a/internal/apiservice/server_test.go b/internal/apiservice/server_test.go index 6969bc83..94299467 100644 --- a/internal/apiservice/server_test.go +++ b/internal/apiservice/server_test.go @@ -28,7 +28,7 @@ import ( "github.com/agent-substrate/env/internal/internaltest/fakecontrol" "github.com/agent-substrate/env/internal/internaltest/fakerouter" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" diff --git a/internal/apiservice/wakeretry.go b/internal/apiservice/wakeretry.go index d9c88571..d1ff9f34 100644 --- a/internal/apiservice/wakeretry.go +++ b/internal/apiservice/wakeretry.go @@ -21,7 +21,7 @@ import ( "time" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" diff --git a/internal/apiservice/wakeretry_test.go b/internal/apiservice/wakeretry_test.go index 19d49265..bccf71e4 100644 --- a/internal/apiservice/wakeretry_test.go +++ b/internal/apiservice/wakeretry_test.go @@ -23,7 +23,7 @@ import ( "github.com/agent-substrate/env/guest" "github.com/agent-substrate/env/internal/apiservice" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" diff --git a/internal/ate/client.go b/internal/ate/client.go index d5079156..454e5abb 100644 --- a/internal/ate/client.go +++ b/internal/ate/client.go @@ -35,7 +35,7 @@ import ( "strings" "sync" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -79,6 +79,10 @@ type Options struct { // Required for Cmd and filesystem operations. RouterAddr string + // RouterCreds are the transport credentials for router connections, + // e.g. from RouterTLSCredentials. Nil means plaintext. + RouterCreds credentials.TransportCredentials + // HostSuffix overrides the router host suffix. Defaults to // DefaultHostSuffix. HostSuffix string @@ -155,6 +159,15 @@ func (c *Client) Close() error { return c.conn.Close() } +// RouterCreds returns the transport credentials for router connections: +// Options.RouterCreds, or plaintext if unset or c is nil. +func (c *Client) RouterCreds() credentials.TransportCredentials { + if c == nil || c.opts.RouterCreds == nil { + return insecure.NewCredentials() + } + return c.opts.RouterCreds +} + // DialGuest dials the guest daemon inside actor id in atespace via the atenet router. func (c *Client) DialGuest(atespace, id string) (*grpc.ClientConn, error) { if c.opts.RouterAddr == "" { @@ -180,7 +193,7 @@ func (c *Client) DialGuest(atespace, id string) (*grpc.ClientConn, error) { targetActor := atespace + "/" + id conn, err := grpc.NewClient( c.opts.RouterAddr, - grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithTransportCredentials(c.RouterCreds()), grpc.WithChainUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { ctx = metadata.AppendToOutgoingContext(ctx, TargetActorHeader, targetActor) return invoker(ctx, method, req, reply, cc, opts...) diff --git a/internal/ate/client_test.go b/internal/ate/client_test.go index 727e5fcf..47684abd 100644 --- a/internal/ate/client_test.go +++ b/internal/ate/client_test.go @@ -23,7 +23,7 @@ import ( "github.com/agent-substrate/env/internal/ate" "github.com/agent-substrate/env/internal/internaltest/fakecontrol" "github.com/agent-substrate/env/internal/internaltest/fakerouter" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" ) type fixture struct { diff --git a/internal/ate/router_tls.go b/internal/ate/router_tls.go new file mode 100644 index 00000000..369c7d7a --- /dev/null +++ b/internal/ate/router_tls.go @@ -0,0 +1,72 @@ +// Copyright 2026 Google LLC +// +// 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 ate + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" + + "google.golang.org/grpc/credentials" +) + +// RouterServerName is the name the atenet router's serving certificate is +// issued for. +const RouterServerName = "atenet-router.ate-system.svc" + +// RouterTLSCredentials returns mTLS credentials for the atenet router's +// ingress listener, which only admits clients presenting a pod identity +// certificate whose SPIFFE ID is on its --ingress-allowed-spiffe-ids list. +// credentialBundle is a PEM file holding the client certificate chain and +// key (a podCertificate projection); caFile is the PEM trust bundle the +// router's serving certificate chains to. The credential bundle is re-read +// on every handshake so rotated pod certificates are picked up. +func RouterTLSCredentials(credentialBundle, caFile string) (credentials.TransportCredentials, error) { + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("ate: reading router CA bundle: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("ate: router CA bundle %s holds no certificates", caFile) + } + if _, err := loadCredentialBundle(credentialBundle); err != nil { + return nil, err + } + return credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + ServerName: RouterServerName, + GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return loadCredentialBundle(credentialBundle) + }, + }), nil +} + +// loadCredentialBundle parses a PEM file holding a certificate chain and its +// private key. +func loadCredentialBundle(path string) (*tls.Certificate, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ate: reading router credential bundle: %w", err) + } + cert, err := tls.X509KeyPair(data, data) + if err != nil { + return nil, errors.Join(fmt.Errorf("ate: parsing router credential bundle %s", path), err) + } + return &cert, nil +} diff --git a/internal/internaltest/fakecontrol/fakecontrol.go b/internal/internaltest/fakecontrol/fakecontrol.go index 15a3ab3f..9f2a31ad 100644 --- a/internal/internaltest/fakecontrol/fakecontrol.go +++ b/internal/internaltest/fakecontrol/fakecontrol.go @@ -32,7 +32,7 @@ import ( "sync" "time" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -216,25 +216,13 @@ func (s *Server) suspend(a *ateapipb.Actor) { Owner: ateapipb.SnapshotOwner_SNAPSHOT_OWNER_ACTOR, Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY, DurableSnapshot: &ateapipb.SnapshotStorage{ - Status: ateapipb.SnapshotStorageStatus_SNAPSHOT_STORAGE_STATUS_COMPLETED, + State: ateapipb.SnapshotStorageState_SNAPSHOT_STORAGE_STATE_COMPLETED, Object: &ateapipb.ObjectSnapshot{}, }, }}, } } -func (s *Server) PauseActor(ctx context.Context, req *ateapipb.PauseActorRequest) (*ateapipb.PauseActorResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - a, err := s.get(req.GetActor()) - if err != nil { - return nil, err - } - // Substrate reports a paused actor as SUSPENDED (substrate#2620). - a.Status = &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED} - return &ateapipb.PauseActorResponse{Actor: clone(a)}, nil -} - func (s *Server) DeleteActor(ctx context.Context, req *ateapipb.DeleteActorRequest) (*ateapipb.Actor, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 3cf48def..30d77a6e 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -29,7 +29,7 @@ import ( internalmcp "github.com/agent-substrate/env/internal/mcp" "github.com/agent-substrate/env/internal/tool" "github.com/modelcontextprotocol/go-sdk/mcp" - "go.ate.dev/substrate/pkg/proto/ateapipb" + ateapipb "go.ate.dev/substrate/api/ateapi/v1" ) func TestMCPServer(t *testing.T) {