Skip to content

Commit 125180e

Browse files
authored
feat: implement container readiness probes with custom HTTP endpoint … (agent-substrate#330)
Extends ActorTemplate.Container spec to allow defining a readyz probe. * ateoms (both gVisor & microVM) are updated and wait for readyz signal if it was enabled on container when creating a new container or resume from a snapshot. * actorTemplates controller has been updated not to wait 20 sec during golden snapshot creation, if readyz is enabled on actor. It assumes, atelet call returns when all containers passed readyz. Tested manually both counters: * gVisor * microvm Fixes agent-substrate#316 - [x] e2e tests pass - [X] Appropriate changes to documentation are included in the PR
1 parent 50a68bc commit 125180e

25 files changed

Lines changed: 1294 additions & 108 deletions

cmd/ateapi/internal/controlapi/workflow_pause.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func (s *CallAteletPauseStep) Execute(ctx context.Context, input *PauseInput, st
137137
Name: ctr.Name,
138138
Image: ctr.Image,
139139
Command: ctr.Command,
140+
Readyz: toAteletReadyz(ctr.Readyz),
140141
}
141142
for _, env := range ctr.Env {
142143
var val string

cmd/ateapi/internal/controlapi/workflow_suspend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func (s *CallAteletSuspendStep) Execute(ctx context.Context, input *SuspendInput
139139
Name: ctr.Name,
140140
Image: ctr.Image,
141141
Command: ctr.Command,
142+
Readyz: toAteletReadyz(ctr.Readyz),
142143
}
143144
for _, env := range ctr.Env {
144145
var val string

cmd/ateapi/internal/controlapi/workload_spec.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func workloadSpecFromActorTemplate(ctx context.Context, kubeClient kubernetes.In
4747
Name: ctr.Name,
4848
Image: ctr.Image,
4949
Command: ctr.Command,
50+
Readyz: toAteletReadyz(ctr.Readyz),
5051
}
5152
for _, env := range ctr.Env {
5253
ateletEnv, err := resolver.resolve(ctx, ctr.Name, env)
@@ -63,6 +64,23 @@ func workloadSpecFromActorTemplate(ctx context.Context, kubeClient kubernetes.In
6364
return workloadSpec, nil
6465
}
6566

67+
// toAteletReadyz projects the CRD readyz field onto the ateletpb wire type.
68+
// Returns nil when the source is nil so containers without a probe stay
69+
// unchanged on the wire.
70+
func toAteletReadyz(in *atev1alpha1.ContainerReadyz) *ateletpb.Readyz {
71+
if in == nil {
72+
return nil
73+
}
74+
out := &ateletpb.Readyz{}
75+
if in.HTTPGet != nil {
76+
out.HttpGet = &ateletpb.HTTPGetAction{
77+
Path: in.HTTPGet.Path,
78+
Port: in.HTTPGet.Port,
79+
}
80+
}
81+
return out
82+
}
83+
6684
type envResolver struct {
6785
kubeClient kubernetes.Interface
6886
namespace string

cmd/ateapi/internal/controlapi/workload_spec_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,50 @@ func TestWorkloadSpecFromActorTemplateResolvesValueFromEnv(t *testing.T) {
9898
}
9999
}
100100

101+
func TestWorkloadSpecFromActorTemplatePropagatesReadyz(t *testing.T) {
102+
ctx := context.Background()
103+
got, err := workloadSpecFromActorTemplate(ctx, fake.NewSimpleClientset(), nil, &atev1alpha1.ActorTemplate{
104+
ObjectMeta: metav1.ObjectMeta{Name: "tmpl-readyz", Namespace: "agent-ns"},
105+
Spec: atev1alpha1.ActorTemplateSpec{
106+
Containers: []atev1alpha1.Container{
107+
{
108+
Name: "with-probe",
109+
Image: "main",
110+
Readyz: &atev1alpha1.ContainerReadyz{
111+
HTTPGet: &atev1alpha1.HTTPGetAction{Path: "/health", Port: 8080},
112+
},
113+
},
114+
{
115+
Name: "without-probe",
116+
Image: "side",
117+
},
118+
},
119+
},
120+
})
121+
if err != nil {
122+
t.Fatalf("workloadSpecFromActorTemplate failed: %v", err)
123+
}
124+
125+
want := &ateletpb.WorkloadSpec{
126+
Containers: []*ateletpb.Container{
127+
{
128+
Name: "with-probe",
129+
Image: "main",
130+
Readyz: &ateletpb.Readyz{
131+
HttpGet: &ateletpb.HTTPGetAction{Path: "/health", Port: 8080},
132+
},
133+
},
134+
{
135+
Name: "without-probe",
136+
Image: "side",
137+
},
138+
},
139+
}
140+
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
141+
t.Errorf("WorkloadSpec mismatch (-want +got):\n%s", diff)
142+
}
143+
}
144+
101145
func TestWorkloadSpecFromActorTemplateOptionalSecretKeyRefSkipsMissingSecret(t *testing.T) {
102146
optional := true
103147
got, err := workloadSpecFromActorTemplate(context.Background(), fake.NewSimpleClientset(), nil, &atev1alpha1.ActorTemplate{

cmd/atecontroller/internal/controllers/actortemplate_controller.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ import (
3232

3333
const (
3434
GoldenSnapshotCreationReason = "GoldenSnapshotCreation"
35+
36+
// goldenSnapshotWarmup is the default wall-clock delay between resuming
37+
// the golden actor and taking its snapshot, used as a coarse "give the
38+
// workload time to finish initializing" fallback for templates without
39+
// a readiness probe. Templates whose containers all declare readyz skip
40+
// this wait — ResumeActor only returns once readyz reports 200, so the
41+
// workload is already initialized by the time we get here.
42+
goldenSnapshotWarmup = 20 * time.Second
3543
)
3644

3745
type ActorTemplateReconciler struct {
@@ -109,7 +117,7 @@ func (r *ActorTemplateReconciler) Reconcile(ctx context.Context, req ctrl.Reques
109117
}
110118

111119
at.Status.Phase = atev1alpha1.PhaseWaitGoldenActor
112-
at.Status.TakeGoldenSnapshotAt = metav1.NewTime(time.Now().Add(20 * time.Second))
120+
at.Status.TakeGoldenSnapshotAt = metav1.NewTime(time.Now().Add(goldenSnapshotWarmupFor(at)))
113121
if err := r.Status().Update(ctx, at); err != nil {
114122
return ctrl.Result{}, err
115123
}
@@ -163,3 +171,20 @@ func (r *ActorTemplateReconciler) Reconcile(ctx context.Context, req ctrl.Reques
163171
func (r *ActorTemplateReconciler) SetupWithManager(mgr ctrl.Manager) error {
164172
return ctrl.NewControllerManagedBy(mgr).For(&atev1alpha1.ActorTemplate{}).Complete(r)
165173
}
174+
175+
// goldenSnapshotWarmupFor returns 0 when every container in the template has
176+
// a readyz probe (so ResumeActor already blocked until the workload reported
177+
// 200), and the default warmup otherwise. A template with no containers
178+
// keeps the default — there is nothing to gate on.
179+
func goldenSnapshotWarmupFor(at *atev1alpha1.ActorTemplate) time.Duration {
180+
containers := at.Spec.Containers
181+
if len(containers) == 0 {
182+
return goldenSnapshotWarmup
183+
}
184+
for i := range containers {
185+
if containers[i].Readyz == nil {
186+
return goldenSnapshotWarmup
187+
}
188+
}
189+
return 0
190+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 controllers
16+
17+
import (
18+
"testing"
19+
20+
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
21+
)
22+
23+
func TestGoldenSnapshotWarmupFor(t *testing.T) {
24+
probe := &atev1alpha1.ContainerReadyz{
25+
HTTPGet: &atev1alpha1.HTTPGetAction{Port: 80},
26+
}
27+
28+
tests := []struct {
29+
name string
30+
containers []atev1alpha1.Container
31+
wantZero bool
32+
}{
33+
{
34+
name: "no containers keeps default warmup",
35+
containers: nil,
36+
wantZero: false,
37+
},
38+
{
39+
name: "all containers have readyz skips warmup",
40+
containers: []atev1alpha1.Container{
41+
{Name: "a", Readyz: probe},
42+
{Name: "b", Readyz: probe},
43+
},
44+
wantZero: true,
45+
},
46+
{
47+
name: "single container with readyz skips warmup",
48+
containers: []atev1alpha1.Container{
49+
{Name: "a", Readyz: probe},
50+
},
51+
wantZero: true,
52+
},
53+
{
54+
name: "mixed containers keep warmup",
55+
containers: []atev1alpha1.Container{
56+
{Name: "a", Readyz: probe},
57+
{Name: "b"},
58+
},
59+
wantZero: false,
60+
},
61+
{
62+
name: "no readyz anywhere keeps warmup",
63+
containers: []atev1alpha1.Container{
64+
{Name: "a"},
65+
{Name: "b"},
66+
},
67+
wantZero: false,
68+
},
69+
}
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
at := &atev1alpha1.ActorTemplate{
73+
Spec: atev1alpha1.ActorTemplateSpec{Containers: tt.containers},
74+
}
75+
got := goldenSnapshotWarmupFor(at)
76+
if tt.wantZero && got != 0 {
77+
t.Errorf("goldenSnapshotWarmupFor = %v, want 0", got)
78+
}
79+
if !tt.wantZero && got != goldenSnapshotWarmup {
80+
t.Errorf("goldenSnapshotWarmupFor = %v, want %v", got, goldenSnapshotWarmup)
81+
}
82+
})
83+
}
84+
}

cmd/atelet/main.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -705,11 +705,31 @@ func (s *AteomHerder) dialAteom(ctx context.Context, targetAteomUid string) (ate
705705
}
706706

707707
// buildAteomWorkloadSpec projects the atelet-facing workload spec onto
708-
// the ateom-facing one — currently just the container names.
708+
// the ateom-facing one.
709709
func buildAteomWorkloadSpec(spec *ateletpb.WorkloadSpec) *ateompb.WorkloadSpec {
710710
out := &ateompb.WorkloadSpec{}
711711
for _, ctr := range spec.GetContainers() {
712-
out.Containers = append(out.Containers, &ateompb.Container{Name: ctr.GetName()})
712+
out.Containers = append(out.Containers, &ateompb.Container{
713+
Name: ctr.GetName(),
714+
Readyz: toAteomReadyz(ctr.GetReadyz()),
715+
})
716+
}
717+
return out
718+
}
719+
720+
// toAteomReadyz converts an ateletpb readyz probe into the ateompb wire
721+
// type. Returns nil when the source is nil so containers without a probe
722+
// stay unchanged on the wire to ateom.
723+
func toAteomReadyz(in *ateletpb.Readyz) *ateompb.Readyz {
724+
if in == nil {
725+
return nil
726+
}
727+
out := &ateompb.Readyz{}
728+
if hg := in.GetHttpGet(); hg != nil {
729+
out.HttpGet = &ateompb.HTTPGetAction{
730+
Path: hg.GetPath(),
731+
Port: hg.GetPort(),
732+
}
713733
}
714734
return out
715735
}

cmd/atelet/main_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ import (
2828

2929
"github.com/agent-substrate/substrate/internal/ateompath"
3030
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
31+
"github.com/agent-substrate/substrate/internal/proto/ateompb"
32+
"github.com/google/go-cmp/cmp"
3133
"google.golang.org/grpc/codes"
3234
"google.golang.org/grpc/status"
35+
"google.golang.org/protobuf/testing/protocmp"
3336
)
3437

3538
func TestWriteFileAtomic(t *testing.T) {
@@ -377,3 +380,36 @@ func TestRPCBoundariesReject(t *testing.T) {
377380
wantInvalidArgument(t, "Restore", err)
378381
})
379382
}
383+
384+
func TestBuildAteomWorkloadSpecForwardsReadyz(t *testing.T) {
385+
in := &ateletpb.WorkloadSpec{
386+
PauseImage: "pause",
387+
Containers: []*ateletpb.Container{
388+
{
389+
Name: "with-probe",
390+
Image: "main",
391+
Readyz: &ateletpb.Readyz{
392+
HttpGet: &ateletpb.HTTPGetAction{Path: "/health", Port: 8080},
393+
},
394+
},
395+
{
396+
Name: "without-probe",
397+
},
398+
},
399+
}
400+
want := &ateompb.WorkloadSpec{
401+
Containers: []*ateompb.Container{
402+
{
403+
Name: "with-probe",
404+
Readyz: &ateompb.Readyz{
405+
HttpGet: &ateompb.HTTPGetAction{Path: "/health", Port: 8080},
406+
},
407+
},
408+
{Name: "without-probe"},
409+
},
410+
}
411+
got := buildAteomWorkloadSpec(in)
412+
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
413+
t.Errorf("buildAteomWorkloadSpec mismatch (-want +got):\n%s", diff)
414+
}
415+
}

cmd/ateom-gvisor/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/agent-substrate/substrate/internal/ateompath"
3434
"github.com/agent-substrate/substrate/internal/contextlogging"
3535
"github.com/agent-substrate/substrate/internal/proto/ateompb"
36+
"github.com/agent-substrate/substrate/internal/readyz"
3637
"github.com/agent-substrate/substrate/internal/serverboot"
3738
"github.com/agent-substrate/substrate/internal/version"
3839
"github.com/google/nftables"
@@ -227,6 +228,11 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload
227228
}
228229
}
229230

231+
// Block until every readyz-enabled container reports 200.
232+
if err := readyz.WaitAll(ctx, req.GetSpec().GetContainers(), actorVethIP); err != nil {
233+
return nil, fmt.Errorf("while waiting for container readyz: %w", err)
234+
}
235+
230236
s.actorLogger.EmitLifecycleLog("Actor started", req.GetActorId(), req.GetActorTemplateName(), req.GetActorTemplateNamespace())
231237

232238
return &ateompb.RunWorkloadResponse{}, nil
@@ -383,6 +389,11 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore
383389
}
384390
}
385391

392+
// Block until every readyz-enabled container reports 200.
393+
if err := readyz.WaitAll(ctx, req.GetSpec().GetContainers(), actorVethIP); err != nil {
394+
return nil, fmt.Errorf("while waiting for container readyz: %w", err)
395+
}
396+
386397
s.actorLogger.EmitLifecycleLog("Actor restored", req.GetActorId(), req.GetActorTemplateName(), req.GetActorTemplateNamespace())
387398

388399
return &ateompb.RestoreWorkloadResponse{}, nil

cmd/ateom-microvm/restore.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/kata"
3131
"github.com/agent-substrate/substrate/internal/ateompath"
3232
"github.com/agent-substrate/substrate/internal/proto/ateompb"
33+
"github.com/agent-substrate/substrate/internal/readyz"
3334
"google.golang.org/grpc/codes"
3435
"google.golang.org/grpc/status"
3536
)
@@ -184,6 +185,11 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore
184185
return nil, fmt.Errorf("while resuming restored guest: %w", err)
185186
}
186187

188+
// Block until every readyz-enabled container reports 200.
189+
if err := readyz.WaitAll(ctx, containers, actorVethIP); err != nil {
190+
return nil, fmt.Errorf("while waiting for container readyz: %w", err)
191+
}
192+
187193
ra := &runningActor{chCmd: chCmd, apiSocket: apiSocket, baseID: srcID, restoreSourceDir: restoreDir}
188194

189195
// Re-attach stdout/stderr forwarding: the restored guest's container + kata-agent

0 commit comments

Comments
 (0)