# Atlantide > Typed, deterministic Infrastructure-as-Code for Python. Atlantide is a typed, deterministic Infrastructure-as-Code engine for Python. Configs are ordinary Python files (`infra.py`) executed by Atlas-lang, a bounded interpreter with no clock, randomness, environment, network or filesystem, so the same config always produces the same plan. Resources form a Merkle-hashed dependency graph; apply skips any node whose hash is unchanged without calling the provider. # Atlantide **Typed, deterministic Infrastructure-as-Code for Python.** Infrastructure is written in Python and checked by your IDE and `mypy`. The same config always produces the same plan, and the engine verifies this. **uv** ```bash uv tool install atlantide ``` **pipx** ```bash pipx install atlantide ``` **pip** ```bash pip install atlantide ``` **Binary (no Python)** ```bash # Linux x86-64; macOS and Windows builds are on the Installation page curl -L -o atlantide \ https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-linux-x86_64 chmod +x atlantide && sudo mv atlantide /usr/local/bin/ ``` The package requires Python 3.12 or newer. The standalone binary bundles its own runtime but cannot load third-party provider plugins. See [Installation](https://atlantide-org.github.io/install.md) to choose between them. > **Using a coding agent?** > > Point it at [`llms.txt`](https://atlantide-org.github.io/llms.txt), or add > the [agent guide](https://atlantide-org.github.io/agents.md) to your project so it writes valid Atlas-lang > and knows which commands are safe to run. ## Quickstart `atlantide init` scaffolds a working project. The default `minimal` template uses the `local` provider and needs no cloud credentials: ```bash mkdir demo && cd demo atlantide init ``` ``` created demo/.gitignore created demo/atlantide.toml created demo/infra.py ok infra.py — 1 resource(s), no cycles next: atlantide plan ``` ```bash atlantide apply -y # create it atlantide plan # again: unchanged ``` ``` + create local.File:greeting Plan: 1 to add ... Applied: 1 to add (0.0s) ``` ``` = noop local.File:greeting Plan: 1 unchanged ``` The second `plan` compares recorded hashes with freshly computed ones and calls no provider. `atlantide init --template aws` scaffolds the same project for AWS. ## A quick look ```python from atlantide.core import Config, EnvSchema, Stack, output from atlantide.policy import enforce from atlantide.providers.aws import S3Bucket, SqsQueue enforce("require-tags", keys=["env"]) enforce("deny-destroy-in-protected", stacks=["prod"]) class AppEnv(EnvSchema): versioning: bool = False config = Config( AppEnv, envs={ "dev": {"region": "eu-north-1", "tags": {"env": "dev"}}, "prod": {"region": "eu-north-1", "tags": {"env": "prod"}, "versioning": True}, }, ) for env in config.envs(): # env: AppEnv with Stack(env.name, config=env, name_prefix="atlantide"): assets = S3Bucket("assets", versioning=env.versioning) jobs = SqsQueue("jobs", fifo=True) output("assets_arn", assets.arn) ``` ```bash atlantide plan infra.py # preview, every environment atlantide apply infra.py # reconcile, in parallel atlantide apply infra.py # again: all NOOP, zero provider calls atlantide apply infra.py --env prod # prod only; dev is not diffed or touched ``` One [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments) holds every environment and what differs between them. Each variable declares a type and an optional default, so a missing or mistyped prod value fails `atlantide validate`, not the prod apply. `Stack` reads the well-known keys `region`, `tags` and `name_prefix` directly. Declaring the shape as an `EnvSchema` makes `env.versioning` complete in an editor and makes `env.versionning` a type error. Reading another resource's output, such as `assets.arn`, returns a lazy `Ref`. The read creates the dependency edge, so no `depends_on` declarations or string addresses are needed. The `Ref` resolves to the real value at apply time. ## Three ideas **Enforced determinism.** Configs are Python, executed by *Atlas-lang*: a bounded interpreter with no clock, randomness, environment or network. Two runs of the same config produce a byte-identical intermediate representation and the same content hash. The interpreter enforces this; it does not rely on author convention. **Graph state with Merkle skip.** Resources form a dependency graph, and each node carries a hash of its own inputs combined with its dependencies' hashes. Apply skips any node whose hash is unchanged without calling a provider, and reconciles independent nodes in parallel. **Per-field mutability.** Every field is declared `mutable()`, `immutable()` or `computed()`. A mutable change is an in-place update, an immutable change is a replacement, and computed fields never contribute to a diff. The cost of a change is visible in the type before anything runs. ## The pipeline Every `plan` and `apply` runs one pipeline. Everything before the diff is pure and deterministic; everything after it performs I/O. ```mermaid flowchart TB subgraph pure["Deterministic — no I/O; same input, same bytes"] direction LR cfg["infra.py"] --> lang["Atlas-lang
subset check + fuel-bounded eval"] lang --> ir["IR + canonical JSON (RFC 8785)
stable content hash"] ir --> dag["Dependency graph
Refs become edges, cycles rejected"] dag --> merkle["Two-phase Merkle
input_hash per node"] end merkle --> diff{"Diff"} store[("State backend
sqlite · s3+dynamodb · postgres")] -.->|prior hashes| diff diff --> plan["Plan
ordered, policy-checked"] plan -->|plan| changeset["Changeset
NOOP · CREATE · UPDATE · REPLACE · DELETE"] plan -->|apply| exec["Executor
parallel, under a renewed lease"] exec -->|unchanged hash| skip["Skipped
no provider call"] exec <-->|create · read · update · delete| prov["Providers
aws · local · random · yours"] exec -->|fenced writes| store ``` Because the first half performs no I/O, `plan` needs no credentials, `validate` can run in a pre-commit hook, and one compiled artifact can be promoted from staging to production without re-executing the config. ## Where to start - **[Installation](https://atlantide-org.github.io/install.md)** --- The standalone binary or the PyPI package, and how to choose. - **[Coming from Terraform](https://atlantide-org.github.io/comparison.md)** --- The vocabulary mapping, key differences, and current limitations. - **[How it works](https://atlantide-org.github.io/how-it-works/index.md)** --- The mental model, then a hands-on walkthrough that needs no AWS account. - **[Building on AWS](https://atlantide-org.github.io/aws/index.md)** --- A multi-environment stack one resource at a time: VPC, S3, SQS, IAM, Lambda, secrets and policies. - **[CLI](https://atlantide-org.github.io/reference/cli.md)** --- Every command, targeting, `--env`, exit codes. - **[For agents](https://atlantide-org.github.io/agents.md)** --- `llms.txt`, Markdown for every page, and a skill for coding agents. **Reference:** [Authoring](https://atlantide-org.github.io/reference/authoring.md) · [Configuration](https://atlantide-org.github.io/reference/configuration.md) · [Policies](https://atlantide-org.github.io/reference/policies.md) · [Remote state](https://atlantide-org.github.io/reference/remote-state.md) · [Providers](https://atlantide-org.github.io/reference/providers.md) · [Components](https://atlantide-org.github.io/reference/components.md) · [CI and automation](https://atlantide-org.github.io/reference/ci.md) · [Troubleshooting](https://atlantide-org.github.io/reference/troubleshooting.md) · [Glossary](https://atlantide-org.github.io/glossary.md) Source: [github.com/atlantide-org/atlantide](https://github.com/atlantide-org/atlantide) # Installation Atlantide is distributed as a standalone binary that bundles its own Python runtime, and as a package on PyPI. | | Standalone binary | PyPI package | | --- | --- | --- | | Python needed | no | 3.12 or newer | | Providers available | the three built-in ones only | built-in **and** third-party plugins | | Use it for | CI images, machines without Python, quick trials | anything using a provider plugin, or embedding the engine | See [provider plugins](#provider-plugins-and-the-binary) for the provider difference. --- ## Standalone binary Each release publishes binaries for three platforms on the [releases page](https://github.com/atlantide-org/atlantide/releases). Each is a single executable of about 30 MB with no external dependencies. **Linux (x86-64)** ```bash curl -L -o atlantide \ https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-linux-x86_64 chmod +x atlantide sudo mv atlantide /usr/local/bin/ ``` The binary is built on the current Ubuntu runner image and links against its glibc. On older distributions, install from PyPI. **macOS (Apple silicon)** ```bash curl -L -o atlantide \ https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-macos-arm64 chmod +x atlantide xattr -d com.apple.quarantine atlantide sudo mv atlantide /usr/local/bin/ ``` > **macOS blocks the binary until the quarantine flag is cleared** > > The published binaries are not code-signed or notarised. macOS adds a > quarantine attribute to downloaded files, and Gatekeeper refuses to run an > unsigned executable that carries it, reporting that the developer cannot be > verified. > > The `xattr -d com.apple.quarantine` line above removes the attribute. > Alternatively, open the file once from Finder with right-click → Open and > confirm the prompt. **Windows (x86-64)** ```powershell curl.exe -L -o atlantide.exe ` https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-windows-x86_64.exe ``` Move `atlantide.exe` to a directory on your `PATH`. ### Confirm it runs ```bash atlantide --version ``` ``` atlantide 0.x.y ``` ### Provider plugins and the binary Providers are discovered through the `atlantide.providers` entry-point group, so they must be installed alongside atlantide as Python distributions. The standalone binary has no site-packages, advertises no entry points, and uses only the providers compiled into it: ```console $ atlantide providers 3 provider(s) ┏━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ name ┃ types ┃ module ┃ summary ┃ ┡━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ aws │ 25 │ atlantide.providers.aws │ AWS resources over boto3. │ │ local │ 3 │ atlantide.providers.local │ Files and no-ops on the local │ │ │ │ │ machine; needs no credentials. │ │ random │ 4 │ atlantide.providers.random │ Values generated once at apply │ │ │ │ │ and pinned in state. │ └────────┴───────┴────────────────────────────┴────────────────────────────────┘ ``` If a config imports a third-party provider, install from PyPI. With the binary, the failure appears as config being unable to find the provider's types, not as a missing plugin. --- ## From PyPI Requires Python 3.12 or newer. ```bash uv tool install atlantide # standalone CLI, isolated environment pipx install atlantide # the same, via pipx uv add atlantide # into a project pip install atlantide # into the current environment ``` For the CLI alone, use `uv tool install` or `pipx`; both install atlantide and its dependencies in an isolated environment. Use `uv add` or `pip install` when embedding the engine as a library, or when a config imports a provider plugin. A plugin must resolve in the same environment as atlantide. ### Extras The Postgres state backend requires an extra, which installs a database driver: ```bash pip install 'atlantide[postgres]' ``` The sqlite and S3 backends need nothing beyond the base install. --- ## Building the binary yourself Build the binary yourself for a platform with no published asset, or to pin it to a specific commit: ```bash git clone https://github.com/atlantide-org/atlantide cd atlantide make binary # uv run --extra build pyinstaller atlantide.spec --clean --noconfirm ``` The executable is written to `dist/atlantide`. It bundles whatever is installed in the build environment, so this is also how to produce a binary that includes a provider plugin or the Postgres driver. --- ## Verifying an installation ```bash atlantide --version # the version, and that the executable runs at all atlantide providers # which providers this installation can see ``` `atlantide providers` also lists any plugin that failed to load. Otherwise, a failed plugin is only visible as config being unable to find its types. To confirm an end-to-end setup without credentials or an AWS account, follow the [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md). It uses the `local` provider and creates only files on disk. --- ## Upgrading and removing ```bash uv tool upgrade atlantide && uv tool uninstall atlantide pipx upgrade atlantide && pipx uninstall atlantide pip install --upgrade atlantide && pip uninstall atlantide ``` To upgrade a standalone binary, download the new release over the old file and check it with `atlantide --version`. Upgrading does not modify state. When a newer atlantide opens a store, it migrates it forward automatically; an older atlantide refuses to read a newer store. Upgrade every machine that shares a backend at the same time. See [Managing state](https://atlantide-org.github.io/reference/remote-state.md#managing-state). --- ## Scaffolding a project `atlantide init` writes `atlantide.toml`, a starter config and a `.gitignore`, then compiles the result before reporting success: ```bash atlantide init # `minimal` template, local state atlantide init --template aws # an AWS starter instead atlantide init --state s3 --bucket acme-atlantide-state \ --key prod/atlantide.json --lock-table atlantide-locks ``` | Option | Values | | --- | --- | | `--template` | `minimal` or `aws` | | `--state` | `local`, `s3` or `postgres`; writes the matching `[state]` table | | `--bucket`, `--key`, `--lock-table` | s3 state settings | | `--dsn`, `--schema` | postgres state settings | | `--secrets` | `keyfile`, `env` or `ssm` | | `--force` | allow overwriting existing files or nesting inside another project; refused otherwise | The `minimal` template needs no credentials; see the [quickstart](https://atlantide-org.github.io/index.md#quickstart). --- ## Next steps - **[How it works](https://atlantide-org.github.io/how-it-works/index.md)** — the mental model, then a walkthrough that needs no AWS account. - **[Building on AWS](https://atlantide-org.github.io/aws/index.md)** — a multi-environment stack built one resource at a time. - **[CLI](https://atlantide-org.github.io/reference/cli.md)** — every command and flag. # Coming from Terraform, Pulumi or CDK Like these tools, Atlantide declares infrastructure, diffs it against recorded state, and reconciles the difference. It differs in how determinism is enforced and in how much of the engine is exposed. ## Vocabulary | You know | Here it is called | Notes | |---|---|---| | `terraform plan` / `apply` | `atlantide plan` / `apply` | `plan` needs no credentials. | | HCL / TypeScript program | Atlas-lang config (`infra.py`) | Real Python, executed by a bounded interpreter. | | Provider | Provider | Python packages, discovered by entry point. | | Module / `ComponentResource` / Construct | [Component](https://atlantide-org.github.io/reference/components.md) | Python; config uses them but cannot define them. | | `terraform init` | [`atlantide component add`](https://atlantide-org.github.io/reference/components.md) | Pins to a commit + content hash. | | Backend / remote state | [State backend](https://atlantide-org.github.io/reference/remote-state.md) | SQLite, S3+DynamoDB, or Postgres. | | `terraform import` | [`atlantide import`](https://atlantide-org.github.io/reference/cli.md#adopting-existing-resources) | Refuses to import a resource that differs from config. | | `terraform refresh` | [`atlantide refresh`](https://atlantide-org.github.io/reference/cli.md#detecting-drift) | Read-only unless `--write`. | | `depends_on` | `depends_on` | Rarely needed; reading a field creates the edge. | | `lifecycle { prevent_destroy }` | `Lifecycle(prevent_destroy=True)` | Plus `create_before_destroy`, `ignore_changes`, `aliases`. | | `moved` block | `Lifecycle(aliases=(...))` | Rename without replace. | | `terraform output` | `atlantide output` | Sensitive values need `--reveal`. | | Workspace | [Stack](https://atlantide-org.github.io/reference/authoring.md#stacks) + [`Config` environment](https://atlantide-org.github.io/reference/configuration.md#environments) | Both in-config: one `Config` declares the environment matrix, `--env` selects. | | `terraform workspace select` | [`--env`](https://atlantide-org.github.io/reference/cli.md#narrowing-a-run) | An unselected environment is out of scope, never planned for deletion. | | `*.tfvars` | [Inputs](https://atlantide-org.github.io/reference/configuration.md#inputs) + [profiles](https://atlantide-org.github.io/reference/configuration.md#profiles) | Inputs are per-run values; profiles point a run at a state backend/account. | | Sentinel / OPA | [Policies](https://atlantide-org.github.io/reference/policies.md) | Native Python, evaluated at plan time. | | Terraform Registry | No central registry | Components are git URLs, pinned by commit. | ## Key differences **A real language, but a bounded one.** Pulumi and CDK use a full programming language, so a config's plan can depend on the clock, the environment or a network call. Terraform avoids this by not being a programming language. Atlantide configs are Python (typed and `mypy`-checkable) but run under [Atlas-lang](https://atlantide-org.github.io/reference/authoring.md#what-config-may-not-do), an interpreter with no clock, randomness, environment, network or filesystem. The interpreter enforces determinism. **The diff is a Merkle comparison, not a per-resource read.** Each node's hash folds in its dependencies' hashes, so an unchanged subgraph is skipped without any provider call. See [The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md). **`plan` needs no credentials.** Everything up to the diff is pure computation over the config and the recorded state. `validate` can run in a pre-commit hook, and `plan` can run on a pull request with read access to state and no provider credentials. **Per-field mutability is in the type.** Every field is declared `mutable()`, `immutable()` or `computed()`, so whether a change is an in-place update or a replacement is known from the type, before any plan runs. **Build once, deploy anywhere.** `atlantide build` compiles a config into a content-hashed `.atlas` artifact with provider versions pinned. `deploy` applies the artifact without re-executing the config, so staging and production receive identical bytes. ## Limitations - **Limited provider coverage.** Three built-in providers and about two dozen AWS resource types, compared with thousands in Terraform. `atlantide resources` lists what exists. The plugin interface is open; there is no third-party provider ecosystem. - **No central registry.** Components are git URLs you pin yourself. - **No HCL interoperability.** Atlantide cannot read Terraform configuration or state. Existing resources are adopted one at a time with `atlantide import`. - **Versioned state format.** The format can change between minor releases. It migrates forward automatically, and an older atlantide refuses a newer store. - **Beta.** The package is classified as beta. ## Migration There is no converter. Migration proceeds in slices: 1. Write the config for a slice of existing infrastructure. 2. `atlantide validate`, then `atlantide plan` — everything will show as `CREATE`, because state knows nothing yet. 3. `atlantide import` each resource. A resource whose live values differ from config is refused, not adopted, so this step also checks that the config matches what is deployed. `--dry-run` previews the import without changing state. 4. `atlantide plan` again. The slice should now be entirely `NOOP`. # CLI ```bash atlantide init # scaffold a project atlantide plan infra.py # preview changes atlantide apply infra.py # reconcile (parallel) atlantide refresh # detect drift vs live state atlantide graph infra.py --format mermaid # dependency graph atlantide destroy # tear down ``` ## Starting a project `atlantide init [DIRECTORY]` writes `atlantide.toml`, a starter config and a `.gitignore`, then compiles the config before reporting success. `--template` takes `minimal` (default; `local` provider, no credentials) or `aws`. `--state local|s3|postgres` writes the matching `[state]` table. See [Installation](https://atlantide-org.github.io/install.md#scaffolding-a-project) for all flags. ## Adopting existing resources `atlantide import` records an existing resource in state, so the next `plan` reports it unchanged instead of proposing to create it. It creates, updates and deletes nothing. ```bash atlantide import # list what is importable, and how atlantide import aws.S3Bucket:assets # located by name atlantide import aws.Vpc:main vpc-0abc123 # located by a provider id atlantide import aws.Vpc:main vpc-0abc123 --dry-run ``` Declare the resource in config first, then pass its node id. Some types are located by name; others need a provider-assigned id that config cannot know, such as a VPC's `vpc_id` or a certificate's `arn`. `import` with no arguments lists which types need which. **A resource whose live values differ from config is not imported.** Reconcile the config, or pass `--allow-drift` to import it anyway; the next plan then shows the update. `-v` names the fields the provider's read did not cover. To undo an import, run `atlantide state rm `. It removes the state row and leaves the resource in place. ## Detecting drift `atlantide refresh` reads live provider state and reports how it differs from recorded state. It is read-only unless `--write` is given, which syncs drifted outputs back to state. A resource the provider cannot find is reported but **kept** in state unless `--prune` is also given. Removing it on a single failed read would cause the next apply to create a second copy. Drift is detected only in fields the provider's `read` reports. The report states how many of each resource's inputs were covered; `-v` names the rest. ## Common options State defaults to `atlantide.db`. Override it with `--state`. The mutating commands (`apply`, `deploy`, `destroy`, `refresh`) accept `--confirm/-y`, `--region` and `--parallelism/-p`. Commands that write state also accept `--on-failure rollback|halt`. The default, `rollback`, undoes the nodes a failed run already completed. ## Narrowing a run `--target/-t` accepts a full node id, a short form, or a glob: ```bash atlantide apply -t aws.S3Bucket:assets # and its dependencies atlantide destroy -t aws.Vpc:main # and its dependents atlantide apply --replace aws.LambdaFunction:worker ``` `apply` includes the target's dependencies; `destroy` includes its dependents. A pattern that matches nothing is an error, not an empty run. `--replace` still honours `prevent_destroy`. `--env/-e` narrows a run to one environment of the config's [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments). It is repeatable: ```bash atlantide plan --env prod # prod only atlantide apply --env dev --env staging ``` An unselected environment is **out of scope, not undeclared**: its recorded state is not diffed and is never planned for deletion. The plan lists the environments it excluded: ``` envs: prod (of dev, prod) — dev is not planned and will not change ``` Naming an environment the config does not declare is an error. Stacks declared outside the `config.envs()` loop, such as a shared `common`, are not part of the matrix and stay in the graph. `plan`, `apply`, `validate` and `build` accept `--env`. `build` records the selection in the artifact, so a later `deploy` covers the same environments. `destroy` also accepts it but selects by stack, because it reads state only, not config, and an environment's stack is its name. `--target` and `--env` can be combined. A target that matches only excluded environments is an error. ## Interrupting a run Ctrl-C cancels the run and follows the failure path. Under the default `--on-failure rollback`, resources already created are undone, then the process exits with code 130. A second Ctrl-C abandons the cleanup. State may then not match what exists; run `atlantide refresh` before applying again. The second press does **not** release the run's lock, because the abandoned run may still have provider calls in flight. The lease expires on its own, or can be cleared with `atlantide state unlock`. ## Output and diagnostics `--log-level debug|info` traces a run to stderr; `--log-format json` makes the stream machine-readable. `--audit-log FILE` appends every run's events to a JSONL file. These are global options and go before the command: ```bash atlantide --log-level debug --log-format json plan atlantide --debug apply # full traceback on error ``` `--json` is a per-command option that replaces the rendered output on stdout. See [CI and automation](https://atlantide-org.github.io/reference/ci.md#machine-readable-output). Every command that touches state first prints which state it uses: ``` state: s3://acme-atlantide-state/prod/atlantide.json ``` Under `--json` the same value appears as the payload's `state` field. ## Exit codes | Code | Meaning | |---|---| | `0` | Success. With `--detailed-exitcode`: nothing to do — no pending changes, no drift. | | `1` | An error, **or** a mandatory policy denied the plan, **or** (`providers`) a plugin failed to load. | | `2` | Only with `--detailed-exitcode`: `plan` has changes pending, or `refresh` found drift. | | `130` | The run was interrupted (Ctrl-C). | Without `--detailed-exitcode`, `plan` exits `0` whether or not changes are pending. Use it to gate CI: ```bash atlantide plan --detailed-exitcode || case $? in 2) echo "changes pending" ;; *) exit 1 ;; esac ``` A denied plan exits `1` with or without `--detailed-exitcode`. ## Other commands **`build` / `verify` / `deploy`**: produce, check and apply portable `.atlas` artifacts. An artifact is content-hashed with provider versions pinned, so a config compiled once can be promoted between environments as identical bytes. **`validate`**: check that a config compiles, stays within the Atlas-lang subset and has an acyclic graph. It touches no state and calls no provider, so it needs no credentials. Use it in pre-commit hooks and pull-request checks, where `plan` would need access to a state backend. **`output`**: print the values a previous apply exported. It reads only state, so it works while the config is being edited: ```bash vpc=$(atlantide output vpc_id) ``` Sensitive values require `--reveal`. **`state`**: inspect and administer the state store. See [Remote state](https://atlantide-org.github.io/reference/remote-state.md). **`secret`**: manage the local AES-GCM encrypted name-to-value store: ```bash atlantide secret set app/signing-key # value prompted (hidden) if omitted atlantide secret get app/signing-key -r # print plaintext (--reveal required) atlantide secret list # names only, no values atlantide secret rm app/signing-key ``` **`component`**: `add`, `lock`, `vendor` and `verify` for published components. **`providers`**: list the provider plugins this installation can see, including any that failed to load. Without this, a failed plugin shows up only as config being unable to find its types. **`resources` / `schema`**: list the available resource types, and show one type's fields and their mutability. # Configuration Atlantide has three kinds of configuration: | | What it is | Where it lives | Selected by | |---|---|---|---| | [Environments](#environments) | The typed matrix of environments the system has, and what differs between them | `Config(...)` in the config file | `--env` | | [Inputs](#inputs) | A per-run value from outside the repository | `-var`, `--var-file`, `[inputs]` | supplied per run | | [Profiles](#profiles) | Where a run points: state backend, AWS profile, parallelism | `[profile.]` in `atlantide.toml` | `--profile/-P` | `--profile` and `--env` are independent; neither implies the other. ## The project file `atlantide.toml` sets the config path, the state database and other defaults, so commands inside the project need no flags. The file is found by searching the working directory and then each parent, as git locates a repository root. Relative paths in it resolve against the directory containing the file, not the directory the command runs from. ```toml config = "infra.py" state = "atlantide.db" parallelism = 8 aws_region = "eu-north-1" aws_profile = "default" aws_endpoint = "http://localhost:4566" # send every AWS call here instead secrets_key = ".atlantide.key" secrets_store = ".atlantide.secrets" [aws.aliases.prod] # alternate account (multi-account) profile = "prod-account" # a resource selects it via provider_alias="prod" ``` ## Environments A `Config` declares every environment and what differs between them, in one place: ```python from atlantide.core import Config, EnvSchema, Stack class AppEnv(EnvSchema): domain: str size: int = 1 config = Config( AppEnv, envs={ "dev": {"region": "eu-north-1", "domain": "dev.example.io"}, "prod": {"region": "us-east-1", "domain": "example.io", "size": 5}, }, ) for env in config.envs(): # env: AppEnv with Stack(env.name, config=env): ... ``` Each variable declares a type and an optional default. All values are checked when the `Config` is constructed, so a missing or mistyped prod value fails `atlantide validate` in CI rather than the prod apply. An environment name becomes the stack name, so it must be an identifier. ### The schema `EnvSchema` is the only class Atlas-lang accepts: a module-level class whose body contains annotated fields only, with no methods, decorators or metaclass. Because the variables are ordinary attributes, an editor completes `env.domain`, and `env.domian` is a type error before anything runs. A field may be annotated `str`, `int`, `float`, `bool`, `list` or `dict`, or `X | None` to make it optional and nullable. Parameterized generics such as `list[str]` are not supported. Alternatively, the schema can be a mapping of `var()` declarations, which provides no static type information: ```python from atlantide.core import Config, var config = Config(schema={"size": var(int, default=1)}, envs=) ``` Both forms run the same validation. A `var()` without a default is required: every environment must supply it, and a missing value is reported when the `Config` is built, not where the value is read. `default=None` makes it optional. ### Well-known keys `region`, `tags` and `name_prefix` are implicit in every schema, so `Stack(env.name, config=env)` needs no separate `region=`: ```python with Stack(env.name, config=env): # region/tags/name_prefix from the env ... ``` An explicit `Stack` argument takes precedence over the environment, except `tags`, which merge (the stack's own taking precedence). A schema may re-declare a well-known key to make it required or to narrow its type. Inside the body the environment is ambient: a [component](https://atlantide-org.github.io/reference/components.md) reads it with `current_config()` instead of receiving it through a constructor. ### Selecting one ```bash atlantide plan infra.py --env prod # prod only atlantide apply infra.py --env dev --env prod # repeatable ``` An unselected environment is **out of scope, not undeclared**: its state is never diffed or planned for deletion, and the plan lists what it excluded. ``` envs: prod (of dev, prod) — dev is not planned and will not change ``` Naming an environment the config does not declare is an error. Stacks declared *outside* the `config.envs()` loop, such as a shared `common`, are not part of the matrix and are unaffected by `--env`, so a shared VPC stays in the graph when a run is narrowed. `--env` is accepted by `plan`, `apply`, `validate`, `build` and `destroy`. On `destroy` it selects by stack name, because destroy reads state only, not config, and an environment's stack is its name. `build` records the selection in the `.atlas` artifact, so `deploy` covers the same environments as the build. Determinism is over *(config, inputs, selected environments)*. ## Inputs An input is a per-run value supplied from outside the repository, such as a CI build number or a fork's name prefix. Inputs arrive as text: ```python env = atlantide.input("env", "dev") # with a default count = int(atlantide.input("count")) # values arrive as strings from -var ``` ```bash atlantide plan -var env=prod atlantide plan --var-file prod.toml ``` Inputs can be set in three places, in increasing order of precedence: `[inputs]` in `atlantide.toml` (or `[profile..inputs]` for one profile), `--var-file`, and `-var` on the command line. Every plan prints the inputs it read. Only inputs the config reads affect the result. > **An input is not an environment** > > Values that differ *between environments* belong in a > [`Config`](#environments), which is checked in, typed and validated at > construction. Values that differ *between runs of one environment* are > inputs. An environment's value may be built from an input. > **Inputs are not for secrets** > > `atlantide.secret("name")` returns a handle, not a value. A secret passed as > an input is written in plaintext to the IR, build artifacts and state. ## Profiles A `[profile.]` table overlays the top level and is selected with `--profile/-P` or the `ATLANTIDE_PROFILE` environment variable. A profile sets where a run points: state backend, AWS account and parallelism. The overlay is applied per table, so a profile sets only the keys it changes and inherits the rest: ```toml parallelism = 4 [profile.prod] parallelism = 16 [profile.prod.state] # inherits the base [state] keys it omits backend = "s3" bucket = "acme-atlantide-state" key = "prod/atlantide.json" lock_table = "atlantide-locks" ``` Naming a profile the file does not define is an error; there is no fallback to the defaults. # Authoring Reference for config constructs and their effects. For step-by-step introductions, see the [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md) and [Building on AWS](https://atlantide-org.github.io/aws/index.md). ## Declaring a resource ```python Resource("logical-name", **fields, lifecycle=..., depends_on=[...]) ``` The logical name is positional and must be an identifier. Every resource accepts `lifecycle=` and `depends_on=`; all other arguments are the type's own fields, whose mutability determines the effect of changing them. Run `atlantide schema ` to list them. Reading another resource's computed field returns a lazy `Ref`, not a value. The read creates the dependency edge, with no string addresses, and resolves at apply. ## Stacks ```python with Stack("prod", region="eu-north-1", tags={"env": "prod"}, name_prefix="acme"): ... ``` A stack scopes everything declared in its body. Resources inside get the node id `{stack}:{type}:{name}`. - **`region`** is required. It is the default for every resource in the body that has a `region` field and does not set one. - **`tags`** are merged into every resource in the body that has a `tags` field. Nested stacks merge, with the inner stack taking precedence; a resource's own tags take precedence over the stack's. - **`name_prefix`** composes the cloud name of resources whose name field is marked `physical_name` as `{name_prefix}-{base}-{stack}`. If omitted, the enclosing stack's value is used. - **`config`** is one environment of a [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments). Its `region`, `tags` and `name_prefix` keys fill in the matching arguments, and the environment is ambient in the body. An explicit argument takes precedence over the environment; `tags` merge. Stacks nest, and a config may declare any number of them. The common pattern is a loop over the environments of a `Config`: ```python for env in config.envs(): with Stack(env.name, config=env): S3Bucket("assets", versioning=env.versioning) ``` Per-environment differences are then typed, declared variables rather than branches on the stack name. See [Environments](https://atlantide-org.github.io/reference/configuration.md#environments). ### Per-block region `with region("us-east-1"):` overrides the enclosing stack's region for the resources declared inside it, and nothing else. A typical use is a resource a service requires in a specific region, such as an ACM certificate for CloudFront. ## Outputs ```python output("assets_arn", assets.arn) ``` `output()` exports a literal or a `Ref` under a name, namespaced by the enclosing stack: inside `with Stack("prod")` it becomes `prod:assets_arn`; outside any stack it goes in `default`. Outputs are persisted to state after apply and read with `atlantide output`. `output()` also returns a handle. A later stack in the same config can use it to consume the value; the reference becomes a dependency edge rather than a state lookup. A stack applied by a **separate** config must be referenced explicitly: ```python vpc_id = StackReference("network").output("vpc_id") ``` `StackReference` resolves from the referenced stack's committed outputs at apply, so that stack must already be applied to the same state store. ## Lifecycle ```python S3Bucket("assets", lifecycle=Lifecycle(prevent_destroy=True), ...) ``` | Field | Effect | |---|---| | `prevent_destroy` | A planned `DELETE` — or the destroy half of a `REPLACE` — on this resource fails the whole plan. | | `create_before_destroy` | A `REPLACE` creates the replacement before destroying the old one. Falls back to destroy-first when the two would collide on identity. | | `ignore_changes` | Field names whose drift is ignored: excluded from the Merkle `input_hash` and from the diff's changed-field set, so a change to one never triggers `UPDATE` or `REPLACE`. | | `aliases` | Prior node ids (or bare old logical names) this resource was **renamed from**. | ### Rename without replace To rename a resource without recreating it, list its previous name as an alias. The engine maps it onto the existing state entry: ```python S3Bucket("assets_v2", lifecycle=Lifecycle(aliases=("assets",)), ...) ``` When the new id is absent from state and an alias id is present, the plan rewrites the existing row to the new id. To guard a whole stack rather than one resource, see [`deny-destroy-in-protected`](https://atlantide-org.github.io/reference/policies.md#deny-destroy-in-protected). ## Explicit ordering `depends_on=[other]` orders one resource after another without reading a value from it. Use it for dependencies that carry no value, such as an IAM policy that must propagate before a dependent service starts, or a bucket policy that must exist before an upload. Reading a field such as `other.arn` already creates the edge; `depends_on` is needed only when nothing is read. Pass the resources or their node ids. The edge is excluded from the content hash, so adding one changes ordering only and never causes a resource to be re-planned. ## Output combinators `concat`, `interpolate` and `join` build strings from values that are not yet known. A computed field such as `assets.arn` is a `Ref`, so ordinary string formatting does not apply to it. ```python S3BucketPolicy("p", bucket=assets.bucket, statements=[ allow("s3:GetObject", on=concat(assets.arn, "/*"), principal="*"), ]) ``` > **Never put a `Ref` in an f-string** > > `"arn:" + assets.arn` fails at evaluation, but `f"arn:{assets.arn}"` does > not: it validates, plans cleanly, and writes the literal text > `Ref(node_id=..., attr=...)` into the field. Use `interpolate("arn:{}", > assets.arn)` instead. Combinators serialize as data, not closures. The config is not re-executed at apply; the engine evaluates the recorded operation once the referenced values resolve. ## Derived values Atlas-lang provides a set of deterministic pure functions in the global namespace for values computed *at config-evaluation time* from known inputs. | Function | Purpose | |---|---| | `slugify(s)` | ASCII-fold, lowercase, non-alphanumerics to a single `-`. `"Café Menu!"` → `"cafe-menu"`. | | `uuid5(namespace, name)` | Name-based UUID, stable across machines and runs. | | `sha256_hex(s)` | Hex SHA-256 of a string. | | `hmac_sha256_hex(key, msg)` | Hex HMAC-SHA256. | | `b64encode(s)` / `b64decode(s)` | Base64, over UTF-8 text. | | `to_json(v)` | Canonical JSON: keys sorted, no insignificant whitespace, byte-stable — safe to hash or embed in a policy document. | | `from_json(s)` | Parse JSON into plain data. | | `merge(a, b, ...)` | Deep-merge dicts left to right, later winning; nested dicts merge recursively, other types are replaced. Arguments are not mutated. | These functions operate on values, not `Ref`s. For values known only at apply, use the [combinators](#output-combinators). ## Environments ```python class AppEnv(EnvSchema): # annotated fields only domain: str size: int = 1 config = Config(AppEnv, envs={ "dev": {"region": "eu-north-1", "domain": "dev.example.io"}, "prod": {"region": "us-east-1", "domain": "example.io", "size": 5}, }) ``` A `Config` declares every environment and its values, and is validated at construction, so a missing or mistyped prod value fails `validate` rather than the prod apply. `region`, `tags` and `name_prefix` are implicit in every schema, so `Stack(env.name, config=env)` needs no `region=`. `atlantide plan --env prod` narrows a run to one environment. The others are out of scope, not undeclared, so their state is never planned for deletion. For details, including the `var()` form, see [Configuration](https://atlantide-org.github.io/reference/configuration.md#environments). ## Inputs and secrets ```python build = atlantide.input("build", "local") # a per-run value, with a default key = atlantide.secret("signing-key") # a handle to a stored secret ``` `atlantide.input()` reads a value supplied by `-var`, `--var-file` or `[inputs]`. If an input has no default and is not supplied, the error names all three sources. Determinism holds over *(config, inputs, selected environments)*, and only inputs the config reads affect the result. See [Configuration](https://atlantide-org.github.io/reference/configuration.md#inputs). Values that differ between environments belong in a `Config`, which is checked in and typed. Values that differ between runs of one environment are inputs. `atlantide.secret()` returns a `SecretRef`, which is a name. Only the name reaches the IR, the artifact and state; the plaintext is resolved in memory from the secrets store at apply and redacted in plan output and logs. It does not read from inputs, because a secret passed as an input would be written in plaintext to all three. Set the value with `atlantide secret set `, and enforce secret handles with [`require-secret-refs`](https://atlantide-org.github.io/reference/policies.md#require-secret-refs). ## Multi-account AWS resources accept `provider_alias=`, which names an entry under `[aws.aliases.*]` in `atlantide.toml`, to target another account: ```python S3Bucket("assets", provider_alias="prod", ...) ``` The field is `immutable()`, so moving a resource between accounts is a replace, not an update. ## What config may not do Config is executed by Atlas-lang, not CPython. It has no clock, randomness, environment, network or filesystem access. Constructs outside the subset are rejected before evaluation, with the reason and the alternative: | Rejected | Because | |---|---| | `while` | Halting must be provable; use a bounded `for`. | | `class` | Define resource types in a provider, not in config. One exception: a module-level [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) whose body is annotated fields only — no methods, decorators, metaclass or type parameters — which is a declaration, not a computation. | | `try` / `raise` | No exceptions in config; guard with `if`. | | `async` / `await` | Config is synchronous and pure. | | `yield` | No generators; build lists with comprehensions. | | `global` / `nonlocal` | No mutable module or closure state; pass arguments. | | `:=` | Use a separate assignment. | | `del` | Bound values are immutable. | Imports are restricted to `atlantide.*`, so a vendored [component](https://atlantide-org.github.io/reference/components.md) can be imported and `import os` cannot. Bounded `for` loops, comprehensions, functions and conditionals are available. Recursion is not rejected, but evaluation runs on a fuel budget, so a runaway computation stops with `FuelExhaustedError` instead of hanging. In summary: per-run values come from `atlantide.input()`, per-environment values are declared in a [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments), and code that needs a real `class`, such as a [component](https://atlantide-org.github.io/reference/components.md) or a provider, is ordinary Python outside the config. # Policies A policy inspects one node's *pending change* and passes or fails it. Policies run at plan time over the changeset, so a violation appears in the plan, before any apply starts. ```python from atlantide.policy import enforce enforce("require-tags", keys=["env", "owner"]) enforce("deny-destroy-in-protected", stacks=["prod"]) enforce("require-secret-refs") ``` Call `enforce()` during config evaluation, conventionally at the top of the file, outside any `Stack` block. Bindings are recorded in the IR and round-trip through a `.atlas` artifact, so a plan built from an artifact enforces the same policies as the config that produced it. ## Levels ```python from atlantide.core import PolicyLevel enforce("require-tags", keys=["env"], level=PolicyLevel.ADVISORY) ``` | Level | Effect of a violation | |---|---| | `MANDATORY` (default) | The plan is blocked. `plan` and `apply` exit `1`. | | `ADVISORY` | Reported as a warning; the run proceeds. | ## Narrowing by type `types=` restricts a binding to the named resource types. A single string is one type name, not a sequence of characters: ```python enforce("require-tags", types="aws.S3Bucket", keys=["owner"]) enforce("require-tags", types=["aws.S3Bucket", "aws.SqsQueue"], keys=["owner"]) ``` A policy bound more than once is evaluated separately with each binding's arguments, for example one `deny-destroy-in-protected` binding per environment. ## What a policy sees Only **actionable** changes are evaluated; `NOOP` nodes are not passed to policies. For each change the policy receives the node id, the pending action, the stack, the desired resource and the binding's arguments. The desired resource is `None` for a pure `DELETE`, so the built-in policies that inspect the resource pass such nodes. ## Built-in policies ### `require-tags` Requires the tag keys listed in `keys`. With no `keys`, requires only that the resource has at least one tag. ```python enforce("require-tags") # any tags enforce("require-tags", keys=["env", "owner"]) # these keys ``` A key with an empty value counts as missing. Resource types without a `tags` field are skipped. ### `require-secret-refs` Every field marked sensitive must hold a `SecretRef` handle, not a literal. Takes no arguments. A secret referenced by name stays out of the config, the IR, state and the `.atlas` artifact; a literal value is written to all four. A field declared with `secret()` is typed `SecretRef | None`, so pydantic already rejects a literal. This policy covers fields declared `mutable(..., sensitive=True)` on a plain `str`, which accept plaintext without error. ### `deny-destroy-in-protected` Denies destructive actions (`DELETE`, and the destroy half of a `REPLACE`) in the stacks listed in `stacks`. ```python enforce("deny-destroy-in-protected", stacks=["prod", "shared"]) ``` With no `stacks`, it passes everything; there is no default protected stack. `deny-destroy-in-prod` is an alias. > **Policy vs. `prevent_destroy`** > > `Lifecycle(prevent_destroy=True)` guards one resource and is checked by the > planner. This policy guards every resource in a stack and is checked by the > policy pass. They are independent; a resource can be covered by both. ## Argument errors A binding with an argument the policy cannot use fails the plan with a `PolicyConfigError` naming the policy and the parameter: ``` require-tags: `keys` must be a name or a list of them, got int ``` ## Binding a policy to a resource type Provider authors can attach a policy to a resource type so that every instance carries it: ```python from atlantide.policy import policy @policy("require-tags") class Gadget(AcmeResource): ... ``` The binding applies to that type only and is evaluated alongside the config's `enforce()` bindings. Config cannot use the decorator, because Atlas-lang accepts no classes other than an [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) of annotated fields. It is an API for Python code: providers and the libraries that publish [components](https://atlantide-org.github.io/reference/components.md). ## Where policies sit in the pipeline Policy evaluation is the last step of stage 7, after the `prevent_destroy` guard and after `--target`/`--replace` selection, so a policy sees the narrowed changeset that would run. See [How it works](https://atlantide-org.github.io/how-it-works/index.md#the-whole-pipeline-on-one-page). # Remote state State defaults to a local SQLite file, usable from one machine only. Set `[state]` to a shared backend to use the same commands from a team or a CI runner. ```toml [state] backend = "s3" # "local" (default) | "s3" | "postgres" bucket = "acme-atlantide-state" key = "prod/atlantide.json" lock_table = "atlantide-locks" # DynamoDB table, hash key `node_id` (S) region = "eu-north-1" kms_key_id = "alias/atlantide" # optional; SSE-S3 (AES256) otherwise lock_ttl = 300 # seconds a lease lasts before it lapses lock_renew_interval = 100 # how often a live run pushes that out ``` ```toml [state] backend = "postgres" # needs the extra: pip install atlantide[postgres] dsn = "postgresql://…" # or the ATLANTIDE_STATE_DSN env var schema = "atlantide" ``` ## Concurrency Locks are held per node id, not over the whole store. Apply re-diffs after acquiring the lease, and actions can appear or disappear between the two diffs, so the lease covers the whole desired graph plus everything already recorded in state. **Two applies run concurrently only if both their configs and their prior state are disjoint.** Two applies over the same stack serialize. A run that loses its lease (the hold lapsed and another run claimed it) stops and reports it. It does not roll back: a compensation is a state write, and the lease no longer authorises one. Run `atlantide refresh` to recover. ### Preparing the backend Atlantide does not create the bucket or the lock table. They are the trust root for shared state and must exist before first use, configured with: - **Bucket versioning**, to recover from a bad state write. - **A DynamoDB TTL on `expires_at`**, so abandoned leases expire. `state check` verifies this setup in one pass. It also probes whether the endpoint honours conditional writes; an S3-compatible store that accepts the request but ignores the condition defeats every compare-and-swap the backend relies on. ```bash atlantide state check # --no-probe skips the scratch write ``` ## Managing state ```bash atlantide state list # every recorded resource atlantide state show # one resource's stored inputs and outputs atlantide state rm # forget a row without destroying anything atlantide state backup # ./atlantide-state--.atlas-state atlantide state restore snap.atlas-state atlantide state migrate --from atlantide.db # local -> remote atlantide state migrate --to-local atlantide.db # remote -> local atlantide state unlock # who holds what atlantide state unlock --owner ci-runner-7 -y # break a dead run's holds ``` **`backup` and `restore`** run under the state lock, so a snapshot cannot capture part of a concurrent apply. `restore` previews which nodes it will stop tracking, and refuses a snapshot if state has been written since it was taken, unless given `--force`. It rewrites only atlantide's record, not cloud resources, so restoring an old snapshot creates drift. **`state rm`** removes a row that no longer describes a real resource. It does not call the provider. If the resource still exists, it becomes untracked and the next apply creates a second one. The command snapshots state first and reports the snapshot. **`state migrate`** copies in a single write in either direction, so an interrupted migration cannot leave a partially populated destination. > **Upgrading a shared backend** > > The schema is versioned and migrates forward automatically. Take a snapshot > first, and upgrade every machine sharing the backend at the same time. An > older atlantide refuses a newer store rather than misreading it. ## Secrets Secret *values* can come from a remote store. Config, the IR and state hold only the secret's name: ```toml [secrets] provider = "ssm" # "keyfile" (default) | "env" | "ssm" prefix = "/atlantide/prod/" # secret `db_password` -> /atlantide/prod/db_password region = "eu-north-1" ``` > **Point `secrets_key` at a shared path when state is shared** > > The keyfile encrypts the local value store, salts the rotation digests, and > seals sensitive outputs at rest. > > A teammate without the keyfile cannot unseal those outputs and sees every > secret field as rotated. `plan` detects this pattern (every secret in state > mismatching, none intact) and warns instead of listing spurious updates. > KMS-backed keys are not supported. # Providers Atlantide ships with three providers. **`local`**: `File`, `SourceFile` and `Null`. Requires no credentials and runs anywhere, including CI. **`random`**: `Uuid`, `Password`, `Id` and `Timestamp`. Each value is generated once at apply and pinned in state; re-applying does not regenerate it. **`aws`**: the resource types below, the `AwsCallerIdentity` and `AwsAvailabilityZones` data sources, the IAM policy helpers (`allow`, `deny`, `assume_role`, `ServicePrincipal`), and the `SecureBucket` component. Any resource can set `provider_alias=` to target an alternate account. | Area | Types | | --- | --- | | Storage | `S3Bucket`, `S3BucketPolicy`, `S3Folder` | | Messaging | `SqsQueue`, `SnsTopic`, `SnsSubscription` | | Compute | `LambdaFunction` | | Identity | `IamRole`, `IamPolicy` | | Data | `DynamoDbTable` | | Observability | `CloudWatchLogGroup` | | Networking | `Vpc`, `Subnet`, `SecurityGroup`, `InternetGateway`, `ElasticIp`, `NatGateway`, `RouteTable` | | Edge and DNS | `CloudFrontDistribution`, `OriginAccessControl`, `AcmCertificate`, `Route53HostedZone`, `Route53Record` | ## Defaults **`S3Bucket` blocks public access and enables SSE-S3 encryption by default.** Set `force_destroy=True` to empty the bucket (objects, versions and delete markers) before removing it. Without it, S3 refuses to delete a non-empty bucket and teardown stalls. **`SecurityGroup` egress defaults to allow-all**, matching the AWS default. Pass `egress=[]` to revoke it. **`Subnet` requires an `availability_zone`.** Take it from `AwsAvailabilityZones` instead of a literal; available zone letters differ between accounts. **`CloudFrontDistribution` accepts `aliases` and `certificate_arn`**, and `Route53Record` accepts an `alias` target. Together they allow serving an apex domain from CloudFront. **`LambdaFunction` requires a package.** Set `code_path=` to a local zip or directory, or set `s3_bucket=` and `s3_key=` for an uploaded package. A directory is zipped with sorted entries and fixed timestamps, then fingerprinted into `code_sha256` at config-evaluation time. Two checkouts of the same source produce the same digest; editing the source produces a planned update. ## Data sources A data source reads a fact about the account instead of hardcoding it, so one config can run against multiple accounts: ```python from atlantide.providers.aws import AwsAvailabilityZones, AwsCallerIdentity me = AwsCallerIdentity("me") zones = AwsAvailabilityZones("azs") ``` Each is read once at apply and pinned in state. A plan makes no provider call, and two runs of the same config produce identical IR. Data sources are never destroyed. `destroy` drops the state entry without calling the provider, because atlantide did not create the object being read. ## Writing a provider A provider is a Python package that declares one entry point: ```toml [project.entry-points."atlantide.providers"] acme = "acme_atlantide:PLUGIN" ``` ```python from atlantide.core.plugin import ProviderPlugin PLUGIN = ProviderPlugin( name="acme", types={Gadget.type_name(): Gadget}, factory=lambda settings: AcmeProvider(**settings), module="acme_atlantide", # what config is allowed to import ) ``` Once installed, `atlantide providers` lists it and config can use `from acme_atlantide import Gadget`. `factory` receives the provider's `[provider.]` settings as a raw mapping, so a third-party provider can accept options atlantide does not define. A plugin that fails to load is reported and does not block unrelated commands. `--no-plugins` ignores all installed plugins, for reproducing a build or bisecting a plugin that breaks a run. > **A plugin is trusted code** > > A plugin runs in the same process, with the same access as atlantide. > Atlas-lang sandboxes a malicious *config*, not a malicious *plugin*. Treat > installing a plugin like installing any other dependency. # Components A component groups several resources behind one parameterized object, like Pulumi's `ComponentResource` or a CDK Construct. It turns a recurring piece of architecture (a hardened bucket, a queue with its dead-letter queue, a service and its role) into a single call. Config can use components but cannot define them. Atlas-lang admits no `class` with a body, except an [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) of annotated fields, so a component is ordinary Python written by a library or provider author: ```python from atlantide.core import Component, child from atlantide.providers.aws import S3Bucket class SecureBucket(Component): def __init__(self, name, *, bucket): self.bucket = child(S3Bucket, "assets", bucket=bucket) # ...plus a TLS-only hardening policy wired to self.bucket ``` A component has no node of its own in the graph. Its children become ordinary flat resources named `{component}-{child}`, so two instances of the same component never collide. ## Publishing and consuming Components are fetched once and pinned, as with `terraform init`. Config cannot import from a live URL: it runs in a sandbox that may import only `atlantide.*` and has no network access. `component add` clones the source, records the resolved commit and a content hash, and writes the tree into `.atlantis/`. Vendored code is imported under the `atlantide.components.*` namespace, which the sandbox permits. ```bash atlantide component add https://github.com/acme/secure-bucket --ref v1.2.0 --as acme --subdir src atlantide component verify # re-hash vendored trees vs the lock (tamper/drift) atlantide component vendor # rebuild .atlantis/ from the lock alone atlantide component lock # re-resolve declared refs -> commits ``` ```python from atlantide.components.acme import SecureBucket SecureBucket("assets", bucket="acme-assets") ``` `add` records the source under `[components.acme]` in `atlantide.toml` and the resolved pin in `atlantide.lock`. **Commit `atlantide.lock` and git-ignore `.atlantis/`**, which is derived from it. A `build` artifact records each component's commit as provenance. ## Worked examples The atlantide-org organisation publishes two components. Each is a small repository with its source in `src/` and tests covering the expansion, the diff and the plan: - **[aws-remote-state](https://github.com/atlantide-org/aws-remote-state)**: a versioned S3 bucket, a TLS-only bucket policy and a DynamoDB lock table, plus an optional access role and policy. This is the backend [remote state](https://atlantide-org.github.io/reference/remote-state.md) requires. - **[aws-static-website](https://github.com/atlantide-org/aws-static-website)**: a private origin bucket, an origin access control, a CloudFront distribution and a bucket policy scoped to that distribution by `AWS:SourceArn`. Both are added the same way. Pass a tag as `--ref`; `add` resolves it to a commit: ```bash atlantide component add https://github.com/atlantide-org/aws-remote-state \ --ref --as acme --subdir src ``` **[component-template](https://github.com/atlantide-org/component-template)** is a starting point for a new component, with the layout, CI workflow and test suite in place, and a `scripts/rename.py` that renames the placeholder. > **A published component is trusted code** > > Like a provider, a component runs with the same access as the engine. Review > it before adding it. After that, integrity rests on the pin: `verify` > re-hashes every vendored tree and fails on any tampering or drift. # CI and automation The first half of the pipeline performs no I/O, so per-commit checks need no provider credentials. A typical setup validates on every commit, plans on pull requests, and applies on merge. | Stage | Command | Needs | |---|---|---| | pre-commit | `atlantide validate` | nothing | | pull request | `atlantide plan --detailed-exitcode` | read access to state | | merge to main | `atlantide apply -y` | provider credentials, write access to state | ## Validate in a pre-commit hook `validate` checks that the config stays within the Atlas-lang subset and that its graph is acyclic. It touches no state and calls no provider: ```yaml title=".pre-commit-config.yaml" repos: - repo: local hooks: - id: atlantide-validate name: atlantide validate entry: atlantide validate language: system pass_filenames: false files: ^(infra\.py|atlantide\.toml)$ ``` ## Plan as a pull-request gate Without `--detailed-exitcode`, a plan with pending changes and a plan with none both exit `0`. With it: | Code | Meaning | |---|---| | `0` | No changes. | | `1` | An error, or a mandatory [policy](https://atlantide-org.github.io/reference/policies.md) denied the plan. | | `2` | Changes pending. | A policy denial exits `1` in both modes, so it is distinct from pending changes. ## Apply on merge A mutating command in CI needs `-y` to skip the confirmation prompt. Also configure: - **Concurrency.** Two applies over the same stack serialize on the state lease; see [Concurrency](https://atlantide-org.github.io/reference/remote-state.md#concurrency). Queue deploys per state backend rather than cancelling one mid-run. - **Failure policy.** `--on-failure rollback` (the default) undoes the nodes a failed run completed; `halt` leaves them for a human. See [When apply fails](https://atlantide-org.github.io/how-it-works/failure.md). ## GitHub Actions ```yaml title=".github/workflows/infra.yml" name: Infrastructure on: pull_request: push: branches: [main] permissions: contents: read id-token: write # for OIDC to AWS concurrency: group: infra-${{ github.ref }} cancel-in-progress: false # never cancel an apply half-way jobs: infra: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 - run: uv tool install atlantide - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.ATLANTIDE_ROLE_ARN }} aws-region: eu-north-1 - run: atlantide validate - name: Plan if: github.event_name == 'pull_request' run: | set +e atlantide plan --detailed-exitcode code=$? [ "$code" -eq 2 ] && echo "::notice::changes pending" [ "$code" -eq 1 ] && exit 1 exit 0 - name: Apply if: github.ref == 'refs/heads/main' run: atlantide --audit-log audit.jsonl apply -y ``` This requires a shared [state backend](https://atlantide-org.github.io/reference/remote-state.md) in `atlantide.toml`; the default local SQLite file does not persist between CI runs. If the config imports a third-party provider, install it into the same environment (`uv tool install atlantide --with acme-atlantide`); the [standalone binary](https://atlantide-org.github.io/install.md#provider-plugins-and-the-binary) cannot load plugins. ## Build once, deploy the same bytes `build` compiles the config into a content-hashed `.atlas` artifact with provider versions pinned. `deploy` applies an artifact without executing the config again, so production receives the same bytes that were tested in staging: ```bash atlantide build -o release.atlas # in the build job atlantide verify release.atlas # hash check, anywhere atlantide -P staging deploy release.atlas -y # then, unchanged: atlantide -P prod deploy release.atlas -y ``` `--env` given to `build` is recorded in the artifact, so a `deploy` covers the same environments the build did. ## Machine-readable output `--json` replaces the rendered output with a JSON document on stdout. It is accepted by `plan`, `apply`, `validate`, `refresh`, `import`, `init`, `output`, `providers`, `state list` and `state show`. Every payload from a command that touches state carries a `state` field naming the backend it used. ```bash atlantide plan --json > plan.json atlantide output --json ``` Logging is separate from command output and goes to stderr. The options are global, so they come **before** the command: ```bash atlantide --log-level info --log-format json plan atlantide --audit-log audit.jsonl apply -y # append every event to a JSONL file ``` | Global option | Environment variable | |---|---| | `--profile/-P` | `ATLANTIDE_PROFILE` | | `--log-level` | `ATLANTIDE_LOG_LEVEL` | | `--audit-log` | `ATLANTIDE_AUDIT_LOG` | | `--debug` | — | | `--no-plugins` | — | The Postgres backend also reads its DSN from `ATLANTIDE_STATE_DSN`, which keeps the password out of `atlantide.toml`. # Troubleshooting Each error name identifies the failed stage. `--debug` before any command (`atlantide --debug plan`) prints the full traceback and cause chain. Stage numbers refer to [the pipeline](https://atlantide-org.github.io/how-it-works/index.md#the-whole-pipeline-on-one-page). ## Config does not compile (stages 1–2) **`LanguageError`**: a construct outside the Atlas-lang subset. The message names the construct, the line, and the replacement: ``` error: construct 'While' is not allowed in Atlas-lang — Atlas-lang has no `while` (halting must be provable); use a bounded `for`. (line 2, col 0) ``` See [what config may not do](https://atlantide-org.github.io/reference/authoring.md#what-config-may-not-do). `import os` fails the same way; imports are restricted to `atlantide.*`. **`FuelExhaustedError`**: evaluation exceeded its step budget, usually from runaway recursion (not rejected at parse time) or an oversized loop. **`IRError`**: a value in a resource field cannot be canonicalized. Config data must be plain JSON-shaped values. **`RegistryError`**: an unknown or duplicate name, or a bad version: two resources with one logical name in a stack, an uninstalled provider, or a component alias that was never added. ## The graph is not a DAG (stage 4) **`CycleError`**: resources depend on each other in a loop, named in the message, typically two resources reading a field from each other. Pass a literal on one side or split a resource. **`StackOutputCycleError`**: cross-stack `output()` references form a loop, for example `common:vpc_id -> dev:x -> common:vpc_id`. It is raised before lowering, so the chain names output keys, not resources. ## The plan is refused (stage 7) **`PreventDestroyError`**: a planned destroy targets a resource with `Lifecycle(prevent_destroy=True)`, which `--replace` and `destroy` also honour. To destroy it, remove the guard and re-plan. **`PolicyViolationError`**: one or more mandatory policies failed and the apply is blocked. `plan` exits `1`. Fix the resource, or set the binding to `level=PolicyLevel.ADVISORY` to warn instead of block. See [Policies](https://atlantide-org.github.io/reference/policies.md). **`PolicyConfigError`**: a policy binding passes arguments the policy does not accept, e.g. `enforce("require-tags", keys=3)`. The message names both. ## Locking and concurrency (stage 8) **`LockError`**: another run holds the lease. Nothing was written. Wait, or inspect holders with `atlantide state unlock`. **`LeaseLostError`**: the run wrote to the provider, then found its lease taken by another run. **Nothing is rolled back**, because a compensation is a state write and the run no longer holds the lock. Run `atlantide refresh` before applying again. **`FencedWriteError`**: the store refused a write because another run is the recorded holder. Unlike `LeaseLostError`, the store decides, not the run's local clock, which can wrongly consider the lease valid. This conditional write prevents two concurrent runs from silently merging state. **`PlanDriftError`**: the changeset about to run differs from the approved one. Apply re-diffs after acquiring the lease so that a resource created by another run in the meantime is not built twice. The message names what was added and what disappeared: ``` state changed between the plan you approved and the lock being taken, so the changes are no longer the ones shown — now also: delete aws.S3Bucket:old. Re-run to plan against current state. ``` Re-run. The difference is not reconciled automatically because it can contain an unreviewed destroy. ## During apply **`ProviderError`**: a provider CRUD call failed. It carries `node_id`, `op` and `resource_type`. Under the default `--on-failure rollback`, completed nodes are undone; see [When apply fails](https://atlantide-org.github.io/how-it-works/failure.md). **`RollbackError`**: a compensation (a provider call followed by a state write) did not complete after a failed apply. State can describe a resource that no longer exists, and because the stored hash still matches config, **the next plan reports `NOOP`**. Run `atlantide refresh` before the next plan. Raised alongside the original failure, never instead of it. **`InterruptedRunError`**: Ctrl-C. Exits `130` instead of `1`, without the `error:` prefix. Completed nodes are compensated on exit where the run still held its lock. ## State, secrets, artifacts, components **`StateError`**: a state backend operation failed. For a remote backend, `atlantide state check` verifies reachability and setup in one pass. **`SecretsError`**: sealing or unsealing failed: unknown backend, wrong key, or corrupt ciphertext. If `plan` warns that *every* secret appears rotated and none intact, the keyfile is wrong. See [Secrets](https://atlantide-org.github.io/reference/remote-state.md#secrets). **`ArtifactError`**: a `.atlas` artifact is malformed, corrupted, or fails its hash check. `atlantide verify` runs that check on its own. **`ComponentError`**: fetching, vendoring or verifying a component failed: a bad git source, a missing `--subdir`, or a vendored tree whose hash does not match `atlantide.lock`. `atlantide component vendor` rebuilds `.atlantis/` from the lock; `component verify` re-hashes it. ## Things that are not errors **A plugin that failed to load.** `atlantide providers` lists it and exits `1`. Config cannot find its types, which resembles a typo. `--no-plugins` ignores all installed plugins. **A plan that shows changes you did not make.** The diff compares desired inputs against *recorded* inputs, not the live world: the config or the state changed. To compare against the cloud, run `atlantide refresh`. **A resource the provider could not find during `refresh`.** It is reported but kept unless `--prune` is given, because discarding the record would make the next apply create a second one. # How it works This section describes the engine in three levels of detail. - **This page** — the mental model, the pipeline, and which command runs which stages. - **[Walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md)** — the pipeline run end to end against local files. It needs no credentials or AWS account. - **[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md)** — how `input_hash` is computed and used, with real numbers. - **[When apply fails](https://atlantide-org.github.io/how-it-works/failure.md)** — the apply phases, what is rolled back, what cannot be, and how a failed rollback is kept visible. The [glossary](https://atlantide-org.github.io/glossary.md) defines every term below, in pipeline order. --- ## The mental model Atlantide runs a Python file in a sandbox with no clock, randomness, environment or network. The resources the file creates become a content-hashed dependency graph, the IR. Each node is hashed together with its dependencies, producing an `input_hash` per node. These hashes are compared against those recorded in a state database; the difference between the two sets is the plan. Apply walks the graph in topological order, in parallel, and calls providers only for nodes whose hash changed. This has two consequences: **`plan` needs no credentials.** Everything up to the diff is pure computation over the config and the recorded state. **The diff compares desired inputs against recorded inputs, not against the live world.** Detecting infrastructure changed outside Atlantide is a separate command, `refresh`. The [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md#11-drift-and-the-limit-of-the-model) shows where that boundary sits and what it costs. --- ## The whole pipeline on one page ``` infra.py │ │ ─── pure: no I/O, no credentials, byte-reproducible ─── │ ├─ 1 Atlas-lang validate_source + Interpreter.run lang/validate.py, lang/interp.py ├─ 2 Lower lower(registry, providers) ir/lower.py ├─ 3 Canonicalize RFC 8785 + sha256 ir/hash.py ├─ 4 Graph build_graph (Tarjan) + Kahn graph/build.py, graph/order.py ├─ 5 Merkle merkle_hashes -> input_hash/node ir/merkle.py │ │ ─── touches the world ─── │ ├─ 6 Diff vs prior hashes from state reconcile/diff.py ├─ 7 Plan guard + policies reconcile/planner.py, engine/planner.py └─ 8 Apply lease -> re-diff -> parallel DAG engine/locking.py, reconcile/executor.py ``` The layering is enforced. [`pyproject.toml`](https://github.com/atlantide-org/atlantide/blob/main/pyproject.toml) declares import-linter contracts for the order `cli > engine > reconcile > (policy | providers | state | secrets | lang) > graph > ir > core`, plus three rules: `core` imports no sibling package, and `providers` and `secrets` depend only on `core`. The contracts run in CI with `mypy --strict` and a 90% coverage floor. An import that crosses a layer sideways fails the build. --- ## Command reference | Command | Runs stages | Needs credentials | Writes state | |---|---|---|---| | `validate` | 1–5 | no | no | | `graph` | 1–4 | no | no | | `build` | 1–5 | no | no | | `verify` | — | no | no | | `plan` | 1–7 | no | no | | `apply` | 1–8 | yes | yes | | `deploy` | 6–8 (from artifact) | yes | yes | | `refresh` | provider `read` only | yes | only with `--write` | | `import` | 1–5, then provider `read` only | yes | yes (records; creates nothing) | | `destroy` | 6–8, empty desired graph | yes | yes | | `init` | — (compiles the config it scaffolds) | no | no | The mutating commands share a set of flags: `--confirm/-y`, `--state`, `--region`, `--parallelism/-p`, `--on-failure rollback|halt` (defaulting to `rollback`), `--target/-t` and `--replace`. `--audit-log` is a global option, given before the command. `--on-failure` is described in [When apply fails](https://atlantide-org.github.io/how-it-works/failure.md). `--env/-e` narrows a run to named environments of the config's [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments). `plan`, `apply`, `validate`, `build` and `destroy` take it; `refresh` does not. Defaults come from `atlantide.toml`, looked up from the current directory upwards. It sets the config path, the state backend, the secrets provider, parallelism, AWS region, profile and endpoint, component pins, inputs, and any `[profile.]` overlays selected with `-P`. See [Configuration](https://atlantide-org.github.io/reference/configuration.md). # Walkthrough This page runs the whole pipeline without credentials. Every command below was run, and every output block is the output it produced. The example uses the `local` provider, which writes files to disk, so no AWS account is needed. The pipeline is the same one the AWS provider uses; only the `create`, `read`, `update` and `delete` implementations differ. This page assumes the [mental model](https://atlantide-org.github.io/how-it-works/index.md#the-mental-model). --- ## 1. Set up the example ```bash mkdir demo && cd demo ``` `demo/infra.py`: ```python """A two-resource walkthrough config, credentials-free.""" from atlantide.core import Stack, output from atlantide.providers.local import File with Stack("demo", region="eu-north-1"): greeting = File("greeting", path="out/greeting.txt", content="hello atlantide") # Reading `greeting.checksum` returns a lazy Ref -> a real dependency edge. File("receipt", path="out/receipt.txt", content=greeting.checksum) output("greeting_checksum", greeting.checksum) ``` The config has three parts: | Line | What it is | |---|---| | `with Stack("demo", region=...)` | A namespace. Every resource inside gets node id `demo::`. `region` is required even for the local provider — it is stack metadata, not a provider detail. | | `greeting.checksum` | `checksum` is a `computed()` field — its value does not exist yet. Reading it returns a lazy `Ref` ([`core/types.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/core/types.py)), which is what creates the dependency edge. No `depends_on`, no string addresses. | | `output(...)` | A declared output, persisted into state after apply. Declared *outside* the `with` block here, so it lands in the `default` namespace (`default:greeting_checksum`). Move it inside the block and it becomes `demo:greeting_checksum` and is readable by another stack. | `File` itself declares its per-field mutability ([`providers/local/resources.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/providers/local/resources.py)): ```python class File(LocalResource): path: str = immutable() # changing it => REPLACE content: str = mutable(default="") # changing it => in-place UPDATE checksum: str = computed() # never diffs; produced by the provider ``` This declaration tells the engine that a path change requires a recreate and a content change does not. The cost is visible in the type before anything runs. --- ## 2. Validate — the pure front half, on its own ```bash atlantide validate infra.py ``` ``` ok infra.py — 2 resource(s), no cycles ``` It uses no state file, credentials or network access, which makes it suitable for a pre-commit hook. It runs pipeline stages 1–5 (sections 3–6 below) and stops. ### The sandbox is real, not a convention ```bash # a while loop atlantide validate bad.py ``` ``` error: construct 'While' is not allowed in Atlas-lang — Atlas-lang has no `while` (halting must be provable); use a bounded `for`. (line 2, col 0) 2 | while True: ^ ``` ```bash # import os atlantide validate bad.py ``` ``` error: import of 'os' is not allowed (only 'atlantide.core', '.policy', '.providers.*', '.components.*') — config must be a pure function of its inputs; move helpers into a provider or use Atlas builtins (`uuid5`, `sha256_hex`, `to_json`, `merge`, `slugify`). (line 1, col 0) 1 | import os ^ ``` Also rejected: `class`, `try`, dunder access, `eval`, `str.format`, and attribute assignment such as `a.content = ...`. (The single `class` exception is an [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) declaring an environment's fields — annotated names only, no methods, so it is data.) Because of the last restriction, a dependency cycle is close to unwritable: references can only be wired at construction time, and construction is ordered. --- ## 3. Stage 1 — Atlas-lang: subset check, then bounded evaluation **Code trace** ``` Engine.compile() engine/engine.py └─ evaluate_source() lang/__init__.py ├─ build_globals(inputs) lang/builtins.py # safe builtins + uuid5, sha256_hex, to_json, merge, slugify ├─ validate_source() lang/validate.py # ast walk; rejects the constructs above └─ _run_module() lang/__init__.py ├─ collecting() core/resource.py # a context that captures every Resource constructed └─ Interpreter(fuel=...).run lang/interp.py # tree-walking, fuel-bounded ``` It is a **tree-walking interpreter**, not `exec`. The interpreter never binds an `os` module. Evaluation is **fuel-bounded**: a step budget prevents a config from hanging the engine. With the ban on `while`, this makes termination provable. Every exception that is not already an `AtlantideError` is converted to a `LanguageError`, so a broken config produces a configuration error rather than an engine stack trace. Output: a `ResourceRegistry` ([`core/resource.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/core/resource.py)) holding the resources, the declared outputs, the policy bindings and the inputs the config read. --- ## 4. Stage 2 — Lowering to IR ``` Engine._compile_registry() engine/engine.py ├─ inline_stack_outputs(registry) core/inline.py # fold in-config cross-stack refs into real Ref edges └─ lower(registry, providers) ir/lower.py ``` Each `Resource` becomes an `IRNode` ([`ir/model.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/model.py)): canonical inputs, the dependency ids taken from `resource.refs()`, and the provider name **and version** stamped in. `build` writes the IR to a file (step 12): ```json { "nodes": [ { "dependencies": [], "id": "demo:local.File:greeting", "properties": { "content": "hello world", "path": "out/greeting.txt" }, "provider": "local", "provider_version": "1.0.0", "type": "local.File" }, { "dependencies": [ "demo:local.File:greeting" ], "id": "demo:local.File:receipt", "properties": { "content": { "$ref": "demo:local.File:greeting#checksum" }, "path": "out/receipt.txt" }, "provider": "local", "provider_version": "1.0.0", "type": "local.File" } ], "version": 1 } ``` `greeting.checksum` had no value at config time; it is now the data `{"$ref": "demo:local.File:greeting#checksum"}`, and `dependencies` records the edge it implies. The lazy attribute read has become a serialisable graph edge. --- ## 5. Stage 3 — Canonicalize and hash ``` canonical_bytes() ir/hash.py # RFC 8785 JSON Canonicalization Scheme hash_ir() ir/hash.py # sha256 over those bytes ``` RFC 8785 fixes key order, number formatting and string escaping, so the same IR always serialises to the same bytes. This makes the content hash stable and the `.atlas` artifact portable. To verify, build twice and compare: ```bash atlantide build infra.py -o a.atlas atlantide build infra.py -o b.atlas shasum -a 256 a.atlas b.atlas ``` ``` built a.atlas — 2 nodes, hash f9ecb89e5ade…, pins {'local': '1.0.0'} built b.atlas — 2 nodes, hash f9ecb89e5ade…, pins {'local': '1.0.0'} 6f10115bf10968dfda4081c19041a92183081875ee1f6370f2f8fbed9ba2292c a.atlas 6f10115bf10968dfda4081c19041a92183081875ee1f6370f2f8fbed9ba2292c b.atlas ``` The files are byte-identical, not only their hashes. --- ## 6. Stages 4–5 — Graph and Merkle ``` assemble_compiled() engine/hydrate.py ├─ build_graph(ir) graph/build.py # DiGraph; cycles rejected (iterative Tarjan) ├─ topological_order(g) graph/order.py # Kahn └─ merkle_hashes(ir, topo) ir/merkle.py # input_hash per node ``` Inspect the graph without applying anything: ```bash atlantide graph infra.py --format mermaid ``` ``` graph LR subgraph cluster0["demo"] n0["local.File:greeting"] n1["local.File:receipt"] end n0 --> n1 ``` The result of all four pure stages is a `Compiled` ([`engine/model.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/engine/model.py)): IR, graph, hashes, resources, policy bindings, outputs, inputs. Every later stage depends on the `input_hash` computed here. [The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md) covers its definition, how a change propagates, and the two cases the hash cannot see. --- ## 7. Stages 6–7 — Diff and Plan ```bash atlantide plan infra.py --state demo.db ``` ``` state: demo.db demo ─────────────────────────────────────────────────────────────────────────── + create local.File:greeting + create local.File:receipt Plan: 2 to add Outputs: default:greeting_checksum = (known after apply) ``` **Code trace** ``` Engine.plan() engine/engine.py └─ _plan_from_compiled() engine/engine.py ├─ resolve_aliases(backend.load(), ir) reconcile/aliases.py # a rename maps onto the existing row, not destroy+create ├─ _selection(...) engine/engine.py # --target / --replace, closed over dependencies └─ Planner.build() engine/planner.py ├─ diff(ir, hashes, prior, mutability) reconcile/diff.py ├─ force_replace(...) reconcile/diff.py # --replace ├─ restrict(...) reconcile/diff.py # --target ├─ plan(raw, protected) reconcile/planner.py # prevent_destroy guard — AFTER the two above └─ _refine() engine/planner.py ├─ _audit_secrets / _require_secrets ├─ _require_stack_outputs └─ _finalize -> _resolve_cbd + _evaluate_policies ``` The diff compares the desired `input_hash` against the `input_hash` stored on the prior state node. State is empty here, so both nodes are creates. When a hash differs, [`_classify`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py) uses the per-field mutability map to choose between an update and a replacement. Policies run at the end of planning, against the finished changeset. A mandatory violation sets `Plan.blocked`, and `apply` refuses before touching anything. Machine-readable form for CI: ```bash atlantide plan infra.py --state demo.db --json ``` ```json { "schema_version": 1, "ok": true, "summary": { "create": 0, "update": 0, "replace": 0, "delete": 0, "noop": 2 }, "inputs": {}, "changes": [ { "node_id": "demo:local.File:greeting", "action": "noop", "changed_fields": [], "conditional": false, "create_before_destroy": false } ], "outputs": { "default:greeting_checksum": null }, "violations": [], "warnings": [], "blocked": false, "state": "demo.db" } ``` `--detailed-exitcode` returns `0` for no changes, `2` for changes pending and `1` for an error. --- ## 8. Stage 8 — Apply ```bash atlantide apply infra.py --state demo.db -y ``` ``` state: demo.db demo ─────────────────────────────────────────────────────────────────────────── + create local.File:greeting + create local.File:receipt Plan: 2 to add demo ─────────────────────────────────────────────────────────────────────────── + done local.File:greeting + done local.File:receipt Applied: 2 to add (0.0s) Outputs: default:greeting_checksum = 1caa462dd96fe06c4e4afa9476f5a2c59c39270f21a09e5a65602daee9def0e0 ``` ```bash cat out/greeting.txt # hello atlantide cat out/receipt.txt # 1caa462dd96fe06c4e4afa9476f5a2c59c39270f21a09e5a65602daee9def0e0 ``` `receipt`'s content is `greeting`'s sha256. The `Ref` resolved at apply time, after `greeting` was created. **Code trace** ``` Engine.apply() engine/engine.py └─ _apply_compiled() engine/engine.py ├─ apply_scope(plan, prior) engine/locking.py # a pre-lock plan sizes the lease scope └─ _locked(run_replanned, scope, prepare=_alias_migration(ir)) └─ with_lock() engine/locking.py ├─ backend.acquire_lock(owner, ttl, scope) ├─ backend.bind_lease(lease) # fences every subsequent write ├─ background lease renewal └─ run_replanned() engine/engine.py ├─ re-diff against freshly-loaded state ├─ raise_drift(approved, fresh) engine/drift.py └─ executor.apply() -> _Applier.run() -> _run_phases() reconcile/executor.py ``` **The lock scope is the reachable graph, not the whole state.** Two runs touching disjoint subgraphs do not block each other. **The lease is renewed for as long as the run lives, and writes are fenced.** A [`LeaseGuard`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/state/backend.py) makes the store refuse a write from a lease that no longer holds the lock, so a stalled run that resumes cannot corrupt state. **Apply re-diffs under the lock**, because another run may have created a node in the meantime. What executes could then differ from what was approved, so the approved changeset is passed in as `expect` and any difference raises `PlanDriftError`. `--allow-plan-drift` disables the check. **Execution is a DAG scheduler**, [`run_graph`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/graph/schedule.py). Each node awaits its predecessors' `asyncio.Event` and only then takes the concurrency semaphore. Awaiting dependencies before acquiring the semaphore prevents a low `--parallelism` from deadlocking a deep graph. Each node is wrapped in `asyncio.timeout(node_timeout)`. **Execution runs in phases**: first create, update and replace over the desired graph in topological order; then `_cbd_cleanup`, destroying the prior halves of create-before-destroy replacements; then deletes over the *prior* graph in reverse order. On failure — including a `CancelledError` from Ctrl-C — the default `--on-failure rollback` runs the recorded compensations in reverse. Rollback failures surface as an `ExceptionGroup`. State is persisted one node at a time, so a crash part-way through the graph leaves an accurate record. --- ## 9. Re-apply — the Merkle skip ```bash atlantide apply infra.py --state demo.db -y ``` ``` demo ─────────────────────────────────────────────────────────────────────────── = noop local.File:greeting = noop local.File:receipt Plan: 2 unchanged nothing to apply ``` No provider calls were made; the engine compared hashes and stopped. With the AWS provider, this avoids hundreds of API calls per re-run. --- ## 10. UPDATE vs REPLACE — per-field mutability ### Change a `mutable()` field Edit `content` to `"hello world"`: ```bash atlantide plan infra.py --state demo.db ``` ``` demo ─────────────────────────────────────────────────────────────────────────── ~ update local.File:greeting content: 'hello atlantide' → 'hello world' ~ update local.File:receipt content: {'$ref': 'demo:local.File:greeting #checksum'} → {'$ref': 'demo:local.File:greeting #checksum'} Plan: 2 to change ``` `receipt`'s own input did not change — the `$ref` is identical on both sides — yet it is an update. This is Merkle propagation. `receipt`'s `input_hash` includes `greeting`'s hash, so a change to `greeting` forces `receipt` to be reconciled, because the value its reference resolves to is about to change. [The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md#watch-the-ripple-with-real-numbers) shows the two hashes that differ and the single `deps` entry that caused it. ```bash atlantide apply infra.py --state demo.db -y ``` ``` Applied: 2 to change (0.0s) Outputs: default:greeting_checksum = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 ``` `out/receipt.txt` now holds the new checksum. ### Change an `immutable()` field Edit `path` to `"out/hello.txt"`: ``` demo ─────────────────────────────────────────────────────────────────────────── ± replace local.File:greeting path: 'out/greeting.txt' → 'out/hello.txt' ~ update local.File:receipt ... Plan: 2 to change ``` The result is `± replace` rather than `~ update`, determined by the `immutable()` marker on the field. The downstream node is updated in both cases. --- ## 11. Drift, and the limit of the model Modify the file outside the engine: ```bash echo "TAMPERED" > out/greeting.txt atlantide refresh --state demo.db -v ``` ``` demo ─────────────────────────────────────────────────────────────────────────── ~ drifted local.File:greeting (1 of 2 inputs checked) checksum: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2… → '314cbe4410c173d9716307a8d25f58f61f3afe467a47f0272020ff3db2… not checked: content = in sync local.File:receipt (1 of 2 inputs checked) not checked: content Refresh: 1 drifted (state unchanged) ``` **`1 of 2 inputs checked`, and `not checked: content`.** Drift is only visible in fields that the provider's `read` reports. [`LocalProvider.read`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/providers/local/provider.py) returns `checksum` and `path`, not `content`. Atlantide reports unchecked fields instead of marking them in sync. `-v` names the unchecked fields; without it, the output includes a one-line notice. **`state unchanged`.** `refresh` is read-only by default. Add `--write` to record what it found back into state. The next step shows where the model differs from Terraform's: ```bash atlantide refresh --state demo.db --write # -> "1 drifted (state updated)" atlantide plan infra.py --state demo.db ``` ``` = noop local.File:greeting = noop local.File:receipt Plan: 2 unchanged ``` The modified file is not repaired. The diff compares desired inputs against recorded inputs, and neither changed: `content` drifted but the provider cannot report it, and `checksum` drifted but is `computed()`, so it never contributes to a diff. The input set and the hash are unchanged, so the node is a no-op. To force the repair: ```bash atlantide apply infra.py --state demo.db -y --replace 'local.File:greeting' ``` ``` ± replace local.File:greeting (forced): None → None = noop local.File:receipt Applied: 1 to change, 1 unchanged (0.0s) ``` `out/greeting.txt` is `hello world` again. Hash-based diffing gives re-runs without provider calls and exact propagation. The trade-off is that out-of-band changes to fields a provider cannot read are invisible to `plan`. `refresh` detects drift, and `--replace` forces the repair. --- ## 12. Artifacts — build once, deploy anywhere ```bash atlantide build infra.py -o demo.atlas ``` ``` built demo.atlas — 2 nodes, hash f9ecb89e5ade…, pins {'local': '1.0.0'} ``` The `.atlas` file is the full canonical IR (shown in step 4) plus `ir_hash`, `provider_pins`, `component_pins`, `policies` and `outputs` ([`ir/artifact.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/artifact.py)). ```bash atlantide verify demo.atlas ``` ``` ok demo.atlas: hash and provider pins verified ``` `deploy` applies an artifact **without re-executing the config**: `verify_artifact` → `rehydrate_resources` ([`engine/hydrate.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/engine/hydrate.py)) rebuilds live `Resource` objects from the IR → the same `_apply_compiled` path as `apply`. This supports promotion: build in CI, verify the hash, then deploy the identical bytes to staging and to production. The config cannot evaluate differently in the second environment, because it is not evaluated again. --- ## 13. Inspecting state ```bash atlantide state list --state demo.db ``` ``` 2 resource(s) ┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┓ ┃ node ┃ type ┃ status ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━┩ │ demo:local.File:greeting │ local.File │ created │ │ demo:local.File:receipt │ local.File │ created │ └──────────────────────────┴────────────┴─────────┘ ``` ```bash atlantide output --state demo.db ``` ``` default:greeting_checksum = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 ``` Also available: `state show`, `state backup` / `restore`, `state rm` (forget a row without touching the provider), `state unlock` (break a dead run's lease), `state check`, `state migrate`. --- ## 14. Destroy ```bash atlantide destroy --state demo.db -y ``` ``` demo ─────────────────────────────────────────────────────────────────────────── - destroy local.File:greeting - destroy local.File:receipt Plan: 2 to destroy demo ─────────────────────────────────────────────────────────────────────────── - done local.File:receipt - done local.File:greeting Destroyed: 2 resource(s) (0.0s) ``` The plan lists the nodes in graph order, but execution runs `receipt` before `greeting`, because deletes run in reverse topological order. `destroy` is implemented as a diff against an *empty* desired graph ([`engine/engine.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/engine/engine.py)), and its `--target` closure runs over **dependents** rather than dependencies: destroy a VPC and it pulls in everything sitting on top of it, not everything underneath. `out/` is empty and `state list` reports `state is empty`. --- ## 15. Where to go next - **Secrets** — `atlantide.secret("name")` is a *handle*; plaintext resolves in memory at apply and never enters the IR. Sensitive outputs are sealed at rest. See [`secrets/`](https://github.com/atlantide-org/atlantide/tree/main/atlantide/secrets/). - **Policies** — `enforce("require-tags", keys=["env"])` at config top level; mandatory violations block `apply`. See [Policies](https://atlantide-org.github.io/reference/policies.md). - **Components (L2)** — publishable from a git repo, pinned to commit + content hash, vendored locally. Worked example in [`examples/components/`](https://github.com/atlantide-org/atlantide/tree/main/examples/components/). - **A real AWS graph** — [`examples/aws/example-one.py`](https://github.com/atlantide-org/atlantide/blob/main/examples/aws/example-one.py): per-env stacks, cross-stack outputs, IAM, Lambda with `code_path` fingerprinting, `SecretRef`, two enforced policies. - **Writing a provider** — a provider is an ordinary Python package exposing a `ProviderPlugin` on the `atlantide.providers` entry-point group. The `local` provider is 80 lines and is the shortest complete example: [`providers/local/`](https://github.com/atlantide-org/atlantide/tree/main/atlantide/providers/local/). - **When it goes wrong** — [When apply fails](https://atlantide-org.github.io/how-it-works/failure.md) covers rollback and its limits; [Troubleshooting](https://atlantide-org.github.io/reference/troubleshooting.md) indexes every error by the stage that raises it. - **Full reference** — the [CLI](https://atlantide-org.github.io/reference/cli.md), [Authoring](https://atlantide-org.github.io/reference/authoring.md), [Configuration](https://atlantide-org.github.io/reference/configuration.md) and [Remote state](https://atlantide-org.github.io/reference/remote-state.md) pages. # The Merkle hash The `input_hash` makes a re-run free of provider calls, propagates a change to exactly the affected nodes, and lets `plan` run without credentials. The [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md) reaches this point at step 6, once the graph has been built. --- ## The definition From [`ir/merkle.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/merkle.py): ``` h(n) = sha256( canonical({ type, properties, [h(d) for d in sorted(deps(n))] }) ) ``` The implementation ([`ir/merkle.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/merkle.py)): ```python def merkle_hashes(ir: IRGraph, topo_order: Sequence[str]) -> dict[str, str]: by_id = {node.id: node for node in ir.nodes} hashes: dict[str, str] = {} for node_id in topo_order: node = by_id[node_id] ignored = set(node.ignore_changes) properties = ( {k: v for k, v in node.properties.items() if k not in ignored} if ignored else node.properties ) payload = { "type": node.type, "properties": properties, "deps": [hashes[dep] for dep in sorted(node.dependencies)], } hashes[node_id] = hashlib.sha256(to_canonical_json(payload)).hexdigest() return hashes ``` Four details of the function: | Detail | Why | |---|---| | **Iterated in `topo_order`** | Guarantees `hashes[dep]` is already computed when a node needs it. The Kahn ordering therefore runs first, and a cycle is a hard error — a cyclic graph has no fixpoint for this recursion. | | **`sorted(node.dependencies)`** | Only the dependency *set* affects the hash, not its order, so reordering config does not register as a change. | | **`to_canonical_json`** | RFC 8785. Key order, number format and string escaping are fixed, so the same logical payload always produces the same bytes on any machine, any Python version. | | **`ignore_changes` fields removed before hashing** | A field listed in `ignore_changes` cannot move the hash, so it never triggers an UPDATE or REPLACE. The diff drops the same fields, so the two agree. | ## It is a DAG fold, not a classic Merkle tree A classic Merkle tree is a binary tree over a list, built so that membership of one leaf can be proved with O(log n) sibling hashes. None of that applies here: there are no sibling proofs, no root hash used for verification, and no balanced structure. Only the recursive-hash property is borrowed: a node's hash is a function of its own content plus its children's hashes, so any change propagates to every ancestor. Here the structure is the resource DAG, "children" are dependencies, and there is one hash per node rather than a single root. The root-hash role ("did the whole config change?") is filled separately by `ir_hash` over the entire IR ([step 5](https://atlantide-org.github.io/how-it-works/walkthrough.md#5-stage-3-canonicalize-and-hash)). The structure is a *content-addressed dependency graph*. ## Watch the ripple, with real numbers Same two-resource config, hashed under three variants: - **A** baseline — `path="out/greeting.txt"`, `content="hello atlantide"` - **B** mutable change — `content="hello world"` - **C** immutable change — `path="out/hello.txt"` ``` demo:local.File:greeting A baseline a0de4c9548771eba8f0e405ce45cd5abeeee898994db49a334c208b831e9f9bd B mutable change 6c1673dc289138d0a7ddb87bebc280c85492b5bca8cd936863c5010d775a40c2 C immutable change 01d15e92cad556ebb9279d3c30c84dc5f4042157ea3bd4830ecebb251be4975b demo:local.File:receipt A baseline d1c54122b306e5ee15c79032364658d9151835a7e7ace777b65ba7b36715862a B mutable change eae4a250263c2385c2c58f1301f12d2f27bf8fc65bd0293cd0e6f55e0908bbf8 C immutable change ab516e29d39b2760d74f40ec1972efd8602dab6d1b23ffdfa0888054cba5f84b ``` `receipt`'s hash changes in all three variants, although `receipt` was never edited. Its own properties are byte-identical across all three: ``` receipt's OWN properties, per variant (canonical bytes): A {"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"} B {"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"} C {"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"} ``` The difference is entirely in the `deps` entry. The payload hashed for `receipt` under variant A: ```json { "type": "local.File", "properties": { "path": "out/receipt.txt", "content": { "$ref": "demo:local.File:greeting#checksum" } }, "deps": [ "a0de4c9548771eba8f0e405ce45cd5abeeee898994db49a334c208b831e9f9bd" ] } ``` canonicalized: ``` {"deps":["a0de4c95…f9bd"],"properties":{"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"},"type":"local.File"} sha256 -> d1c54122b306e5ee15c79032364658d9151835a7e7ace777b65ba7b36715862a ``` and under variant B, the identical payload with one substitution: ```json "deps": [ "6c1673dc289138d0a7ddb87bebc280c85492b5bca8cd936863c5010d775a40c2" ] ``` That field is the whole mechanism. It explains the plan output at [step 10 of the walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md#10-update-vs-replace-per-field-mutability), where `receipt` shows `~ update` with an identical `$ref` on both sides of the arrow: the CLI renders the properties, which did not change, while the diff reacts to the `deps` entry, which did. ## Why the hash is computed over *symbolic* refs `properties` keeps `{"$ref": "demo:local.File:greeting#checksum"}` rather than the resolved checksum. Three consequences follow: 1. **No provider I/O is needed to compute the hash.** The resolved value of that ref does not exist until `greeting` has been created. If the hash depended on resolved values, `plan` would need to call the provider, and therefore need credentials. 2. **Nothing is lost.** A ref's resolved value can only change if the referenced node changed, and that is already folded in via `deps`. An unchanged hash therefore implies unchanged resolved inputs. 3. **The hash the executor persists equals the hash the planner computed** ([`reconcile/executor.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/executor.py)), because both are over the symbolic form. No apply-time recompute step can disagree with the plan. ## How the diff uses it In [`reconcile/diff.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py): ```python if desired_hashes[node_id] == have.input_hash: # Merkle skip: no provider read return Change(node_id, Action.NOOP, desired=want, prior=have) return _classify(want, have, mutability.get(want.type, {})) ``` The full ladder in `_change_for` ([`reconcile/diff.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py)): | Condition | Action | |---|---| | in prior state only | DELETE | | in desired IR only | CREATE | | in both, but `status != created` (a write-ahead row from a crashed create) | CREATE — never NOOP | | in both, **hashes equal** | **NOOP — no provider `read`, no provider anything** | | in both, hashes differ | `_classify` → per-field mutability decides UPDATE vs REPLACE | A no-op does not mean "checked and matched"; it means "not checked". This is the source of the performance gain and of the limitation described at [step 11 of the walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md#11-drift-and-the-limit-of-the-model). When hashes differ, `_classify` needs a set of changed fields. Usually a property comparison supplies it. In the propagation case the properties are identical, so [`_changed_fields`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py) attributes the change to the ref-bearing fields, whose resolved values are what moved. This is why the plan names `content` on `receipt`. ## Two cases the hash alone cannot see The Merkle hash is a function of the config alone. Two situations change a node's real inputs without changing its hash; each has its own mechanism. **1. An upstream node is recreated for a *state-side* reason.** Suppose `greeting` is missing from state, or its create never confirmed. Its config is unchanged, so its hash and `receipt`'s hash are unchanged, yet `greeting` is about to get a new physical identity and `receipt`'s resolved input will change. `_stale_dependents` ([`reconcile/diff.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py)) pulls those NOOPs back into the changeset, transitively, whenever an upstream action is CREATE or REPLACE. **2. A state row stops being trustworthy.** If a rollback compensation calls the provider and then dies before writing state, the stored row describes something that no longer exists. Because the diff is symbolic, its stored hash still matches config, and the next plan would report NOOP indefinitely. The sentinel `NO_INPUT_HASH` is written into `input_hash` so the comparison can never succeed. Both `_poison` ([`reconcile/executor.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/executor.py)) and `refresh --write` ([`reconcile/refresh.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/refresh.py)) use it. `_poison` writes it *before* running the compensation; otherwise a process killed mid-rollback would leave the row clean and the divergence silent. A poisoned row is classified as UPDATE, not REPLACE. `_changed_fields` checks `prior.input_hash != NO_INPUT_HASH` before attributing the change to ref-bearing fields, because a ref-bearing field can also be `immutable()` (`SecurityGroup.vpc_id`, `Route53Record.zone_id`). Attributing a change there would turn "re-check this node" into "destroy and recreate working infrastructure". ## Reproduce the numbers The hashes above were produced by calling the pure stages directly: ```python from atlantide.graph.build import build_graph from atlantide.graph.order import topological_order from atlantide.ir.lower import lower from atlantide.ir.merkle import merkle_hashes from atlantide.lang import evaluate_source registry = evaluate_source(open("infra.py").read()).unwrap() ir = lower(registry) graph = build_graph(ir).unwrap() print(merkle_hashes(ir, topological_order(graph))) ``` It needs no credentials, state file or network access. # When apply fails A plan can fail part-way through. This page describes what the engine does next. ```bash atlantide apply --on-failure rollback # default: undo what this run completed atlantide apply --on-failure halt # stop where it stands, undo nothing ``` ## The three phases Apply runs in phases. Only the first is reversible. | Phase | What runs | Order | Reversible | |---|---|---|---| | 1 | `CREATE`, `UPDATE`, `REPLACE` | dependencies first, in parallel | **yes** | | 1b | the destroy half of a create-before-destroy `REPLACE` | dependents first | no — terminal | | 2 | `DELETE` | dependents first | no — terminal | Phases 1b and 2 are terminal: recreating a destroyed resource would lose its identity and outputs, so a failure there is reported, not compensated. Committed stack outputs are persisted only after all three phases succeed. ## The compensation saga Under the default `--on-failure rollback`, every node that completes in phase 1 records an undo. On failure the undos run in **reverse completion order**, not reverse graph order, so they follow what this run actually did. | What was applied | What the undo does | |---|---| | `CREATE` | `provider.delete`, then drop the state row. The resource carries the outputs of its create — notably its id — so the delete acts on that resource directly rather than locating it by attributes, which could match an unrelated resource sharing them. | | `UPDATE` | `provider.update` back to the prior values, then restore the prior state row verbatim, still sealed. | | `REPLACE` | Undo the create half, then restore the prior resource and its recorded outputs. | The saga catches `BaseException`, not `Exception`. An interrupt arrives as a cancellation; catching only `Exception` would skip compensation on a Ctrl-C during an apply that has already created resources. ## When rollback does not run **A run that lost its lease is not rolled back.** A compensation is a provider call and a state write, and a run that no longer holds the lock may be racing the run that took it; undoing "its" creates could destroy the other run's resources. The resources are left in place, and the report states this. This is the only case in which `--on-failure rollback` does not roll back. Recover with `atlantide refresh`. ## When rollback itself fails An undo is a provider call followed by a state write. A failure between the two leaves state describing a resource that no longer exists. Because the diff is symbolic, that row's stored hash still matches config, so the next plan would report `NOOP` and the divergence would stay hidden permanently. To prevent this, the engine **poisons** the row before running a node's compensation: it clears the row's `input_hash` so the next plan cannot Merkle-skip it. The undo's own state write clears the poison when it succeeds. Poisoning afterwards would only take effect on an exception; a process killed mid-rollback would leave the row untouched and the divergence silent. When compensations fail, the original error and the rollback errors are raised together as a group, and every leaf is rendered. The rollback failure never replaces the apply failure. ## Interrupts The first Ctrl-C cancels the run and follows the failure path above: under the default, completed nodes are compensated on the way out. The exit code is `130`. A second Ctrl-C abandons that cleanup. Each compensation is *shielded*, so the press cannot cancel an undo between its provider call and its state write — the window poisoning makes visible — but the CLI's hard exit still takes effect. State may then not describe what exists: run `atlantide refresh` before applying again. The second press does **not** release the run's lock. An abandoned run may still have provider calls in flight, so the lease is left to lapse, or to be cleared with `atlantide state unlock`, rather than handed to a second writer. ## The re-diff before anything runs Apply does not execute the changeset that `plan` printed. Once it holds the lease, it re-diffs against freshly loaded state; otherwise a resource that another run created in the meantime would still be planned as a `CREATE` and built twice. The approved plan and the plan about to execute can therefore differ, and that difference could include an unreviewed destroy. When they differ, the run stops with a `PlanDriftError` naming what appeared and what disappeared. ## After a failed run ```bash atlantide refresh # what actually exists, vs what state records atlantide state list # what state records atlantide state unlock # who holds the lease ``` `refresh` is the recovery command in every case above. It is read-only unless given `--write`. A resource the provider cannot find is reported but kept unless `--prune` is also given. See [Troubleshooting](https://atlantide-org.github.io/reference/troubleshooting.md) for the errors these paths raise. # Building on AWS This guide builds a multi-environment AWS stack one resource at a time and introduces each authoring feature where it is first needed. The finished configuration has a shared VPC in a `common` stack, plus `dev` and `prod` stacks. Each environment stack holds an S3 bucket, an SQS queue, an IAM role with an inline policy, a Lambda function that reads a secret, and a security group attached to the shared VPC: fifteen resources across two providers, wired together by references. The end state is [`examples/aws/example-one.py`](https://github.com/atlantide-org/atlantide/blob/main/examples/aws/example-one.py); you can compare your file against it at any point. > **About the output in this guide** > > Every `validate`, `plan` and `graph` block below is output captured by > running the configuration at that step. These commands belong to the pure > half of the pipeline and require **no AWS credentials**, so you can follow > the guide without an AWS account and without creating anything. > > `apply` output is not shown, because producing it provisions real > infrastructure. Where an applied value would appear, the plan prints > `(known after apply)`. This guide assumes the [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md), which covers the same pipeline against local files in more depth. --- ## Before you start ```bash mkdir aws-demo && cd aws-demo ``` `plan` requires nothing further. `apply` requires AWS credentials (environment variables, `~/.aws/credentials`, or an instance role) with permission to create the resource types used below. --- ## 1. One bucket, one stack `infra.py`: ```python from atlantide.core import Stack, output from atlantide.providers.aws import Region, S3Bucket with Stack("dev", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "dev"}): assets = S3Bucket("assets", bucket="atlantide-assets-dev-7f3a91c2") output("assets_arn", assets.arn) ``` ```bash atlantide validate infra.py ``` ``` ok infra.py — 1 resource(s), no cycles ``` ```bash atlantide plan infra.py --state infra.db ``` ``` state: infra.db dev ──────────────────────────────────────────────────────────────────────────── + create aws.S3Bucket:assets Plan: 1 to add Outputs: dev:assets_arn = (known after apply) ``` | | | |---|---| | `Stack("dev", ...)` | A namespace. Every resource inside gets node id `dev::` and inherits the stack's `region`, `name_prefix` and `tags`. The same logical name can exist in every stack without colliding. | | `Region.EuNorth1` | An enum, so a typo is a type error in your editor instead of an API error at apply time. | | `output(...)` | Declared inside the `with` block, so it is namespaced `dev:assets_arn` and readable by another stack. Declared outside, it lands in `default`. | `S3Bucket` blocks public access and enables SSE-S3 encryption by default. A public or unencrypted bucket must be configured explicitly. --- ## 2. A queue, a role, and a policy wired by references Add an SQS queue, an IAM role, and an inline policy that grants the role access to the bucket and the queue: ```python from atlantide.core import Stack, output from atlantide.providers.aws import ( IamPolicy, IamRole, Region, S3Bucket, ServicePrincipal, SqsQueue, allow, ) with Stack("dev", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "dev"}): assets = S3Bucket("assets", bucket="atlantide-assets-dev-7f3a91c2") jobs = SqsQueue("jobs", fifo=True) worker = IamRole("worker", assumed_by=[ServicePrincipal.Ec2, ServicePrincipal.Lambda]) IamPolicy( "worker-policy", role_arn=worker.arn, policy_name="worker-access", statements=[ allow(S3Bucket.Action.GetObject, S3Bucket.Action.PutObject, on=assets.objects_arn), allow(SqsQueue.Action.SendMessage, on=jobs.arn), ], ) output("assets_arn", assets.arn) ``` ```bash atlantide plan infra.py --state infra.db ``` ``` state: infra.db dev ──────────────────────────────────────────────────────────────────────────── + create aws.IamPolicy:worker-policy + create aws.IamRole:worker + create aws.S3Bucket:assets + create aws.SqsQueue:jobs Plan: 4 to add ``` The config declares no ordering, but the engine derives one: ```bash atlantide graph infra.py --format mermaid ``` ```mermaid graph LR subgraph cluster0["dev"] n0["aws.IamPolicy:worker-policy"] n1["aws.IamRole:worker"] n2["aws.S3Bucket:assets"] n3["aws.SqsQueue:jobs"] end n1 --> n0 n2 --> n0 n3 --> n0 ``` The policy reads `worker.arn`, `assets.objects_arn` and `jobs.arn`. None of those ARNs exists yet, so each read returns a lazy `Ref`, and each `Ref` becomes a graph edge. The role, bucket and queue are independent and are created in parallel; the policy waits for all three. `allow()` builds the policy document, so no JSON is hand-written. Actions are enum members such as `S3Bucket.Action.GetObject`, so a misspelled action fails before anything runs. The statement uses `assets.objects_arn` rather than `assets.arn`, because object-level permissions require the `/*` form. The resource exposes both ARNs as named attributes. --- ## 3. A Lambda, fingerprinted from disk Create the handler: ```python title="processor/app.py" def handler(event, context): return {"received": len(event.get("Records", []))} ``` Add the function and give it the role: ```python processor = LambdaFunction( "processor", role_arn=worker.arn, handler="app.handler", code_path="processor", signing_secret=SecretRef("app/signing-key-dev"), ) ``` `code_path` points at a local directory. At config-evaluation time the directory is zipped with sorted entries and fixed timestamps, then fingerprinted into `code_sha256`. Two checkouts of the same source produce the same digest; editing `app.py` changes it, and the next plan shows an update. Plan: ```bash atlantide plan infra.py --state infra.db ``` ``` state: infra.db error: undefined secret(s): dev:aws.LambdaFunction:processor.signing_secret -> 'app/signing-key-dev' ``` `SecretRef("app/signing-key-dev")` names a secret that is not in the store. An apply would fail part-way through, after creating some resources, so Atlantide checks every referenced secret during planning. Set the secret: ```bash atlantide secret set app/signing-key-dev ``` ``` set secret 'app/signing-key-dev' ``` Without a value argument, the command prompts for it with hidden input. By default, secrets are held in a local AES-256-GCM encrypted keyfile store; `[secrets]` in `atlantide.toml` can point at SSM Parameter Store or the environment instead. ```bash atlantide plan infra.py --state infra.db ``` ``` state: infra.db dev ──────────────────────────────────────────────────────────────────────────── + create aws.IamPolicy:worker-policy + create aws.IamRole:worker + create aws.LambdaFunction:processor + create aws.S3Bucket:assets + create aws.SqsQueue:jobs Plan: 5 to add ``` The secret's value never appears in the config, the IR or the state file; only its name does. The plaintext is resolved in memory at apply time. State keeps a salted digest, which detects a rotated secret without storing the value. --- ## 4. A shared VPC, and a second environment Put the VPC that both environments share in its own stack: ```python with Stack("common", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "common"}): network = Vpc("network", cidr_block="10.0.0.0/16") vpc_id = output("vpc_id", network.vpc_id) ``` `output()` returns a typed handle. When you consume the variable instead of a string such as `"common:vpc_id"`, a typo is an undefined-variable error at config time instead of a mismatch found during planning. Declare the two environments and what differs between them: ```python from atlantide.core import Config, EnvSchema class AppEnv(EnvSchema): versioning: bool = False config = Config( AppEnv, envs={ "dev": { "region": Region.EuNorth1, "name_prefix": "atlantide", "tags": {"env": "dev"}, }, "prod": { "region": Region.EuNorth1, "name_prefix": "atlantide", "tags": {"env": "prod"}, "versioning": True, }, }, ) ``` Wrap the environment body in a loop over the environments, and attach a security group to the shared VPC: ```python for env in config.envs(): with Stack(env.name, config=env): ... edge = SecurityGroup("edge", group_name=f"edge-{env.name}", vpc_id=vpc_id) ``` `region`, `name_prefix` and `tags` are well-known keys of every environment, so `config=env` supplies all three. `versioning` is a config-specific variable with a default: `dev` uses the default and `prod` overrides it. The shape is an `EnvSchema`, the one class Atlas-lang admits (annotated fields only), so your editor completes `env.versioning` and a misspelling is a type error instead of a plan-time error. The `Config` is validated when it is constructed, so a missing or mistyped prod value fails `atlantide validate` in CI instead of when prod is applied. Because `common` is in the same config, the engine turns the output handle into a dependency edge and applies `common` before `dev` and `prod`. `dev` and `prod` are independent and apply in parallel. `common` sits *outside* the loop because it is not per-environment, so narrowing a run to one environment keeps it in the graph. > **Running one environment** > > `atlantide plan --env prod` acts on `prod` alone. `dev` is out of scope, not > undeclared: its recorded state is not diffed and is never planned for > deletion. The plan reports this: > > ``` > envs: prod (of dev, prod) — dev is not planned and will not change > ``` > **Two stacks, or two configs?** > > A stack applied by a *separate* config is referenced with > `StackReference("common").output("vpc_id")`, which resolves from committed > state instead of from an edge within the run. Use the handle when the stacks > share a config, and `StackReference` when they do not. --- ## 5. Deterministic names, and a value decided at apply Two more lines in the environment body: ```python build = Id("build-id", byte_length=4) bucket_name = f"atlantide-assets-{env.name}-{uuid5('atlantide-buckets', env.name)[:8]}" assets = S3Bucket( "assets", bucket=bucket_name, versioning=env.versioning, tags={"build_id": build.result, "config_hash": sha256_hex(env.name)[:12]}, ) ``` The two values are decided at different times: | | When it is decided | Where it lives | |---|---|---| | `uuid5(...)`, `sha256_hex(...)` | **Config time.** Atlas-lang builtins — pure functions the interpreter injects, used with no import. | A fixed literal baked into the IR. | | `random.Id` | **Apply time.** A *resource*, from the `random` provider. Generated once and pinned in state. | State. A re-apply is a NOOP, never a new value. | The bucket name is stable across every run and machine; the build id is stable across every run after the first. The bucket reads `build.result`, so the engine orders the `random` resource before the AWS bucket in a single graph spanning two providers. `versioning=env.versioning` reads the value from the environment instead of branching on its name. Per-environment differences are declared once, typed, in the `Config`, instead of as `env == "prod"` tests in the body. --- ## 6. Policies, at the top of the file ```python enforce("require-tags", keys=["env"]) enforce("deny-destroy-in-protected", stacks=["prod"]) ``` Policies run at the end of planning against the finished changeset, so they see the planned actions as well as the config. A mandatory violation blocks `apply` before anything is changed. To trigger the first policy, remove the `env` tag from the stacks and plan: ``` policy DENY require-tags: dev:aws.S3Bucket:assets is missing tag(s): env policy DENY require-tags: dev:aws.SqsQueue:jobs is missing tag(s): env policy DENY require-tags: dev:aws.SecurityGroup:edge is missing tag(s): env policy DENY require-tags: dev:aws.LambdaFunction:processor is missing tag(s): env ... 10 mandatory policy violation(s) block apply ``` Every offending resource is named. Stack tags merge into every resource in the body, so restoring `tags={"env": env}` on the two stacks resolves all ten violations. The second policy, `deny-destroy-in-protected`, fails any plan that deletes or replaces something in `prod`. This includes removing a resource from the file and editing an `immutable()` field, since a replacement is a destroy followed by a create. > **It does not cover `atlantide destroy`** > > `destroy` runs against an empty config and therefore evaluates no policy > bindings. `Lifecycle(prevent_destroy=True)` is the per-resource guard for > that path. --- ## 7. The finished config Add the exports for each environment: ```python output("assets_arn", assets.arn) output("assets_bucket", assets.bucket) output("jobs_url", jobs.url) output("worker_role_arn", worker.arn) output("processor_arn", processor.arn) output("build_id", build.result) output("edge_sg_id", edge.group_id) output("region", Region.EuNorth1) ``` ```bash atlantide plan infra.py --state infra.db ``` ``` state: infra.db common ───────────────────────────────────────────────────────────────────────── + create aws.Vpc:network dev ──────────────────────────────────────────────────────────────────────────── + create aws.IamPolicy:worker-policy + create aws.IamRole:worker + create aws.LambdaFunction:processor + create aws.S3Bucket:assets + create aws.SecurityGroup:edge + create aws.SqsQueue:jobs + create random.Id:build-id prod ─────────────────────────────────────────────────────────────────────────── + create aws.IamPolicy:worker-policy + create aws.IamRole:worker + create aws.LambdaFunction:processor + create aws.S3Bucket:assets + create aws.SecurityGroup:edge + create aws.SqsQueue:jobs + create random.Id:build-id Plan: 15 to add Outputs: common:vpc_id = (known after apply) dev:assets_arn = (known after apply) dev:assets_bucket = 'atlantide-assets-dev-b89f6cc5' dev:jobs_url = (known after apply) dev:worker_role_arn = (known after apply) dev:processor_arn = (known after apply) dev:build_id = (known after apply) dev:edge_sg_id = (known after apply) dev:region = 'eu-north-1' prod:assets_arn = (known after apply) prod:assets_bucket = 'atlantide-assets-prod-445ca963' ... ``` `assets_bucket` and `region` already have values because both are computed at config time. The other outputs read `(known after apply)`. The full graph: ```mermaid graph LR subgraph cluster0["common"] n0["aws.Vpc:network"] end subgraph cluster1["dev"] n1["aws.IamPolicy:worker-policy"] n2["aws.IamRole:worker"] n3["aws.LambdaFunction:processor"] n4["aws.S3Bucket:assets"] n5["aws.SecurityGroup:edge"] n6["aws.SqsQueue:jobs"] n7["random.Id:build-id"] end subgraph cluster2["prod"] n8["aws.IamPolicy:worker-policy"] n9["aws.IamRole:worker"] n10["aws.LambdaFunction:processor"] n11["aws.S3Bucket:assets"] n12["aws.SecurityGroup:edge"] n13["aws.SqsQueue:jobs"] n14["random.Id:build-id"] end n2 --> n1 n4 --> n1 n6 --> n1 n2 --> n3 n7 --> n4 n0 --> n5 n9 --> n8 n11 --> n8 n13 --> n8 n9 --> n10 n14 --> n11 n0 --> n12 ``` One VPC feeds two independent environment subgraphs. The ordering follows from which resources read which values. --- ## 8. Apply, and what happens after ```bash atlantide secret set app/signing-key-prod # prod needs its own atlantide apply infra.py --state infra.db ``` Apply takes a lease over the reachable graph, re-diffs under that lock, and reconciles independent nodes in parallel. State is written per node as each one succeeds, so a crash part-way through leaves an accurate record. On failure, the default `--on-failure rollback` undoes the completed nodes. A second run makes every node a no-op with no AWS API calls: the engine compares hashes and stops. After applying: ```bash atlantide output --state infra.db # what the apply exported atlantide state list --state infra.db # what is recorded atlantide refresh --state infra.db # has anything drifted? atlantide destroy --state infra.db # tear it down, dependents first ``` --- ## Making it a project Put the defaults in an `atlantide.toml` beside the config to omit the flags: ```toml config = "infra.py" state = "infra.db" aws_region = "eu-north-1" aws_profile = "default" ``` ```bash atlantide plan # no flags needed ``` For a team, move state to a shared backend (S3 with DynamoDB locking, or Postgres) so concurrent runs cannot overwrite each other. See [Remote state](https://atlantide-org.github.io/reference/remote-state.md). A `[profile.]` overlay is separate from the `Config`: the `Config` defines what each environment contains, and a profile defines where a run points (state backend, AWS account, parallelism). They are selected independently with `--env` and `--profile`. See [Configuration](https://atlantide-org.github.io/reference/configuration.md). --- ## Where to go next - **[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md)**: why the second apply makes no API calls, and which nodes a change propagates to. - **[Authoring](https://atlantide-org.github.io/reference/authoring.md)**: output combinators for values not known until apply, explicit ordering, and renaming a resource without destroying it. - **[Components](https://atlantide-org.github.io/reference/components.md)**: packaging this stack as a reusable construct, publishable from a git repository and pinned by commit. - **[Providers](https://atlantide-org.github.io/reference/providers.md)**: the full AWS resource list, and how to write a provider. - **A static website**: [`examples/aws/example-three.py`](https://github.com/atlantide-org/atlantide/blob/main/examples/aws/example-three.py) builds a private S3 origin behind CloudFront with an Origin Access Control, in four resources. # For agents Every page on this site is also published as plain Markdown for coding agents, along with a short guide for agents that edit a config. ## Give your agent the guide [`SKILL.md`](https://atlantide-org.github.io/agents/SKILL.md) covers the Atlas-lang restrictions, how dependencies and `Ref`s work, the `validate` → `plan` loop, which commands are safe to run unasked, and what each error means. It is reproduced [below](#the-guide). **Claude Code** Install it as a project skill; Claude Code loads it whenever a task touches the config: ```bash mkdir -p .claude/skills/atlantide curl -fsSL https://atlantide-org.github.io/agents/SKILL.md \ -o .claude/skills/atlantide/SKILL.md ``` **AGENTS.md** For Codex, Cursor, Copilot, Aider and anything else that reads `AGENTS.md`, append the same guide without its frontmatter: ```bash curl -fsSL https://atlantide-org.github.io/agents/AGENTS.md >> AGENTS.md ``` **Anything else** Paste the guide into the system prompt, or point the agent at `https://atlantide-org.github.io/llms.txt` and let it follow the links. Commit the file with the project so every contributor's agent uses the same rules. ## Machine-readable docs | URL | What it is | |---|---| | [`/llms.txt`](https://atlantide-org.github.io/llms.txt) | An index of every page as Markdown, with a one-line description each, in the [llms.txt](https://llmstxt.org) format. | | [`/llms-full.txt`](https://atlantide-org.github.io/llms-full.txt) | Every page in one file, in navigation order. | | `/.md` | Any single page: replace the trailing `/` of its URL with `.md`, e.g. [`/reference/cli.md`](https://atlantide-org.github.io/reference/cli.md). | | [`/agents/SKILL.md`](https://atlantide-org.github.io/agents/SKILL.md) | The guide, with Claude Code skill frontmatter. | | [`/agents/AGENTS.md`](https://atlantide-org.github.io/agents/AGENTS.md) | The same guide, without it. | The Markdown versions are generated from the same sources as these pages, with admonitions turned into blockquotes, tabs into labelled blocks, and every link made absolute. Each page also advertises its Markdown with ``, and the buttons at the top right of every page view it or copy it to the clipboard. ## Let the CLI answer The installed CLI reports the resource types and schemas available in that install. The guide tells agents to query it: ```bash atlantide resources # every resource type this install can see atlantide schema aws.S3Bucket # one type's fields, mutability and defaults atlantide validate --json # compile, with a machine-readable result atlantide plan --json --detailed-exitcode ``` `--json` output and exit codes are covered under [CI and automation](https://atlantide-org.github.io/reference/ci.md). ## The guide ````markdown --- name: atlantide description: Write, validate and plan Atlantide infrastructure configs (infra.py, atlantide.toml). Use when a project contains atlantide.toml or imports atlantide, or when asked to add, change or debug infrastructure declared with Atlantide. --- # Atlantide Atlantide is typed, deterministic Infrastructure-as-Code for Python. A config (`infra.py` by default) is ordinary Python syntax executed by **Atlas-lang**, a bounded interpreter with no clock, randomness, environment, network or filesystem. Same config + same inputs = byte-identical plan. Full docs as Markdown: https://atlantide-org.github.io/llms.txt (`llms-full.txt` is everything in one file.) ## Safety - `validate`, `plan`, `graph`, `resources`, `schema`, `providers`, `output` and `state list`/`state show` are read-only. Run them freely. - `apply`, `deploy`, `destroy`, `import`, `refresh --write`, `state rm`, `state restore`, `state migrate` and `state unlock` change real infrastructure or its record. **Do not run them unless the user asked for that specific action.** Never pass `-y`/`--confirm` on their behalf. - Never put a secret value in config, in `-var`, or in `atlantide.toml`. Use `atlantide.secret("name")`; the user sets the value with `atlantide secret set name`. - Do not commit `atlantide.key`, `atlantide.secrets`, `atlantide.db*` or `.atlantis/`. Do commit `atlantide.lock`. ## The loop 1. Find the project root: the nearest `atlantide.toml` going upwards. It names the config file and state backend, so commands need no flags inside it. 2. Discover what exists — from the installed CLI, not from memory: ```bash atlantide resources # every resource type this install can see atlantide schema aws.S3Bucket # fields, types, mutability, defaults ``` 3. Edit the config. 4. `atlantide validate --json` — subset check, graph, cycles. No credentials. 5. `atlantide plan --json --detailed-exitcode` — exit `0` no changes, `2` changes pending, `1` error or policy denial. 6. Report the plan to the user. Pay attention to every `REPLACE` and `DELETE`: `REPLACE` means an `immutable()` field changed and the resource will be destroyed and recreated. For the full traceback, put `--debug` before the command: `atlantide --debug plan`. The same goes for the other global options, `--profile/-P`, `--log-level`, `--audit-log` and `--no-plugins`. ## Atlas-lang: what config may not do The subset is enforced before evaluation. Rejected: | Construct | Use instead | |---|---| | `while` | bounded `for` | | `class` | only a module-level `EnvSchema` subclass with annotated fields; components and resource types live in Python packages | | `try` / `raise` / `assert` | guard with `if` | | `match` | `if` / `elif` | | `async` / `await`, `yield` | plain functions, comprehensions | | `global` / `nonlocal`, `:=`, `del` | plain assignment | | `import os`, `time`, `random`, … | only `atlantide.*` imports are allowed | | `open`, `eval`, `exec`, `getattr`, `setattr`, `hasattr`, `globals`, `vars` | not available | Allowed: functions, lambdas, `if`, `for`, comprehensions, f-strings, `with`. Evaluation runs on a fuel budget; runaway recursion ends in `FuelExhaustedError`. Pure helpers are globals (no import): `slugify`, `uuid5`, `sha256_hex`, `hmac_sha256_hex`, `b64encode`, `b64decode`, `to_json`, `from_json`, `merge`. The `atlantide` global gives `atlantide.input(name, default)` and `atlantide.secret(name)`. ## Shape of a config ```python from atlantide.core import Config, EnvSchema, Lifecycle, Stack, concat, output from atlantide.policy import enforce from atlantide.providers.aws import S3Bucket, SqsQueue enforce("require-tags", keys=["env"]) # top level, outside any Stack enforce("deny-destroy-in-protected", stacks=["prod"]) class AppEnv(EnvSchema): # annotated fields only versioning: bool = False config = Config(AppEnv, envs={ "dev": {"region": "eu-north-1", "tags": {"env": "dev"}}, "prod": {"region": "eu-north-1", "tags": {"env": "prod"}, "versioning": True}, }) for env in config.envs(): with Stack(env.name, config=env, name_prefix="acme"): assets = S3Bucket( "assets", versioning=env.versioning, lifecycle=Lifecycle(prevent_destroy=True), ) jobs = SqsQueue("jobs", fifo=True) output("assets_arn", assets.arn) output("assets_objects", concat(assets.arn, "/*")) ``` ## Rules that matter - **Dependencies come from reads.** `assets.arn` returns a lazy `Ref`, and reading it creates the graph edge. Use `depends_on=[x]` only when the dependency carries no value. - **A `Ref` is not a string.** Build strings from one with `concat(...)`, `interpolate("https://{}", ref)` or `join(sep, parts)` from `atlantide.core`. `"x" + ref` fails at evaluation, but **an f-string such as `f"{ref}"` validates and plans cleanly and writes the literal text `Ref(node_id=..., attr=...)` into the resource.** Never put a `Ref` in an f-string. - **Logical names are identifiers**, unique per type within a stack. Node ids are `{stack}:{type}:{name}`, e.g. `prod:aws.S3Bucket:assets`. - **Renaming a resource destroys it** unless the new one carries `lifecycle=Lifecycle(aliases=("old_name",))`. - **Per-environment differences go in `Config`**, as typed `EnvSchema` fields — not as branches on the stack name and not as inputs. `region`, `tags` and `name_prefix` are implicit keys every environment may set. - **Per-run values** (a build number, a fork prefix) are `atlantide.input("name", default)` and arrive as strings. - **`with region("us-east-1"):`** overrides the region for one block, e.g. an ACM certificate for CloudFront. - **Another config's stack:** `StackReference("network").output("vpc_id")`. - **Data sources** such as `AwsCallerIdentity` and `AwsAvailabilityZones` read account facts; use them instead of hardcoding zone letters or account ids. - **`Subnet` requires `availability_zone`**; `LambdaFunction` requires `code_path=` or `s3_bucket=` + `s3_key=`; `S3Bucket` needs `force_destroy=True` to be deletable while non-empty. ## Commands ```bash atlantide init [--template minimal|aws] # scaffold; minimal needs no credentials atlantide validate # compile only atlantide plan [--env prod] [-t ] # preview; --env and -t narrow the run atlantide graph --format mermaid # dependency graph atlantide output [name] # values exported by the last apply atlantide state list | state show # what state records atlantide refresh # read-only drift report ``` `--json` is supported by `plan`, `apply`, `validate`, `refresh`, `import`, `init`, `output`, `providers`, `state list` and `state show`. ## Errors | Error | Meaning and fix | |---|---| | `LanguageError` | Construct outside Atlas-lang; the message names the line and the replacement. | | `FuelExhaustedError` | Runaway recursion or an oversized loop. | | `IRError` | A field value is not JSON-shaped data. | | `RegistryError` | Duplicate logical name, unknown type, missing provider, or unknown component alias. | | `CycleError` | Two resources read from each other; pass a literal on one side. | | `PolicyViolationError` | A mandatory policy failed; fix the resource (usually missing tags). | | `PreventDestroyError` | The plan destroys a `prevent_destroy` resource; surface to the user, do not remove the guard yourself. | | `LockError` | Another run holds the state lease; wait. | | `PlanDriftError` | State changed after the plan was shown; re-plan. | | `RollbackError`, `LeaseLostError` | State may not match reality; tell the user to run `atlantide refresh`. | A plan showing changes nobody made means config or *state* changed — the diff is against recorded inputs, not the live cloud. `refresh` compares with reality. ```` # Glossary Terms used across these pages, in the order the pipeline meets them. **Atlas-lang** — the bounded interpreter that executes a config. Python syntax, without any construct that would let the result depend on anything other than the config text and its declared inputs: no clock, randomness, environment, network or filesystem. See [what config may not do](https://atlantide-org.github.io/reference/authoring.md#what-config-may-not-do). **Fuel** — the step budget for an evaluation. A runaway computation stops with a `FuelExhaustedError` instead of hanging. **Input** — a per-run value the config may branch on, supplied by `-var`, `--var-file` or `[inputs]`. The determinism guarantee is over *(config, inputs, selected environments)*, not config alone. Not for secrets. **Config** — the checked-in environment matrix: every environment and what differs between them, declared once as `Config(AppEnv, envs=)` and validated on construction. An input, by contrast, is a per-run value from outside the repository. See [Environments](https://atlantide-org.github.io/reference/configuration.md#environments). **`EnvSchema`** — the shape of one environment, declared as a class of annotated fields. It is the only class Atlas-lang admits, because it is a declaration, not a computation. It makes `env.domain` complete in an editor and `env.domian` a type error. The untyped alternative is a mapping of `var()` declarations. **Environment** — one entry in a `Config`. Its name is its stack's name, and `--env` narrows a run to it. An environment left out is *out of scope*, not undeclared: its state is not diffed and is never planned for deletion. **Resource** — one managed thing, declared with a logical name. Its fields carry their own mutability. **Logical name** — the name you give a resource in config. **Node id** — what it becomes once scoped: `{stack}:{type}:{name}`, e.g. `prod:aws.S3Bucket:assets`. Most commands accept a short form or a glob in place of a full id. **Stack** — a namespace scoping region, tags and name prefix over the resources declared in its body. **Component** — several resources behind one parameterized object. Written in Python, used by config, never defined by it. The children become ordinary flat resources named `{component}-{child}`. **Ref** — the lazy handle returned by reading a computed field. The read creates the dependency edge; the `Ref` resolves to a real value at apply. **Mutability** — each field is `mutable()` (in-place `UPDATE`), `immutable()` (`REPLACE`) or `computed()` (never diffs; produced by the provider). **IR** — the intermediate representation: the content-hashed, provider-stamped graph a config lowers to. Everything downstream works on the IR, not on the source. **Canonical form** — RFC 8785 JSON. Fixes key order, number formatting and string escaping, so the same IR always serializes to the same bytes and the content hash is stable. **`input_hash`** — a node's Merkle hash: its own canonical inputs folded together with its dependencies' hashes. Unchanged hash, no provider call. See [The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md). **Symbolic ref** — the hash is computed over the *reference* to an upstream value, not over the resolved value (which does not exist yet at plan time). **Diff** — the comparison of desired `input_hash`es against those recorded in state. It compares against *recorded* inputs, not the live world; comparing against the live world is [`refresh`](https://atlantide-org.github.io/reference/cli.md#detecting-drift). **Changeset** — the result: one action per node — `NOOP`, `CREATE`, `UPDATE`, `REPLACE` or `DELETE`. **Conditional replace** — a `REPLACE` caused by an immutable field holding a `Ref`. It becomes a no-op if the value resolves unchanged at apply. **Plan** — the ordered, guarded, policy-checked changeset. **Policy** — a check over one node's pending change, evaluated at plan time. `MANDATORY` blocks, `ADVISORY` warns. See [Policies](https://atlantide-org.github.io/reference/policies.md). **Lease / lock** — the time-bounded claim a run takes on state before writing. Renewed while the run lives; lapses if it dies. **Fenced write** — a state write that succeeds only if the writer is still the recorded lease holder. The store makes the check, not the writer, which prevents two concurrent runs from silently merging state. **Compensation** — the undo recorded for a completed node, run in reverse completion order when an apply fails. See [When apply fails](https://atlantide-org.github.io/how-it-works/failure.md). **Poisoned row** — a state row whose `input_hash` is cleared so the next plan cannot Merkle-skip it. Set before a compensation runs, so a rollback that fails mid-way cannot leave a silent divergence. **Plan drift** — the changeset about to execute differs from the one that was approved, because state moved between the two. Raised, not reconciled. **Drift** — the live world differing from what state records. Found by `refresh`, and only in the fields a provider's `read` reports. **Artifact** (`.atlas`) — a compiled, content-hashed config with provider versions pinned. `deploy` applies it without re-executing the config, so identical bytes are promoted between environments. **Output** — a value exported with `output()`, namespaced by its stack and persisted into state after apply. Read back with `atlantide output`, or from another stack via `StackReference`. **Secret** — a name, not a value. `atlantide.secret("x")` returns a `SecretRef`; the plaintext is resolved in-memory at apply and never reaches the IR, the artifact or state.