Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

📊 Grafana Cloud Stack Terraform Module

Manages a Grafana Cloud stack together with the service accounts — and their static tokens — that operate against it, as one composite unit. Targets grafana/grafana (~> 4.0).


Badges

Terraform Provider Module Version Module Type Resources Posture


🧩 Overview

  • ☁️ Creates and manages a grafana_cloud_stack — name, required slug, optional region, description, custom URL, labels, delete protection, and readiness-wait behavior.
  • 🔐 Renders zero or more grafana_cloud_stack_service_account resources, for_each-keyed by a caller-chosen stable service account name.
  • 🔑 Renders exactly one grafana_cloud_stack_service_account_token per service account entry — a stack with service accounts but no way to authenticate against them is an incomplete bootstrap, so the token is a required nested field on each service_accounts entry, not a separate, independently-optional collection.
  • 🆔 Emits slug as the primary output — the required, caller-facing identity real sibling resources key on (grafana_cloud_stack_service_account.stack_slug), not the computed numeric id.
  • 🌐 Surfaces the genuinely useful computed per-product endpoint URLs (Prometheus, Logs, Alertmanager, Graphite, OTLP, Traces, Profiles, Fleet Management, OnCall, Synthetic Monitoring, Connections, Cloud Provider Observability) as outputs — never as inputs, since all of them are read-only on the live schema.

💡 Why it matters: a newly created Grafana Cloud stack has no useful machine-identity access until at least one service account and its token exist. Managing the stack and its initial service accounts as one composite unit means a caller provisioning a new stack also owns the first-class access needed to configure it, rather than leaving a bare stack pending a separate manual bootstrap step that is easy to forget in a regulated environment.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits

flowchart TD
 accesspolicy["terraform-grafana-cloud-access-policy\n(sibling, supplies auth token)"]:::sibling
 keystone["grafana_cloud_stack.this"]:::this
 svcacct["grafana_cloud_stack_service_account\n(for_each)"]:::this
 token["grafana_cloud_stack_service_account_token\n(for_each)"]:::this
 frontendobs["terraform-grafana-frontend-observability-app\n(stack_id type pending confirmation)"]:::sibling
 content["Stack-scoped content modules\n(terraform-grafana-folder, -dashboard, -team,...)"]:::sibling

 accesspolicy -- "cloud_access_policy_token (root provider alias auth)" --> keystone
 keystone -- "slug (stack_slug)" --> svcacct
 svcacct -- "id (service_account_id)" --> token
 keystone -- "slug (stack-scoped grafana provider alias url)" --> content
 keystone -- "slug (candidate stack_id reference)" --> frontendobs

 classDef this fill:#F46800,color:#FFFFFF,stroke:#F46800;
 classDef sibling fill:#E8E8E8,color:#111217,stroke:#8A8A8A;
Loading

Validated via the Mermaid Chart MCP (valid: true) before embedding. terraform-grafana-cloud-access-policy owns and rotates the cloud_access_policy_token this module's own provider alias authenticates with — it is a wholly separate auth mechanism from the auth/Service Account token used once the stack exists. This module's slug output is the identity a caller wires into a second, stack-scoped grafana provider alias for downstream content modules (terraform-grafana-folder, terraform-grafana-dashboard, terraform-grafana-team, etc.), and is the candidate reference for terraform-grafana-frontend-observability-app's stack_id-shaped input, pending that module's own confirmation of its expected identity type.


🧬 What this builds

