Cloud Integrations
Cloud integrations attach runtime cloud-provider access to a stack. They are separate from GantryCD’s own storage/state credentials.
Model
One integration is a stack’s access to one cloud provider. A stack integrates with AWS at most once and with GCP at most once — the provider is the integration’s identity on the stack:
GET /api/v1/orgs/{org}/stacks/{stack}/cloud-integrations
GET /api/v1/orgs/{org}/stacks/{stack}/cloud-integrations/{provider_type}
PUT /api/v1/orgs/{org}/stacks/{stack}/cloud-integrations/{provider_type}
DELETE /api/v1/orgs/{org}/stacks/{stack}/cloud-integrations/{provider_type}
How many cloud identities that one integration carries is the provider’s business, not the model’s. A stack spanning three AWS accounts has one AWS integration whose config lists three IAM roles. A stack using GCP has one GCP integration naming one service account — because the google provider reads a single ambient token and has nothing to select between.
That is the point of the shape. The alternative — one row per identity, keyed (provider_type, name) — forces every provider through AWS’s cardinality: GCP would need a vestigial name nobody reads, an “at most one” cap enforced in code, and a lock-and-count to make that cap race-safe. Here GCP’s “at most one” is a unique index, and AWS’s “up to fifty roles” is a length check on a list. Neither provider pays for the other’s shape.
PUT is the only write, and it is an upsert — it creates the integration if the stack has none for that provider yet, otherwise replaces its config. There is deliberately no POST: create and update are one operation, so a client reconciles with one unconditional PUT and never has to choose between them (the check-then-write that choice implies is a create-vs-update race — the Terraform provider hit exactly that). For AWS the role list you send becomes the stack’s complete set of roles: adding a role, retargeting one and removing one are all the same write, and a role you leave out is deleted in the same transaction. (The corollary: two clients editing different roles of one stack concurrently each do a read-modify-write, and the last writer wins — the same limitation, for the same reason, as a stack’s environment variables.)
Authorization: one write means one write permission. The cloud_integration action set is read / update / delete — there is no create (a create grant would be meaningless when the write is an upsert, and worse, it could disagree with update about who may add a provider binding). update matches the composite stackName:providerType, so a grant covers a stack’s access to that provider — adding, changing, or first-configuring it.
The config is opaque above the provider
The request and response carry the provider’s configuration as an uninterpreted config object:
{ "provider_type": "aws",
"config": { "roles": [
{ "name": "default", "role_arn": "arn:aws:iam::111122223333:role/AppRole" },
{ "name": "network", "role_arn": "arn:aws:iam::444455556666:role/NetworkRole" }
] } }
{ "provider_type": "gcp",
"config": { "service_account_email": "gantrycd@my-project.iam.gserviceaccount.com" } }
Nothing between the HTTP boundary and the provider decodes it. The handler validates only stack_id and provider_type; the service hands config to RuntimeProvider.ParseConfig, the single place that knows the shape, validates it, and normalises it. Clients (the web UI, the CLI) necessarily know the shapes because they render the forms — the backend does not.
Storage mirrors this. cloud_integrations holds the identity (stack_org_id, stack_id, provider_type — unique together), and each provider owns its own table keyed by cloud_integration_id:
cloud_integration_aws_roles— one row per role: primary key(cloud_integration_id, name), plusrole_arnand a nullableinline_policy.cloud_integration_gcp— one row:service_account_email.
AWS’s cardinality is a table with several rows; GCP’s is a table with one. Column constraints therefore stay real — a role-name regex, an ARN regex, the 2048-character policy cap — rather than dissolving into a JSON blob, and ON DELETE CASCADE takes the children with the parent. One repository owns the whole aggregate; it reads it with one query per provider actually present, not a join every read pays for.
Roles are read back sorted by name, not in the order they were written. Some stable order is required — the web diffs the config it fetched against the config in its form, so a read that shuffled would look like an edit and re-PUT on every save — and the name is the only thing to sort on, being the role’s identity. Nothing downstream cares about the order itself: each role is an independent [name] section in the credentials file.
Azure is not implemented; the check constraint rejects any provider_type other than aws / gcp.
There is no per-stack cap to enforce
There is nothing to count and nothing to lock. A stack cannot hold two AWS integrations — that is a unique index, so it holds under concurrency for free. How many roles the one AWS integration may list is the AWS provider’s own rule, checked when it parses its config: 1 to 50.
Fifty is not a design limit; named profiles mean there is no reason to stop at a small number. It exists because every role costs its own sts:AssumeRole at run-accept, inside the write transaction that holds the deployment’s row lock. The ceiling is far past any real stack; it only stops a pathological one from holding that lock, or hammering STS, without bound.
Which providers this backend has
GET /api/v1/cloud-providers → [{ "provider_type": "aws" }, …]
Only a session is required: it reports which providers RUNTIME_PROVIDERS registered — deployment configuration, identical for every caller. Clients read it so they offer exactly the providers a run could actually use. Without it a UI would hard-code the list, and a backend running with only AWS enabled would still invite the user to fill in a GCP form that could only ever be rejected.
It reports the provider list, not its config shape. Clients still hard-code the fields each provider takes, because they render the forms — a schema-driven form is a great deal of machinery for a two-field config. So this makes a client provider-list-agnostic, not provider-agnostic. The web renders only providers it has a form for and ignores the rest; the CLI prints an unknown provider’s config as raw JSON rather than failing.
The config is assumed to hold no secret
cloud_integrations and its provider tables are plaintext, and GET returns the config verbatim. That is safe today only because no provider’s config is a secret — an IAM role ARN and a service-account email are public identifiers, and the trust policy on the other side is what grants anything.
A provider whose config carried a secret (an Azure client secret, a static access key) would break that assumption, and the expensive part is not the storage. The repository would take a crypto.SecretCipher, as scm_integration_repo.go and runner_group_config_repo.go already do. But the read path would also have to stop returning the secret — and because PUT replaces the whole config, clients read-modify-write on save and would need a “leave unchanged” sentinel, the way SCM integrations treat auth_config as write-only and return an auth_config_summary instead. Plan for the round-trip, not the encryption.
AWS roles
Each role in an AWS integration’s config has a name, and that name is the handle the stack’s OpenTofu selects it by: it becomes the profile in the runner’s shared-credentials file, so provider "aws" { profile = "network" } works exactly as it does on a workstation, and a role named default serves a bare provider "aws" {}. There is no AWS_PROFILE and no is_default flag — a role called default is the default because that is AWS’s own rule, and inventing a second way to say so would only let the two disagree.
Names match ^[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}$ and are unique within the integration (two roles answering to one profile would make profile = … ambiguous). The regex excludes every character that is significant in an INI file, which is what makes it safe to write these names into the credentials file at all. No prefix is reserved — the managed state backend reads its own separate credentials file, never the stack’s, so a stack role may even be named gantrycd-state without shadowing it.
That rule is stated three times — in Go, in a Postgres CHECK, and in the web’s form validation — because the three cannot be generated from one source. They are instead all tested against one corpus, testdata/cloud_integration_rules.json, so a change to any of them that the others do not follow fails the build. It has already caught a real drift: the web accepted IAM role paths that Go rejected.
Inline session policy (optional)
A role’s inline_policy is an IAM policy JSON document passed as the Policy on that role’s sts:AssumeRole call. STS mints credentials that are the intersection of the role’s own permissions and this policy — it can only narrow the role, never expand it. This lets several stacks share one broad IAM role while each is scoped down to just what it needs; omit it to assume the role with its full permissions. It scopes only the role that declares it: two roles in one integration can carry different policies, or none.
AWSRuntimeProvider.ParseConfig validates the document is a plausible IAM policy (an object with a non-empty Statement, each statement having a valid Effect and at least one of Action/NotAction) and stores it minified — AWS measures the 2048-character cap against the plaintext STS actually receives, so what is stored, validated and sent must be the same bytes. The web UI pretty-prints the stored value for editing and offers a Format action.
Runtime Provider Support
A persisted integration is only usable if the backend has a registered runtime provider for that provider type. Providers are registered from the RUNTIME_PROVIDERS env var (CSV of aws / gcp). Creating an integration for an unregistered provider fails with UnsupportedRuntimeProviderError (HTTP 422), so one cannot be persisted at all — a run can never be queued against a provider no backend could service.
Backend identity
Each runtime provider authenticates with the backend’s own cloud identity:
- AWS: the default credential chain (env vars, EKS IRSA, instance profile).
RUNTIME_AWS_AWS_ACCESS_KEY_ID/_SECRET_ACCESS_KEY/_SESSION_TOKEN/_REGIONoverride it, letting the runtime principal differ from the principal the S3 concerns (state/logs/artifacts) use. Each S3 concern has the same*_S3_AWS_*override pattern. - GCP: Application Default Credentials (Workload Identity /
GOOGLE_APPLICATION_CREDENTIALS).
The backend identity must be trusted by the customer resource: every AWS role a stack lists must trust it (with the external ID), and the GCP service account must grant it roles/iam.serviceAccountTokenCreator.
Security requirement: the runtime principal must not be able to assume GantryCD’s own roles
This is a hard operator requirement, not a tuning knob. A stack’s AWS role ARN is free text and validated only for shape — GantryCD does not and cannot know which ARNs are “yours”. The external ID (realm@org@stack@mode) is what normally makes that safe: an arbitrary role grants nothing unless its trust policy admits GantryCD for that exact stack. But that guarantee holds only for roles whose trust policy requires the external ID. GantryCD’s own S3 roles (state, logs, artifacts — <CONCERN>_S3_ROLE_ARN) are assumed without an external ID (they can’t require one, or the S3 path would break), so if the runtime principal is also able to assume them — which it is by default, because runtime and the S3 concerns both fall back to the same pod credential chain — a stack can set its role ARN to GantryCD’s state role and, on a mere plan (a PR preview), receive full state-bucket credentials. That reads and rewrites every tenant’s Terraform state.
Close it at the principal, where the responsibility belongs:
- Give the runtime provider a distinct principal (set
RUNTIME_AWS_*) that the S3 roles do not trust — so even a same-account S3 role ARN can’t be assumed on the runtime path; or - keep the S3 roles in a separate account from the runtime principal (a runtime role always lives in the customer’s account, so a legitimate integration never names an ARN in GantryCD’s); and
- scope the runtime principal’s own identity policy so it can only
sts:AssumeRoleon customer roles, never on GantryCD’s platform roles.
Treat “the runtime principal shares the S3 principal” as a misconfiguration to be caught in review, the same way a wildcarded external ID (below) is.
Fail-Fast at Deployment Creation
CreateDeployment runs a pre-flight (preflightCloudProviders) over the stack’s integrations before any run is queued. For each integration it checks:
- the provider type is registered (
SupportsProvider), and - the backend can authenticate to it (
ValidateRuntimeAccess— ansts:GetCallerIdentityfor AWS, a token-source probe for GCP).
A failure fails deployment creation immediately, so no runner is ever spun up for a stack the backend cannot service. An unregistered provider yields UnsupportedRuntimeProviderError (HTTP 422); an unreachable one yields RuntimeCredentialsUnavailableError (HTTP 503). The probe touches only the backend’s own principal — never a customer role.
Startup Failure Path
The pre-flight is a fast-fail aid; correctness for provider support still lives in the write-transaction check. If a deployment reaches the startup-sensitive write transaction and the stack’s configured provider is unsupported:
- the active run is marked with a startup failure message
- state-locked queued/pending runs on the same stack incarnation are failed in bulk
- the deployment is failed
PromoteNextOnStackis called to release the stack lock (except forplan_unlockeddeployments, which never held it)
See deployment_state_machine.md.
Runner Delivery
At accept time the backend mints the credentials the stack’s integrations imply — for AWS, one sts:AssumeRole per role in its config, fanned out — and delivers them in RunContext.RuntimeCredentials, each tagged with its provider (and, where the provider has one, its name) and carrying an opaque provider-owned blob. The runner materialises them before any subprocess starts. See runner_runtime_credentials.md.
Upgrading from the single-role model
Integrations used to be one row per (stack, provider_type) with the provider’s fields as columns on cloud_integrations (aws_role_arn, aws_inline_policy, gcp_service_account_email). The identity is unchanged; what moved is the provider’s configuration, into per-provider tables — and AWS’s is now a list.
This is a breaking, one-way change and existing integrations must be re-entered. There is no in-band backfill: schema-apply runs before migrate-apply, so a data migration cannot read the old columns — by the time it runs they are already dropped, and the new per-provider tables are empty.
Before upgrading, record what you have, because the config is about to be lost:
SELECT stack_org_id, stack_id, provider_type, aws_role_arn, aws_inline_policy, gcp_service_account_email
FROM cloud_integrations;
The upgrade leaves each pre-existing integration as a parent row with no config. A migration (migrations/20260714000000_drop_orphaned_cloud_integrations.sql) then deletes those orphaned parents, so each stack goes cleanly back to “no integration for this provider”. This matters for recovery: the parent occupies the (stack, provider_type) unique key, so without that cleanup a re-create would collide with the stranded row and fail with a 409 — the migration is what lets you re-create at all.
After the upgrade, re-create each integration from what you recorded. Name the AWS role default to keep existing provider "aws" {} blocks working with no HCL change. Until they are re-entered, runs on those stacks have no cloud credentials.
Adding a provider
What the design buys you is that the request path never learns the provider exists: the HTTP handler, CloudIntegrationService, pkg/contracts/cloud_integrations.go and the runner’s executor need no edit, because the config is opaque from DecodeJSONBody all the way to ParseConfig. That now includes the provider’s cardinality — a provider needing one credential and a provider needing twenty are the same shape to everything above ParseConfig. It is the property to preserve.
It is not a four-file change, though. Honestly, a new provider touches:
Its own, new:
- a
CloudConfigimplementation inpkg/domain(ProviderType(),Validate()) — whatever shape it needs; there is no list to conform to, - one or more tables keyed by
cloud_integration_id, holding its constraints, - a
RuntimeProvider(ParseConfig,GenerateRuntimeCredentials,ValidateAccess), plus whatever cloud client it needs, - a materializer in
runtimeCredentialMaterializers, - a credential blob type in
pkg/contracts/runner.go(shared by the two above).
Shared, enumerating code it must be added to:
domain.CloudProviderXxxandValidateProviderType,- the
provider_typeCHECK oncloud_integrations, - the repository’s
loadConfigandwriteConfig— one arm each, - the
RUNTIME_PROVIDERSwiring incmd/backend/runtime_providers.go, and the chart’s env plumbing.
Clients, which necessarily know config shapes because they render the forms: the CLI’s flag set and the web’s draft types, form section and validation.