Actions and List Resources
This is part 6 of the series. Parts 1–5 cover a resource, a data source, a function, an ephemeral resource, and packaging it as a binary.
Get the code: provide-io/pyvider-tutorial, directory part6-protocol-611.
Before you start: this part needs Terraform
Parts 1–5 run on OpenTofu or Terraform. This one does not.
OpenTofu v1.12.5
action → Error: Unsupported block type
list → Error: Unsupported block type
query → no such subcommand
Actions and list resources need Terraform 1.14 or newer. Everything below was run on 1.15.9. Nothing about pyvider stops OpenTofu — it simply has not shipped the configuration syntax yet.
Actions: the operation that isn’t a resource
Some things you need to do to infrastructure are not “make reality match this configuration”. Restart a node. Rotate a credential. Trigger a run. Before 6.11 these were modelled as resources with a trigger attribute, which put an imperative verb into a declarative graph and left state describing something that had already finished.
An action has no state and produces no diff. What it produces is progress.
from pyvider.actions import ActionContext, ActionPlan, ActionProgress, BaseAction, register_action
@register_action("mycloud_restart_server")
class RestartServer(BaseAction):
config_class = RestartConfig
async def plan(self, ctx: ActionContext[RestartConfig]) -> ActionPlan:
# The plan is where you say what an operator should read *before*
# approving, since an action's effect is not visible as a state diff.
if ctx.config is None:
return ActionPlan()
return ActionPlan(
warnings=(f"{ctx.config.server_id} will be briefly unavailable.",)
)
async def invoke(self, ctx: ActionContext[RestartConfig]) -> AsyncIterator[ActionProgress]:
server = Server._servers[ctx.config.server_id]
yield ActionProgress(message=f"Stopping {server['name']}...")
server["status"] = "stopped"
yield ActionProgress(message=f"Starting {server['name']}...")
server["status"] = "running"
yield ActionProgress(message=f"{server['name']} is running again.")
You do not call an action like a function. You attach it to a resource’s lifecycle, and events decides when it fires:
resource "mycloud_server" "web" {
name = local.server_name
lifecycle {
action_trigger {
events = [after_create]
actions = [action.mycloud_restart_server.smoke_test]
}
}
}
action "mycloud_restart_server" "smoke_test" {
config {
server_id = mycloud_server.web.id
}
}
Terraform counts actions separately in the plan, and prints each progress message as it arrives:
Plan: 1 to add, 0 to change, 0 to destroy. Actions: 1 to invoke.
mycloud_server.web: Creation complete after 0s [id=srv-001]
Action started: action.mycloud_restart_server.smoke_test (triggered by mycloud_server.web)
Action ...: Stopping web-prod...
Action ...: Starting web-prod...
Action ...: web-prod is running again.
Action complete: action.mycloud_restart_server.smoke_test
Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Actions: 1 invoked.
List resources: asking what already exists
Showing someone their existing infrastructure used to mean writing a data source per resource type and inventing a shape for the results. A list resource answers Terraform’s ListResource RPC instead, and describes each result with the identity schema of a managed resource you already wrote — which is how Terraform ties a listed instance back to a mycloud_server.
Identity is optional for a managed resource and mandatory for a list resource, so the resource opts in first:
@classmethod
def get_identity_schema(cls) -> PvsSchema:
return s_resource({
"id": a_str(required=True, description="Server identifier"),
})
Then the list resource itself. list is an async iterator, so results stream rather than being assembled in memory first:
@register_list_resource("mycloud_server", resource_type="mycloud_server")
class ServerList(BaseListResource):
config_class = ServerListConfig
resource_type = "mycloud_server"
async def list(self, ctx: ListResourceContext[ServerListConfig]) -> AsyncIterator[ListResult]:
wanted = ctx.config.status if ctx.config else None
for data in Server._servers.values():
if wanted is not None and data["status"] != wanted:
continue
yield ListResult(
identity={"id": data["id"]},
display_name=f"{data['name']} ({data['status']})",
# Only built when asked for: assembling full state is usually
# the expensive half of listing.
resource_object=data if ctx.include_resource_object else None,
)
The part that will cost you an afternoon
Register a list resource under the same type name as the managed resource it lists.
list "mycloud_server" "all" resolves to a list resource named mycloud_server. Name it mycloud_servers — which reads better, and is what I wrote first — and Terraform fails with:
Error: Identity schema not found for resource type mycloud_servers;
this is a bug in the provider - please report it there
It is not a bug in the provider. Terraform takes the identity schema from the managed resource of the same name, so a list resource under a different name has nowhere to get identity from. This is a resolution rule, not a naming convention.
Running it
list blocks live in a .tfquery.hcl file and run under terraform query, not plan or apply. The file shares the directory’s configuration — do not repeat terraform {} or provider {} in it, or you get a duplicate-provider error.
list "mycloud_server" "all" {
provider = mycloud
config {}
}
list "mycloud_server" "running_only" {
provider = mycloud
config {
status = "running"
}
}
$ terraform query
list.mycloud_server.all id=srv-900 legacy-db (running)
list.mycloud_server.all id=srv-901 legacy-cache (stopped)
list.mycloud_server.running_only id=srv-900 legacy-db (running)
Nothing was created or destroyed. The provider was asked what exists, and answered.
Both, end to end
What’s left in 6.11
Two component types this part does not cover. State stores let a provider serve Terraform’s state backend, with locking that survives a crashed process; they are demonstrated differently, since a state store is configured in the terraform block rather than used as a resource. Deferred responses let a resource answer “not yet” instead of guessing at a value it cannot know — worth its own piece, because the interesting part is when not to defer.
Both are implemented in pyvider 0.5 today; see the release notes.