diff --git a/README.md b/README.md index 22bd30c..fca56c9 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ Confidential Virtual Machine (CVM) deployment scripts: ### [AKS Samples](/aks-samples/README.md) Azure Kubernetes Service with AMD SEV-SNP confidential computing: - **BuildRandomAKS.ps1** - AKS cluster with Customer Managed Keys and confidential node pools +- **[AKS Virtual Nodes on Confidential AKS](/aks-samples/virtual%20nodes/README.md)** - End-to-end sample that deploys AKS with virtual nodes backed by ACI, validates hello-world scheduling on the virtual node, and links to a confidential ACI follow-on manifest. ### [Attestation Samples](/attestation-samples/README.md) Microsoft Azure Attestation (MAA) provider management: diff --git a/aci-samples/visual-attestation-demo-v2/deployment-template-confidential-allowall.json b/aci-samples/visual-attestation-demo-v2/deployment-template-confidential-allowall.json new file mode 100644 index 0000000..7858d4c --- /dev/null +++ b/aci-samples/visual-attestation-demo-v2/deployment-template-confidential-allowall.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "containerGroupName": { + "type": "string" + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "appImage": { + "type": "string" + }, + "registryServer": { + "type": "string" + }, + "registryUsername": { + "type": "string" + }, + "registryPassword": { + "type": "securestring" + }, + "dnsNameLabel": { + "type": "string" + } + }, + "variables": {}, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[parameters('containerGroupName')]", + "location": "[parameters('location')]", + "properties": { + "sku": "Confidential", + "confidentialComputeProperties": { + "ccePolicy": "cGFja2FnZSBwb2xpY3kKYXBpX3ZlcnNpb24gOj0gIjAuMS4wIgppbXBvcnQgZnV0dXJlLmtleXdvcmRzLmV2ZXJ5CmltcG9ydCBmdXR1cmUua2V5d29yZHMuaW4KZnJhZ21lbnRzIDo9IFtdCmNvbnRhaW5lcnMgOj0gW10KYWxsb3dfcHJvcGVydGllc19hY2Nlc3MgOj0gdHJ1ZQphbGxvd19kdW1wX3N0YWNrcyA6PSB0cnVlCmFsbG93X3J1bnRpbWVfbG9nZ2luZyA6PSB0cnVlCmFsbG93X2Vudmlyb25tZW50X3ZhcmlhYmxlX2Ryb3BwaW5nIDo9IHRydWUKYWxsb3dfdW5lbmNyeXB0ZWRfc2NyYXRjaCA6PSB0cnVlCmFsbG93X2NhcGFiaWxpdHlfZHJvcHBpbmcgOj0gdHJ1ZQ==" + }, + "containers": [ + { + "name": "cc-attest", + "properties": { + "image": "[parameters('appImage')]", + "ports": [ + { + "protocol": "TCP", + "port": 80 + } + ], + "environmentVariables": [ + { + "name": "ACI_SKU", + "value": "Confidential" + }, + { + "name": "MAA_ENDPOINT", + "value": "sharedeus.eus.attest.azure.net" + } + ], + "resources": { + "requests": { + "memoryInGB": 2, + "cpu": 1 + } + } + } + } + ], + "imageRegistryCredentials": [ + { + "server": "[parameters('registryServer')]", + "username": "[parameters('registryUsername')]", + "password": "[parameters('registryPassword')]" + } + ], + "ipAddress": { + "ports": [ + { + "protocol": "TCP", + "port": 80 + } + ], + "type": "Public", + "dnsNameLabel": "[parameters('dnsNameLabel')]" + }, + "osType": "Linux", + "restartPolicy": "Always" + } + } + ], + "outputs": { + "fqdn": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn]" + } + } +} diff --git a/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json b/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json index 12f3b61..7ce9c8c 100644 --- a/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json +++ b/aci-samples/visual-attestation-demo-v2/deployment-template-confidential.json @@ -35,7 +35,7 @@ "properties": { "sku": "Confidential", "confidentialComputeProperties": { - "ccePolicy": "cGFja2FnZSBwb2xpY3kKCmltcG9ydCBmdXR1cmUua2V5d29yZHMuZXZlcnkKaW1wb3J0IGZ1dHVyZS5rZXl3b3Jkcy5pbgoKYXBpX3ZlcnNpb24gOj0gIjAuMTAuMCIKZnJhbWV3b3JrX3ZlcnNpb24gOj0gIjAuMi4zIgoKZnJhZ21lbnRzIDo9IFsKICB7CiAgICAiZmVlZCI6ICJtY3IubWljcm9zb2Z0LmNvbS9hY2kvYWNpLWNjLWluZnJhLWZyYWdtZW50IiwKICAgICJpbmNsdWRlcyI6IFsKICAgICAgImNvbnRhaW5lcnMiLAogICAgICAiZnJhZ21lbnRzIgogICAgXSwKICAgICJpc3N1ZXIiOiAiZGlkOng1MDk6MDpzaGEyNTY6SV9faXVMMjVvWEVWRmRUUF9hQkx4X2VUMVJQSGJDUV9FQ0JRZllacHQ5czo6ZWt1OjEuMy42LjEuNC4xLjMxMS43Ni41OS4xLjMiLAogICAgIm1pbmltdW1fc3ZuIjogIjQiCiAgfQpdCgpjb250YWluZXJzIDo9IFt7ImFsbG93X2VsZXZhdGVkIjpmYWxzZSwiYWxsb3dfc3RkaW9fYWNjZXNzIjpmYWxzZSwiY2FwYWJpbGl0aWVzIjp7ImFtYmllbnQiOltdLCJib3VuZGluZyI6WyJDQVBfQVVESVRfV1JJVEUiLCJDQVBfQ0hPV04iLCJDQVBfREFDX09WRVJSSURFIiwiQ0FQX0ZPV05FUiIsIkNBUF9GU0VUSUQiLCJDQVBfS0lMTCIsIkNBUF9NS05PRCIsIkNBUF9ORVRfQklORF9TRVJWSUNFIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRHSUQiLCJDQVBfU0VUUENBUCIsIkNBUF9TRVRVSUQiLCJDQVBfU1lTX0NIUk9PVCJdLCJlZmZlY3RpdmUiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXSwiaW5oZXJpdGFibGUiOltdLCJwZXJtaXR0ZWQiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXX0sImNvbW1hbmQiOlsicHl0aG9uIiwiL2FwcC9hcHAucHkiXSwiZW52X3J1bGVzIjpbeyJwYXR0ZXJuIjoiQUNJX1NLVT1Db25maWRlbnRpYWwiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiTUFBX0VORFBPSU5UPXNoYXJlZGV1cy5ldXMuYXR0ZXN0LmF6dXJlLm5ldCIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJQQVRIPS91c3IvbG9jYWwvYmluOi91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJMQU5HPUMuVVRGLTgiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiR1BHX0tFWT1BMDM1QzhDMTkyMTlCQTgyMUVDRUE4NkI2NEU2MjhGOEQ2ODQ2OTZEIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTl9WRVJTSU9OPTMuMTEuMTUiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OX1NIQTI1Nj0yNzIxNzlkZGQ5YTJlNDFhMGZjOGU0MmUzM2RmYmRjYTBiMzcxMWFhNWFiZjM3MmQzZjJkNTE1NDNkMDliNjI1IiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTkRPTlRXUklURUJZVEVDT0RFPTEiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OVU5CVUZGRVJFRD0xIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBPUlQ9ODAiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiVEVSTT14dGVybSIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiIoP2kpKEZBQlJJQylfLis9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSE9TVE5BTUU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiVChFKT9NUD0uKyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifSx7InBhdHRlcm4iOiJGYWJyaWNQYWNrYWdlRmlsZU5hbWU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSG9zdGVkU2VydmljZU5hbWU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSURFTlRJVFlfQVBJX1ZFUlNJT049LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSURFTlRJVFlfSEVBREVSPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IklERU5USVRZX1NFUlZFUl9USFVNQlBSSU5UPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6ImF6dXJlY29udGFpbmVyaW5zdGFuY2VfcmVzdGFydGVkX2J5PS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9XSwiZXhlY19wcm9jZXNzZXMiOltdLCJpZCI6ImFjcmxpZm1jZHdqLmF6dXJlY3IuaW8vY2MtYXR0ZXN0OjEuMCIsImxheWVycyI6WyI4NzkxODg2YWY4MDg2MmQyNDk3YzcwNTM4NWE1NGQ5MDIwMzI2MGY5NGVjYTI3ZjhiYjgyMWIzMGQ5YzM0Y2U1IiwiNTIwN2NhNGZkNDk3NGEzMGFkODdmOGUyMGE0Nzg1ODVhNDk2MjY1ZmQ3YjllN2Q3Njg3Y2I5MzZhM2FkYzYyNyIsIjM3OWM1NTQxZjVmMzIwZTMyNDViNjg1NmQxYzZlODk3OWJjMTA0NDc4N2NmZjU3OTVkMjdmYzM2MGFhYTBhZjIiLCJiYmI2NWQ1OTMyNWJjMDRiNWRhYWM5MTMzYmE3OGVkZjE3Nzk2ZWYyNGI2NzAxM2RlZjliNDhlZmM4ZjBjZTA0IiwiNzYwMDY0OGMxYWU3MzFkNzI2OTg5ZTk3YzQ2ZWU0NzYzZDcxNmRmMTkyNDdlNDg4ZGRlMGQ2MDhiMjBlOGIyYyIsIjM4MjBhNGU2YjcxYmFmMjBjMzRjZmY3NmM0NGQ1MDlmYjU1MGMxMDlmZDAwZjA4NjBiMGZjNDkxMjIzMzc2ZjkiLCIzNmJiY2U0MWQzZjIxNGNmZWJjMDJjYzE2ZWY2OWE4ODM2ZmZjYjAyMDAxOGNlNDZkODJmNTRhNGE2NmFmMjg5IiwiZDE3YjQ1ODk0NDg4OTkwMjMxNzk5Y2ExMDZiMmJjN2IxNjg3MzQ1NmZhMzJmOGNlMDAxNDRiOWU1NGFmMjI5ZSIsIjgzMTE0ZTdjOWQ1ODE3NWEwNDM5ZjFkYWJhNGRlNTZlMjNjOTcyZjA2ZGJjNmQ1YWE2NGRjNjc5YjY2OTI1ZjciLCJiZjI2MzNhOTBiMThiOGQyYjg0MDkwYmZjYmQzYTBiZWRmNTkwODdkYTY1Y2JhOGIzMDZjOWQ1YmI2ZmZkZjVmIl0sIm1vdW50cyI6W3siZGVzdGluYXRpb24iOiIvZXRjL3Jlc29sdi5jb25mIiwib3B0aW9ucyI6WyJyYmluZCIsInJzaGFyZWQiLCJydyJdLCJzb3VyY2UiOiJzYW5kYm94Oi8vL3RtcC9hdGxhcy9yZXNvbHZjb25mLy4rIiwidHlwZSI6ImJpbmQifV0sIm5hbWUiOiJjYy1hdHRlc3QiLCJub19uZXdfcHJpdmlsZWdlcyI6ZmFsc2UsInNlY2NvbXBfcHJvZmlsZV9zaGEyNTYiOiIiLCJzaWduYWxzIjpbXSwidXNlciI6eyJncm91cF9pZG5hbWVzIjpbeyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifV0sInVtYXNrIjoiMDAyMiIsInVzZXJfaWRuYW1lIjp7InBhdHRlcm4iOiIiLCJzdHJhdGVneSI6ImFueSJ9fSwid29ya2luZ19kaXIiOiIvYXBwIn0seyJhbGxvd19lbGV2YXRlZCI6ZmFsc2UsImFsbG93X3N0ZGlvX2FjY2VzcyI6ZmFsc2UsImNhcGFiaWxpdGllcyI6eyJhbWJpZW50IjpbXSwiYm91bmRpbmciOlsiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GU0VUSUQiLCJDQVBfRk9XTkVSIiwiQ0FQX01LTk9EIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFVJRCIsIkNBUF9TRVRGQ0FQIiwiQ0FQX1NFVFBDQVAiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9TWVNfQ0hST09UIiwiQ0FQX0tJTEwiLCJDQVBfQVVESVRfV1JJVEUiXSwiZWZmZWN0aXZlIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl0sImluaGVyaXRhYmxlIjpbXSwicGVybWl0dGVkIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl19LCJjb21tYW5kIjpbIi9wYXVzZSJdLCJlbnZfcnVsZXMiOlt7InBhdHRlcm4iOiJQQVRIPS91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsInJlcXVpcmVkIjp0cnVlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlRFUk09eHRlcm0iLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn1dLCJleGVjX3Byb2Nlc3NlcyI6W10sImxheWVycyI6WyIxNmI1MTQwNTdhMDZhZDY2NWY5MmMwMjg2M2FjYTA3NGZkNTk3NmM3NTVkMjZiZmYxNjM2NTI5OTE2OWU4NDE1Il0sIm1vdW50cyI6W10sIm5hbWUiOiJwYXVzZS1jb250YWluZXIiLCJub19uZXdfcHJpdmlsZWdlcyI6ZmFsc2UsInNlY2NvbXBfcHJvZmlsZV9zaGEyNTYiOiIiLCJzaWduYWxzIjpbXSwidXNlciI6eyJncm91cF9pZG5hbWVzIjpbeyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifV0sInVtYXNrIjoiMDAyMiIsInVzZXJfaWRuYW1lIjp7InBhdHRlcm4iOiIiLCJzdHJhdGVneSI6ImFueSJ9fSwid29ya2luZ19kaXIiOiIvIn1dCgphbGxvd19wcm9wZXJ0aWVzX2FjY2VzcyA6PSB0cnVlCmFsbG93X2R1bXBfc3RhY2tzIDo9IGZhbHNlCmFsbG93X3J1bnRpbWVfbG9nZ2luZyA6PSBmYWxzZQphbGxvd19lbnZpcm9ubWVudF92YXJpYWJsZV9kcm9wcGluZyA6PSB0cnVlCmFsbG93X3VuZW5jcnlwdGVkX3NjcmF0Y2ggOj0gZmFsc2UKYWxsb3dfY2FwYWJpbGl0eV9kcm9wcGluZyA6PSB0cnVlCgptb3VudF9kZXZpY2UgOj0gZGF0YS5mcmFtZXdvcmsubW91bnRfZGV2aWNlCnVubW91bnRfZGV2aWNlIDo9IGRhdGEuZnJhbWV3b3JrLnVubW91bnRfZGV2aWNlCm1vdW50X292ZXJsYXkgOj0gZGF0YS5mcmFtZXdvcmsubW91bnRfb3ZlcmxheQp1bm1vdW50X292ZXJsYXkgOj0gZGF0YS5mcmFtZXdvcmsudW5tb3VudF9vdmVybGF5CmNyZWF0ZV9jb250YWluZXIgOj0gZGF0YS5mcmFtZXdvcmsuY3JlYXRlX2NvbnRhaW5lcgpleGVjX2luX2NvbnRhaW5lciA6PSBkYXRhLmZyYW1ld29yay5leGVjX2luX2NvbnRhaW5lcgpleGVjX2V4dGVybmFsIDo9IGRhdGEuZnJhbWV3b3JrLmV4ZWNfZXh0ZXJuYWwKc2h1dGRvd25fY29udGFpbmVyIDo9IGRhdGEuZnJhbWV3b3JrLnNodXRkb3duX2NvbnRhaW5lcgpzaWduYWxfY29udGFpbmVyX3Byb2Nlc3MgOj0gZGF0YS5mcmFtZXdvcmsuc2lnbmFsX2NvbnRhaW5lcl9wcm9jZXNzCnBsYW45X21vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnBsYW45X21vdW50CnBsYW45X3VubW91bnQgOj0gZGF0YS5mcmFtZXdvcmsucGxhbjlfdW5tb3VudApnZXRfcHJvcGVydGllcyA6PSBkYXRhLmZyYW1ld29yay5nZXRfcHJvcGVydGllcwpkdW1wX3N0YWNrcyA6PSBkYXRhLmZyYW1ld29yay5kdW1wX3N0YWNrcwpydW50aW1lX2xvZ2dpbmcgOj0gZGF0YS5mcmFtZXdvcmsucnVudGltZV9sb2dnaW5nCmxvYWRfZnJhZ21lbnQgOj0gZGF0YS5mcmFtZXdvcmsubG9hZF9mcmFnbWVudApzY3JhdGNoX21vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnNjcmF0Y2hfbW91bnQKc2NyYXRjaF91bm1vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnNjcmF0Y2hfdW5tb3VudAoKcmVhc29uIDo9IHsiZXJyb3JzIjogZGF0YS5mcmFtZXdvcmsuZXJyb3JzfQ==" + "ccePolicy": "cGFja2FnZSBwb2xpY3kKCmltcG9ydCBmdXR1cmUua2V5d29yZHMuZXZlcnkKaW1wb3J0IGZ1dHVyZS5rZXl3b3Jkcy5pbgoKYXBpX3ZlcnNpb24gOj0gIjAuMTEuMCIKZnJhbWV3b3JrX3ZlcnNpb24gOj0gIjAuMi4zIgoKZnJhZ21lbnRzIDo9IFsKICB7CiAgICAiZmVlZCI6ICJtY3IubWljcm9zb2Z0LmNvbS9hY2kvYWNpLWNjLWluZnJhLWZyYWdtZW50IiwKICAgICJpbmNsdWRlcyI6IFsKICAgICAgImNvbnRhaW5lcnMiLAogICAgICAiZnJhZ21lbnRzIgogICAgXSwKICAgICJpc3N1ZXIiOiAiZGlkOng1MDk6MDpzaGEyNTY6SV9faXVMMjVvWEVWRmRUUF9hQkx4X2VUMVJQSGJDUV9FQ0JRZllacHQ5czo6ZWt1OjEuMy42LjEuNC4xLjMxMS43Ni41OS4xLjMiLAogICAgIm1pbmltdW1fc3ZuIjogIjQiCiAgfQpdCgpjb250YWluZXJzIDo9IFt7ImFsbG93X2VsZXZhdGVkIjpmYWxzZSwiYWxsb3dfc3RkaW9fYWNjZXNzIjpmYWxzZSwiY2FwYWJpbGl0aWVzIjp7ImFtYmllbnQiOltdLCJib3VuZGluZyI6WyJDQVBfQVVESVRfV1JJVEUiLCJDQVBfQ0hPV04iLCJDQVBfREFDX09WRVJSSURFIiwiQ0FQX0ZPV05FUiIsIkNBUF9GU0VUSUQiLCJDQVBfS0lMTCIsIkNBUF9NS05PRCIsIkNBUF9ORVRfQklORF9TRVJWSUNFIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRHSUQiLCJDQVBfU0VUUENBUCIsIkNBUF9TRVRVSUQiLCJDQVBfU1lTX0NIUk9PVCJdLCJlZmZlY3RpdmUiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXSwiaW5oZXJpdGFibGUiOltdLCJwZXJtaXR0ZWQiOlsiQ0FQX0FVRElUX1dSSVRFIiwiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GT1dORVIiLCJDQVBfRlNFVElEIiwiQ0FQX0tJTEwiLCJDQVBfTUtOT0QiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEZDQVAiLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFBDQVAiLCJDQVBfU0VUVUlEIiwiQ0FQX1NZU19DSFJPT1QiXX0sImNvbW1hbmQiOlsicHl0aG9uIiwiL2FwcC9hcHAucHkiXSwiZW52X3J1bGVzIjpbeyJwYXR0ZXJuIjoiQUNJX1NLVT1Db25maWRlbnRpYWwiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiTUFBX0VORFBPSU5UPXNoYXJlZGV1cy5ldXMuYXR0ZXN0LmF6dXJlLm5ldCIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJQQVRIPS91c3IvbG9jYWwvYmluOi91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiJMQU5HPUMuVVRGLTgiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiR1BHX0tFWT1BMDM1QzhDMTkyMTlCQTgyMUVDRUE4NkI2NEU2MjhGOEQ2ODQ2OTZEIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTl9WRVJTSU9OPTMuMTEuMTUiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OX1NIQTI1Nj0yNzIxNzlkZGQ5YTJlNDFhMGZjOGU0MmUzM2RmYmRjYTBiMzcxMWFhNWFiZjM3MmQzZjJkNTE1NDNkMDliNjI1IiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBZVEhPTkRPTlRXUklURUJZVEVDT0RFPTEiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiUFlUSE9OVU5CVUZGRVJFRD0xIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlBPUlQ9ODAiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn0seyJwYXR0ZXJuIjoiVEVSTT14dGVybSIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJzdHJpbmcifSx7InBhdHRlcm4iOiIoP2kpKEZBQlJJQylfLis9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSE9TVE5BTUU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiVChFKT9NUD0uKyIsInJlcXVpcmVkIjpmYWxzZSwic3RyYXRlZ3kiOiJyZTIifSx7InBhdHRlcm4iOiJGYWJyaWNQYWNrYWdlRmlsZU5hbWU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSG9zdGVkU2VydmljZU5hbWU9LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSURFTlRJVFlfQVBJX1ZFUlNJT049LisiLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5IjoicmUyIn0seyJwYXR0ZXJuIjoiSURFTlRJVFlfSEVBREVSPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6IklERU5USVRZX1NFUlZFUl9USFVNQlBSSU5UPS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9LHsicGF0dGVybiI6ImF6dXJlY29udGFpbmVyaW5zdGFuY2VfcmVzdGFydGVkX2J5PS4rIiwicmVxdWlyZWQiOmZhbHNlLCJzdHJhdGVneSI6InJlMiJ9XSwiZXhlY19wcm9jZXNzZXMiOltdLCJpZCI6ImFjcnFseXVzb2VnLmF6dXJlY3IuaW8vY2MtYXR0ZXN0OjEuMCIsImxheWVycyI6WyIxZjdjY2YzOTIxMTczOTMyZTU2ZjZmMzA4ZGE3Mjc5YWU3M2U0NDhhMmNiNDNkNGRhMDBkOTQ3ODRiNzAxZWI5IiwiMGRmN2Q2ZGI0NTBhZGRlOTIwNjk3ZjRiYWM2MjhiYWRmYjVmYWY3YTliM2FlMzYwMTU2ZTFhYzQxYmFiZjc1OSIsIjA4ZGExODBmZjJiNDk4YWYwMGI4ZmIxYzc3ZTIyZGFlZWI2MGExNDA4Y2MwMDlkMzIxOTE4Y2U4MWYwZjRkNGQiLCI4NjIyNjBlYmEzMmE5MGU3NDI5Mzk2ODExYmNkMWI2ZDAwNmIwOWZkYTEyZWM1YTJiMTY5YjcxNzdhNzAxMGVkIiwiMTRiMzgwZmQ1MDJlZWM0NDEwYjVlNDhkZWVjNzhkNzlmNTg4YmMyZTYzMDA2YjAwYWNkZjEyZjNlN2QxOGZmMCIsIjFjNmNhYTAzMzA4NDJhODFkN2NjODI2YmI2ZmUzNzBhYmFkZmFiYmIzNTNkYzBjMDcyOGJiZGU1NWE4NDRjOTEiLCJiNTMwOGIwZjYyZjVjMGI0MDNkOGE0ZDA5YThmZDllNTg0M2I4ZGZhNzU3MDZjY2I1MGIxNjhhZGNhZjRlNTQ2IiwiNDgwNWU0MjJlYTU3MWY4NGJkNTBlZWE4MTkwMTY0N2NkYzVjY2MxMWRkYWUxZWViNWNlZTMzYThjNzJmYzcyMyIsIjg1MTMwNmMxZWZkZWM2OGZkMjkzZWQ2MmE5YTk0YmQzODI5MzVmNjQwZjIyM2FmZDAzMWQwMGZiYWE1ZjFmZjkiLCI4NmVjOWFlOWVmMzUxZjMxYjU2MWRjYjE5ODdkODJmODE3YWM5MTIwMTU2OTA5NDc5ZDIzYTQwMGNjMzQ2YzgyIl0sIm1vdW50cyI6W3siZGVzdGluYXRpb24iOiIvZXRjL3Jlc29sdi5jb25mIiwib3B0aW9ucyI6WyJyYmluZCIsInJzaGFyZWQiLCJydyJdLCJzb3VyY2UiOiJzYW5kYm94Oi8vL3RtcC9hdGxhcy9yZXNvbHZjb25mLy4rIiwidHlwZSI6ImJpbmQifV0sIm5hbWUiOiJjYy1hdHRlc3QiLCJub19uZXdfcHJpdmlsZWdlcyI6ZmFsc2UsInNlY2NvbXBfcHJvZmlsZV9zaGEyNTYiOiIiLCJzaWduYWxzIjpbXSwidXNlciI6eyJncm91cF9pZG5hbWVzIjpbeyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifV0sInVtYXNrIjoiMDAyMiIsInVzZXJfaWRuYW1lIjp7InBhdHRlcm4iOiIiLCJzdHJhdGVneSI6ImFueSJ9fSwid29ya2luZ19kaXIiOiIvYXBwIn0seyJhbGxvd19lbGV2YXRlZCI6ZmFsc2UsImFsbG93X3N0ZGlvX2FjY2VzcyI6ZmFsc2UsImNhcGFiaWxpdGllcyI6eyJhbWJpZW50IjpbXSwiYm91bmRpbmciOlsiQ0FQX0NIT1dOIiwiQ0FQX0RBQ19PVkVSUklERSIsIkNBUF9GU0VUSUQiLCJDQVBfRk9XTkVSIiwiQ0FQX01LTk9EIiwiQ0FQX05FVF9SQVciLCJDQVBfU0VUR0lEIiwiQ0FQX1NFVFVJRCIsIkNBUF9TRVRGQ0FQIiwiQ0FQX1NFVFBDQVAiLCJDQVBfTkVUX0JJTkRfU0VSVklDRSIsIkNBUF9TWVNfQ0hST09UIiwiQ0FQX0tJTEwiLCJDQVBfQVVESVRfV1JJVEUiXSwiZWZmZWN0aXZlIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl0sImluaGVyaXRhYmxlIjpbXSwicGVybWl0dGVkIjpbIkNBUF9DSE9XTiIsIkNBUF9EQUNfT1ZFUlJJREUiLCJDQVBfRlNFVElEIiwiQ0FQX0ZPV05FUiIsIkNBUF9NS05PRCIsIkNBUF9ORVRfUkFXIiwiQ0FQX1NFVEdJRCIsIkNBUF9TRVRVSUQiLCJDQVBfU0VURkNBUCIsIkNBUF9TRVRQQ0FQIiwiQ0FQX05FVF9CSU5EX1NFUlZJQ0UiLCJDQVBfU1lTX0NIUk9PVCIsIkNBUF9LSUxMIiwiQ0FQX0FVRElUX1dSSVRFIl19LCJjb21tYW5kIjpbIi9wYXVzZSJdLCJlbnZfcnVsZXMiOlt7InBhdHRlcm4iOiJQQVRIPS91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsInJlcXVpcmVkIjp0cnVlLCJzdHJhdGVneSI6InN0cmluZyJ9LHsicGF0dGVybiI6IlRFUk09eHRlcm0iLCJyZXF1aXJlZCI6ZmFsc2UsInN0cmF0ZWd5Ijoic3RyaW5nIn1dLCJleGVjX3Byb2Nlc3NlcyI6W10sImxheWVycyI6WyIxNmI1MTQwNTdhMDZhZDY2NWY5MmMwMjg2M2FjYTA3NGZkNTk3NmM3NTVkMjZiZmYxNjM2NTI5OTE2OWU4NDE1Il0sIm1vdW50cyI6W10sIm5hbWUiOiJwYXVzZS1jb250YWluZXIiLCJub19uZXdfcHJpdmlsZWdlcyI6ZmFsc2UsInNlY2NvbXBfcHJvZmlsZV9zaGEyNTYiOiIiLCJzaWduYWxzIjpbXSwidXNlciI6eyJncm91cF9pZG5hbWVzIjpbeyJwYXR0ZXJuIjoiIiwic3RyYXRlZ3kiOiJhbnkifV0sInVtYXNrIjoiMDAyMiIsInVzZXJfaWRuYW1lIjp7InBhdHRlcm4iOiIiLCJzdHJhdGVneSI6ImFueSJ9fSwid29ya2luZ19kaXIiOiIvIn1dCgphbGxvd19wcm9wZXJ0aWVzX2FjY2VzcyA6PSB0cnVlCmFsbG93X2R1bXBfc3RhY2tzIDo9IGZhbHNlCmFsbG93X3J1bnRpbWVfbG9nZ2luZyA6PSBmYWxzZQphbGxvd19lbnZpcm9ubWVudF92YXJpYWJsZV9kcm9wcGluZyA6PSB0cnVlCmFsbG93X3VuZW5jcnlwdGVkX3NjcmF0Y2ggOj0gZmFsc2UKYWxsb3dfY2FwYWJpbGl0eV9kcm9wcGluZyA6PSB0cnVlCgptb3VudF9kZXZpY2UgOj0gZGF0YS5mcmFtZXdvcmsubW91bnRfZGV2aWNlCnVubW91bnRfZGV2aWNlIDo9IGRhdGEuZnJhbWV3b3JrLnVubW91bnRfZGV2aWNlCm1vdW50X292ZXJsYXkgOj0gZGF0YS5mcmFtZXdvcmsubW91bnRfb3ZlcmxheQp1bm1vdW50X292ZXJsYXkgOj0gZGF0YS5mcmFtZXdvcmsudW5tb3VudF9vdmVybGF5CmNyZWF0ZV9jb250YWluZXIgOj0gZGF0YS5mcmFtZXdvcmsuY3JlYXRlX2NvbnRhaW5lcgpleGVjX2luX2NvbnRhaW5lciA6PSBkYXRhLmZyYW1ld29yay5leGVjX2luX2NvbnRhaW5lcgpleGVjX2V4dGVybmFsIDo9IGRhdGEuZnJhbWV3b3JrLmV4ZWNfZXh0ZXJuYWwKc2h1dGRvd25fY29udGFpbmVyIDo9IGRhdGEuZnJhbWV3b3JrLnNodXRkb3duX2NvbnRhaW5lcgpzaWduYWxfY29udGFpbmVyX3Byb2Nlc3MgOj0gZGF0YS5mcmFtZXdvcmsuc2lnbmFsX2NvbnRhaW5lcl9wcm9jZXNzCnBsYW45X21vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnBsYW45X21vdW50CnBsYW45X3VubW91bnQgOj0gZGF0YS5mcmFtZXdvcmsucGxhbjlfdW5tb3VudApnZXRfcHJvcGVydGllcyA6PSBkYXRhLmZyYW1ld29yay5nZXRfcHJvcGVydGllcwpkdW1wX3N0YWNrcyA6PSBkYXRhLmZyYW1ld29yay5kdW1wX3N0YWNrcwpydW50aW1lX2xvZ2dpbmcgOj0gZGF0YS5mcmFtZXdvcmsucnVudGltZV9sb2dnaW5nCmxvYWRfZnJhZ21lbnQgOj0gZGF0YS5mcmFtZXdvcmsubG9hZF9mcmFnbWVudApzY3JhdGNoX21vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnNjcmF0Y2hfbW91bnQKc2NyYXRjaF91bm1vdW50IDo9IGRhdGEuZnJhbWV3b3JrLnNjcmF0Y2hfdW5tb3VudApyd19tb3VudF9kZXZpY2UgOj0gZGF0YS5mcmFtZXdvcmsucndfbW91bnRfZGV2aWNlCgpyZWFzb24gOj0geyJlcnJvcnMiOiBkYXRhLmZyYW1ld29yay5lcnJvcnN9" }, "containers": [ { diff --git a/aks-samples/virtual nodes/Deploy-VirtualNodesAKS.ps1 b/aks-samples/virtual nodes/Deploy-VirtualNodesAKS.ps1 new file mode 100644 index 0000000..9ea5488 --- /dev/null +++ b/aks-samples/virtual nodes/Deploy-VirtualNodesAKS.ps1 @@ -0,0 +1,961 @@ +<# +.SYNOPSIS +Deploy a confidential-node AKS cluster with the virtual nodes add-on and a hello-world workload. + +.DESCRIPTION +Creates a new resource group named <5-random-letters>, provisions an AKS cluster with: +- Azure CNI networking and dedicated subnets for AKS and ACI virtual nodes +- A standard system pool and an AMD SEV-SNP confidential user pool +- The AKS virtual nodes add-on backed by Azure Container Instances +- A public hello-world workload scheduled onto the virtual node + +The script checks local dependencies, validates Azure RBAC before provisioning, +attempts to detect PIM eligibility for required roles, registers required resource providers, +and prints a deployment summary including the hello-world endpoint. + +.PARAMETER Prefix +Prefix used for resource naming. The resource group will be named <5 letters>. + +.PARAMETER Region +Optional Azure region. If omitted, the script tries supported candidate regions and prefers eastus2. + +.PARAMETER SubscriptionId +Optional subscription ID. If omitted, uses the current Azure CLI subscription. + +.PARAMETER ConfidentialVmSize +VM size for the confidential node pool. Default: Standard_DC2as_v5. + +.PARAMETER SystemVmSize +VM size for the system node pool. Default: Standard_D2as_v7. + +.PARAMETER SystemNodeCount +Node count for the system pool. Default: 1. + +.PARAMETER ConfidentialNodeCount +Node count for the confidential pool. Default: 1. + +.PARAMETER SkipExternalSmokeTest +Skip the external HTTP check against the public load balancer. + +.PARAMETER VisualAttestationUrl +Optional explicit URL (or FQDN) for the visual attestation container to display in the final summary. + +.PARAMETER VisualAttestationResourceGroup +Resource group used to auto-discover a visual attestation container FQDN when VisualAttestationUrl is not provided. + +.PARAMETER VisualAttestationContainerPrefix +Container name prefix used to auto-discover a visual attestation container FQDN. + +.PARAMETER AutoActivatePim +Automatically submit a native Azure PowerShell PIM self-activation request for the first eligible role. +#> + +param( + [Parameter(Mandatory = $true)] + [string]$Prefix, + + [string]$Region = "", + [string]$SubscriptionId = "", + [string]$ConfidentialVmSize = "Standard_DC2as_v5", + [string]$SystemVmSize = "Standard_D2as_v7", + [int]$SystemNodeCount = 1, + [int]$ConfidentialNodeCount = 1, + [switch]$SkipExternalSmokeTest, + [string]$VisualAttestationUrl = "", + [string]$VisualAttestationResourceGroup = "sgall-acrqlyusoeg-rg", + [string]$VisualAttestationContainerPrefix = "cc-attest-conf-", + [switch]$AutoActivatePim +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Write-Header { + param([string]$Message) + Write-Host "" + Write-Host "=== $Message ===" -ForegroundColor Cyan +} + +function Write-Detail { + param([string]$Message) + Write-Host $Message -ForegroundColor DarkGray +} + +function New-RandomLetters { + param([int]$Count = 5) + -join ((97..122) | Get-Random -Count $Count | ForEach-Object { [char]$_ }) +} + +function New-SafeName { + param( + [string]$Raw, + [int]$MaxLength, + [string]$Fallback = "sample" + ) + + $value = ($Raw.ToLowerInvariant() -replace "[^a-z0-9-]", "") + $value = $value.Trim('-') + if ([string]::IsNullOrWhiteSpace($value)) { + $value = $Fallback + } + if ($value.Length -gt $MaxLength) { + $value = $value.Substring(0, $MaxLength).Trim('-') + } + if ([string]::IsNullOrWhiteSpace($value)) { + $value = $Fallback + } + return $value +} + +function Invoke-Az { + param([string]$CommandText) + Write-Detail "-> $CommandText" + $result = Invoke-Expression $CommandText + if ($LASTEXITCODE -ne 0) { + throw "Command failed: $CommandText" + } + return $result +} + +function Read-Confirmation { + param( + [string]$Prompt, + [bool]$DefaultYes = $true + ) + + $suffix = if ($DefaultYes) { "[Y/n]" } else { "[y/N]" } + $response = Read-Host "$Prompt $suffix" + if ([string]::IsNullOrWhiteSpace($response)) { + return $DefaultYes + } + + switch -Regex ($response.Trim()) { + '^(y|yes)$' { return $true } + '^(n|no)$' { return $false } + default { + Write-Host "Please answer yes or no." -ForegroundColor Yellow + return Read-Confirmation -Prompt $Prompt -DefaultYes:$DefaultYes + } + } +} + +function Ensure-Command { + param( + [string]$CommandName, + [string]$InstallHint, + [scriptblock]$Installer = $null + ) + + if (Get-Command $CommandName -ErrorAction SilentlyContinue) { + return + } + + Write-Host "$CommandName was not found on PATH." -ForegroundColor Yellow + if ($Installer -and (Read-Confirmation -Prompt "Install $CommandName now?" -DefaultYes $true)) { + & $Installer + if (Get-Command $CommandName -ErrorAction SilentlyContinue) { + return + } + } + + throw "$CommandName is required before this script can continue. $InstallHint" +} + +function Ensure-AzureCli { + Ensure-Command -CommandName "az" -InstallHint "Install Azure CLI from https://learn.microsoft.com/cli/azure/install-azure-cli-windows and rerun the script." -Installer { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + throw "winget is not available for unattended Azure CLI installation. Install Azure CLI from https://learn.microsoft.com/cli/azure/install-azure-cli-windows." + } + winget install -e --id Microsoft.AzureCLI --accept-package-agreements --accept-source-agreements + } +} + +function Ensure-AzPowerShell { + $requiredCommands = @( + 'Connect-AzAccount', + 'Get-AzContext', + 'Set-AzContext', + 'Invoke-AzRestMethod', + 'Get-AzRoleAssignment', + 'Get-AzRoleDefinition', + 'Get-AzRoleEligibilityScheduleInstance', + 'New-AzRoleAssignmentScheduleRequest' + ) + + foreach ($commandName in $requiredCommands) { + if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) { + throw "Azure PowerShell cmdlet '$commandName' is required. Install or update the Az modules before running this script." + } + } +} + +function Ensure-Kubectl { + if (Get-Command kubectl -ErrorAction SilentlyContinue) { + return + } + + Write-Host "kubectl was not found on PATH." -ForegroundColor Yellow + if (-not (Read-Confirmation -Prompt "Install kubectl using 'az aks install-cli'?" -DefaultYes $true)) { + throw "kubectl is required before this script can continue." + } + + Invoke-Az "az aks install-cli --only-show-errors" + if (-not (Get-Command kubectl -ErrorAction SilentlyContinue)) { + throw "kubectl installation did not complete successfully." + } +} + +function Ensure-AksPreviewExtension { + try { + $null = Invoke-Az "az extension show --name aks-preview --output json" | ConvertFrom-Json + return + } catch { + Write-Host "Azure CLI extension 'aks-preview' is not installed." -ForegroundColor Yellow + } + + if (-not (Read-Confirmation -Prompt "Install Azure CLI extension 'aks-preview' now?" -DefaultYes $true)) { + throw "The aks-preview extension is required for consistent virtual nodes behavior in this sample." + } + + Invoke-Az "az extension add --name aks-preview --upgrade --only-show-errors" +} + +function Ensure-AzureLogin { + try { + $null = Invoke-Az "az account show --output json" | ConvertFrom-Json + } catch { + Write-Host "Azure CLI is not logged in." -ForegroundColor Yellow + Invoke-Az "az login --output none" + } +} + +function Ensure-AzLogin { + try { + $context = Get-AzContext -ErrorAction Stop + if ($context) { + return + } + } catch { + } + + Write-Host "Azure PowerShell is not logged in." -ForegroundColor Yellow + Connect-AzAccount | Out-Null +} + +function Get-CurrentAccount { + Invoke-Az "az account show --output json" | ConvertFrom-Json +} + +function Get-OwnerUpn { + $upn = "" + try { + $upn = (Invoke-Az "az ad signed-in-user show --query userPrincipalName --output tsv").Trim() + } catch { + } + if (-not $upn) { + $upn = (Invoke-Az "az account show --query user.name --output tsv").Trim() + } + return $upn +} + +function Get-SignedInObjectId { + try { + return (Invoke-Az "az ad signed-in-user show --query id --output tsv").Trim() + } catch { + return "" + } +} + +function Test-RoleAssignmentPermission { + param( + [string]$SubscriptionId, + [string]$Upn + ) + + $scope = "/subscriptions/$SubscriptionId" + $allowedRoles = @( + "Owner", + "User Access Administrator", + "Role Based Access Control Administrator" + ) + + $assignments = @(Get-AzRoleAssignment -SignInName $Upn -ExpandPrincipalGroups -ErrorAction Stop | + Where-Object { + ($allowedRoles -contains $_.RoleDefinitionName) -and + $_.Scope -and + $scope.StartsWith([string]$_.Scope, [System.StringComparison]::OrdinalIgnoreCase) + }) + $permissionsResponse = Invoke-AzRestMethod -Path "$scope/providers/Microsoft.Authorization/permissions?api-version=2015-07-01" -Method GET + $permissions = ($permissionsResponse.Content | ConvertFrom-Json).value + + $grantsRoleAssignmentWrite = $false + + foreach ($entry in @($permissions)) { + $actions = @($entry.actions) + $notActions = @($entry.notActions) + $allowsWrite = ($actions -contains "*") -or ($actions -contains "Microsoft.Authorization/*") -or ($actions -contains "Microsoft.Authorization/roleAssignments/*") -or ($actions -contains "Microsoft.Authorization/roleAssignments/write") + $deniesWrite = ($notActions -contains "Microsoft.Authorization/*") -or ($notActions -contains "Microsoft.Authorization/roleAssignments/*") -or ($notActions -contains "Microsoft.Authorization/roleAssignments/write") + + if ($allowsWrite -and -not $deniesWrite) { + $grantsRoleAssignmentWrite = $true + break + } + } + + [pscustomobject]@{ + Scope = $scope + AllowedRoles = $allowedRoles + MatchingAssignments = $assignments + CanAssignRoles = $assignments.Count -gt 0 -and $grantsRoleAssignmentWrite + HasMatchingRole = $assignments.Count -gt 0 + HasEffectiveWritePermission = $grantsRoleAssignmentWrite + } +} + +function Get-PimEligibleRoleSchedules { + param( + [string]$SubscriptionId, + [string]$PrincipalObjectId, + [string[]]$RoleNames + ) + + if ([string]::IsNullOrWhiteSpace($PrincipalObjectId)) { + return @() + } + + $scope = "/subscriptions/$SubscriptionId" + $allowedDefinitions = @{} + foreach ($roleName in $RoleNames) { + $definition = Get-AzRoleDefinition -Name $roleName -ErrorAction SilentlyContinue + if ($definition) { + $allowedDefinitions[[string]$definition.Id] = $roleName + } + } + + if ($allowedDefinitions.Count -eq 0) { + return @() + } + + $schedules = @(Get-AzRoleEligibilityScheduleInstance -Scope $scope -Filter "asTarget()" -ErrorAction Stop) + $matches = foreach ($schedule in $schedules) { + if ($schedule.PrincipalId -ne $PrincipalObjectId) { + continue + } + if ($schedule.Scope -and -not $schedule.Scope.StartsWith($scope, [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $matchedRole = $null + foreach ($allowedId in $allowedDefinitions.Keys) { + if ([string]$schedule.RoleDefinitionId -match [regex]::Escape($allowedId)) { + $matchedRole = $allowedDefinitions[$allowedId] + break + } + } + + if ($matchedRole) { + [pscustomobject]@{ + RoleName = $matchedRole + RoleDefinitionId = [string]$schedule.RoleDefinitionId + Scope = [string]$schedule.Scope + } + } + } + + return @($matches | Sort-Object RoleName -Unique) +} + +function Invoke-PimSelfActivation { + param( + [string]$Scope, + [string]$PrincipalObjectId, + [string]$RoleDefinitionId, + [string]$RoleName + ) + + $requestName = [guid]::NewGuid().Guid + $startTime = (Get-Date).ToUniversalTime().ToString("o") + $justification = "Activate $RoleName for AKS virtual nodes sample deployment" + + New-AzRoleAssignmentScheduleRequest ` + -Name $requestName ` + -Scope $Scope ` + -PrincipalId $PrincipalObjectId ` + -RequestType SelfActivate ` + -RoleDefinitionId $RoleDefinitionId ` + -ScheduleInfoStartDateTime $startTime ` + -ExpirationType AfterDuration ` + -ExpirationDuration PT8H ` + -Justification $justification | Out-Null +} + +function Assert-DeploymentRoles { + param( + [string]$SubscriptionId, + [string]$Upn, + [string]$PrincipalObjectId + ) + + Write-Header "Checking required Azure roles" + + $permission = Test-RoleAssignmentPermission -SubscriptionId $SubscriptionId -Upn $Upn + if ($permission.CanAssignRoles) { + $roles = ($permission.MatchingAssignments | Select-Object -ExpandProperty roleDefinitionName -Unique) -join ", " + Write-Host "Verified role-assignment capability at $($permission.Scope)" -ForegroundColor Green + Write-Host "Active role(s): $roles" + return + } + + $eligibleSchedules = @(Get-PimEligibleRoleSchedules -SubscriptionId $SubscriptionId -PrincipalObjectId $PrincipalObjectId -RoleNames $permission.AllowedRoles) + if ($eligibleSchedules.Count -gt 0) { + $eligibleRoleNames = $eligibleSchedules.RoleName | Sort-Object -Unique + Write-Host "No active role currently grants roleAssignments/write at $($permission.Scope)." -ForegroundColor Yellow + Write-Host "PIM appears to offer these eligible roles: $($eligibleRoleNames -join ', ')" -ForegroundColor Yellow + + $activationTarget = $eligibleSchedules | Where-Object { $_.RoleName -eq 'Owner' } | Select-Object -First 1 + if (-not $activationTarget) { + $activationTarget = $eligibleSchedules | Select-Object -First 1 + } + + $shouldActivate = $false + if ($activationTarget) { + $shouldActivate = $AutoActivatePim -or (Read-Confirmation -Prompt "Activate PIM role '$($activationTarget.RoleName)' now using Azure PowerShell?" -DefaultYes $true) + } + + if ($activationTarget -and $shouldActivate) { + Invoke-PimSelfActivation -Scope $permission.Scope -PrincipalObjectId $PrincipalObjectId -RoleDefinitionId $activationTarget.RoleDefinitionId -RoleName $activationTarget.RoleName + + $deadline = (Get-Date).AddMinutes(5) + do { + Start-Sleep -Seconds 10 + $permission = Test-RoleAssignmentPermission -SubscriptionId $SubscriptionId -Upn $Upn + if ($permission.CanAssignRoles) { + $roles = ($permission.MatchingAssignments | Select-Object -ExpandProperty RoleDefinitionName -Unique) -join ", " + Write-Host "Verified role-assignment capability after PIM activation." -ForegroundColor Green + Write-Host "Active role(s): $roles" + return + } + } while ((Get-Date) -lt $deadline) + + Write-Host "PIM activation request submitted, but the role is not active yet." -ForegroundColor Yellow + } + + Write-Host "Activate one of those roles for this subscription, wait for it to become active, then return here." -ForegroundColor Yellow + [void](Read-Host "Press Enter after PIM activation to retry the permission check") + + $permission = Test-RoleAssignmentPermission -SubscriptionId $SubscriptionId -Upn $Upn + if ($permission.CanAssignRoles) { + $roles = ($permission.MatchingAssignments | Select-Object -ExpandProperty RoleDefinitionName -Unique) -join ", " + Write-Host "Verified role-assignment capability after PIM activation." -ForegroundColor Green + Write-Host "Active role(s): $roles" + return + } + } + + $expected = $permission.AllowedRoles -join ", " + $detail = if ($permission.HasMatchingRole -and -not $permission.HasEffectiveWritePermission) { + "A matching role assignment exists, but the current token does not have effective Microsoft.Authorization/roleAssignments/write yet. Refresh Azure CLI authentication after PIM activation and rerun the script." + } else { + "No active assignment was found that can grant roleAssignments/write at the required scope." + } + + $message = @( + "Missing required Azure RBAC permissions before deployment starts.", + "This script needs one of these active roles at subscription scope: $expected.", + "Checked scope: $($permission.Scope)", + $detail, + "If your organization uses Microsoft Entra PIM, activate one of those roles and rerun the script.", + "No resources were created because the check ran before provisioning." + ) -join " `n" + + throw $message +} + +function Ensure-ProviderRegistered { + param([string]$Namespace) + + $state = "" + try { + $state = (Invoke-Az "az provider show --namespace $Namespace --query registrationState --output tsv").Trim() + } catch { + } + + if ($state -eq "Registered") { + return + } + + Write-Host "Registering provider '$Namespace'" -ForegroundColor Yellow + Invoke-Az "az provider register --namespace $Namespace --output none" + + $deadline = (Get-Date).AddMinutes(10) + do { + Start-Sleep -Seconds 10 + $state = (Invoke-Az "az provider show --namespace $Namespace --query registrationState --output tsv").Trim() + if ($state -eq "Registered") { + return + } + } while ((Get-Date) -lt $deadline) + + throw "Provider '$Namespace' did not reach Registered state in time." +} + +function Test-VmSizeAvailability { + param( + [string]$Location, + [string]$VmSize + ) + + try { + $sku = Get-AzComputeResourceSku -Location $Location -ErrorAction Stop | + Where-Object { $_.ResourceType -eq 'virtualMachines' -and $_.Name -eq $VmSize } | + Select-Object -First 1 + } catch { + return $null + } + + if ($null -eq $sku) { + return $null + } + + $restrictions = @($sku.restrictions) + $blocked = $false + foreach ($restriction in $restrictions) { + if ($restriction.reasonCode -eq "NotAvailableForSubscription") { + $blocked = $true + break + } + if ($restriction.restrictionInfo -and $restriction.restrictionInfo.locations -and ($restriction.restrictionInfo.locations -contains $Location)) { + $blocked = $true + break + } + } + + if ($blocked) { + return $null + } + + return $sku +} + +function Test-ConfidentialSkuAvailability { + param( + [string]$Location, + [string]$VmSize + ) + + return Test-VmSizeAvailability -Location $Location -VmSize $VmSize +} + +function Resolve-DeploymentLocation { + param( + [string]$RequestedLocation, + [string]$VmSize + ) + + if (-not [string]::IsNullOrWhiteSpace($RequestedLocation)) { + $requested = $RequestedLocation.Trim().ToLowerInvariant() + if ($null -eq (Test-ConfidentialSkuAvailability -Location $requested -VmSize $VmSize)) { + throw "Requested region '$requested' does not currently report $VmSize as available for this subscription. Choose another region or omit -Region to auto-select." + } + return $requested + } + + $candidates = @("eastus2", "centralus", "westus3", "southcentralus", "northeurope") + foreach ($candidate in $candidates) { + if ($null -ne (Test-ConfidentialSkuAvailability -Location $candidate -VmSize $VmSize)) { + return $candidate + } + } + + throw "Could not find a candidate region with visible availability for $VmSize. Provide -Region explicitly after checking SKU availability in your subscription." +} + +function Resolve-SystemVmSize { + param( + [string]$Location, + [string]$RequestedVmSize + ) + + if ($null -ne (Test-VmSizeAvailability -Location $Location -VmSize $RequestedVmSize)) { + return $RequestedVmSize + } + + $fallbacks = @( + 'Standard_D2as_v7', + 'Standard_D2as_v6', + 'Standard_D4as_v7', + 'Standard_D4as_v6', + 'Standard_D4ads_v5' + ) + + foreach ($candidate in $fallbacks) { + if ($candidate -eq $RequestedVmSize) { + continue + } + if ($null -ne (Test-VmSizeAvailability -Location $Location -VmSize $candidate)) { + Write-Host "System VM size '$RequestedVmSize' is not currently allowed in '$Location'. Falling back to '$candidate'." -ForegroundColor Yellow + return $candidate + } + } + + throw "Could not find an allowed system node pool VM size in '$Location'. Provide -SystemVmSize explicitly after checking allowed SKUs for this subscription." +} + +function Wait-ForManagedIdentity { + param( + [string]$ResourceGroup, + [string]$IdentityName, + [int]$TimeoutSeconds = 600 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $principalId = (Invoke-Az "az identity show --resource-group $ResourceGroup --name $IdentityName --query principalId --output tsv").Trim() + if ($principalId) { + return $principalId + } + } catch { + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + + throw "Managed identity '$IdentityName' was not discoverable in resource group '$ResourceGroup' in time." +} + +function Ensure-RoleAssignment { + param( + [string]$AssigneeObjectId, + [string]$RoleName, + [string]$Scope + ) + + $existing = "" + try { + $existing = (Invoke-Az "az role assignment list --assignee-object-id $AssigneeObjectId --scope $Scope --role `"$RoleName`" --query '[0].id' --output tsv").Trim() + } catch { + } + + if ($existing) { + return + } + + Invoke-Az "az role assignment create --assignee-object-id $AssigneeObjectId --assignee-principal-type ServicePrincipal --role `"$RoleName`" --scope $Scope --output none" +} + +function Wait-ForVirtualNode { + param([int]$TimeoutSeconds = 900) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $nodeNames = kubectl get nodes -l type=virtual-kubelet -o name 2>$null + if ($LASTEXITCODE -eq 0 -and $nodeNames) { + return ($nodeNames | Select-Object -First 1).Replace("node/", "") + } + } catch { + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + + throw "Timed out waiting for the virtual node to become Ready in Kubernetes." +} + +function Wait-ForServiceEndpoint { + param( + [string]$Namespace, + [string]$ServiceName, + [int]$TimeoutSeconds = 900 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $payload = kubectl get service $ServiceName -n $Namespace -o json 2>$null + if ($LASTEXITCODE -eq 0 -and $payload) { + $service = $payload | ConvertFrom-Json + $ingress = @($service.status.loadBalancer.ingress) + if ($ingress.Count -gt 0) { + $ip = [string]$ingress[0].ip + $hostname = [string]$ingress[0].hostname + if ($ip) { + return $ip + } + if ($hostname) { + return $hostname + } + } + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + + return "" +} + +function Invoke-InternalSmokeTest { + param( + [string]$Namespace, + [string]$ServiceName + ) + + Write-Header "Running in-cluster smoke test" + kubectl delete pod virtual-node-probe --ignore-not-found | Out-Null + kubectl run virtual-node-probe --image=curlimages/curl:8.9.1 --restart=Never --command -- sleep 300 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to create the in-cluster probe pod." + } + + kubectl wait --for=condition=Ready pod/virtual-node-probe --timeout=300s | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "The in-cluster probe pod did not become Ready in time." + } + + $response = kubectl exec virtual-node-probe -- curl -L --silent --show-error "http://$ServiceName.$Namespace.svc.cluster.local" + if ($LASTEXITCODE -ne 0) { + throw "Internal curl probe failed against service '$ServiceName'." + } + + if ($response -notmatch "Welcome to Azure Container Instances") { + throw "Internal curl probe completed, but the expected hello-world payload was not returned." + } + + kubectl delete pod virtual-node-probe --ignore-not-found | Out-Null +} + +function Invoke-ExternalSmokeTest { + param( + [string]$Endpoint, + [int]$TimeoutSeconds = 300 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + try { + $response = Invoke-WebRequest -Uri ("http://{0}" -f $Endpoint) -UseBasicParsing -TimeoutSec 20 + if ($response.Content -match "Welcome to Azure Container Instances") { + return + } + } catch { + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + + Write-Warning "The external load balancer endpoint '$Endpoint' did not respond in time. This is common in restricted network environments (VPN, corp firewall). The in-cluster smoke test already confirmed the workload is healthy." +} + +function Resolve-VisualAttestationLink { + param( + [string]$ExplicitUrl, + [string]$ResourceGroup, + [string]$ContainerPrefix + ) + + if ($ExplicitUrl) { + $trimmed = $ExplicitUrl.Trim() + if ($trimmed -match '^https?://') { + return $trimmed + } + return "http://$trimmed" + } + + if (-not $ResourceGroup -or -not $ContainerPrefix) { + return $null + } + + try { + $containers = az container list --resource-group $ResourceGroup --query "[?starts_with(name, '$ContainerPrefix')].{name:name,fqdn:ipAddress.fqdn}" --output json 2>$null | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not $containers) { + return $null + } + + $candidate = $containers | + Where-Object { $_.fqdn } | + Sort-Object name -Descending | + Select-Object -First 1 + + if (-not $candidate) { + return $null + } + + return "http://$($candidate.fqdn)" + } + catch { + return $null + } +} + +Write-Header "Preparing deployment" + +Ensure-AzureCli +Ensure-AzPowerShell +Ensure-AzureLogin +Ensure-AzLogin +Ensure-Kubectl +Ensure-AksPreviewExtension + +if ($SubscriptionId) { + Invoke-Az "az account set --subscription $SubscriptionId" + Set-AzContext -SubscriptionId $SubscriptionId | Out-Null +} + +$account = Get-CurrentAccount +$activeSubscriptionId = [string]$account.id +$ownerUpn = Get-OwnerUpn +$principalObjectId = Get-SignedInObjectId + +if ([string]::IsNullOrWhiteSpace($ownerUpn)) { + throw "Could not resolve the current Azure user from Azure CLI." +} + +Assert-DeploymentRoles -SubscriptionId $activeSubscriptionId -Upn $ownerUpn -PrincipalObjectId $principalObjectId + +Write-Header "Checking subscription prerequisites" +Ensure-ProviderRegistered -Namespace "Microsoft.ContainerService" +Ensure-ProviderRegistered -Namespace "Microsoft.ContainerInstance" +Ensure-ProviderRegistered -Namespace "Microsoft.Network" + +$deploymentLocation = Resolve-DeploymentLocation -RequestedLocation $Region -VmSize $ConfidentialVmSize +$SystemVmSize = Resolve-SystemVmSize -Location $deploymentLocation -RequestedVmSize $SystemVmSize + +$scriptName = $MyInvocation.MyCommand.Name +$gitRemoteUrl = "" +try { $gitRemoteUrl = (git remote get-url origin) -replace "\.git$", "" } catch {} +if (-not $gitRemoteUrl) { + $gitRemoteUrl = "https://github.com/Azure-Samples/confidential-computing" +} + +$prefixSafe = New-SafeName -Raw $Prefix -MaxLength 10 -Fallback "vnode" +$random5 = New-RandomLetters -Count 5 + +$resourceGroupName = "$prefixSafe$random5" +$aksName = New-SafeName -Raw "$prefixSafe-aks" -MaxLength 63 -Fallback "vnode-aks" +$vnetName = New-SafeName -Raw "$prefixSafe-vnet" -MaxLength 64 -Fallback "vnode-vnet" +$aksSubnetName = "aks-subnet" +$aciSubnetName = "aci-subnet" +$systemPoolName = "syspool" +$confidentialPoolName = "ccpool" +$namespaceName = "virtualnodes-demo" +$serviceName = "aci-helloworld" +$manifestPath = Join-Path $PSScriptRoot "virtual-node-hello-world.yaml" + +$tags = @{ + owner = $ownerUpn + BuiltBy = $scriptName + GitRepo = $gitRemoteUrl + Workload = "virtual-nodes" + Scenario = "confidential-aks-aci" +} +$tagArgs = ($tags.GetEnumerator() | Sort-Object Name | ForEach-Object { "{0}={1}" -f $_.Key, $_.Value }) -join " " + +Write-Host "Subscription: $activeSubscriptionId" +Write-Host "Owner: $ownerUpn" +Write-Host "Region: $deploymentLocation" +Write-Host "ResourceGroup: $resourceGroupName" +Write-Host "AKS Cluster: $aksName" +Write-Host "VNet: $vnetName" +Write-Host "System Pool: $SystemNodeCount x $SystemVmSize" +Write-Host "CC Pool: $ConfidentialNodeCount x $ConfidentialVmSize" + +Write-Header "Creating resource group and networking" +Invoke-Az "az group create --name $resourceGroupName --location $deploymentLocation --tags $tagArgs --output table" +Invoke-Az "az network vnet create --resource-group $resourceGroupName --location $deploymentLocation --name $vnetName --address-prefixes 10.240.0.0/12 --subnet-name $aksSubnetName --subnet-prefixes 10.240.0.0/16 --tags $tagArgs --output table" +Invoke-Az "az network vnet subnet create --resource-group $resourceGroupName --vnet-name $vnetName --name $aciSubnetName --address-prefixes 10.241.0.0/16 --output table" + +$aksSubnetId = (Invoke-Az "az network vnet subnet show --resource-group $resourceGroupName --vnet-name $vnetName --name $aksSubnetName --query id --output tsv").Trim() +$aciSubnetId = (Invoke-Az "az network vnet subnet show --resource-group $resourceGroupName --vnet-name $vnetName --name $aciSubnetName --query id --output tsv").Trim() +$vnetId = (Invoke-Az "az network vnet show --resource-group $resourceGroupName --name $vnetName --query id --output tsv").Trim() + +Write-Header "Creating AKS cluster" +Invoke-Az @" +az aks create --resource-group $resourceGroupName --name $aksName --location $deploymentLocation --node-count $SystemNodeCount --nodepool-name $systemPoolName --node-vm-size $SystemVmSize --os-sku Ubuntu --enable-managed-identity --generate-ssh-keys --network-plugin azure --vnet-subnet-id $aksSubnetId --service-cidr 10.2.0.0/24 --dns-service-ip 10.2.0.10 --auto-upgrade-channel stable --node-os-upgrade-channel NodeImage --tier standard --tags $tagArgs --only-show-errors +"@ + +Write-Header "Adding confidential node pool" +Invoke-Az @" +az aks nodepool add --resource-group $resourceGroupName --cluster-name $aksName --name $confidentialPoolName --node-count $ConfidentialNodeCount --node-vm-size $ConfidentialVmSize --os-sku Ubuntu --mode User --labels workload=confidential sku=amd-sev-snp --tags owner=$ownerUpn BuiltBy=$scriptName Workload=virtual-nodes CCType=AMD-SEV-SNP --only-show-errors +"@ + +Write-Header "Enabling virtual nodes" +Invoke-Az "az aks enable-addons --resource-group $resourceGroupName --name $aksName --addons virtual-node --subnet-name $aciSubnetName --only-show-errors" + +$nodeResourceGroup = (Invoke-Az "az aks show --resource-group $resourceGroupName --name $aksName --query nodeResourceGroup --output tsv").Trim() +$aciConnectorIdentity = "aciconnectorlinux-$aksName" +$aciConnectorPrincipalId = Wait-ForManagedIdentity -ResourceGroup $nodeResourceGroup -IdentityName $aciConnectorIdentity + +Write-Header "Assigning subnet rights to the virtual nodes identity" +Ensure-RoleAssignment -AssigneeObjectId $aciConnectorPrincipalId -RoleName "Network Contributor" -Scope $aciSubnetId +Ensure-RoleAssignment -AssigneeObjectId $aciConnectorPrincipalId -RoleName "Network Contributor" -Scope $vnetId + +Write-Header "Connecting kubectl" +Invoke-Az "az aks get-credentials --resource-group $resourceGroupName --name $aksName --overwrite-existing --only-show-errors" +kubectl get nodes -o wide +if ($LASTEXITCODE -ne 0) { + throw "kubectl could not reach the new AKS cluster." +} + +# Force the connector to restart after RBAC assignment so it refreshes network access promptly. +kubectl delete pod -n kube-system -l app=aci-connector-linux --ignore-not-found | Out-Null + +$virtualNodeName = Wait-ForVirtualNode +Write-Host "Virtual node detected: $virtualNodeName" -ForegroundColor Green + +Write-Header "Deploying hello-world workload to the virtual node" +kubectl apply -f $manifestPath | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "Failed to apply $manifestPath" +} + +kubectl rollout status deployment/$serviceName -n $namespaceName --timeout=900s +if ($LASTEXITCODE -ne 0) { + throw "The hello-world deployment did not become ready in time." +} + +kubectl get pods -n $namespaceName -o wide +if ($LASTEXITCODE -ne 0) { + throw "Failed to list hello-world pods." +} + +Invoke-InternalSmokeTest -Namespace $namespaceName -ServiceName $serviceName + +$externalEndpoint = Wait-ForServiceEndpoint -Namespace $namespaceName -ServiceName $serviceName +if (-not $SkipExternalSmokeTest -and $externalEndpoint) { + Write-Header "Running external smoke test" + Invoke-ExternalSmokeTest -Endpoint $externalEndpoint +} + +$visualAttestationLink = Resolve-VisualAttestationLink -ExplicitUrl $VisualAttestationUrl -ResourceGroup $VisualAttestationResourceGroup -ContainerPrefix $VisualAttestationContainerPrefix + +Write-Header "Deployment summary" +Write-Host "Resource group: $resourceGroupName" +Write-Host "Region: $deploymentLocation" +Write-Host "AKS cluster: $aksName" +Write-Host "Node resource group: $nodeResourceGroup" +Write-Host "Virtual node identity: $aciConnectorIdentity" +Write-Host "System pool: $SystemNodeCount x $SystemVmSize" +Write-Host "Confidential pool: $ConfidentialNodeCount x $ConfidentialVmSize" +Write-Host "Virtual node subnet: $aciSubnetId" +Write-Host "Hello-world namespace: $namespaceName" +Write-Host "Hello-world service: $serviceName" +if ($externalEndpoint) { + Write-Host "Public endpoint: http://$externalEndpoint" +} else { + Write-Host "Public endpoint: Pending. Run 'kubectl get service $serviceName -n $namespaceName' to watch for assignment." +} +if ($visualAttestationLink) { + Write-Host "Visual attestation link: $visualAttestationLink" +} else { + Write-Host "Visual attestation link: Not detected. Pass -VisualAttestationUrl to set it explicitly." +} + +Write-Host "" +Write-Host "How to access the sample:" -ForegroundColor Green +if ($externalEndpoint) { + Write-Host "- Browse to http://$externalEndpoint" +} +if ($visualAttestationLink) { + Write-Host "- Browse to $visualAttestationLink" +} +Write-Host "- Inspect the pod placement with: kubectl get pods -n $namespaceName -o wide" +Write-Host "- Inspect the virtual node with: kubectl get nodes -l type=virtual-kubelet -o wide" +Write-Host "- List backing container groups with: az container list --resource-group $nodeResourceGroup -o table" +Write-Host "" +Write-Host "This first version validates virtual nodes on a confidential-node AKS cluster and deploys a standard ACI hello-world image. Moving to a confidential ACI image is a follow-on step once you provide a CCE-policy-based container manifest." -ForegroundColor Yellow \ No newline at end of file diff --git a/aks-samples/virtual nodes/README.md b/aks-samples/virtual nodes/README.md new file mode 100644 index 0000000..630e88e --- /dev/null +++ b/aks-samples/virtual nodes/README.md @@ -0,0 +1,102 @@ +# AKS Virtual Nodes on a Confidential AKS Cluster + +This sample creates a fresh AKS cluster with: + +- Azure CNI networking and dedicated AKS plus ACI subnets +- A standard system node pool +- An AMD SEV-SNP confidential node pool +- The AKS virtual nodes add-on backed by Azure Container Instances +- A public hello-world deployment scheduled onto the virtual node + +## What AKS Virtual Nodes are + +AKS Virtual Nodes let you run Kubernetes pods on Azure Container Instances (ACI) without adding or managing more AKS VM nodes. The virtual node appears as a Kubernetes node, but the pod runtime is provided by ACI. + +This is useful when you want rapid burst capacity, simpler operations for specific pod classes, or to run selected workloads in ACI while keeping Kubernetes control and scheduling in AKS. + +### Typical scenarios + +- Burst or spiky workloads where you do not want to scale VM node pools up and down +- Event-driven or short-lived jobs that benefit from ACI startup and billing behavior +- Isolating selected workloads from regular AKS node pools while keeping a single Kubernetes API surface +- Hybrid AKS + ACI patterns where most services run on AKS nodes and overflow or special workloads run on virtual nodes + +### Official documentation + +- AKS Virtual Nodes overview: https://learn.microsoft.com/azure/aks/virtual-nodes +- AKS Virtual Nodes CLI walkthrough: https://learn.microsoft.com/azure/aks/virtual-nodes-cli +- Azure Container Instances overview: https://learn.microsoft.com/azure/container-instances/container-instances-overview + +The script lives entirely in this folder and follows the repo's usual naming and tagging pattern: + +- Resource group name: `<5 random lowercase letters>` +- Tags: `owner`, `BuiltBy`, `GitRepo`, `Workload`, `Scenario` + +## Files + +- `Deploy-VirtualNodesAKS.ps1`: end-to-end deployment script +- `virtual-node-hello-world.yaml`: hello-world workload scheduled onto the virtual node (standard ACI) +- `virtual-node-confidential.yaml`: confidential ACI workload template with CCE policy annotation scaffold + +## What the script checks + +- Azure CLI installed and logged in +- Azure PowerShell `Az` modules installed and logged in +- `kubectl` installed, with an option to install via `az aks install-cli` +- `aks-preview` Azure CLI extension installed, with an option to install it +- Required Azure resource providers registered +- Active RBAC capable of creating role assignments at subscription scope +- PIM eligibility for the required roles if the active role is missing, using native Az PowerShell cmdlets +- Confidential VM SKU visibility in the target region + +The script requires an active subscription-scope role that can both create resources and assign RBAC on the virtual-node network resources. In practice, that means one of: + +- `Owner` +- `User Access Administrator` +- `Role Based Access Control Administrator` + +If one of those roles is only eligible through Microsoft Entra PIM, the script pauses and asks you to activate it before retrying the permission check. + +During live validation, the AKS virtual-nodes connector identity needed `Network Contributor` on both the `aci-subnet` and the parent virtual network to stabilize quickly, so the script assigns both scopes before restarting the connector. + +## Usage + +```powershell +./Deploy-VirtualNodesAKS.ps1 -Prefix sgall +``` + +Optional parameters: + +```powershell +./Deploy-VirtualNodesAKS.ps1 -Prefix sgall -Region eastus2 -SubscriptionId +./Deploy-VirtualNodesAKS.ps1 -Prefix sgall -ConfidentialVmSize Standard_DC2as_v5 -SystemVmSize Standard_D2as_v7 +./Deploy-VirtualNodesAKS.ps1 -Prefix sgall -Region eastus2 -AutoActivatePim +``` + +## Default behavior + +- If `-Region` is omitted, the script prefers `eastus2` and falls back across a short candidate list until it finds visible availability for the confidential node SKU. +- The default system node pool size is `Standard_D2as_v7`, which aligns with the currently allowed SKUs in the target subscription and region used during validation. +- The cluster uses Azure CNI because virtual nodes require advanced networking. +- The hello-world pod is scheduled onto the virtual node using node selectors and tolerations from the AKS virtual nodes guidance. +- The RBAC and PIM preflight uses native Azure PowerShell cmdlets. If you pass `-AutoActivatePim`, the script can submit a native `SelfActivate` request for the first eligible role instead of waiting for manual confirmation. +- The script performs an internal smoke test from inside the cluster and, when possible, an external HTTP test through the public load balancer. + +## Notes on confidential ACI + +This first sample validates the virtual-nodes plumbing on top of a confidential-node AKS cluster and uses the standard `mcr.microsoft.com/azuredocs/aci-helloworld` image as the smoke test. + +That gives you a working foundation for the next step: replacing the sample manifest with a confidential ACI deployment that includes a generated Confidential Computing Enforcement policy and any attestation sidecars you want to test. + +`virtual-node-confidential.yaml` is the starting point for that step. It includes the `microsoft.containerinstance.virtualnode.ccepolicy` annotation with an allow-all debug policy. Replace the policy value with the output of: + +```bash +az confcom acipolicygen --image --print-policy +``` + +## References + +- https://learn.microsoft.com/en-us/azure/aks/virtual-nodes +- https://learn.microsoft.com/en-us/azure/aks/virtual-nodes-cli +- https://learn.microsoft.com/en-us/azure/container-instances/container-instances-confidential-overview +- https://learn.microsoft.com/en-us/azure/aks/confidential-containers-overview \ No newline at end of file diff --git a/aks-samples/virtual nodes/virtual-node-confidential.yaml b/aks-samples/virtual nodes/virtual-node-confidential.yaml new file mode 100644 index 0000000..9760a69 --- /dev/null +++ b/aks-samples/virtual nodes/virtual-node-confidential.yaml @@ -0,0 +1,99 @@ +# Confidential ACI workload via AKS Virtual Nodes +# +# This manifest schedules an ACI container group on the virtual-node with the +# confidential-compute add-on enabled. The key pieces are: +# +# 1. annotations: +# microsoft.containerinstance.virtualnode.ccepolicy +# Base64-encoded Rego CCE policy generated by: +# az confcom acipolicygen --image --print-policy +# The policy hard-codes every layer digest and must be regenerated +# whenever the image changes. +# +# microsoft.containerinstance.virtualnode.skippolicyvalidation (OPTIONAL) +# Set to "true" only during early development to skip policy +# enforcement. NEVER use in production. +# +# 2. nodeSelector / tolerations that target virtual-node-aci-linux. +# +# CCE policy in this file +# ----------------------- +# The policy embedded here is the allow-all DEBUG policy. It permits any +# container image and any execution context and is suitable only for +# exploring the wiring. Replace it with a generated restrictive policy +# before running any sensitive workload: +# +# az confcom acipolicygen \ +# --image mcr.microsoft.com/azuredocs/aci-helloworld \ +# --print-policy +# +# Then substitute the printed base64 value into the annotation below. + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: virtualnodes-confidential-demo + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aci-confidential-helloworld + namespace: virtualnodes-confidential-demo + labels: + app: aci-confidential-helloworld +spec: + replicas: 1 + selector: + matchLabels: + app: aci-confidential-helloworld + template: + metadata: + labels: + app: aci-confidential-helloworld + annotations: + # --------------------------------------------------------------- + # CCE Policy (REQUIRED for confidential ACI) + # Replace this value with the output of: + # az confcom acipolicygen --image --print-policy + # The value below is the allow-all debug policy — NOT for production. + # --------------------------------------------------------------- + microsoft.containerinstance.virtualnode.ccepolicy: "cGFja2FnZSBwb2xpY3kKYXBpX3ZlcnNpb24gOj0gIjAuMS4wIgppbXBvcnQgZnV0dXJlLmtleXdvcmRzLmV2ZXJ5CmltcG9ydCBmdXR1cmUua2V5d29yZHMuaW4KZnJhZ21lbnRzIDo9IFtdCmNvbnRhaW5lcnMgOj0gW10KYWxsb3dfcHJvcGVydGllc19hY2Nlc3MgOj0gdHJ1ZQphbGxvd19kdW1wX3N0YWNrcyA6PSB0cnVlCmFsbG93X3J1bnRpbWVfbG9nZ2luZyA6PSB0cnVlCmFsbG93X2Vudmlyb25tZW50X3ZhcmlhYmxlX2Ryb3BwaW5nIDo9IHRydWUKYWxsb3dfdW5lbmNyeXB0ZWRfc2NyYXRjaCA6PSB0cnVlCmFsbG93X2NhcGFiaWxpdHlfZHJvcHBpbmcgOj0gdHJ1ZQ==" + spec: + containers: + - name: aci-confidential-helloworld + image: mcr.microsoft.com/azuredocs/aci-helloworld + ports: + - containerPort: 80 + resources: + requests: + cpu: "1" + memory: "1Gi" + limits: + cpu: "1" + memory: "1Gi" + nodeSelector: + kubernetes.io/os: linux + kubernetes.io/role: agent + type: virtual-kubelet + tolerations: + - key: virtual-kubelet.io/provider + operator: Exists + - key: azure.com/aci + effect: NoSchedule + +--- +apiVersion: v1 +kind: Service +metadata: + name: aci-confidential-helloworld + namespace: virtualnodes-confidential-demo +spec: + type: LoadBalancer + selector: + app: aci-confidential-helloworld + ports: + - protocol: TCP + port: 80 + targetPort: 80 diff --git a/aks-samples/virtual nodes/virtual-node-hello-world.yaml b/aks-samples/virtual nodes/virtual-node-hello-world.yaml new file mode 100644 index 0000000..a137821 --- /dev/null +++ b/aks-samples/virtual nodes/virtual-node-hello-world.yaml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: virtualnodes-demo +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: aci-helloworld + namespace: virtualnodes-demo +spec: + replicas: 1 + selector: + matchLabels: + app: aci-helloworld + template: + metadata: + labels: + app: aci-helloworld + spec: + nodeSelector: + kubernetes.io/role: agent + kubernetes.io/os: linux + type: virtual-kubelet + tolerations: + - key: virtual-kubelet.io/provider + operator: Exists + - key: azure.com/aci + effect: NoSchedule + containers: + - name: aci-helloworld + image: mcr.microsoft.com/azuredocs/aci-helloworld + ports: + - containerPort: 80 + resources: + requests: + cpu: 1 + memory: 1Gi + limits: + cpu: 1 + memory: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: aci-helloworld + namespace: virtualnodes-demo +spec: + type: LoadBalancer + selector: + app: aci-helloworld + ports: + - port: 80 + targetPort: 80 + protocol: TCP \ No newline at end of file