Operator Install

Configure the installer with a settings document, and how it resolves flags, environment, and config.

10 min read ·

Every setting the installer reads can be supplied three ways: a flag, an environment variable, or a configuration document. This page describes the document, because it is the only one of the three an operator owns as a file, reviews in a change, and reuses across runs.

The document

apiVersion: install.ate.dev/v1alpha1
kind: SubstrateInstall

namespace: ate-system
atenet:
  dataplane: envoy
images:
  repo: registry.example.com/substrate
  tag: v0.4.1

Pass it with --config, or name it in ATE_CONFIG:

ate-setup deploy ate-system --config substrate-install.yaml
ATE_CONFIG=substrate-install.yaml ate-setup deploy ate-system

A worked example is at docs/examples/substrate-install.yaml.

Keys are nested; the table below is flat

A key written ateapi.postgres.schema is nested in the document:

ateapi:
  postgres:
    schema: public

An unrecognized key is rejected rather than ignored, naming the file:

Error: install.yaml: unknown setting "ateapi.postgres.schemas"

A typo would otherwise leave the install running on the default with nothing on screen to say so.

Precedence

Lowest to highest:

default  <  environment  <  configuration file  <  flag

The file outranks the environment deliberately: the file is the artifact an operator wrote and can review, and ambient state left in a shell must not silently outrank it. A flag outranks both because it was typed for this run.

A channel that does not mention a setting leaves the one below it in place, so a document need only name what differs from the defaults.

Empty values

A setting a channel mentions is set, whatever it is set to. An empty value is a value:

namespace: ""
export ATE_NAMESPACE=      # the same thing
ate-setup deploy ate-system --namespace=

All three clear the setting rather than falling through to the default. That is how a setting is removed from an installation: write its zero value and re-apply.

Only an absent setting falls through. In the shell that distinction is the difference between export FOO= and unset FOO, and the two do not mean the same thing here:

ShellMeaning
unset ATE_NAMESPACEnot set; the layer below applies
export ATE_NAMESPACE=set to empty
export ATE_NAMESPACE=ate-prodset to ate-prod

A script that exports an empty variable to mean “unset” is configuring the setting. The report names the channel each value came from, so when that happens it is visible rather than mysterious.

For a setting that is not a string, an empty value is not a value of its kind and is reported as such – ATE_INSTALL_PODCERT_WORKERS_PER_SIGNER= is rejected, naming the variable, rather than quietly ignored.

Every run reports what it resolved

Every setting is listed with the channel that supplied it:

configuration: 6 set, 35 default
  ateapi.expectedJWTIssuer                    ""            (from default)
  ateapi.postgres.readWrite.connectionString  <set>         (from ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING)
  ...
  atenet.dataplane                            agentgateway  (from ATE_ATENET_DATAPLANE)
  benchmark.sandboxClass                      microvm       (from --sandbox-class)
  benchmark.workerCount                       7             (from config.benchmark.workerCount)
  clusterSize                                 size0         (from default)
  context                                     kind-kind     (from the --kind profile)
  namespace                                   ate-prod      (from config.namespace)

An install that succeeds with the wrong value never produces an error, so this report is the only place an inherited variable or a forgotten file becomes visible – and a value that came from a default is worth seeing for the same reason, because next release it may be a different default.

A setting marked secret in the table below prints as <set> when something supplied it, with the channel still named so you know where to change it. One nothing supplied prints its default, which is empty: no credential appears either way.

To list only what was set:

ate-setup deploy ate-system --no-report-defaults
export ATE_NO_REPORT_DEFAULTS=1          # the same, for a shell that runs it repeatedly
configuration: 6 set, 35 default
  ateapi.postgres.readWrite.connectionString  <set>         (from ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING)
  atenet.dataplane                            agentgateway  (from ATE_ATENET_DATAPLANE)
  benchmark.sandboxClass                      microvm       (from --sandbox-class)
  benchmark.workerCount                       7             (from config.benchmark.workerCount)
  context                                     kind-kind     (from the --kind profile)
  namespace                                   ate-prod      (from config.namespace)

Errors name the channel

Error: atenet.dataplane must be envoy or agentgateway, got "nginx" (from config.atenet.dataplane)
Error: at least one of ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING or
       config.ateapi.postgres.readWrite.connectionString must be set, got none

