terraform-provider-ig1 — desired state over the same contract.
A Go provider on terraform-plugin-framework, OpenTofu-compatible, built against the
same generated SDK the CLI uses — so resources behave exactly like the
REST surface they front. It lives in terraform-provider-ig1/ at the repo
root (workstream W11); the attribute-level contract is the generated registry
documentation in that repo's docs/ — this page is the patterns.
Install
Current release: 0.2.2,
for linux, macOS and Windows on amd64 and arm64. If a committed lock file pins
0.2.0 or 0.2.1, run terraform init -upgrade once — the mirror serves
the current release only. Declare it the normal way:
terraform {
required_providers {
ig1 = {
source = "MoxForge/ig1"
version = "~> 0.2"
}
}
}
The registry listing is still pending, so point your CLI at this hub’s provider mirror once — after that terraform init behaves exactly as it will when the listing lands, including version constraints and .terraform.lock.hcl. Put this in your CLI config file (~/.terraformrc, or ~/.tofurc for OpenTofu; %APPDATA%\terraform.rc on Windows):
provider_installation {
network_mirror {
url = "https://docs.cloud.ig1.com/providers/"
include = ["registry.terraform.io/moxforge/ig1",
"registry.opentofu.org/moxforge/ig1"]
}
# Everything else keeps coming from the public registry. Without this block
# the mirror would be asked for every provider you use, and answer 404.
direct {
exclude = ["registry.terraform.io/moxforge/ig1",
"registry.opentofu.org/moxforge/ig1"]
}
}
Then, in any configuration that declares the provider:
terraform init # or: tofu init
# - Finding moxforge/ig1 versions matching "~> 0.2"...
# - Installing moxforge/ig1 v0.2.2...
# - Installed moxforge/ig1 v0.2.2 (verified checksum)
MoxForge/ig1 you write is fetched — and reported —
as moxforge/ig1. Write it either way; it is the same provider.
terraform providers lock \
-net-mirror=https://docs.cloud.ig1.com/providers/ \
-platform=linux_amd64 -platform=linux_arm64 \
-platform=darwin_amd64 -platform=darwin_arm64 \
-platform=windows_amd64 -platform=windows_arm64
-net-mirror is not optional here, and its absence is a confusing failure
rather than a quiet one: terraform providers lock deliberately
ignores provider_installation and goes to the origin registry,
so without the flag it reports that
registry.terraform.io does not have a provider named
registry.terraform.io/moxforge/ig1 — which is true, and is the whole reason
this mirror exists.
Direct downloads
For an air-gapped filesystem mirror, or to check a package by hand. The zip contains terraform-provider-ig1_v0.2.2 — the name Terraform looks for inside a provider package.
| Platform | Package | |
|---|---|---|
| Linux · x86-64 | terraform-provider-ig1_0.2.2_linux_amd64.zip | |
| Linux · arm64 | terraform-provider-ig1_0.2.2_linux_arm64.zip | |
| macOS · Apple silicon | terraform-provider-ig1_0.2.2_darwin_arm64.zip | |
| macOS · Intel | terraform-provider-ig1_0.2.2_darwin_amd64.zip | |
| Windows · x86-64 | terraform-provider-ig1_0.2.2_windows_amd64.zip | |
| Windows · arm64 | terraform-provider-ig1_0.2.2_windows_arm64.zip | |
| checksums | terraform-provider-ig1_0.2.2_SHA256SUMS | |
| signatures | <package>.minisig beside each zip and the checksums | minisign, signed at build time |
| public key | ig1-minisign.pub | id 8C6142C3D56C052E |
curl -fLO https://docs.cloud.ig1.com/providers/dist/terraform-provider-ig1_0.2.2_linux_amd64.zip
curl -fLO https://docs.cloud.ig1.com/providers/dist/terraform-provider-ig1_0.2.2_linux_amd64.zip.minisig
curl -fLO https://docs.cloud.ig1.com/ig1-minisign.pub
minisign -Vm terraform-provider-ig1_0.2.2_linux_amd64.zip -P $(sed -n 2p ig1-minisign.pub)
curl -fLO https://docs.cloud.ig1.com/providers/dist/terraform-provider-ig1_0.2.2_SHA256SUMS
shasum -a 256 --ignore-missing --check terraform-provider-ig1_0.2.2_SHA256SUMS
Build from source instead
Only needed to modify it — the packages above are built from this same tree by the docs image, so they are never behind it.
cd terraform-provider-ig1
make build # → bin/terraform-provider-ig1 (version read from config/build.yml)
make build-all # → dist/ the six release zips + SHA256SUMS
make vet test
Provider configuration
Auth is the phase-26 scoped key pair (create one in the portal under
Security → API keys or with ig1 credential create --kind api-key) —
the same model the REST surface uses, never an OIDC device flow:
provider "ig1" {
endpoint = "https://api.cloud.ig1.com"
api_key = var.ig1_client_id # credential client_id
api_secret = var.ig1_client_secret # credential client_secret
project_id = "a3d17d97b6444c20b54a20056e055ed7" # the lab pilot project
# ca_bundle = "ig1-internal-ca.pem" # only for in-lab *.nip.io endpoints; the
# # public cloud.ig1.com surfaces need no bundle
# insecure = true # lab-only escape hatch — prefer ca_bundle
}
The tier model applies unchanged: plans that create need a tier-1 key, destroys need
tier 2 — the API's 403 tier text surfaces verbatim in terraform apply
output.
Resources (v1)
Twenty CRUD-complete resources — the registrations in
internal/provider/provider.go, one section per domain below. Import by the
API's native identity (server/volume/network ids, bucket, keypair, cluster and ASG
names, exposure ids — exactly as the REST endpoints address them):
Compute
data "ig1_images" "ubuntu" { name = "Ubuntu-24.04" }
data "ig1_flavors" "small" { name = "m1.small" }
resource "ig1_keypair" "ops" {
name = "ops"
public_key = file("~/.ssh/id_ed25519.pub")
# IMPORT ONLY — public_key is required by design. Omitting it would be
# Nova's key-GENERATION mode, which returns the private half; that key
# would land in Terraform state, so the provider makes it unreachable.
}
resource "ig1_server" "web" {
name = "web-1"
image_id = data.ig1_images.ubuntu.images[0].id
flavor_id = data.ig1_flavors.small.flavors[0].id
key_pair = ig1_keypair.ops.name
networks = [ig1_network.app.id]
tags = ["env:lab", "role:web"]
}
# terraform import ig1_server.web <server-id>
Block storage
resource "ig1_volume" "data" {
name = "data-vol"
size_gb = 10 # growing in place is supported (os-extend); shrinking is not
}
resource "ig1_volume_attachment" "data_to_web" {
volume_id = ig1_volume.data.id
server_id = ig1_server.web.id # device is optional — Nova assigns /dev/vdX
}
resource "ig1_volume_snapshot" "pre_upgrade" {
volume_id = ig1_volume.data.id
name = "pre-upgrade"
# force = true snapshots an attached volume — the result is CRASH-CONSISTENT
}
# terraform import ig1_volume.data <volume-id>
Network
resource "ig1_network" "app" {
name = "app-net"
cidr = "10.10.0.0/24" # inline subnet; subnet_ids exposes what it created
}
resource "ig1_router" "egress" {
name = "app-egress"
external_network = "ext-net" # name OR id; resolved id lands in external_network_id
}
resource "ig1_router_interface" "app" {
router_id = ig1_router.egress.id
subnet_id = ig1_network.app.subnet_ids[0]
}
resource "ig1_security_group" "web" {
name = "web-sg"
description = "web tier ingress"
# Rules are FULLY managed: Neutron's default egress rules are removed at
# create — add an explicit egress rule to keep outbound traffic.
rules {
direction = "ingress"
protocol = "tcp"
port_range_min = 443
port_range_max = 443
remote_ip_prefix = "10.10.0.0/24"
}
rules {
direction = "egress"
remote_ip_prefix = "0.0.0.0/0"
}
}
resource "ig1_floating_ip" "web" {
pool = "ext-net" # name or id; empty = the first external network
server_id = ig1_server.web.id # associates in place; removing disassociates
}
# terraform import ig1_network.app <network-id>
DNS (Designate)
resource "ig1_dns_zone" "corp" {
name = "example.com" # a domain YOU control; a name inside IG1's own
# namespace is refused by the gateway
email = "dns@example.com" # SOA contact; omitted = the platform's
ttl = 3600
}
resource "ig1_dns_record" "www" {
zone_id = ig1_dns_zone.corp.id
name = "www" # short or fully qualified; "@" is the apex.
# The GATEWAY qualifies it — exactly one
# normaliser, so "www" and "www.example.com."
# cannot become two records.
type = "A"
records = ["203.0.113.10"]
ttl = 300
}
# name, type and zone_id force replacement: Designate cannot rename a recordset
# or change its type in place. records/ttl/description update in place, so a
# target change is an UPDATE — never a destroy/create, which would take the name
# out of resolution between the two calls.
# terraform import ig1_dns_zone.corp <zone-id>
# terraform import ig1_dns_record.www <zone-id>/<recordset-id>
Load balancing (Octavia, OVN driver)
resource "ig1_loadbalancer" "web" {
name = "web-lb"
vip_subnet_id = ig1_network.app.subnet_ids[0]
# Each listener gets its own pool holding every member below.
listeners = [{ protocol = "TCP", port = 443 }]
# Members are ADDRESSES, not instance handles — derive them from the
# instances so a rebuilt backend shows up as a diff, not a black hole.
# With internet_facing below, members MUST serve HTTPS on their port: the
# edge re-encrypts to the VIP, so a plain-HTTP backend fails the handshake
# (a self-signed certificate is accepted — the edge does not verify it).
members = [
for s in ig1_server.web : { address = s.addresses["app-net"][0], port = 8443 }
]
# ON BY DEFAULT (the AWS posture) — without it, OVN keeps hashing flows
# onto a dead member forever. No `type`: TCP listeners get a TCP probe,
# UDP listeners get UDP-CONNECT, the only two the OVN driver implements.
health_monitor = {
delay = 5
timeout = 3
max_retries = 3
}
# Default false. true publishes the VIP through the .75 edge — a floating
# IP on the VIP port, then an edge exposure targeting it — and toggles IN
# PLACE: flipping it attaches or detaches the public path, never replaces
# the load balancer. TCP listeners only (the edge speaks HTTPS to the target);
# with several listeners the edge forwards to TCP/443 when there is one,
# else the lowest TCP port.
internet_facing = true
# ON BY DEFAULT. Octavia's OVN VIP port is born with the tenant's DEFAULT
# security group, which denies all ingress — so without this the load
# balancer is ACTIVE, its members ONLINE, and nothing answers. true creates
# "<name>-lb", opens each listener port on it (0.0.0.0/0), and attaches it to
# the VIP port beside anything already there; destroy deletes it. false hands
# you the VIP's ingress: it refuses traffic until you attach a group yourself.
# Toggles IN PLACE, and the rules follow `listeners` on the next apply.
manage_security_group = true
}
output "web_public" {
# public_hostname web-lb-<tenant-slug>.10.57.8.75.nip.io (internal-CA cert; private name)
# public_status "pending" until the edge acknowledges the config, then "active"
# floating_ip_address 10.168.210.x — the internal hop the edge forwards to
# security_group_id the managed group above; null when you manage it yourself
value = "${ig1_loadbalancer.web.public_hostname} (${ig1_loadbalancer.web.public_status})"
}
L4 pass-through, internal by default: the VIP answers on the tenant subnet, TLS
terminates on the members, and the pool algorithm is fixed to SOURCE_IP_PORT
(the only one the OVN driver implements). internet_facing = true is the
opt-in; the computed public_hostname, public_status and
floating_ip_address carry the honest state — a plan that shows
public_status = "pending" is telling the truth about the edge, not failing.
If publishing fails the load balancer is kept and the diagnostic names what
exists and how to finish by hand; nothing claims internet-facing on a floating IP alone.
Destroy cascades through listeners, pools, monitors and members — taking down every
exposure that targets the floating IP first, then the floating IP. The address is
released only once the exposure list was read successfully and every exposure
on it is confirmed gone; if the list cannot be read or a delete fails, the floating IP
is kept (Neutron would hand it to the next project while a live hostname still
forwards to it) and the diagnostic names what remains and the
ig1 edge delete / ig1 network fip release commands.
The multi-listener rule in the comment above is the provider's alone —
ig1 network lb create and the MCP create_load_balancer take a
single listener port, so those surfaces cannot diverge from it.
"Internet-facing" here means the VIP is published through the platform edge at
10.57.8.75 under a generated .nip.io hostname — which resolves
to a private address, so the flag alone buys fabric/VPN reach. For public reach, put a
domain you own on an ig1_edge_exposure: the edge has answered on
185.255.84.178 since 2026-08-25, and a verified custom domain is served with
a publicly-trusted certificate.
manage_security_group is the other half of "the load balancer works when
the apply finishes". The OVN VIP port arrives carrying the tenant's default security
group, which denies all ingress, so the plan used to converge on a resource that was
ACTIVE with ONLINE members and served nothing — no attribute
you could read said otherwise. Left at its default the resource creates
{name}-lb, one ingress rule per distinct listener port from
0.0.0.0/0, attaches it beside whatever the port already carries, and
exposes the id as the computed security_group_id. That default is honest
rather than lax: the boundary is the network — the VIP answers only where your subnets,
floating IPs and exposures already reach — and a narrower rule would recreate the same
silence the first time internet_facing flips to true. Day 2
behaves the way a Terraform user expects: a port added to or removed from
listeners is added to or removed from the group on the next apply, while a
rule you added yourself to narrow it — another prefix, another protocol — is left
alone, and the flag toggles in place (true →
false detaches and deletes the managed group, false →
true builds and attaches one; the load balancer is never replaced for it).
Set it to false when the ingress is yours to model (a group you manage in
ig1_security_group, say), and read the consequence in the plan: the VIP
refuses traffic on the listener port until you attach one. Destroy
deletes the group last — after the cascade releases the VIP port, because Neutron
refuses to delete a group still in use — and only when it is the group this resource
made: {name}-lb, attached to that VIP port. A group you attached yourself
is never touched, and a group left behind by a failed delete is named in the diagnostic.
Kubernetes, autoscaling & the tenant edge (elasticity wave)
# ig1_cluster and ig1_asg ride the FACTORY service (factory_endpoint —
# derived from endpoint by swapping the api. host label for factory.).
resource "ig1_cluster" "web" {
name = "web"
autoscaling_enabled = true # the cluster-autoscaler owns the worker count:
autoscaling_min = 1 # manual scales are refused 409 while enabled,
autoscaling_max = 5 # so leave worker_count unset here
}
resource "ig1_asg" "web" {
name = "web"
flavor = "m1.small"
image = "ubuntu-24.04"
min = 1
max = 5 # desired unset: the reconciler owns it
health_check_type = "tcp" # consecutive-failure probes; one blip never
health_check_port = 8080 # deletes a serving VM
scale_out_alarm = "web-cpu-high" # ok -> alarm TRANSITIONS scale;
scale_in_alarm = "web-cpu-low" # insufficient_data never does
}
resource "ig1_edge_exposure" "shop" {
name = "shop" # hostname becomes
target_ip = ig1_floating_ip.shop.address # shop-<tenant-slug>.10.57.8.75.nip.io
target_port = 8443 # ownership verified via Neutron
custom_domain = "shop.example.com" # CLAIMED, not routed — see below
}
# Your own domain is proved before it is served, and the proof lives in a zone
# this provider does not manage. Hence a separate resource and a depends_on —
# the aws_acm_certificate_validation shape, for exactly the same reason.
resource "cloudflare_record" "challenge" {
zone_id = var.zone
name = ig1_edge_exposure.shop.domain_challenge_name # _ig1-challenge.shop.example.com
type = "TXT"
value = ig1_edge_exposure.shop.domain_token
}
resource "ig1_edge_domain_verification" "shop" {
exposure_id = ig1_edge_exposure.shop.id
depends_on = [cloudflare_record.challenge] # apply FAILS if the TXT is not live
}
resource "cloudflare_record" "traffic" {
zone_id = var.zone
name = "shop.example.com"
type = "A"
# From the API, never hard-coded: it is the edge VIP while this platform has
# no public ingress, and the public address afterwards.
value = ig1_edge_domain_verification.shop.domain_target
}
The elasticity contracts carry through unchanged: ASG scale-out passes tenant-quota
admission (the 409 names the numbers), scale-in is drain-first (destroy waits for the
teardown to finish), autoscaling_max is what admission charges, and an
exposure reports pending until the edge acknowledges the rendered config —
never a false active. Every factory/API refusal surfaces verbatim in the
plan/apply diagnostics.
custom_domain is the one attribute on ig1_edge_exposure that does
not force replacement: moving a domain in place avoids taking the derived
.nip.io hostname down along with it. Removing it from the configuration
releases the name back to the global pool, where another tenant may claim it —
so that is not reliably reversible by adding it back.
ig1_edge_domain_verification destroys as a no-op: a verification is a fact about
the past, and there is no un-prove call to make. A customer domain on the managed edge gets a
publicly-trusted (Let's Encrypt) certificate automatically since 2026-08-25 — only the
platform-generated *.10.57.8.75.nip.io names keep the internal CA, because no
public CA can validate a name that resolves into private space.
Object storage & databases (W6)
resource "ig1_bucket" "artifacts" {
name = "ci-artifacts" # the bucket name IS the identity
}
resource "ig1_database" "pg" {
engine = "postgres" # or "kafka"
name = "app-db"
size_gb = 20 # grow-only in place — a smaller size is refused
# phase 52 (PostgreSQL): replicas scale IN PLACE, either direction;
# backup is create-time only (WAL archiving + scheduled base backups)
replicas = 2
backup {
enabled = true # retention_days 7, schedule "0 3 * * *" by default
}
}
# terraform import ig1_database.pg postgres/app-db
Credentials, webhooks & budgets (W4 / W7)
resource "ig1_credential" "ci" {
kind = "api-key" # or "mcp"
tier = 1
label = "ci-runner"
}
resource "ig1_webhook" "audit" {
url = var.webhook_receiver_url # https only (the events SSRF guard)
topics = ["iam.credentials", "agent.actions", "billing.budget.breached"]
}
resource "ig1_budget" "monthly" {
amount_cents = 25000 # EUR 250.00 — breaches publish billing.budget.breached
} # v1 period is monthly only
# terraform import ig1_credential.ci <credential-id>
Egress & workload identity (phases 59-60)
resource "ig1_nat_gateway" "egress" {} # no arguments: one per project, or none
# The platform enables SNAT on this project's router. Destroying it disables SNAT,
# and every instance without a floating IP loses outbound internet.
# Tier grant: discovery 1 / standard 1 / extension 1 / suspended 0.
# terraform import ig1_nat_gateway.egress <gateway-id>
resource "ig1_workload_identity" "ci" {
cluster_id = ig1_cluster.prod.id
namespace = "build"
service_account = "runner" # this SA's projected token is exchanged for IG1 creds
} # the IRSA equivalent: no long-lived key in the cluster
# terraform import ig1_workload_identity.ci <binding-id>
Secrets manager (Barbican)
resource "ig1_secret" "db_password" {
name = "app-db-password"
payload = var.db_password # write-only on the API: uploaded at create, NEVER read back
} # changing payload replaces the secret — that IS rotation
# terraform import ig1_secret.db_password <secret-id> # imports metadata; payload stays null
ig1_credential's
client_secret and ig1_webhook's signing secret are
returned ONCE at create — after that, state is the only place they exist.
ig1_secret's payload is the same contract from the other
direction: the provider never reads it back, so state holds exactly what you PUT (the
standard aws_secretsmanager_secret_version.secret_string caveat). All three
attributes are marked sensitive. Treat state as a secret: a remote,
encrypted backend; never commit it; rotate by terraform taint + apply
(revoke + re-issue) — or, for ig1_secret, by editing payload —
not by reading it back.
Data sources
| Data source | Answers |
|---|---|
ig1_images | the Glance catalog (filter to one image id) |
ig1_flavors | the Nova flavor list (filter to one flavor id) |
ig1_project | the whoami-derived tenant info for the configured key |
ig1_server | one server, looked up by name |
ig1_network | one network, looked up by name |
ig1_dns_zone | one zone by id or exact name — and whether the domain is actually delegated here. nameservers is what you give a registrar; delegation_status says whether they have published it (delegated / partial / elsewhere / absent / unreachable). Shares its name with the resource, which is normal: resource declares a zone you own, data reads one that already exists — the common case, since a zone is created once and its records churn |
data "ig1_network" "app" { name = "app-net" }
data "ig1_project" "self" {}
# Look up a zone somebody else created, and refuse to pretend it is live
# when the registrar has not pointed the domain here yet.
data "ig1_dns_zone" "corp" { name = "example.com" }
resource "ig1_dns_record" "www" {
zone_id = data.ig1_dns_zone.corp.id
name = "www"
type = "A"
records = [ig1_floating_ip.web.address]
}
output "publish_these_nameservers" { value = data.ig1_dns_zone.corp.nameservers }
output "delegated" { value = data.ig1_dns_zone.corp.delegation_status }
delegation_status is that
check. It reaches a resolver, so it degrades to a warning rather than failing the
plan: a hiccup upstream must not take down every configuration that merely wanted
the zone id.