astrolift.toml reference¶
Agent notes
The registered app's manifest is what the platform runs, not your working
copy: astro app manifest pull <app-slug> replaces the staged draft with the
repo's file, and astro ci render prints the Kubernetes resources the server
renders from it. The parser is backend/astrolift_manifest/parser.py in
astrolift-app; [package], [environment] and [secrets] on an agent
manifest are read by backend/astrolift_agents/services/agent_package.py.
Compatibility only: scope, tier, version and inject_as on a managed
service, web and worker as [app] kind, dockerfile on a job or task.
Frequent mistakes: an unknown key inside [[workloads]] or a container is
refused, so env goes under [workloads.containers.env], not on the
workload; a cronjob without schedule; [workloads.metrics] without
port; [app] slug or display_name missing, which astro app register
refuses before contacting the server.
astrolift.toml is used by more than one source workflow. Identify the file's
role before choosing fields:
| Role | Discovery path | Required shape |
|---|---|---|
| App | astrolift.toml or apps/<slug>/astrolift.toml |
top-level name; one or more non-agent [[workloads]] |
| Agent | astrolift.toml or agents/<slug>/astrolift.toml |
top-level name; exactly one kind = "agent" workload |
| Agent config/brief include | selected by an environment spec | [skills.*], [environment], [secrets], optional include |
Workflow definitions use workflows/**/*.toml, not astrolift.toml; see the
workflow reference. An agent manifest can additionally use
the Agent Package fields in Agent packages.
Minimal application¶
astrolift_version = 1
name = "orders-api"
[environments.production]
[app]
slug = "orders-api"
display_name = "Orders API"
[[workloads]]
name = "web"
kind = "deployment"
is_public = true
replicas = 2
cpu_request = "250m"
cpu_limit = "1"
memory_request = "256Mi"
memory_limit = "512Mi"
hpa_min = 2
hpa_max = 10
hpa_target_cpu_pct = 70
[[workloads.containers]]
name = "web"
is_primary = true
dockerfile_path = "Dockerfile"
build_context = "."
port = 8000
[workloads.containers.env]
LOG_LEVEL = "info"
[workloads.containers.healthcheck]
kind = "http"
value = "/health"
port = 8000
The top-level name is the server parser's required display name. The [app]
identity is read by the CLI registration flow; include both. Unknown keys
inside [[workloads]], [[workloads.containers]], [[managed_services]],
[edge], [ingress.access] and [brain] are refused at parse time with the
offending key path. An unknown top-level table is kept with the manifest but
affects nothing.
There is not yet a generated JSON Schema for this filename. App manifests and agent configuration/includes are two related parser families, and validation also depends on cross-field rules, source paths, chroot boundaries, provider capabilities, and organization policy. Treat target-server validation as authoritative until those parsers consume shared typed contracts that can emit separate app and agent schemas plus a dispatcher schema.
[environments.<name>] tables declare environment names that registration and
repo resync can bootstrap, as the empty production table above does. Manage
the environment's cluster binding and runtime configuration through the
control plane; arbitrary nested override keys are not a supported deployment
contract yet.
[app]¶
| Key | Read by | Meaning |
|---|---|---|
slug |
astro app register |
The app slug sent to registration; required by the CLI |
display_name |
astro app register |
The registered name; required by the CLI |
kind |
server | What the app is for, separate from how it runs. Omit it for a generic app. The one registered kind is mimwell, a brain-serving app; the older web and worker values are still accepted and mean a generic app |
expires_in |
both | Makes the app temporary: 30m, 4h, 7d, 6w or 3mo (30 days) from registration. The organization's temporary-app policy applies; see Temporary apps |
[brain]¶
slug names the hosted brain this app reads. The manifest only names it; the
grant that lets the app read the brain is made by someone who can grant it.
slug is the table's only key.
[env]¶
App-wide literals, materialized into a per-environment Kubernetes Secret the
app's containers read. Precedence from lowest to highest: [env], app secret
bundles, managed-service connection envelopes, then
[workloads.containers.env]. A document still carrying the masked placeholder
from a masked read is refused wherever it is applied, so a secret is never
replaced by the mask.
Workloads¶
Every [[workloads]] requires name and kind. Names must be unique across
workloads, [[jobs]], and [[tasks]].
Supported kinds are deployment, statefulset, job, cronjob, task,
agent, workflow, function, static_site, and faas.
Common fields:
| Field | Default | Meaning |
|---|---|---|
is_public |
false |
Create the public service/hostname path |
replicas |
1 |
Fixed replica count when HPA is not active |
cpu_request, cpu_limit |
platform defaults | Kubernetes quantities |
memory_request, memory_limit |
platform defaults | Kubernetes quantities |
hpa_min, hpa_max |
unset | HPA bounds |
hpa_target_cpu_pct |
80 |
HPA CPU target |
storage_class, storage_size |
unset | Legacy workload storage defaults |
gpu |
0 |
Whole GPUs (0 to 16) requested on the primary container, or MIG slices when mig_profile is set |
gpu_type |
unset | Pins the GPU product label, such as nvidia-l4; needs gpu >= 1 |
mig_profile |
unset | A MIG slice such as 3g.47gb; needs gpu >= 1 |
fs_group |
platform default | Pod fsGroup for a workload that mounts a PVC. Falls back to run_as_user from [workloads.security], then to the platform default. Emitted only when the workload mounts a PVC |
A cronjob additionally requires schedule and accepts
concurrency_policy = "forbid" | "queue" | "replace" (default forbid).
Metrics¶
A workload is not scraped unless it says so. Declaring the table is the
opt-in; port is required because a scrape target with no port would have
to be guessed.
[[workloads]]
name = "web"
kind = "deployment"
is_public = true
[workloads.metrics]
port = 9090
path = "/metrics" # default
# enabled = false # keep the config, stop the scrape
| Field | Default | Meaning |
|---|---|---|
port |
required | Container port serving the metrics endpoint |
path |
/metrics |
Path scraped on that port |
enabled |
true |
false keeps the table and turns the scrape off |
Rendering emits both scrape mechanisms, because a cluster may run either:
prometheus.io/scrape, prometheus.io/port and prometheus.io/path
annotations on the pod, and a PodMonitor scoped to the app's namespace for
a Prometheus Operator install. A private worker gets a PodMonitor with no
Service, which is the case annotation-only scraping handles badly.
What the request panels need¶
Traffic, errors and latency (the RED half of the golden signals) are read from one of two sources, and an app that matches neither leaves those three panels empty:
- Edge metrics. If the cluster's ingress controller is one Astrolift has a mapping for, the request signals come from the controller's own metrics, keyed by the app's namespace. Nothing is required of the app.
- Application metrics. Otherwise the queries read the Prometheus HTTP conventions from the app itself:
| Signal | Metric | Type |
|---|---|---|
| Traffic, errors, status codes | http_requests_total |
counter, with a code label |
| Latency quantiles | http_request_duration_seconds_bucket |
histogram |
Both must carry an app="<app-slug>" label and, to answer the
per-environment and per-workload views, environment and workload
labels matching those slugs. The standard Prometheus client library for
your language emits both metrics under these names; the labels are yours
to add.
Saturation (CPU and memory) is read from the cluster's own cAdvisor and kube-state-metrics series, so it populates whether or not the app is instrumented. When the request panels have no source at all, they say so rather than reporting an empty window.
Edge identity¶
Apps behind the platform's auth gate receive the authenticated identity as
X-Auth-Request-User / X-Auth-Request-Email headers, and a shared secret on
X-Astrolift-Gateway-Secret that proves the request came through the gate
rather than from anything else that can reach the Service port in-cluster.
An app whose backend reads those under names of its own declares the mapping:
[edge]
gateway_secret_header = "x-qsr-proxy-secret"
[edge.identity_headers]
user = "x-qsr-user-id"
email = "x-qsr-email"
| Key | Meaning |
|---|---|
gateway_secret_header |
Header the gate's shared secret is stamped on for this app. The value is never declared here; it comes from the cluster's auth config |
identity_headers |
identity = header-name pairs. Valid identities are user, email and access_token, the ones the gate forwards |
The block is app-level, not per workload: every managed hostname for an app shares one Ingress.
Header names must be valid HTTP header names; the value is written into the ingress controller's configuration, so anything else is refused at parse time. An identity the gate does not forward is refused too; a mapping for a header that never arrives would render configuration that silently forwards nothing.
This is deliberately a mapping rather than a raw configuration snippet. A
snippet ties the app to one ingress controller's config language and hands it a
sharp edge, and ingress-nginx has been narrowing allow-snippet-annotations.
If your app needs something the mapping cannot express, that case is worth
filing rather than working around.
Ingress and access¶
[ingress]
class = "nginx"
[ingress.auth]
kind = "oidc"
auth_proxy_host = "auth.apps.example.com"
discovery_url = "https://login.example.com/.well-known/openid-configuration"
client_id = "astrolift-edge"
[ingress.access]
groups = ["clinical-ops"]
users = ["oncall@example.com"]
[ingress] and [ingress.auth]¶
The login gate is a cluster setting, not an app setting. A cluster on the ALB ingress class gates with Cognito; other classes gate with the cluster's central OIDC proxy. Every managed hostname on a gated cluster sits behind the gate.
[ingress] and [ingress.auth] are the platform's record of that gate in the
app's repo. When an operator changes a cluster's gate, Astrolift AI writes the
matching table into the manifest of every app on that cluster, as a commit on
the deploy branch, or as a pull request when the branch is protected. The
renderer reads the cluster's configuration, not this table.
kind |
Fields |
|---|---|
cognito |
user_pool_arn, user_pool_client_id, user_pool_domain |
oidc |
auth_proxy_host, discovery_url, client_id, optional logout_url |
[ingress] carries class, the cluster's ingress class. The OIDC cookie secret
is never written to the manifest.
Editing or removing [ingress.auth] does not change the gate. An app whose
manifest has no [ingress.auth] is still gated while its cluster is, and the
platform writes the table back the next time the cluster's gate changes. To
take an app off the gate, change the cluster, not the manifest.
[ingress.access]¶
[ingress.access] narrows who may enter once signed in. groups are identity
provider groups, users are email addresses. A signed-in user outside both
lists gets a no-access page, never the app. Empty lists restrict nobody.
The rule is enforced on clusters using the Envoy edge. On any other edge the
app is gated by login only, and astro app access show says the rule is not
enforced yet.
astro app access manages the same rule from the CLI. my-app is the app
slug from astro app list:
astro app access show my-app
astro app access allow my-app --group clinical-ops
astro app access deny my-app --user former@example.com
astro app access clear my-app
show prints my-app: open to every signed-in user of the cluster's central
auth. for an open app, or my-app: only these may enter. followed by the
groups: and users: lines. allow and deny print how many users would
lose access and wait for y; when the manifest declares [ingress.access]
they are refused, and the fix is to edit the manifest and sync it.
| Manifest state | Effect |
|---|---|
[ingress.access] declared |
The manifest's lists replace the rule on every sync. show reports it as set by the manifest; allow, deny and clear are refused |
| Removed from a manifest that declared it | The rule is cleared on the next sync: every signed-in user may enter. astro app access can set a rule again |
| Never declared | A rule set with astro app access or the dashboard is left alone |
allow on an open app restricts it to what you name. allow and deny
preview who would lose access and ask before saving; pass --yes to skip the
question.
[ingress] max_body_size¶
The largest request body the edge passes to the app.
The value is a byte count with an optional k, m or g suffix, the way
nginx writes a size, or "0" for no limit. Without the key there is no limit.
A value nginx cannot read, such as "100mb" or "1.5m", is refused when the
manifest is parsed. The key is the app's own: when the platform writes
[ingress.auth] back into the manifest, it leaves max_body_size in place.
A change takes effect on the app's next deploy.
On an ingress-nginx edge the value becomes the app Ingress's
nginx.ingress.kubernetes.io/proxy-body-size, and a larger body gets a 413
from the edge. Whatever the limit, the body streams to the app as it arrives
instead of being stored at the edge first (proxy-request-buffering: "off"),
and the edge allows the app an hour between reads or writes on its connection
(proxy-read-timeout, proxy-send-timeout) instead of nginx's 60 seconds, so
a slow upload or a slow response is not cut off.
The Envoy edge and the ALB set no body limit, and the key has no effect there.
Containers¶
[[workloads.containers]] supports name, is_primary, image_ref,
dockerfile_path (default Dockerfile), build_context (default .), port,
command, args, and [workloads.containers.env].
Health checks are structured:
[workloads.containers.healthcheck]
kind = "http" # none | http | tcp | exec
value = "/health"
port = 8080
When no container is explicitly primary, normalization selects the first. An
explicit primary is clearer. A portless worker does not receive a fabricated
HTTP probe; a container with a port and no healthcheck gets the platform's
default HTTP probe on /health.
dockerfile_path and build_context must be repo-relative; an absolute path
is refused. Only one image is built per app, so two containers declaring
different non-default values for either field are refused at parse time.
Jobs and tasks shorthand¶
[[jobs]]
name = "nightly-report"
schedule = "0 3 * * *"
concurrency_policy = "forbid"
dockerfile_path = "Dockerfile.jobs"
command = ["python", "-m", "jobs.report"]
[jobs.env]
REPORT_MODE = "full"
[[tasks]]
name = "reindex"
dockerfile_path = "Dockerfile.jobs"
command = ["python", "-m", "jobs.reindex"]
[[jobs]] becomes a single-container cronjob. [[tasks]] becomes a
single-container one-shot Job and has no schedule. Both accept name,
image_ref, dockerfile_path (or its alias dockerfile), build_context,
command, args, env, and the cpu_* and memory_* quantities; a job also
requires schedule and accepts concurrency_policy. Neither exposes a port
or carries a probe.
Volumes¶
[[workloads.volumes]]
name = "data"
kind = "pvc" # pvc | empty_dir | config_map | secret
mount_path = "/data"
size = "20Gi"
storage_class = ""
access_mode = "ReadWriteOnce"
performance_tier = "balanced"
durability = "zonal"
PVC volumes require size. config_map and secret require source_name.
empty_dir may set size_limit. Paths must be absolute mount paths.
performance_tier is standard, balanced (default), high_iops or
extreme; durability is local, zonal (default) or regional.
Managed services¶
[[managed_services]]
kind = "postgres"
name = "primary"
variant = "rds"
[managed_services.config]
version = "17"
size = "small"
Choose a non-empty variant advertised by the target cluster catalogue; rds
is an example and requires a configured AWS provider.
Declaring a service provisions it. When the manifest is applied (at
astro app register, and on every later sync from the repo), each entry is
reconciled into a managed service and a provisioning run starts. The parser
itself provisions nothing; the reconcile that follows it does, once the app has
an environment bound to a cluster. Provisioning is asynchronous, so a
registration can return before the database exists.
| Field | Default | Meaning |
|---|---|---|
kind |
required | Service kind from the cluster catalogue, such as postgres or redis |
name |
the kind | Name, unique per kind and environment |
variant |
catalogue default | Provider variant, such as rds |
owner_scope |
app |
app: the app owns the service. project: a shared project resource the app attaches to |
environment |
production |
App environment the service belongs to. Registration creates it; a later sync refuses an environment that does not exist |
bind_workloads |
["*"] |
Workloads that receive the binding |
size |
small |
small, medium, large, xlarge or custom (tier is an accepted alias) |
config |
{} |
Provider configuration table, such as version. The top-level version and inject_as keys are accepted and folded into it |
isolation |
catalogue default | shared or dedicated |
auth_mode |
catalogue default | password, iam or mtls |
extensions |
{} |
A string array of extensions to enable, or a provider-extension table |
networking, retention, backup |
{} |
Provider option tables |
restore |
{} |
Restores from a snapshot; requires both snapshot_id and source_handle |
deletion_policy |
retain |
delete also requires confirm_delete = true |
scope is an accepted alias for owner_scope. Any of these tables may carry
secret references; a key named like a secret value (password, token,
api_key, connection_string, and so on) is refused unless its name ends in
_ref, _name, _id or _path. Anything else is refused as unknown;
provider-specific options belong in extensions.
With owner_scope = "app" the service belongs to this app and environment, and
appears under astro app services. Removing the entry from the manifest
deprovisions it; provider data is deleted only under deletion_policy =
"delete". The manifest cannot take over a service of the same kind and name
that was created outside it; the sync fails instead.
With owner_scope = "project" the entry creates the project resource if the
project has none of that kind and name, then attaches this app environment to
it. An existing project resource must match the entry's variant and
configuration; an app manifest cannot change a shared resource. Creating or
attaching a project resource this way needs project.update on the project,
the same permission astro project resources attach needs. Without it, a
--manifest-raw registration is refused, and other registrations and syncs
leave those changes out. Removing the entry detaches the app environment; the
project resource stays. See Shared services for
the CLI path.
Bound services reach workloads through stable environment envelopes such as
DATABASE_URL and REDIS_URL. Do not put credentials in
[workloads.containers.env]; use secret bundles, managed-service bindings, or
agent secret references.
Kind-specific fields¶
Agent workload fields are run_family = "task" | "service", max_retries
(default 5), tool_timeout_seconds (300), and result_ttl_hours (72).
run_mode declares how the agent is triggered: once, loop, schedule,
trigger or persistent; schedule requires run_cron_expression, which is
validated as a cron expression at parse time. Leaving run_mode out keeps
whatever the registration or an operator set. brains lists the hosted brains
(by slug, at most 10) every run reads; brains_allowed lists the brains a
launch may add (at most 100). Without brains_allowed a launch may add any
brain the launcher can read; an empty list allows none. All of these are
refused on a non-agent workload.
A workflow-worker workload requires workflow_type and task_queue; it also
accepts temporal_namespace, max_concurrent_activities, and
max_concurrent_workflows.
A Knative function accepts min_scale, max_scale, concurrency, and
timeout_seconds.
A static_site has no containers. With platform build mode, set both
static_build_command and static_output_dir; optional fields are
static_spa and static_index.
A faas has no containers. faas_package_type = "image" must not set runtime
or handler. "zip" requires faas_runtime, faas_handler, and
faas_output_dir. Other fields include faas_memory_mb,
faas_timeout_seconds, faas_architecture, faas_public, and
faas_build_command.
Monorepos¶
Astrolift recognizes only the bounded declarative layouts:
Agent package [package] rules create a separate chrooted source slice. Use
explicit --manifest-path selections to register only part of a large repo.
The image build context is an app setting, and its default depends on how the
app was registered. Whole-repo discovery sets it to the manifest's directory.
astro app register --manifest-path apps/web/astrolift.toml leaves it at the
repository root unless you also pass --build-context apps/web. A container's
own build_context and dockerfile_path are read relative to the manifest's
directory, and apply only while the app-level value is still the default. See
Build a monorepo app in CI.
Validate and render¶
Registration and sync are covered in Set up an application and the dashboard's manifest page. To see what the server renders from the registered manifest, from CI or a shell:
export ASTROLIFT_API_URL="https://<your-control-plane>" # the URL astro status prints
export ASTROLIFT_DEPLOY_TOKEN="<deploy token>" # from the app's Tokens page
export ASTROLIFT_APP_SLUG="orders-api" # the app slug
astro ci render
Success prints the rendered Kubernetes resources as JSON and exits 0. Exit 2
with ASTROLIFT_API_URL, ASTROLIFT_DEPLOY_TOKEN, ASTROLIFT_APP_SLUG named is a
missing variable. Exit 1 with manifest render error at <key path>: ... names
the manifest key to fix; app "orders-api" not found or has no manifest to
render means the slug is wrong or the app was registered without a manifest.
The target control plane is authoritative because provider capabilities and organization defaults participate in normalization and rendering. TOML syntax validation alone cannot prove a deployment is portable to a selected cluster.