flowchart LR
 subgraph inputs["Inputs"]
 varstack["var.stack\n(object)"]
 varsvcaccts["var.service_accounts\n(map(object), default {})"]
 end

 keystone["grafana_cloud_stack.this\n(keystone)"]:::this
 svcacct["grafana_cloud_stack_service_account.this\n(for_each, keyed map)"]:::this
 token["grafana_cloud_stack_service_account_token.this\n(for_each, keyed map)"]:::this

 varstack -- "name, slug, region_slug, description, url, labels, delete_protection, wait_for_readiness*" --> keystone
 varsvcaccts -- "role, is_disabled (per key)" --> svcacct
 varsvcaccts -- "token.name, token.seconds_to_live (per key)" --> token
 keystone -- "slug (stack_slug)" --> svcacct
 svcacct -- "id (service_account_id)" --> token

 keystone --> outslug["slug\n(primary_output)"]
 keystone --> outid["id"]
 keystone --> outendpoints["per-product endpoint outputs\n(prometheus_url, logs_url, alertmanager_url,...)"]
 svcacct --> outsvcids["service_account_ids\n(map)"]
 svcacct --> outsvcmeta["service_accounts\n(map, metadata only)"]
 token --> outtokmeta["service_account_tokens\n(map, metadata only)"]
 token --> outtokkeys["service_account_token_keys\n(map, sensitive)"]

 classDef this fill:#F46800,color:#FFFFFF,stroke:#F46800;
Loading

Validated via the Mermaid Chart MCP (valid: true) before embedding.

Resource inventory:

Resource Cardinality Role
grafana_cloud_stack.this 1 (keystone) The stack itself: name, required slug, optional region, description, custom URL, labels, delete protection, readiness-wait behavior
grafana_cloud_stack_service_account.this 0..N (for_each, keyed by var.service_accounts map key) One resource per service account name — role, disabled flag
grafana_cloud_stack_service_account_token.this 0..N (for_each, same key set as above) One static token per service account — name, optional lifetime

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
grafana/grafana provider ~> 4.0 (confirmed latest at authoring time: 4.40.1, installed and locked during this session's terraform init)
Provider block None — the caller configures provider "grafana" {... } (with the cloud_access_policy_token mechanism this module requires) at the root module and passes it in via providers = { grafana =... }

Schema notes that bite:

  • slug is the practical cross-reference identity, not id. Confirmed against the live provider schema: slug is a required string field on the live schema ("Subdomain that the Grafana instance will be available at"), and sibling resources (grafana_cloud_stack_service_account.stack_slug, grafana_cloud_stack_service_account_token.stack_slug) key on it directly. id is a computed numeric string — diagnostic/legacy reference only. This module's primary_output is slug.
  • Changing region_slug destroys and recreates the stack. The live schema's own field description states this in prose ("Changing region will destroy the existing stack and create a new one in the desired region") without a separate "Forces new resource" annotation in the rendered docs — treat it as force-new in practice regardless of how the docs page tags it.
  • slug uniqueness is enforced platform-side, not by Terraform state. A naming collision surfaces as an apply-time Cloud API error, not a plan-time Terraform error.
  • labels has a hard cap and a regex constraint enforced by the live schema: at most 10 entries, and every key and value must match ^[a-zA-Z0-9/\-._]+$. This module enforces both constraints in variables.tf via validation {} blocks so a malformed label set fails at plan, not at the Cloud API.
  • delete_protection defaults to true on the live schema — this module's var.stack.delete_protection mirrors that default exactly. Per this module suite's Human judgment guidance, destroying a grafana_cloud_stack is a genuinely destructive, billing-relevant, org-level action; leave this enabled unless a human has explicitly decided the stack is disposable.
  • grafana_cloud_stack exposes dozens of computed-only per-product attributes (alertmanager, graphite, logs, otlp, pdc, profiles, prometheus, traces, fleet_management — URLs, statuses, and AWS-PrivateLink-only connectivity info) — confirmed against the live provider schema. These are read-only; never model any of them as a module input. This module surfaces the operationally useful subset as outputs (see §14) and deliberately omits the AWS-PrivateLink-only connectivity attributes and per-product status/name fields to avoid dozens of rarely-used passthroughs.
  • grafana_cloud_stack_service_account_rotating_token is a real, live-schema alternative to the static grafana_cloud_stack_service_account_token resource this module renders — confirmed against the live provider schema: it adds name_prefix, early_rotation_window_seconds, delete_on_destroy, and a computed ready_for_rotation attribute for provider-managed rotation. Not adopted in v1 — this module keeps a static, caller-rotated token to keep the secret-lifecycle story simple and auditable (explicit re-apply to rotate, versus a provider-managed rotation schedule this module does not yet model). A caller needing provider-managed rotation must fork or extend this module deliberately.
  • grafana_cloud_stack_service_account_token.seconds_to_live has no module-level default. Per the live schema's own documented behavior, a null, zero, or omitted value produces a token that never expires. This module does not inject its own default — omission is an explicit risk acceptance the caller must choose knowingly (see §16 Design Principles).

🔑 Required Grafana Auth & Scope

Grafana core auth (per this module suite's Authentication model convention → Grafana core → Cloud access policy token). Requires a cloud_access_policy_token scoped to Cloud org/stack-management capabilities — stack create/read/ update/delete plus the service-account-scoped capabilities needed to create and manage stack service accounts and their tokens (the live schema's own docs list stacks:read, stacks:write, stacks:delete, and stack-service-accounts:write as the required access policy scopes across the three resources this module renders). This is a wholly separate auth mechanism from the base Grafana core auth argument used once the stack exists — a provider instance configured only with auth cannot create, modify, or destroy the stack resource itself. Configured once by the caller on whichever aliased grafana provider instance is passed into this module via providers = { grafana =... }; this module declares no alias or credential of its own.

Grafana Prerequisites

  • The target Grafana Cloud organization must already exist and be reachable via the caller's cloud_access_policy_token.
  • region_slug must be one of the org's enabled Grafana Cloud regions — confirm the current enum via the Grafana Cloud "list regions" API or the live provider schema at call time; this module does not hardcode a region enum into variables.tf since the valid set is org-specific and can change.
  • No feature-flag gating is currently known for grafana_cloud_stack, grafana_cloud_stack_service_account, or grafana_cloud_stack_service_account_token themselves — re-verify per this module suite's Provider-migration convention at the start of each authoring session.

📁 Module Structure

terraform-grafana-cloud-stack/
├── providers.tf # required_providers: grafana/grafana ~> 4.0, required_version >= 1.12.0. No provider {} block.
├── variables.tf # var.stack (keystone object) + var.service_accounts (for_each-keyed map, nested token)
├── main.tf # grafana_cloud_stack.this (keystone) + grafana_cloud_stack_service_account.this (for_each) + grafana_cloud_stack_service_account_token.this (for_each)
├── outputs.tf # slug (primary), id, computed metadata, per-product endpoints, service_account_ids/service_accounts/service_account_tokens/service_account_token_keys
├── README.md # this file
├── SCOPE.md # cross-module contract, auth/scope, prerequisites, provider gotchas
└── examples/ # runnable example(s)

⚙️ Quick Start

module "primary_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-primary"
    slug = "caseyprimary"
  }
}

