Repository navigation
Expand file tree
/
Copy pathBuildRandomCVM.ps1
More file actions
1089 lines (982 loc) · 57.2 KB
/
Copy pathBuildRandomCVM.ps1
File metadata and controls
1089 lines (982 loc) · 57.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Hands-off script to build a windows CVM and then make it do attestation by automating an attestation process /inside/ the VM
# VM will be created in a private vnet with no public IP and can only be accessed over the Internet via the Azure Bastion service
# Feb 2025 - ported to all native PowerShell code and re-implemented Azure Bastion code and added command line parameters rather than editing file
# April 2025 - attestation check now runs inside the VM using the WindowsAttest.ps1 script
# Tested on MacOS (PWSH 7.5) & Windows (7.4.6)
#
# Simon Gallagher, ACC Product Group
# Use at your own risk, no warranties implied, test in a non-production environment first
# based on https://learn.microsoft.com/en-us/azure/confidential-computing/quick-create-confidential-vm-azure-cli
#
# Clone this repo to a folder (relies on the WindowsAttest.ps1 script being in the same folder as this script)
#
# Usage: ./BuildRandomCVM.ps1 -subsID <YOUR SUBSCRIPTION ID> -basename <YOUR BASENAME> -osType <Windows|Windows11|Windows2019|Ubuntu|Ubuntu2604|RHEL> [-description <OPTIONAL DESCRIPTION>] [-smoketest] [-region <AZURE REGION>] [-policyFilePath <PATH TO POLICY FILE>] [-DisableBastion] [-NoInternetAccess] [-SkipSkuPreflight] [-GPU]
#
# Basename is a prefix for all resources created, it's used to create unique names for the resources
# osType specifies which OS to deploy: Windows (Server 2022), Windows11 (Windows 11 Enterprise), Windows2019 (Server 2019), Ubuntu (24.04), Ubuntu2604 (26.04), or RHEL (9.5)
# description is an optional parameter that will be added as a tag to the resource group
# smoketest is an optional switch that automatically removes all resources after completion (useful for testing)
# region is an optional parameter that specifies the Azure region (defaults to northeurope)
# policyFilePath is an optional parameter that specifies the path to a custom policy file for key vault key creation
# DisableBastion is an optional switch that skips the creation of Azure Bastion (VM will only be accessible via private network)
# NoInternetAccess is an optional switch that blocks outbound internet access from the CVM subnet by not attaching NAT Gateway egress
# GPU is an optional switch that provisions an NVIDIA H100 confidential GPU VM with Ubuntu 22.04 CVM, installs the NVIDIA open-kernel driver and nvtrust verifier inside the VM, and runs both GPU CC-mode and CPU SEV-SNP attestation.
#
# You'll need to have the latest Azure PowerShell module installed as older versions don't have the parameters for AKV & ACC (update-module -force)
#
# TODO (cosmetic/docs)
# - review credential handling guidance; current flow is functional but not ideal
# handle command line parameters, mandatory, will force you to enter them
param (
[Parameter(Mandatory)]$subsID,
[Parameter(Mandatory)]$basename,
[Parameter(Mandatory)]
[ValidateSet("Windows", "Windows11", "Windows2019", "Ubuntu", "Ubuntu2604", "RHEL")]
$osType,
[Parameter(Mandatory=$false)]$description = "",
[Parameter(Mandatory=$false)][switch]$smoketest,
[Parameter(Mandatory=$false)]$region = "northeurope",
[Parameter(Mandatory=$false)]$vmsize = "Standard_DC2as_v6",
[Parameter(Mandatory=$false)]$policyFilePath = "",
[Parameter(Mandatory=$false)][switch]$DisableBastion,
[Parameter(Mandatory=$false)][switch]$NoInternetAccess,
[Parameter(Mandatory=$false)][switch]$SkipSkuPreflight,
[Parameter(Mandatory=$false)][switch]$GPU
)
if ($subsID -eq "" -or $basename -eq "" -or $osType -eq "") {
write-host "You must enter a subscription ID, basename, and OS type (Windows, Windows11, Windows2019, Ubuntu, Ubuntu2604, or RHEL)"
exit
}# exit if any of the parameters are empty
if ($GPU -and $NoInternetAccess) {
throw "-GPU requires outbound internet access to install the NVIDIA driver and nvtrust verifier, so it cannot be combined with -NoInternetAccess."
}
if ($GPU) {
$h100Sku = 'Standard_NCC40ads_H100_v5'
$supportedH100Regions = @('eastus2', 'westeurope', 'southcentralus', 'westus3', 'swedencentral', 'centraluseuap')
if ($vmsize -ne $h100Sku) {
write-host "-GPU was specified: overriding -vmsize '$vmsize' with '$h100Sku' (NVIDIA H100 SEV-SNP CVM)." -ForegroundColor Yellow
$vmsize = $h100Sku
}
if ($osType -ne 'Ubuntu') {
write-host "-GPU was specified: overriding -osType '$osType' with 'Ubuntu' (NVIDIA H100 CC mode is Linux-only on Azure)." -ForegroundColor Yellow
$osType = 'Ubuntu'
}
if ($supportedH100Regions -notcontains $region) {
write-host "-GPU was specified: region '$region' does not offer '$h100Sku'. Switching to 'eastus2'. Pass -region with one of: $($supportedH100Regions -join ', ') to override." -ForegroundColor Yellow
$region = 'eastus2'
}
}
#---------Prerequisite Checks: PowerShell version and required Az modules-----------------------------------------------
function Test-PrerequisitesInstalled {
param(
[Parameter(Mandatory=$false)][switch]$StrictMode # If true, fail on missing optional tools like Azure CLI
)
$missingPrereqs = @()
# Check PowerShell version (need 7.0+)
$psVersion = $PSVersionTable.PSVersion
if ($psVersion.Major -lt 7) {
$missingPrereqs += "PowerShell 7.0+ (currently running: $($psVersion.Major).$($psVersion.Minor).$($psVersion.Patch))"
}
# Check required Az modules
$requiredModules = @("Az.Accounts", "Az.Compute", "Az.KeyVault", "Az.Network")
foreach ($moduleName in $requiredModules) {
$module = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue
if (-not $module) {
$missingPrereqs += "$moduleName (required)"
}
}
# Check optional but recommended tools (just warnings, not errors)
$optionalTools = @()
$azCli = Get-Command az -ErrorAction SilentlyContinue
if (-not $azCli) {
$optionalTools += "Azure CLI 2.60+ (optional, used for enhanced queries and Bastion RDP tunneling)"
}
$git = Get-Command git -ErrorAction SilentlyContinue
if (-not $git) {
$optionalTools += "git (optional, used to auto-detect repository URL)"
}
# Display results
write-host ""
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "Prerequisite Check" -ForegroundColor Cyan
write-host "----------------------------------------------------------------------------------------------------------------"
# PowerShell version (OK)
if ($psVersion.Major -ge 7) {
write-host "✓ PowerShell $($psVersion.Major).$($psVersion.Minor).$($psVersion.Patch)" -ForegroundColor Green
}
# Required modules status
foreach ($moduleName in $requiredModules) {
$module = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue
if ($module) {
$version = $module.Version | Sort-Object -Descending | Select-Object -First 1
write-host "✓ $moduleName (v$version)" -ForegroundColor Green
}
}
# Optional tools (just info, don't block)
if ($optionalTools.Count -gt 0) {
write-host ""
foreach ($tool in $optionalTools) {
write-host "⚠ $tool" -ForegroundColor Yellow
}
}
# Display errors and exit if critical prereqs missing
if ($missingPrereqs.Count -gt 0) {
write-host ""
write-host "MISSING PREREQUISITES:" -ForegroundColor Red
foreach ($prereq in $missingPrereqs) {
write-host "✗ $prereq" -ForegroundColor Red
}
write-host ""
write-host "Installation steps:" -ForegroundColor Yellow
write-host " PowerShell 7+: https://github.com/PowerShell/PowerShell/releases" -ForegroundColor Gray
write-host " Azure PowerShell: Update-Module -Name Az -Force" -ForegroundColor Gray
write-host " Azure CLI (optional): https://learn.microsoft.com/cli/azure/install-azure-cli" -ForegroundColor Gray
write-host ""
write-host "After installing, restart PowerShell and run this script again." -ForegroundColor Yellow
write-host "----------------------------------------------------------------------------------------------------------------"
exit 1
}
write-host "✓ All required prerequisites are installed" -ForegroundColor Green
write-host "----------------------------------------------------------------------------------------------------------------"
}
# Run the prerequisite check
Test-PrerequisitesInstalled
# mark the start time of the script execution
$startTime = Get-Date
# get the name of the script so we can tag the resource group with it
$scriptName = $MyInvocation.MyCommand.Name
# Get GitHub repository URL from git remote - we use this to tag the resource group with the repo URL
$gitRemoteUrl = ""
$gitRemoteUrl = git remote get-url origin
# Remove .git suffix if present
$gitRemoteUrl = $gitRemoteUrl -replace "\.git$", ""
# If git remote didn't work, use fallback
if (-not $gitRemoteUrl) {
$gitRemoteUrl = "[Originally from] https://github.com/Microsoft/confidential-computing"
}
# Set PowerShell variables to use in the script
$basename = $basename + -join ((97..122) | Get-Random -Count 5 | % {[char]$_}) # basename + 5 random lower-case letters
$vmusername = "azureuser" # you can adjust this if you want
$vmadminpassword = -join ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%".ToCharArray() | Get-Random -Count 40) # build a random password - note you can't get it back afterwards
$resgrp = $basename # name of the resource group where all resources will be created, copied from $basename
$akvname = $basename + "akv" #Name of the Azure Key Vault
$desname = $basename + "des" #Name of the Disk Encryption Set
$keyname = $basename + "-cmk-key" #Name of the key in the Key Vault
$vmname = $basename # name of the VM, copied from $basename, or customise it here
$vnetname = $vmname + "vnet" # name of the VNET
$bastionname = $vnetname + "-bastion" # name of the bastion host
$vnetipname = $vnetname + "-pip" #Name of the VNET IP
$natGatewayName = $vnetname + "-nat" # name of the NAT gateway used for outbound internet
$natPublicIpName = $vnetname + "-nat-pip" # name of the NAT gateway public IP
$nicPrefix = $basename + "-nic" #Name of the NIC
$bastionsubnetName = "AzureBastionSubnet" # don't change this
$vmsubnetname = $basename + "vmsubnet" # don't change this
# region is now a command line parameter with default value of northeurope
$vmSize = $vmsize # Use the value from the command line parameter
$identityType = "SystemAssigned";
$secureEncryptGuestState = "DiskWithVMGuestState";
$vmSecurityType = "ConfidentialVM";
$KeySize = 3072
$diskEncryptionType = "ConfidentialVmEncryptedWithCustomerKey";
# Display region information
if ($region -eq "northeurope") {
write-host "Using default region: $region (North Europe)" -ForegroundColor Cyan
write-host "To use a different region, specify -region parameter. Ensure the region supports Confidential VMs." -ForegroundColor Cyan
} else {
write-host "Using specified region: $region" -ForegroundColor Cyan
write-host "Please ensure this region supports Confidential VMs and the selected VM SKU." -ForegroundColor Cyan
}
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "Building a Confidential Virtual Machine ($osType) in " $basename " in " $region
if ($smoketest) {
write-host "SMOKETEST MODE: Resources will be automatically deleted after completion" -ForegroundColor Yellow
}
if ($DisableBastion) {
write-host "BASTION DISABLED: VM will only be accessible via private network connectivity" -ForegroundColor Yellow
}
if ($NoInternetAccess) {
write-host "INTERNET DISABLED: CVM subnet will not have outbound internet access" -ForegroundColor Yellow
} else {
write-host "INTERNET ENABLED: CVM subnet will use NAT Gateway for outbound internet access" -ForegroundColor Cyan
}
write-host "IMPORTANT"
write-host "VM admin username is " $vmusername
write-host "randomly generated passsword for the VM is " $vmadminpassword " - save this now as you CANNOT retrieve it later"
write-host ""
write-host "Script: $scriptName"
write-host "Repository URL: $gitRemoteUrl"
write-host "----------------------------------------------------------------------------------------------------------------"
#Interactive login for PowerShell - uncomment if you want the script to prompt you
#If you are not logged in, or dont have the correct subscription selected you will need to do so 1st
#Connect-AzAccount -SubscriptionId $subsid -Tenant <ADD TENANT ID>
Set-AzContext -SubscriptionId $subsID
if (!$?) {
write-host "Failed to connect to the Azure subscription " $subsID " extiting"
exit
}
#Get username of logged-in Azure user so we can tag the resource group with it
$tmp = Get-AzContext
$ownername = $tmp.Account.Id
#---------Pre-flight: SKU availability and quota check---------------------------------------------------------------
# Verify the chosen VM SKU is a Confidential VM SKU (AMD SEV-SNP or Intel TDX, NOT Intel SGX which is
# a different isolation model and not supported by this script), is offered in the chosen region and not
# restricted for this subscription, and that there's enough vCPU quota left in the SKU's family before
# we start creating resources.
# Note: Get-AzComputeResourceSku and Get-AzVMUsage have been observed to misreport NotAvailableForSubscription / 0 quota
# even when ARM accepts the deployment (e.g. Standard_DC*as_v6 in koreacentral). Use -SkipSkuPreflight to bypass.
if ($SkipSkuPreflight) {
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "Pre-flight check SKIPPED (-SkipSkuPreflight). ARM will validate '$vmSize' in '$region' at deploy time." -ForegroundColor Yellow
write-host "----------------------------------------------------------------------------------------------------------------"
}
else {
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "Pre-flight check: confirming '$vmSize' is available in '$region' with sufficient quota..." -ForegroundColor Cyan
# Reject Intel SGX SKUs early - this script targets Confidential VMs (full-VM isolation),
# not SGX enclaves (per-process isolation). SGX SKUs use the DC*s_v3 / DC*s_v2 naming.
if ($vmSize -match '^Standard_DC\d+s_v[23]$') {
write-host "ERROR: '$vmSize' is an Intel SGX SKU (application-enclave isolation), which is NOT supported by this script." -ForegroundColor Red
write-host "This script provisions Confidential VMs (whole-VM hardware isolation) using either:" -ForegroundColor Yellow
write-host " - AMD SEV-SNP : DCa*/ECa* (e.g. Standard_DC2as_v5, Standard_DC4as_v5)" -ForegroundColor Yellow
write-host " - Intel TDX : DCe*/ECe* (e.g. Standard_DC2es_v6, Standard_EC4es_v6)" -ForegroundColor Yellow
write-host "For Intel SGX (DCsv3/DCsv2) workloads, see https://learn.microsoft.com/azure/confidential-computing/virtual-machine-solutions-sgx instead." -ForegroundColor Yellow
exit 1
}
# Warn (but don't fail) if the SKU doesn't look like a known CVM SKU naming pattern.
$isKnownCvmSku = ($vmSize -match '^Standard_(DC|EC)\d+[a-z]+_v\d+$' -and $vmSize -match '_(DC|EC)\d+(a|e)') -or ($vmSize -match '^Standard_NCC\d+ads_H100_v\d+$')
if (-not $isKnownCvmSku) {
write-host "Warning: '$vmSize' does not match a known Confidential VM SKU pattern (DCa*/ECa* for SEV-SNP, DCe*/ECe* for TDX, NCCads_H100 for confidential GPU). Continuing, but deployment may fail if this is not a CVM SKU." -ForegroundColor Yellow
}
$skuInfo = $null
try {
$skuInfo = Get-AzComputeResourceSku -Location $region -ErrorAction Stop |
Where-Object { $_.ResourceType -eq 'virtualMachines' -and $_.Name -eq $vmSize } |
Select-Object -First 1
} catch {
write-host "Warning: could not query Get-AzComputeResourceSku for '$region': $($_.Exception.Message)" -ForegroundColor Yellow
}
function Show-QuotaHelp($sku, $region) {
write-host ""
write-host "To find regions where this SKU IS available to your subscription, try:" -ForegroundColor Yellow
write-host " Get-AzComputeResourceSku | Where-Object { `$_.ResourceType -eq 'virtualMachines' -and `$_.Name -eq '$sku' -and (-not `$_.Restrictions -or `$_.Restrictions.Count -eq 0) } | Select-Object Locations, Name" -ForegroundColor Gray
write-host ""
write-host "To list the Confidential VM SKUs offered in '$region' (SEV-SNP DCa*/ECa* and Intel TDX DCe*/ECe*):" -ForegroundColor Yellow
write-host " Get-AzComputeResourceSku -Location '$region' | Where-Object { `$_.ResourceType -eq 'virtualMachines' -and `$_.Name -match '_(DC|EC)\d+(a|e)' } | Select-Object Name, @{n='Restricted';e={`$_.Restrictions.Count -gt 0}}" -ForegroundColor Gray
write-host ""
write-host "To see your vCPU usage and limits in '$region':" -ForegroundColor Yellow
write-host " Get-AzVMUsage -Location '$region' | Where-Object { `$_.Name.Value -match 'DCa|DCe|ECa|ECe|cores' } | Format-Table -AutoSize" -ForegroundColor Gray
write-host ""
write-host "To request a quota increase, see: https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal" -ForegroundColor Yellow
}
if ($null -eq $skuInfo) {
write-host "ERROR: VM SKU '$vmSize' is not offered in region '$region'." -ForegroundColor Red
Show-QuotaHelp $vmSize $region
exit 1
}
# Check subscription-level restrictions (e.g. NotAvailableForSubscription)
$subRestriction = $skuInfo.Restrictions | Where-Object {
$_.ReasonCode -eq 'NotAvailableForSubscription' -or
($_.RestrictionInfo -and $_.RestrictionInfo.Locations -contains $region) -or
($_.Values -contains $region)
}
if ($subRestriction) {
$reason = ($skuInfo.Restrictions | ForEach-Object { $_.ReasonCode }) -join ', '
write-host "ERROR: VM SKU '$vmSize' is restricted for this subscription in '$region' (reason: $reason)." -ForegroundColor Red
Show-QuotaHelp $vmSize $region
exit 1
}
# Determine vCPU count and family for the SKU
$skuVCpus = ($skuInfo.Capabilities | Where-Object { $_.Name -eq 'vCPUs' } | Select-Object -First 1).Value -as [int]
$skuFamily = $skuInfo.Family # e.g. 'standardDCASv5Family'
if (-not $skuVCpus) { $skuVCpus = 2 } # fall back to a sensible minimum
# Check vCPU quota for that family in this region
try {
$usage = Get-AzVMUsage -Location $region -ErrorAction Stop |
Where-Object { $_.Name.Value -eq $skuFamily } |
Select-Object -First 1
if ($usage) {
$available = [int]$usage.Limit - [int]$usage.CurrentValue
write-host ("Quota for {0} in {1}: {2}/{3} used, {4} vCPUs available, this SKU needs {5}." -f `
$skuFamily, $region, $usage.CurrentValue, $usage.Limit, $available, $skuVCpus) -ForegroundColor Cyan
if ($available -lt $skuVCpus) {
write-host "ERROR: Insufficient vCPU quota in family '$skuFamily' in '$region' to deploy '$vmSize' ($skuVCpus vCPUs needed, $available available)." -ForegroundColor Red
Show-QuotaHelp $vmSize $region
exit 1
}
} else {
write-host "Note: could not find VM usage entry for family '$skuFamily' in '$region' - proceeding without quota check." -ForegroundColor Yellow
}
} catch {
write-host "Warning: Get-AzVMUsage failed for '$region': $($_.Exception.Message). Proceeding without quota check." -ForegroundColor Yellow
}
write-host "Pre-flight check passed: '$vmSize' is available and within quota in '$region'." -ForegroundColor Green
write-host "----------------------------------------------------------------------------------------------------------------"
}
# Create Resource Group
$resourceGroupTags = @{
owner = $ownername
BuiltBy = $scriptName
OSType = $osType
GitRepo = $gitRemoteUrl
}
# Add description tag if provided
if ($description -ne "") {
$resourceGroupTags.Add("description", $description)
}
# Add smoketest tag if running in smoketest mode
if ($smoketest) {
$resourceGroupTags.Add("smoketest", "true")
}
# Add DisableBastion tag if running without Bastion
if ($DisableBastion) {
$resourceGroupTags.Add("BastionDisabled", "true")
}
# Add NoInternetAccess tag if outbound internet is disabled
if ($NoInternetAccess) {
$resourceGroupTags.Add("NoInternetAccess", "true")
}
New-AzResourceGroup -Name $resgrp -Location $region -Tag $resourceGroupTags -force
#create a credential object
$securePassword = ConvertTo-SecureString -String $vmadminpassword -AsPlainText -Force # this could probably be done better inline rather than via a variable
$cred = New-Object System.Management.Automation.PSCredential ($vmusername, $securePassword);
# Create Key Vault
New-AzKeyVault -Name $akvname -Location $region -ResourceGroupName $resgrp -Sku Premium -EnabledForDiskEncryption -DisableRbacAuthorization -SoftDeleteRetentionInDays 10 -EnablePurgeProtection;
#TO DO - if the SP hasn't been created in this tenant yet - break here, or prompt to create it (code as follows)
#Connect-Graph -Tenant "your tenant ID" Application.ReadWrite.All
#New-MgServicePrincipal -AppId bf7b6499-ff71-4aa2-97a4-f372087be7f0 -DisplayName "Confidential VM Orchestrator"
$cvmAgent = Get-AzADServicePrincipal -ApplicationId 'bf7b6499-ff71-4aa2-97a4-f372087be7f0';
Set-AzKeyVaultAccessPolicy -VaultName $akvname -ResourceGroupName $resgrp -ObjectId $cvmAgent.id -PermissionsToKeys get,release;
# Add Key vault Key
if ($policyFilePath -ne "" -and (Test-Path $policyFilePath)) {
Add-AzKeyVaultKey -VaultName $akvname -Name $KeyName -Size $KeySize -KeyOps wrapKey,unwrapKey -KeyType RSA -Destination HSM -Exportable -ReleasePolicyPath $policyFilePath;
} else {
if ($policyFilePath -ne "" -and !(Test-Path $policyFilePath)) {
Write-Host "Warning: Policy file path '$policyFilePath' does not exist. Using default CVM policy instead." -ForegroundColor Yellow
}
Add-AzKeyVaultKey -VaultName $akvname -Name $KeyName -Size $KeySize -KeyOps wrapKey,unwrapKey -KeyType RSA -Destination HSM -Exportable -UseDefaultCVMPolicy;
}
# Capture Key Vault and Key details
$encryptionKeyVaultId = (Get-AzKeyVault -VaultName $akvname -ResourceGroupName $resgrp).ResourceId;
$encryptionKeyURL = (Get-AzKeyVaultKey -VaultName $akvname -KeyName $keyName).Key.Kid;
# Create new DES Config and Disk Encryption Set
$desConfig = New-AzDiskEncryptionSetConfig -Location $region -SourceVaultId $encryptionKeyVaultId -KeyUrl $encryptionKeyURL -IdentityType SystemAssigned -EncryptionType $diskEncryptionType;
New-AzDiskEncryptionSet -ResourceGroupName $resgrp -Name $desName -DiskEncryptionSet $desConfig;
$diskencset = Get-AzDiskEncryptionSet -ResourceGroupName $resgrp -Name $desName;
# Assign DES Access Policy to key vault
$desIdentity = (Get-AzDiskEncryptionSet -Name $desName -ResourceGroupName $resgrp).Identity.PrincipalId;
Set-AzKeyVaultAccessPolicy -VaultName $akvname -ResourceGroupName $resgrp -ObjectId $desIdentity -PermissionsToKeys wrapKey,unwrapKey,get -BypassObjectIdValidation;
$VirtualMachine = New-AzVMConfig -VMName $VMName -VMSize $vmSize;
# Configure OS based on the selected type
switch ($osType) {
"Windows" {
$VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Windows -ComputerName $vmname -Credential $cred -ProvisionVMAgent -EnableAutoUpdate;
# Use the new Windows Server 2022 offer (windowsserver2022) to avoid deprecated legacy images.
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'MicrosoftWindowsServer' -Offer 'windowsserver2022' -Skus '2022-datacenter-smalldisk-g2' -Version "latest";
$VMIsLinux = $false
}
"Windows11" {
$VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Windows -ComputerName $vmname -Credential $cred -ProvisionVMAgent -EnableAutoUpdate;
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'MicrosoftWindowsDesktop' -Offer 'Windows-11' -Skus 'win11-23h2-ent' -Version "latest";
$VMIsLinux = $false
}
"Windows2019" {
# Windows Server 2019 Confidential VM image (G2)
$VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Windows -ComputerName $vmname -Credential $cred -ProvisionVMAgent -EnableAutoUpdate;
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'MicrosoftWindowsServer' -Offer 'windowsserver' -Skus '2019-datacenter-smalldisk-g2' -Version "latest";
$VMIsLinux = $false
}
"Ubuntu" { # updated to use Ubuntu 24.04 LTS
$VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Linux -ComputerName $vmname -Credential $cred;
if ($GPU) {
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'Canonical' -Offer '0001-com-ubuntu-confidential-vm-jammy' -Skus '22_04-lts-cvm' -Version "latest";
} else {
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'Canonical' -Offer 'ubuntu-24_04-lts' -Skus 'cvm' -Version "latest";
}
$VMIsLinux = $true
}
"Ubuntu2604" {
$VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Linux -ComputerName $vmname -Credential $cred;
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'Canonical' -Offer 'ubuntu-26_04-lts' -Skus 'server-cvm' -Version "latest";
$VMIsLinux = $true
}
"RHEL" {
$VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Linux -ComputerName $vmname -Credential $cred;
$VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'redhat' -Offer 'rhel-cvm' -Skus '9_5_cvm' -Version "latest";
$VMIsLinux = $true
}
}
$subnet = New-AzVirtualNetworkSubnetConfig -Name ($vmsubnetName) -AddressPrefix "10.0.0.0/24" -DefaultOutboundAccess $false;
$vnet = New-AzVirtualNetwork -Force -Name ($vnetname) -ResourceGroupName $resgrp -Location $region -AddressPrefix "10.0.0.0/16" -Subnet $subnet;
$vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp;
# Configure outbound internet path for the VM subnet via NAT Gateway unless explicitly disabled.
if ($NoInternetAccess) {
write-host "No NAT Gateway attached to VM subnet due to -NoInternetAccess. Outbound internet will be blocked." -ForegroundColor Yellow
}
else {
write-host "Configuring NAT Gateway egress so the private CVM can access internet without a public IP..." -ForegroundColor Cyan
$natPublicIp = New-AzPublicIpAddress -ResourceGroupName $resgrp -Name $natPublicIpName -Location $region -AllocationMethod Static -Sku Standard
$natGateway = New-AzNatGateway -ResourceGroupName $resgrp -Name $natGatewayName -Location $region -IdleTimeoutInMinutes 10 -Sku Standard -PublicIpAddress $natPublicIp
# Set the resource reference directly; some Az.Network versions drop the -InputObject association.
$vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1
$vmSubnet.NatGateway = New-Object Microsoft.Azure.Commands.Network.Models.PSResourceId
$vmSubnet.NatGateway.Id = $natGateway.Id
$vnet | Set-AzVirtualNetwork -ErrorAction Stop | Out-Null
$vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp
$vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1
if (-not $vmSubnet -or -not $vmSubnet.NatGateway -or -not $vmSubnet.NatGateway.Id) {
$azCli = Get-Command az -ErrorAction SilentlyContinue
if ($azCli) {
write-host "Az.Network cmdlet did not persist NAT association on this host; falling back to Azure CLI subnet update..." -ForegroundColor Yellow
& $azCli.Source network vnet subnet update --resource-group $resgrp --vnet-name $vnetname --name $vmsubnetName --nat-gateway $natGatewayName --only-show-errors -o none
if ($LASTEXITCODE -ne 0) {
throw "Failed to attach NAT Gateway '$natGatewayName' to subnet '$vmsubnetName' via Azure CLI."
}
$vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp
$vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1
}
}
if (-not $vmSubnet -or -not $vmSubnet.NatGateway -or -not $vmSubnet.NatGateway.Id) {
throw "NAT Gateway '$natGatewayName' is not associated with subnet '$vmsubnetName'. Outbound internet will fail."
}
write-host "NAT Gateway '$natGatewayName' attached to subnet '$vmsubnetname'." -ForegroundColor Green
}
$subnetId = ($vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1).Id;
#uncomment the below if you want to add a public IP address to the VM
#$pubip = New-AzPublicIpAddress -Force -Name ($pubIpPrefix + $resgrp) -ResourceGroupName $resgrp -Location $region -AllocationMethod Static -DomainNameLabel $domainNameLabel2;
#$pubip = Get-AzPublicIpAddress -Name ($pubIpPrefix + $resgrp) -ResourceGroupName $resgrp;
#$pubipId = $pubip.Id;
$nic = New-AzNetworkInterface -Force -Name ($nicPrefix) -ResourceGroupName $resgrp -Location $region -SubnetId $subnetId #-PublicIpAddressId $pubip.Id;
$nic = Get-AzNetworkInterface -Name ($nicPrefix) -ResourceGroupName $resgrp;
$nicId = $nic.Id;
$VirtualMachine = Add-AzVMNetworkInterface -VM $VirtualMachine -Id $nicId;
# Set VM SecurityType and connect to DES
if ($VMisLinux) {
$VirtualMachine = Set-AzVMOSDisk -VM $VirtualMachine -StorageAccountType "StandardSSD_LRS" -CreateOption "FromImage" -SecurityEncryptionType $secureEncryptGuestState -SecureVMDiskEncryptionSet $diskencset.Id -Linux;
} else {
$VirtualMachine = Set-AzVMOSDisk -VM $VirtualMachine -StorageAccountType "StandardSSD_LRS" -CreateOption "FromImage" -SecurityEncryptionType $secureEncryptGuestState -SecureVMDiskEncryptionSet $diskencset.Id;
}
$VirtualMachine = Set-AzVmSecurityProfile -VM $VirtualMachine -SecurityType $vmSecurityType;
$VirtualMachine = Set-AzVmUefi -VM $VirtualMachine -EnableVtpm $true -EnableSecureBoot $true;
$VirtualMachine = Set-AzVMBootDiagnostic -VM $VirtualMachine -disable #disable boot diagnostics, you can re-enable if required
try {
New-AzVM -ResourceGroupName $resgrp -Location $region -Vm $VirtualMachine -ErrorAction Stop
} catch {
throw "VM deployment failed for '$vmname' in '$region': $($_.Exception.Message)"
}
# ARM can briefly report the VM as missing or not yet running immediately after creation.
# Wait for the VM resource to become visible and the guest to reach a running state before
# proceeding to Bastion setup or in-guest attestation.
$vm = $null
$vmReady = $false
for ($vmAttempt = 1; $vmAttempt -le 20; $vmAttempt++) {
try {
$vm = Get-AzVm -ResourceGroupName $resgrp -Name $vmname -Status -ErrorAction Stop
$powerState = $vm.Statuses | Where-Object { $_.Code -like 'PowerState/*' } | Select-Object -First 1
if ($powerState -and $powerState.DisplayStatus -eq 'VM running') {
$vmReady = $true
break
}
} catch {
# Resource can lag briefly behind the successful create response.
}
if ($vmAttempt -lt 20) {
write-host "Waiting for VM '$vmname' to reach running state ($vmAttempt/20)..." -ForegroundColor DarkGray
Start-Sleep -Seconds 15
}
}
if (-not $vmReady) {
throw "VM '$vmname' was created but did not reach 'VM running' state in the expected time window."
}
# Create the Bastion to allow accessing the VM via the Azure portal (unless disabled)
if (-not $DisableBastion) {
write-host "VM created, now enabling Bastion for the VM"
$vnet = Get-AzVirtualNetwork -Name $vnetname -ResourceGroupName $resgrp
Add-AzVirtualNetworkSubnetConfig -Name "AzureBastionSubnet" -VirtualNetwork $vnet -AddressPrefix "10.0.99.0/26" | Set-AzVirtualNetwork # you can make this subnet anything you like as long as it fits into the vnet address space
$publicip = New-AzPublicIpAddress -ResourceGroupName $resgrp -name "VNet1-ip" -location $region -AllocationMethod Static -Sku Standard
New-AzBastion -ResourceGroupName $resgrp -Name $bastionname -PublicIpAddressRgName $resgrp -PublicIpAddressName $publicIp.Name -VirtualNetworkRgName $resgrp -VirtualNetworkName $vnetname -Sku "Basic"
} else {
write-host "VM created, Bastion creation skipped due to -DisableBastion parameter"
write-host "VM is only accessible via private network connectivity (VPN, ExpressRoute, or peered networks)"
}
#---------GPU mode: run the checksum-pinned Azure H100 onboarding release and GPU attestation--------------------
if ($GPU) {
function Invoke-GpuRunCommand {
param(
[Parameter(Mandatory)] [string] $Label,
[Parameter(Mandatory)] [string] $Script,
[Parameter(Mandatory)] [string] $SuccessMarker
)
for ($attempt = 1; $attempt -le 10; $attempt++) {
try {
write-host "$Label run-command attempt $attempt of 10..." -ForegroundColor Cyan
$result = Invoke-AzVMRunCommand -Name $vmname -ResourceGroupName $resgrp -CommandId 'RunShellScript' -ScriptString $Script -ErrorAction Stop
$text = ($result.Value | ForEach-Object { $_.Message }) -join "`n"
foreach ($entry in $result.Value) {
if ($entry.Message) { write-host $entry.Message }
}
if ($text -notmatch [regex]::Escape($SuccessMarker)) {
throw "$Label did not produce success marker '$SuccessMarker'."
}
return $text
} catch {
$message = $_.Exception.Message
$isTransient = $message -match 'Conflict|in progress|409|not ready|OperationPreempted|preempted|Canceled|ResourceNotFound|was not found'
if ($attempt -lt 10 -and $isTransient) {
write-host "$Label endpoint busy/not ready; waiting 45s..." -ForegroundColor Yellow
Start-Sleep -Seconds 45
continue
}
throw
}
}
}
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "GPU step 1/5: preparing the supported Azure FDE kernel with Azure CGPU onboarding V4.3.3..." -ForegroundColor Magenta
$gpuKernelScript = @'
#!/bin/bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
cloud-init status --wait 2>/dev/null || true
PACKAGE=/tmp/cgpu-onboarding-package.tar.gz
INSTALL_DIR=/opt/cgpu-onboarding
URL=https://github.com/Azure/az-cgpu-onboarding/releases/download/V4.3.3/cgpu-onboarding-package.tar.gz
SHA256=297a9ebbb2228a4ef26c0e9d3b7917a0d9e90bfb52bcb4713c50cd6a3658d8f3
curl -fsSL --retry 5 --retry-connrefused --retry-delay 10 "$URL" -o "$PACKAGE"
echo "$SHA256 $PACKAGE" | sha256sum --check --strict
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
tar -xzf "$PACKAGE" -C "$INSTALL_DIR"
cd "$INSTALL_DIR/cgpu-onboarding-package"
test -f step-0-prepare-kernel.sh
sed '/^[[:space:]]*sudo reboot[[:space:]]*$/d' step-0-prepare-kernel.sh > step-0-prepare-kernel-no-reboot.sh
bash ./step-0-prepare-kernel-no-reboot.sh --enable-snapshot 20260827T120000Z
echo "Running kernel before required reboot: $(uname -r)"
echo "Newest installed kernel: $(find /lib/modules -mindepth 1 -maxdepth 1 -printf '%f\n' | sort -V | tail -1)"
echo "GPU_KERNEL_PREPARED=1"
'@
Invoke-GpuRunCommand -Label 'GPU kernel preparation' -Script $gpuKernelScript -SuccessMarker 'GPU_KERNEL_PREPARED=1' | Out-Null
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "GPU step 2/5: rebooting into the supported kernel..." -ForegroundColor Magenta
Restart-AzVM -ResourceGroupName $resgrp -Name $vmname | Out-Null
Start-Sleep -Seconds 90
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "GPU step 3/5: installing the NVIDIA 595 open driver from Azure CGPU onboarding V4.3.3..." -ForegroundColor Magenta
$gpuDriverScript = @'
#!/bin/bash
set -euo pipefail
cd /opt/cgpu-onboarding/cgpu-onboarding-package
test -f step-1-install-gpu-driver.sh
echo "Running kernel: $(uname -r)"
printf '%s\n%s\n' '6.8.0-1025-azure' "$(uname -r)" | sort --check=quiet --version-sort
bash ./step-1-install-gpu-driver.sh
nvidia-smi
echo "GPU_DRIVER_INSTALLED=1"
'@
Invoke-GpuRunCommand -Label 'GPU driver installation' -Script $gpuDriverScript -SuccessMarker 'GPU_DRIVER_INSTALLED=1' | Out-Null
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "GPU step 4/5: rebooting after NVIDIA driver installation..." -ForegroundColor Magenta
Restart-AzVM -ResourceGroupName $resgrp -Name $vmname | Out-Null
Start-Sleep -Seconds 90
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "GPU step 5/5: validating H100 CC mode and running GPU attestation..." -ForegroundColor Magenta
$gpuAttestScript = @'
#!/bin/bash
set -euo pipefail
cd /opt/cgpu-onboarding/cgpu-onboarding-package
for attempt in $(seq 1 30); do
if nvidia-smi >/dev/null 2>&1; then break; fi
if [ "$attempt" -eq 30 ]; then
echo "ERROR: nvidia-smi did not become ready."
journalctl -b 2>/dev/null | grep -iE 'nvidia|secure|pci' | tail -100 || true
exit 1
fi
sleep 10
done
echo "GPU_KERNEL=$(uname -r)"
nvidia-smi
CC_STATUS=$(nvidia-smi conf-compute -f)
CC_ENVIRONMENT=$(nvidia-smi conf-compute -e)
echo "$CC_STATUS"
echo "$CC_ENVIRONMENT"
test "$CC_STATUS" = 'CC status: ON'
test "$CC_ENVIRONMENT" = 'CC Environment: PRODUCTION'
set +e
bash ./step-2-attestation.sh --gpu-only 2>&1 | tee /tmp/gpu-attestation.log
ATTEST_EXIT=${PIPESTATUS[0]}
set -e
test "$ATTEST_EXIT" -eq 0
grep -F 'GPU Attestation is Successful.' /tmp/gpu-attestation.log
echo "$CC_STATUS"
echo "$CC_ENVIRONMENT"
echo "GPU_ATTEST_EXIT=0"
'@
$gpuOutputText = Invoke-GpuRunCommand -Label 'GPU attestation' -Script $gpuAttestScript -SuccessMarker 'GPU_ATTEST_EXIT=0'
foreach ($requiredGpuEvidence in @('CC status: ON', 'CC Environment: PRODUCTION', 'GPU Attestation is Successful.')) {
if ($gpuOutputText -notmatch [regex]::Escape($requiredGpuEvidence)) {
throw "GPU attestation output did not contain '$requiredGpuEvidence'."
}
}
write-host "----------------------------------------------------------------------------------------------------------------"
}
#---------Do attestation check inside the VM using Azure/cvm-attestation-tools-----------------------------------
# Downloads the latest pre-built attest CLI release from https://github.com/Azure/cvm-attestation-tools/releases
# and runs it inside the freshly deployed CVM, returning the output to the caller.
if ($NoInternetAccess) {
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "Skipping in-VM attestation because -NoInternetAccess blocks outbound internet access required to download cvm-attestation-tools." -ForegroundColor Yellow
write-host "Build complete (attestation skipped due to -NoInternetAccess)." -ForegroundColor Green
}
else {
# Pick the right config based on the VM SKU's isolation type:
# AMD SEV-SNP: DCa*/DCad*/ECa*/ECad* (e.g. Standard_DC2as_v5) -> config_snp.json
# Intel TDX : DCe*/DCed*/ECe*/ECed* (e.g. Standard_DC2es_v5) -> config_tdx.json
if ($vmSize -match '_(DC|EC)\d+e[a-z]*_') {
$attestConfig = "config_tdx.json"
$isolationType = "Intel TDX"
} else {
$attestConfig = "config_snp.json"
$isolationType = "AMD SEV-SNP"
}
write-host "----------------------------------------------------------------------------------------------------------------"
write-host "Running attestation inside the $osType VM using cvm-attestation-tools (isolation: $isolationType, config: $attestConfig)..." -ForegroundColor Cyan
write-host "This downloads the latest release of attest from https://github.com/Azure/cvm-attestation-tools/releases inside the VM."
if ($VMisLinux) {
# Linux: download attest-lin.zip from the latest release, extract, run attest
# Note: the zip extracts files at its root (no "attest-lin/" subfolder)
$attestScript = @"
#!/bin/bash
set -e
export DEBIAN_FRONTEND=noninteractive
if ! command -v jq >/dev/null 2>&1; then
(apt-get update -y && apt-get install -y jq) >/dev/null 2>&1 || \
(dnf install -y jq || yum install -y jq) >/dev/null 2>&1
fi
if ! command -v unzip >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1 && ! command -v bsdtar >/dev/null 2>&1; then
(apt-get update -y && apt-get install -y unzip) >/dev/null 2>&1 || \
(dnf install -y unzip || yum install -y unzip) >/dev/null 2>&1
fi
WORKDIR=`$(mktemp -d)
cd "`$WORKDIR"
echo "Downloading latest attest-lin.zip from cvm-attestation-tools..."
URLS="https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-lin.zip"
# Try to discover a concrete asset URL via GitHub API as a fallback.
API_URL=`$(curl -fsSL https://api.github.com/repos/Azure/cvm-attestation-tools/releases/latest 2>/dev/null \
| tr -d '\n' \
| sed -n 's/.*"browser_download_url":"\\([^"]*attest-lin.zip\\)".*/\\1/p' \
| sed 's#\\/#/#g')
if [ -n "`$API_URL" ]; then
URLS="`$URLS `$API_URL"
fi
DOWNLOADED=0
for URL in `$URLS; do
for TRY in 1 2 3; do
echo "Download attempt `$TRY/3: `$URL"
if curl -fsSL -o attest-lin.zip "`$URL"; then
if [ -s attest-lin.zip ]; then
DOWNLOADED=1
break
fi
echo "Downloaded file is empty, retrying..."
else
echo "Download failed on attempt `$TRY for `$URL"
fi
[ `$TRY -lt 3 ] && sleep 10
done
[ `$DOWNLOADED -eq 1 ] && break
done
if [ `$DOWNLOADED -ne 1 ]; then
echo "Failed to download attest-lin.zip after retries. Ensure outbound internet to github.com and objects.githubusercontent.com is allowed from the CVM subnet."
exit 1
fi
if command -v unzip >/dev/null 2>&1; then
unzip -q attest-lin.zip
elif command -v python3 >/dev/null 2>&1; then
python3 - <<'PY'
import zipfile
zipfile.ZipFile('attest-lin.zip').extractall('.')
PY
elif command -v bsdtar >/dev/null 2>&1; then
bsdtar -xf attest-lin.zip
else
echo "No zip extractor available (requires unzip, python3, or bsdtar)."
exit 1
fi
chmod +x attest read_report 2>/dev/null || true
echo "--------- attest --c $attestConfig ---------"
./attest --c $attestConfig 2>&1 | tee attest.out
ATTEST_EXIT=`${PIPESTATUS[0]}
if [ `$ATTEST_EXIT -ne 0 ]; then
echo "attest exited with code `$ATTEST_EXIT"
exit `$ATTEST_EXIT
fi
# Extract JWT (a single token of the form xxx.yyy.zzz with base64url chars)
# from the attest output and pretty-print header + payload claims using jq.
JWT=`$(grep -Eo '[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' attest.out | awk '{ print length, `$0 }' | sort -nr | head -1 | cut -d' ' -f2-)
if [ -n "`$JWT" ] && command -v jq >/dev/null 2>&1; then
echo ""
echo "--------- Decoded JWT (via jq) ---------"
b64d() { local s=`$1; local m=`$(( `${#s} % 4 )); if [ `$m -eq 2 ]; then s="`${s}=="; elif [ `$m -eq 3 ]; then s="`${s}="; fi; echo "`$s" | tr '_-' '/+' | base64 -d 2>/dev/null; }
H=`$(echo "`$JWT" | cut -d. -f1)
P=`$(echo "`$JWT" | cut -d. -f2)
echo "--- header ---"
b64d "`$H" | jq .
echo "--- payload ---"
b64d "`$P" | jq .
echo "--- key MAA claims ---"
b64d "`$P" | jq '{iss, "x-ms-attestation-type", "x-ms-compliance-status", "x-ms-isolation-tee": ."x-ms-isolation-tee"."x-ms-attestation-type", "x-ms-runtime-vm-configuration-secure-boot": ."x-ms-runtime"."vm-configuration"."secure-boot", "x-ms-runtime-vm-configuration-tpm-enabled": ."x-ms-runtime"."vm-configuration"."tpm-enabled"}'
COMPLIANCE=`$(b64d "`$P" | jq -r '."x-ms-compliance-status"')
if [ "`$COMPLIANCE" != "azure-compliant-cvm" ]; then
echo "ERROR: unexpected x-ms-compliance-status: `$COMPLIANCE"
exit 1
fi
echo "CPU_ATTEST_COMPLIANCE=azure-compliant-cvm"
else
echo "(no JWT found in attest output to decode, or jq unavailable)"
exit 1
fi
cd /
rm -rf "`$WORKDIR"
"@
$runCommandId = 'RunShellScript'
} else {
# Windows: download attest-win.zip from the latest release, extract, run attest.exe
# Note: the zip extracts files at its root (no "attest-win/" subfolder)
$attestScript = @"
`$ErrorActionPreference = 'Stop'
`$ProgressPreference = 'SilentlyContinue'
`$work = Join-Path `$env:TEMP "cvm-attest-`$(Get-Random)"
New-Item -ItemType Directory -Path `$work -Force | Out-Null
Set-Location `$work
Write-Host "Downloading latest attest-win.zip from cvm-attestation-tools..."
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Run-command runs as SYSTEM, which has no IE/WinINet proxy config; Invoke-WebRequest then
# fails with "Unable to connect to the remote server" even though outbound is fine.
# Prefer curl.exe (in-box on Win10/11/Server2019+, doesn't use WinINet); fall back to
# Invoke-WebRequest with the default proxy explicitly cleared.
`$attestUrl = 'https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-win.zip'
`$dlOk = `$false
`$curl = Get-Command curl.exe -ErrorAction SilentlyContinue
# Wait for outbound to github.com:443 to come up before the real download. A freshly-attached
# NAT Gateway on the VM subnet can take a couple of minutes to become effective, during which
# every connection out of the VM fails with TCP connect timeout (curl exit 28). Probe with a
# 5s timeout per attempt and back off; total ~5 minutes.
Write-Host "Waiting for outbound connectivity to github.com:443..."
for (`$i = 1; `$i -le 30; `$i++) {
`$ok = `$false
if (`$curl) {
try {
`$prevProbeEap = `$ErrorActionPreference
`$ErrorActionPreference = 'Continue'
if (`$PSVersionTable.PSVersion.Major -ge 7) { `$PSNativeCommandUseErrorActionPreference = `$false }
& `$curl.Source -fsS --max-time 5 -o NUL https://github.com 2>`$null | Out-Null
if (`$LASTEXITCODE -eq 0) { `$ok = `$true }
} catch {
`$ok = `$false
} finally {
`$ErrorActionPreference = `$prevProbeEap
}
} else {
try {
`$tnc = Test-NetConnection -ComputerName 'github.com' -Port 443 -WarningAction SilentlyContinue
if (`$tnc.TcpTestSucceeded) { `$ok = `$true }
} catch { `$ok = `$false }
}
if (`$ok) { Write-Host "Outbound to github.com is up (attempt `$i)." -ForegroundColor Green; break }
if (`$i -eq 30) {
Write-Host "WARNING: github.com still unreachable after 5 minutes; attempting download anyway." -ForegroundColor Yellow
} else {
Write-Host " attempt `${i}: outbound not yet ready, sleeping 10s..." -ForegroundColor DarkGray
Start-Sleep -Seconds 10
}
}
if (`$curl) {
try {
`$prevCurlEap = `$ErrorActionPreference
`$ErrorActionPreference = 'Continue'
if (`$PSVersionTable.PSVersion.Major -ge 7) { `$PSNativeCommandUseErrorActionPreference = `$false }
& `$curl.Source -fsSL --retry 5 --retry-connrefused --retry-delay 5 -o 'attest-win.zip' `$attestUrl 2>`$null | Out-Null
if (`$LASTEXITCODE -eq 0 -and (Test-Path 'attest-win.zip') -and ((Get-Item 'attest-win.zip').Length -gt 0)) { `$dlOk = `$true }
else { Write-Host "curl.exe download failed (exit `$LASTEXITCODE); falling back to Invoke-WebRequest..." -ForegroundColor Yellow }
} catch {
Write-Host "curl.exe threw an exception; falling back to Invoke-WebRequest..." -ForegroundColor Yellow
} finally {
`$ErrorActionPreference = `$prevCurlEap
}
}
if (-not `$dlOk) {
[System.Net.WebRequest]::DefaultWebProxy = New-Object System.Net.WebProxy
for (`$i = 1; `$i -le 5 -and -not `$dlOk; `$i++) {
try {
Invoke-WebRequest -Uri `$attestUrl -OutFile 'attest-win.zip' -UseBasicParsing
`$dlOk = `$true
} catch {
Write-Host "Invoke-WebRequest attempt `$i failed: `$(`$_.Exception.Message)" -ForegroundColor Yellow
if (`$i -lt 5) { Start-Sleep -Seconds 10 }
}
}
}
if (-not `$dlOk) { throw "Failed to download attest-win.zip from `$attestUrl" }
Expand-Archive -Path 'attest-win.zip' -DestinationPath '.' -Force
Write-Host "--------- attest.exe --c $attestConfig ---------"
# attest.exe writes INFO logs to stderr; under `$ErrorActionPreference='Stop' that surfaces
# as a NativeCommandError even though the tool is working. Relax EAP for this single call,
# merge stderr into stdout, and rely on `$LASTEXITCODE for success/failure.
# Use a wide -Width on Out-String so the JWT (which can be ~1.5KB on a single line)
# is not wrapped at the default ~80-char console width - otherwise the regex below only
# matches a wrapped fragment and base64url decoding fails with "Invalid length".
`$prevEap = `$ErrorActionPreference
`$ErrorActionPreference = 'Continue'
if (`$PSVersionTable.PSVersion.Major -ge 7) { `$PSNativeCommandUseErrorActionPreference = `$false }
# Retry attest.exe a few times: the attestation provider's internal retry budget is short,
# and on a brand-new VM the SNAT mapping to the MAA endpoint can take a minute or two to
# warm up after NAT GW attach, surfacing as ConnectTimeoutError to *.attest.azure.net even
# though github.com is already reachable by the time we finish downloading.
`$attestOut = ''
`$attestExit = -1
for (`$i = 1; `$i -le 5; `$i++) {
`$attestOut = (& .\attest.exe --c $attestConfig 2>&1 | Out-String -Width 16384)
`$attestExit = `$LASTEXITCODE
if (`$attestExit -eq 0) { break }
if (`$i -lt 5) {
Write-Host "attest.exe attempt `$i exited with code `$attestExit; sleeping 30s before retry..." -ForegroundColor Yellow
Start-Sleep -Seconds 30
}
}
`$ErrorActionPreference = `$prevEap
Write-Host `$attestOut
if (`$attestExit -ne 0) { Write-Host "attest.exe exited with code `$attestExit" -ForegroundColor Yellow }
# Extract JWT (xxx.yyy.zzz, base64url) from the attest output and pretty-print
# header + payload claims using built-in PowerShell JSON support (no jq needed).
# Defensive: collapse the captured output to a single line so any stray CR/LF inside
# the token is removed, then anchor the regex to 'eyJ' (base64url of '{"') which is
# the canonical JWT header start. Without that anchor, the greedy 3-segment match
# can cross attest.exe's interleaved Python INFO log lines (which share '-' / '_'
# with base64url) and pick up a corrupted token, producing garbage on decode.
`$attestOutFlat = (`$attestOut -replace '\s+', '')
`$jwtMatch = [regex]::Matches(`$attestOutFlat, 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{100,}\.[A-Za-z0-9_-]{50,}') | Sort-Object { `$_.Length } -Descending | Select-Object -First 1
if (`$jwtMatch) {
function ConvertFrom-Base64Url(`$s) {
`$s = (`$s -replace '\s', '').Replace('-', '+').Replace('_', '/')
switch (`$s.Length % 4) { 2 { `$s += '==' } 3 { `$s += '=' } }
[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(`$s))
}
`$parts = `$jwtMatch.Value.Split('.')
Write-Host ""
Write-Host '--------- Decoded JWT ---------'
Write-Host '--- header ---'
ConvertFrom-Base64Url `$parts[0] | ConvertFrom-Json | ConvertTo-Json -Depth 10
Write-Host '--- payload ---'
`$payload = ConvertFrom-Base64Url `$parts[1] | ConvertFrom-Json
`$payload | ConvertTo-Json -Depth 10
Write-Host '--- key MAA claims ---'
[pscustomobject]@{
iss = `$payload.iss
'x-ms-attestation-type' = `$payload.'x-ms-attestation-type'
'x-ms-compliance-status' = `$payload.'x-ms-compliance-status'
'x-ms-isolation-tee' = `$payload.'x-ms-isolation-tee'.'x-ms-attestation-type'
'secure-boot' = `$payload.'x-ms-runtime'.'vm-configuration'.'secure-boot'
'tpm-enabled' = `$payload.'x-ms-runtime'.'vm-configuration'.'tpm-enabled'
} | Format-List
} else {
Write-Host '(no JWT found in attest output to decode)'
}
Set-Location `$env:TEMP
Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue
"@
$runCommandId = 'RunPowerShellScript'
}
# Retry loop: Invoke-AzVMRunCommand can return 409 Conflict for several minutes
# after VM creation while the run-command extension is still finalising. This is
# especially common on TDX SKUs. Back off and retry on Conflict / "in progress".
$output = $null
$maxAttempts = 10
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
write-host "Attestation run-command attempt $attempt of $maxAttempts..." -ForegroundColor Cyan
$output = Invoke-AzVMRunCommand -Name $vmname -ResourceGroupName $resgrp -CommandId $runCommandId -ScriptString $attestScript -ErrorAction Stop
break
} catch {
$msg = $_.Exception.Message
if ($attempt -lt $maxAttempts -and ($msg -like '*Conflict*' -or $msg -like '*in progress*' -or $msg -like '*409*' -or $msg -like '*OperationPreempted*' -or $msg -like '*preempted*' -or $msg -like '*Canceled*')) {
write-host "Run-command extension busy (409); waiting 60s before retry..." -ForegroundColor Yellow