frostmoln_kubernetes_cluster (Resource)
Manages a managed Kubernetes cluster in the Frostmoln platform. The cluster owns its initial node pool (created embedded, scaled in-place). Additional node pools are managed with the frostmoln_kubernetes_node_pool resource.
Authoritative scope — who owns what on this resource, declared in internal/scopedecl and machine-checked against the schema.
Enacted and reconciled — every configurable attribute not listed below: the platform applies it, and a refresh reads the truth back.
Create-immutable — control_plane_tier, initial_node_pool.flavor_id, initial_node_pool.name, region, subnet_id, version, vpc_id: the platform has no in-place migration for it — a change destroys and re-creates the cluster.
Create-immutable — public_ip_id: retained and deprecated: every new cluster's apiserver is a private VIP and the API refuses any value with a 400 — the attribute exists so a stale configuration is told so.
Not enacted state — addon_versions: a refresh reads a pin back only for an addon this attribute already names, and only when the platform can report pins (not while the cluster is still being created, or during a platform deployment or outage, when the recorded value is kept); a pin set outside Terraform for any other addon is neither read into state nor sent.
Observed, not enacted — endpoint: platform-issued once the apiserver is up. kubeconfig: platform-issued once the apiserver is up. load_balancer_id: the platform provisions the load balancer the cluster rides on.
Example Usage
data "frostmoln_kubernetes_versions" "available" {}
variable "external_dns_version" {
description = "A version of external-dns listed by the frostmoln_kubernetes_addon_versions data source."
type = string
}
data "frostmoln_kubernetes_flavors" "available" {}
resource "frostmoln_kubernetes_cluster" "main" {
name = "my-cluster"
version = [for v in data.frostmoln_kubernetes_versions.available.versions : v.version if v.is_default][0]
vpc_id = frostmoln_vpc.main.id
subnet_id = frostmoln_subnet.nodes.id
# To reach your workloads from outside the cluster, give your ingress controller
# (or any workload) a Kubernetes Service of type LoadBalancer: the platform
# provisions a load balancer per Service and reports its address in
# .status.loadBalancer.ingress. The cluster itself provisions none — `endpoint`
# below is the Kubernetes API endpoint, for kubectl, not for traffic.
# Cluster addons. Adding OR removing a key is applied in place to a running
# cluster and reaches it within minutes — the accepted change starts its own
# reconciliation, and falls back to the platform's addon reconciliation period
# if that cannot be started;
# removing one DELETES the objects that addon installed. Omit the attribute to
# install the platform defaults; set an empty list ([]) to install none. See the
# frostmoln_kubernetes_addons data source for available keys.
#
# external-dns requires the tenant's DNS feature, and publishes nothing until you
# create its `frostmoln-dns` API-key Secret inside the cluster. Create that Secret
# outside Terraform (kubectl), with an API key carrying the narrowest scopes that
# allow DNS record changes (list the available scopes with the
# frostmoln_api_key_scopes data source). Creating it
# with Terraform instead puts the key in plain text in Terraform state, so
# protect the state backend accordingly.
addons = ["external-dns"]
# Optional version pins, keyed by addon key; each key must be in addons. Use a
# version string from the frostmoln_kubernetes_addon_versions data source. Pins
# set here at creation go in the create request; after that, only a changed pin
# is sent, and removing a key leaves the addon on its version.
addon_versions = {
"external-dns" = var.external_dns_version
}
initial_node_pool = {
flavor_id = data.frostmoln_kubernetes_flavors.available.flavors[0].id
node_count = 3
}
# Resource tags: your own metadata for search and filtering, never applied to
# the cluster or its nodes. The initial node pool gets a ONE-TIME copy of the
# cluster's merged tags at create; changing them here later does not reach it.
tags = {
env = "prod"
team = "platform"
}
}
# The API endpoint is PRIVATE: it lives on an address inside your own VPC and is
# reachable from inside that VPC only. A kubectl (or a `kubernetes`/`helm`
# provider, or a CI runner) outside the VPC cannot connect to it.
output "cluster_endpoint" {
value = frostmoln_kubernetes_cluster.main.endpoint
}
output "kubeconfig" {
value = frostmoln_kubernetes_cluster.main.kubeconfig
sensitive = true
}Schema
Required
initial_node_pool(Attributes) The cluster's initial node pool, owned by this resource. (see below for nested schema)name(String) The name of the cluster. Updatable in-place.subnet_id(String) The subnet ID where the cluster nodes are deployed.vpc_id(String) The VPC ID where the cluster nodes are deployed.
Optional
addon_versions(Map of String) Pins addons to published versions, keyed by addon key (at most 32). Every key must also be in addons, which must be set explicitly. List an addon's pinnable versions with the frostmoln_kubernetes_addon_versions data source and use one of those strings verbatim. Pins given when the cluster is created are sent in the create request, so the cluster starts on those versions and a pin the platform refuses when the request is made fails the create before anything exists. Changing a pin later is applied IN PLACE and reaches the cluster within minutes, on the reconciliation the accepted change starts, falling back to the platform's own addon reconciliation period if that cannot be started. Only pins whose value differs from state are sent, so an addon not pinned here keeps whatever version it is pinned to, including a pin set outside Terraform. An addon being ADDED to addons without a pin gets its recommended version; an addon that is already selected keeps its current pin. After an import, or whenever state records no pins, every configured pin is sent. A refresh reads back the pin of each addon named here, so a configured pin moved outside Terraform is reported as drift and the next apply restores it; pins of addons not named here are never read into state. When the platform cannot report pins (for example during a platform deployment) the refresh keeps the recorded values and warns. Removing a key stops Terraform managing that pin — the addon stays on its current version, as there is no unpin. Read each addon's pinned and applied version with the frostmoln_kubernetes_cluster_addons data source.addons(Set of String) The set of cluster-addon catalog keys for this cluster (see the frostmoln_kubernetes_addons data source for available keys). ADDING a key is applied IN PLACE to a running cluster: the accepted change starts its own reconciliation, so it reaches the cluster within minutes rather than immediately, and falls back to the platform's own addon reconciliation period if that cannot be started. REMOVING a key is ALSO applied in place and DELETES the objects that addon installed; a cluster whose control plane predates the platform's ability to remove an addon is refused with an error saying so, and must be recreated to gain it. When you SET this attribute, note that it is refreshed from the API before every plan, so an addon added outside Terraform (the portal, the fm CLI) appears in state and will be REMOVED on the next apply, as ordinary configuration drift — terraform plan shows the removal before anything happens. Leaving the attribute unset keeps such an addon, since nothing in configuration asks for its removal. Removal leaves behind what the platform will not delete for you: the addon's namespace, any CustomResourceDefinition, StorageClass or CSIDriver it created, and anything you put in that namespace yourself. Leave it unset to apply the platform default addons (the frostmoln_kubernetes_addons data source reports which are defaulted); set it to an explicit empty set ([]) to select none. An addon's upstream container images are pulled from a public registry when its pods start rather than preloaded onto your nodes, so the addon needs your worker nodes to have outbound HTTPS to that registry; where an addon has such a prerequisite its catalog description states it (external-dns: registry.k8s.io, which redirects image downloads to cloud-hosted backing stores on other domains, so allowing that hostname alone is not enough if you filter egress by name). That reachability is a property of your own VPC at the moment of the pull, and every new node pulls again, so the platform does not validate it: applying a key your nodes cannot pull succeeds here and leaves the addon's pods in ImagePullBackOff. Nothing about the cluster itself depends on it.control_plane_tier(String) The control-plane tier key (see the frostmoln_kubernetes_tiers data source for canonical keys). Defaults to the platform default tier.public_ip_id(String, Deprecated) REMOVED. Any value is rejected by the API with a 400, on every account — remove this attribute from your configuration.It supplied a bring-your-own address for the cluster API endpoint's public load balancer. Clusters no longer have one: the API endpoint takes no address of yours, and workload exposure is chosen per Service — create a Kubernetes Service of type LoadBalancer and the platform provisions a load balancer for it.
An existing cluster created with a BYO public IP is unaffected and keeps its address; only CREATE refuses the attribute.
🔴 REMOVING IT CAN PLAN A REPLACEMENT. This attribute forces replacement and is never read back from the API, so deleting the line is itself the change that makes Terraform want to destroy and recreate the cluster. Add
lifecycle { ignore_changes = [public_ip_id] }as well as removing it — the two are not alternatives, and only the pair is non-destructive.region(String) The region to create the cluster in. Defaults server-side.tags(Map of String) Resource tags on the cluster: your own key/value metadata for search and filtering (?tag=key=valuein the API,--tagin the fm CLI), stored by the platform only and never applied to the cluster or its nodes. At most 32 keys including the tenant's default tags, which the platform merges in at create; a key is 1-64 bytes matching^[a-zA-Z0-9]([a-zA-Z0-9._:/-]*[a-zA-Z0-9])?$, a value at most 256 bytes; keys beginningfrostmoln_orfrostmoln-(any case) are reserved. Changing them updates the cluster in place and does not touch the running cluster. 🔴 THE INITIAL NODE POOL GETS A ONE-TIME COPY. At create, the platform copies the cluster's merged tags (these, the provider'sdefault_tagsand the tenant's default tags) onto the initial node pool, once. Later changes to this attribute do NOT reach that pool, and this resource does not manage its tags — and the initial pool cannot be imported as afrostmoln_kubernetes_node_pooleither. Change them in the portal, withfm kubernetes node-pool tags set, or with the API (PATCH the node pool'stags). Pools you add withfrostmoln_kubernetes_node_poolget no copy: tag them on that resource, where the same tags can be set again if you want them to match. Merged with the provider'sdefault_tagson every write (a key set here wins). Holds only the keys this configuration sets; the full set is intags_all.timeouts(Block, Optional) (see below for nested schema)version(String) The Kubernetes version (e.g. "1.35"). Defaults to the platform default version. Changing it currently REPLACES the cluster — in-place upgrade is not available yet.
Read-Only
ca_cert_hash(String) The cluster CA certificate hash.created_at(String) The timestamp when the cluster was created.endpoint(String) The Kubernetes API endpoint URL.failed_step(String) The provisioning phase the cluster failed in: one ofvalidation,control_plane,api_endpoint,addons,node_poolorprovisioning. Set only whenstatusiserror, and may be absent even then.ha_enabled(Boolean) Whether the control plane is highly available (derived from the control-plane tier).id(String) The unique identifier of the cluster.kubeconfig(String, Sensitive) A kubeconfig for the cluster, re-fetched from the platform on every refresh while the cluster is running. Stored in Terraform state in plaintext —sensitiveredacts CLI output, not the state file. See the Secrets in Terraform state guide. Its server address is private to the cluster's VPC, so akubernetesorhelmprovider configured from it must run somewhere with a route into that VPC. Because it is re-fetchable, state is not the only place it can come from: if you do not need it in Terraform, leave the attribute unreferenced and take it out of band withfm kubernetes cluster kubeconfig <cluster-id> --exec, which writes a kubeconfig that opens that route itself (it needs an API key with bothkubernetes:readandsessions:write, and thefmbinary on PATH wherever the kubeconfig is used — a CI runner will not have it by default).load_balancer_id(String) The ID of the load balancer fronting the Kubernetes API.pod_cidr(String) The server-allocated pod network CIDR.public_ip(String) The public IP address of the cluster API endpoint.service_cidr(String) The server-allocated service network CIDR.status(String) The current status of the cluster.status_message(String) A human-readable description of why the cluster failed. Set only whenstatusiserror.status_reason(String) Why the cluster failed, as a failure class (e.g.QuotaExceeded). Set only whenstatusiserror.tags_all(Map of String) Every tag the platform holds on this resource: the provider'sdefault_tags, this resource's owntags(which win on a shared key), and any key set outside Terraform — in the portal, by the fm CLI, or stamped by the platform. Keys set outside Terraform appear only here and are kept on every apply, never removed.tenant_id(String) The tenant ID that owns this cluster.updated_at(String) The timestamp when the cluster was last updated.
Nested Schema for initial_node_pool
Required:
flavor_id(String) The node flavor ID (see the frostmoln_kubernetes_flavors data source).Optional:
name(String) The name of the initial node pool. Defaults to "default".node_count(Number) The number of worker nodes in the initial pool (1-100). Scaled in-place. Omitting it manages the pool at 1 node — an out-of-band scale will be reverted on the next apply.Read-Only:
failed_step(String) The provisioning phase the initial node pool failed in, e.g.node_pool. Set only whenstatusiserror, and may be absent even then.id(String) The unique identifier of the initial node pool.status(String) The current status of the initial node pool.status_message(String) A human-readable description of why the initial node pool failed. Set only whenstatusiserror.status_reason(String) Why the initial node pool failed, as a failure class (e.g.QuotaExceeded). Set only whenstatusiserror.
Nested Schema for timeouts
Optional:
create(String) How long the provider waits for the create operation to converge before giving up (e.g. "45m", "2h").delete(String) How long the provider waits for the delete to complete before giving up (e.g. "30m").update(String) How long the provider waits for an update (resize, in-place change) to converge before giving up (e.g. "30m").
Import
Import is supported using the following syntax:
The terraform import command can be used, for example:
# Import a cluster by its ID. public_ip_id is retired: it is refused at plan time
# and was never readable back from the API, so an imported cluster never carries
# it. If a plan then wants to REPLACE the cluster over that attribute, add
# lifecycle { ignore_changes = [public_ip_id] } — removing the line alone is what
# triggers the replacement, not what avoids it.
terraform import frostmoln_kubernetes_cluster.main 51455a51-db3d-4231-ac27-4fee2553c15f