The caller configures the grafana provider (aliased for cloud_access_policy_token auth) once at the root module — this module never accepts its own url, cloud_access_policy_token, or org_id default.


🔌 Cross-Module Contract

Consumes

Input Type Source module
none — stack definition fields (name, slug, region_slug,...) and the service_accounts collection are first-class, caller-supplied inputs to this module; nothing is consumed by reference from a sibling module — —

Emits

Output Description Consumed by
slug (primary) The stack's required, caller-facing slug — the stable cross-reference identity real sibling resources key on Any module/root config targeting this stack via a stack-scoped grafana provider alias; terraform-grafana-frontend-observability-app pending confirmation of its stack_id field's expected identity type
id Computed numeric stack ID — diagnostic/legacy reference only —
service_account_ids map(string), service account name → id Any module granting additional stack-scoped permissions to these service accounts
service_accounts map(object), metadata only (id, role, is_disabled) Inventory/drift-detection
service_account_tokens map(object), metadata only (id, name, expiration, has_expired) Automation/inventory tracking token lifecycle without seeing the secret
service_account_token_keys map(string), sensitive = true Caller's secrets manager — must be moved out of Terraform output consumption immediately per this module suite's Secure-by-default convention
prometheus_url, prometheus_remote_write_endpoint, prometheus_remote_endpoint, prometheus_user_id Computed Prometheus endpoints/identity Root config wiring a Prometheus data source or remote_write target
logs_url, logs_user_id Computed Loki endpoint/identity Root config wiring a Loki data source or push target
alertmanager_url, graphite_url, otlp_url, traces_url, profiles_url, fleet_management_url, oncall_api_url, sm_url, connections_api_url, cloud_provider_url Computed per-product base URLs Root config / Alloy or agent configs wiring the corresponding product
org_id, org_slug, org_name, status, cluster_slug, cluster_name Computed stack/org metadata Informational

📚 Example Library

1 · Minimal stack, no service accounts
module "sandbox_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-sandbox"
    slug = "caseysandbox"
  }
}

💡 The empty-call default: delete_protection = true, wait_for_readiness = true (wait_for_readiness_timeout = "10m0s"), no labels, no custom URL, no service accounts — a bare stack with the platform's own safe defaults intact.

2 · Stack with an explicit region
module "eu_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name        = "casey-eu"
    slug        = "caseyeu"
    region_slug = "eu"
  }
}

⚠️ Changing region_slug on a later apply destroys and recreates the stack — confirm the target region before the first apply, and confirm the current valid region enum via the Grafana Cloud "list regions" API (see §8 Grafana Prerequisites) rather than assuming "eu" is universally valid for every org.

3 · Stack with labels
module "labeled_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-reporting"
    slug = "caseyreporting"
    labels = {
      "cost-center" = "reporting"
      "environment" = "prod"
    }
  }
}

ℹ️ At most 10 labels, and every key/value must match ^[a-zA-Z0-9/\-._]+$ — enforced by this module's validation {} blocks at plan time, not left to fail at the Cloud API.

4 · ⚠️ Stack with delete protection explicitly disabled
module "disposable_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name              = "casey-ephemeral-test"
    slug              = "caseyephemeraltest"
    delete_protection = false
  }
}

⚠️ Per this module suite's Human judgment guidance, destroying a grafana_cloud_stack is a genuinely destructive, billing-relevant, org-level action. Only disable delete_protection for a stack a human has explicitly designated as disposable (e.g. a short-lived test stack) — never as a default posture.

5 · Stack with a custom URL (CNAME)
module "custom_url_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-branded"
    slug = "caseybranded"
    url  = "https://grafana.financialpartners.com"
  }
}

🔒 Requires a CNAME already pointed at <slug>.grafana.net before the stack is created — the live schema does not create the CNAME on the caller's behalf.

6 · Single least-privilege service account + token
module "monitored_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-monitored"
    slug = "caseymonitored"
  }

  service_accounts = {
    "ci-readonly" = {
      role = "Viewer"
      token = {
        name            = "ci-readonly-token"
        seconds_to_live = 2592000 # 30 days
      }
    }
  }
}

💡 Per this module suite's secure-by-default convention, "Viewer" is the narrowest role this module can render — prefer it whenever the account's automation can operate read-only. seconds_to_live is set explicitly here to bound the token's lifetime rather than leaving it unset.

7 · Multiple service accounts at scale via caller-side locals
locals {
  automation_accounts = {
    "ci-deploy"    = "Editor"
    "ci-readonly"  = "Viewer"
    "oncall-admin" = "Admin"
  }
}

module "fleet_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-fleet"
    slug = "caseyfleet"
  }

  service_accounts = {
    for name, role in local.automation_accounts :
    name => {
      role = role
      token = {
        name = "${name}-token"
      }
    }
  }
}

💡 Because service accounts and their tokens are for_each-keyed by the same map key, adding or removing one named entry never re-indexes or disturbs any other entry's state.

8 · Service account with a bounded token lifetime
module "short_lived_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-shortlived"
    slug = "caseyshortlived"
  }

  service_accounts = {
    "pipeline-runner" = {
      role = "Editor"
      token = {
        name            = "pipeline-runner-token"
        seconds_to_live = 604800 # 7 days
      }
    }
  }
}

🔒 Set seconds_to_live explicitly whenever the caller's threat model allows it — omitting it produces a token that never expires (see §6 Schema notes that bite).

9 · Disabled service account (retained, access suspended)
module "suspended_account_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-suspended"
    slug = "caseysuspended"
  }

  service_accounts = {
    "legacy-integration" = {
      role        = "Editor"
      is_disabled = true
      token = {
        name = "legacy-integration-token"
      }
    }
  }
}

ℹ️ is_disabled = true retains the account and its token record in state while suspending its ability to authenticate — useful for a deprecated integration a caller isn't ready to fully remove yet.

10 · ⚠️ Admin-scoped service account (deliberate escalation)
module "admin_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-admin-ops"
    slug = "caseyadminops"
  }

  service_accounts = {
    "bootstrap-admin" = {
      role = "Admin"
      token = {
        name            = "bootstrap-admin-token"
        seconds_to_live = 86400 # 24 hours
      }
    }
  }
}

⚠️ "Admin" is never this module's default — it must be typed explicitly by the caller. Reserve it for genuine bootstrap/administrative automation, and prefer a short seconds_to_live for any Admin-scoped token.

11 · ⚠️ What NOT to do — an invalid role value
module "bad_role_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-bad-role"
    slug = "caseybadrole"
  }

  service_accounts = {
    "broken" = {
      role = "SuperAdmin" # fails validation -- not one of Viewer/Editor/Admin
      token = {
        name = "broken-token"
      }
    }
  }
}

⚠️ This module's variable "service_accounts" carries a validation {} block that fails terraform plan with an actionable error when role is not exactly "Viewer", "Editor", or "Admin", rather than letting the live Cloud API reject it at apply time.

12 · Consuming computed endpoints downstream
module "metrics_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-metrics"
    slug = "caseymetrics"
  }
}

output "metrics_stack_remote_write_endpoint" {
  value = module.metrics_stack.prometheus_remote_write_endpoint
}

output "metrics_stack_prometheus_user_id" {
  value = module.metrics_stack.prometheus_user_id
}

💡 prometheus_remote_write_endpoint and prometheus_user_id are the two computed values most commonly wired into an external agent's (Alloy, Grafana Agent, Prometheus) remote_write basic-auth block.

13 · Consuming `slug` vs. `id` downstream
module "network_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-network"
    slug = "caseynetwork"
  }
}

output "network_stack_slug" {
  value = module.network_stack.slug
}

output "network_stack_id" {
  value = module.network_stack.id
}

⚠️ Always wire slug, not id, into any sibling resource's stack_slug-shaped input or a stack-scoped grafana provider alias's url — id is documented here only as an informational, diagnostic/legacy pass-through.

14 · Conceptual integration with terraform-grafana-cloud-access-policy
# terraform-grafana-cloud-access-policy is not yet authored in this session -- see that module's own
# README once available for its real variable/output names. Conceptually, its output token feeds the
# cloud_access_policy_token argument on the provider alias this module requires:

provider "grafana" {
  alias                     = "cloud_mgmt"
  cloud_access_policy_token = module.stacks_access_policy.access_policy_token # illustrative name only
}

module "primary_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-primary"
    slug = "caseyprimary"
  }
}

ℹ️ Illustrative only — terraform-grafana-cloud-access-policy had not been authored as of this session. This module never accepts a cloud_access_policy_token variable of its own; the caller always configures it once on the provider alias, per this module suite's Authentication model convention.

15 · 🏗️ End-to-end composition — access policy → stack → service accounts → stack-scoped content
provider "grafana" {
  alias                     = "cloud_mgmt"
  cloud_access_policy_token = var.grafana_cloud_access_policy_token
}

