Azure - Capture AzureRM Virtual Machine Image

Octopus.AzurePowerShell exported 2017-05-29 by paulmarsy belongs to ‘Azure’ category.

Prepares an AzureRM Virtual Machine (Managed Disk or Storage Account based) and captures a Managed Image or Image VHD:

  1. Runs Sysprep
  2. Deallocates & Generalizes VM
  3. Creates Managed Image or Image VHD
  4. Removes virtual machine resource group

Parameters

When steps based on the template are included in a project’s deployment process, the parameters below can be set.

Octopus Azure Account

StepTemplate_Account =

Select the account id to use for the connection.

Resource Group Name

StepTemplate_ResourceGroupName =

Name of the Azure Resource Group containing the Virtual Machine.

Virtual Machine Name

StepTemplate_VMName =

The name of the AzureRM Virtual Machine to capture. This VM will be shut down & generalized.

Image Type

StepTemplate_ImageType = managed

Desired type of image to capture from the Virtual Machine.

Image Destination

StepTemplate_ImageDest =

Where the image should be created.

Managed Images should enter a Resource Group name

Storage Account VHDs should enter a Storage Account name

Image Name

StepTemplate_ImageName =

Name to use when creating the image.

Delete VM Resource Group?

StepTemplate_DeleteVMResourceGroup = True

Delete the virtual machine resource group after an image has been captured.

Once a Virtual Machine is marked as generalized Azure will prevent it from being started or modified.

Script body

Steps based on this template will execute the following undefined script.

<#
 ----- Capture AzureRM Virtual Machine Image ----- 
    Paul Marston @paulmarsy (paul@marston.me)
Links
    https://github.com/OctopusDeploy/Library/commits/master/step-templates/azure-capture-virtualmachine-image.json
    
The sequence of steps performed by the step template:
    1) Virtual Machine prep
        a) PowerState/running - Custom script extension is used to sysprep & shutdown
        b) PowerState/stopped - only when the VM is shutdown by the OS, if Azure stops the VM it is automatically deallocated
        c) PowerState/deallocated
        d) OSState/generalized
    2) Image capture
        - Managed VM & Managed Image - New image with VM as source
        - Managed VM & Unmanaged VHD - Access to the underlying blob is granted, and the VHD copied into the specified storage account
        - Unmanaged VM & Managed Image - New image with VM as source
        - Unmanaged VM & Unmanaged VHD - VM image is saved, a SAS token is generated and it is copied from the VM's storage account into the specified storage account
    3) Virtual machine cleanup.
        Once a VM has been marked as 'generalized' Azure will no longer allow it to be started up, making the VM unusable
        If the delete option is selected, and the image just created has been moved outside the VM's resource group 
        
----- Advanced Configuration Settings -----
Variable names can use either of the following two formats: 
    Octopus.Action.<Setting Name> - will apply to all steps in the deployment, e.g.
        Octopus.Action.DebugLogging
    Octopus.Action[Step Name].<Setting Name> - will apply to 'step name' alone, e.g.
        Octopus.Action[Capture Web VM Image].StorageAccountKey

Available Settings:
    VhdDestContainer - overrides the default container that an unmanaged VHD image is copied to, default is 'images'
    StorageAccountKey - allows copying to a storage account in a different subscription by using the providing the key, default is null
#>
#Requires -Modules AzureRM.Resources
#Requires -Modules AzureRM.Compute
#Requires -Modules AzureRM.Storage
#Requires -Modules Azure.Storage

$ErrorActionPreference = 'Stop'

<#---------- SysPrep Script - Begin  ----------#>
<#
    Sysprep marker file: C:\WindowsAzure\sysprep
    1) If marker file exists, sysprep has already been run so exit script
    2) Start a new powershell process and exit with code 0, this allows the custom script extension to report back as having run successfully to Azure
        a) In the child script wait until the successful exit code has been logged
        b) Create the marker file
        c) Run sysprep
#>
$SysPrepScript = @'
if (Test-Path "${env:SystemDrive}\WindowsAzure\sysprep") { return }

Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NonInteractive','-NoProfile',('-EncodedCommand {0}' -f ([System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes({
    do {
        Start-Sleep -Seconds 1
        $status = Get-ChildItem "${env:SystemDrive}\Packages\Plugins\Microsoft.Compute.CustomScriptExtension\*\Status\" -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content | ConvertFrom-Json
    } while ($status[0].status.code -ne 0)
    New-Item -ItemType File -Path "${env:SystemDrive}\WindowsAzure\sysprep" -Force | Out-Null
    & (Join-Path -Resolve ([System.Environment]::SystemDirectory) 'sysprep\sysprep.exe') /oobe /generalize /quiet /shutdown
}.ToString())))))

exit 0
'@
<#---------- SysPrep Script - End ----------#>

function Get-OctopusSetting {
    param([Parameter(Position = 0, Mandatory)][string]$Name, [Parameter(Position = 1)]$DefaultValue)
    $formattedName = 'Octopus.Action.{0}' -f $Name
    if ($OctopusParameters.ContainsKey($formattedName)) {
        $value = $OctopusParameters[$formattedName]
        if ($DefaultValue -is [int]) { return ([int]::Parse($value)) }
        if ($DefaultValue -is [bool]) { return ([System.Convert]::ToBoolean($value)) }
        if ($DefaultValue -is [array] -or $DefaultValue -is [hashtable] -or $DefaultValue -is [pscustomobject]) { return (ConvertFrom-Json -InputObject $value) }
        return $value
    }
    else { return $DefaultValue }
}
function Test-String {
    param([Parameter(Position=0)]$InputObject,[switch]$ForAbsence)

    $hasNoValue = [System.String]::IsNullOrWhiteSpace($InputObject)
    if ($ForAbsence) { $hasNoValue }
    else { -not $hasNoValue }
}
filter Out-Verbose {
    Write-Verbose ($_ | Out-String)
}
function Split-BlobUri {
    param($Uri)
    $uriRegex = [regex]::Match($Uri, '(?>https:\/\/)(?<Account>[a-z0-9]{3,24})\.blob\.core\.windows\.net\/(?<Container>[-a-z0-9]{3,63})\/(?<Blob>.+)')
    if (!$uriRegex.Success) {
        throw "Unable to parse blob uri: $Uri"
    }
    [pscustomobject]@{
        Account = $uriRegex.Groups['Account'].Value
        Container = $uriRegex.Groups['Container'].Value
        Blob = $uriRegex.Groups['Blob'].Value
    }
}
function Get-AzureRmAccessToken {
    # https://github.com/paulmarsy/AzureRest/blob/master/Internals/Get-AzureRmAccessToken.ps1
    $accessToken = Invoke-RestMethod -UseBasicParsing -Uri ('https://login.microsoftonline.com/{0}/oauth2/token?api-version=1.0' -f $OctopusAzureADTenantId) -Method Post -Body @{"grant_type" = "client_credentials"; "resource" = "https://management.core.windows.net/"; "client_id" = $OctopusAzureADClientId; "client_secret" = $OctopusAzureADPassword }
    [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $accessToken.access_token).ToString()
}
function Get-TemporarySasBlob {
    param($BlobName)
    # https://github.com/paulmarsy/AzureRest/blob/master/Exported/New-AzureBlob.ps1
    $sasToken = Invoke-RestMethod -UseBasicParsing -Uri 'https://mscompute2.iaas.ext.azure.com/api/Compute/VmExtensions/GetTemporarySas/' -Headers @{
        [Microsoft.WindowsAzure.Commands.Common.ApiConstants]::AuthorizationHeaderName = (Get-AzureRmAccessToken)
    }
    $containerSas = [uri]::new($sasToken)
    $container = [Microsoft.WindowsAzure.Storage.Blob.CloudBlobContainer]::new($containerSas)
    $blobRef = $container.GetBlockBlobReference($BlobName)
    
    [psobject]@{
        Blob = $blobRef
        Uri = [uri]::new($blobRef.Uri.AbsoluteUri + $containerSas.Query)
    }
}

'Checking AzureRM Modules...' | Out-Verbose
Get-Module | ? Name -like 'AzureRM.*' | Format-Table -AutoSize -Property Name,Version | Out-String | Out-Verbose
if ((Get-Module AzureRM.Compute | % Version) -lt '2.6.0') {
    $bundledErrorMessage = if ([System.Convert]::ToBoolean($OctopusUseBundledAzureModules)) {
        'The Azure PowerShell Modules bundled with Octopus have been loaded. To use the version installed on the server create a variable named "Octopus.Action.Azure.UseBundledAzurePowerShellModules" and set its value to "False".'
    }
    throw "${bundledErrorMessage}Please ensure version 2.6.0 or newer of the AzureRM.Compute module has been installed. The module can be installed with the PowerShell command: Install-Module AzureRM.Compute -MinimumVersion 2.6.0"
}

$vm = Get-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -WarningAction SilentlyContinue
if ($null -eq $vm) {
    throw "Unable to find virtual machine '$StepTemplate_VMName' in resource group '$StepTemplate_ResourceGroupName'"
}
Write-Host "Image will be captured from Virtual Machine '$($vm.Name)' in resource group '$($vm.ResourceGroupName)'"
if (Test-String $StepTemplate_ImageDest -ForAbsence) {
    throw "The Image Destination parameter is required"
}
$StepTemplate_ImageStorageContext = if ($StepTemplate_ImageType -eq 'unmanaged') {
    $storageAccountKey = Get-OctopusSetting StorageAccountKey $null
    if (Test-String $storageAccountKey) {
        Write-Host "Image will be copied to storage account context '$StepTemplate_ImageDest' using provided key"
        New-AzureStorageContext -StorageAccountName $StepTemplate_ImageDest -StorageAccountKey $storageAccountKey
    } else {
        $storageAccountResource = Find-AzureRmResource -ResourceNameEquals $StepTemplate_ImageDest -ResourceType Microsoft.Storage/storageAccounts
        if ($storageAccountResource) {
            Write-Host "Image will be copied to storage account '$($storageAccountResource.Name)' found in resource group '$($storageAccountResource.ResourceGroupName)'"
        } else {
            throw "Unable to find storage account '$StepTemplate_ImageDest'"
        }
        Get-AzureRmStorageAccount -ResourceGroupName $storageAccountResource.ResourceGroupName -Name $storageAccountResource.Name | % Context
    }
}
$StepTemplate_ImageResourceGroupName = switch ($StepTemplate_ImageType) {
    'managed' {
        $resourceGroup = Get-AzureRmResourceGroup -Name $StepTemplate_ImageDest | % ResourceGroupName 
        Write-Host "Managed Image will be created in resource group '$resourceGroup'"
        $resourceGroup
    }
    'unmanaged' { Find-AzureRmResource -ResourceNameEquals $StepTemplate_ImageDest -ResourceType Microsoft.Storage/storageAccounts | % ResourceGroupName }
}
if ($StepTemplate_ImageResourceGroupName -ieq $StepTemplate_ResourceGroupName -and $StepTemplate_DeleteVMResourceGroup -ieq 'True') {
    throw "You have chosen to delete the virtual machine and it's resource group ($StepTemplate_ResourceGroupName), however this resource group is also where the captured image will be created!"
}

Write-Host ('-'*80)
Write-Host "Preparing virtual machine $($vm.Name) for image capture..."

$sysprepRun = $false
while ($true) {
    $statusCode = Get-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -Status -WarningAction SilentlyContinue | % Statuses | % Code
    if ($statusCode -contains 'OSState/generalized') {
        Write-Host 'VM is deallocated & generalized, proceeding to image capture...'
        break
    }
    if ($statusCode -contains 'PowerState/deallocated') {
        Write-Host 'VM has been deallocated, setting state to generalized... '
        Set-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -Generalized | Out-Verbose
        continue
    }
    if ($statusCode -contains 'PowerState/deallocating') {
        Write-Host 'VM is deallocating, waiting...'
        Start-Sleep 30
        continue
    }
    if ($statusCode -contains 'PowerState/stopped') {
        Write-Host 'VM has been shutdown, starting deallocation...'
        Stop-AzureRmVm -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -Force | Out-Verbose
        continue
    }
    if ($statusCode -contains 'PowerState/stopping') {
        Write-Host 'VM is stopping, waiting...'
        Start-Sleep 30
        continue
    }
    if ($statusCode -contains 'PowerState/running' -and $sysprepRun) {
        Write-Host 'VM is running, but sysprep already deployed, waiting...'
        Start-Sleep 30
        continue
    }
    if ($statusCode -contains 'PowerState/running') {
        Write-Host 'VM is running, performing sysprep...'
        $existingCustomScriptExtensionName = $vm.Extensions | ? VirtualMachineExtensionType -eq 'CustomScriptExtension' | % Name
        if ($existingCustomScriptExtensionName) {
            Write-Warning "Removing existing CustomScriptExtension ($existingCustomScriptExtensionName)..."
            Remove-AzureRmVMCustomScriptExtension -ResourceGroupName $StepTemplate_ResourceGroupName -VMName $StepTemplate_VMName -Name $existingCustomScriptExtensionName -Force | Out-Verbose
        }
        
        Write-Host 'Uploading sysprep script to blob storage...'
        $sysprepScriptFileName = 'Sysprep.ps1'
        $sysprepScriptBlob = Get-TemporarySasBlob $sysprepScriptFileName
        $sysprepScriptBlob.Blob.UploadText($SysPrepScript)
    
        Write-Host 'Deploying sysprep custom script extension...'
        Set-AzureRmVMCustomScriptExtension -ResourceGroupName $StepTemplate_ResourceGroupName -VMName $StepTemplate_VMName -Name 'Sysprep' -Location $vm.Location -FileUri $sysprepScriptBlob.Uri -Run $sysprepScriptFileName -ForceRerun (Get-Date).Ticks | Out-Verbose
        $sysprepRun = $true
        continue
    }
    Write-Warning "VM is in an unknown state. Current status codes: $($statusCode -join ', '). Waiting..."
    Start-Sleep -Seconds 30
}

Write-Host ('-'*80)

Write-Host 'Retrieving virtual machine disk configuration...'
$vm = Get-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -WarningAction SilentlyContinue 
$isManagedVm = $null -ne $vm.StorageProfile.OsDisk.ManagedDisk
if ($isManagedVm) { Write-Host "Virtual machine $($vm.Name) is using Managed Disks" }
$isUnmanagedVm = $null -ne $vm.StorageProfile.OsDisk.Vhd
if ($isUnmanagedVm) { Write-Host "Virtual machine $($vm.Name) is using unmanaged storage account VHDs" }

if ($StepTemplate_ImageType -eq 'managed') {
    Write-Host "Creating Managed Image of $($vm.Name)..."
    $image = New-AzureRmImageConfig -Location $vm.Location -SourceVirtualMachineId $vm.Id
    New-AzureRmImage -Image $image -ImageName $StepTemplate_ImageName -ResourceGroupName $StepTemplate_ImageResourceGroupName | Out-Verbose
    Write-Host 'Image created:'
    Get-AzureRmImage -ImageName $StepTemplate_ImageName -ResourceGroupName $StepTemplate_ImageResourceGroupName | Out-Host
}

if ($StepTemplate_ImageType -eq 'unmanaged') {
    if ($isManagedVm) {
        Write-Host "Granting access to os disk ($($vm.StorageProfile.OsDisk.Name)) blob..."
        $manageDisk = Grant-AzureRmDiskAccess -ResourceGroupName $StepTemplate_ResourceGroupName -DiskName $vm.StorageProfile.OsDisk.Name -DurationInSecond 3600 -Access Read 
        $vhdSasUri = $manageDisk.AccessSAS
    }
    if ($isUnmanagedVm) {
        Write-Host "Saving Unmanaged Image of $($vm.Name)..."
        $armTemplatePath = [System.IO.Path]::GetTempFileName()
        $vhdDestContainer = Get-OctopusSetting VhdDestContainer 'images'
        Save-AzureRmVMImage -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -DestinationContainerName $vhdDestContainer -VHDNamePrefix $StepTemplate_ImageName -Overwrite -Path $armTemplatePath | Out-Verbose
        $armTemplate = Get-Content -Path $armTemplatePath
        "VM Image ARM Template:`n$armTemplate" | Out-Verbose
        Remove-Item $armTemplatePath -Force
        $osDiskUri = ($armTemplate | ConvertFrom-Json).resources.properties.storageprofile.osdisk.image.uri
        "OS Disk Image URI: $osDiskUri" | Out-Verbose
        $unmanagedVhd = Split-BlobUri $osDiskUri
        
        Write-Host "Granting access to vhd image ($($unmanagedVhd.Blob))..."
        $unmanagedVhdStorageResource = Find-AzureRmResource -ResourceNameEquals $unmanagedVhd.Account -ResourceType Microsoft.Storage/storageAccounts
        $unmanagedVhdStorageResource | Out-Verbose
        $unmanagedVhdStorageContext = Get-AzureRmStorageAccount -ResourceGroupName $unmanagedVhdStorageResource.ResourceGroupName -Name $unmanagedVhdStorageResource.Name | % Context
        $vhdSasUri = New-AzureStorageBlobSASToken -Container $unmanagedVhd.Container -Blob $unmanagedVhd.Blob -Permission r -ExpiryTime (Get-Date).AddHours(1) -FullUri -Context $unmanagedVhdStorageContext
    }
    Write-Host "Source image SAS token created: $vhdSasUri"

    Write-Host 'Copying image to storage account...'
    $destContainerName = Get-OctopusSetting VhdDestContainer 'images'
    $destContainer = Get-AzureStorageContainer -Name $destContainerName -Context $StepTemplate_ImageStorageContext -ErrorAction SilentlyContinue
    if ($destContainer) {
        Write-Host "Using container '$destContainerName' in storage account $StepTemplate_ImageDest..."
    } else {
        Write-Host "Creating container '$destContainerName' in storage account $StepTemplate_ImageDest..."
        $destContainer = New-AzureStorageContainer -Name $destContainerName -Context $StepTemplate_ImageStorageContext -Permission Off
    }

    $copyBlob = Start-AzureStorageBlobCopy -AbsoluteUri $vhdSasUri -DestContainer $destContainerName -DestContext $StepTemplate_ImageStorageContext -DestBlob $StepTemplate_ImageName -Force
    $copyBlob | Out-Verbose
    do {   
        if ($copyState.Status -eq 'Pending') {
            Start-Sleep -Seconds 60
        }
        $copyState = $copyBlob | Get-AzureStorageBlobCopyState
        $copyState | Out-Verbose
        $percent = ($copyState.BytesCopied / $copyState.TotalBytes) * 100
        Write-Host "Blob transfer $($copyState.Status.ToString().ToLower())... $('{0:N2}' -f $percent)% @ $([System.Math]::Round($copyState.BytesCopied/1GB, 2))GB / $([System.Math]::Round($copyState.TotalBytes/1GB, 2))GB"
    } while ($copyState.Status -eq 'Pending')
    Write-Host "Final image transfer status: $($copyState.Status)"
    
    if ($isManagedVm) {
        Write-Host 'Revoking access to os disk blob...'
        Revoke-AzureRmDiskAccess -ResourceGroupName $StepTemplate_ResourceGroupName -DiskName $vm.StorageProfile.OsDisk.Name | Out-Verbose
    }
}

Write-Host "Image of $($vm.Name) captured successfully!"

if ($StepTemplate_DeleteVMResourceGroup -ieq 'True') {
    Write-Host ('-'*80)
    Write-Host "Removing $($vm.Name) VM's resource group $StepTemplate_ResourceGroupName, the following resources will be deleted..."
    Find-AzureRmResource -ResourceGroupNameEquals $StepTemplate_ResourceGroupName | Sort-Object -Property ResourceId -Descending | Select-Object -Property ResourceGroupName,ResourceType,ResourceName | Format-Table -AutoSize | Out-Host
    Remove-AzureRmResourceGroup -Name $StepTemplate_ResourceGroupName -Force | Out-Verbose
}

Provided under the Apache License version 2.0.

Report an issue

To use this template in Octopus Deploy, copy the JSON below and paste it into the Library → Step templates → Import dialog.

{
  "Id": "dd2b147c-3f20-42e1-a94c-17b157a0f0a4",
  "Name": "Azure - Capture AzureRM Virtual Machine Image",
  "Description": "Prepares an AzureRM Virtual Machine (Managed Disk or Storage Account based) and captures a [Managed Image](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/capture-image-resource) or [Image VHD](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/capture-image):\n1. Runs Sysprep\n2. Deallocates & Generalizes VM\n3. Creates Managed Image or Image VHD\n4. Removes virtual machine resource group",
  "Version": 1,
  "ExportedAt": "2017-05-29T18:58:54.733Z",
  "ActionType": "Octopus.AzurePowerShell",
  "Author": "paulmarsy",
  "Parameters": [
    {
      "Id": "911668fe-9653-4f08-892c-0e103e72cad0",
      "Name": "StepTemplate_Account",
      "Label": "Octopus Azure Account",
      "HelpText": "Select the [account id](#/accounts) to use for the connection.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      },
      "Links": {}
    },
    {
      "Id": "b838a80e-3e49-4788-8302-6b64bf0159ea",
      "Name": "StepTemplate_ResourceGroupName",
      "Label": "Resource Group Name",
      "HelpText": "Name of the Azure Resource Group containing the Virtual Machine.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      },
      "Links": {}
    },
    {
      "Id": "8be775a8-53e4-4740-ae0f-10d72f9fdc67",
      "Name": "StepTemplate_VMName",
      "Label": "Virtual Machine Name",
      "HelpText": "The name of the AzureRM Virtual Machine to capture. This VM will be shut down & generalized.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      },
      "Links": {}
    },
    {
      "Id": "73dae502-7a82-461c-a82b-8c40f519611f",
      "Name": "StepTemplate_ImageType",
      "Label": "Image Type",
      "HelpText": "Desired type of image to capture from the Virtual Machine.",
      "DefaultValue": "managed",
      "DisplaySettings": {
        "Octopus.ControlType": "Select",
        "Octopus.SelectOptions": "managed|Managed Image\nunmanaged|Storage Account VHD"
      },
      "Links": {}
    },
    {
      "Id": "111b827b-a2c2-4d57-895e-b18daf9c6344",
      "Name": "StepTemplate_ImageDest",
      "Label": "Image Destination",
      "HelpText": "Where the image should be created.\n\n**Managed Images** should enter a _Resource Group_ name\n\n**Storage Account VHDs** should enter a _Storage Account_ name",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      },
      "Links": {}
    },
    {
      "Id": "f461eac4-565c-47e6-8bf0-923f9647e3b2",
      "Name": "StepTemplate_ImageName",
      "Label": "Image Name",
      "HelpText": "Name to use when creating the image.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      },
      "Links": {}
    },
    {
      "Id": "ba0ea0f4-710c-4732-8fb4-9f9b4faf325b",
      "Name": "StepTemplate_DeleteVMResourceGroup",
      "Label": "Delete VM Resource Group?",
      "HelpText": "Delete the virtual machine resource group after an image has been captured.\n\n**Once a Virtual Machine is marked as generalized Azure will prevent it from being started or modified.**",
      "DefaultValue": "True",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      },
      "Links": {}
    }
  ],
  "Properties": {
    "Octopus.Action.Azure.AccountId": "#{StepTemplate_Account}",
    "Octopus.Action.Script.ScriptSource": "Inline",
    "Octopus.Action.Script.ScriptBody": "<#\n ----- Capture AzureRM Virtual Machine Image ----- \n    Paul Marston @paulmarsy (paul@marston.me)\nLinks\n    https://github.com/OctopusDeploy/Library/commits/master/step-templates/azure-capture-virtualmachine-image.json\n    \nThe sequence of steps performed by the step template:\n    1) Virtual Machine prep\n        a) PowerState/running - Custom script extension is used to sysprep & shutdown\n        b) PowerState/stopped - only when the VM is shutdown by the OS, if Azure stops the VM it is automatically deallocated\n        c) PowerState/deallocated\n        d) OSState/generalized\n    2) Image capture\n        - Managed VM & Managed Image - New image with VM as source\n        - Managed VM & Unmanaged VHD - Access to the underlying blob is granted, and the VHD copied into the specified storage account\n        - Unmanaged VM & Managed Image - New image with VM as source\n        - Unmanaged VM & Unmanaged VHD - VM image is saved, a SAS token is generated and it is copied from the VM's storage account into the specified storage account\n    3) Virtual machine cleanup.\n        Once a VM has been marked as 'generalized' Azure will no longer allow it to be started up, making the VM unusable\n        If the delete option is selected, and the image just created has been moved outside the VM's resource group \n        \n----- Advanced Configuration Settings -----\nVariable names can use either of the following two formats: \n    Octopus.Action.<Setting Name> - will apply to all steps in the deployment, e.g.\n        Octopus.Action.DebugLogging\n    Octopus.Action[Step Name].<Setting Name> - will apply to 'step name' alone, e.g.\n        Octopus.Action[Capture Web VM Image].StorageAccountKey\n\nAvailable Settings:\n    VhdDestContainer - overrides the default container that an unmanaged VHD image is copied to, default is 'images'\n    StorageAccountKey - allows copying to a storage account in a different subscription by using the providing the key, default is null\n#>\n#Requires -Modules AzureRM.Resources\n#Requires -Modules AzureRM.Compute\n#Requires -Modules AzureRM.Storage\n#Requires -Modules Azure.Storage\n\n$ErrorActionPreference = 'Stop'\n\n<#---------- SysPrep Script - Begin  ----------#>\n<#\n    Sysprep marker file: C:\\WindowsAzure\\sysprep\n    1) If marker file exists, sysprep has already been run so exit script\n    2) Start a new powershell process and exit with code 0, this allows the custom script extension to report back as having run successfully to Azure\n        a) In the child script wait until the successful exit code has been logged\n        b) Create the marker file\n        c) Run sysprep\n#>\n$SysPrepScript = @'\nif (Test-Path \"${env:SystemDrive}\\WindowsAzure\\sysprep\") { return }\n\nStart-Process -FilePath 'powershell.exe' -ArgumentList @('-NonInteractive','-NoProfile',('-EncodedCommand {0}' -f ([System.Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes({\n    do {\n        Start-Sleep -Seconds 1\n        $status = Get-ChildItem \"${env:SystemDrive}\\Packages\\Plugins\\Microsoft.Compute.CustomScriptExtension\\*\\Status\\\" -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Get-Content | ConvertFrom-Json\n    } while ($status[0].status.code -ne 0)\n    New-Item -ItemType File -Path \"${env:SystemDrive}\\WindowsAzure\\sysprep\" -Force | Out-Null\n    & (Join-Path -Resolve ([System.Environment]::SystemDirectory) 'sysprep\\sysprep.exe') /oobe /generalize /quiet /shutdown\n}.ToString())))))\n\nexit 0\n'@\n<#---------- SysPrep Script - End ----------#>\n\nfunction Get-OctopusSetting {\n    param([Parameter(Position = 0, Mandatory)][string]$Name, [Parameter(Position = 1)]$DefaultValue)\n    $formattedName = 'Octopus.Action.{0}' -f $Name\n    if ($OctopusParameters.ContainsKey($formattedName)) {\n        $value = $OctopusParameters[$formattedName]\n        if ($DefaultValue -is [int]) { return ([int]::Parse($value)) }\n        if ($DefaultValue -is [bool]) { return ([System.Convert]::ToBoolean($value)) }\n        if ($DefaultValue -is [array] -or $DefaultValue -is [hashtable] -or $DefaultValue -is [pscustomobject]) { return (ConvertFrom-Json -InputObject $value) }\n        return $value\n    }\n    else { return $DefaultValue }\n}\nfunction Test-String {\n    param([Parameter(Position=0)]$InputObject,[switch]$ForAbsence)\n\n    $hasNoValue = [System.String]::IsNullOrWhiteSpace($InputObject)\n    if ($ForAbsence) { $hasNoValue }\n    else { -not $hasNoValue }\n}\nfilter Out-Verbose {\n    Write-Verbose ($_ | Out-String)\n}\nfunction Split-BlobUri {\n    param($Uri)\n    $uriRegex = [regex]::Match($Uri, '(?>https:\\/\\/)(?<Account>[a-z0-9]{3,24})\\.blob\\.core\\.windows\\.net\\/(?<Container>[-a-z0-9]{3,63})\\/(?<Blob>.+)')\n    if (!$uriRegex.Success) {\n        throw \"Unable to parse blob uri: $Uri\"\n    }\n    [pscustomobject]@{\n        Account = $uriRegex.Groups['Account'].Value\n        Container = $uriRegex.Groups['Container'].Value\n        Blob = $uriRegex.Groups['Blob'].Value\n    }\n}\nfunction Get-AzureRmAccessToken {\n    # https://github.com/paulmarsy/AzureRest/blob/master/Internals/Get-AzureRmAccessToken.ps1\n    $accessToken = Invoke-RestMethod -UseBasicParsing -Uri ('https://login.microsoftonline.com/{0}/oauth2/token?api-version=1.0' -f $OctopusAzureADTenantId) -Method Post -Body @{\"grant_type\" = \"client_credentials\"; \"resource\" = \"https://management.core.windows.net/\"; \"client_id\" = $OctopusAzureADClientId; \"client_secret\" = $OctopusAzureADPassword }\n    [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $accessToken.access_token).ToString()\n}\nfunction Get-TemporarySasBlob {\n    param($BlobName)\n    # https://github.com/paulmarsy/AzureRest/blob/master/Exported/New-AzureBlob.ps1\n    $sasToken = Invoke-RestMethod -UseBasicParsing -Uri 'https://mscompute2.iaas.ext.azure.com/api/Compute/VmExtensions/GetTemporarySas/' -Headers @{\n        [Microsoft.WindowsAzure.Commands.Common.ApiConstants]::AuthorizationHeaderName = (Get-AzureRmAccessToken)\n    }\n    $containerSas = [uri]::new($sasToken)\n    $container = [Microsoft.WindowsAzure.Storage.Blob.CloudBlobContainer]::new($containerSas)\n    $blobRef = $container.GetBlockBlobReference($BlobName)\n    \n    [psobject]@{\n        Blob = $blobRef\n        Uri = [uri]::new($blobRef.Uri.AbsoluteUri + $containerSas.Query)\n    }\n}\n\n'Checking AzureRM Modules...' | Out-Verbose\nGet-Module | ? Name -like 'AzureRM.*' | Format-Table -AutoSize -Property Name,Version | Out-String | Out-Verbose\nif ((Get-Module AzureRM.Compute | % Version) -lt '2.6.0') {\n    $bundledErrorMessage = if ([System.Convert]::ToBoolean($OctopusUseBundledAzureModules)) {\n        'The Azure PowerShell Modules bundled with Octopus have been loaded. To use the version installed on the server create a variable named \"Octopus.Action.Azure.UseBundledAzurePowerShellModules\" and set its value to \"False\".'\n    }\n    throw \"${bundledErrorMessage}Please ensure version 2.6.0 or newer of the AzureRM.Compute module has been installed. The module can be installed with the PowerShell command: Install-Module AzureRM.Compute -MinimumVersion 2.6.0\"\n}\n\n$vm = Get-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -WarningAction SilentlyContinue\nif ($null -eq $vm) {\n    throw \"Unable to find virtual machine '$StepTemplate_VMName' in resource group '$StepTemplate_ResourceGroupName'\"\n}\nWrite-Host \"Image will be captured from Virtual Machine '$($vm.Name)' in resource group '$($vm.ResourceGroupName)'\"\nif (Test-String $StepTemplate_ImageDest -ForAbsence) {\n    throw \"The Image Destination parameter is required\"\n}\n$StepTemplate_ImageStorageContext = if ($StepTemplate_ImageType -eq 'unmanaged') {\n    $storageAccountKey = Get-OctopusSetting StorageAccountKey $null\n    if (Test-String $storageAccountKey) {\n        Write-Host \"Image will be copied to storage account context '$StepTemplate_ImageDest' using provided key\"\n        New-AzureStorageContext -StorageAccountName $StepTemplate_ImageDest -StorageAccountKey $storageAccountKey\n    } else {\n        $storageAccountResource = Find-AzureRmResource -ResourceNameEquals $StepTemplate_ImageDest -ResourceType Microsoft.Storage/storageAccounts\n        if ($storageAccountResource) {\n            Write-Host \"Image will be copied to storage account '$($storageAccountResource.Name)' found in resource group '$($storageAccountResource.ResourceGroupName)'\"\n        } else {\n            throw \"Unable to find storage account '$StepTemplate_ImageDest'\"\n        }\n        Get-AzureRmStorageAccount -ResourceGroupName $storageAccountResource.ResourceGroupName -Name $storageAccountResource.Name | % Context\n    }\n}\n$StepTemplate_ImageResourceGroupName = switch ($StepTemplate_ImageType) {\n    'managed' {\n        $resourceGroup = Get-AzureRmResourceGroup -Name $StepTemplate_ImageDest | % ResourceGroupName \n        Write-Host \"Managed Image will be created in resource group '$resourceGroup'\"\n        $resourceGroup\n    }\n    'unmanaged' { Find-AzureRmResource -ResourceNameEquals $StepTemplate_ImageDest -ResourceType Microsoft.Storage/storageAccounts | % ResourceGroupName }\n}\nif ($StepTemplate_ImageResourceGroupName -ieq $StepTemplate_ResourceGroupName -and $StepTemplate_DeleteVMResourceGroup -ieq 'True') {\n    throw \"You have chosen to delete the virtual machine and it's resource group ($StepTemplate_ResourceGroupName), however this resource group is also where the captured image will be created!\"\n}\n\nWrite-Host ('-'*80)\nWrite-Host \"Preparing virtual machine $($vm.Name) for image capture...\"\n\n$sysprepRun = $false\nwhile ($true) {\n    $statusCode = Get-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -Status -WarningAction SilentlyContinue | % Statuses | % Code\n    if ($statusCode -contains 'OSState/generalized') {\n        Write-Host 'VM is deallocated & generalized, proceeding to image capture...'\n        break\n    }\n    if ($statusCode -contains 'PowerState/deallocated') {\n        Write-Host 'VM has been deallocated, setting state to generalized... '\n        Set-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -Generalized | Out-Verbose\n        continue\n    }\n    if ($statusCode -contains 'PowerState/deallocating') {\n        Write-Host 'VM is deallocating, waiting...'\n        Start-Sleep 30\n        continue\n    }\n    if ($statusCode -contains 'PowerState/stopped') {\n        Write-Host 'VM has been shutdown, starting deallocation...'\n        Stop-AzureRmVm -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -Force | Out-Verbose\n        continue\n    }\n    if ($statusCode -contains 'PowerState/stopping') {\n        Write-Host 'VM is stopping, waiting...'\n        Start-Sleep 30\n        continue\n    }\n    if ($statusCode -contains 'PowerState/running' -and $sysprepRun) {\n        Write-Host 'VM is running, but sysprep already deployed, waiting...'\n        Start-Sleep 30\n        continue\n    }\n    if ($statusCode -contains 'PowerState/running') {\n        Write-Host 'VM is running, performing sysprep...'\n        $existingCustomScriptExtensionName = $vm.Extensions | ? VirtualMachineExtensionType -eq 'CustomScriptExtension' | % Name\n        if ($existingCustomScriptExtensionName) {\n            Write-Warning \"Removing existing CustomScriptExtension ($existingCustomScriptExtensionName)...\"\n            Remove-AzureRmVMCustomScriptExtension -ResourceGroupName $StepTemplate_ResourceGroupName -VMName $StepTemplate_VMName -Name $existingCustomScriptExtensionName -Force | Out-Verbose\n        }\n        \n        Write-Host 'Uploading sysprep script to blob storage...'\n        $sysprepScriptFileName = 'Sysprep.ps1'\n        $sysprepScriptBlob = Get-TemporarySasBlob $sysprepScriptFileName\n        $sysprepScriptBlob.Blob.UploadText($SysPrepScript)\n    \n        Write-Host 'Deploying sysprep custom script extension...'\n        Set-AzureRmVMCustomScriptExtension -ResourceGroupName $StepTemplate_ResourceGroupName -VMName $StepTemplate_VMName -Name 'Sysprep' -Location $vm.Location -FileUri $sysprepScriptBlob.Uri -Run $sysprepScriptFileName -ForceRerun (Get-Date).Ticks | Out-Verbose\n        $sysprepRun = $true\n        continue\n    }\n    Write-Warning \"VM is in an unknown state. Current status codes: $($statusCode -join ', '). Waiting...\"\n    Start-Sleep -Seconds 30\n}\n\nWrite-Host ('-'*80)\n\nWrite-Host 'Retrieving virtual machine disk configuration...'\n$vm = Get-AzureRmVM -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -WarningAction SilentlyContinue \n$isManagedVm = $null -ne $vm.StorageProfile.OsDisk.ManagedDisk\nif ($isManagedVm) { Write-Host \"Virtual machine $($vm.Name) is using Managed Disks\" }\n$isUnmanagedVm = $null -ne $vm.StorageProfile.OsDisk.Vhd\nif ($isUnmanagedVm) { Write-Host \"Virtual machine $($vm.Name) is using unmanaged storage account VHDs\" }\n\nif ($StepTemplate_ImageType -eq 'managed') {\n    Write-Host \"Creating Managed Image of $($vm.Name)...\"\n    $image = New-AzureRmImageConfig -Location $vm.Location -SourceVirtualMachineId $vm.Id\n    New-AzureRmImage -Image $image -ImageName $StepTemplate_ImageName -ResourceGroupName $StepTemplate_ImageResourceGroupName | Out-Verbose\n    Write-Host 'Image created:'\n    Get-AzureRmImage -ImageName $StepTemplate_ImageName -ResourceGroupName $StepTemplate_ImageResourceGroupName | Out-Host\n}\n\nif ($StepTemplate_ImageType -eq 'unmanaged') {\n    if ($isManagedVm) {\n        Write-Host \"Granting access to os disk ($($vm.StorageProfile.OsDisk.Name)) blob...\"\n        $manageDisk = Grant-AzureRmDiskAccess -ResourceGroupName $StepTemplate_ResourceGroupName -DiskName $vm.StorageProfile.OsDisk.Name -DurationInSecond 3600 -Access Read \n        $vhdSasUri = $manageDisk.AccessSAS\n    }\n    if ($isUnmanagedVm) {\n        Write-Host \"Saving Unmanaged Image of $($vm.Name)...\"\n        $armTemplatePath = [System.IO.Path]::GetTempFileName()\n        $vhdDestContainer = Get-OctopusSetting VhdDestContainer 'images'\n        Save-AzureRmVMImage -ResourceGroupName $StepTemplate_ResourceGroupName -Name $StepTemplate_VMName -DestinationContainerName $vhdDestContainer -VHDNamePrefix $StepTemplate_ImageName -Overwrite -Path $armTemplatePath | Out-Verbose\n        $armTemplate = Get-Content -Path $armTemplatePath\n        \"VM Image ARM Template:`n$armTemplate\" | Out-Verbose\n        Remove-Item $armTemplatePath -Force\n        $osDiskUri = ($armTemplate | ConvertFrom-Json).resources.properties.storageprofile.osdisk.image.uri\n        \"OS Disk Image URI: $osDiskUri\" | Out-Verbose\n        $unmanagedVhd = Split-BlobUri $osDiskUri\n        \n        Write-Host \"Granting access to vhd image ($($unmanagedVhd.Blob))...\"\n        $unmanagedVhdStorageResource = Find-AzureRmResource -ResourceNameEquals $unmanagedVhd.Account -ResourceType Microsoft.Storage/storageAccounts\n        $unmanagedVhdStorageResource | Out-Verbose\n        $unmanagedVhdStorageContext = Get-AzureRmStorageAccount -ResourceGroupName $unmanagedVhdStorageResource.ResourceGroupName -Name $unmanagedVhdStorageResource.Name | % Context\n        $vhdSasUri = New-AzureStorageBlobSASToken -Container $unmanagedVhd.Container -Blob $unmanagedVhd.Blob -Permission r -ExpiryTime (Get-Date).AddHours(1) -FullUri -Context $unmanagedVhdStorageContext\n    }\n    Write-Host \"Source image SAS token created: $vhdSasUri\"\n\n    Write-Host 'Copying image to storage account...'\n    $destContainerName = Get-OctopusSetting VhdDestContainer 'images'\n    $destContainer = Get-AzureStorageContainer -Name $destContainerName -Context $StepTemplate_ImageStorageContext -ErrorAction SilentlyContinue\n    if ($destContainer) {\n        Write-Host \"Using container '$destContainerName' in storage account $StepTemplate_ImageDest...\"\n    } else {\n        Write-Host \"Creating container '$destContainerName' in storage account $StepTemplate_ImageDest...\"\n        $destContainer = New-AzureStorageContainer -Name $destContainerName -Context $StepTemplate_ImageStorageContext -Permission Off\n    }\n\n    $copyBlob = Start-AzureStorageBlobCopy -AbsoluteUri $vhdSasUri -DestContainer $destContainerName -DestContext $StepTemplate_ImageStorageContext -DestBlob $StepTemplate_ImageName -Force\n    $copyBlob | Out-Verbose\n    do {   \n        if ($copyState.Status -eq 'Pending') {\n            Start-Sleep -Seconds 60\n        }\n        $copyState = $copyBlob | Get-AzureStorageBlobCopyState\n        $copyState | Out-Verbose\n        $percent = ($copyState.BytesCopied / $copyState.TotalBytes) * 100\n        Write-Host \"Blob transfer $($copyState.Status.ToString().ToLower())... $('{0:N2}' -f $percent)% @ $([System.Math]::Round($copyState.BytesCopied/1GB, 2))GB / $([System.Math]::Round($copyState.TotalBytes/1GB, 2))GB\"\n    } while ($copyState.Status -eq 'Pending')\n    Write-Host \"Final image transfer status: $($copyState.Status)\"\n    \n    if ($isManagedVm) {\n        Write-Host 'Revoking access to os disk blob...'\n        Revoke-AzureRmDiskAccess -ResourceGroupName $StepTemplate_ResourceGroupName -DiskName $vm.StorageProfile.OsDisk.Name | Out-Verbose\n    }\n}\n\nWrite-Host \"Image of $($vm.Name) captured successfully!\"\n\nif ($StepTemplate_DeleteVMResourceGroup -ieq 'True') {\n    Write-Host ('-'*80)\n    Write-Host \"Removing $($vm.Name) VM's resource group $StepTemplate_ResourceGroupName, the following resources will be deleted...\"\n    Find-AzureRmResource -ResourceGroupNameEquals $StepTemplate_ResourceGroupName | Sort-Object -Property ResourceId -Descending | Select-Object -Property ResourceGroupName,ResourceType,ResourceName | Format-Table -AutoSize | Out-Host\n    Remove-AzureRmResourceGroup -Name $StepTemplate_ResourceGroupName -Force | Out-Verbose\n}",
    "Octopus.Action.Package.FeedId": null,
    "Octopus.Action.Script.ScriptFileName": null,
    "Octopus.Action.Package.PackageId": null
  },
  "Category": "Azure",
  "HistoryUrl": "https://github.com/OctopusDeploy/Library/commits/master/step-templates//opt/buildagent/work/75443764cd38076d/step-templates/azure-capture-virtualmachine-image.json",
  "Website": "/step-templates/dd2b147c-3f20-42e1-a94c-17b157a0f0a4",
  "Logo": "iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADNQTFRF////AHjXf7vrv931QJrh7/f8EIDaIIncMJHfYKvmz+b3n8zw3+76j8Ttr9XycLPpUKLkkKvYFAAABGZJREFUeNrsnNmCqjoQRc1MEiD8/9cer7Yt2KBJZQC8ez07sKlKTQlcLgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzoUSnt8YxXlFuGHSbIaxvj+fip4btkLn1blkWLaF5v03yLhLOYlVuGYfMOMZzNGxCOzhjTJqFkXnjq3Dr1yyvPI3hGl3Ih3zzHHNKudRstRhX5O58vIcShY67Gq6EPIESlzUWvazaGAOGbvU7ArDu/g8M4o8opDZWvbvPzlL/MMBE8jT9T9W7PbAJlHPTBFRf9yVTEcs63msXz2UHLSgf650G/d5t+wjbxxB2UCMqGrk8/LFSD7uJMeNt5bcJCyQZyAe5Fo9KYfWS2flQrr4b4tpuzaeWjYs49rt9LHf9uZD7+VbyVi9EBNrjYjuq2sxQOrl+p+HuBVu45qvqfq691ttYFQ5KyKbyJgaIY/NGxrlWZwlwGvmvu1oY3PuAv0niTq6tZ78jk//9uc1r1r4lQki7y7sp2Tu4V1y2iLoqFTqi1lIGcpFiebrZNZ1dOkF0cCIlO8jQ47nCkam9Lilz9GhDF1I6XGLzfnhwDIIZVfI7+8SSgfHsijqXENOGJF5QorG4EcW0OrScqX/dDrXpr70Ut/BII+1OfECPuYz/NWxYmgrCsUskxPvyhgmrw+WGZ6lGTuOlIyCYWTFyWjpM5KIZRUIOwjRNYRQ6tZF9BXtk8hWAHPtLNJ727Fq0JSkC1FDRRF0Jalj0d5qVh2KEpM2TuSsCYTCT6ZkdmFYI9LrYp5QayWbo6NXlZwcRD/61pth5Fq5EX423QQxNjhqWvvklkljOLkYjrmphXPZOJOk6Pg7HKMsrtQKcowzZoK3rx1ZUelGMdQA/HaKkjAt2RgqpZeYqbNbH7Hp2ct4nqfSPOfe0ftiSTZJydOV6rG5bQbyLK+nRuCC0343PzDgiOXyQA5c14BTZi98uR/5KJ1SnatLdoO50WWBQZPTq0VgsklU3h932actuo17ayrHrb/3ykiegd3KbqF2wbV6RrlsJ07yLcpsWFTul9RyK6ZScr+tk7oNrFj0o7HQUlj4EiEvJ6rPLKSmlMZCrksl1OnLaRkxc+/HB1naMhNtT/6yM2bDs6azCRHrM3aVPN7aW8irD/10B8njpAMcsl8okXcdKrl4sPsLmQVy/Sj90ucPRc/d/Bxxj+dXSpCayen32D+hLi16MsIV8gfCXrYp6ySsiJKRUF0XXiLpVbFU+fNv4r7mOwhFsX4ZdwpSi1DYs2jb6ebZ9788cblTzMrYhu7sf/17IFdtuviJ2ioHA6pMHkoH4CLUeMBU7iGkxuM/YgcdderF9ibRdc7O982F1HpYhjfWUe+x5a6pjop9iNLfoePvlsdZdTSMwfxSmTY20Q0eHnUNzga1edeNmmqbg18aMVR1L9vwSXHF9TfIWBxpKLs2hj3eQeBC0USvp2HHF3eIkRdhFOd6ER8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA/I/4J8AAo/80BciBec4AAAAASUVORK5CYII=",
  "$Meta": {
    "Type": "ActionTemplate"
  }
}

History

Page updated on Monday, May 29, 2017