Command-scoped settings

Most settings apply to every command and are flags on the root. A few belong to one command and are flags only there — but they are still reachable from the environment and the file, so one document can describe a whole install:

csi:
  setup: nfs
benchmark:
  workerCount: 4

Install runs record what they used

A deploy command that installs Substrate writes the settings it resolved to a file, so a later run can reproduce it without anyone remembering what was typed. deploy demo and deploy benchmarks write nothing: they deploy on top of the system with only their own settings.

recorded this configuration in ~/.cache/ate-setup/installs/prod.yaml

Records go under the user cache directory, or wherever record.dir points. Nothing reads them on its own – a record only takes effect when you pass it to --config.

OutcomePathRetention
Succeeded<record.dir>/installs/<context>.yamlreplaced each run: a cluster has one current configuration
Failed<record.dir>/failed/<context>-<timestamp>.yamlkept: each attempt is its own artifact

Retrying a failed install

A run that fails prints the record it wrote and the command that replays it:

Error: while reading kubeconfig: context "prod" does not exist
the settings this run used were written to ~/.cache/ate-setup/failed/prod-20260929T231438Z.yaml
retry with: ate-setup deploy atenet --config ~/.cache/ate-setup/failed/prod-20260929T231438Z.yaml
1 secret setting(s) were not written and must be supplied again: ateapi.postgres.readWrite.connectionString

The record is ordinary --config input. Nothing has to be stripped from it first: the cluster: block holding the timestamp and outcome is skipped when the file is read back.

A retry is not a complete replay. Secrets are never written, so the last line above lists what to supply again:

export ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING='postgres://...'
ate-setup deploy atenet --config ~/.cache/ate-setup/failed/prod-20260929T231438Z.yaml

A failed run changes nothing about later runs. Its record is inert until you name it – a value that caused the failure must not become the default that reaches the cluster on the next success.

Reusing a successful install

The same applies to the success record, which is the one to keep:

# Rebuild a cluster the way it was, or set up a second one to match.
ate-setup deploy ate-system --config ~/.cache/ate-setup/installs/prod.yaml

Point record.dir at a checkout to keep these under version control, which is how several operators share one configuration and how an install survives a cluster that no longer exists:

export ATE_RECORD_DIR=~/ops/substrate-installs

Records hold no credentials, so they are safe to commit. They are written readable only by you regardless.

What a record looks like

apiVersion: install.ate.dev/v1alpha1
kind: SubstrateInstall
cluster:
  context: prod
  outcome: succeeded
  substrateVersion: v0.4.1
  writtenAt: "2026-09-29T23:14:38Z"
  writtenBy: ate-setup v0.4.1
atenet:
  dataplane: agentgateway
context: prod
namespace: ate-prod

cluster.substrateVersion is the Substrate release the run installed: the version stamped into the binaries it built, or the image tag of a prebuilt install. cluster.writtenBy is the ate-setup that wrote the file.

Only settings something supplied are recorded. A setting left on its default is left out on purpose, so it keeps following the default if a later release changes it.

Settings

Defaults are also shown by ate-setup --help and by --help on the owning command. A setting with no default is unset unless something supplies it. The ateapi.authz.* settings are described in authorization.md.

Settings for the demos, benchmarks, and e2e tests are listed with them: the counter and claude-code-multiplex demos, the benchmarks, and the e2e tests.