# Illustrative -- see terraform-grafana-cloud-access-policy's own README for its real variable/output names
# once authored.
# module "stacks_access_policy" {
# source = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-access-policy.git?ref=v1.0.0"
# providers = { grafana = grafana.cloud_mgmt }
# }

module "primary_stack" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-cloud-stack.git?ref=v1.0.0"
  providers = { grafana = grafana.cloud_mgmt }

  stack = {
    name = "casey-primary"
    slug = "caseyprimary"
  }

  service_accounts = {
    "terraform-content-mgmt" = {
      role = "Editor"
      token = {
        name            = "terraform-content-mgmt-token"
        seconds_to_live = 7776000 # 90 days
      }
    }
  }
}

# The stack's own service account token bootstraps a SECOND, stack-scoped provider alias -- this module
# never configures that alias itself; the caller does, in the root module, using the sensitive
# service_account_token_keys output below.
provider "grafana" {
  alias = "stack_primary"
  url   = "https://${module.primary_stack.slug}.grafana.net"
  auth  = module.primary_stack.service_account_token_keys["terraform-content-mgmt"]
}

module "primary_folder" {
  source    = "git::https://github.com/microsoftexpert/terraform-grafana-folder.git?ref=v1.0.0"
  providers = { grafana = grafana.stack_primary }

  folder = {
    title = "Platform Ops"
  }
}

🔒 service_account_token_keys is sensitive = true — passing it directly into a second provider block's auth argument keeps the raw key out of any output the caller doesn't explicitly mark sensitive themselves, but it must still be moved to a secrets manager for any real bootstrap, per this module suite's Secure-by-default convention. This composition shows the full intended chain: an access-policy-scoped provider alias creates the stack and its content-management service account/token; that token then bootstraps a second, stack-scoped provider alias; and a downstream content-layer module (terraform-grafana-folder) places a folder inside the new stack using that second alias.


📥 Inputs

Variable Type (summary) Default Required
stack object — keystone fields (see below) n/a Yes (name, slug required inside)
service_accounts map(object(...)) — service accounts + their one static token each {} No
Full object schemas
variable "stack" {
  type = object({
    name        = string
    slug        = string
    region_slug = optional(string)
    description = optional(string, "")
    url         = optional(string)
    labels      = optional(map(string), {}) # max 10 entries; keys/values must match ^[a-zA-Z0-9/\-._]+$

    delete_protection          = optional(bool, true)
    wait_for_readiness         = optional(bool, true)
    wait_for_readiness_timeout = optional(string, "10m0s")
  })
}

variable "service_accounts" {
  type = map(object({
    role        = string # "Viewer" | "Editor" | "Admin"
    is_disabled = optional(bool, false)

    token = object({
      name            = string
      seconds_to_live = optional(number) # omitted/null/0 -- never expires
    })
  }))
  default = {}
}

🧾 Outputs

Output Description Sensitive / Conditional
slug Primary output. Stack's required, stable slug No
id Computed numeric stack ID (diagnostic/legacy only) No
name Stack display name No
status Computed stack status No
org_id / org_slug / org_name Computed org assignment No
cluster_slug / cluster_name Computed cluster assignment No
prometheus_url / prometheus_remote_write_endpoint / prometheus_remote_endpoint / prometheus_user_id Computed Prometheus endpoints/identity No
logs_url / logs_user_id Computed Loki endpoint/identity No
alertmanager_url Computed Alertmanager endpoint No
graphite_url Computed Graphite endpoint No
otlp_url Computed OTLP ingest endpoint No
traces_url Computed Traces (Tempo) endpoint No
profiles_url Computed Profiles (Pyroscope) endpoint No
fleet_management_url Computed Fleet Management endpoint No
oncall_api_url Computed OnCall API endpoint No
sm_url Computed Synthetic Monitoring API endpoint (requires separate activation) No
connections_api_url Computed Connections API endpoint No
cloud_provider_url Computed Cloud Provider Observability API endpoint No
service_account_ids Map, keyed like var.service_accounts, of each account's id No — empty map {} when var.service_accounts is empty
service_accounts Map, keyed like var.service_accounts, metadata only (id, role, is_disabled) No
service_account_tokens Map, keyed like var.service_accounts, metadata only (id, name, expiration, has_expired) No
service_account_token_keys Map, keyed like var.service_accounts, raw secret token values Yes — sensitive = true

