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.

source = "ataila/ataila" OpenTofu ≥ 1.6 Terraform ≥ 1.6 MPL-2.0 1.0.0, published 2026-10-02

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:

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.

versions.tf
terraform {
  required_providers {
    ataila = {
      source  = "ataila/ataila"
      version = "~> 1.0"
    }
  }
}
OpenTofu
tofu init
Terraform
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

provider.tf
# 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"
}
ArgumentEnvironmentDefaultMeaning
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:

OpenTofu
export ATAILA_ENDPOINT="https://portal.example.com"
export ATAILA_TOKEN="<token from the portal>"

tofu init
tofu plan
tofu apply
Terraform
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:

OpenTofu
tofu state rm ataila_customer.example
Terraform
terraform state rm ataila_customer.example
ResourceWhat it managesDestroy
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.

main.tf
# 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.

main.tf
# 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.

main.tf
# 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.

main.tf
# 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.

main.tf
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.

main.tf
# 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.

main.tf
# 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.

main.tf
# 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.

main.tf
# 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.

OpenTofu
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
terraform import ataila_customer.example short_name:EXAMPLE
terraform import ataila_user.dana email:dana.example@example.com
terraform import ataila_brand.this current
ResourceImport 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.

OpenTofu
tofu state replace-provider registry.terraform.io/ataila/ataila registry.opentofu.org/ataila/ataila
Terraform
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:

layout
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:

shell
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.

OpenTofu · ~/.tofurc
provider_installation {
  filesystem_mirror {
    path    = "/opt/terraform/mirror"
    include = ["registry.opentofu.org/ataila/ataila"]
  }
  direct {
    exclude = ["registry.opentofu.org/ataila/ataila"]
  }
}
Terraform · ~/.terraformrc
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.

OpenTofu
tofu init
Terraform
terraform init

Signing key and verifying a release by hand

Every release's SHA256SUMS is signed with this key, the one both registries hold:

IdentityATAILA Kft. (Budapest) <support@ataila.com>
TypeRSA-4096
Fingerprint9997D23D 22220320 3B02B5EE 51740425 A2D915F8
Key ID51740425A2D915F8
Valid until2036-09-28
Public keydocs/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:

shell
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