Skip to content

Commit e09cb61

Browse files
fkautzahmedtd
authored andcommitted
test(e2e): identity restore suite, probe fixture, and router client
The one property unit tests cannot catch is identity captured in the golden snapshot: the env-var approach passed unit tests and config.json inspection yet returned the golden actor's ID after restore. The identity suite restores TWO actors from one golden snapshot and asserts each observes its own ID, and explicitly not the golden's, via the probe actor's /whoami, reached through the atenet router the way real traffic arrives. - probe: minimal introspection actor for e2e suites; reports the identity file at request time and surfaces read errors in the response, so a failing assertion explains itself. - RouterClient: port-forwards the atenet router service and routes on resources.ActorDNSSuffix. - deployProbe renders the manifest template in Go and execs the pinned ko/kubectl directly with argument slices: no shell, nothing to quote. KO_CONFIG_PATH must point at the repo root because ko resolves .ko.yaml from its working directory; without it the build silently loses defaultPlatforms and produces amd64-only images that cannot start on arm64 nodes.
1 parent 1f2c27b commit e09cb61

6 files changed

Lines changed: 660 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Command probe is a minimal introspection actor used by the e2e suites. It
16+
// reports what the runtime looks like from inside the actor, so tests can
17+
// assert on real in-actor state rather than the config atelet generates.
18+
//
19+
// Keep each endpoint small and independently assertable. New e2e suites add
20+
// probes here.
21+
package main
22+
23+
import (
24+
"encoding/json"
25+
"log"
26+
"net/http"
27+
"os"
28+
)
29+
30+
// identityFile is the actor-id file inside the identity directory atelet
31+
// bind-mounts at IdentityMountPath.
32+
const identityFile = "/run/ate/actor-id"
33+
34+
// whoami reports the actor's identity as observed at request time from the
35+
// bind-mounted identity file. A read failure is reported in the response
36+
// rather than swallowed, so a failing e2e assertion explains itself.
37+
func whoami(w http.ResponseWriter, _ *http.Request) {
38+
host, _ := os.Hostname()
39+
40+
resp := map[string]string{"hostname": host}
41+
if b, err := os.ReadFile(identityFile); err == nil {
42+
resp["file"] = string(b)
43+
} else {
44+
resp["file"] = ""
45+
resp["error"] = err.Error()
46+
}
47+
48+
writeJSON(w, resp)
49+
}
50+
51+
func writeJSON(w http.ResponseWriter, v any) {
52+
w.Header().Set("Content-Type", "application/json")
53+
if err := json.NewEncoder(w).Encode(v); err != nil {
54+
log.Printf("probe: encoding response: %v", err)
55+
}
56+
}
57+
58+
func main() {
59+
mux := http.NewServeMux()
60+
mux.HandleFunc("/whoami", whoami)
61+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
62+
63+
const addr = ":80"
64+
log.Printf("probe listening on %s", addr)
65+
server := &http.Server{Addr: addr, Handler: mux}
66+
if err := server.ListenAndServe(); err != nil {
67+
log.Fatalf("probe server: %v", err)
68+
}
69+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
apiVersion: v1
16+
kind: Namespace
17+
metadata:
18+
name: ate-e2e-probe
19+
20+
---
21+
22+
apiVersion: ate.dev/v1alpha1
23+
kind: WorkerPool
24+
metadata:
25+
name: probe
26+
namespace: ate-e2e-probe
27+
spec:
28+
replicas: 3
29+
ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor
30+
31+
---
32+
33+
apiVersion: ate.dev/v1alpha1
34+
kind: ActorTemplate
35+
metadata:
36+
name: probe
37+
namespace: ate-e2e-probe
38+
spec:
39+
runsc:
40+
amd64:
41+
url: "gs://gvisor/releases/nightly/2026-05-19/x86_64/runsc"
42+
sha256Hash: "a397be1abc2420d26bce6c70e6e2ff96c73aaaab929756c56f5e2089ea842b63"
43+
arm64:
44+
url: "gs://gvisor/releases/nightly/2026-05-19/aarch64/runsc"
45+
sha256Hash: "1ba2366ae2efceba166046f51a4104f9261c9cb72c6db8f5b3fe2dc57dea86b9"
46+
pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4"
47+
containers:
48+
- name: probe
49+
image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe
50+
command: ["/ko-app/probe"]
51+
workerPoolRef:
52+
namespace: ate-e2e-probe
53+
name: probe
54+
snapshotsConfig:
55+
location: gs://${BUCKET_NAME}/ate-e2e-probe/

internal/e2e/router_client.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package e2e
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"io"
21+
"net/http"
22+
"time"
23+
24+
"github.com/agent-substrate/substrate/internal/ateclient"
25+
"github.com/agent-substrate/substrate/internal/resources"
26+
corev1 "k8s.io/api/core/v1"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/labels"
29+
"k8s.io/apimachinery/pkg/util/intstr"
30+
"k8s.io/client-go/kubernetes"
31+
"k8s.io/client-go/tools/portforward"
32+
"k8s.io/client-go/transport/spdy"
33+
)
34+
35+
const (
36+
routerNamespace = "ate-system"
37+
routerService = "atenet-router"
38+
)
39+
40+
// RouterClient sends HTTP requests to actors through the atenet router, the
41+
// same way real traffic arrives (so the request is routed and, if needed, the
42+
// actor is resumed). It port-forwards the router Service, mirroring the
43+
// approach in internal/ateclient.
44+
type RouterClient struct {
45+
baseURL string
46+
http *http.Client
47+
stopCh chan struct{}
48+
}
49+
50+
// NewRouterClient establishes a port-forward to the atenet router. Call Close
51+
// to tear it down.
52+
func NewRouterClient(ctx context.Context) (*RouterClient, error) {
53+
config, err := ateclient.LoadConfig(KubeConfig, KubeContext)
54+
if err != nil {
55+
return nil, fmt.Errorf("loading kubeconfig: %w", err)
56+
}
57+
clientset, err := kubernetes.NewForConfig(config)
58+
if err != nil {
59+
return nil, fmt.Errorf("creating k8s client: %w", err)
60+
}
61+
62+
// Resolve the router Service to one of its backing pods.
63+
svc, err := clientset.CoreV1().Services(routerNamespace).Get(ctx, routerService, metav1.GetOptions{})
64+
if err != nil {
65+
return nil, fmt.Errorf("getting %s service: %w", routerService, err)
66+
}
67+
// A headless/selectorless Service would make SelectorFromSet match every
68+
// pod in the namespace; refuse rather than forward to an arbitrary pod.
69+
if len(svc.Spec.Selector) == 0 {
70+
return nil, fmt.Errorf("service %s has no selector", routerService)
71+
}
72+
selector := labels.SelectorFromSet(svc.Spec.Selector).String()
73+
pods, err := clientset.CoreV1().Pods(routerNamespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
74+
if err != nil {
75+
return nil, fmt.Errorf("listing %s pods: %w", routerService, err)
76+
}
77+
var targetPod *corev1.Pod
78+
for i := range pods.Items {
79+
if isPodReady(&pods.Items[i]) {
80+
targetPod = &pods.Items[i]
81+
break
82+
}
83+
}
84+
if targetPod == nil {
85+
return nil, fmt.Errorf("no ready %s pods in %s", routerService, routerNamespace)
86+
}
87+
88+
// Port-forward targets a pod's container port, so resolve the Service's
89+
// HTTP port (80) to its backing targetPort (kubectl does this for us when
90+
// forwarding a Service, but we forward the pod directly).
91+
targetPort, err := resolveHTTPTargetPort(svc, targetPod)
92+
if err != nil {
93+
return nil, err
94+
}
95+
96+
req := clientset.CoreV1().RESTClient().Post().
97+
Resource("pods").
98+
Namespace(routerNamespace).
99+
Name(targetPod.Name).
100+
SubResource("portforward")
101+
102+
transport, upgrader, err := spdy.RoundTripperFor(config)
103+
if err != nil {
104+
return nil, fmt.Errorf("creating SPDY transport: %w", err)
105+
}
106+
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, req.URL())
107+
108+
stopCh := make(chan struct{})
109+
readyCh := make(chan struct{})
110+
// Port 0 asks the OS for a random available local port.
111+
fw, err := portforward.New(dialer, []string{fmt.Sprintf("0:%d", targetPort)}, stopCh, readyCh, io.Discard, io.Discard)
112+
if err != nil {
113+
return nil, fmt.Errorf("creating port forwarder: %w", err)
114+
}
115+
116+
errCh := make(chan error, 1)
117+
go func() {
118+
if err := fw.ForwardPorts(); err != nil {
119+
errCh <- err
120+
}
121+
}()
122+
select {
123+
case <-readyCh:
124+
case err := <-errCh:
125+
return nil, fmt.Errorf("port forwarding: %w", err)
126+
case <-ctx.Done():
127+
close(stopCh)
128+
return nil, ctx.Err()
129+
}
130+
131+
ports, err := fw.GetPorts()
132+
if err != nil || len(ports) == 0 {
133+
close(stopCh)
134+
return nil, fmt.Errorf("getting forwarded ports: %w", err)
135+
}
136+
137+
return &RouterClient{
138+
baseURL: fmt.Sprintf("http://127.0.0.1:%d", ports[0].Local),
139+
http: &http.Client{Timeout: 30 * time.Second},
140+
stopCh: stopCh,
141+
}, nil
142+
}
143+
144+
// isPodReady reports whether the pod is Running, not terminating, and has
145+
// passed its readiness probe — i.e. actually serving, the same bar the Service
146+
// uses to select endpoints.
147+
func isPodReady(pod *corev1.Pod) bool {
148+
if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil {
149+
return false
150+
}
151+
for _, c := range pod.Status.Conditions {
152+
if c.Type == corev1.PodReady {
153+
return c.Status == corev1.ConditionTrue
154+
}
155+
}
156+
return false
157+
}
158+
159+
// resolveHTTPTargetPort maps the router Service's HTTP port (80) to the
160+
// container port it targets on the given pod, resolving named targetPorts.
161+
func resolveHTTPTargetPort(svc *corev1.Service, pod *corev1.Pod) (int32, error) {
162+
for _, sp := range svc.Spec.Ports {
163+
if sp.Port != 80 {
164+
continue
165+
}
166+
var port int32
167+
switch sp.TargetPort.Type {
168+
case intstr.Int:
169+
port = sp.TargetPort.IntVal
170+
case intstr.String:
171+
for _, c := range pod.Spec.Containers {
172+
for _, cp := range c.Ports {
173+
if cp.Name == sp.TargetPort.StrVal {
174+
port = cp.ContainerPort
175+
}
176+
}
177+
}
178+
if port == 0 {
179+
return 0, fmt.Errorf("named targetPort %q not found on pod %s", sp.TargetPort.StrVal, pod.Name)
180+
}
181+
}
182+
// Guard against an unset/zero targetPort, which would forward to nothing.
183+
if port <= 0 {
184+
return 0, fmt.Errorf("service %s port 80 has no usable targetPort", svc.Name)
185+
}
186+
return port, nil
187+
}
188+
return 0, fmt.Errorf("service %s has no port 80", svc.Name)
189+
}
190+
191+
// Close stops the port-forward tunnel.
192+
func (c *RouterClient) Close() {
193+
close(c.stopCh)
194+
}
195+
196+
// Get issues GET path to actorID through the router, setting the actor's mesh
197+
// Host so the router routes (and resumes) it. The caller must close the body.
198+
func (c *RouterClient) Get(ctx context.Context, actorID, path string) (*http.Response, error) {
199+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
200+
if err != nil {
201+
return nil, err
202+
}
203+
// The router routes on the Host/:authority, not a header.
204+
req.Host = fmt.Sprintf("%s.%s", actorID, resources.ActorDNSSuffix)
205+
return c.http.Do(req)
206+
}

0 commit comments

Comments
 (0)