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
4 changes: 2 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ updates:
groups:
go-deps:
patterns: ["*"]
exclude-patterns: ["github.com/agent-substrate/substrate"]
exclude-patterns: ["go.ate.dev/substrate"]
ignore:
- dependency-name: github.com/agent-substrate/substrate
- dependency-name: go.ate.dev/substrate
- package-ecosystem: pip
directory: /clients/python
schedule:
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ signed, and meant to be used by digest:

The digests are in the release notes and in the `images_<tag>.txt` asset; the
release's `ate-env` binary defaults to them. The worker image
(`--worker-image`, `ateom-gvisor`) comes from the
(`--worker-image`, `ateworker-gvisor`) comes from the
[Substrate repo](https://github.com/agent-substrate/substrate) and must match the
Substrate version on your cluster. See [docs/release.md](docs/release.md) for
verification, pinning and how releases are cut.
Expand All @@ -65,23 +65,37 @@ verification, pinning and how releases are cut.

Prerequisites: a cluster with [Agent Substrate](https://github.com/agent-substrate/substrate)
installed and a snapshots bucket. ate-env is built against Substrate
[`da35a983`](https://github.com/agent-substrate/substrate/commit/da35a983) (2026-10-13). The cluster
needs a Substrate recent enough to accept what ate-env writes: the WorkerPool declares
`spec.sandboxClasses`, the ActorTemplate uses `snapshotConfig` and `wakeupProbe`, and `--worker-image`
must be the `ateom` image published by that Substrate install.
[`3a2ca623`](https://github.com/agent-substrate/substrate/commit/3a2ca623) (2026-10-16, Go module
`go.ate.dev/substrate`). The cluster needs a Substrate recent enough to accept what ate-env writes:
the WorkerPool declares `spec.sandboxClasses`, the ActorTemplate uses `snapshotConfig` and
`wakeupProbe`, names its sandbox class as the string `gvisor`, and declares CPU and memory limits.
That means Substrate at or after
[`63a17a92`](https://github.com/agent-substrate/substrate/commit/63a17a92) (2026-10-15), which also
renamed the worker image: `--worker-image` must be the `ateworker-gvisor` image published by that
Substrate install. An older Substrate rejects the string sandbox class (from
[`9ec49759`](https://github.com/agent-substrate/substrate/commit/9ec49759)) or the limits (from
[`c58f374a`](https://github.com/agent-substrate/substrate/commit/c58f374a)); use an ate-env from before
this bump with it.

When you upgrade an existing cluster across that boundary, register your base templates again.
Substrate cannot update an ActorTemplate in place, and a base registered earlier has no CPU and memory
limits, thus each template `ate-env-api` derives from it for a rootfs image fails with
`resources: Required`. Delete the base and its derived templates (named `<base>-<12 hex digits>`) with
`kubectl-ate delete actor-template`, then register the base again with `ate-env manifest template`.
Each environment created after that derives its template again, with the limits.

### 1. Deploy the Kubernetes Resources

Deploy the namespace, worker pool, and API service:

```bash
# With a release binary the API image defaults to the release's. The worker
# image is ateom-gvisor from the Substrate repo at your cluster's version; in
# image is ateworker-gvisor from the Substrate repo at your cluster's version; in
# that checkout, `go run ./cmd/ate-setup publish worker-images` pushes it and
# prints the digest-pinned ref. Use the digest: a :latest tag is whatever was
# built last, and a stale worker image registers workers with no capacity.
ate-env manifest \
--worker-image <registry>/ateom-gvisor@sha256:<digest> | kubectl apply -f -
--worker-image <registry>/ateworker-gvisor@sha256:<digest> | kubectl apply -f -
# From a source build, add: --api-image ghcr.io/agent-substrate/env/ate-env-api@sha256:<digest>

# Ensure that the pods are running:
Expand All @@ -106,6 +120,10 @@ ate-env manifest template \
# From a source build, add: --guest-image ghcr.io/agent-substrate/env/ate-env-guest@sha256:<digest>
```

Each actor gets 2 CPUs and 4 GiB unless you pass `--cpu` and `--memory` (Kubernetes quantities such as
`500m` or `8Gi`; CPU at most `999.999`, as Substrate requires). Substrate requires both limits and books them against worker capacity; templates
derived per rootfs image inherit them.

That template's container is the guest image itself. To run another image unmodified, name
it with `--rootfs-image`: the guest is then mounted into it as a read-only OCI image volume at
`/ate` and started from there, so the rootfs image is never rebuilt.
Expand Down
2 changes: 1 addition & 1 deletion clients/go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"go.ate.dev/substrate/pkg/proto/ateapipb"
"google.golang.org/grpc"
)

Expand Down
92 changes: 83 additions & 9 deletions cmd/ate-env/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ import (
"strings"

"github.com/agent-substrate/env/internal/apiservice"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/spf13/cobra"
atev1alpha1 "go.ate.dev/substrate/pkg/api/v1alpha1"
"go.ate.dev/substrate/pkg/proto/ateapipb"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/yaml"
Expand All @@ -42,6 +43,15 @@ const apiName = "ate-env-api"
// one, so a pool declared with it is schedulable on a stock install.
const sandboxConfigName = "gvisor-default"

// 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
// default; size them for the workload with --cpu and --memory.
const (
defaultActorCPU = "2"
defaultActorMemory = "4Gi"
)

// manifestConfig holds configuration for generating Kubernetes deployment manifests.
type manifestConfig struct {
namespace string
Expand All @@ -56,8 +66,8 @@ type manifestConfig struct {

func (c *manifestConfig) resolveImages() error {
if c.workerImage == "" {
return errors.New(`--worker-image (or --ateom-image) is required: the worker image is
ateom-gvisor from the Substrate repo and must match the Substrate version
return errors.New(`--worker-image is required: the worker image is
ateworker-gvisor from the Substrate repo and must match the Substrate version
deployed on your cluster. From a Substrate checkout at that version run
"go run ./cmd/ate-setup publish worker-images": it pushes the images and
prints the digest-pinned refs to use. Pin by digest: a registry's :latest
Expand Down Expand Up @@ -93,6 +103,10 @@ type templateConfig struct {
sidecars []string
// sidecarReadyz are URLs the guest's /readyz waits for.
sidecarReadyz []string
// cpu and memory are the actor's compute limits as Kubernetes quantities.
// Empty means defaultActorCPU and defaultActorMemory.
cpu string
memory string
}

// runtimeLayer is a parsed --layer value.
Expand Down Expand Up @@ -139,6 +153,44 @@ func parseLayers(values []string) ([]runtimeLayer, error) {
return layers, nil
}

// Bounds Substrate applies to an ActorTemplate's limits
// (go.ate.dev/substrate/internal/resources.ValidateLimit and the
// Limits.quantity maxLength), checked here so the CLI never prints a template
// the control plane rejects.
const maxQuantityLen = 32

var (
maxCPULimit = resource.MustParse("999.999") // the wire carries millicores below 1000 cores
maxMemoryLimit = resource.MustParse("9223372036854775807") // the wire carries an int64 byte count
)

// validateResources checks --cpu and --memory the way Substrate validates
// resources.limits. An empty value means the default.
func (c *templateConfig) validateResources() error {
for _, l := range []struct {
flag, value string
max resource.Quantity
}{{"--cpu", c.cpu, maxCPULimit}, {"--memory", c.memory, maxMemoryLimit}} {
if l.value == "" {
continue
}
if len(l.value) > maxQuantityLen {
return fmt.Errorf("%s %q is longer than %d characters, which Substrate rejects", l.flag, l.value, maxQuantityLen)
}
q, err := resource.ParseQuantity(l.value)
if err != nil {
return fmt.Errorf("%s %q is not a Kubernetes quantity (e.g. 2, 500m, 4Gi): %w", l.flag, l.value, err)
}
if q.Sign() <= 0 {
return fmt.Errorf("%s must be greater than zero, got %q", l.flag, l.value)
}
if q.Cmp(l.max) > 0 {
return fmt.Errorf("%s %q is above Substrate's maximum of %s", l.flag, l.value, l.max.String())
}
}
return nil
}

func (c *templateConfig) resolveImages() error {
if c.guestImage == "" {
return errors.New(`--guest-image is required when ate-env is built from source; release
Expand Down Expand Up @@ -203,8 +255,8 @@ the "template" subcommand: ate-env manifest template`,

cmd.Flags().StringVar(&mCfg.namespace, "namespace", apiservice.DefaultNamespace, "Kubernetes namespace to deploy into")
cmd.Flags().StringVar(&mCfg.template, "template", apiservice.DefaultTemplate, "ActorTemplate name")
cmd.Flags().StringVar(&mCfg.workerImage, "worker-image", "", "digest-pinned worker image for the worker pool, e.g. ateom-gvisor built from the Substrate repo")
cmd.Flags().StringVar(&mCfg.workerImage, "ateom-image", "", "alias for --worker-image")
cmd.Flags().StringVar(&mCfg.workerImage, "worker-image", "", "digest-pinned worker image for the worker pool, e.g. ateworker-gvisor built from the Substrate repo")
cmd.Flags().StringVar(&mCfg.workerImage, "ateom-image", "", "alias for --worker-image, kept from before Substrate renamed ateom to ateworker")
cmd.Flags().StringVar(&mCfg.apiImage, "api-image", defaultAPIImage, imageDefaultHelp("digest-pinned ate-env-api image for the API service", defaultAPIImage))
cmd.Flags().Int32Var(&mCfg.apiReplicas, "api-replicas", 1, "number of API service replicas")
cmd.Flags().Int32Var(&mCfg.apiPort, "api-port", 7777, "port the ate-env-api service listens on")
Expand Down Expand Up @@ -232,6 +284,15 @@ It prints YAML to stdout without touching the cluster.`,
if err := tCfg.resolveImages(); err != nil {
return err
}
// An empty --cpu or --memory would silently mean the default.
for _, f := range []string{"cpu", "memory"} {
if cmd.Flags().Changed(f) && cmd.Flags().Lookup(f).Value.String() == "" {
return fmt.Errorf("--%s must not be empty; omit it for the default", f)
}
}
if err := tCfg.validateResources(); err != nil {
return err
}
tmpl, err := buildTemplate(tCfg)
if err != nil {
return err
Expand All @@ -250,6 +311,8 @@ It prints YAML to stdout without touching the cluster.`,
cmd.Flags().StringArrayVar(&tCfg.layers, "layer", nil, "extra runtime mounted as a read-only image volume, as name=image@sha256:...=/mount/path (repeatable)")
cmd.Flags().StringArrayVar(&tCfg.sidecars, "sidecar", nil, "command the guest starts and keeps running next to itself, an absolute binary path plus arguments, e.g. '/opt/opensandbox/execd --port 44772' (repeatable)")
cmd.Flags().StringArrayVar(&tCfg.sidecarReadyz, "sidecar-readyz", nil, "URL the actor's readiness waits for, e.g. http://127.0.0.1:44772/ready (repeatable)")
cmd.Flags().StringVar(&tCfg.cpu, "cpu", defaultActorCPU, "CPU limit of each actor, a Kubernetes quantity such as 2 or 500m; Substrate requires one")
cmd.Flags().StringVar(&tCfg.memory, "memory", defaultActorMemory, "memory limit of each actor, a Kubernetes quantity such as 4Gi; Substrate requires one")

return cmd
}
Expand Down Expand Up @@ -387,7 +450,7 @@ func buildWorkerPool(cfg manifestConfig) *atev1alpha1.WorkerPool {
// the SandboxConfig the workers run; the template names the same
// one in sandboxConfig.configName.
SandboxClasses: []atev1alpha1.WorkerPoolSandboxClass{{
Name: atev1alpha1.SandboxClassGvisor,
Name: atev1alpha1.SandboxClassGvisor, // the template's sandboxConfig.sandboxClass is the same string
ConfigRef: &atev1alpha1.SandboxConfigReference{Name: sandboxConfigName},
}},
},
Expand All @@ -412,7 +475,7 @@ func buildActorTemplate(cfg templateConfig) *ateapipb.ActorTemplate {
Name: "PORT",
Value: "80",
}},
WakeupProbe: &ateapipb.ContainerWakeupProbe{
WakeupProbe: &ateapipb.WakeupProbe{
HttpGet: &ateapipb.HttpGetAction{
Path: "/readyz",
Port: 80,
Expand All @@ -427,10 +490,21 @@ func buildActorTemplate(cfg templateConfig) *ateapipb.ActorTemplate {
PreferredFidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY,
},
SandboxConfig: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
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)},
}},
}
}

func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}

// buildTemplate returns the ActorTemplate for cfg: the guest template from
Expand Down
88 changes: 81 additions & 7 deletions cmd/ate-env/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import (
"testing"

"github.com/agent-substrate/env/internal/apiservice"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
atev1alpha1 "go.ate.dev/substrate/pkg/api/v1alpha1"
"go.ate.dev/substrate/pkg/proto/ateapipb"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
)
Expand Down Expand Up @@ -116,11 +116,10 @@ func TestBuildManifests(t *testing.T) {
if sc.Name != atev1alpha1.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 is a CRD string and the template's a proto enum: two
// vocabularies for one thing. A pool of the wrong class never hosts the
// template's actors.
tmplClass := strings.ToLower(strings.TrimPrefix(
buildActorTemplate(testTemplateConfig()).GetSandboxConfig().GetSandboxClass().String(), "SANDBOX_CLASS_"))
// 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)
}
Expand Down Expand Up @@ -176,6 +175,74 @@ func TestBuildActorTemplate(t *testing.T) {
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()
}
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)
}
}

func TestTemplateConfigRejectsBadResources(t *testing.T) {
for _, tc := range []struct{ cpu, memory string }{
{"two", ""},
{"", "4GB!"},
{"0", ""},
{"", "-1Gi"},
// Substrate's bounds: cpu below 1000 cores, memory an int64 byte
// count, each quantity at most 32 characters.
{"1000", ""},
{"1e3", ""},
{"999.9991", ""},
{"", "9223372036854775808"},
{"0.0000000000000000000000000000001", ""},
} {
cfg := testTemplateConfig()
cfg.cpu, cfg.memory = tc.cpu, tc.memory
if err := cfg.validateResources(); err == nil {
t.Errorf("cpu=%q memory=%q: want error, got nil", tc.cpu, tc.memory)
}
}
for _, tc := range []struct{ cpu, memory string }{
{"999.999", "9223372036854775807"},
{"500m", "8Gi"},
{"", ""},
} {
cfg := testTemplateConfig()
cfg.cpu, cfg.memory = tc.cpu, tc.memory
if err := cfg.validateResources(); err != nil {
t.Errorf("cpu=%q memory=%q: %v, want accepted", tc.cpu, tc.memory, err)
}
}
}

func TestManifestTemplateCommandRejectsEmptyResource(t *testing.T) {
for _, flag := range []string{"--cpu=", "--memory="} {
cmd := newManifestCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"template", "--guest-image=example.com/guest@sha256:aaaa", "--snapshots-bucket=gs://b/", flag})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Errorf("%s: got %v, want an error about the empty value", flag, err)
}
}
}

func TestBuildManifestsCustomAPIPort(t *testing.T) {
Expand Down Expand Up @@ -291,6 +358,8 @@ func TestManifestTemplateCommandExecution(t *testing.T) {
"template",
"--guest-image=example.com/guest@sha256:aaaa",
"--snapshots-bucket=gs://bucket/prefix/",
"--cpu=4",
"--memory=8Gi",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute manifest template: %v", err)
Expand All @@ -299,6 +368,11 @@ 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"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
}

const testHex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
Expand Down
2 changes: 1 addition & 1 deletion docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ and asks for both flags. `make build-cli` reproduces a release-style binary
locally from `ATE_ENV_API_IMAGE` and `ATE_ENV_GUEST_IMAGE`.

The worker image (`--worker-image`) is not published here: it is
`ateom-gvisor` from the [Substrate repo](https://github.com/agent-substrate/substrate)
`ateworker-gvisor` from the [Substrate repo](https://github.com/agent-substrate/substrate)
and must match the Substrate version deployed on the cluster. From a Substrate
checkout at that version, `go run ./cmd/ate-setup publish worker-images` builds
and pushes it and prints the digest-pinned ref. Use the digest: a registry's
Expand Down
Loading
Loading