OpenTofu & Terraform
The ataila/ataila provider
Manage an ATAILA Cloud Platform installation as code, through its versioned API, with an API token minted in its portal. One provider, one configuration and one state for both CLIs.
The provider acts with the token you give it, and a token is an operator credential: read why. In CI, give the provider a service-account token from a protected variable, with only the scopes the configuration needs.
Both CLIs, first-class
Every change is tested against the oldest and the newest supported release of each CLI: OpenTofu 1.6.0 and 1.12.6, Terraform 1.6.0 and 1.16.4. To stay usable from both, the provider uses nothing that exists in only one CLI or only at some versions: no actions, no ephemeral resources, no write-only attributes and no provider functions. It speaks plugin protocol 6.
Install
Version 1.0.0, the first public release, was published on 2026-10-02 to both registries, under the same address and signed with the same key:
- OpenTofu: search.opentofu.org/provider/ataila/ataila (address
registry.opentofu.org/ataila/ataila) - Terraform: registry.terraform.io/providers/ataila/ataila (address
registry.terraform.io/ataila/ataila)
source = "ataila/ataila" resolves to the first under OpenTofu and to the second under Terraform, so one configuration serves both CLIs. Declare it with ~> 1.0: within 1.x nothing is removed or renamed.
terraform {
required_providers {
ataila = {
source = "ataila/ataila"
version = "~> 1.0"
}
}
} tofu init terraform init Source and support
The source is at github.com/ataila/terraform-provider-ataila, a public, read-only mirror of the primary repository; its issue tracker is off. Every release is there too, with the signed files the registries read and the air-gapped bundle. Problems and questions: support@ataila.com; quote the provider version and the request_id of the error.
Configure
# The token is best kept out of files: export ATAILA_TOKEN instead.
provider "ataila" {
endpoint = "https://portal.example.com"
# Only needed when the portal's certificate comes from a private
# certificate authority.
ca_cert_file = "ca.pem"
} | Argument | Environment | Default | Meaning |
|---|---|---|---|
endpoint | ATAILA_ENDPOINT | — | Base URL of the portal, for example https://portal.example.com. /api/v1 is appended; a trailing /api/v1 is accepted. HTTPS is required except for a loopback address. |
token | ATAILA_TOKEN | — | API token: personal (ataila_pat_…) or service account (ataila_sat_…). Sensitive. Prefer the environment variable. |
ca_cert_file | ATAILA_CA_CERT | — | PEM file of a private certificate authority, trusted in addition to the system roots. The environment variable takes a path or PEM text. |
ca_cert_pem | | — | The same certificate authority as PEM text. Conflicts with ca_cert_file. |
allow_destroy | | false | Allow destroying platform objects. A destroy needs this and a token minted with destroy allowed. |
request_timeout | | 60s | Timeout of one HTTP request; each retry gets its own. |
With the endpoint and the token in the environment, the same commands work with either CLI:
export ATAILA_ENDPOINT="https://portal.example.com"
export ATAILA_TOKEN="<token from the portal>"
tofu init
tofu plan
tofu apply export ATAILA_ENDPOINT="https://portal.example.com"
export ATAILA_TOKEN="<token from the portal>"
terraform init
terraform plan
terraform apply Destroy needs two switches
Destroy is off by default. It needs allow_destroy = true on the provider and a token minted with Allow destroy. With the provider switch off, destroying a gated object fails at plan time, before any request, and the error explains both switches. With the provider switch on and a token without the flag, the platform refuses. A refusal by the platform itself (a customer that still has projects, a tenant that is not empty) comes with the platform's code and what blocks it.
Even then, destroy never wipes: customers are archived, users deactivated, projects retired. Several resources are only forgotten by a destroy, because removing them would re-brand, unlicense or repoint a live installation. To stop managing an object without destroying it, remove it from the state:
tofu state rm ataila_customer.example terraform state rm ataila_customer.example | Resource | What it manages | Destroy |
|---|---|---|
ataila_customer | A customer (company), with its primary tenant and GitLab group | Archives; gated |
ataila_tenant | A further tenant of a customer | Deletes an empty tenant; gated |
ataila_tenant_membership | A user's role in a tenant | Removes the membership |
ataila_user | A person: created without a password, provisioned by the platform | Deactivates; gated |
ataila_user_role_grant | One role held by one user (additive) | Removes the role |
ataila_ai_gateway_key | A virtual key of the AI gateway; value returned once, rotation by trigger | Deletes in the gateway, irreversibly; not gated |
ataila_ai_serving_tier | The pin and enabled flag of an existing serving tier | Forgets only |
ataila_project | A project record and its settings; provisions nothing | Retires; gated |
ataila_project_provisioning | Runs a project's provisioning and waits until it is converged | Forgets only |
ataila_project_member | A user's role in a project | Removes the membership |
ataila_licence_bundle | The licence bundle installed on the platform (singleton) | Forgets only |
ataila_brand | The platform's brand: name, colour, title, logo (singleton) | Forgets only |
ataila_brand_asset | A logo or favicon, content-addressed; readable without signing in | Forgets only |
ataila_release_promotion | A promotion request of a Kubernetes project's component into dev, uat or prod | Forgets only |
ataila_project_prod_lock | A project's PROD data lock | Forgets only |
ataila_ai_model | An AI model catalogue row (no weights) | Removes the row; refused while weights exist; not gated |
ataila_ai_model_node_cache | A cached copy of a model's weights on an AI node | Removes the node's copy |
"Gated" means both switches are needed.
Examples
One example per area, as shipped with the provider. They build on each other (the tenant belongs to the customer, the project to the tenant), so together they form one configuration.
Customer and tenant since 1.0.0
Destroying a customer archives it; destroying a tenant deletes it only when it is empty and not the customer's primary. Both are gated. Destroying a membership removes it and needs no allow_destroy.
# A customer, with its primary tenant and GitLab group created alongside.
resource "ataila_customer" "example" {
short_name = "EXAMPLE"
long_name = "Example Holdings Ltd"
gitlab_group = "example"
primary_contact_email = "it@example.com"
primary_contact_name = "Example IT Desk"
# Optional. Omit customer_index and the platform allocates the next free one.
edition = "sp"
billing_tier = "PAYING"
notes = "Managed with OpenTofu / Terraform."
}
output "primary_tenant_id" {
value = ataila_customer.example.primary_tenant_id
}
resource "ataila_tenant" "builds" {
customer_id = ataila_customer.example.id
slug = "example-builds"
name = "Build Farm"
description = "CI runners and build caches."
}
variable "developer_user_id" {
description = "Id of a platform user, as the portal shows it."
type = string
}
resource "ataila_tenant_membership" "developer" {
tenant_id = ataila_tenant.builds.id
user_id = var.developer_user_id
role = "member"
} User and role grant since 1.0.0
Users are deactivated, never deleted. A token grants only roles it carries itself, and never admin, founder or ssh-console.
# A person. No password is sent or returned: they sign in after a password
# reset in the portal or a self-service reset.
resource "ataila_user" "dana" {
email = "dana.example@example.com"
first_name = "Dana"
last_name = "Example"
locale = "en"
needs_git_access = true
}
output "dana_username" {
value = ataila_user.dana.username
}
# One role for one person. The token applying this must itself carry the role.
resource "ataila_user_role_grant" "dana_reads_users" {
user_id = ataila_user.dana.id
role = "users-read-global"
} Project, provisioning and member since 1.0.0
A project is a record first. Provisioning is its own resource, which waits until every stage is done. Destroying a project retires it (gated); its index, short name and domain stay reserved.
# A project record. Creating it provisions nothing: it stays "planned" until
# an ataila_project_provisioning runs.
resource "ataila_project" "shop" {
tenant_id = ataila_tenant.builds.id
short_name = "shop"
gitlab_repo_slug = "shop-app"
primary_domain = "shop.example.com"
long_name = "Example Shop"
# Settings left out get the platform's default at create.
frontend_variant = "vue"
enable_ai = true
}
output "shop_frontend_url" {
value = ataila_project.shop.urls.frontend
}
# Provisions the project and waits until every stage is done. After a change
# of the project leaves stages stale, the next plan shows an in-place update
# here, and applying it re-applies just those stages.
resource "ataila_project_provisioning" "shop" {
project_id = ataila_project.shop.id
timeouts {
create = "90m"
update = "30m"
}
}
output "shop_provisioned" {
value = ataila_project_provisioning.shop.provisioned
}
variable "developer_user_id" {
description = "Id of a platform user who is a member of one of the customer's tenants."
type = string
}
resource "ataila_project_member" "developer" {
project_id = ataila_project.shop.id
user_id = var.developer_user_id
role = "developer"
gitlab_role = "developer" # recorded, not enforced yet
} Release promotion since 1.0.0
Releases are requests: the provider books a promotion and follows it, and never decides a go-live. A production request waits for a person in the portal. Kubernetes projects only.
# DEV deploys a named build.
resource "ataila_release_promotion" "api_dev" {
project_id = ataila_project.shop.id
component = "app-api"
target_env = "dev"
version = "1.4.0"
}
# UAT takes the version DEV last reported.
resource "ataila_release_promotion" "api_uat" {
project_id = ataila_project.shop.id
component = "app-api"
target_env = "uat"
depends_on = [ataila_release_promotion.api_dev]
}
# PROD is a request a person approves in the portal; wait for the decision.
resource "ataila_release_promotion" "api_prod" {
project_id = ataila_project.shop.id
component = "app-api"
target_env = "prod"
wait_for_approval = true
timeouts {
create = "4h"
}
depends_on = [ataila_release_promotion.api_uat]
} PROD data lock since 1.0.0
Unlocking needs the project's short name as a confirmation. Destroying the resource only forgets it.
resource "ataila_project_prod_lock" "shop" {
project_id = ataila_project.shop.id
locked = true
}
# To unlock:
# locked = false
# confirm_unlock = "shop" # the project's short name AI gateway key and serving tier since 1.0.0
Needs an installation with an AI gateway. The key's value is returned only by the create or rotation that produced it, and only with expose_secret. Destroying a tier only forgets it.
# A virtual key for one tenant's chatbot. Needs a platform with an AI gateway.
resource "ataila_ai_gateway_key" "chatbot" {
organization_id = ataila_customer.example.primary_tenant_id
env = "prod"
app = "chatbot"
models = ["general", "code"]
rpm_limit = 120
soft_budget_usd = 50
budget_duration = "30d"
# Return the value once, into the sensitive `secret` attribute. It is in
# the platform's secrets store at `secret_path` either way.
expose_secret = true
# Change this to rotate the key.
rotation_trigger = {
at = "2026-10"
}
}
output "chatbot_key" {
value = ataila_ai_gateway_key.chatbot.secret
sensitive = true
}
# Pin the "code" tier to one served model. Destroying this only forgets it.
resource "ataila_ai_serving_tier" "code" {
key = "code"
pinned_model = "model-code-large"
enabled = true
} AI model and node cache since 1.0.0
A catalogue row carries no weights. Removing the row is refused while weights exist. A node cache copies weights the platform already holds onto one node.
# A catalogue row: the weights are pulled in the portal, not here.
resource "ataila_ai_model" "coder" {
repo = "example-lab/example-coder-32B"
vendor = "Example Lab"
license = "apache-2.0"
param_count_b = 32
category = "code"
notes = "Candidate for the code tier."
}
# Copies the weights from the central store to the node's local disk.
resource "ataila_ai_model_node_cache" "coder_on_ai_a" {
model_id = ataila_ai_model.coder.id
node = "ai-a"
timeouts {
create = "6h"
}
} Licence bundle since 1.0.0
The bundle is compared by the digest of its document: the installed one is adopted without being sent again, and an older one is refused. Destroy only forgets.
# The bundle issued for this platform, kept next to the configuration.
resource "ataila_licence_bundle" "this" {
bundle = trimspace(file("${path.module}/licence.acplic1"))
}
output "licence_state" {
value = ataila_licence_bundle.this.state
} Brand and brand assets since 1.0.0
Every brand write names the version last read, so a change made in the portal since then is reported, never overwritten. Assets are content-addressed; destroy only forgets.
# Everything uploaded is readable without signing in: upload only public files.
resource "ataila_brand_asset" "logo" {
kind = "logo"
source = "${path.module}/brand/logo.png"
}
resource "ataila_brand_asset" "favicon" {
kind = "favicon"
source = "${path.module}/brand/favicon.ico"
}
resource "ataila_brand" "this" {
product_name = "Example Cloud"
product_name_accent = "Cloud"
brand_color = "#1e88e5"
page_title = "Example Cloud portal"
logo_size = "medium"
logo_asset_id = ataila_brand_asset.logo.id
} Data sources
ataila_meta | API version, platform version, licence tier, tenancy mode, licensed modules, licence state |
ataila_whoami | The calling principal, how it authenticated, its effective scopes, token details and expiry |
ataila_customer | One customer, by id, short name or GitLab group |
ataila_tenant | One tenant, by id or slug |
ataila_tenants | Every tenant, or every tenant of one customer (all pages) |
ataila_user | One user, by id, e-mail address or username |
ataila_users | Users filtered by address, username, kind, state, tenant, customer or role (all pages) |
ataila_permission_catalog | Every permission key, with whether it is grantable and mintable |
ataila_ai_serving_tiers | The AI gateway's serving tiers and how each resolves now |
ataila_ai_gateway | The AI gateway's base URL and tier names |
ataila_project | One project, by id or short name, with its settings, outputs and stale stages |
ataila_projects | Project summaries filtered by tenant, customer, status or short name (all pages) |
ataila_project_stages | A project's provisioning stages with dependencies and latest runs |
ataila_licence | The licence state, tier, modules, term and document digest (never the bundle) |
ataila_licence_socket_facts | The socket census and whether its hash chain is intact |
ataila_brand | The platform's brand |
ataila_brand_asset | One brand asset, by id or sha256 |
ataila_release_state | Last reported versions per environment, the PROD data lock, open operations |
ataila_release_operation | One release operation (a promotion or a data copy) |
ataila_ai_model | One AI model, by id or repo |
ataila_ai_models | The model catalogue, filtered by repo, status, category or gateway tier (all pages) |
ataila_ai_model_storage | Central-store shares and node disks, as last scanned |
ataila_ai_load_targets | Nodes and DGX clusters a model can be served on, with their VRAM budget |
ataila_ai_nodes | The AI fleet, with monitoring_reachable |
ataila_ai_node | One AI node, by hostname |
ataila_dgx_clusters | The DGX clusters as recorded |
ataila_ai_model_launch_catalog | The models each node can launch |
Import existing objects
Everything the portal already holds can be brought under management with an import. The platform's own projects are read-only and refused.
tofu import ataila_customer.example short_name:EXAMPLE
tofu import ataila_user.dana email:dana.example@example.com
tofu import ataila_brand.this current terraform import ataila_customer.example short_name:EXAMPLE
terraform import ataila_user.dana email:dana.example@example.com
terraform import ataila_brand.this current | Resource | Import id |
|---|---|
ataila_customer | the id, or short_name:<SHORT_NAME> |
ataila_tenant | the id, or slug:<slug> |
ataila_tenant_membership | <tenant_id>/<user_id> |
ataila_user | the id, email:<address> or username:<name> |
ataila_user_role_grant | <user_id>/<role> |
ataila_ai_gateway_key | the id, or alias:<key_alias> |
ataila_ai_serving_tier | the tier's key |
ataila_project | the id, or short_name:<short_name> |
ataila_project_provisioning | the project id |
ataila_project_member | <project_id>/<user_id> |
ataila_licence_bundle | current |
ataila_brand | current |
ataila_brand_asset | the asset id |
ataila_release_promotion | the release operation id |
ataila_project_prod_lock | the project id |
ataila_ai_model | the id, or repo:<org/name> |
ataila_ai_model_node_cache | <model_id>/<node> |
Switching between OpenTofu and Terraform
One configuration works with both CLIs, and so does one state, with one step in one direction. A state records the provider's full address, and source = "ataila/ataila" means registry.opentofu.org/ataila/ataila to OpenTofu and registry.terraform.io/ataila/ataila to Terraform.
- Terraform → OpenTofu: nothing to do. OpenTofu maps the Terraform address in a state to its own registry by itself, and its next apply records its own address. To record it at once, run the OpenTofu command below.
- OpenTofu → Terraform: required, once, before anything else. Without it Terraform stops with Missing required provider (Terraform 1.6 words it Failed to load plugin schemas). After it,
terraform planshows no changes.
tofu state replace-provider registry.terraform.io/ataila/ataila registry.opentofu.org/ataila/ataila terraform state replace-provider registry.opentofu.org/ataila/ataila registry.terraform.io/ataila/ataila Both commands ask for confirmation; -auto-approve skips it. A remote backend is changed in place, so switch once, not back and forth in parallel runs.
Air-gapped installations
For an installation without internet access, every release from 1.0.0 has a mirror bundle attached to its GitHub release: terraform-provider-ataila_<version>_mirror.zip, with its .sha256. It holds the same binaries under both registry addresses, because source = "ataila/ataila" resolves to a different host in each CLI, in the unpacked filesystem mirror layout both CLIs read, for linux_amd64, linux_arm64, darwin_arm64, windows_amd64:
terraform-provider-ataila_<version>_mirror.zip
├─ registry.opentofu.org/ataila/ataila/<version>/<os>_<arch>/terraform-provider-ataila_v<version>
├─ registry.terraform.io/ataila/ataila/<version>/<os>_<arch>/terraform-provider-ataila_v<version>
├─ SHA256SUMS the sha256 of every binary
└─ README.md where to unpack it and how to point each CLI at it Check the archive against its .sha256 and unpack it on a machine that can reach the portal, for example into /opt/terraform/mirror:
sha256sum -c terraform-provider-ataila_1.0.0_mirror.zip.sha256
sudo mkdir -p /opt/terraform/mirror
sudo unzip terraform-provider-ataila_1.0.0_mirror.zip -d /opt/terraform/mirror Then point the CLI at it: ~/.tofurc for OpenTofu, ~/.terraformrc for Terraform (on Windows %APPDATA%\tofu.rc and %APPDATA%\terraform.rc). Without any network at all, leave the direct block out.
provider_installation {
filesystem_mirror {
path = "/opt/terraform/mirror"
include = ["registry.opentofu.org/ataila/ataila"]
}
direct {
exclude = ["registry.opentofu.org/ataila/ataila"]
}
} provider_installation {
filesystem_mirror {
path = "/opt/terraform/mirror"
include = ["registry.terraform.io/ataila/ataila"]
}
direct {
exclude = ["registry.terraform.io/ataila/ataila"]
}
} The configuration keeps source = "ataila/ataila"; init then installs the provider from the mirror and records its checksums in .terraform.lock.hcl.
tofu init terraform init Signing key and verifying a release by hand
Every release's SHA256SUMS is signed with this key, the one both registries hold:
| Identity | ATAILA Kft. (Budapest) <support@ataila.com> |
|---|---|
| Type | RSA-4096 |
| Fingerprint | 9997D23D 22220320 3B02B5EE 51740425 A2D915F8 |
| Key ID | 51740425A2D915F8 |
| Valid until | 2036-09-28 |
| Public key | docs/signing-key.asc in the provider's repository |
To check release files yourself, import the key once, compare its fingerprint with the one above, then verify the signature and the checksums:
VERSION=1.0.0
# The signing key, once: check that its fingerprint is the one on this page.
curl -sSLO https://raw.githubusercontent.com/ataila/terraform-provider-ataila/main/docs/signing-key.asc
gpg --import signing-key.asc
gpg --fingerprint 51740425A2D915F8
# The release files, from the GitHub release of that version.
gpg --verify terraform-provider-ataila_${VERSION}_SHA256SUMS.sig \
terraform-provider-ataila_${VERSION}_SHA256SUMS
sha256sum --ignore-missing -c terraform-provider-ataila_${VERSION}_SHA256SUMS
# The air-gapped bundle carries its own checksum file.
sha256sum -c terraform-provider-ataila_${VERSION}_mirror.zip.sha256 Versions
The provider follows semantic versioning. 1.0.0, published on 2026-10-02, is its first public release, and every resource and data source on this page is in it. Within 1.x nothing is removed or renamed and the platform's API v1 changes only by addition (see the changelog), so version = "~> 1.0" is the constraint to use. Release notes are in the CHANGELOG of the provider's repository.
What the provider does on every run
- On start the provider reads
GET /api/v1/metaand refuses an installation whose API major version is not 1, or whose API is older than the provider release needs. - It retries only on 429, 502, 503 and 504, with backoff, honouring
Retry-After. Nothing else is retried. - A licence refusal (403 whose
codestarts withlicence_) is final, and the error quotes the remedy the platform gives. - Every operation that declares an
Idempotency-Key(the creates, release promotions, key rotations, node-cache changes) carries a fresh one, so a retried request never runs twice. - On a
202, it polls the operation theLocationheader names, and logs when the answer was the stored one of an earlier attempt (Idempotent-Replayed). - Every error becomes a diagnostic with its title, detail,
codeandrequest_id, so an operator can find the request in the platform's logs. - Warnings the platform returns on a request that succeeded are reported as warnings, never as errors.
- Keys the platform fixes at create (for example a customer's
short_nameor a tenant'sslug) are frozen: changing one fails the plan with an explanation. The provider never replaces such an object, because destroying it only archives or retires it and the keys stay taken. - Timestamps are compared as instants: the same time written another way is never a difference.
- A 404 on start means a wrong endpoint, or an installation whose API is switched off.