🧠 Architecture Notes

  • slug is the stable cross-reference identity; id is not. Every downstream reference in this module's own examples and in this library's convention wires slug, never id — this mirrors the live schema's own design, where stack_slug (not a numeric stack ID) is the field every stack-scoped resource and provider argument expects.
  • The service account token is a required nested field, not an independent collection. variables.tf models var.service_accounts[*].token as a required object, not a separately-keyed optional(map(object(...)), {}) — every service account this module renders gets exactly one static token in the same apply, matching this module's SCOPE.md Design intent (a stack with service accounts but no tokens is still an incomplete bootstrap). main.tf cross-references the token to its parent via grafana_cloud_stack_service_account.this[each.key].id — both resources share the same for_each key set (var.service_accounts), so adding or removing one named service account never disturbs any other entry's account or token state.
  • region_slug is treated as force-new in this module's documentation even though the rendered provider docs page does not tag it with a separate "Forces new resource" annotation — its own field description states the destroy-and-recreate behavior in prose. Treat any change to an existing stack's region_slug as equivalent to replacing the stack.
  • delete_protection is a platform-side safety net, not a Terraform lifecycle block. It defaults to true on the live schema and is threaded straight through by this module; disabling it (§12 example 4) is a deliberate, human-confirmed opt-out per this module suite's Human judgment guidance, not a routine toggle.
  • Computed per-product endpoints are a curated subset, not exhaustive. The live schema exposes dozens of AWS-PrivateLink-only connectivity attributes and per-product status/name fields this module does not surface as outputs (see §6 Schema notes that bite) — a caller needing one of those must reference grafana_cloud_stack.this directly in a fork of this module.
  • Secret handling mirrors terraform-grafana-service-account's pattern: service_account_tokens carries metadata only; service_account_token_keys carries the raw secret and is the only sensitive output this module emits.
  • Cloud-only applicability. Unlike grafana_folder/grafana_team/grafana_data_source, all three resources this module renders apply only to Grafana Cloud organizations — there is no OSS/Enterprise equivalent of a "stack" as a manageable Terraform resource.

🧱 Design Principles

Concern Safe default (this module) Opt-out
Stack deletion delete_protection defaults to true, matching the live schema's own default — the platform itself refuses to delete a protected stack Caller sets var.stack.delete_protection = false (human-confirmed, per §12 example 4)
Service account tokens No default expiration override — seconds_to_live is left to the live schema's own "omitted = never expires" behavior; this module never injects a hidden default Caller sets an explicit, bounded token.seconds_to_live per entry
Team/role permissions (service account roles) No module-level default role — every service_accounts entry must state its role explicitly; this module's own examples default to "Viewer" wherever automation can be read-only Caller sets role = "Editor" or role = "Admin" explicitly, per entry
Legacy API keys Not modeled at all — this module only renders grafana_cloud_stack_service_account + grafana_cloud_stack_service_account_token, never the legacy grafana_api_key N/A
Org/stack scope slug is always an explicit, required, visible field — this module never relies on "whatever stack the provider defaults to" N/A — this is not user-overridable; it is a hard module design rule
Data source access / TLS Not applicable — this module renders no data source resources N/A
Credentials No credential-bearing input variables; the only credential-shaped output (service_account_token_keys) is sensitive = true N/A

🚀 Runbook

cd C:\GitHubCode\newgrafanamodules\terraform-grafana-cloud-stack
terraform init -backend=false
terraform validate
terraform fmt -check

Pin consumers at ?ref=v1.0.0, never a branch. This module is plan-only in this authoring pipeline — no apply is ever run here; a human applies from CI against a real Grafana Cloud organization.


