frostmoln_image (Resource)
Manages a customer custom image (bring-your-own-image). The provider runs the full flow: it creates the image record, uploads the local disk image straight to Frostmoln object storage with the presigned form the platform returns, asks the platform to import it, and waits for the image to reach "active". Custom images require the custom-images entitlement; without it the API refuses the create. Only name, description, min_disk_gb and min_ram_mb can be changed in place — every other attribute, including source_file, replaces the image. Create waits up to 60 minutes for the import to finish, and a destroy retries for the same 60 minutes while an import still holds the image; neither budget is configurable.
Example Usage
terraform
# Upload a custom disk image (bring-your-own-image) and boot an instance from it.
#
# source_file is a LOCAL filesystem path, read by the machine running Terraform —
# it is not a bucket key or a URL. The provider creates the image record, uploads
# the file straight to Frostmoln object storage with a presigned form, asks the
# platform to import it, and waits for it to reach "active".
#
# Custom images require the `custom-images` entitlement on the tenant. Without
# it the API refuses the create.
#
# Terraform cannot see a change to the CONTENTS of source_file under an unchanged
# path, so pair it with source_file_hash when you rebuild the image in place — a
# changed hash replaces the image, which re-uploads and re-imports it.
#
# VERIFYING A VENDOR IMAGE. source_file_hash is a change trigger, not a check: it
# is never sent anywhere and nothing compares it to anything. The read-only
# `checksum` attribute is not the check either — it is an MD5 of what the
# platform STORES, and for a qcow2 the import converts the image to raw first,
# so it cannot equal the SHA-256 a vendor publishes. Assert the published value
# locally, against the file itself, before the upload spends the bandwidth — the
# precondition below does that.
#
# That is a check on the SOURCE. In-transit corruption is a separate failure and
# is handled for you: the provider hashes the bytes as it uploads them and
# compares them with the checksum the storage edge reports, failing the apply
# rather than importing a damaged image.
#
# Two create failures are worth recognising, because they share an error code
# and differ only by status. Neither is safe to treat as "wait and re-apply":
#
# * 403 quota_exceeded — the anti-abuse staging limit on uploads in flight.
# Its first check counts the images this tenant holds in `queued`, and a
# FAILED import leaves the image back in `queued` — so abandoned attempts
# keep holding a slot no matter how long you wait, and only deleting them
# helps. (Refusals on staged BYTES or objects do drain by themselves once
# an import settles; nothing in the error says which of the three you hit,
# so read the message the API returned.)
# * 409 quota_exceeded — the tenant's custom-image allowance is full. This one
# never clears on its own. The allowance bounds both the NUMBER of images
# and their total size, so deleting the smallest one may not be enough;
# delete enough to free space, or ask for a higher limit. See the destroy
# note below — that delete can itself be refused.
#
# DESTROY CAN FAIL WITH 409 resource_in_use. Frostmoln stores images on Ceph
# RBD and boots instances from copy-on-write clones of them, so an image the
# storage backend still has clones of cannot be deleted — in practice that means
# instances launched from it. Terraform destroys `frostmoln_instance.app` before
# `frostmoln_image.golden` here, because the `image_id` reference makes that
# dependency explicit — but an instance that takes its image by literal id,
# through a data source, or from another state file carries NO such edge, and
# `terraform destroy` then stops at the image. Add `depends_on` to restore the
# ordering, or destroy the instances first.
#
# The image stays fully intact — the refusal happens before anything is removed,
# so the same destroy succeeds once the last clone is gone.
#
# DESTROY CAN ALSO FAIL WITH 409 invalid_state, while the image is still being
# imported. Glance holds the image for the lifetime of its import task, so it
# cannot be deleted mid-import. What clears that is the import FINISHING — after
# which the image is deletable like any other. The platform also expires a
# STALLED import on its own, and reports how long that has left to run, which is
# the number the refusal quotes; because the countdown restarts every time the
# import makes progress, a healthy four-minute import quotes about an hour for
# its whole life and then goes deletable in four minutes.
#
# The provider therefore keeps RETRYING rather than sleeping out the quoted time,
# and does not weigh that time against its own patience before starting — a
# healthy import quotes more than the destroy's whole 60-minute budget for its
# entire life, so weighing it would refuse the one case retrying is for. A
# destroy retries every half minute until the image lets go or the 60 minutes are
# spent; an import that finishes normally is picked up within seconds of
# finishing. If the budget runs out first, the destroy fails with the platform's
# latest quote in the diagnostic — re-run once the import has finished, or after
# that time if it never does. When the platform cannot quote anything at all, the
# destroy fails immediately: re-run shortly (that answer also covers a passing
# failure to read the import) and contact support if it keeps failing.
#
# Note the budget cuts both ways: a destroy against an importing image may now
# BLOCK for up to an hour rather than failing fast, which matters most in CI,
# where a job timeout is what notices.
#
# `terraform import` is deliberately NOT supported for this resource. source_file
# is a local path the API never returns, so an imported image would immediately
# plan a destroy/recreate — re-uploading gigabytes to rebuild something that
# already exists. Declare the image in configuration and apply instead.
locals {
image_path = "${path.module}/build/golden-ubuntu-24.04.qcow2"
# The value from the vendor's published SHA256SUMS for exactly this file.
image_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
}
resource "frostmoln_image" "golden" {
name = "golden-ubuntu-24.04"
description = "Hardened Ubuntu 24.04 base image"
source_file = local.image_path
# One expression serves both purposes: the change trigger that replaces the
# image when the file is rebuilt, and the value compared below.
source_file_hash = filesha256(local.image_path)
disk_format = "qcow2"
os_distro = "ubuntu"
os_version = "24.04"
architecture = "x86_64"
min_disk_gb = 20
min_ram_mb = 2048
lifecycle {
precondition {
condition = filesha256(local.image_path) == local.image_sha256
error_message = "The image at ${local.image_path} does not match the vendor's published SHA-256."
}
}
}
resource "frostmoln_instance" "app" {
name = "app-01"
image_id = frostmoln_image.golden.id
flavor_id = "gp1.small"
subnet_id = frostmoln_subnet.example.id
}Schema
Required
disk_format(String) The disk format of source_file. Customer images must be "qcow2" or "raw".name(String) The name of the image. Between 1 and 255 characters.source_file(String) Local filesystem path to the disk image to upload, read by the machine running Terraform — not a bucket key or a URL. Its size is checked against the platform's upload limit before any bytes are sent. Terraform cannot see a change to the file's CONTENTS under an unchanged path: pair it with source_file_hash if you want a rebuilt image to trigger a replacement.
Optional
architecture(String) The CPU architecture the image is built for (e.g. "x86_64"). Recorded as a Glance image property at creation and cannot be changed afterwards.container_format(String) The container format of source_file. Customer images must be "bare" (the default).description(String) A human-readable description of the image.min_disk_gb(Number) Minimum root disk size in GB an instance must have to boot this image. Can be changed in place. The platform may RAISE this value after an import completes, to match the image's actual expanded size — a configured value is kept in state as written, so a later refresh can surface the platform's larger figure as a diff.min_ram_mb(Number) Minimum RAM in MB an instance must have to boot this image. Can be changed in place.os_distro(String) The OS distribution of the image (e.g. "ubuntu", "debian"). Recorded as a Glance image property at creation and cannot be changed afterwards.os_version(String) The OS version of the image (e.g. "24.04"). Recorded as a Glance image property at creation and cannot be changed afterwards.source_file_hash(String) Optional change trigger for the CONTENTS of source_file, typically filemd5("...") or filesha256("...") over the same path. The provider never computes it itself — hashing a multi-gigabyte disk image on every plan would make every plan read the whole file. Its value is never sent to the API; changing it replaces the image, which re-uploads and re-imports it.
Read-Only
checksum(String) The MD5 checksum of the STORED image data, set by the platform once the image is active. It is not the value to check a vendor download against: it is an MD5 while vendors publish SHA-256, and for a qcow2 upload it describes the image AFTER the import has converted it to raw, so it cannot equal a digest of source_file. (For a disk_format of "raw" there is no conversion, so only the algorithm differs.) To verify a download, compare filesha256 of the local path against the vendor's published value before you apply — see the example. The provider also checks, at upload time, that the bytes the storage edge received match the bytes it sent, and fails the apply if they do not.created_at(String) The timestamp when the image was created.id(String) The unique identifier of the image.owner(String) The owning project of the image.size(Number) The stored size of the image in bytes.status(String) The status of the image ("active" once the import has completed).virtual_size(Number) The virtual disk size of the image in bytes once expanded.visibility(String) The visibility of the image. Customer images are always "private" — the API rejects anything else.