ZSoftly Cloud Platform
Back to blog

Building the ZCP Terraform / OpenTofu Provider

ZCP infrastructure is now code. How we built our provider on the CLI SDK, what live testing taught us about state, networks, and public IPs, and why we ship to both registries with signed releases.

Ditah Kumbong (Founder & CTO)
5 min read
The zsoftly/zcp provider page on the OpenTofu registry, showing version 0.1.0, the MIT license, and the required_providers usage snippet.

You manage ZCP infrastructure as code now. The official provider is live on the OpenTofu registry and the Terraform Registry, with 38 resources and 12 data sources covering everything our zcp CLI does: instances, volumes, snapshots and backups, VPCs and networks, firewall and egress rules, load balancers, auto-scaling, Kubernetes clusters, DNS, object storage, VPN, and account governance.

terraform {
  required_providers {
    zcp = {
      source  = "zsoftly/zcp"
      version = "~> 0.1"
    }
  }
}

The same source address works with both tools. Here is what building it taught us.

One SDK, two clients

The provider does not reimplement our API. It imports the same Go SDK the zcp CLI uses. Every request the provider sends is a request the CLI already sends. Authentication, retries, and platform quirks live in one place. When we fix a platform edge case in the SDK, both clients inherit the fix in their next release.

This forced a licensing decision. The CLI repository carried a source-available license. A Go binary statically links its dependencies. An MIT provider embedding a restricted SDK would have promised users rights we had not granted. We relicensed the CLI and its SDK under Apache-2.0. The dependency chain is now clean. Contributions carry a patent grant. Both registries serve a provider anyone is free to rebuild and redistribute.

Declarative semantics are the hard part

Wrapping an API in resource schemas is the easy half of a provider. The hard half is honoring Terraform’s contract: a plan must tell the truth, an apply must converge, and a second plan must show no changes.

This contract surfaced platform behaviors a CLI user never notices:

  • Cached state lies. Our control plane refreshes instance state lazily, so a VM reports Starting for minutes after it is running. A CLI user shrugs and reruns list. A provider blocking on Running would hang. We added a live state endpoint to the platform, and the provider polls it instead, reconciling against the hypervisor. Creates now return in about two minutes, exactly when the VM is up.
  • DNS has no record IDs. Our DNS backend models records as sets addressed by name and type, the PowerDNS way. Faking per-record IDs would have produced resources Terraform has no reliable way to refresh or delete. Instead, zcp_dns_record embraces the model: one resource per name and type pair, with a synthetic ID built from both.
  • Write-only fields need a plan. The API never returns an SSH key’s material or a sub-user’s password. Naive handling makes every terraform import plan a destroy-and-recreate. The provider adopts the configured value on the first apply after import, and only forces replacement when a known prior value changes.

Live testing beats unit testing

Every resource has unit tests against fakes, but the findings we cared about came from applying real configurations against production. Two examples from one afternoon:

First, orphaned networks. Our instance resource accepts a network_plan shortcut, and the platform creates a network for the VM as a side effect. Terraform never sees it, so destroy removes the VM and leaves the network and its public address billing forever. The fix was not code, it was guidance: declare the network as its own resource and attach the instance to it. Our examples and tutorial now use the tracked pattern everywhere, and a full destroy leaves nothing behind. We verified the whole cycle live: apply, check the platform, destroy, confirm zero leftovers.

Second, the empty public_ip. On source-NAT networks the public address belongs to the network, not the VM, so the VM object never carries it and the attribute came back blank. The provider now resolves it from the account’s IP list by VM identifier, preferring static assignments. Outputs show the address the moment apply completes.

We also ran three adversarial review rounds over the codebase and verified every finding against the live API before fixing it. Several were platform bugs exposed by the provider’s strictness, not provider bugs. Those became backend tickets. The provider now fails loudly rather than recording state the platform did not honor.

Signed releases, two registries, zero ceremony

CI builds and GPG-signs every release. The private key lives only in the release pipeline and in our vault. Both registries verify the signature against our published key before serving a version, and your init verifies it again on install.

We published to the OpenTofu registry first. The whole process is two issue forms on a public GitHub repository. A bot validates the release artifacts, then a maintainer merges the request.

The zsoftly/zcp provider page on the OpenTofu registry, showing version 0.1.0 and the usage snippet

The Terraform Registry followed the same afternoon. Shipping a new version now takes one git tag. Both registries pick it up automatically.

The zsoftly/zcp provider page on the Terraform Registry, version 0.1.0 in the Cloud Automation category

Try it

The tutorial takes you from an empty directory to a running VM and back to a clean account in about fifteen minutes, with OpenTofu and Terraform commands side by side. The provider reference lives on either registry, and the source is on GitHub under MIT. Issues and contributions are welcome. Our first community contribution shipped in the CLI last week. We would be glad to see the same happen here.

If you are new to ZCP, create an account and put your first terraform apply on Canadian infrastructure today.