Skip to content

microsoftexpert/tf-mod-azapi-resource-action

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

3 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

⚑ azapi Resource Action Terraform Module

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 the azapi_resource_action primitive; secret results route to a dedicated sensitive output channel, and this primitive deliberately has no request sensitive_body. Built for the Azure azapi provider v2.x (~> 2.10).

Terraform azapi module type azapi blocks authoring


🧩 Overview

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) or when = 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, flagged sensitive_output. Non-secret results go to output via response_export_values ([] by default).
  • 🧭 Six HTTP methods β€” POST (default), PATCH, PUT, DELETE, GET, HEAD, validated.
  • πŸ›‘οΈ ignore_not_found to tolerate a missing target; locks + retry for safe, resilient calls.

πŸ’‘ Why it matters: some operations are verbs β€” and their answer is often a secret (listKeys returns access keys). This module wires those to a sensitive output so a key never lands in plaintext state, and it exposes no request sensitive_body because, for this primitive, secrets are results, not request payloads.

⚠️ Correction vs the per-primitive governance table: azapi_resource_action has no sensitive_body (confirmed against the live v2.10.0 schema). Do not pass secrets in the request body.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


πŸ—ΊοΈ Where this fits in the family

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;
Loading

ℹ️ 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 by azurerm; secret results flow only through the sensitive channel.


🧬 What this module builds

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;
Loading

Resource inventory (1 azapi_* block):

  • azapi_resource_action.this β€” performs action (HTTP method) on the resource at resource_id, optionally with a request body; routes non-secret results to output and secret results to sensitive_output.

βœ… Provider / Versions

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 request body is plain Dynamic β€” never put a secret in it (it would be persisted). Secret results use sensitive_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"] (default POST); when ∈ ["apply","destroy"] (default apply).
  • βž– No tags / location / identity / parent_id / name on this primitive.

πŸ”‘ Required Azure RBAC Permissions

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/action
  • Microsoft.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.


🧰 Azure Prerequisites

  • 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 sets enable_preflight = true (recommended).
  • Auth configured by the caller β€” OIDC / workload-identity federation preferred.

πŸ“ Module Structure

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

βš™οΈ Quick Start

# 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.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
resource_id string (ARM ID) the module that OWNS the target (tf-mod-azapi-resource, an azurerm resource)

Emits

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

πŸ“š Example Library

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 to output or 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), destroy does nothing. Use when = "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 in body β€” 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-only sensitive_body.


πŸ“₯ Inputs

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

🧾 Outputs

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

🧠 Architecture Notes

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.


🧱 Design Principles β€” the empty call is the safe call

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

πŸš€ Runbook

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.


πŸ§ͺ Testing

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.


πŸ’¬ Example Output

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

πŸ” Troubleshooting

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

πŸ”— Related Docs

  • Terraform azapi provider β€” azapi_resource_action resource 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."

About

No description or website provided.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages