Convert or migrate Azure ARM (Azure Resource Manager) templates, Bicep templates, or code to Pulumi, including importing existing Azure resources. This skill MUST be loaded whenever a user requests migration, conversion, or import of ARM templates, Bicep templates, ARM code, Bicep code, or Azure resources to Pulumi.
Install with Tessl CLI
npx tessl i github:pulumi/agent-skills --skill pulumi-arm-to-pulumi92
Does it follow best practices?
Validation for skill structure
If you have already generated a migration plan before loading this skill, you MUST:
The migration output MUST meet all of the following:
Complete Resource Coverage
Successful Deployment
pulumi preview (assuming proper config).Zero-Diff Import Validation (if importing existing resources)
pulumi preview must show:
Final Migration Report
If a user-provided ARM template is incomplete, ambiguous, or missing artifacts, ask targeted questions before generating Pulumi code.
If there is ambiguity on how to handle a specific resource property on import, ask targeted questions before altering Pulumi code.
Follow this workflow exactly and in this order:
Running Azure CLI commands (e.g., az resource list, az resource show). Requires initial login using ESC and az login
Setting up Azure CLI using ESC:
pulumi env run {org}/{project}/{environment} -- bash -c 'az login --service-principal -u "$ARM_CLIENT_ID" --tenant "$ARM_TENANT_ID" --federated-token "$ARM_OIDC_TOKEN"'. ESC is not required after establishing the sessionaz account showaz account list --query "[].{Name:name, SubscriptionId:id, IsDefault:isDefault}" -o tableFor detailed ESC information: Load the pulumi-esc skill by calling the tool "Skill" with name = "pulumi-esc"
ARM templates do not have the concept of "stacks" like CloudFormation. Read the ARM template JSON file directly:
# View template structure
cat template.json | jq '.resources[] | {type: .type, name: .name}'
# View parameters
cat template.json | jq '.parameters'
# View variables
cat template.json | jq '.variables'Extract:
Documentation: ARM Template Structure
If the ARM template has already been deployed and you're importing existing resources:
# List all resources in a resource group
az resource list \
--resource-group <resource-group-name> \
--output json
# Get specific resource details
az resource show \
--ids <resource-id> \
--output json
# Query specific properties using JMESPath
az resource show \
--ids <resource-id> \
--query "{name:name, location:location, properties:properties}" \
--output jsonDocumentation: Azure CLI Documentation
IMPORTANT: ARM to Pulumi conversion requires manual translation. There is NO automated conversion tool for ARM templates. You are responsible for the complete conversion.
Provider Strategy:
@pulumi/azure-native for full Azure Resource Manager API coverage@pulumi/azure (classic provider) when azure-native doesn't support specific features or when you need simplified abstractionsDocumentation:
Language Support:
Complete Coverage:
ARM Template:
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[parameters('storageAccountName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2",
"properties": {
"supportsHttpsTrafficOnly": true
}
}Pulumi TypeScript:
import * as pulumi from "@pulumi/pulumi";
import * as azure_native from "@pulumi/azure-native";
const config = new pulumi.Config();
const storageAccountName = config.require("storageAccountName");
const location = config.require("location");
const resourceGroupName = config.require("resourceGroupName");
const storageAccount = new azure_native.storage.StorageAccount("storageAccount", {
accountName: storageAccountName,
location: location,
resourceGroupName: resourceGroupName,
sku: {
name: azure_native.storage.SkuName.Standard_LRS,
},
kind: azure_native.storage.Kind.StorageV2,
enableHttpsTrafficOnly: true,
});ARM Template:
{
"parameters": {
"location": {
"type": "string",
"defaultValue": "eastus",
"metadata": {
"description": "Location for resources"
}
},
"instanceCount": {
"type": "int",
"defaultValue": 2,
"minValue": 1,
"maxValue": 10
},
"enableBackup": {
"type": "bool",
"defaultValue": true
},
"secretValue": {
"type": "securestring"
}
}
}Pulumi TypeScript:
const config = new pulumi.Config();
const location = config.get("location") || "eastus";
const instanceCount = config.getNumber("instanceCount") || 2;
const enableBackup = config.getBoolean("enableBackup") ?? true;
const secretValue = config.requireSecret("secretValue"); // Returns Output<string>ARM Template:
{
"variables": {
"storageAccountName": "[concat('storage', uniqueString(resourceGroup().id))]",
"webAppName": "[concat(parameters('prefix'), '-webapp')]"
}
}Pulumi TypeScript:
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const prefix = config.require("prefix");
const resourceGroupId = config.require("resourceGroupId");
// Simple variable
const webAppName = `${prefix}-webapp`;
// For uniqueString equivalent, use stack name or generate hash
const storageAccountName = `storage${resourceGroupId}`.toLowerCase();ARM Template:
{
"type": "Microsoft.Network/virtualNetworks/subnets",
"apiVersion": "2023-05-01",
"name": "[concat(variables('vnetName'), '/subnet-', copyIndex())]",
"copy": {
"name": "subnetCopy",
"count": "[parameters('subnetCount')]"
},
"properties": {
"addressPrefix": "[concat('10.0.', copyIndex(), '.0/24')]"
}
}Pulumi TypeScript:
const config = new pulumi.Config();
const subnetCount = config.getNumber("subnetCount") || 3;
const subnets: azure_native.network.Subnet[] = [];
for (let i = 0; i < subnetCount; i++) {
subnets.push(new azure_native.network.Subnet(`subnet-${i}`, {
subnetName: `subnet-${i}`,
virtualNetworkName: vnet.name,
resourceGroupName: resourceGroup.name,
addressPrefix: `10.0.${i}.0/24`,
}));
}ARM Template:
{
"type": "Microsoft.Network/publicIPAddresses",
"apiVersion": "2023-05-01",
"condition": "[parameters('createPublicIP')]",
"name": "[variables('publicIPName')]",
"location": "[parameters('location')]"
}Pulumi TypeScript:
const config = new pulumi.Config();
const createPublicIP = config.getBoolean("createPublicIP") ?? false;
let publicIP: azure_native.network.PublicIPAddress | undefined;
if (createPublicIP) {
publicIP = new azure_native.network.PublicIPAddress("publicIP", {
publicIpAddressName: publicIPName,
location: location,
resourceGroupName: resourceGroup.name,
});
}
// Handle optional references
const publicIPId = publicIP ? publicIP.id : pulumi.output(undefined);ARM Template:
{
"type": "Microsoft.Web/sites",
"apiVersion": "2023-01-01",
"name": "[variables('webAppName')]",
"dependsOn": [
"[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]"
]
}Pulumi TypeScript:
// Implicit dependency (preferred)
const webApp = new azure_native.web.WebApp("webApp", {
name: webAppName,
resourceGroupName: resourceGroup.name,
serverFarmId: appServicePlan.id, // Implicit dependency through property reference
});
// Explicit dependency (when needed)
const webApp = new azure_native.web.WebApp("webApp", {
name: webAppName,
resourceGroupName: resourceGroup.name,
serverFarmId: appServicePlan.id,
}, {
dependsOn: [appServicePlan], // Explicit dependency
});ARM Template:
{
"type": "Microsoft.Resources/deployments",
"apiVersion": "2021-04-01",
"name": "nestedTemplate",
"properties": {
"mode": "Incremental",
"template": {
"resources": [...]
}
}
}Pulumi Approach:
Instead of nested templates, use Pulumi ComponentResource to group related resources:
class NetworkComponent extends pulumi.ComponentResource {
public readonly vnet: azure_native.network.VirtualNetwork;
public readonly subnets: azure_native.network.Subnet[];
constructor(name: string, args: NetworkComponentArgs, opts?: pulumi.ComponentResourceOptions) {
super("custom:azure:NetworkComponent", name, {}, opts);
const defaultOptions = { parent: this };
this.vnet = new azure_native.network.VirtualNetwork(`${name}-vnet`, {
virtualNetworkName: args.vnetName,
resourceGroupName: args.resourceGroupName,
location: args.location,
addressSpace: {
addressPrefixes: [args.addressPrefix],
},
}, defaultOptions);
this.subnets = args.subnets.map((subnet, i) =>
new azure_native.network.Subnet(`${name}-subnet-${i}`, {
subnetName: subnet.name,
virtualNetworkName: this.vnet.name,
resourceGroupName: args.resourceGroupName,
addressPrefix: subnet.addressPrefix,
}, defaultOptions)
);
this.registerOutputs({
vnetId: this.vnet.id,
subnetIds: this.subnets.map(s => s.id),
});
}
}ARM Template:
{
"outputs": {
"storageAccountName": {
"type": "string",
"value": "[variables('storageAccountName')]"
},
"storageAccountId": {
"type": "string",
"value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
}
}
}Pulumi TypeScript:
export const storageAccountName = storageAccount.name;
export const storageAccountId = storageAccount.id;In some cases, you may need to use the Azure Classic provider (@pulumi/azure) instead of Azure Native. The Classic provider offers simplified abstractions and may be easier to work with for certain resources.
ARM Template:
{
"type": "Microsoft.Network/virtualNetworks",
"apiVersion": "2023-05-01",
"name": "[parameters('vnetName')]",
"location": "[parameters('location')]",
"properties": {
"addressSpace": {
"addressPrefixes": [
"10.0.0.0/16"
]
},
"subnets": [
{
"name": "default",
"properties": {
"addressPrefix": "10.0.1.0/24"
}
},
{
"name": "apps",
"properties": {
"addressPrefix": "10.0.2.0/24"
}
}
]
}
}Pulumi TypeScript (Classic Provider):
import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";
const config = new pulumi.Config();
const vnetName = config.require("vnetName");
const location = config.require("location");
const resourceGroupName = config.require("resourceGroupName");
const vnet = new azure.network.VirtualNetwork("vnet", {
name: vnetName,
location: location,
resourceGroupName: resourceGroupName,
addressSpaces: ["10.0.0.0/16"],
subnets: [
{
name: "default",
addressPrefix: "10.0.1.0/24",
},
{
name: "apps",
addressPrefix: "10.0.2.0/24",
},
],
});Note: The Classic provider allows defining subnets inline within the VirtualNetwork resource, which can be simpler than managing them as separate resources.
ARM Template:
{
"resources": [
{
"type": "Microsoft.Web/serverfarms",
"apiVersion": "2023-01-01",
"name": "[parameters('appServicePlanName')]",
"location": "[parameters('location')]",
"sku": {
"name": "B1",
"tier": "Basic",
"size": "B1",
"capacity": 1
},
"kind": "linux",
"properties": {
"reserved": true
}
},
{
"type": "Microsoft.Web/sites",
"apiVersion": "2023-01-01",
"name": "[parameters('webAppName')]",
"location": "[parameters('location')]",
"dependsOn": [
"[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]"
],
"properties": {
"serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]",
"siteConfig": {
"linuxFxVersion": "NODE|18-lts",
"appSettings": [
{
"name": "WEBSITE_NODE_DEFAULT_VERSION",
"value": "~18"
}
]
}
}
}
]
}Pulumi TypeScript (Classic Provider):
import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";
const config = new pulumi.Config();
const appServicePlanName = config.require("appServicePlanName");
const webAppName = config.require("webAppName");
const location = config.require("location");
const resourceGroupName = config.require("resourceGroupName");
const appServicePlan = new azure.appservice.ServicePlan("appServicePlan", {
name: appServicePlanName,
location: location,
resourceGroupName: resourceGroupName,
osType: "Linux",
skuName: "B1",
});
const webApp = new azure.appservice.LinuxWebApp("webApp", {
name: webAppName,
location: location,
resourceGroupName: resourceGroupName,
servicePlanId: appServicePlan.id,
siteConfig: {
applicationStack: {
nodeVersion: "18-lts",
},
},
appSettings: {
"WEBSITE_NODE_DEFAULT_VERSION": "~18",
},
});Note: The Classic provider has dedicated resources like LinuxWebApp and WindowsWebApp that provide better type safety and clearer configuration options compared to the generic WebApp resource.
Azure Native outputs often include undefined. Avoid ! non-null assertions. Always safely unwrap with .apply():
// ❌ WRONG - Will cause TypeScript errors
const webAppUrl = `https://${webApp.defaultHostName!}`;
// ✅ CORRECT - Handle undefined safely
const webAppUrl = webApp.defaultHostName.apply(hostname =>
hostname ? `https://${hostname}` : ""
);ARM template name property maps to specific naming fields in Pulumi:
// ARM: "name": "myStorageAccount"
// Pulumi:
new azure_native.storage.StorageAccount("logicalName", {
accountName: "mystorageaccount", // Actual Azure resource name
// ...
});ARM templates require explicit API versions. Pulumi providers use recent stable API versions by default:
// ARM: "apiVersion": "2023-01-01"
// Pulumi: API version is embedded in the providerCheck the Pulumi Registry documentation for which API version each resource uses.
.apply() in TypeScript)azure provider when azure-native is availableconcat(), uniqueString(), etc.After conversion, you can optionally import existing resources to be managed by Pulumi. If the user does not request this, suggest it as a follow-up step to conversion.
CRITICAL: When the user requests importing existing Azure resources into Pulumi, see arm-import.md for detailed import procedures and zero-diff validation workflows.
arm-import.md provides:
Inline Import Approach:
import resource option with Azure Resource IDspulumi-cdk-importer)Azure Resource IDs:
/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}Zero-Diff Validation:
pulumi preview after importSet up stack configuration matching ARM template parameters:
# Set Azure region
pulumi config set azure-native:location eastus --stack dev
# Set application parameters
pulumi config set storageAccountName mystorageaccount --stack dev
# Set secret parameters
pulumi config set --secret adminPassword MyS3cr3tP@ssw0rd --stack devAfter achieving zero diff in preview (if importing), validate the migration:
Review all exports:
pulumi stack outputVerify resource relationships:
pulumi stack graphTest application functionality (if applicable)
Document any manual steps required post-migration
If the user asks for help planning or performing an ARM to Pulumi migration, use the information above to guide the user through the conversion and import process.
When the user wants additional information, use the web-fetch tool to get content from the official Pulumi documentation:
Microsoft Azure Documentation:
When performing a migration, always produce:
pulumi config set commandsKeep code syntactically valid and clearly separated by files.
b6b942f
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.