KeyEnvironment variableFlagDefaultApplies to
actorJWT.algorithmACTOR_JWT_ALGORITHM--actor-jwt-algorithmES256(global)
actorJWT.issuerACTOR_JWT_ISSUER--actor-jwt-issuer—(global)
ateapi.authz.bootstrapOwnersATE_API_AUTHZ_BOOTSTRAP_OWNERS--ateapi-authz-bootstrap-ownerssystem:serviceaccount:<namespace>:ate-client(global)
ateapi.authz.egressGatewayExtraSPIFFEIDsATE_EGRESS_GATEWAY_EXTRA_SPIFFE_IDS--egress-gateway-extra-spiffe-ids—(global)
ateapi.expectedJWTIssuerEXPECTED_JWT_ISSUER--ateapi-expected-jwt-issuer—(global)
ateapi.logLevelATE_API_LOG_LEVEL--ateapi-log-level—(global)
ateapi.postgres.cloudsql.gsaATE_API_POSTGRES_CLOUDSQL_GSA--ateapi-postgres-cloudsql-gsa—(global)
ateapi.postgres.cloudsql.iamAuthATE_API_POSTGRES_CLOUDSQL_IAM_AUTH--ateapi-postgres-cloudsql-iam-auth—(global)
ateapi.postgres.cloudsql.instanceATE_API_POSTGRES_CLOUDSQL_INSTANCE--ateapi-postgres-cloudsql-instance—(global)
ateapi.postgres.cloudsql.ipTypeATE_API_POSTGRES_CLOUDSQL_IP_TYPE--ateapi-postgres-cloudsql-ip-type—(global)
ateapi.postgres.owner.connectionStringATE_API_POSTGRES_OWNER_CONNECTION_STRING— (no flag)— (secret)(global)
ateapi.postgres.owner.roleATE_API_POSTGRES_OWNER_ROLE--ateapi-postgres-owner-rolesubstrate_owner(global)
ateapi.postgres.poolMaxConnsATE_API_POSTGRES_POOL_MAX_CONNS--ateapi-postgres-pool-max-conns—(global)
ateapi.postgres.readWrite.connectionStringATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING— (no flag)— (secret)(global)
ateapi.postgres.readWrite.roleATE_API_POSTGRES_READ_WRITE_ROLE--ateapi-postgres-read-write-rolesubstrate_readwrite(global)
ateapi.postgres.schemaATE_API_POSTGRES_SCHEMA--ateapi-postgres-schema—(global)
ateapi.postgres.serverCAFileATE_API_POSTGRES_SERVER_CA_FILE--ateapi-postgres-server-ca-file—(global)
ateapi.postgres.storageClassATE_API_POSTGRES_STORAGE_CLASS--ateapi-postgres-storage-class—(global)
atenet.dataplaneATE_ATENET_DATAPLANE--atenet-dataplaneenvoy(global)
atenet.egress.additionalExtprocServiceATE_ADDITIONAL_EGRESS_EXTPROC_SERVICE--experimental-additional-egress-extproc-service—(global)
atenet.egress.credentialProviderATE_CREDENTIAL_PROVIDER--credential-provider—(global)
clusterSizeATE_INSTALL_CLUSTER_SIZE--cluster-sizesize0(global)
contextKUBECTL_CONTEXT--context—(global)
cordonSystemInfraComponentsATE_INSTALL_CORDON_SYSTEM_INFRA_COMPONENTS--cordon-system-infra-componentsfalse(global)
csi.setupSETUP_CSI--setup-csinonedeploy ate-system
docker.buildFlagsDOCKER_BUILD_FLAGS--docker-build-flags—(global)
fakeDataPlane.enabledATE_INSTALL_FAKE_DATA_PLANE--fake-data-planefalse(global)
gcp.clusterLocationCLUSTER_LOCATION--gcp-cluster-location—(global)
gcp.clusterNameCLUSTER_NAME--gcp-cluster-name—(global)
gcp.projectIDPROJECT_ID--gcp-project-id—(global)
images.repoATE_IMAGE_REPO--image-repo—(global)
images.tagATE_IMAGE_TAG--image-tag—(global)
ingressAllowedSPIFFEIDsATE_INGRESS_ALLOWED_SPIFFE_IDS--ingress-allowed-spiffe-ids—(global)
kindCluster.enabledATE_INSTALL_KIND--kindfalse(global)
kindCluster.nameKIND_CLUSTER_NAME--kind-cluster-namekind(global)
ko.defaultPlatformsKO_DEFAULTPLATFORMS--ko-default-platforms—(global)
ko.dockerRepoKO_DOCKER_REPO--ko-docker-repo—(global)
kubeconfigKUBECONFIG--kubeconfig—(global)
namespaceATE_NAMESPACE--namespaceate-system(global)
otlpEndpointATE_OTLP_ENDPOINT--otlp-endpoint—(global)
podcert.workersPerSignerATE_INSTALL_PODCERT_WORKERS_PER_SIGNER--podcert-workers-per-signer0(global)
previewATE_PREVIEW--preview—(global)
publish.binariesDirATE_PUBLISH_BINARIES_DIR--outbin/releasepublish release-binaries
publish.overwriteATE_PUBLISH_OVERWRITE--overwritefalsepublish release-images
record.dirATE_RECORD_DIR--record-dir—(global)
rolloutTimeoutATE_INSTALL_ROLLOUT_TIMEOUT--rollout-timeout1m0s(global)
storage.bucketNameBUCKET_NAME--storage-bucket-name—(global)
storage.snapshotLocationSNAPSHOT_LOCATION--snapshot-location—(global)
storage.transferGSANameSNAPSHOT_TRANSFER_GSA_NAME--snapshot-transfer-gsa-name—(global)

Snapshot storage

ate-api-server stores every actor and tag snapshot under a cluster-wide --snapshot-location, an object-storage URI such as gs://BUCKET/PREFIX/. Set it with SNAPSHOT_LOCATION (or ate-setup’s --snapshot-location). It has no default, and ate-setup fails before deploying without it unless the PerTemplateSnapshotLocation preview gate is enabled. Even with the gate, the demos and benchmarks need it, since they do not set snapshotConfig.storageLocation. So do e2e runs, which deploy the demos. If you deploy ate-api-server some other way, pass the flag yourself; without the gate, the server refuses to start without it.

On GCS, ate-api-server’s credential exchange runs snapshot transfers as a dedicated Google service account, so GCS audit logs name that account. Set its name with SNAPSHOT_TRANSFER_GSA_NAME (or ate-setup’s --snapshot-transfer-gsa-name), and ate-setup builds its email in gcp.projectID. ate-setup refuses to deploy ate-api-server on GKE without one. The exchange impersonates the account, so ate-api-server’s identity needs roles/iam.serviceAccountTokenCreator on it, and the account needs object admin and bucket viewer on every snapshot bucket. go run ./tools/setup-gcp create iam creates it under that name, ate-snapshot-transfer by default, with both grants. kind does not use it.

With --kind, ate-setup defaults storage.snapshotLocation to gs://<storage.bucketName>/ (gs://ate-snapshots/, the bucket the in-cluster rustfs creates) and ignores an ambient SNAPSHOT_LOCATION. On GKE, ate-setup does not create the bucket or grant access to it: run go run ./tools/setup-gcp create bucket and go run ./tools/setup-gcp create iam first, or bootstrap for both (see tools/setup-gcp).

setup-gcp also creates an asset bucket, <BUCKET_NAME>-assets, where hack/install-microvm-deps.sh stages microVM sandbox assets, and gives atelet read access to it. atelet gets no access to the snapshot bucket: every snapshot transfer runs on a token scoped to its actor, issued by ate-api-server’s credential exchange. A GCP project set up before the asset bucket existed keeps atelet’s old grants, since setup-gcp only adds bindings. Remove them as tools/setup-gcp shows, which also covers recreating a cluster with microVM workers.

With the PerTemplateSnapshotLocation preview gate, an ActorTemplate may set snapshotConfig.storageLocation to store its snapshots elsewhere; templates that leave it empty use --snapshot-location. Grant ate-api-server’s identity and the snapshot transfer service account object admin and bucket viewer on any other bucket a template names, as setup-gcp does on the snapshot bucket. Without the gate, ate-api-server rejects templates that set snapshotConfig.storageLocation. See Snapshot Storage Layout.

Ingress to actors

Important

atenet-router lets a client reach actors only if the client’s SPIFFE ID is in ingressAllowedSPIFFEIDs (--ingress-allowed-spiffe-ids). The setting has no default, so an install that leaves it unset accepts no ingress traffic.

List every client that calls atenet-router. If all traffic reaches actors through one frontend or gateway, list that gateway’s identity. If several services call the router, list each of them. Any listed client may reach any actor.

Secrets

Credentials should come from the environment, not from a document that gets committed:

export ATE_API_POSTGRES_READ_WRITE_CONNECTION_STRING='postgres://...'
ate-setup deploy ate-system --config substrate-install.yaml

These settings have no flag. A command line is visible in ps to every user on the host and is written to shell history, so a credential is supplied through the environment or through a file you can permission.

The run report prints <set> for these rather than the value.

Was this page helpful?