ate-setup: add flags and functionality to use pre-built images (#1388)

Fixes #1387

Also tested manually on GKE cluster and kind cluster both existing build
on demand pathways and relying on prebuilt images from my own registry.

- [X] Tests pass
- [X] Appropriate changes to documentation are included in the PR

---------

Co-authored-by: Anna Pendleton <13793293+annapendleton@users.noreply.github.com>
This commit is contained in:
Anna Pendleton
2026-09-01 21:49:09 -07:00
committed by GitHub
co-authored by Anna Pendleton
parent 1d58b95a7f
commit ea3bdc3224
22 changed files with 1402 additions and 63 deletions
+16
View File
@@ -10,6 +10,22 @@ make build-ate-setup # builds bin/ate-setup
`ate-setup` is a Go port of `hack/install-ate.sh` and the scripts it sources. `ate-setup` is a Go port of `hack/install-ate.sh` and the scripts it sources.
Both work today and can be used against the same cluster. Both work today and can be used against the same cluster.
## Installing a release
By default every image is built from the checkout with `ko`, which is what a
developer wants and what the shell installer always did. To install published
images instead, name the registry they were pushed to:
```
ate-setup deploy ate-system \
--image-repo registry.example.com/substrate \
--image-tag v0.0.0
```
Nothing is built and no registry is pushed to; the manifests still come from the
checkout. Each reference is pinned to the digest its tag names, so the registry
has to be readable from here as well as from the cluster.
- [`commands.md`](commands.md) — every command with its `hack/install-ate.sh` - [`commands.md`](commands.md) — every command with its `hack/install-ate.sh`
equivalent. equivalent.
- [`differences.md`](differences.md) — where the port deliberately behaves - [`differences.md`](differences.md) — where the port deliberately behaves
+28
View File
@@ -31,6 +31,34 @@ a pre-scan pass, so they may appear anywhere on its command line.
| `--kubeconfig PATH` | `KUBECONFIG=PATH` | Explicit kubeconfig path | | `--kubeconfig PATH` | `KUBECONFIG=PATH` | Explicit kubeconfig path |
| `--no-dev-env` | `NO_DEV_ENV=1` | Skip `.ate-dev-env.sh` at the repository root | | `--no-dev-env` | `NO_DEV_ENV=1` | Skip `.ate-dev-env.sh` at the repository root |
| `--version` / `-v` | — | New; the shell installer had no version | | `--version` / `-v` | — | New; the shell installer had no version |
| `--image-repo REPO` | — | New. Install pre-built images from `REPO` instead of building them with `ko` |
| `--image-tag TAG` | — | New. The tag those images carry. Each of the two requires the other |
Both have an environment equivalent, read when the flag is absent:
`ATE_IMAGE_REPO` and `ATE_IMAGE_TAG`.
## Installing a release
Without `--image-repo`, `ate-setup` builds every image from the checkout with
`ko` and pushes it to `KO_DOCKER_REPO`. That is the developer install and is
unchanged.
```
ate-setup deploy ate-system \
--image-repo registry.example.com/substrate \
--image-tag v0.0.0
```
installs published images instead, and never invokes `ko`. The manifests still
come from the checkout, so this needs one; what it removes is the build, the Go
toolchain, and write access to a registry.
`REPO` has to hold every component image the manifests reference, all under the
same tag, which is how a release publishes them. A release that adds a component
has to publish it alongside the others before a pre-built install can use it.
Each reference is then pinned to the digest its tag names, which takes one HEAD
request per image, so the installer needs read access to `REPO` and not only the
cluster does.
## Deploy ## Deploy
+54 -4
View File
@@ -73,9 +73,9 @@ Still required, and why:
| Binary | Used for | | Binary | Used for |
|---|---| |---|---|
| `ko` | building and publishing images (`ko resolve` only) | | `ko` | building and publishing images (`ko resolve` only); not used with `--image-repo` |
| `go` | locating the pinned `ko` tool, exactly as `hack/run-tool.sh` does | | `go` | locating the pinned `ko` tool, exactly as `hack/run-tool.sh` does; not used with `--image-repo` |
| `git` | the `git describe` version stamp passed to ko | | `git` | the `git describe` version stamp passed to ko; not used with `--image-repo` |
| `docker` | the kind CSI setup (`docker exec` into the node) and the claude-code-multiplex workload build | | `docker` | the kind CSI setup (`docker exec` into the node) and the claude-code-multiplex workload build |
| `gcloud` | GKE `get-credentials`, only when `PROJECT_ID` is set and no context was given | | `gcloud` | GKE `get-credentials`, only when `PROJECT_ID` is set and no context was given |
| `bash` | sourcing `.ate-dev-env.sh` | | `bash` | sourcing `.ate-dev-env.sh` |
@@ -92,6 +92,56 @@ made ko shell out to kubectl and forced the awkward `-- --context=` special case
them). Now ko only resolves, and the resulting manifest is applied through them). Now ko only resolves, and the resulting manifest is applied through
client-go. client-go.
## Image sources
The shell installer had exactly one: build every image from the checkout with
`ko` and push it to `KO_DOCKER_REPO`. That is still the default and is
unchanged. `--image-repo` adds a second, in which nothing is built.
Manifests carry `ko://<import path>` references either way. With `--image-repo`
each is looked up in `images.Components` and rewritten to
`<repo>/<base of the import path>:<tag>`, which is ko's own
`--base-import-paths` naming and so is how the release images are already
published — `ateapi`, `atelet`, `atenet`, `ateom-gvisor`, and the rest.
`--image-tag` supplies the tag. Each of the two requires the other: a tag with
no repository to pull from would be dropped, leaving a build from source that
looks like the release the tag names.
The rewritten reference then keeps that tag and adds the digest it names, as
`<repo>/<image>:<tag>@sha256:...`, which is the shape ko itself produces for a
tagged release. Resolving the digest is one HEAD request per image, cached for
the install, so `--image-repo` needs read access to the registry from wherever
`ate-setup` runs rather than only from the cluster. It is not optional: an
ActorTemplate's container image, an image volume's reference, and a
SandboxConfig's `pauseImage` each carry the CEL rule `self.contains('@')`, so an
unpinned reference is rejected by admission and every demo fails to deploy.
Pinning also gives a pre-built install the property a `ko` install had for free,
that a tag moving afterwards cannot change what is running.
That tag also becomes the substrate version, which names the atelet DaemonSet
and sets the node label partitioning nodes across coexisting versions. It has
to: that label must describe the atelet actually running, which came from the
image, not the checkout `git describe` happens to be sitting on. `VERSION`
overrides it, as it always has.
The rewrite is textual, one token at a time, matching `SubstituteVersion` above.
A reference is replaced wherever it appears, so the code needs no list of which
fields hold images — `workerImage:` in a CRD spec is rewritten like any other —
and the applied manifest differs from the checked-in YAML only in the image
references.
Every reference must be in the list. A reference outside the module path, one
carrying an unexpanded `${PLACEHOLDER}`, and one naming a package that is not
installable all fail the same way — an error naming every unmapped reference at
once, rather than an unpullable image reaching the cluster and failing as
`ImagePullBackOff` twenty minutes later. Matching whole references rather than
replacing the listed ones is what makes that hold: a reference extending a
listed package would otherwise inherit its image. `images.Components` is checked
against the real manifests by a test, so adding a component fails a test rather
than an install — though only the list is checked that way. The release also has
to publish the new image under the same repository and tag as the rest, and
until it does, the component can only be installed by building it.
## Cluster access ## Cluster access
**Server-side apply.** `kubectl apply -f -` is a client-side apply by default. **Server-side apply.** `kubectl apply -f -` is a client-side apply by default.
@@ -219,4 +269,4 @@ outcome.
The shell installer had no tests. `cmd/ate-setup` has unit tests for template The shell installer had no tests. `cmd/ate-setup` has unit tests for template
rendering, overlay selection, config resolution, the authentication config, the rendering, overlay selection, config resolution, the authentication config, the
apiserver environment ConfigMap, delegated script arguments, manifest deletion, apiserver environment ConfigMap, delegated script arguments, manifest deletion,
and per-demo rendering. per-demo rendering, and image reference rewriting.
+5
View File
@@ -95,6 +95,11 @@ func init() {
f.StringVar(&opts.AdditionalEgressExtprocService, "experimental-additional-egress-extproc-service", "", "Run an additional ext_proc authorization filter served by NS/SVC:PORT (requires --experimental-use-sdsmint)") f.StringVar(&opts.AdditionalEgressExtprocService, "experimental-additional-egress-extproc-service", "", "Run an additional ext_proc authorization filter served by NS/SVC:PORT (requires --experimental-use-sdsmint)")
f.BoolVar(&opts.NoDevEnv, "no-dev-env", false, "Do not source .ate-dev-env.sh") f.BoolVar(&opts.NoDevEnv, "no-dev-env", false, "Do not source .ate-dev-env.sh")
f.StringVar(&opts.ImageRepo, "image-repo", "",
"Install pre-built images from this registry path instead of building them from source (e.g. registry.example.com/substrate)")
f.StringVar(&opts.ImageTag, "image-tag", "",
"Tag the pre-built images carry (required with --image-repo)")
// Cobra's default completion command would run PersistentPreRunE and // Cobra's default completion command would run PersistentPreRunE and
// require a cluster; the tree is not deep enough to justify that. // require a cluster; the tree is not deep enough to justify that.
rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.CompletionOptions.DisableDefaultCmd = true
+22
View File
@@ -27,6 +27,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/images"
) )
// Enumerated values for the install-shaping flags. // Enumerated values for the install-shaping flags.
@@ -85,6 +87,10 @@ type Config struct {
// KODefaultPlatforms constrains ko's build platforms. // KODefaultPlatforms constrains ko's build platforms.
KODefaultPlatforms string KODefaultPlatforms string
// Images selects where container images come from. Its zero value builds
// them from source with ko, which is what a developer install does.
Images images.Source
// Router selects the atenet router dataplane. // Router selects the atenet router dataplane.
Router string Router string
// PostgresConnectionString is the apiserver's store connection string. // PostgresConnectionString is the apiserver's store connection string.
@@ -134,6 +140,10 @@ type Options struct {
ExperimentalUseSDSMint bool ExperimentalUseSDSMint bool
AdditionalEgressExtprocService string AdditionalEgressExtprocService string
// Image source selection.
ImageRepo string
ImageTag string
// NoDevEnv skips sourcing .ate-dev-env.sh even when it exists. // NoDevEnv skips sourcing .ate-dev-env.sh even when it exists.
NoDevEnv bool NoDevEnv bool
} }
@@ -202,6 +212,7 @@ func Load(opts Options) (*Config, error) {
BucketName: env["BUCKET_NAME"], BucketName: env["BUCKET_NAME"],
KODockerRepo: env["KO_DOCKER_REPO"], KODockerRepo: env["KO_DOCKER_REPO"],
KODefaultPlatforms: env["KO_DEFAULTPLATFORMS"], KODefaultPlatforms: env["KO_DEFAULTPLATFORMS"],
Images: loadImageSource(opts, env),
PostgresConnectionString: env["ATE_API_POSTGRES_CONNECTION_STRING"], PostgresConnectionString: env["ATE_API_POSTGRES_CONNECTION_STRING"],
RolloutTimeout: rolloutTimeout, RolloutTimeout: rolloutTimeout,
rolloutTimeoutSet: timeoutStr != "", rolloutTimeoutSet: timeoutStr != "",
@@ -226,6 +237,14 @@ func Load(opts Options) (*Config, error) {
return cfg, nil return cfg, nil
} }
// loadImageSource resolves where images come from.
func loadImageSource(opts Options, env map[string]string) images.Source {
return images.Source{
Repo: strings.TrimSuffix(firstNonEmpty(opts.ImageRepo, env["ATE_IMAGE_REPO"]), "/"),
Tag: firstNonEmpty(opts.ImageTag, env["ATE_IMAGE_TAG"]),
}
}
func applyKindDefaults(cfg *Config) { func applyKindDefaults(cfg *Config) {
cfg.ProjectID = "" cfg.ProjectID = ""
cfg.ClusterLocation = "" cfg.ClusterLocation = ""
@@ -237,6 +256,9 @@ func applyKindDefaults(cfg *Config) {
} }
func validate(cfg *Config) error { func validate(cfg *Config) error {
if err := cfg.Images.Validate(); err != nil {
return err
}
switch cfg.Router { switch cfg.Router {
case RouterEnvoy, RouterAgentgateway: case RouterEnvoy, RouterAgentgateway:
default: default:
@@ -22,6 +22,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/images"
) )
// loadEnv isolates Load from the ambient environment. // loadEnv isolates Load from the ambient environment.
@@ -41,6 +43,8 @@ func loadEnv(t *testing.T) {
"ATE_API_POSTGRES_CONNECTION_STRING", "ATE_API_POSTGRES_CONNECTION_STRING",
"ATE_ATENET_ROUTER", "ATE_ATENET_ROUTER",
"ATE_EXPERIMENTAL_USE_SDSMINT", "ATE_EXPERIMENTAL_USE_SDSMINT",
"ATE_IMAGE_REPO",
"ATE_IMAGE_TAG",
"ATE_INSTALL_PODCERT_WORKERS_PER_SIGNER", "ATE_INSTALL_PODCERT_WORKERS_PER_SIGNER",
"ATE_INSTALL_ROLLOUT_TIMEOUT", "ATE_INSTALL_ROLLOUT_TIMEOUT",
"ATE_OTLP_ENDPOINT", "ATE_OTLP_ENDPOINT",
@@ -341,3 +345,89 @@ func TestSourceShellEnvReportsFailure(t *testing.T) {
t.Fatal("sourceShellEnv() succeeded, want an error") t.Fatal("sourceShellEnv() succeeded, want an error")
} }
} }
func TestLoadImageSource(t *testing.T) {
tests := []struct {
name string
env map[string]string
opts Options
want images.Source
wantError string
}{
{
name: "unset builds from source",
want: images.Source{},
},
{
name: "flags",
opts: Options{ImageRepo: "example.com/substrate", ImageTag: "v1.2.3"},
want: images.Source{Repo: "example.com/substrate", Tag: "v1.2.3"},
},
{
name: "environment",
env: map[string]string{"ATE_IMAGE_REPO": "example.com/env", "ATE_IMAGE_TAG": "v9"},
want: images.Source{Repo: "example.com/env", Tag: "v9"},
},
{
name: "flags beat the environment",
env: map[string]string{"ATE_IMAGE_REPO": "example.com/env", "ATE_IMAGE_TAG": "v9"},
opts: Options{ImageRepo: "example.com/flag", ImageTag: "v1"},
want: images.Source{Repo: "example.com/flag", Tag: "v1"},
},
{
// A repo pasted from a registry UI often carries one, and it would
// otherwise produce a double slash in every reference.
name: "trailing slash is trimmed",
opts: Options{ImageRepo: "example.com/substrate/", ImageTag: "v1"},
want: images.Source{Repo: "example.com/substrate", Tag: "v1"},
},
{
name: "a repo needs a tag",
opts: Options{ImageRepo: "example.com/substrate"},
wantError: "--image-repo (or ATE_IMAGE_REPO) requires --image-tag",
},
{
name: "a tag needs a repo",
opts: Options{ImageTag: "v1"},
wantError: "--image-tag (or ATE_IMAGE_TAG) requires --image-repo",
},
{
// The environment reaches validation the same way the flags do.
name: "a tag from the environment needs a repo",
env: map[string]string{"ATE_IMAGE_TAG": "v1"},
wantError: "--image-tag (or ATE_IMAGE_TAG) requires --image-repo",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
loadEnv(t)
for name, value := range tc.env {
t.Setenv(name, value)
}
cfg, err := Load(tc.opts)
if tc.wantError != "" {
if err == nil {
t.Fatalf("Load() = nil error, want one containing %q", tc.wantError)
}
if !strings.Contains(err.Error(), tc.wantError) {
t.Errorf("Load() error = %v, want it to contain %q", err, tc.wantError)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Images.Repo != tc.want.Repo {
t.Errorf("Images.Repo = %q, want %q", cfg.Images.Repo, tc.want.Repo)
}
if cfg.Images.Tag != tc.want.Tag {
t.Errorf("Images.Tag = %q, want %q", cfg.Images.Tag, tc.want.Tag)
}
if cfg.Images.IsPrebuilt() != (tc.want.Repo != "") {
t.Errorf("Images.IsPrebuilt() = %v, want %v", cfg.Images.IsPrebuilt(), tc.want.Repo != "")
}
})
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ func (d *Simple) DeployWorkload(ctx context.Context, e *steps.Env) error {
if err != nil { if err != nil {
return err return err
} }
return e.KoApplyBytes(ctx, manifest) return e.ResolveAndApplyBytes(ctx, manifest)
} }
// WaitReady blocks until the demo's Deployments are rolled out. // WaitReady blocks until the demo's Deployments are rolled out.
+5 -5
View File
@@ -116,7 +116,7 @@ func (d *Substrate) Deploy(ctx context.Context, e *steps.Env) error {
if err != nil { if err != nil {
return err return err
} }
if err := e.KoApplyBytes(ctx, manifest); err != nil { if err := e.ResolveAndApplyBytes(ctx, manifest); err != nil {
return err return err
} }
for _, ref := range d.Deployments { for _, ref := range d.Deployments {
@@ -142,10 +142,10 @@ func (d *Substrate) Deploy(ctx context.Context, e *steps.Env) error {
if err != nil { if err != nil {
return err return err
} }
// ko resolve replaces the ko:// image references with pushed digests // The ko:// image references become digest-pinned ones before the
// before the manifest is parsed; manifests without ko:// references // manifest is parsed, which is what the ActorTemplate schema requires;
// pass through unchanged. // manifests without ko:// references pass through unchanged.
resolved, err := e.KoResolveBytes(ctx, manifest) resolved, err := e.ResolveManifestBytes(ctx, manifest)
if err != nil { if err != nil {
return err return err
} }
+50
View File
@@ -0,0 +1,50 @@
// 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 images
import (
"context"
"fmt"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
// Digester resolves a tagged image reference to the digest its tag currently
// names, in "sha256:..." form. Prebuilt takes one so tests can rewrite
// manifests without a registry.
type Digester func(ctx context.Context, ref string) (string, error)
// RemoteDigest asks ref's registry which manifest the tag names.
//
// One HEAD request per image, needing only read access, and authenticated the
// way docker and ko are: from the docker config file and the credential helpers
// it names. A localhost registry is contacted over plain HTTP, which is what
// the Kind local registry serves.
func RemoteDigest(ctx context.Context, ref string) (string, error) {
parsed, err := name.ParseReference(ref)
if err != nil {
return "", fmt.Errorf("%s is not a valid image reference: %w", ref, err)
}
desc, err := remote.Head(parsed,
remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
)
if err != nil {
return "", fmt.Errorf("resolving %s to a digest: %w", ref, err)
}
return desc.Digest.String(), nil
}
+107
View File
@@ -0,0 +1,107 @@
// 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 images decides where ate-setup gets container images from.
//
// The manifests name images as ko:// import paths, which ko builds and
// publishes on every install. That needs a checkout, a Go toolchain, and a
// registry to push to. Installing an already-published release instead means
// mapping those same references onto images that exist, which is what Source
// describes and Prebuilt performs.
package images
import (
"fmt"
"path"
)
// ModulePath is this repository's module path. A ko:// reference is it joined
// with a component's package.
const ModulePath = "github.com/agent-substrate/substrate"
// Components is every package the installer can deploy a published image for,
// as an import path within the module.
//
// The list is closed on purpose. A reference that is not in it names something
// this build has no published image for, and failing beats guessing at a name
// that was never pushed. TestComponentsCoverTheManifests keeps it in step with
// the manifests, which is half of it: a new component also has to be published
// under the same repository and tag as the rest before a pre-built install can
// use it.
var Components = []string{
"cmd/ateapi",
"cmd/atecontroller",
"cmd/atelet",
"cmd/atenet",
"cmd/ateom-gvisor",
"cmd/ateom-microvm",
"cmd/podcertcontroller",
"demos/counter",
"demos/egress",
"demos/multi-template/fspersist",
"demos/sandbox",
}
// KoReference is the ko:// reference the manifests name pkg by.
//
// ko resolves a manifest reference as written, so a "./"-relative import path
// works there too. Naming packages in full is this repository's convention
// rather than ko's rule, and only the full form maps to an image here:
// TestComponentsCoverTheManifests holds the convention, and a reference spelled
// any other way fails the install rather than resolving.
func KoReference(pkg string) string { return "ko://" + ModulePath + "/" + pkg }
// ImageName is the image name pkg is published under: the last element of the
// import path, which is ko's --base-import-paths naming. ko lowercases it as
// well, which changes nothing while every package here already is.
func ImageName(pkg string) string { return path.Base(pkg) }
// Source describes where images come from.
//
// The zero value builds and publishes them from source with ko. Setting Repo
// switches to images already published in a registry, and ko is never invoked.
type Source struct {
// Repo is the registry path holding the component images, without a
// trailing slash, e.g. "registry.example.com/substrate".
Repo string
// Tag is the tag every component image carries. Releases tag all
// components together, so one tag covers the set.
Tag string
}
// IsPrebuilt reports whether images should be pulled rather than built.
func (s Source) IsPrebuilt() bool { return s.Repo != "" }
// Validate checks a Source is usable before anything reaches the cluster.
//
// Both halves are checked. A tag on its own would otherwise be discarded in
// silence, leaving a build from source that looks like the release the tag
// names.
func (s Source) Validate() error {
switch {
case s.IsPrebuilt() && s.Tag == "":
return fmt.Errorf("--image-repo (or ATE_IMAGE_REPO) requires --image-tag (or ATE_IMAGE_TAG)")
case !s.IsPrebuilt() && s.Tag != "":
return fmt.Errorf("--image-tag (or ATE_IMAGE_TAG) requires --image-repo (or ATE_IMAGE_REPO); without one, images are built from source and the tag names nothing")
}
return nil
}
// Describe renders the source for the install log.
func (s Source) Describe() string {
if !s.IsPrebuilt() {
return "built from source with ko"
}
return fmt.Sprintf("pre-built from %s, tag %s", s.Repo, s.Tag)
}
@@ -0,0 +1,165 @@
// 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.
// The component list is checked against the real manifests, so this is an
// external test package: config imports images, and only the test needs config.
package images_test
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/config"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/images"
)
// installTrees are the directories the installer reads manifests from.
//
// benchmarking/ and internal/e2e/ are left out deliberately: neither is
// installed through the image resolvers -- the benchmark workloads go through
// a shell script that runs ko itself, and the e2e fixtures are built by the
// test suite.
var installTrees = []string{"manifests", "demos"}
var koRefPattern = regexp.MustCompile(`ko://[^\s"',\]}]+`)
// componentsInTree collects the package of every ko:// reference under dir.
//
// A reference that does not carry the module path is recorded whole, so it
// shows up in the failure rather than being silently dropped.
func componentsInTree(t *testing.T, dir string) []string {
t.Helper()
var found []string
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
switch filepath.Ext(strings.TrimSuffix(p, ".tmpl")) {
case ".yaml", ".yml", ".json":
default:
return nil
}
body, err := os.ReadFile(p)
if err != nil {
return err
}
for _, match := range koRefPattern.FindAllString(string(body), -1) {
pkg, ok := strings.CutPrefix(match, "ko://"+images.ModulePath+"/")
if !ok {
pkg = match
}
if !slices.Contains(found, pkg) {
found = append(found, pkg)
}
}
return nil
})
if err != nil {
t.Fatalf("walking %s: %v", dir, err)
}
return found
}
// images.Components is a closed list, so a component added to a manifest but
// not to the list would install fine from source and fail only when someone
// tried to install a release. Catch it here instead.
//
// The same comparison holds the spelling of the references. ko accepts a
// "./"-relative import path as well as a full one, and images.KoReference only
// builds the full form, so a relative reference would resolve to no image. It
// lands here as an entry the list cannot contain.
func TestComponentsCoverTheManifests(t *testing.T) {
root, err := config.RepoRoot()
if err != nil {
t.Fatalf("resolving repo root: %v", err)
}
var found []string
for _, tree := range installTrees {
for _, pkg := range componentsInTree(t, filepath.Join(root, tree)) {
if !slices.Contains(found, pkg) {
found = append(found, pkg)
}
}
}
slices.Sort(found)
want := slices.Clone(images.Components)
slices.Sort(want)
if !slices.Equal(found, want) {
t.Errorf("the components referenced by %s and images.Components disagree:\n"+
" in the manifests: %v\n"+
" in images.Components: %v\n"+
"add the missing entries to images.Components, or remove the stale ones; "+
"an entry still carrying a ko:// prefix is a reference that does not name "+
"its package by the full import path %s/...",
strings.Join(installTrees, "/ and "), found, want, images.ModulePath)
}
}
func TestSourceValidate(t *testing.T) {
tests := []struct {
name string
src images.Source
error string
}{
{
name: "build from source",
src: images.Source{},
},
{
name: "prebuilt",
src: images.Source{Repo: "example.com/substrate", Tag: "v1"},
},
{
name: "no tag",
src: images.Source{Repo: "example.com/substrate"},
error: "--image-repo (or ATE_IMAGE_REPO) requires --image-tag",
},
{
// A tag with nowhere to pull from would otherwise be dropped, and
// the source build that followed would look like the release.
name: "no repo",
src: images.Source{Tag: "v1"},
error: "--image-tag (or ATE_IMAGE_TAG) requires --image-repo",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.src.Validate()
if tc.error == "" {
if err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
return
}
if err == nil {
t.Fatalf("Validate() = nil, want an error containing %q", tc.error)
}
if !strings.Contains(err.Error(), tc.error) {
t.Errorf("Validate() error = %v, want it to contain %q", err, tc.error)
}
})
}
}
+161
View File
@@ -0,0 +1,161 @@
// 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 images
import (
"context"
"errors"
"fmt"
"maps"
"regexp"
"slices"
"strings"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/kube"
)
// koRef matches a whole ko:// reference: the prefix plus everything up to the
// first delimiter that can end a YAML scalar.
//
// Matching the whole reference is what makes the rewrite safe. Replacing the
// table's literals directly would rewrite a prefix of a longer reference, so
// ko://<module>/cmd/atelet-sidecar would silently become the atelet image with
// "-sidecar" left dangling on the tag. Requiring a character after the prefix
// also keeps the "# ko:// image reference" prose in the demo templates from
// matching.
var koRef = regexp.MustCompile(`ko://[^\s"',\]}]+`)
// imageByRef maps a ko:// reference to the image name it publishes as.
// Rewriting is a lookup in this table.
var imageByRef = func() map[string]string {
m := make(map[string]string, len(Components))
for _, pkg := range Components {
m[KoReference(pkg)] = ImageName(pkg)
}
return m
}()
// Prebuilt rewrites ko:// references to point at already-published images.
//
// It is the drop-in counterpart to ko.Runner: same two methods, no builds, no
// registry writes, and no repository checkout beyond the manifests themselves.
// What it does need is read access to the registry, to pin each tag to a
// digest.
type Prebuilt struct {
src Source
digest Digester
// pinned caches a resolved reference by its tagged form, so a component
// several manifests name costs one registry lookup per install.
pinned map[string]string
}
// NewPrebuilt returns a resolver for an already-published image set. digest
// turns a tag into the image it names; callers outside tests pass RemoteDigest.
func NewPrebuilt(src Source, digest Digester) *Prebuilt {
return &Prebuilt{src: src, digest: digest, pinned: make(map[string]string)}
}
// ResolvePath rewrites the manifests a path covers.
func (p *Prebuilt) ResolvePath(ctx context.Context, path string) ([]byte, error) {
manifest, err := kube.ReadPath(path)
if err != nil {
return nil, err
}
out, err := p.rewrite(ctx, manifest)
if err != nil {
return nil, fmt.Errorf("in %s: %w", path, err)
}
return out, nil
}
// ResolveBytes rewrites an in-memory manifest, such as kustomize output.
func (p *Prebuilt) ResolveBytes(ctx context.Context, manifest []byte) ([]byte, error) {
return p.rewrite(ctx, manifest)
}
// pin returns the reference to install an image by: its tag, and the digest
// that tag currently names.
//
// The digest is not decoration. An ActorTemplate's container image, an image
// volume's reference, and a SandboxConfig's pauseImage each carry the CEL rule
// self.contains('@'), so a tag on its own is rejected by admission and every
// demo would fail to deploy. Pinning also restores what ko gave the control
// plane deployments for free: an install that cannot shift under a tag someone
// moves later. Keeping the tag alongside the digest is ko's own output shape,
// and leaves the reference legible in `kubectl get`.
func (p *Prebuilt) pin(ctx context.Context, image string) (string, error) {
tagged := p.src.Repo + "/" + image + ":" + p.src.Tag
if ref, ok := p.pinned[tagged]; ok {
return ref, nil
}
digest, err := p.digest(ctx, tagged)
if err != nil {
return "", err
}
ref := tagged + "@" + digest
p.pinned[tagged] = ref
return ref, nil
}
// rewrite replaces every ko:// reference with the image it maps to.
//
// Substitution is textual, the same way SubstituteVersion fills
// ${SUBSTRATE_VERSION}. References appear in CRD fields as well as pod specs (a
// WorkerPool's spec.workerImage, an ActorTemplate's image), so a schema walk
// would need to know every such field, and rewriting the bytes leaves
// everything else -- the multi-kilobyte Envoy configuration blocks in
// particular -- exactly as committed.
func (p *Prebuilt) rewrite(ctx context.Context, manifest []byte) ([]byte, error) {
// Collect the failures rather than stopping at the first. Diagnosing them
// one install attempt at a time is slow, and they are usually related: a
// registry that is unreachable or a tag that was never pushed fails for
// every component at once.
unknown := make(map[string]bool)
unpinned := make(map[string]error)
out := koRef.ReplaceAllFunc(manifest, func(match []byte) []byte {
image, ok := imageByRef[string(match)]
if !ok {
unknown[string(match)] = true
return match
}
ref, err := p.pin(ctx, image)
if err != nil {
unpinned[image] = err
return match
}
return []byte(ref)
})
var errs []error
if len(unknown) > 0 {
// One line per reference, then the package list once. Repeating the
// list per reference buries the references themselves.
for _, ref := range slices.Sorted(maps.Keys(unknown)) {
errs = append(errs, fmt.Errorf("%s has no published image", ref))
}
errs = append(errs, fmt.Errorf("installable packages under %s are %s; a reference that is "+
"templated has to be rendered before it can be resolved",
ModulePath, strings.Join(Components, ", ")))
}
for _, image := range slices.Sorted(maps.Keys(unpinned)) {
errs = append(errs, unpinned[image])
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
return out, nil
}
@@ -0,0 +1,340 @@
// 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 images_test
import (
"context"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/config"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/images"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/kube"
)
// testSource is the repo/tag every rewrite test resolves against.
var testSource = images.Source{Repo: "example.com/substrate", Tag: "v1.2.3"}
// What a resolved reference carries beyond its tag. Fixed rather than derived
// from the reference so the expected output below reads as a literal.
const (
testDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
digestSuffix = "@" + testDigest
)
// stubRegistry stands in for the registry the installer would query: it answers
// every lookup with testDigest, or with err, and records what was asked for.
type stubRegistry struct {
calls map[string]int
err error
}
func newStubRegistry() *stubRegistry { return &stubRegistry{calls: make(map[string]int)} }
// digest is the [images.Digester] a test passes to [images.NewPrebuilt].
func (s *stubRegistry) digest(_ context.Context, ref string) (string, error) {
s.calls[ref]++
if s.err != nil {
return "", fmt.Errorf("resolving %s to a digest: %w", ref, s.err)
}
return testDigest, nil
}
func TestPrebuiltResolveBytes(t *testing.T) {
tests := []struct {
name string
src images.Source
in string
want string
error string
}{
{
name: "bare scalar",
in: " image: ko://github.com/agent-substrate/substrate/cmd/ateapi\n",
want: " image: example.com/substrate/ateapi:v1.2.3" + digestSuffix + "\n",
},
{
name: "double quoted",
in: `image: "ko://github.com/agent-substrate/substrate/cmd/atelet"`,
want: `image: "example.com/substrate/atelet:v1.2.3` + digestSuffix + `"`,
},
{
name: "single quoted",
in: `image: 'ko://github.com/agent-substrate/substrate/cmd/atenet'`,
want: `image: 'example.com/substrate/atenet:v1.2.3` + digestSuffix + `'`,
},
{
name: "flow sequence",
in: `images: [ko://github.com/agent-substrate/substrate/demos/counter, other]`,
want: `images: [example.com/substrate/counter:v1.2.3` + digestSuffix + `, other]`,
},
{
// The reference is a plain CRD field here, not a pod spec, which is
// why the rewrite cannot be driven off a Kubernetes schema.
name: "CRD field",
in: "spec:\n workerImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor\n",
want: "spec:\n workerImage: example.com/substrate/ateom-gvisor:v1.2.3" + digestSuffix + "\n",
},
{
name: "nested package maps to its image name",
in: "image: ko://github.com/agent-substrate/substrate/demos/multi-template/fspersist\n",
want: "image: example.com/substrate/fspersist:v1.2.3" + digestSuffix + "\n",
},
{
name: "two references on one line",
in: "a: ko://github.com/agent-substrate/substrate/cmd/ateapi, b: ko://github.com/agent-substrate/substrate/demos/egress\n",
want: "a: example.com/substrate/ateapi:v1.2.3" + digestSuffix + ", b: example.com/substrate/egress:v1.2.3" + digestSuffix + "\n",
},
{
// The demo templates carry "# ko:// image reference" as prose. The
// regex requires a character after the prefix so those survive.
name: "prose mention is left alone",
in: " # ko:// image reference for the workload\n",
want: " # ko:// image reference for the workload\n",
},
{
name: "nothing to rewrite",
in: "apiVersion: v1\nkind: ConfigMap\n",
want: "apiVersion: v1\nkind: ConfigMap\n",
},
{
// benchmarking/workloads/manifests templates the sandbox class into
// the import path. It has to fail rather than resolve to something.
//
// The reported reference stops at the "}", which ends a YAML flow
// mapping and so terminates the match. That costs a character in the
// message and nothing else: the truncation cannot collide with a
// listed package, because every one of those ends before the "$".
name: "unexpanded placeholder",
in: "image: ko://github.com/agent-substrate/substrate/cmd/ateom-${SANDBOX_CLASS}\n",
error: "cmd/ateom-${SANDBOX_CLASS has no published image",
},
{
// A reference extending a known one must not inherit its image. A
// literal replacement would leave "-sidecar" dangling on the tag.
name: "reference extending a known one",
in: "image: ko://github.com/agent-substrate/substrate/cmd/atelet-sidecar\n",
error: "cmd/atelet-sidecar has no published image",
},
{
// ko accepts a "./"-relative import path in a manifest as readily as
// a full one, and would publish this as "atelet". Nothing in the tree
// spells a reference this way, so it is rejected rather than mapped:
// only the packages named in full can be known to have been published.
name: "relative import path",
in: "image: ko://./cmd/atelet\n",
error: "ko://./cmd/atelet has no published image",
},
{
// ko would map this onto its own "atelet" image, because it names
// images by the last path element alone. Matching the whole reference
// is what keeps another module's package out of this repo's registry
// path.
name: "another module ending in a known name",
in: "image: ko://github.com/example/other/cmd/atelet\n",
error: "ko://github.com/example/other/cmd/atelet has no published image",
},
{
name: "outside the module",
in: "image: ko://github.com/example/other/cmd/thing\n",
error: "ko://github.com/example/other/cmd/thing has no published image",
},
{
name: "unknown component",
in: "image: ko://github.com/agent-substrate/substrate/cmd/notacomponent\n",
error: "cmd/notacomponent has no published image",
},
{
// The e2e fixtures are not part of an install, so a manifest
// reaching the installer with one is a mistake worth reporting.
name: "e2e fixture is not installable",
in: "image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe\n",
error: "e2e/fixtures/probe has no published image",
},
{
// Every unmappable reference is named, not just the first.
name: "all failures are reported",
in: "a: ko://github.com/agent-substrate/substrate/cmd/one\nb: ko://github.com/agent-substrate/substrate/cmd/two\n",
error: "cmd/two has no published image",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
src := tc.src
if src.Repo == "" {
src = testSource
}
got, err := images.NewPrebuilt(src, newStubRegistry().digest).
ResolveBytes(context.Background(), []byte(tc.in))
if tc.error != "" {
if err == nil {
t.Fatalf("ResolveBytes() = %q, want an error containing %q", got, tc.error)
}
if !strings.Contains(err.Error(), tc.error) {
t.Errorf("ResolveBytes() error = %v, want it to contain %q", err, tc.error)
}
return
}
if err != nil {
t.Fatalf("ResolveBytes() error = %v", err)
}
if string(got) != tc.want {
t.Errorf("ResolveBytes() =\n%q\nwant\n%q", got, tc.want)
}
})
}
}
// Every unmappable reference is reported at once, so a manifest with several
// problems takes one install attempt to diagnose rather than several.
func TestPrebuiltReportsEveryFailure(t *testing.T) {
in := "a: ko://github.com/agent-substrate/substrate/cmd/ateom-${SANDBOX_CLASS}\n" +
"b: ko://github.com/example/other/cmd/thing\n" +
"c: ko://github.com/agent-substrate/substrate/cmd/nope\n"
_, err := images.NewPrebuilt(testSource, newStubRegistry().digest).
ResolveBytes(context.Background(), []byte(in))
if err == nil {
t.Fatal("ResolveBytes() = nil error, want failures for all three references")
}
for _, want := range []string{"SANDBOX_CLASS", "github.com/example/other", "cmd/nope"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error is missing %q:\n%v", want, err)
}
}
}
// A repeated bad reference is one problem, not one per occurrence.
func TestPrebuiltDeduplicatesFailures(t *testing.T) {
ref := "image: ko://github.com/agent-substrate/substrate/cmd/nope\n"
_, err := images.NewPrebuilt(testSource, newStubRegistry().digest).
ResolveBytes(context.Background(), []byte(ref+ref+ref))
if err == nil {
t.Fatal("ResolveBytes() = nil error, want a failure")
}
if n := strings.Count(err.Error(), "cmd/nope has no published image"); n != 1 {
t.Errorf("reported the same reference %d times, want 1:\n%v", n, err)
}
}
// A tag is resolved once per image, however many manifests name it, and the
// answer is reused across calls: an install applies several manifests through
// one resolver, and a registry round trip each time would be waste.
func TestPrebuiltLooksUpEachImageOnce(t *testing.T) {
registry := newStubRegistry()
resolver := images.NewPrebuilt(testSource, registry.digest)
in := "a: ko://github.com/agent-substrate/substrate/cmd/ateapi\n" +
"b: ko://github.com/agent-substrate/substrate/cmd/atelet\n" +
"c: ko://github.com/agent-substrate/substrate/cmd/ateapi\n"
for range 2 {
if _, err := resolver.ResolveBytes(context.Background(), []byte(in)); err != nil {
t.Fatalf("ResolveBytes() error = %v", err)
}
}
want := map[string]int{
"example.com/substrate/ateapi:v1.2.3": 1,
"example.com/substrate/atelet:v1.2.3": 1,
}
if !maps.Equal(registry.calls, want) {
t.Errorf("registry lookups = %v, want %v", registry.calls, want)
}
}
// A registry that cannot answer fails the install, and says so for every image
// it was asked about. Falling back to the bare tag would produce manifests that
// admission rejects, and a partially pinned manifest would be worse still.
func TestPrebuiltReportsDigestFailures(t *testing.T) {
registry := newStubRegistry()
registry.err = errors.New("UNAUTHORIZED")
in := "a: ko://github.com/agent-substrate/substrate/cmd/ateapi\n" +
"b: ko://github.com/agent-substrate/substrate/cmd/atelet\n"
got, err := images.NewPrebuilt(testSource, registry.digest).
ResolveBytes(context.Background(), []byte(in))
if err == nil {
t.Fatalf("ResolveBytes() = %q, want an error", got)
}
for _, want := range []string{
"resolving example.com/substrate/ateapi:v1.2.3 to a digest: UNAUTHORIZED",
"resolving example.com/substrate/atelet:v1.2.3 to a digest: UNAUTHORIZED",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error is missing %q:\n%v", want, err)
}
}
}
// resolvedRef matches a rewritten reference in testSource's repository.
var resolvedRef = regexp.MustCompile(regexp.QuoteMeta(testSource.Repo) + `/[^\s"',\]}]+`)
// Resolving the control plane manifests must produce a manifest that still
// parses and holds no unresolved reference. This is the closest a unit test
// gets to the install itself.
func TestResolveEveryInstallManifest(t *testing.T) {
root, err := config.RepoRoot()
if err != nil {
t.Fatalf("resolving repo root: %v", err)
}
installDir := filepath.Join(root, "manifests", "ate-install")
entries, err := os.ReadDir(installDir)
if err != nil {
t.Fatalf("listing %s: %v", installDir, err)
}
resolver := images.NewPrebuilt(testSource, newStubRegistry().digest)
// The directory as a whole is what a plain GKE install applies.
paths := []string{installDir}
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".yaml" {
paths = append(paths, filepath.Join(installDir, entry.Name()))
}
}
for _, p := range paths {
t.Run(filepath.Base(p), func(t *testing.T) {
out, err := resolver.ResolvePath(context.Background(), p)
if err != nil {
t.Fatalf("ResolvePath() = %v", err)
}
if refs := koRefPattern.FindAllString(string(out), -1); len(refs) > 0 {
t.Errorf("unresolved references survived: %v", refs)
}
// Every reference has to name a digest. ActorTemplate images,
// image volume references, and a SandboxConfig's pauseImage all
// carry the CEL rule self.contains('@'), so an unpinned reference
// is not applied at all, it is rejected by admission.
for _, ref := range resolvedRef.FindAllString(string(out), -1) {
if !strings.Contains(ref, "@sha256:") {
t.Errorf("%s is not pinned to a digest", ref)
}
}
if _, err := kube.DecodeManifestBytes(out); err != nil {
t.Errorf("the resolved manifest no longer parses: %v", err)
}
})
}
}
+54 -12
View File
@@ -75,12 +75,63 @@ func DecodeManifestBytes(data []byte) ([]*unstructured.Unstructured, error) {
// comments there call out specific filename-ordering hazards, so recursing or // comments there call out specific filename-ordering hazards, so recursing or
// reordering here would change install behavior. // reordering here would change install behavior.
func LoadPath(path string) ([]*unstructured.Unstructured, error) { func LoadPath(path string) ([]*unstructured.Unstructured, error) {
files, err := manifestFiles(path)
if err != nil {
return nil, err
}
var objs []*unstructured.Unstructured
for _, file := range files {
fileObjs, err := loadFile(file)
if err != nil {
return nil, err
}
objs = append(objs, fileObjs...)
}
return objs, nil
}
// ReadPath concatenates the same files LoadPath would read into a single
// multi-document stream, leaving them unparsed.
//
// This is the input the pre-built image resolver takes, and sharing
// manifestFiles keeps it reading exactly what an apply applies. ko is handed
// the path and enumerates it itself, accepting only .yaml and .json where this
// also accepts .yml; nothing in the tree uses that extension, and a manifest
// that did would reach an apply with its ko:// references unresolved.
func ReadPath(path string) ([]byte, error) {
files, err := manifestFiles(path)
if err != nil {
return nil, err
}
var out bytes.Buffer
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("while reading %s: %w", file, err)
}
// A separator before every document, including the first: a leading
// empty document decodes to nothing, whereas a file that does not end
// in a newline would otherwise run into the next one.
out.WriteString("---\n")
out.Write(data)
if !bytes.HasSuffix(data, []byte("\n")) {
out.WriteString("\n")
}
}
return out.Bytes(), nil
}
// manifestFiles lists the files a manifest path covers: the file itself, or
// every manifest directly inside a directory, in lexical order.
func manifestFiles(path string) ([]string, error) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("while reading %s: %w", path, err) return nil, fmt.Errorf("while reading %s: %w", path, err)
} }
if !info.IsDir() { if !info.IsDir() {
return loadFile(path) return []string{path}, nil
} }
entries, err := os.ReadDir(path) entries, err := os.ReadDir(path)
@@ -95,19 +146,10 @@ func LoadPath(path string) ([]*unstructured.Unstructured, error) {
if !manifestExtensions[strings.ToLower(filepath.Ext(entry.Name()))] { if !manifestExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
continue continue
} }
names = append(names, entry.Name()) names = append(names, filepath.Join(path, entry.Name()))
} }
sort.Strings(names) sort.Strings(names)
return names, nil
var objs []*unstructured.Unstructured
for _, name := range names {
fileObjs, err := loadFile(filepath.Join(path, name))
if err != nil {
return nil, err
}
objs = append(objs, fileObjs...)
}
return objs, nil
} }
func loadFile(path string) ([]*unstructured.Unstructured, error) { func loadFile(path string) ([]*unstructured.Unstructured, error) {
@@ -0,0 +1,184 @@
// 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 kube
import (
"errors"
"io/fs"
"os"
"path/filepath"
"testing"
)
// writeFiles populates a temporary directory and returns its path.
func writeFiles(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range files {
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
// manifest returns a minimal object whose name identifies it in assertions.
func manifest(name string) string {
return "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\n"
}
func names(t *testing.T, data []byte) []string {
t.Helper()
objs, err := DecodeManifestBytes(data)
if err != nil {
t.Fatalf("DecodeManifestBytes() = %v", err)
}
out := make([]string, 0, len(objs))
for _, obj := range objs {
out = append(out, obj.GetName())
}
return out
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ReadPath feeds the pre-built image resolver, and its output is applied in
// place of what LoadPath would have applied. The two must therefore cover the
// same documents in the same order: the install depends on that ordering, and
// a divergence would be a silently different install rather than an error.
func TestReadPathMatchesLoadPath(t *testing.T) {
tests := []struct {
name string
files map[string]string
want []string
}{
{
name: "single file",
files: map[string]string{"a.yaml": manifest("only")},
want: []string{"only"},
},
{
name: "directory is lexical, not filesystem order",
files: map[string]string{
"20-second.yaml": manifest("second"),
"10-first.yaml": manifest("first"),
"30-third.yaml": manifest("third"),
},
want: []string{"first", "second", "third"},
},
{
name: "non-manifest extensions are skipped",
files: map[string]string{
"keep.yaml": manifest("keep"),
"keep2.yml": manifest("keep2"),
"keep3.json": `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"keep3"}}`,
"skip.tmpl": manifest("skip"),
"skip.md": "# not a manifest\n",
"skip.yaml.bak": manifest("skip"),
},
want: []string{"keep", "keep2", "keep3"},
},
{
name: "nested directories are not recursed into",
files: map[string]string{
"top.yaml": manifest("top"),
"nested/deep.yaml": manifest("deep"),
},
want: []string{"top"},
},
{
name: "a file with no trailing newline does not merge into the next",
files: map[string]string{
"a.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a",
"b.yaml": manifest("b"),
},
want: []string{"a", "b"},
},
{
name: "an empty file contributes no document",
files: map[string]string{
"a.yaml": manifest("a"),
"empty.yaml": "",
},
want: []string{"a"},
},
{
name: "multi-document files keep their internal order",
files: map[string]string{
"a.yaml": manifest("one") + "---\n" + manifest("two"),
},
want: []string{"one", "two"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := writeFiles(t, tc.files)
// A single-file case addresses the file directly, which is the
// shape the manifest-path call sites use.
path := dir
if len(tc.files) == 1 {
for name := range tc.files {
path = filepath.Join(dir, name)
}
}
data, err := ReadPath(path)
if err != nil {
t.Fatalf("ReadPath() = %v", err)
}
got := names(t, data)
if !equal(got, tc.want) {
t.Errorf("ReadPath() objects = %v, want %v", got, tc.want)
}
objs, err := LoadPath(path)
if err != nil {
t.Fatalf("LoadPath() = %v", err)
}
loaded := make([]string, 0, len(objs))
for _, obj := range objs {
loaded = append(loaded, obj.GetName())
}
if !equal(loaded, got) {
t.Errorf("LoadPath() objects = %v, ReadPath() objects = %v; want identical", loaded, got)
}
})
}
}
// The manifest-path call sites hand ReadPath a path that may not exist on a
// teardown, and DeletePath distinguishes that from a real failure by checking
// for fs.ErrNotExist. Keep the wrapping transparent to errors.Is.
func TestReadPathMissing(t *testing.T) {
if _, err := ReadPath(filepath.Join(t.TempDir(), "absent.yaml")); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("ReadPath(absent) = %v, want a not-exist error", err)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (e *Env) SetupCSI(ctx context.Context) error {
if err := e.EnsurePodCertificateCAs(ctx); err != nil { if err := e.EnsurePodCertificateCAs(ctx); err != nil {
return err return err
} }
if err := e.KoApply(ctx, e.Cfg.Manifest("pod-certificate-controller.yaml")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("pod-certificate-controller.yaml")); err != nil {
return err return err
} }
if err := e.applyPodcertWorkersOverride(ctx); err != nil { if err := e.applyPodcertWorkersOverride(ctx); err != nil {
+6 -6
View File
@@ -85,7 +85,7 @@ func (e *Env) DeployAteSystem(ctx context.Context, opts DeployOptions) error {
// The podcertificate controller goes first so it starts signing and // The podcertificate controller goes first so it starts signing and
// publishing trust bundles immediately. // publishing trust bundles immediately.
if err := e.KoApply(ctx, e.Cfg.Manifest("pod-certificate-controller.yaml")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("pod-certificate-controller.yaml")); err != nil {
return err return err
} }
if err := e.applyPodcertWorkersOverride(ctx); err != nil { if err := e.applyPodcertWorkersOverride(ctx); err != nil {
@@ -228,7 +228,7 @@ func (e *Env) DeployAteAPIServer(ctx context.Context) error {
if err := e.applyOtelConfig(ctx); err != nil { if err := e.applyOtelConfig(ctx); err != nil {
return err return err
} }
if err := e.KoApply(ctx, e.Cfg.Manifest("ate-api-server.yaml")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("ate-api-server.yaml")); err != nil {
return err return err
} }
return e.Kube.RolloutStatus(ctx, kube.KindDeployment, NamespaceAteSystem, "ate-api-server", e.Cfg.RolloutTimeout) return e.Kube.RolloutStatus(ctx, kube.KindDeployment, NamespaceAteSystem, "ate-api-server", e.Cfg.RolloutTimeout)
@@ -247,7 +247,7 @@ func (e *Env) DeployAteController(ctx context.Context) error {
if err := e.applyOtelConfig(ctx); err != nil { if err := e.applyOtelConfig(ctx); err != nil {
return err return err
} }
if err := e.KoApply(ctx, e.Cfg.Manifest("ate-controller.yaml")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("ate-controller.yaml")); err != nil {
return err return err
} }
return e.Kube.RolloutStatus(ctx, kube.KindDeployment, NamespaceAteSystem, "ate-controller", e.Cfg.RolloutTimeout) return e.Kube.RolloutStatus(ctx, kube.KindDeployment, NamespaceAteSystem, "ate-controller", e.Cfg.RolloutTimeout)
@@ -276,7 +276,7 @@ func (e *Env) DeployAtelet(ctx context.Context) error {
// The kind overlay patches the DaemonSet for the local node layout. // The kind overlay patches the DaemonSet for the local node layout.
manifest, err = e.KustomizeResolve(ctx, installDir+"/kind/atelet") manifest, err = e.KustomizeResolve(ctx, installDir+"/kind/atelet")
} else { } else {
manifest, err = e.KoResolve(ctx, e.Cfg.Manifest("atelet.yaml")) manifest, err = e.ResolveManifest(ctx, e.Cfg.Manifest("atelet.yaml"))
} }
if err != nil { if err != nil {
return err return err
@@ -322,7 +322,7 @@ func (e *Env) DeployAtenet(ctx context.Context) error {
if err := e.applyAtenetEgress(ctx); err != nil { if err := e.applyAtenetEgress(ctx); err != nil {
return err return err
} }
if err := e.KoApply(ctx, e.Cfg.Manifest("atenet-dns.yaml")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("atenet-dns.yaml")); err != nil {
return err return err
} }
@@ -359,7 +359,7 @@ func (e *Env) EnsureCRDs(ctx context.Context) error {
// DeployCRDs applies the generated CRDs and RBAC, waiting for them to reach Established condition. // DeployCRDs applies the generated CRDs and RBAC, waiting for them to reach Established condition.
func (e *Env) DeployCRDs(ctx context.Context) error { func (e *Env) DeployCRDs(ctx context.Context) error {
log.Step("deploy_crds") log.Step("deploy_crds")
if err := e.KoApply(ctx, e.Cfg.Manifest("generated")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("generated")); err != nil {
return err return err
} }
for _, name := range ateCRDs { for _, name := range ateCRDs {
+46 -27
View File
@@ -23,9 +23,11 @@ import (
"time" "time"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/config" "github.com/agent-substrate/substrate/cmd/ate-setup/internal/config"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/images"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/ko" "github.com/agent-substrate/substrate/cmd/ate-setup/internal/ko"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/kube" "github.com/agent-substrate/substrate/cmd/ate-setup/internal/kube"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/kustomize" "github.com/agent-substrate/substrate/cmd/ate-setup/internal/kustomize"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/log"
) )
// Timeouts carried over from the --timeout values in the shell installer. // Timeouts carried over from the --timeout values in the shell installer.
@@ -48,15 +50,24 @@ const (
NamespacePodCert = "podcertificate-controller-system" NamespacePodCert = "podcertificate-controller-system"
) )
// imageResolver turns a manifest whose image references are ko:// import paths
// into one whose references are pullable. *ko.Runner builds and publishes them
// from source; *images.Prebuilt maps them onto an already-published release.
type imageResolver interface {
ResolvePath(ctx context.Context, path string) ([]byte, error)
ResolveBytes(ctx context.Context, manifest []byte) ([]byte, error)
}
// Env is the shared execution context for every step: resolved configuration // Env is the shared execution context for every step: resolved configuration
// plus the clients needed to act on the cluster. // plus the clients needed to act on the cluster.
type Env struct { type Env struct {
Cfg *config.Config Cfg *config.Config
Kube *kube.Client Kube *kube.Client
// ko is created lazily. Steps that only apply static manifests must work // resolver is created lazily. Steps that only apply static manifests must
// on a machine with no container registry credentials configured. // work on a machine with no container registry credentials configured, and
ko *ko.Runner // the build-from-source resolver fails at construction without them.
resolver imageResolver
// substrateVersion caches the derived build version and its object-name // substrateVersion caches the derived build version and its object-name
// suffix; see SubstrateVersion. // suffix; see SubstrateVersion.
@@ -73,51 +84,59 @@ func NewEnv(cfg *config.Config) (*Env, error) {
return &Env{Cfg: cfg, Kube: client}, nil return &Env{Cfg: cfg, Kube: client}, nil
} }
// Ko returns the ko runner, creating it on first use. // imageResolver returns the image resolver, creating it on first use.
func (e *Env) Ko() (*ko.Runner, error) { func (e *Env) imageResolver() (imageResolver, error) {
if e.ko != nil { if e.resolver != nil {
return e.ko, nil return e.resolver, nil
} }
log.Stepf("images: %s", e.Cfg.Images.Describe())
if e.Cfg.Images.IsPrebuilt() {
e.resolver = images.NewPrebuilt(e.Cfg.Images, images.RemoteDigest)
return e.resolver, nil
}
runner, err := ko.New(e.Cfg.Root, e.Cfg.KoEnv()) runner, err := ko.New(e.Cfg.Root, e.Cfg.KoEnv())
if err != nil { if err != nil {
return nil, err return nil, err
} }
e.ko = runner e.resolver = runner
return e.ko, nil return e.resolver, nil
} }
// KoApply resolves a manifest path with ko and applies the result. This is the // ResolveAndApply resolves the images in a manifest path and applies the
// run_ko apply of the shell scripts, split into its two real steps. // result. This is the run_ko apply of the shell scripts, split into its two
func (e *Env) KoApply(ctx context.Context, path string) error { // real steps.
manifest, err := e.KoResolve(ctx, path) func (e *Env) ResolveAndApply(ctx context.Context, path string) error {
manifest, err := e.ResolveManifest(ctx, path)
if err != nil { if err != nil {
return err return err
} }
return e.Kube.ApplyBytes(ctx, manifest) return e.Kube.ApplyBytes(ctx, manifest)
} }
// KoResolve builds and publishes the images in a manifest path, returning the // ResolveManifest turns the ko:// references in a manifest path into pullable
// digest-pinned manifest. // image references.
func (e *Env) KoResolve(ctx context.Context, path string) ([]byte, error) { func (e *Env) ResolveManifest(ctx context.Context, path string) ([]byte, error) {
runner, err := e.Ko() resolver, err := e.imageResolver()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return runner.ResolvePath(ctx, path) return resolver.ResolvePath(ctx, path)
} }
// KoResolveBytes resolves an in-memory manifest, such as kustomize output. // ResolveManifestBytes resolves an in-memory manifest, such as kustomize output.
func (e *Env) KoResolveBytes(ctx context.Context, manifest []byte) ([]byte, error) { func (e *Env) ResolveManifestBytes(ctx context.Context, manifest []byte) ([]byte, error) {
runner, err := e.Ko() resolver, err := e.imageResolver()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return runner.ResolveBytes(ctx, manifest) return resolver.ResolveBytes(ctx, manifest)
} }
// KoApplyBytes resolves an in-memory manifest with ko and applies the result. // ResolveAndApplyBytes resolves an in-memory manifest and applies the result.
func (e *Env) KoApplyBytes(ctx context.Context, manifest []byte) error { func (e *Env) ResolveAndApplyBytes(ctx context.Context, manifest []byte) error {
resolved, err := e.KoResolveBytes(ctx, manifest) resolved, err := e.ResolveManifestBytes(ctx, manifest)
if err != nil { if err != nil {
return err return err
} }
@@ -129,14 +148,14 @@ func (e *Env) Kustomize(overlay string) ([]byte, error) {
return kustomize.Build(e.Cfg.Path(overlay)) return kustomize.Build(e.Cfg.Path(overlay))
} }
// KustomizeResolve renders an overlay and pipes it through ko, the // KustomizeResolve renders an overlay and resolves its images, the
// `kubectl kustomize ... | run_ko resolve -f -` pipeline. // `kubectl kustomize ... | run_ko resolve -f -` pipeline.
func (e *Env) KustomizeResolve(ctx context.Context, overlay string) ([]byte, error) { func (e *Env) KustomizeResolve(ctx context.Context, overlay string) ([]byte, error) {
built, err := e.Kustomize(overlay) built, err := e.Kustomize(overlay)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return e.KoResolveBytes(ctx, built) return e.ResolveManifestBytes(ctx, built)
} }
// EnsureAteSystemNamespace applies the ate-system namespace manifest and waits // EnsureAteSystemNamespace applies the ate-system namespace manifest and waits
+4 -4
View File
@@ -51,7 +51,7 @@ func (e *Env) renderSystemManifests(ctx context.Context) ([]byte, error) {
if overlay := SystemOverlay(e.Cfg); overlay != "" { if overlay := SystemOverlay(e.Cfg); overlay != "" {
return e.KustomizeResolve(ctx, overlay) return e.KustomizeResolve(ctx, overlay)
} }
return e.KoResolve(ctx, e.Cfg.Manifest()) return e.ResolveManifest(ctx, e.Cfg.Manifest())
} }
// renderAtenetRouterManifest produces the atenet router manifest for the // renderAtenetRouterManifest produces the atenet router manifest for the
@@ -60,7 +60,7 @@ func (e *Env) renderAtenetRouterManifest(ctx context.Context) ([]byte, error) {
if e.Cfg.Router == config.RouterAgentgateway { if e.Cfg.Router == config.RouterAgentgateway {
return e.KustomizeResolve(ctx, installDir+"/agentgateway-router") return e.KustomizeResolve(ctx, installDir+"/agentgateway-router")
} }
return e.KoResolve(ctx, e.Cfg.Manifest("atenet-router.yaml")) return e.ResolveManifest(ctx, e.Cfg.Manifest("atenet-router.yaml"))
} }
// atenetEgressManifestPath returns the egress manifest path based on configuration. // atenetEgressManifestPath returns the egress manifest path based on configuration.
@@ -85,10 +85,10 @@ func (e *Env) renderAtenetEgressManifest(ctx context.Context) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return e.KoResolveBytes(ctx, patched) return e.ResolveManifestBytes(ctx, patched)
} }
return e.KoResolve(ctx, e.atenetEgressManifestPath()) return e.ResolveManifest(ctx, e.atenetEgressManifestPath())
} }
func (e *Env) patchAtenetEgressManifest() ([]byte, error) { func (e *Env) patchAtenetEgressManifest() ([]byte, error) {
+1 -1
View File
@@ -37,7 +37,7 @@ func (e *Env) DeployPostgres(ctx context.Context) error {
// The StatefulSet's projected serving certificate is issued by this // The StatefulSet's projected serving certificate is issued by this
// controller. Applying it here makes `deploy postgres` usable on a fresh // controller. Applying it here makes `deploy postgres` usable on a fresh
// cluster as well as after `deploy ate-system`. // cluster as well as after `deploy ate-system`.
if err := e.KoApply(ctx, e.Cfg.Manifest("pod-certificate-controller.yaml")); err != nil { if err := e.ResolveAndApply(ctx, e.Cfg.Manifest("pod-certificate-controller.yaml")); err != nil {
return err return err
} }
if err := e.applyPodcertWorkersOverride(ctx); err != nil { if err := e.applyPodcertWorkersOverride(ctx); err != nil {
+19 -1
View File
@@ -17,6 +17,7 @@ package steps
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@@ -31,11 +32,28 @@ import (
// SubstrateVersion returns the build version and the object-name suffix for atelet. // SubstrateVersion returns the build version and the object-name suffix for atelet.
// The version is the same one ko stamps into the binaries. // The version is the same one ko stamps into the binaries.
//
// With --image-repo nothing is built, so `git describe` would report the
// checkout rather than the images being installed. The image tag is the version
// in that case. It has to be: the version names the atelet DaemonSet and sets
// the node label that partitions nodes across coexisting versions, so it must
// describe the atelet that is actually running, which came from the image.
// VERSION still wins over both, so a tag that is not a valid label value can be
// worked around the same way.
func (e *Env) SubstrateVersion() (version, suffix string, err error) { func (e *Env) SubstrateVersion() (version, suffix string, err error) {
if e.substrateVersion != "" { if e.substrateVersion != "" {
return e.substrateVersion, e.substrateVersionSuffix, nil return e.substrateVersion, e.substrateVersionSuffix, nil
} }
raw := ko.BuildVersion(e.Cfg.Root) var raw string
if e.Cfg.Images.IsPrebuilt() {
raw = os.Getenv("VERSION")
if raw == "" {
raw = e.Cfg.Images.Tag
}
} else {
// ko.BuildVersion consults VERSION itself before `git describe`.
raw = ko.BuildVersion(e.Cfg.Root)
}
if v := versionlabel.Value(raw); v != raw { if v := versionlabel.Value(raw); v != raw {
return "", "", fmt.Errorf("build version %q is not a valid label value (it would sanitize to %q); pin a label-safe one with the VERSION env var", raw, v) return "", "", fmt.Errorf("build version %q is not a valid label value (it would sanitize to %q); pin a label-safe one with the VERSION env var", raw, v)
} }
+43 -1
View File
@@ -14,7 +14,49 @@
package steps package steps
import "testing" import (
"testing"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/config"
"github.com/agent-substrate/substrate/cmd/ate-setup/internal/images"
)
// TestSubstrateVersionPrebuilt covers the version a prebuilt install stamps.
// Root points at a directory that is not a checkout, so `git describe` has
// nothing to say and only the image tag can supply it.
func TestSubstrateVersionPrebuilt(t *testing.T) {
t.Setenv("VERSION", "")
cases := []struct {
name string
source images.Source
want string
wantSuffix string
}{{
name: "tag is the version",
source: images.Source{Repo: "example.com/substrate", Tag: "v0.0.0-503-4b3423c0"},
want: "v0.0.0-503-4b3423c0",
wantSuffix: "v0-0-0-503-4b3423c0",
}, {
name: "built from source falls back to git",
source: images.Source{},
want: "dev",
wantSuffix: "dev",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := &Env{Cfg: &config.Config{Root: t.TempDir(), Images: tc.source}}
got, suffix, err := e.SubstrateVersion()
if err != nil {
t.Fatalf("SubstrateVersion: %v", err)
}
if got != tc.want || suffix != tc.wantSuffix {
t.Fatalf("SubstrateVersion = %q, %q; want %q, %q", got, suffix, tc.want, tc.wantSuffix)
}
})
}
}
func TestSubstituteVersion(t *testing.T) { func TestSubstituteVersion(t *testing.T) {
e := &Env{substrateVersion: "v1.2.3", substrateVersionSuffix: "v1-2-3"} e := &Env{substrateVersion: "v1.2.3", substrateVersionSuffix: "v1-2-3"}