Azure Function Deployment¶
Argus runs as an Azure Function with a Timer trigger on a weekly schedule.
Prerequisites¶
- Azure CLI installed and authenticated:
az login - Azure Functions Core Tools v4 installed (
funcCLI — needed to publish function code after Bicep deploy) - A resource group:
- The subscription IDs you want to scan — you'll need them for
subscriptionIdsparameter - An Azure OpenAI resource with a GPT-4o deployment or an Anthropic API key
Deploy¶
az deployment group create \
--resource-group Argus-RG \
--template-file deploy/azure/function-app.bicep \
--parameters \
subscriptionIds="sub-id-1,sub-id-2" \
slackWebhookUrl="https://hooks.slack.com/services/T.../B.../..."
With Azure OpenAI¶
az deployment group create \
--resource-group Argus-RG \
--template-file deploy/azure/function-app.bicep \
--parameters \
subscriptionIds="sub-id-1,sub-id-2" \
slackWebhookUrl="https://hooks.slack.com/services/..." \
azureOpenAIEndpoint="https://my-resource.openai.azure.com/" \
azureOpenAIDeployment="gpt-4o"
With Anthropic API¶
az deployment group create \
--resource-group Argus-RG \
--template-file deploy/azure/function-app.bicep \
--parameters \
subscriptionIds="sub-id-1,sub-id-2" \
slackWebhookUrl="https://hooks.slack.com/services/..." \
anthropicApiKey="sk-ant-..."
IAM permissions¶
The Bicep template creates a system-assigned Managed Identity for the Function App. All permissions are read-only — Argus never writes to any Azure resource.
What each adapter method calls¶
| Adapter method | Azure API called | Azure SDK client |
|---|---|---|
list_resources | POST /providers/Microsoft.ResourceGraph/resources (KQL query) | ResourceGraphClient.resources() |
get_metrics | GET /subscriptions/{sub}/resourceGroups/.../providers/.../metrics | MetricsQueryClient.query_resource() + list_metric_definitions() |
get_cost | POST /subscriptions/{sub}/providers/Microsoft.CostManagement/query | CostManagementClient.query.usage() |
get_last_activity | Log Analytics: POST /workspaces/{id}/query (KQL) | LogsQueryClient.query_workspace() |
get_last_activity (fallback) | GET /subscriptions/{sub}/providers/microsoft.insights/eventtypes/management/values | MonitorManagementClient.activity_logs.list() |
Required roles (minimum)¶
| Role | Scope | Azure permissions granted | Used by | Required |
|---|---|---|---|---|
Reader | Each subscription | */read on all resource types | Resource Graph KQL, Monitor metrics, Activity Log fallback | Yes |
Cost Management Reader | Each subscription | Microsoft.CostManagement/query/action, Microsoft.CostManagement/*/read | Cost Management API for spend data | Yes for cost data |
Log Analytics Reader | Log Analytics workspace | Microsoft.OperationalInsights/workspaces/read, */query/read | Activity Log KQL queries via Log Analytics | Optional¹ |
Storage Blob Data Contributor | Report storage account | Microsoft.Storage/storageAccounts/blobServices/containers/blobs/* | Write JSON + HTML reports, generate SAS URLs | Optional² |
¹ Required only when
logAnalyticsWorkspaceIdis set. Without it, Argus falls back to the Activity Log REST API (covered byReader).
² Required only whenreportStorageAccountis set.
Custom role (minimum permission surface)¶
If you want tighter control than the built-in roles, create a custom role with only the exact actions Argus calls:
{
"Name": "Argus Scanner",
"Description": "Minimum read-only permissions for Argus cost optimizer",
"IsCustom": true,
"Actions": [
"Microsoft.ResourceGraph/resources/action",
"Microsoft.Insights/metrics/read",
"Microsoft.Insights/metricDefinitions/read",
"Microsoft.Insights/eventtypes/management/values/read",
"Microsoft.CostManagement/query/action",
"Microsoft.CostManagement/*/read"
],
"NotActions": [],
"DataActions": [
"Microsoft.OperationalInsights/workspaces/query/read"
],
"AssignableScopes": [
"/subscriptions/YOUR-SUB-ID"
]
}
# Save the JSON above as argus-custom-role.json, then:
az role definition create --role-definition argus-custom-role.json
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Argus Scanner" \
--scope /subscriptions/YOUR-SUB-ID
Note: Most users should use built-in
Reader+Cost Management Reader— they are simpler, pre-audited by Microsoft, and cover all Argus operations. The custom role above is for environments with strict role minimization requirements.
Grant roles: copy-paste commands¶
# Step 1: get the managed identity principal ID from the deployment output
PRINCIPAL_ID=$(az deployment group show \
--resource-group Argus-RG \
--name function-app \
--query properties.outputs.functionAppPrincipalId.value -o tsv)
echo "Managed Identity Principal ID: $PRINCIPAL_ID"
# Step 2: grant Reader + Cost Management Reader on every subscription to scan
for SUB_ID in sub-id-1 sub-id-2 sub-id-3; do
echo "Granting roles on subscription: $SUB_ID"
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Reader" \
--scope /subscriptions/$SUB_ID
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Cost Management Reader" \
--scope /subscriptions/$SUB_ID
done
# Step 3 (optional): grant Log Analytics Reader for richer activity data
LOG_WORKSPACE_ID="/subscriptions/sub-id-1/resourceGroups/my-rg/providers/Microsoft.OperationalInsights/workspaces/my-workspace"
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Log Analytics Reader" \
--scope $LOG_WORKSPACE_ID
# Step 4 (optional): grant Storage Blob Data Contributor for HTML report uploads
STORAGE_ACCOUNT_ID="/subscriptions/sub-id-1/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myreportstore"
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Storage Blob Data Contributor" \
--scope $STORAGE_ACCOUNT_ID
Verify the assignments¶
az role assignment list \
--assignee $PRINCIPAL_ID \
--query "[].{Role:roleDefinitionName,Scope:scope}" \
--output table
Expected output:
Role Scope
-------------------------- ------------------------------------------
Reader /subscriptions/sub-id-1
Cost Management Reader /subscriptions/sub-id-1
Reader /subscriptions/sub-id-2
Cost Management Reader /subscriptions/sub-id-2
Terraform equivalent¶
data "azurerm_subscription" "targets" {
for_each = toset(var.subscription_ids)
subscription_id = each.value
}
# Reader — covers Resource Graph, Monitor metrics, Activity Log fallback
resource "azurerm_role_assignment" "argus_reader" {
for_each = data.azurerm_subscription.targets
principal_id = azurerm_linux_function_app.argus.identity[0].principal_id
role_definition_name = "Reader"
scope = each.value.id
}
# Cost Management Reader — covers CostManagement/query/action
resource "azurerm_role_assignment" "argus_cost" {
for_each = data.azurerm_subscription.targets
principal_id = azurerm_linux_function_app.argus.identity[0].principal_id
role_definition_name = "Cost Management Reader"
scope = each.value.id
}
# Log Analytics Reader — optional, only if log_analytics_workspace_id is set
resource "azurerm_role_assignment" "argus_logs" {
count = var.log_analytics_workspace_id != "" ? 1 : 0
principal_id = azurerm_linux_function_app.argus.identity[0].principal_id
role_definition_name = "Log Analytics Reader"
scope = var.log_analytics_workspace_id
}
Why Reader is sufficient for most operations¶
Azure's Reader role includes */read on all resource providers. This covers:
Microsoft.ResourceGraph/resources/action— Resource Graph KQL queriesmicrosoft.insights/metrics/read— Monitor metrics for all resource typesmicrosoft.insights/eventtypes/management/values/read— Activity Log fallback
Cost Management Reader is a separate role because cost data is scoped to billing, not resources, and Reader does not include Microsoft.CostManagement/query/action.
Post-deploy: deploy the function code¶
What gets created¶
| Resource | Purpose |
|---|---|
| Function App (Linux, Python 3.11) | Runs the scan |
| App Service Plan (Consumption Y1) | Serverless billing |
| Storage Account | Required by Function runtime |
| System-assigned managed identity | Authentication to Azure APIs — no credentials stored |
| Role assignments | Reader + Cost Management Reader at resource group level. Cross-subscription access must be granted manually (see above). |
View logs¶
Multi-subscription setup¶
To scan multiple subscriptions in one run, see the Multi-subscription guide — it covers:
- Granting roles across subscriptions (the
forloop above works for this) - Configuring
AZURE_SUBSCRIPTION_IDSorACCOUNTS_CONFIG - Terraform alternative
Parameters reference¶
| Parameter | Required | Default | Description |
|---|---|---|---|
subscriptionIds | Yes | — | Comma-separated subscription IDs |
slackWebhookUrl | Yes | — | Slack incoming webhook URL |
azureOpenAIEndpoint | No | (empty) | Use Azure OpenAI for AI inference |
azureOpenAIDeployment | No | gpt-4o | Deployment name |
anthropicApiKey | No | (empty) | Use Anthropic API instead |
logAnalyticsWorkspaceId | No | (empty) | Enables Activity Log KQL queries (richer last-activity data) |
scheduleExpression | No | 0 0 9 * * 1 | Timer cron expression |
ignoreRegions | No | (empty) | Comma-separated Azure regions to skip |
dryRun | No | false | true to skip Slack post |
reportStorageAccount | No | (empty) | Storage account name for JSON + HTML reports. When set, the Slack digest includes a "Full report" button with a 7-day SAS URL. The Bicep automatically assigns Storage Blob Data Contributor to the managed identity. The storage account must already exist in the same subscription. |
reportStorageContainer | No | argus-reports | Blob container name (created automatically if missing) |
reportUrlExpiry | No | 604800 | SAS URL expiry in seconds (default: 7 days) |
location | No | resource group location | Azure region for all resources |