Invoke a control-plane action (or raw HTTP request) on an existing ARM resource β start/stop a VM,
listKeys,regenerateKey, trigger a failover β without managing the resource's lifecycle. Wraps theazapi_resource_actionprimitive; secret results route to a dedicated sensitive output channel, and this primitive deliberately has no requestsensitive_body. Built for the Azureazapiprovider v2.x (~> 2.10).
This module wraps the azapi_resource_action primitive β the verb, not the noun:
- β‘ Actions, not resources β
start,powerOff,listKeys,regenerateKey, or a raw HTTP request against the resource ID. Owns no lifecycle. - π
when = apply(default) orwhen = destroyβ run the action on create/update, or on teardown (e.g. a deregister/cleanup step). - π Secret results to the sensitive channel β
sensitive_response_export_valuesβ the computed, flaggedsensitive_output. Non-secret results go tooutputviaresponse_export_values([]by default). - π§ Six HTTP methods β
POST(default),PATCH,PUT,DELETE,GET,HEAD, validated. - π‘οΈ
ignore_not_foundto tolerate a missing target;locks+retryfor safe, resilient calls.
π‘ Why it matters: some operations are verbs β and their answer is often a secret (
listKeysreturns access keys). This module wires those to a sensitive output so a key never lands in plaintext state, and it exposes no requestsensitive_bodybecause, for this primitive, secrets are results, not request payloads.
β οΈ Correction vs the per-primitive governance table:azapi_resource_actionhas nosensitive_body(confirmed against the live v2.10.0 schema). Do not pass secrets in the requestbody.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
caller["Caller root config<br/>provider azapi + OIDC auth"]:::ext
res["tf-mod-azapi-resource<br/>creates resources"]:::sib
upd["tf-mod-azapi-update-resource<br/>patches resources"]:::sib
dp["tf-mod-azapi-data-plane-resource<br/>data-plane objects"]:::sib
azurerm["azurerm / other team<br/>owns the target"]:::sib
act["tf-mod-azapi-resource-action<br/>THIS Β· control-plane action"]:::this
target["Existing ARM resource<br/>e.g. VM, storage account"]:::keystone
secret["Sensitive consumers<br/>Key Vault, app config"]:::sib
caller --> act
res -->|"id as resource_id"| act
upd -->|"id as resource_id"| act
dp -->|"id as resource_id"| act
azurerm -->|"id as resource_id"| act
act -->|"invoke action / HTTP request"| target
act -. "sensitive_output: keys, tokens".-> secret
classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:3px;
classDef keystone fill:#004578,color:#fff,stroke:#0078D4,stroke-width:2px;
classDef sib fill:#E6F2FB,color:#073B6B,stroke:#0078D4,stroke-width:1px;
classDef ext fill:#f2f2f2,color:#333333,stroke:#999999,stroke-width:1px;
βΉοΈ Blue = this module. It can invoke an action on any existing resource owned by a sibling tier-1 primitive (
tf-mod-azapi-resource,tf-mod-azapi-update-resource,tf-mod-azapi-data-plane-resource) or byazurerm; secret results flow only through the sensitive channel.
flowchart TD
subgraph inputs["Typed inputs (the contract)"]
direction TB
t["type @ api-version<br/>resource_id (target)"]:::ext
a["action Β· method Β· when"]:::ext
b["body (optional request)"]:::ext
exp["response_export_values<br/>sensitive_response_export_values"]:::ext
end
this["azapi_resource_action.this<br/>invoke action Β· NO sensitive_body"]:::this
target["Existing ARM resource"]:::keystone
out["output (non-secret) Β· exist Β· id"]:::keystone
sout["sensitive_output (flagged sensitive)"]:::secret
t --> this
a --> this
b --> this
exp --> this
this -->|"invoke action / HTTP request"| target
this --> out
this -. "secret results".-> sout
classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:3px;
classDef keystone fill:#004578,color:#fff,stroke:#0078D4,stroke-width:2px;
classDef secret fill:#FCE8E6,color:#8B1A10,stroke:#D13438,stroke-width:1px;
classDef ext fill:#f2f2f2,color:#333333,stroke:#999999,stroke-width:1px;
Resource inventory (1 azapi_* block):
azapi_resource_action.thisβ performsaction(HTTPmethod) on the resource atresource_id, optionally with a requestbody; routes non-secret results tooutputand secret results tosensitive_output.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| azapi provider | ~> 2.10 (validated against v2.10.0) |
| Provider config | None inside the module β the caller configures provider "azapi" + auth. |
Schema notes that bite (verified against the azapi v2.10.0 binary schema):
- π No
sensitive_body. The requestbodyis plainDynamicβ never put a secret in it (it would be persisted). Secret results usesensitive_response_export_valuesβsensitive_output. - π No change detection. An action can re-run on every apply if its inputs change. Keep inputs stable for one-shot actions.
- π§
methodβ["POST","PATCH","PUT","DELETE","GET","HEAD"](defaultPOST);whenβ["apply","destroy"](defaultapply). - β No
tags/location/identity/parent_id/nameon this primitive.
Action-specific (the action is caller-supplied). The apply identity needs the specific control-plane operation the action maps to, on the target resource, e.g.:
Microsoft.Compute/virtualMachines/start/action,.../powerOff/actionMicrosoft.Storage/storageAccounts/listKeys/action,.../regenerateKey/action- For a raw
GET/HEAD:Microsoft.<RP>/<resource>/read.
Plus RP registration for the target's Microsoft.<RP>. Least-privilege: a custom role granting only
the action(s) used.
β οΈ Actions that retrieve secrets (e.g.listKeys) are sensitive β route role design through Cloud Security / IAM, and prefer Entra-based data access over key retrieval where possible.
- The target resource already exists.
- The action is valid for the target type at the chosen api-version.
- Terraform
>= 1.12; azapi~> 2.10; caller setsenable_preflight = true(recommended). - Auth configured by the caller β OIDC / workload-identity federation preferred.
tf-mod-azapi-resource-action/
βββ providers.tf # required_providers (Azure/azapi ~> 2.10) + required_version >= 1.12.0; no provider block
βββ variables.tf # resource_id, type, action, method, when, body, (sensitive_)response_export_values, behavior
βββ main.tf # one keystone azapi_resource_action.this; total renderer; dynamic timeouts
βββ outputs.tf # id (primary), exist, output (non-secret), sensitive_output (flagged sensitive)
βββ examples/
β βββ basic/ # listKeys -> sensitive_output (the secure secret-retrieval pattern)
βββ README.md # you are here
βββ SCOPE.md # lightweight cross-module contract
# Stop a VM on apply (an action, not a resource):
module "stop_vm" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Compute/virtualMachines@2024-07-01"
resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-casey/providers/Microsoft.Compute/virtualMachines/vm-casey"
action = "powerOff"
}
provider "azapi" {
enable_preflight = true
}
β οΈ Pin the module by tag (?ref=v1.0.0), never a branch.
| Input | Type | Source |
|---|---|---|
resource_id |
string (ARM ID) |
the module that OWNS the target (tf-mod-azapi-resource, an azurerm resource) |
| Output | Description | Consumed by |
|---|---|---|
id |
ARM resource ID the action ran on | references / auditing |
exist |
Whether the action was performed successfully | conditional downstream logic |
output |
NON-SECRET results (response_export_values; {} default) |
downstream config |
sensitive_output |
SECRET results (sensitive_response_export_values; flagged sensitive) |
secret consumers only |
1 Β· Minimal β start a VM
module "start_vm" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Compute/virtualMachines@2024-07-01"
resource_id = module.vm.id
action = "start"
}2 Β· Stop / powerOff a VM
module "stop_vm" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Compute/virtualMachines@2024-07-01"
resource_id = module.vm.id
action = "powerOff" # "deallocate" to also release compute billing
}3 Β· listKeys β sensitive_output (the secure secret-retrieval pattern)
module "storage_keys" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
action = "listKeys"
method = "POST"
# Keys are SECRET β export ONLY via the sensitive channel.
sensitive_response_export_values = ["keys"]
}
output "primary_key" {
value = try(module.storage_keys.sensitive_output.keys[0].value, null)
sensitive = true
}π The key flows to
sensitive_output(flagged sensitive), never tooutputor plaintext state.
4 Β· regenerateKey
module "rotate_key" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
action = "regenerateKey"
body = { keyName = "key1" }
sensitive_response_export_values = ["keys"]
}π Prefer Entra-based access over keys; when keys are unavoidable, rotate them here and consume from
sensitive_output.
5 Β· Raw GET β read info without managing the resource
module "read_sku" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
method = "GET" # action left null = plain HTTP request to the resource ID
response_export_values = { sku = "sku.name" } # non-secret
}
output "storage_sku" {
value = module.read_sku.output.sku
}6 Β· Run on destroy β a teardown/deregister action
module "deregister_on_destroy" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.AppPlatform/Spring@2024-05-01-preview"
resource_id = module.spring.id
action = "stop"
when = "destroy" # runs on destroy instead of apply
}βΉοΈ With
when = "apply"(default),destroydoes nothing. Usewhen = "destroy"for cleanup steps.
7 Β· Tolerate a missing target (ignore_not_found)
module "best_effort_stop" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Compute/virtualMachines@2024-07-01"
resource_id = module.vm.id
action = "powerOff"
ignore_not_found = true # 404 is not a failure (e.g. VM already gone)
}8 Β· Custom method + request body
module "failover" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
action = "failover"
method = "POST"
body = { failoverType = "Planned" }
}
β οΈ No secrets inbodyβ this primitive has no write-only request channel.
9 Β· Non-secret + secret results together
module "list_conn" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.EventHub/namespaces/authorizationRules@2024-01-01"
resource_id = "${module.eventhub_ns.id}/authorizationRules/RootManageSharedAccessKey"
action = "listKeys"
# Non-secret metadata to output, secret connection strings to sensitive_output.
response_export_values = { key_name = "keyName" }
sensitive_response_export_values = { primary_conn = "primaryConnectionString" }
}
output "key_name" { value = module.list_conn.output.key_name }
output "primary_connection_string" {
value = module.list_conn.sensitive_output.primary_conn
sensitive = true
}π Split deliberately: non-secret β
output, secret βsensitive_output. Never mix them.
10 Β· Transient-error retry policy
module "start_with_retry" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Compute/virtualMachines@2024-07-01"
resource_id = module.vm.id
action = "start"
behavior = {
retry = {
error_message_regex = ["Conflict", "TooManyRequests"]
interval_seconds = 15
max_interval_seconds = 180
}
}
}11 Β· Serialize actions with behavior.locks
module "scale_action" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.App/containerApps@2024-03-01"
resource_id = module.container_app.id
action = "start"
behavior = {
locks = [module.container_app.id] # avoid parallel actions racing on the same resource
}
}12 Β· for_each at scale β start a fleet of VMs
locals {
vms = {
web1 = module.vm_web1.id
web2 = module.vm_web2.id
api1 = module.vm_api1.id
}
}
module "start_fleet" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
for_each = local.vms
type = "Microsoft.Compute/virtualMachines@2024-07-01"
resource_id = each.value
action = "start"
}13 Β· ποΈ End-to-end composition (mandatory) β create, then act
# 1) Create a storage account (owned by tf-mod-azapi-resource)
module "storage" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
name = "stcaseyorders001"
parent_id = module.resource_group.id
location = "eastus2"
body = { sku = { name = "Standard_LRS" }, kind = "StorageV2" }
}
# 2) Retrieve its keys via an action, into the sensitive channel (this module)
module "storage_keys" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-resource-action?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id # β wired from output
action = "listKeys"
sensitive_response_export_values = ["keys"]
}
# 3) Store the key in Key Vault (consume sensitive_output, kept sensitive)
module "kv_secret" {
source = "git::https://github.com/microsoftexpert/tf-mod-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.KeyVault/vaults/secrets@7.5"
parent_id = module.key_vault.id
name = "stcaseyorders001-key1"
sensitive_body = { value = module.storage_keys.sensitive_output.keys[0].value }
}ποΈ The key never touches plaintext state: action β
sensitive_outputβ write-onlysensitive_body.
Core β resource_id (required), type (required), action, method, when.
Request / results β body, response_export_values, sensitive_response_export_values, ignore_not_found, headers, query_parameters.
Behavior tail β behavior (locks + retry), timeouts.
Full object schemas
resource_id = string # full ARM ID, must start with "/"; force-new
type = string # "<Namespace>/<resource>@<api-version>"; validated ^.+/.+@.+$
action = optional(string) # default null = plain HTTP request to the resource ID
method = optional(string, "POST") # POST | PATCH | PUT | DELETE | GET | HEAD
when = optional(string, "apply") # apply | destroy
body = optional(any) # optional request body; NO secrets (no write-only channel)
response_export_values = optional(any, []) # NON-SECRET result paths -> output
sensitive_response_export_values = optional(any, []) # SECRET result paths -> sensitive_output
ignore_not_found = optional(bool, false)
headers = optional(map(string), {})
query_parameters = optional(map(list(string)), {})
behavior = optional(object({
locks = optional(list(string), [])
retry = optional(object({
error_message_regex = list(string)
interval_seconds = optional(number, 10) # 1..120
max_interval_seconds = optional(number, 180) # 1..300
}))
}), {})
timeouts = optional(object({ create, read, update, delete = optional(string) })) # default null| Output | Description | Sensitive |
|---|---|---|
id |
Primary. ARM resource ID the action ran on. | no |
exist |
Whether the action was performed successfully. | no |
output |
NON-SECRET results (response_export_values; {} default). |
no β never export secret paths here |
sensitive_output |
SECRET results (sensitive_response_export_values; e.g. keys/tokens). |
yes |
Secrets are results, not requests. This primitive has no write-only sensitive_body β its job is to
retrieve or trigger, not to send credentials. The secure pattern is sensitive_response_export_values
β the computed, sensitive = true sensitive_output. Putting a secret in the request body would
persist it to state; don't. Non-secret results go to response_export_values β output (empty by
default).
No change detection β keep inputs stable. azapi_resource_action does not diff against remote state;
if its inputs change, the action re-runs. For a genuinely one-shot action, drive it from stable inputs or
guard the module call. exist reports whether the last action succeeded.
when controls the phase. Default apply runs the action on create/update; destroy runs it on
teardown (a deregister/stop/cleanup step) and not on apply. Pick deliberately.
Total renderer. main.tf projects the typed inputs onto the one azapi_resource_action.this β
direct passthrough for action/method/when/body/exports, a null-guarded retry, and a dynamic
timeouts block. ignore_not_found swallows HTTP 404s when set.
| Concern | Secure default | Opt-out |
|---|---|---|
| Secret results | routed via sensitive_response_export_values β sensitive_output (sensitive) |
β (exporting secrets via response_export_values is forbidden) |
| Non-secret exports | response_export_values = [] (export nothing) |
pass explicit non-secret paths |
| Request secrets | not supported β this primitive has no sensitive_body |
β (do not place secrets in body) |
| HTTP method | method = "POST" (validated enum) |
set another allowed method |
| Run phase | when = "apply" |
set "destroy" for teardown actions |
| Concurrency | behavior.locks = [] available |
populate with ARM IDs |
| 404 handling | ignore_not_found = false |
set true to tolerate missing targets |
Authoring is plan-only (regulated-FI posture) β a human applies from a controlled CI context:
cd tf-mod-azapi-resource-action
terraform init -backend=false
terraform validate
terraform fmt -check -recursive
# NO `terraform apply` here. plan/apply against an environment is a separate, human-reviewed CI step.
rm -rf.terraform.terraform.lock.hcl
β οΈ Consume the module pinned to a tag β?ref=v1.0.0, never a branch.
terraform init -backend=false # azapi ~> 2.10 only
terraform validate # "Success! The configuration is valid."
terraform fmt -check -recursive # zero formatting differencesβΉοΈ Validate runs offline. The action itself (and its results) only exercises against a real target at plan/apply β a separate, human-reviewed step.
id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/stcasey001"
exist = true
output = {} # nothing non-secret exported by default
sensitive_output = (sensitive value) # the listKeys result, when exported| Symptom | Cause | Fix |
|---|---|---|
| A retrieved key/token landed in plaintext state | You exported it via response_export_values |
Move that path to sensitive_response_export_values; consume from sensitive_output (mark the consuming output sensitive = true) |
| The action re-runs on every apply | azapi_resource_action has no change detection |
Keep inputs stable; if one-shot, drive it from a stable input or guard the call |
| 404 on a target that may not exist | Target missing | Set ignore_not_found = true to tolerate it |
method must be one of... |
Invalid method | Use POST, PATCH, PUT, DELETE, GET, or HEAD |
| Nothing happened on destroy | when = "apply" (default) β the action only ran on apply |
Use when = "destroy" for teardown actions |
- Terraform azapi provider β
azapi_resource_actionresource reference - Azure Resource Manager β action reference for the target type (
learn.microsoft.com/azure/templates/...) - Sibling modules β
tf-mod-azapi-resource(creates the target),tf-mod-azapi-data-plane-resource(store the result) SCOPE.md(this module)
π "Infrastructure as Code should be standardized, consistent, and secure."