Building Your First Terraform Provider with Pyvider
This is part 1 of a four-part series. Part 2 adds a data source. Part 3 adds a provider function. Part 4 adds an ephemeral resource.
Get the code: all four parts live in provide-io/pyvider-tutorial. Clone and follow along:
git clone https://github.com/provide-io/pyvider-tutorial.git
cd pyvider-tutorial/part1-resource
uv sync && uv run pyvider install && tofu init && tofu apply
This tutorial walks through building a minimal Terraform provider in Python using Pyvider. By the end you’ll have a working provider that creates, reads, updates, and deletes a simple server resource.
Prerequisites
- Python 3.11+
uvorpip- Terraform or OpenTofu installed
Setup
uv init my-provider
cd my-provider
uv add pyvider
Define Your Provider
Create my_provider/__init__.py:
from pyvider.providers import BaseProvider, ProviderMetadata, register_provider
from pyvider.schema import PvsSchema, s_provider
@register_provider("mycloud")
class MyCloudProvider(BaseProvider):
"""A minimal cloud provider."""
def __init__(self) -> None:
super().__init__(
metadata=ProviderMetadata(
name="mycloud",
version="0.1.0",
protocol_version="6",
)
)
@classmethod
def get_schema(cls) -> PvsSchema:
return s_provider({}) # No provider-level config needed yet
Define a Resource
Create my_provider/server.py. The resource stores servers in a class-level dict (standing in for a real API) so that other components — like the data source in part 2 — can query the same state.
from typing import Any, ClassVar
from attrs import define
from pyvider.resources import BaseResource, ResourceContext, register_resource
from pyvider.schema import PvsSchema, a_str, s_resource
@define
class ServerConfig:
name: str
@define
class ServerState:
id: str
name: str
status: str
@register_resource("server")
class Server(BaseResource):
config_class = ServerConfig
state_class = ServerState
# In-memory store — replace with real API calls in production
_servers: ClassVar[dict[str, dict[str, Any]]] = {}
_next_id: ClassVar[int] = 1
@classmethod
def get_schema(cls) -> PvsSchema:
return s_resource({
"id": a_str(computed=True, description="Server identifier"),
"name": a_str(required=True, description="Server name"),
"status": a_str(computed=True, description="Server status"),
})
async def _validate_config(self, config: ServerConfig) -> list[str]:
if not config.name:
return ["name cannot be empty"]
return []
async def _create_apply(self, ctx: ResourceContext) -> tuple[ServerState | None, None]:
server_id = f"srv-{Server._next_id:03d}"
Server._next_id += 1
data = {"id": server_id, "name": ctx.config.name, "status": "running"}
Server._servers[server_id] = data
return ServerState(**data), None
async def read(self, ctx: ResourceContext) -> ServerState | None:
data = Server._servers.get(ctx.state.id)
return ServerState(**data) if data else None
async def _update_apply(self, ctx: ResourceContext) -> tuple[ServerState | None, None]:
data = Server._servers[ctx.state.id]
data["name"] = ctx.config.name
return ServerState(**data), None
async def _delete_apply(self, ctx: ResourceContext) -> None:
Server._servers.pop(ctx.state.id, None)
Create the Terraform Configuration
Create main.tf:
terraform {
required_providers {
mycloud = {
source = "example.com/tutorial/mycloud"
version = "0.1.0"
}
}
}
provider "mycloud" {}
resource "mycloud_server" "web" {
name = "web-01"
}
output "server_id" { value = mycloud_server.web.id }
output "server_status" { value = mycloud_server.web.status }
Run It
pyvider install # registers the provider with Terraform
terraform init
terraform plan
terraform apply
pyvider install writes a shim into Terraform’s plugin directory that activates your virtualenv and runs your provider.
After apply you should see server_id = "srv-001" and server_status = "running" in the output.
Required Methods on Every Resource
| Method | Purpose |
|---|---|
get_schema | Declares attributes Terraform sees |
_validate_config | Return a list of errors, or [] to pass |
read | Refresh state from the real backend |
_delete_apply | Destroy the resource |
_create_apply and _update_apply have defaults (pass planned state through), but you’ll want to override them to call your real API.
Next: Part 2 — Adding a Data Source to query your servers without managing them.