🧪 Testing

terraform validate + terraform fmt -check together confirm: every field in var.stack and var.service_accounts type-checks against the schemas declared in variables.tf; the three validation {} blocks (labels count, labels regex, service account role enum) are themselves syntactically valid; and the whole file set is canonically formatted. These checks run entirely offline — no cloud_access_policy_token or live Grafana Cloud organization is required.

What this harness cannot catch: whether a slug collides with an existing stack org-wide, whether a region_slug is actually a valid, enabled region for the caller's organization, whether the caller's cloud_access_policy_token actually carries the stacks:write / stack-service-accounts:write scopes this module's resources require, or the real destroy/recreate blast radius of changing region_slug on a stack already holding content — those surface only at plan/apply against a live Grafana Cloud organization.


💬 Example Output

$ terraform output

id = "1234567"
slug = "caseyprimary"
name = "casey-primary"
status = "active"
org_id = 654321
org_slug = "financialpartners"
prometheus_remote_write_endpoint = "https://prometheus-prod-13-prod-us-east-0.grafana.net/api/prom/push"
prometheus_user_id = 987654
service_account_ids = {
 "terraform-content-mgmt" = "42"
}
service_accounts = {
 "terraform-content-mgmt" = {
 "id" = "42"
 "is_disabled" = false
 "role" = "Editor"
 }
}
service_account_tokens = {
 "terraform-content-mgmt" = {
 "expiration" = "2026-10-08T00:00:00Z"
 "has_expired" = false
 "id" = "7"
 "name" = "terraform-content-mgmt-token"
 }
}

service_account_token_keys is omitted from plain terraform output — it prints only via terraform output -json or terraform output service_account_token_keys explicitly, since it is marked sensitive = true.


🔍 Troubleshooting

Symptom Cause Fix
plan fails: "var.stack.labels may contain at most 10 entries" More than 10 labels supplied Reduce var.stack.labels to 10 or fewer entries
plan fails: "Every var.stack.labels key and value must match ^[a-zA-Z0-9/-._]+$" A label key or value contains disallowed characters Use only alphanumerics, /, -, ., _ in every label key and value
plan fails: "Each var.service_accounts entry's role must be one of: Viewer, Editor, Admin" An invalid/mistyped role value Use exactly "Viewer", "Editor", or "Admin"
apply fails with a Cloud API error on slug The requested slug is already taken org-wide (uniqueness is enforced platform-side, not by Terraform state) Choose a different slug
apply unexpectedly destroys and recreates the stack region_slug was changed on an existing stack — the live schema documents this as destroy-and-recreate behavior Treat region_slug as write-once; provision a new stack instead of changing an existing one's region
apply fails attempting to delete the stack delete_protection is true (the default) and the platform is refusing the deletion Get explicit human confirmation, then set var.stack.delete_protection = false before the destroy plan
A service account token never expires even though the caller expected it to token.seconds_to_live was omitted, null, or 0 — the live schema's own documented behavior for that case is "never expires" Set an explicit, positive seconds_to_live on the token
terraform init can't find provider version ~> 4.0 Local provider cache is stale or offline Re-run terraform init -backend=false -upgrade, or confirm network access to the Terraform Registry

🔗 Related Docs

  • Provider resource docs: grafana_cloud_stack, grafana_cloud_stack_service_account, grafana_cloud_stack_service_account_token, grafana_cloud_stack_service_account_rotating_token (grafana/grafana provider, Terraform Registry)
  • This module's SCOPE.md — cross-module contract, required auth/scope, Grafana prerequisites, provider gotchas, design decisions
  • Sibling modules referenced above: terraform-grafana-cloud-access-policy (not yet authored as of this session — treat its example call shapes above as illustrative), terraform-grafana-folder, terraform-grafana-frontend-observability-app, terraform-grafana-service-account

About

Terraform module: terraform-grafana-cloud-stack

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages