├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── bug-report-feature-request.md └── workflows │ ├── auto-triage-issues │ └── defaultLabels.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── action.yml ├── lib ├── AzureImageBuilderClient.js ├── BuildTemplate.js ├── ImageBuilder.js ├── TaskParameters.js ├── Utils.js ├── constants.js └── index.js ├── package-lock.json ├── package.json ├── src ├── AzureImageBuilderClient.ts ├── BuildTemplate.ts ├── ImageBuilder.ts ├── TaskParameters.ts ├── Utils.ts ├── constants.ts └── index.ts ├── tsconfig.json └── tutorial ├── _imgs ├── bakedimage.png ├── role-assignment.png ├── sig.png └── text.md └── how-to-use-action.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @olayemio 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Issue : Bug report/ Feature request' 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: need-to-triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/auto-triage-issues: -------------------------------------------------------------------------------- 1 | #This workflow is used for automatically labelling the respository issues to the nearest possible labels from enhancement,bug, documentation. 2 | 3 | name: "Auto-Labelling Issues" 4 | on: 5 | issues: 6 | types: [opened, edited] 7 | 8 | jobs: 9 | auto_label: 10 | runs-on: ubuntu-latest 11 | name: Auto-Labelling Issues 12 | steps: 13 | - name: Label Step 14 | uses: larrylawl/Auto-Github-Issue-Labeller@v1.0 15 | with: 16 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 17 | REPOSITORY: ${{github.repository}} 18 | DELTA: "7" 19 | CONFIDENCE: "2" 20 | FEATURE: "enhancement" 21 | BUG: "bug" 22 | DOCS: "documentation 23 | -------------------------------------------------------------------------------- /.github/workflows/defaultLabels.yml: -------------------------------------------------------------------------------- 1 | name: setting-default-labels 2 | 3 | # Controls when the action will run. 4 | on: 5 | schedule: 6 | - cron: "0 0/3 * * *" 7 | 8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 9 | jobs: 10 | build: 11 | # The type of runner that the job will run on 12 | runs-on: ubuntu-latest 13 | 14 | # Steps represent a sequence of tasks that will be executed as part of the job 15 | steps: 16 | - uses: actions/stale@v3 17 | name: Setting issue as idle 18 | with: 19 | repo-token: ${{ secrets.GITHUB_TOKEN }} 20 | stale-issue-message: 'This issue is idle because it has been open for 14 days with no activity.' 21 | stale-issue-label: 'idle' 22 | days-before-stale: 14 23 | days-before-close: -1 24 | operations-per-run: 100 25 | exempt-issue-labels: 'backlog' 26 | 27 | - uses: actions/stale@v3 28 | name: Setting PR as idle 29 | with: 30 | repo-token: ${{ secrets.GITHUB_TOKEN }} 31 | stale-pr-message: 'This PR is idle because it has been open for 14 days with no activity.' 32 | stale-pr-label: 'idle' 33 | days-before-stale: 14 34 | days-before-close: -1 35 | operations-per-run: 100 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Action to Build Custom Virtual Machine Images 2 | 3 | With the Build Azure Virtual Machine Image action, you can now create custom virtual machine images that contain artifacts produced in your CI/CD workflows and have pre-installed software. This action not only lets you build customized images but also distribute them using image managing Azure services like [Shared Image Gallery](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/shared-image-galleries). These images can then be used for creating [Virtual Machines](https://azure.microsoft.com/en-in/services/virtual-machines/) or [Virtual Machine Scale Sets](https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/overview) 4 | 5 | 6 | The definition of this Github Action is in [action.yml](https://github.com/Azure/build-vm-image/blob/master/action.yml). 7 | 8 | Note that this action uses the [Azure Image Builder](https://azure.microsoft.com/en-in/blog/streamlining-your-image-building-process-with-azure-image-builder/) service in the background for creating and publishing images. 9 | 10 | 11 | ## Prerequisites: 12 | 13 | * User Assigned Managed Identity: A managed identity is required for Azure Image Builder(AIB) to distribute images(Shared Image Gallery or Managed Image). You must create an [Azure user-assigned managed identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-manage-ua-identity-cli) that will be used during the image build to read and write images. You then need to grant it permission to do specific actions using a [custom role](https://docs.microsoft.com/en-us/azure/role-based-access-control/custom-roles-portal) with the following json(replace your subscription id and resource group name) and assign it to the managed identity. 14 | 15 | 16 | ```json 17 | { 18 | "Name": "Image Creation Role", 19 | "IsCustom": true, 20 | "Description": "Azure Image Builder access to create resources for the image build", 21 | "Actions": [ 22 | "Microsoft.Compute/galleries/read", 23 | "Microsoft.Compute/galleries/images/read", 24 | "Microsoft.Compute/galleries/images/versions/read", 25 | "Microsoft.Compute/galleries/images/versions/write", 26 | 27 | "Microsoft.Compute/images/write", 28 | "Microsoft.Compute/images/read", 29 | "Microsoft.Compute/images/delete" 30 | ], 31 | "NotActions": [ 32 | 33 | ], 34 | "AssignableScopes": [ 35 | "/subscriptions//resourceGroups/" 36 | ] 37 | } 38 | 39 | ``` 40 | Learn more about configuring permissions for Azure Image builder Service using [Powershell](https://docs.microsoft.com/en-us/azure/virtual-machines/linux/image-builder-permissions-powershell?toc=/azure/virtual-machines/windows/toc.json&bc=/azure/virtual-machines/windows/breadcrumb/toc.json) or [Azure CLI](https://docs.microsoft.com/en-us/azure/virtual-machines/linux/image-builder-permissions-cli?toc=/azure/virtual-machines/windows/toc.json&bc=/azure/virtual-machines/windows/breadcrumb/toc.json). 41 | 42 | # Inputs for the Action 43 | 44 | * `resource-group-name`: Required. This is the resource group where the action creates a storage for saving artifacts needed for customized image. Azure image builder also uses the same resource group for Image Template creation. 45 | 46 | * `image-builder-template-name`: The name of the image builder template resource to be used for creating and running the Image builder service. If you already have an [AIB Template file](https://github.com/danielsollondon/azvmimagebuilder/tree/master/quickquickstarts) downloaded in the runner, then you can give the full filepath to that as well. E.g. _${{ GITHUB.WORKSPACE }}/vmImageTemplate/ubuntuCustomVM.json_. Note that incase a filepath is provided in this action input, then parameters in the file will take precedence over action inputs. Irrespective, customizer section of action is always executed. 47 | 48 | * `location`: This is the location where the Azure Image Builder(AIB) will run. Eg, 'eastus2'. AIB supports only a [specific set of locations](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/image-builder-overview#regions). The source images must be present in this location, so for example, if you are using Shared Image Gallery, a replica must exist in that region. This is optional if AIB template filepath is provided in `image-builder-template` input. 49 | 50 | * `build-timeout-in-minutes`: Optional. Time after which the build is cancelled. Defaults to 240. 51 | 52 | * `vm-size`: Optional. By default AIB uses a "Standard_D1_v2" build VM, however, you can override this. Check out different VM sizes offered in azure [here](https://docs.microsoft.com/en-us/azure/virtual-machines/sizes). 53 | 54 | 55 | * `managed-identity`: As mentioned in pre-requisites, AIB will use the user assigned managed identity to add the image into the resource group. It takes the full identifier for managed identity or if you have the managed identity in the same resource group then just the name of managed identity suffices. Refer the sample input value below. You can find more details about identity creation [here](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/image-builder#create-a-user-assigned-identity-and-set-permissions-on-the-resource-group). This is input is optional if AIB template filepath is provided in `image-builder-template` input. 56 | 57 | ```yaml 58 | /subscriptions/xxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourceGroups/my-dev-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/my-imagebuild-identity 59 | ``` 60 | 61 | 62 | * `source-os-type`: Required. The OS type of the base image(i.e. source image). It can be set to [ Linux | Windows ]. 63 | * `source-image-type`: The base image type that will be used for creating the custom image. This should be set to one of three types: [ PlatformImage | SharedImageGallery | ManagedImage ]. This is input is optional if AIB template filepath is provided in `image-builder-template` input 64 | * `source-image`: This is the resource identifier for base image. A source image should be present in the same Azure region set in the input value of `location`. This is input is optional if AIB template filepath is provided in `image-builder-template` input 65 | 66 | * If the `source-image-type` is PlatformImage, then the value of `source-image` will be the urn of image. Format 'publisher:offer:sku:version' E.g. _Ubuntu:Canonical:18.04-LTS:latest_. You can run the following AZ CLI command to list images available 67 | 68 | ```bash 69 | az vm image list or az vm image show 70 | ``` 71 | * if the `source-image-type` is ManagedImage - the value of source-image is the resourceId of the source image, for example: 72 | 73 | ```yaml 74 | /subscriptions//resourceGroups//providers/Microsoft.Compute/images/ 75 | ``` 76 | 77 | * If the `source-image-type` is SharedImageGallery - You need to pass in the resourceId of the image version for example: 78 | 79 | ```yaml 80 | /subscriptions//resourceGroups//providers/Microsoft.Compute/galleries//images//versions/ 81 | ``` 82 | 83 | * `customizer-source`: Optional. This takes the path to a directory in the runner. This is the directory where you can keep all the artifacts that need to be added to the base image for customization. By default, the value is _${{ GITHUB.WORKSPACE }}/workflow-artifacts. 84 | * `customizer-script `: Optional. This takes multi inline powershell or shell commands and use variables to point to directories inside the downloaded location. 85 | * `customizer-destination` : Optional. This is the directory in the customized image where artifacts are copied to. The default path of customizer-destination would depend on the OS defined in 'source-os-type' field. For windows it is C:\ and for linux it is /tmp/. Note that for many Linux OS's, on a reboot, the /tmp directory contents are deleted. So if you need these artifacts to persist you need to write customizer script to copy them to a persistent location. Here is a sample input for customizer-script: 86 | 87 | ```yaml 88 | customizer-script: | 89 | sudo mkdir /buildArtifacts 90 | sudo cp -r /tmp/ /buildArtifacts/ 91 | ``` 92 | 93 | * `customizer-windows-update`: Optional. Applicable for only windows images. The value is a boolean. If set to true, the image builder will run Windows update at the end of the customizations and also handle the reboots if required. By default the value is set to 'false' 94 | 95 | * `dist-type`: Optional. This takes your choice for distributing the built image. It can be set to [ ManagedImage | SharedImageGallery | VHD ]. By default its ManagedImage. 96 | * `dist-resource-id`: Optional. Takes the full resource identifier. 97 | * If the dist-type is SharedImageGallery the value can be: 98 | ```yaml 99 | /subscriptions//resourceGroups//providers/Microsoft.Compute/galleries//images/ 100 | ``` 101 | * If the dist-type is ManagedImage, the value should be of format: 102 | ```yaml 103 | /subscriptions//resourceGroups//providers/Microsoft.Compute/images/ 104 | ``` 105 | * If the image-type is VHD, You do not need to pass this parameter 106 | * `dist-location`: Optional. This is required only when SharedImageGallery is the `dist-type` 107 | * `dist-image-tags`: Optional. These are user defined tags that are added to the custom image created. They take key value pairs as input. E.g. _'version:beta'_ 108 | 109 | 110 | # End-to-End Sample Workflows 111 | 112 | ### Sample workflow to create a custom Windows OS image and distribute it as a Managed Image 113 | 114 | ```yaml 115 | name: create_custom_windows_image 116 | 117 | on: push 118 | 119 | jobs: 120 | BUILD-CUSTOM-IMAGE: 121 | runs-on: ubuntu-latest 122 | steps: 123 | - name: CHECKOUT 124 | uses: actions/checkout@v2 125 | 126 | 127 | - name: AZURE LOGIN 128 | uses: azure/login@v1 129 | with: 130 | creds: ${{secrets.AZURE_CREDENTIALS}} 131 | 132 | - name: BUILD WEBAPP 133 | run: sudo ${{ GITHUB.WORKSPACE }}/webApp/buildscript.sh # Runs necessary build scripts and copies built artifacts to ${{ GITHUB.WORKSPACE }}/workflow-artifacts 134 | 135 | 136 | - name: BUILD-CUSTOM-VM-IMAGE 137 | uses: azure/build-vm-image@v0 138 | with: 139 | resource-group-name: 'myResourceGroup' 140 | managed-identity: 'myImageBuilderIdentity' 141 | location: 'eastus2' 142 | source-os-type: 'windows' 143 | source-image: MicrosoftWindowsServer:WindowsServer:2019-Datacenter:latest 144 | customizer-script: | 145 | & 'c:\workflow-artifacts\webApp\webconfig.ps1' 146 | 147 | ``` 148 | The above workflow will use a Microsoft Windows Server platform image as base image, inject files present in directory `${{ GITHUB.WORKSPACE }}/worflow-artifacts` of GitHub runner into the base image at default `customizer-destination` directory and run image customizations(E.g. Set up IIS web server, configure bindings etc) using script webconfig.ps1, finally it will distribute the baked custom image as a Managed Image(default distribution) 149 | 150 | 151 | ### Sample workflow to create a custom Ubuntu OS image and distribute it as Managed Image 152 | 153 | ```yaml 154 | on: push 155 | 156 | jobs: 157 | job1: 158 | runs-on: ubuntu-latest 159 | name: Create Custom Linux Image 160 | steps: 161 | - name: Checkout 162 | uses: actions/checkout@v2 163 | 164 | - name: Create Workflow Artifacts 165 | run: | 166 | cd "$GITHUB_WORKFLOW" 167 | mkdir worflow-artifacts/ 168 | echo "echo Installing World... " > $GITHUB_WORKSPACE/workflow-artifacts/install-world.sh # You can have your own installation script here 169 | 170 | 171 | - name: Login via Az module 172 | uses: azure/login@v1 173 | with: 174 | creds: ${{secrets.AZURE_CREDENTIALS}} 175 | 176 | - name: Build and Distribute Custom VM Image 177 | uses: azure/build-vm-image@v0 178 | with: 179 | resource-group-name: 'myResourceGroup' 180 | location: 'eastus2' 181 | managed-identity: 'myImageBuilderIdentity' 182 | source-os-type: 'linux' 183 | source-image-type: 'PlatformImage' 184 | source-image: Canonical:UbuntuServer:18.04-LTS:latest 185 | customizer-source: ${{ GITHUB.WORKSPACE }}/workflow-artifacts 186 | customizer-script: | 187 | sudo mkdir /buildArtifacts 188 | sudo cp -r /tmp/ /buildArtifacts/ 189 | sh /buildArtifacts/workflow-artifacts/install-world.sh 190 | 191 | 192 | ``` 193 | The above workflow will use a linux platform image as base image, inject files present in directory `${{ GITHUB.WORKSPACE }}/worflow-artifacts` of GitHub runner into the base image at default `customizer-destination` directory and run install-world.sh script. Finally it will distribute the baked custom image as a Managed Image(default distribution) 194 | 195 | 196 | ### Sample workflow to create a custom Ubuntu OS image and distribute through Shared Image Gallery 197 | 198 | ```yaml 199 | on: push 200 | 201 | jobs: 202 | BUILD-CUSTOM-UBUNTU-IMAGE: 203 | runs-on: ubuntu-latest 204 | steps: 205 | - name: CHECKOUT 206 | uses: actions/checkout@v2 207 | 208 | 209 | - name: AZURE LOGIN 210 | uses: azure/login@v1 211 | with: 212 | creds: ${{secrets.AZURE_CREDENTIALS}} 213 | 214 | - name: BUILD WEBAPP 215 | run: sudo ${{ GITHUB.WORKSPACE }}/webApp/buildscript.sh # Run necessary build scripts and copies built artifacts to ${{ GITHUB.WORKSPACE }}/workflow-artifacts 216 | 217 | 218 | - name: BUILD-CUSTOM-VM-IMAGE 219 | uses: azure/build-vm-image@v0 220 | with: 221 | resource-group-name: 'myResourceGroup' 222 | managed-identity: 'myImageBuilderIdentity' 223 | location: 'eastus2' 224 | source-os-type: 'linux' 225 | source-image: Canonical:UbuntuServer:18.04-LTS:latest 226 | customizer-script: | 227 | sh /tmp/workflow-artifacts/install.sh 228 | dist-type: 'SharedImageGallery' 229 | dist-resource-id: '/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/myResourceGroup/providers/Microsoft.Compute/galleries/AppTeam/images/ImagesWithApp' 230 | dist-location: 'eastus2' 231 | 232 | 233 | ``` 234 | The above workflow will use a linux platform image as base image, inject files present in directory `${{ GITHUB.WORKSPACE }}/worflow-artifacts` of GitHub runner into the base image at default `customizer-destination` directory and run install.sh script. Finally it will distribute the baked custom image through Shared Image Gallery 235 | 236 | ### Snippet to spin up a virtual machine from custom image 237 | 238 | You can easily create a Virtual Machine using [AZ CLI commands](https://docs.microsoft.com/en-us/cli/azure/vm?view=azure-cli-latest). Here is a simple example to do it using GitHub workflows. 239 | 240 | ```yaml 241 | - name: CREATE VM 242 | uses: azure/CLI@v1 243 | with: 244 | azcliversion: 2.0.72 245 | inlineScript: | 246 | az vm create --resource-group myResourceGroup --name "app-vm-${{ GITHUB.RUN_NUMBER }}" --admin-username vmUserName --admin-password "${{ secrets.VM_PWD }}" --location eastus2 \ 247 | --image "${{ steps..outputs.custom-image-uri }}" 248 | 249 | ``` 250 | You can also take a look at the [end to end tutorial](https://github.com/Azure/build-vm-image/blob/master/tutorial/how-to-use-action.md) that describes how to use this action and also create a Virtual machine from customized image. 251 | 252 | 253 | 254 | 255 | ## Configure credentials for Azure login action: 256 | 257 | With the Azure login Action, you can perform an Azure login using [Azure service principal](https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals). The credentials of Azure Service Principal can be added as [secrets](https://help.github.com/en/articles/virtual-environments-for-github-actions#creating-and-using-secrets-encrypted-variables) in the GitHub repository and then used in the workflow. Follow the below steps to generate credentials and store in github. 258 | 259 | 260 | * Prerequisite: You should have installed Azure cli on your local machine to run the command or use the cloudshell in the Azure portal. To install Azure cli, follow [Install Azure Cli](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest). To use cloudshell, follow [CloudShell Quickstart](https://docs.microsoft.com/en-us/azure/cloud-shell/quickstart). After you have one of the above ready, follow these steps: 261 | 262 | 263 | * Run the below Azure cli command and copy the output JSON object to your clipboard. 264 | 265 | 266 | ```bash 267 | 268 | az ad sp create-for-rbac --name "myApp" --role contributor \ 269 | --scopes /subscriptions/{subscription-id} \ 270 | --sdk-auth 271 | 272 | # Replace {subscription-id} with the subscription identifiers 273 | 274 | # The command should output a JSON object similar to this: 275 | 276 | { 277 | "clientId": "", 278 | "clientSecret": "", 279 | "subscriptionId": "", 280 | "tenantId": "", 281 | (...) 282 | } 283 | 284 | ``` 285 | * Define a 'New secret' under your GitHub repository settings -> 'Secrets' menu. Lets name it 'AZURE_CREDENTIALS'. 286 | * Paste the contents of the clipboard as the value of the above secret variable. 287 | * Use the secret variable in the Azure Login Action(Refer to the examples above) 288 | 289 | 290 | If needed, you can modify the Azure CLI command to further reduce the scope for which permissions are provided. Here is the command that gives contributor access to only a resource group. 291 | 292 | ```bash 293 | 294 | az ad sp create-for-rbac --name "myApp" --role contributor \ 295 | --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} \ 296 | --sdk-auth 297 | 298 | # Replace {subscription-id}, {resource-group} with the subscription and resource group identifiers. 299 | 300 | ``` 301 | 302 | You can also provide permissions to multiple scopes using the Azure CLI command: 303 | 304 | ```bash 305 | 306 | az ad sp create-for-rbac --name "myApp" --role contributor \ 307 | --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group1} \ 308 | /subscriptions/{subscription-id}/resourceGroups/{resource-group2} \ 309 | --sdk-auth 310 | 311 | # Replace {subscription-id}, {resource-group1}, {resource-group2} with the subscription and resource group identifiers. 312 | 313 | ``` 314 | # Feedback 315 | 316 | If you have any changes you’d like to see or suggestions for this action, we’d love your feedback ❤️ . Please feel free to raise a GitHub issue in this repository describing your suggestion. This would enable us to label and track it properly. You can do the same if you encounter a problem with the feature as well. 317 | 318 | # Contributing 319 | 320 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 321 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 322 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 323 | 324 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 325 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 326 | provided by the bot. You will only need to do this once across all repos using our CLA. 327 | 328 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 329 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 330 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 331 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Build Azure Virtual Machine Image" 2 | description: "Create custom virtual machine images that contain artifacts built in CI workflows" 3 | inputs: 4 | #general inputs 5 | location: 6 | description: 'This is the Azure region in which the Image Builder will run.' 7 | resource-group-name: 8 | description: 'This is the Resource Group where the temporary Imagebuilder Template resource will be created.' 9 | required: true 10 | image-builder-template-name: 11 | description: 'The name of the image builder template resource to be used for creating and running the Image builder service.' 12 | build-timeout-in-minutes: 13 | description: 'The value is an integer which is used as timeout in minutes for running the image build.' 14 | default: 240 15 | vm-size: 16 | description: 'You can override the VM size, from the default value i.e. Standard_D1_v2.' 17 | default: 'Standard_D1_v2' 18 | managed-identity: 19 | description: 'The identity that will be used to do the role assignment and resource creation' 20 | 21 | #source inputs 22 | source-image-type: 23 | description: '[ PlatformImage | SharedImageGallery | ManagedImage ]' 24 | default: 'PlatformImage' 25 | source-os-type: 26 | description: 'OS types supported: [ linux | windows ].' 27 | required: true 28 | source-image: 29 | description: 'Value of source-image supported by Azure Image Builder.' 30 | 31 | #customization inouts 32 | customizer-source: 33 | description: 'This takes the path to a directory or a file in the runner. By default, it points to the default download directory of the github runner.' 34 | customizer-script: 35 | description: 'The customer can enter multi inline powershell or shell commands and use variables to point to directories inside the downloaded location.' 36 | customizer-windows-update: 37 | description: 'The value is boolean and set to false by default. This value is for Windows images only, the image builder will run Windows Update at the end of the customizations and also handle the reboots it requires.' 38 | default: false 39 | 40 | #distribution inputs 41 | dist-type: 42 | description: 'ManagedImage | SharedImageGallery | VHD' 43 | default: 'ManagedImage' 44 | dist-resource-id: 45 | description: 'Image Resource Id to be created by AIB' 46 | dist-location: 47 | description: 'location of Image created by AIB' 48 | run-output-name: 49 | description: 'Every Image builder run is identified with a unique run id.' 50 | dist-image-tags: 51 | description: 'The values set will be used to set the user defined tags on the custom image artifact created.' 52 | 53 | outputs: 54 | imagebuilder-run-status: 55 | description: 'This value of this output will be the value of Image builder Run status set to either Succeeded or Failed based on the runState returned by Azure Image Builder.' 56 | run-output-name: 57 | description: 'The action emits output value run-output-name which can be used to get the details of the Image Builder Run.' 58 | custom-image-uri: 59 | description: 'Upon successful completion, The github action emits the URI or resource id of the Image distributed.' 60 | 61 | runs: 62 | using: 'node12' 63 | main: 'lib/index.js' 64 | -------------------------------------------------------------------------------- /lib/AzureImageBuilderClient.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 22 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 23 | return new (P || (P = Promise))(function (resolve, reject) { 24 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 25 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 26 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 27 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 28 | }); 29 | }; 30 | Object.defineProperty(exports, "__esModule", { value: true }); 31 | const AzureRestClient_1 = require("azure-actions-webclient/AzureRestClient"); 32 | const core = __importStar(require("@actions/core")); 33 | var apiVersion = "2020-02-14"; 34 | class ImageBuilderClient { 35 | constructor(resourceAuthorizer, taskParameters) { 36 | this._client = new AzureRestClient_1.ServiceClient(resourceAuthorizer); 37 | this._taskParameters = taskParameters; 38 | } 39 | getTemplateId(templateName, subscriptionId) { 40 | return __awaiter(this, void 0, void 0, function* () { 41 | let httpRequest = { 42 | method: 'GET', 43 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion) 44 | }; 45 | var resourceId = ""; 46 | try { 47 | var response = yield this._client.beginRequest(httpRequest); 48 | if (response.statusCode != 200 || response.body.status == "Failed") 49 | throw AzureRestClient_1.ToError(response); 50 | if (response.statusCode == 200 && response.body.id) 51 | resourceId = response.body.id; 52 | } 53 | catch (error) { 54 | throw Error(`Get template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 55 | } 56 | return resourceId; 57 | }); 58 | } 59 | putImageTemplate(template, templateName, subscriptionId) { 60 | return __awaiter(this, void 0, void 0, function* () { 61 | console.log("Submitting the template"); 62 | let httpRequest = { 63 | method: 'PUT', 64 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion), 65 | body: template 66 | }; 67 | try { 68 | var response = yield this._client.beginRequest(httpRequest); 69 | if (response.statusCode == 201) { 70 | response = yield this.getLongRunningOperationResult(response); 71 | } 72 | if (response.statusCode != 200 || response.body.status == "Failed") { 73 | throw AzureRestClient_1.ToError(response); 74 | } 75 | if (response.statusCode == 200 && response.body && response.body.status == "Succeeded") { 76 | console.log("Submitted template: \n", response.body.status); 77 | } 78 | } 79 | catch (error) { 80 | throw Error(`Submit template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 81 | } 82 | }); 83 | } 84 | runTemplate(templateName, subscriptionId, timeOutInMinutes) { 85 | return __awaiter(this, void 0, void 0, function* () { 86 | try { 87 | console.log("Starting run template..."); 88 | let httpRequest = { 89 | method: 'POST', 90 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}/run`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion) 91 | }; 92 | var response = yield this._client.beginRequest(httpRequest); 93 | if (response.statusCode == 202) { 94 | response = yield this.getLongRunningOperationResult(response, timeOutInMinutes); 95 | } 96 | if (response.statusCode != 200 || response.body.status == "Failed") { 97 | throw AzureRestClient_1.ToError(response); 98 | } 99 | if (response.statusCode == 200 && response.body && response.body.status == "Succeeded") { 100 | console.log("Run template: \n", response.body.status); 101 | } 102 | } 103 | catch (error) { 104 | throw Error(`Post template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 105 | } 106 | }); 107 | } 108 | deleteTemplate(templateName, subscriptionId) { 109 | return __awaiter(this, void 0, void 0, function* () { 110 | try { 111 | console.log(`Deleting template ${templateName}...`); 112 | let httpRequest = { 113 | method: 'DELETE', 114 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion) 115 | }; 116 | var response = yield this._client.beginRequest(httpRequest); 117 | if (response.statusCode == 202) { 118 | response = yield this.getLongRunningOperationResult(response); 119 | } 120 | if (response.statusCode != 200 || response.body.status == "Failed") { 121 | throw AzureRestClient_1.ToError(response); 122 | } 123 | if (response.statusCode == 200 && response.body && response.body.status == "Succeeded") { 124 | console.log("Delete template: ", response.body.status); 125 | } 126 | } 127 | catch (error) { 128 | throw Error(`Delete template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 129 | } 130 | }); 131 | } 132 | getRunOutput(templateName, runOutput, subscriptionId) { 133 | return __awaiter(this, void 0, void 0, function* () { 134 | let httpRequest = { 135 | method: 'GET', 136 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}/runOutputs/{runOutput}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName, '{runOutput}': runOutput }, [], apiVersion) 137 | }; 138 | var output = ""; 139 | try { 140 | var response = yield this._client.beginRequest(httpRequest); 141 | if (response.statusCode != 200 || response.body.status == "Failed") 142 | throw AzureRestClient_1.ToError(response); 143 | if (response.statusCode == 200 && response.body) { 144 | if (response.body && response.body.properties.artifactId) 145 | output = response.body.properties.artifactId; 146 | else if (response.body && response.body.properties.artifactUri) 147 | output = response.body.properties.artifactUri; 148 | else 149 | console.log(`Error in parsing response.body -- ${response.body}.`); 150 | } 151 | } 152 | catch (error) { 153 | throw Error(`Get runOutput call failed for template ${templateName} for ${runOutput} with error: ${JSON.stringify(error)}`); 154 | } 155 | return output; 156 | }); 157 | } 158 | getLongRunningOperationResult(response, timeoutInMinutes) { 159 | var response; 160 | return __awaiter(this, void 0, void 0, function* () { 161 | var longRunningOperationRetryTimeout = !!timeoutInMinutes ? timeoutInMinutes : 0; 162 | timeoutInMinutes = timeoutInMinutes || longRunningOperationRetryTimeout; 163 | var timeout = new Date().getTime() + timeoutInMinutes * 60 * 1000; 164 | var waitIndefinitely = timeoutInMinutes == 0; 165 | var requestURI = response.headers["azure-asyncoperation"] || response.headers["location"]; 166 | let httpRequest = { 167 | method: 'GET', 168 | uri: requestURI 169 | }; 170 | if (!httpRequest.uri) { 171 | throw new Error("InvalidResponseLongRunningOperation"); 172 | } 173 | if (!httpRequest.uri) { 174 | console.log("error in uri " + httpRequest.uri); 175 | } 176 | while (true) { 177 | response = yield this._client.beginRequest(httpRequest); 178 | if (response.statusCode === 202 || (response.body && (response.body.status == "Accepted" || response.body.status == "Running" || response.body.status == "InProgress"))) { 179 | if (response.body && response.body.status) { 180 | core.debug(response.body.status); 181 | } 182 | if (!waitIndefinitely && timeout < new Date().getTime()) { 183 | throw Error(`error in url`); 184 | } 185 | var sleepDuration = 15; 186 | yield this.sleepFor(sleepDuration); 187 | } 188 | else { 189 | break; 190 | } 191 | } 192 | return response; 193 | }); 194 | } 195 | sleepFor(sleepDurationInSeconds) { 196 | return new Promise((resolve, reject) => { 197 | setTimeout(resolve, sleepDurationInSeconds * 1000); 198 | }); 199 | } 200 | } 201 | exports.default = ImageBuilderClient; 202 | -------------------------------------------------------------------------------- /lib/BuildTemplate.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 22 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 23 | return new (P || (P = Promise))(function (resolve, reject) { 24 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 25 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 26 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 27 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 28 | }); 29 | }; 30 | Object.defineProperty(exports, "__esModule", { value: true }); 31 | const Utils_1 = __importStar(require("./Utils")); 32 | const AzureRestClient_1 = require("azure-actions-webclient/AzureRestClient"); 33 | var defaultTemplate = ` 34 | { 35 | "location": "", 36 | "identity": { 37 | "type": "UserAssigned", 38 | "userAssignedIdentities": { 39 | "IDENTITY": {} 40 | } 41 | }, 42 | "properties": { 43 | "source": SOURCE, 44 | "customize": [CUSTOMIZE], 45 | "distribute": [DISTRIBUTE], 46 | "vmProfile": { 47 | "vmSize": "VM_SIZE" 48 | } 49 | } 50 | } 51 | `; 52 | var templateSource = new Map([ 53 | ["managedimage", `{"type": "ManagedImage", "imageId": "IMAGE_ID"}`], 54 | ["sharedimagegallery", `{"type": "SharedImageVersion", "imageVersionId": "IMAGE_ID"}`], 55 | ["platformimage", `{"type": "PlatformImage", "publisher": "PUBLISHER_NAME", "offer": "OFFER_NAME","sku": "SKU_NAME", "version": "VERSION"}`] 56 | ]); 57 | var templateCustomizer = new Map([ 58 | ["shell", `{"type": "File", "name": "aibaction_file_copy", "sourceUri": "", "destination": ""},{"type": "Shell", "name": "aibaction_inline", "inline":[]}`], 59 | ["shellInline", `{"type": "Shell", "name": "aibaction_inline", "inline":[]}`], 60 | ["powershell", `{"type": "PowerShell", "name": "aibaction_inline", "inline":[]}`], 61 | ["windowsUpdate", `{"type": "PowerShell", "name": "5minWait_is_needed_before_windowsUpdate", "inline":["Start-Sleep -Seconds 300"]},{"type": "WindowsUpdate", "searchCriteria": "IsInstalled=0", "filters": ["exclude:$_.Title -like '*Preview*'", "include:$true"]}`] 62 | ]); 63 | var templateDistribute = new Map([ 64 | ["managedimage", `{"type": "ManagedImage", "imageId": "IMAGE_ID", "location": "", "runOutputName": "ManagedImage_distribute", "artifactTags": {"RunURL": "URL", "GitHubRepo": "GITHUB_REPO", "GithubCommit": "GITHUB_COMMIT"}}`], 65 | ["sharedimagegallery", `{"type": "SharedImage", "galleryImageId": "IMAGE_ID", "replicationRegions": [], "runOutputName": "SharedImage_distribute", "artifactTags": {"RunURL": "URL", "GitHubRepo": "GITHUB_REPO", "GithubCommit": "GITHUB_COMMIT"}}`], 66 | ["vhd", `{"type": "VHD", "runOutputName": "VHD_distribute"}`] 67 | ]); 68 | class BuildTemplate { 69 | constructor(resourceAuthorizer, taskParameters) { 70 | try { 71 | this._taskParameters = taskParameters; 72 | this._client = new AzureRestClient_1.ServiceClient(resourceAuthorizer); 73 | } 74 | catch (error) { 75 | throw Error(error); 76 | } 77 | } 78 | getLatestVersion(subscriptionId) { 79 | return __awaiter(this, void 0, void 0, function* () { 80 | let httpRequest = { 81 | method: 'GET', 82 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmimage/offers/{offer}/skus/{skus}/versions`, { '{subscriptionId}': subscriptionId, '{location}': this._taskParameters.location, '{publisherName}': this._taskParameters.imagePublisher, '{offer}': this._taskParameters.imageOffer, '{skus}': this._taskParameters.imageSku }, ["$orderby=name%20desc", "$top=1"], '2018-06-01') 83 | }; 84 | var latestVersion = ""; 85 | try { 86 | var response = yield this._client.beginRequest(httpRequest); 87 | if (response.statusCode != 200 || response.body.statusCode == "Failed") { 88 | throw Error(response.statusCode.toString()); 89 | } 90 | if (response.statusCode == 200 && response.body) 91 | latestVersion = response.body[0].name; 92 | } 93 | catch (error) { 94 | throw Error(`failed to get latest image version: request uri ${httpRequest.uri}: ${error}`); 95 | } 96 | return latestVersion; 97 | }); 98 | } 99 | getTemplate(blobUrl, imgBuilderId, subscriptionId) { 100 | return __awaiter(this, void 0, void 0, function* () { 101 | var template = defaultTemplate; 102 | template = template.replace("IDENTITY", imgBuilderId); 103 | template = template.replace("VM_SIZE", this._taskParameters.vmSize); 104 | template = template.replace("SOURCE", templateSource.get(this._taskParameters.sourceImageType.toLowerCase())); 105 | template = template.replace("DISTRIBUTE", templateDistribute.get(this._taskParameters.distributeType.toLowerCase())); 106 | var customizers; 107 | if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "shell") && (this._taskParameters.customizerSource == undefined || this._taskParameters.customizerSource.length == 0)) { 108 | customizers = templateCustomizer.get("shellInline"); 109 | } 110 | else { 111 | customizers = templateCustomizer.get(this._taskParameters.provisioner); 112 | } 113 | if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "powershell") && this._taskParameters.windowsUpdateProvisioner) 114 | customizers = customizers + "," + templateCustomizer.get("windowsUpdate"); 115 | template = template.replace("CUSTOMIZE", customizers); 116 | var templateJson = JSON.parse(template); 117 | templateJson.location = this._taskParameters.location; 118 | if (Utils_1.default.IsEqual(templateJson.properties.source.type, "PlatformImage")) { 119 | templateJson.properties.source.publisher = this._taskParameters.imagePublisher; 120 | templateJson.properties.source.offer = this._taskParameters.imageOffer; 121 | templateJson.properties.source.sku = this._taskParameters.imageSku; 122 | if (Utils_1.default.IsEqual(this._taskParameters.baseImageVersion, "latest")) 123 | templateJson.properties.source.version = yield this.getLatestVersion(subscriptionId); 124 | else 125 | templateJson.properties.source.version = this._taskParameters.baseImageVersion; 126 | } 127 | else if (Utils_1.default.IsEqual(templateJson.properties.source.type, "ManagedImage")) 128 | templateJson.properties.source.imageId = this._taskParameters.sourceResourceId; 129 | else 130 | templateJson.properties.source.imageVersionId = this._taskParameters.imageVersionId; 131 | // customize 132 | if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "shell")) { 133 | var inline = "#\n"; 134 | if (!(this._taskParameters.buildFolder == "")) { 135 | var packageName = `/tmp/${this._taskParameters.buildFolder}`; 136 | templateJson.properties.customize[0].sourceUri = blobUrl; 137 | templateJson.properties.customize[0].destination = `${packageName}.tar.gz`; 138 | inline += `mkdir -p ${packageName}\n`; 139 | inline += `sudo tar -xzvf ${templateJson.properties.customize[0].destination} -C ${packageName}\n`; 140 | if (this._taskParameters.inlineScript) 141 | inline += `${this._taskParameters.inlineScript}\n`; 142 | templateJson.properties.customize[1].inline = inline.split("\n"); 143 | } 144 | else { 145 | if (this._taskParameters.inlineScript) 146 | inline += `${this._taskParameters.inlineScript}\n`; 147 | templateJson.properties.customize[0].inline = inline.split("\n"); 148 | } 149 | } 150 | else if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "powershell")) { 151 | var inline = ""; 152 | if (!(this._taskParameters.buildFolder == "")) { 153 | var packageName = "c:\\" + this._taskParameters.buildFolder; 154 | inline += `Invoke-WebRequest -Uri '${blobUrl}' -OutFile ${packageName}.zip -UseBasicParsing\n`; 155 | inline += `Expand-Archive -Path ${packageName}.zip -DestinationPath ${packageName}\n`; 156 | } 157 | if (this._taskParameters.inlineScript) 158 | inline += `${this._taskParameters.inlineScript}\n`; 159 | templateJson.properties.customize[0].inline = inline.split("\n"); 160 | } 161 | if (Utils_1.default.IsEqual(templateJson.properties.distribute[0].type, "ManagedImage")) { 162 | if (this._taskParameters.imageIdForDistribute == "" || this._taskParameters.imageIdForDistribute == undefined) { 163 | var imageDefn = "mi_" + Utils_1.getCurrentTime(); 164 | templateJson.properties.distribute[0].imageId = `/subscriptions/${subscriptionId}/resourceGroups/${this._taskParameters.resourceGroupName}/providers/Microsoft.Compute/images/${imageDefn}`; 165 | } 166 | else { 167 | templateJson.properties.distribute[0].imageId = this._taskParameters.imageIdForDistribute; 168 | } 169 | templateJson.properties.distribute[0].location = this._taskParameters.managedImageLocation; 170 | } 171 | if (Utils_1.default.IsEqual(templateJson.properties.distribute[0].type, "SharedImage")) { 172 | templateJson.properties.distribute[0].galleryImageId = this._taskParameters.galleryImageId; 173 | var regions = this._taskParameters.replicationRegions.split(","); 174 | templateJson.properties.distribute[0].replicationRegions = regions; 175 | } 176 | if (Utils_1.default.IsEqual(templateJson.properties.distribute[0].type, "SharedImage") || Utils_1.default.IsEqual(templateJson.properties.distribute[0].type, "ManagedImage")) { 177 | templateJson.properties.distribute[0].artifactTags.RunURL = process.env.GITHUB_SERVER_URL + "/" + process.env.GITHUB_REPOSITORY + "/actions/runs/" + process.env.GITHUB_RUN_ID; 178 | templateJson.properties.distribute[0].artifactTags.GitHubRepo = process.env.GITHUB_REPOSITORY; 179 | templateJson.properties.distribute[0].artifactTags.GithubCommit = process.env.GITHUB_SHA; 180 | if (this._taskParameters.distImageTags !== "" && this._taskParameters.distImageTags !== undefined) { 181 | var distImageTags = this._taskParameters.distImageTags.split(","); 182 | for (var i = 0; i < distImageTags.length; i++) { 183 | var distImageTag = distImageTags[i].split(":"); 184 | templateJson.properties.distribute[0].artifactTags[distImageTag[0]] = distImageTag[1]; 185 | } 186 | } 187 | } 188 | return templateJson; 189 | }); 190 | } 191 | addUserCustomisationIfNeeded(blobUrl) { 192 | let json = JSON.parse(this._taskParameters.templateJsonFromUser); 193 | let customizers = json.properties.customize; 194 | // add customization for custom source 195 | let fileCustomizer; 196 | if (!!this._taskParameters.customizerSource) { 197 | let windowsUpdateCustomizer; 198 | if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "powershell") && this._taskParameters.windowsUpdateProvisioner) { 199 | windowsUpdateCustomizer = JSON.parse("[" + templateCustomizer.get("windowsUpdate") + "]"); 200 | for (var i = windowsUpdateCustomizer.length - 1; i >= 0; i--) { 201 | customizers.unshift(windowsUpdateCustomizer[i]); 202 | } 203 | } 204 | fileCustomizer = JSON.parse("[" + templateCustomizer.get(this._taskParameters.provisioner) + "]"); 205 | for (var i = fileCustomizer.length - 1; i >= 0; i--) { 206 | customizers.unshift(fileCustomizer[i]); 207 | } 208 | json.properties.customize = customizers; 209 | if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "shell")) { 210 | var inline = "#\n"; 211 | if (!(this._taskParameters.buildFolder == "")) { 212 | var packageName = `/tmp/${this._taskParameters.buildFolder}`; 213 | json.properties.customize[0].sourceUri = blobUrl; 214 | json.properties.customize[0].destination = `${packageName}.tar.gz`; 215 | inline += `mkdir -p ${packageName}\n`; 216 | inline += `sudo tar -xzvf ${json.properties.customize[0].destination} -C ${packageName}\n`; 217 | } 218 | if (this._taskParameters.inlineScript) 219 | inline += `${this._taskParameters.inlineScript}\n`; 220 | json.properties.customize[1].inline = inline.split("\n"); 221 | } 222 | else if (Utils_1.default.IsEqual(this._taskParameters.provisioner, "powershell")) { 223 | var inline = ""; 224 | if (!(this._taskParameters.buildFolder == "")) { 225 | var packageName = "c:\\" + this._taskParameters.buildFolder; 226 | inline += `Invoke-WebRequest -Uri '${blobUrl}' -OutFile ${packageName}.zip -UseBasicParsing\n`; 227 | inline += `Expand-Archive -Path ${packageName}.zip -DestinationPath ${packageName}\n`; 228 | } 229 | if (this._taskParameters.inlineScript) 230 | inline += `${this._taskParameters.inlineScript}\n`; 231 | json.properties.customize[0].inline = inline.split("\n"); 232 | } 233 | } 234 | json.properties.customize = customizers; 235 | return json; 236 | } 237 | } 238 | exports.default = BuildTemplate; 239 | -------------------------------------------------------------------------------- /lib/ImageBuilder.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 22 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 23 | return new (P || (P = Promise))(function (resolve, reject) { 24 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 25 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 26 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 27 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 28 | }); 29 | }; 30 | var __importDefault = (this && this.__importDefault) || function (mod) { 31 | return (mod && mod.__esModule) ? mod : { "default": mod }; 32 | }; 33 | Object.defineProperty(exports, "__esModule", { value: true }); 34 | const Q = require("q"); 35 | const path = require("path"); 36 | const core = __importStar(require("@actions/core")); 37 | const exec = __importStar(require("@actions/exec")); 38 | const io = __importStar(require("@actions/io")); 39 | const TaskParameters_1 = __importDefault(require("./TaskParameters")); 40 | const Utils_1 = require("./Utils"); 41 | const AzureImageBuilderClient_1 = __importDefault(require("./AzureImageBuilderClient")); 42 | const BuildTemplate_1 = __importDefault(require("./BuildTemplate")); 43 | const Util = require("util"); 44 | const Utils_2 = __importDefault(require("./Utils")); 45 | var fs = require('fs'); 46 | var archiver = require('archiver'); 47 | const constants = __importStar(require("./constants")); 48 | const AzureRestClient_1 = require("azure-actions-webclient/AzureRestClient"); 49 | var azure = require('azure-storage'); 50 | var azPath; 51 | var storageAccountExists = false; 52 | class ImageBuilder { 53 | constructor(resourceAuthorizer) { 54 | this.isVhdDistribute = false; 55 | this.templateName = ""; 56 | this.storageAccount = ""; 57 | this.containerName = ""; 58 | this.idenityName = ""; 59 | this.imgBuilderTemplateExists = false; 60 | this.accountkeys = ""; 61 | try { 62 | this._taskParameters = new TaskParameters_1.default(); 63 | this._buildTemplate = new BuildTemplate_1.default(resourceAuthorizer, this._taskParameters); 64 | this._aibClient = new AzureImageBuilderClient_1.default(resourceAuthorizer, this._taskParameters); 65 | this._client = new AzureRestClient_1.ServiceClient(resourceAuthorizer); 66 | this.idenityName = this._taskParameters.managedIdentity; 67 | } 68 | catch (error) { 69 | throw (`Error happened while initializing Image builder: ${error}`); 70 | } 71 | } 72 | execute() { 73 | return __awaiter(this, void 0, void 0, function* () { 74 | try { 75 | azPath = yield io.which("az", true); 76 | core.debug("Az module path: " + azPath); 77 | var outStream = ''; 78 | yield this.executeAzCliCommand("--version"); 79 | yield this.registerFeatures(); 80 | //GENERAL INPUTS 81 | outStream = yield this.executeAzCliCommand("account show"); 82 | var subscriptionId = JSON.parse(`${outStream}`).id.toString(); 83 | var isCreateBlob = false; 84 | var imgBuilderId = ""; 85 | if (this._taskParameters.customizerSource != undefined && this._taskParameters.customizerSource != "") { 86 | isCreateBlob = true; 87 | } 88 | if (!this._taskParameters.isTemplateJsonProvided) { 89 | if (this.idenityName.startsWith("/subscriptions/")) { 90 | imgBuilderId = this.idenityName; 91 | } 92 | else { 93 | imgBuilderId = `/subscriptions/${subscriptionId}/resourcegroups/${this._taskParameters.resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/${this.idenityName}`; 94 | } 95 | } 96 | else { 97 | var template = JSON.parse(this._taskParameters.templateJsonFromUser); 98 | this._taskParameters.location = template.location; 99 | } 100 | console.log("Using Managed Identity " + this.idenityName); 101 | var blobUrl = ""; 102 | if (isCreateBlob) { 103 | //create a blob service 104 | yield this.createStorageAccount(); 105 | this._blobService = azure.createBlobService(this.storageAccount, this.accountkeys); 106 | this.containerName = constants.containerName; 107 | var blobName = this._taskParameters.buildFolder + "/" + process.env.GITHUB_RUN_ID + "/" + this._taskParameters.buildFolder + `_${Utils_1.getCurrentTime()}`; 108 | if (Utils_2.default.IsEqual(this._taskParameters.provisioner, "powershell")) 109 | blobName = blobName + '.zip'; 110 | else 111 | blobName = blobName + '.tar.gz'; 112 | blobUrl = yield this.uploadPackage(this.containerName, blobName); 113 | core.debug("Blob Url: " + blobUrl); 114 | } 115 | let templateJson = ""; 116 | if (!this._taskParameters.isTemplateJsonProvided) { 117 | templateJson = yield this._buildTemplate.getTemplate(blobUrl, imgBuilderId, subscriptionId); 118 | } 119 | else { 120 | templateJson = this._buildTemplate.addUserCustomisationIfNeeded(blobUrl); 121 | } 122 | this.templateName = this.getTemplateName(); 123 | var runOutputName = this.getRunoutputName(); 124 | templateJson.properties.distribute[0].runOutputName = runOutputName; 125 | this.isVhdDistribute = templateJson.properties.distribute[0].type == "VHD"; 126 | var templateStr = JSON.stringify(templateJson, null, 2); 127 | console.log("Template Name: " + this.templateName); 128 | console.log("Template: \n" + templateStr); 129 | yield this._aibClient.putImageTemplate(templateStr, this.templateName, subscriptionId); 130 | this.imgBuilderTemplateExists = true; 131 | yield this._aibClient.runTemplate(this.templateName, subscriptionId, this._taskParameters.buildTimeoutInMinutes); 132 | var out = yield this._aibClient.getRunOutput(this.templateName, runOutputName, subscriptionId); 133 | var templateID = yield this._aibClient.getTemplateId(this.templateName, subscriptionId); 134 | var imagebuilderRunStatus = "failed"; 135 | core.setOutput('templateName', this.templateName); 136 | core.setOutput('templateId', templateID); 137 | core.setOutput('run-output-name', runOutputName); 138 | if (out) { 139 | core.setOutput('custom-image-uri', out); 140 | core.setOutput('imagebuilder-run-status', "succeeded"); 141 | imagebuilderRunStatus = "succeeded"; 142 | } 143 | if (Utils_2.default.IsEqual(templateJson.properties.source.type, "PlatformImage")) { 144 | core.setOutput('pirPublisher', templateJson.properties.source.publisher); 145 | core.setOutput('pirOffer', templateJson.properties.source.offer); 146 | core.setOutput('pirSku', templateJson.properties.source.sku); 147 | core.setOutput('pirVersion', templateJson.properties.source.version); 148 | } 149 | console.log("=============================================================================="); 150 | console.log("## task output variables ##"); 151 | console.log("$(imagebuilder-run-status) = ", imagebuilderRunStatus); 152 | console.log("$(imageUri) = ", out); 153 | if (this.isVhdDistribute) { 154 | console.log("$(templateName) = ", this.templateName); 155 | console.log("$(templateId) = ", templateID); 156 | } 157 | console.log("=============================================================================="); 158 | } 159 | catch (error) { 160 | throw error; 161 | } 162 | finally { 163 | var outStream = yield this.executeAzCliCommand(`group exists -n ${this._taskParameters.resourceGroupName}`); 164 | if (outStream) { 165 | this.cleanup(subscriptionId); 166 | } 167 | } 168 | }); 169 | } 170 | createStorageAccount() { 171 | return __awaiter(this, void 0, void 0, function* () { 172 | this.storageAccount = Util.format('%s%s', constants.storageAccountName, Utils_1.getCurrentTime()); 173 | yield this.executeAzCliCommand(`storage account create --name "${this.storageAccount}" --resource-group "${this._taskParameters.resourceGroupName}" --location "${this._taskParameters.location}" --sku Standard_RAGRS`); 174 | core.debug("Created storage account " + this.storageAccount); 175 | var outStream = yield this.executeAzCliCommand(`storage account keys list -g "${this._taskParameters.resourceGroupName}" -n "${this.storageAccount}"`); 176 | this.accountkeys = JSON.parse(`${outStream}`)[0].value; 177 | storageAccountExists = true; 178 | }); 179 | } 180 | registerFeatures() { 181 | return __awaiter(this, void 0, void 0, function* () { 182 | var outStream = yield this.executeAzCliCommand(`feature show --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview`); 183 | if (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).properties.state, "Registered")) { 184 | core.info("Registering Microsoft.VirtualMachineImages"); 185 | yield this.executeAzCliCommand("feature register --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview"); 186 | outStream = yield this.executeAzCliCommand(`feature show --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview`); 187 | while (!Utils_2.default.IsEqual(JSON.parse(outStream).properties.state, "Registered")) { 188 | this.sleepFor(1); 189 | outStream = yield this.executeAzCliCommand(`feature show --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview`); 190 | } 191 | } 192 | outStream = ''; 193 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.VirtualMachineImages`); 194 | if (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 195 | yield this.executeAzCliCommand("provider register -n Microsoft.VirtualMachineImages"); 196 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.VirtualMachineImages`); 197 | while (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 198 | this.sleepFor(1); 199 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.VirtualMachineImages`); 200 | } 201 | } 202 | outStream = ''; 203 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.Storage`); 204 | if (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 205 | core.info("Registering Microsoft.Storage"); 206 | yield this.executeAzCliCommand("provider register -n Microsoft.Storage"); 207 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.Storage`); 208 | while (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 209 | this.sleepFor(1); 210 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.Storage`); 211 | } 212 | } 213 | outStream = ''; 214 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.Compute`); 215 | if (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 216 | core.info("Registering Microsoft.Compute"); 217 | yield this.executeAzCliCommand("provider register -n Microsoft.Compute"); 218 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.Compute`); 219 | while (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 220 | this.sleepFor(1); 221 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.Compute`); 222 | } 223 | } 224 | outStream = ''; 225 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.KeyVault`); 226 | if (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 227 | core.info("Registering Microsoft.KeyVault"); 228 | yield this.executeAzCliCommand("provider register -n Microsoft.KeyVault"); 229 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.KeyVault`); 230 | while (JSON.parse(outStream) && !Utils_2.default.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 231 | this.sleepFor(1); 232 | outStream = yield this.executeAzCliCommand(`provider show -n Microsoft.KeyVault`); 233 | } 234 | } 235 | }); 236 | } 237 | getTemplateName() { 238 | if (this._taskParameters.isTemplateJsonProvided) { 239 | var templateName = this.getTemplateNameFromProvidedJson(this._taskParameters.templateJsonFromUser); 240 | return templateName == "" ? constants.imageTemplateName + Utils_1.getCurrentTime() : templateName; 241 | } 242 | else if (!this._taskParameters.isTemplateJsonProvided && this._taskParameters.imagebuilderTemplateName) { 243 | return this._taskParameters.imagebuilderTemplateName; 244 | } 245 | return constants.imageTemplateName + Utils_1.getCurrentTime(); 246 | } 247 | getRunoutputName() { 248 | var runOutputName = this._taskParameters.runOutputName; 249 | if (runOutputName == "") { 250 | if (this._taskParameters.isTemplateJsonProvided) { 251 | var runOutputName = this.getRunoutputNameFromProvidedJson(this._taskParameters.templateJsonFromUser); 252 | return runOutputName == "" ? this.templateName + "_" + process.env.GITHUB_RUN_ID : runOutputName; 253 | } 254 | else { 255 | return this.templateName + "_" + process.env.GITHUB_RUN_ID; 256 | } 257 | } 258 | return ""; 259 | } 260 | getTemplateNameFromProvidedJson(templateJson) { 261 | var template = JSON.parse(templateJson); 262 | if (template.tags && template.tags.imagebuilderTemplate) { 263 | return template.tags.imagebuilderTemplate; 264 | } 265 | return ""; 266 | } 267 | getRunoutputNameFromProvidedJson(templateJson) { 268 | var template = JSON.parse(templateJson); 269 | if (template.properties.distribute && template.properties.distribute[0].runOutputName) { 270 | return template.properties.distribute[0].runOutputName; 271 | } 272 | return ""; 273 | } 274 | uploadPackage(containerName, blobName) { 275 | return __awaiter(this, void 0, void 0, function* () { 276 | var defer = Q.defer(); 277 | var archivedWebPackage; 278 | var temp = this._generateTemporaryFile(`${process.env.GITHUB_WORKSPACE}`); 279 | try { 280 | if (Utils_2.default.IsEqual(this._taskParameters.provisioner, "powershell")) { 281 | temp = temp + `.zip`; 282 | archivedWebPackage = yield this.createArchiveTar(this._taskParameters.buildPath, temp, "zip"); 283 | } 284 | else { 285 | temp = temp + `.tar.gz`; 286 | archivedWebPackage = yield this.createArchiveTar(this._taskParameters.buildPath, temp, "tar"); 287 | } 288 | } 289 | catch (error) { 290 | defer.reject(console.log(`unable to create archive build: ${error}`)); 291 | } 292 | console.log(`created archive ` + archivedWebPackage); 293 | this._blobService.createContainerIfNotExists(containerName, (error) => { 294 | if (error) { 295 | defer.reject(console.log(`unable to create container ${containerName} in storage account: ${error}`)); 296 | } 297 | //upoading package 298 | this._blobService.createBlockBlobFromLocalFile(containerName, blobName, archivedWebPackage, (error, result) => { 299 | if (error) { 300 | defer.reject(console.log(`unable to create blob ${blobName} in container ${containerName} in storage account: ${error}`)); 301 | } 302 | //generating SAS URL 303 | var startDate = new Date(); 304 | var expiryDate = new Date(startDate); 305 | expiryDate.setFullYear(startDate.getUTCFullYear() + 1); 306 | startDate.setMinutes(startDate.getMinutes() - 5); 307 | var sharedAccessPolicy = { 308 | AccessPolicy: { 309 | Permissions: azure.BlobUtilities.SharedAccessPermissions.READ, 310 | Start: startDate, 311 | Expiry: expiryDate 312 | } 313 | }; 314 | var token = this._blobService.generateSharedAccessSignature(containerName, blobName, sharedAccessPolicy); 315 | var blobUrl = this._blobService.getUrl(containerName, blobName, token); 316 | defer.resolve(blobUrl); 317 | }); 318 | }); 319 | return defer.promise; 320 | }); 321 | } 322 | createArchiveTar(folderPath, targetPath, extension) { 323 | return __awaiter(this, void 0, void 0, function* () { 324 | var defer = Q.defer(); 325 | console.log('Archiving ' + folderPath + ' to ' + targetPath); 326 | var output = fs.createWriteStream(targetPath); 327 | var archive; 328 | if (Utils_2.default.IsEqual(extension, 'zip')) { 329 | archive = archiver('zip', { zlib: { level: 9 } }); 330 | } 331 | else { 332 | archive = archiver('tar', { 333 | gzip: true, 334 | gzipOptions: { 335 | level: 1 336 | } 337 | }); 338 | } 339 | output.on('close', function () { 340 | console.log(archive.pointer() + ' total bytes'); 341 | core.debug('Successfully created archive ' + targetPath); 342 | defer.resolve(targetPath); 343 | }); 344 | output.on('error', function (error) { 345 | defer.reject(error); 346 | }); 347 | var stats = fs.statSync(folderPath); 348 | if (stats.isFile()) { 349 | archive.file(folderPath, { name: this._taskParameters.buildFolder }); 350 | } 351 | else { 352 | archive.glob("**", { 353 | cwd: folderPath, 354 | dot: true 355 | }); 356 | } 357 | archive.pipe(output); 358 | archive.finalize(); 359 | return defer.promise; 360 | }); 361 | } 362 | _generateTemporaryFile(folderPath) { 363 | var randomString = Math.random().toString().split('.')[1]; 364 | var tempPath = path.join(folderPath, '/temp_web_package_' + randomString); 365 | return tempPath; 366 | } 367 | cleanup(subscriptionId) { 368 | return __awaiter(this, void 0, void 0, function* () { 369 | try { 370 | if (!this.isVhdDistribute && this.imgBuilderTemplateExists) { 371 | yield this._aibClient.deleteTemplate(this.templateName, subscriptionId); 372 | console.log(`${this.templateName} got deleted`); 373 | } 374 | if (storageAccountExists) { 375 | let httpRequest = { 376 | method: 'DELETE', 377 | uri: this._client.getRequestUri(`subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccount}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{storageAccount}': this.storageAccount }, [], "2019-06-01") 378 | }; 379 | var response = yield this._client.beginRequest(httpRequest); 380 | console.log("storage account " + this.storageAccount + " deleted"); 381 | } 382 | } 383 | catch (error) { 384 | console.log(`Error in cleanup: `, error); 385 | } 386 | }); 387 | } 388 | executeAzCliCommand(command) { 389 | return __awaiter(this, void 0, void 0, function* () { 390 | var outStream = ''; 391 | var errorStream = ''; 392 | var execOptions = { 393 | outStream: new Utils_1.NullOutstreamStringWritable({ decodeStrings: false }), 394 | listeners: { 395 | stdout: (data) => outStream += data.toString(), 396 | errline: (data) => { 397 | errorStream += data; 398 | } 399 | } 400 | }; 401 | try { 402 | yield exec.exec(`"${azPath}" ${command}`, [], execOptions); 403 | return outStream; 404 | } 405 | catch (error) { 406 | if (errorStream != '') 407 | throw (`${errorStream} ${error}`); 408 | else 409 | throw (`${error}`); 410 | } 411 | }); 412 | } 413 | sleepFor(sleepDurationInSeconds) { 414 | return new Promise((resolve) => { 415 | setTimeout(resolve, sleepDurationInSeconds * 1000); 416 | }); 417 | } 418 | } 419 | exports.default = ImageBuilder; 420 | -------------------------------------------------------------------------------- /lib/TaskParameters.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | var __importDefault = (this && this.__importDefault) || function (mod) { 22 | return (mod && mod.__esModule) ? mod : { "default": mod }; 23 | }; 24 | Object.defineProperty(exports, "__esModule", { value: true }); 25 | const path = require("path"); 26 | const tl = __importStar(require("@actions/core")); 27 | const constants = __importStar(require("./constants")); 28 | const Utils_1 = __importDefault(require("./Utils")); 29 | var fs = require('fs'); 30 | class TaskParameters { 31 | constructor() { 32 | // image builder inputs 33 | this.resourceGroupName = ""; 34 | this.location = ""; 35 | this.isTemplateJsonProvided = false; 36 | this.templateJsonFromUser = ''; 37 | this.buildTimeoutInMinutes = 240; 38 | this.vmSize = ""; 39 | this.managedIdentity = ""; 40 | // source 41 | this.sourceImageType = ""; 42 | this.sourceOSType = ""; 43 | this.sourceResourceId = ""; 44 | this.imageVersionId = ""; 45 | this.baseImageVersion = ""; 46 | this.imagePublisher = ""; 47 | this.imageOffer = ""; 48 | this.imageSku = ""; 49 | //customize 50 | this.buildPath = ""; 51 | this.buildFolder = ""; 52 | this.blobName = ""; 53 | this.provisioner = ""; 54 | this.customizerSource = ""; 55 | this.customizerScript = ""; 56 | this.customizerWindowsUpdate = ""; 57 | //distribute 58 | this.distributeType = ""; 59 | this.imageIdForDistribute = ""; 60 | this.replicationRegions = ""; 61 | this.managedImageLocation = ""; 62 | this.galleryImageId = ""; 63 | this.distImageTags = ""; 64 | var locations = ["eastus", "eastus2", "westcentralus", "westus", "westus2", "westus3", "southcentralus", "northeurope", "westeurope", "southeastasia", "australiasoutheast", "australiaeast", "uksouth", "ukwest", "brazilsouth", "canadacentral", "centralindia", "centralus", "francecentral", "germanywestcentral", "japaneast", "northcentralus", "norwayeast", "switzerlandnorth", "jioindiawest", "uaenorth", "eastasia", "koreacentral", "southafricanorth", "usgovarizona", "usgovvirginia"]; 65 | console.log("start reading task parameters..."); 66 | this.imagebuilderTemplateName = tl.getInput(constants.ImageBuilderTemplateName); 67 | if (this.imagebuilderTemplateName.indexOf(".json") > -1) { 68 | this.isTemplateJsonProvided = true; 69 | var data = fs.readFileSync(this.imagebuilderTemplateName, 'utf8'); 70 | this.templateJsonFromUser = JSON.parse(JSON.stringify(data)); 71 | } 72 | this.resourceGroupName = tl.getInput(constants.ResourceGroupName, { required: true }); 73 | this.buildTimeoutInMinutes = parseInt(tl.getInput(constants.BuildTimeoutInMinutes)); 74 | this.sourceOSType = tl.getInput(constants.SourceOSType, { required: true }); 75 | if (Utils_1.default.IsEqual(this.sourceOSType, "windows")) { 76 | this.provisioner = "powershell"; 77 | } 78 | else { 79 | this.provisioner = "shell"; 80 | } 81 | if (!this.isTemplateJsonProvided) { 82 | //general inputs 83 | this.location = tl.getInput(constants.Location, { required: true }); 84 | if (!(locations.indexOf(this.location.toString().replace(/\s/g, "").toLowerCase()) > -1)) { 85 | throw new Error("location not from available regions or it is not defined"); 86 | } 87 | this.managedIdentity = tl.getInput(constants.ManagedIdentity, { required: true }); 88 | //vm size 89 | this.vmSize = tl.getInput(constants.VMSize); 90 | //source inputs 91 | this.sourceImageType = tl.getInput(constants.SourceImageType); 92 | var sourceImage = tl.getInput(constants.SourceImage, { required: true }); 93 | if (Utils_1.default.IsEqual(this.sourceImageType, constants.platformImageSourceTypeImage) || Utils_1.default.IsEqual(this.sourceImageType, constants.marketPlaceSourceTypeImage)) { 94 | this.sourceImageType = constants.platformImageSourceTypeImage; 95 | this._extractImageDetails(sourceImage); 96 | } 97 | else if (Utils_1.default.IsEqual(this.sourceImageType, constants.managedImageSourceTypeImage)) { 98 | this.sourceResourceId = sourceImage; 99 | } 100 | else { 101 | this.imageVersionId = sourceImage; 102 | } 103 | } 104 | //customize inputs 105 | this.customizerSource = tl.getInput(constants.CustomizerSource).toString(); 106 | if (this.customizerSource == undefined || this.customizerSource == "" || this.customizerSource == null) { 107 | var artifactsPath = path.join(`${process.env.GITHUB_WORKSPACE}`, "workflow-artifacts"); 108 | if (fs.existsSync(artifactsPath)) { 109 | this.customizerSource = artifactsPath; 110 | } 111 | } 112 | if (!(this.customizerSource == undefined || this.customizerSource == '' || this.customizerSource == null)) { 113 | var bp = this.customizerSource; 114 | var x = bp.split(path.sep); 115 | this.buildFolder = x[x.length - 1].split(".")[0]; 116 | this.buildPath = path.normalize(bp.trim()); 117 | console.log("Customizer source: " + this.customizerSource); 118 | console.log("Artifacts folder: " + this.buildFolder); 119 | } 120 | this.customizerScript = tl.getInput(constants.customizerScript).toString(); 121 | this.inlineScript = tl.getInput(constants.customizerScript); 122 | if (Utils_1.default.IsEqual(tl.getInput(constants.customizerWindowsUpdate), "true")) { 123 | this.windowsUpdateProvisioner = true; 124 | } 125 | else { 126 | this.windowsUpdateProvisioner = false; 127 | } 128 | //distribute inputs 129 | if (!this.isTemplateJsonProvided) { 130 | this.distributeType = tl.getInput(constants.DistributeType); 131 | const distResourceId = tl.getInput(constants.DistResourceId); 132 | const distLocation = tl.getInput(constants.DistLocation); 133 | if (!(Utils_1.default.IsEqual(this.distributeType, "VHD") || Utils_1.default.IsEqual(this.distributeType, "ManagedImage"))) { 134 | if (distResourceId == "" || distResourceId == undefined) { 135 | throw Error("Distributor Resource Id is required"); 136 | } 137 | if (distLocation == undefined || distLocation == "") { 138 | throw Error("Distributor Location is required"); 139 | } 140 | } 141 | if (Utils_1.default.IsEqual(this.distributeType, constants.managedImageSourceTypeImage)) { 142 | if (distResourceId) { 143 | this.imageIdForDistribute = distResourceId; 144 | } 145 | this.managedImageLocation = this.location; 146 | } 147 | else if (Utils_1.default.IsEqual(this.distributeType, constants.sharedImageGallerySourceTypeImage)) { 148 | this.galleryImageId = distResourceId; 149 | this.replicationRegions = distLocation; 150 | } 151 | this.distImageTags = tl.getInput(constants.DistImageTags); 152 | } 153 | this.runOutputName = tl.getInput(constants.RunOutputName); 154 | console.log("end reading parameters"); 155 | } 156 | _extractImageDetails(img) { 157 | this.imagePublisher = ""; 158 | this.imageOffer = ""; 159 | this.imageSku = ""; 160 | this.baseImageVersion; 161 | var parts = img.split(':'); 162 | if (parts.length != 4) { 163 | throw Error("Platform Base Image should have '{publisher}:{offer}:{sku}:{version}'. All fields are required."); 164 | } 165 | this.imagePublisher = parts[0]; 166 | this.imageOffer = parts[1]; 167 | this.imageSku = parts[2]; 168 | this.baseImageVersion = parts[3]; 169 | } 170 | } 171 | exports.default = TaskParameters; 172 | -------------------------------------------------------------------------------- /lib/Utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.NullOutstreamStringWritable = exports.getCurrentTime = void 0; 4 | const stream = require("stream"); 5 | class Utils { 6 | static IsEqual(a, b) { 7 | if (a !== undefined && a != null && b != null && b !== undefined) { 8 | return a.toLowerCase() == b.toLowerCase(); 9 | } 10 | return false; 11 | } 12 | } 13 | exports.default = Utils; 14 | exports.getCurrentTime = () => { 15 | return new Date().getTime().toString(); 16 | }; 17 | class NullOutstreamStringWritable extends stream.Writable { 18 | constructor(options) { 19 | super(options); 20 | } 21 | _write(data, encoding, callback) { 22 | if (callback) { 23 | callback(); 24 | } 25 | } 26 | } 27 | exports.NullOutstreamStringWritable = NullOutstreamStringWritable; 28 | ; 29 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.imageTemplateName = exports.containerName = exports.storageAccountName = exports.DistImageTags = exports.RunOutputName = exports.DistLocation = exports.DistResourceId = exports.DistributeType = exports.customizerDestination = exports.customizerScript = exports.customizerWindowsUpdate = exports.WindowsUpdateProvisioner = exports.InlineScript = exports.CustomizerSource = exports.sharedImageGallerySourceTypeImage = exports.managedImageSourceTypeImage = exports.marketPlaceSourceTypeImage = exports.platformImageSourceTypeImage = exports.SourceImage = exports.SourceOSType = exports.SourceImageType = exports.ManagedIdentity = exports.VMSize = exports.BuildTimeoutInMinutes = exports.ImageBuilderTemplateName = exports.ResourceGroupName = exports.Location = void 0; 4 | exports.Location = "location"; 5 | exports.ResourceGroupName = "resource-group-name"; 6 | exports.ImageBuilderTemplateName = "image-builder-template-name"; 7 | exports.BuildTimeoutInMinutes = "build-timeout-in-minutes"; 8 | exports.VMSize = "vm-size"; 9 | exports.ManagedIdentity = "managed-identity"; 10 | exports.SourceImageType = "source-image-type"; 11 | exports.SourceOSType = "source-os-type"; 12 | exports.SourceImage = "source-image"; 13 | exports.platformImageSourceTypeImage = "platformimage"; 14 | exports.marketPlaceSourceTypeImage = "marketplace"; 15 | exports.managedImageSourceTypeImage = "managedimage"; 16 | exports.sharedImageGallerySourceTypeImage = "SharedImageGallery"; 17 | exports.CustomizerSource = "customizer-source"; 18 | exports.InlineScript = "inlineScript"; 19 | exports.WindowsUpdateProvisioner = "windowsUpdateProvisioner"; 20 | exports.customizerWindowsUpdate = "customizer-windows-update"; 21 | exports.customizerScript = "customizer-script"; 22 | exports.customizerDestination = "customizer-destination"; 23 | exports.DistributeType = "dist-type"; 24 | exports.DistResourceId = "dist-resource-id"; 25 | exports.DistLocation = "dist-location"; 26 | exports.RunOutputName = "run-output-name"; 27 | exports.DistImageTags = "dist-image-tags"; 28 | exports.storageAccountName = "strgacc"; 29 | exports.containerName = "imagebuilder-aib-action"; 30 | exports.imageTemplateName = "imagebuilderTemplate_"; 31 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 10 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 11 | }) : function(o, v) { 12 | o["default"] = v; 13 | }); 14 | var __importStar = (this && this.__importStar) || function (mod) { 15 | if (mod && mod.__esModule) return mod; 16 | var result = {}; 17 | if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 18 | __setModuleDefault(result, mod); 19 | return result; 20 | }; 21 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 22 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 23 | return new (P || (P = Promise))(function (resolve, reject) { 24 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 25 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 26 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 27 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 28 | }); 29 | }; 30 | var __importDefault = (this && this.__importDefault) || function (mod) { 31 | return (mod && mod.__esModule) ? mod : { "default": mod }; 32 | }; 33 | Object.defineProperty(exports, "__esModule", { value: true }); 34 | const ImageBuilder_1 = __importDefault(require("./ImageBuilder")); 35 | const AuthorizerFactory_1 = require("azure-actions-webclient/AuthorizerFactory"); 36 | const core = __importStar(require("@actions/core")); 37 | function main() { 38 | return __awaiter(this, void 0, void 0, function* () { 39 | let azureResourceAuthorizer = yield AuthorizerFactory_1.AuthorizerFactory.getAuthorizer(); 40 | var ib = new ImageBuilder_1.default(azureResourceAuthorizer); 41 | yield ib.execute(); 42 | }); 43 | } 44 | main().then() 45 | .catch((error) => { 46 | console.log("$(imagebuilder-run-status) = ", "failed"); 47 | core.setOutput('imagebuilder-run-status', "failed"); 48 | core.error(error); 49 | core.setFailed("Action run failed."); 50 | }); 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-vm-image", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "main": "lib/main.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/Azure/build-vm-image.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/Azure/build-vm-image/issues" 19 | }, 20 | "homepage": "https://github.com/Azure/build-vm-image#readme", 21 | "dependencies": { 22 | "@actions/core": "^1.2.4", 23 | "@actions/exec": "^1.0.4", 24 | "@actions/io": "^1.0.2", 25 | "@types/node": "^14.6.0", 26 | "@types/q": "^1.5.4", 27 | "archiver": "^5.0.0", 28 | "azure-actions-webclient": "^1.0.11", 29 | "azure-storage": "^2.10.3", 30 | "eslint": "^5.16.0", 31 | "jszip": "^3.5.0", 32 | "tar.gz": "^1.0.7", 33 | "typescript": "^3.9.7", 34 | "zip-lib": "^0.7.1" 35 | }, 36 | "devDependencies": {} 37 | } 38 | -------------------------------------------------------------------------------- /src/AzureImageBuilderClient.ts: -------------------------------------------------------------------------------- 1 | import TaskParameters from './TaskParameters'; 2 | import { IAuthorizer } from 'azure-actions-webclient/Authorizer/IAuthorizer'; 3 | import { WebRequest, WebResponse } from 'azure-actions-webclient/WebClient'; 4 | import { ServiceClient as AzureRestClient, ToError, AzureError } from 'azure-actions-webclient/AzureRestClient'; 5 | import * as core from '@actions/core'; 6 | 7 | var apiVersion = "2020-02-14"; 8 | 9 | export default class ImageBuilderClient { 10 | 11 | private _client: AzureRestClient; 12 | private _taskParameters: TaskParameters; 13 | 14 | constructor(resourceAuthorizer: IAuthorizer, taskParameters: TaskParameters) { 15 | this._client = new AzureRestClient(resourceAuthorizer); 16 | this._taskParameters = taskParameters; 17 | } 18 | 19 | public async getTemplateId(templateName: string, subscriptionId: string): Promise { 20 | let httpRequest: WebRequest = { 21 | method: 'GET', 22 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion) 23 | }; 24 | var resourceId: string = ""; 25 | try { 26 | var response = await this._client.beginRequest(httpRequest); 27 | if (response.statusCode != 200 || response.body.status == "Failed") 28 | throw ToError(response); 29 | 30 | if (response.statusCode == 200 && response.body.id) 31 | resourceId = response.body.id; 32 | } 33 | catch (error) { 34 | throw Error(`Get template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 35 | } 36 | return resourceId; 37 | } 38 | 39 | public async putImageTemplate(template: string, templateName: string, subscriptionId: string) { 40 | console.log("Submitting the template"); 41 | let httpRequest: WebRequest = { 42 | method: 'PUT', 43 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion), 44 | body: template 45 | }; 46 | 47 | try { 48 | var response = await this._client.beginRequest(httpRequest); 49 | if (response.statusCode == 201) { 50 | response = await this.getLongRunningOperationResult(response); 51 | } 52 | if (response.statusCode != 200 || response.body.status == "Failed") { 53 | throw ToError(response); 54 | } 55 | if (response.statusCode == 200 && response.body && response.body.status == "Succeeded") { 56 | console.log("Submitted template: \n", response.body.status); 57 | } 58 | } 59 | catch (error) { 60 | throw Error(`Submit template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 61 | } 62 | } 63 | 64 | public async runTemplate(templateName: string, subscriptionId: string, timeOutInMinutes: number) { 65 | try { 66 | console.log("Starting run template..."); 67 | let httpRequest: WebRequest = { 68 | method: 'POST', 69 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}/run`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion) 70 | }; 71 | 72 | var response = await this._client.beginRequest(httpRequest); 73 | if (response.statusCode == 202) { 74 | response = await this.getLongRunningOperationResult(response, timeOutInMinutes); 75 | } 76 | if (response.statusCode != 200 || response.body.status == "Failed") { 77 | throw ToError(response); 78 | } 79 | if (response.statusCode == 200 && response.body && response.body.status == "Succeeded") { 80 | console.log("Run template: \n", response.body.status); 81 | } 82 | } 83 | catch (error) { 84 | throw Error(`Post template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 85 | } 86 | } 87 | 88 | public async deleteTemplate(templateName: string, subscriptionId: string) { 89 | try { 90 | console.log(`Deleting template ${templateName}...`); 91 | let httpRequest: WebRequest = { 92 | method: 'DELETE', 93 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName }, [], apiVersion) 94 | }; 95 | var response = await this._client.beginRequest(httpRequest); 96 | if (response.statusCode == 202) { 97 | response = await this.getLongRunningOperationResult(response); 98 | } 99 | if (response.statusCode != 200 || response.body.status == "Failed") { 100 | throw ToError(response); 101 | } 102 | 103 | if (response.statusCode == 200 && response.body && response.body.status == "Succeeded") { 104 | console.log("Delete template: ", response.body.status); 105 | } 106 | } 107 | catch (error) { 108 | throw Error(`Delete template call failed for template ${templateName} with error: ${JSON.stringify(error)}`); 109 | } 110 | } 111 | 112 | 113 | public async getRunOutput(templateName: string, runOutput: string, subscriptionId: string): Promise { 114 | let httpRequest: WebRequest = { 115 | method: 'GET', 116 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.VirtualMachineImages/imagetemplates/{imageTemplateName}/runOutputs/{runOutput}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{imageTemplateName}': templateName, '{runOutput}': runOutput }, [], apiVersion) 117 | }; 118 | var output: string = ""; 119 | try { 120 | var response = await this._client.beginRequest(httpRequest); 121 | if (response.statusCode != 200 || response.body.status == "Failed") 122 | throw ToError(response); 123 | if (response.statusCode == 200 && response.body) { 124 | if (response.body && response.body.properties.artifactId) 125 | output = response.body.properties.artifactId; 126 | else if (response.body && response.body.properties.artifactUri) 127 | output = response.body.properties.artifactUri; 128 | else 129 | console.log(`Error in parsing response.body -- ${response.body}.`); 130 | } 131 | } 132 | catch (error) { 133 | throw Error(`Get runOutput call failed for template ${templateName} for ${runOutput} with error: ${JSON.stringify(error)}`); 134 | } 135 | return output; 136 | } 137 | 138 | public async getLongRunningOperationResult(response: WebResponse, timeoutInMinutes?: number): Promise { 139 | var longRunningOperationRetryTimeout = !!timeoutInMinutes ? timeoutInMinutes : 0; 140 | timeoutInMinutes = timeoutInMinutes || longRunningOperationRetryTimeout; 141 | var timeout = new Date().getTime() + timeoutInMinutes * 60 * 1000; 142 | var waitIndefinitely = timeoutInMinutes == 0; 143 | var requestURI = response.headers["azure-asyncoperation"] || response.headers["location"]; 144 | let httpRequest: WebRequest = { 145 | method: 'GET', 146 | uri: requestURI 147 | }; 148 | if (!httpRequest.uri) { 149 | throw new Error("InvalidResponseLongRunningOperation"); 150 | } 151 | 152 | if (!httpRequest.uri) { 153 | console.log("error in uri " + httpRequest.uri); 154 | } 155 | while (true) { 156 | var response = await this._client.beginRequest(httpRequest); 157 | if (response.statusCode === 202 || (response.body && (response.body.status == "Accepted" || response.body.status == "Running" || response.body.status == "InProgress"))) { 158 | if (response.body && response.body.status) { 159 | core.debug(response.body.status); 160 | } 161 | if (!waitIndefinitely && timeout < new Date().getTime()) { 162 | throw Error(`error in url`); 163 | } 164 | var sleepDuration = 15; 165 | await this.sleepFor(sleepDuration); 166 | } else { 167 | break; 168 | } 169 | } 170 | 171 | return response; 172 | } 173 | 174 | private sleepFor(sleepDurationInSeconds: any): Promise { 175 | return new Promise((resolve, reject) => { 176 | setTimeout(resolve, sleepDurationInSeconds * 1000); 177 | }); 178 | } 179 | } -------------------------------------------------------------------------------- /src/BuildTemplate.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import path = require("path"); 3 | import TaskParameters from "./TaskParameters"; 4 | import Utils, { getCurrentTime } from "./Utils"; 5 | import { IAuthorizer } from 'azure-actions-webclient/Authorizer/IAuthorizer'; 6 | import { WebRequest } from 'azure-actions-webclient/WebClient'; 7 | import { ServiceClient as AzureRestClient, ToError, AzureError } from 'azure-actions-webclient/AzureRestClient'; 8 | 9 | var defaultTemplate = ` 10 | { 11 | "location": "", 12 | "identity": { 13 | "type": "UserAssigned", 14 | "userAssignedIdentities": { 15 | "IDENTITY": {} 16 | } 17 | }, 18 | "properties": { 19 | "source": SOURCE, 20 | "customize": [CUSTOMIZE], 21 | "distribute": [DISTRIBUTE], 22 | "vmProfile": { 23 | "vmSize": "VM_SIZE" 24 | } 25 | } 26 | } 27 | ` 28 | var templateSource = new Map([ 29 | ["managedimage", `{"type": "ManagedImage", "imageId": "IMAGE_ID"}`], 30 | ["sharedimagegallery", `{"type": "SharedImageVersion", "imageVersionId": "IMAGE_ID"}`], 31 | ["platformimage", `{"type": "PlatformImage", "publisher": "PUBLISHER_NAME", "offer": "OFFER_NAME","sku": "SKU_NAME", "version": "VERSION"}`] 32 | ]) 33 | 34 | var templateCustomizer = new Map([ 35 | ["shell", `{"type": "File", "name": "aibaction_file_copy", "sourceUri": "", "destination": ""},{"type": "Shell", "name": "aibaction_inline", "inline":[]}`], 36 | ["shellInline", `{"type": "Shell", "name": "aibaction_inline", "inline":[]}`], 37 | ["powershell", `{"type": "PowerShell", "name": "aibaction_inline", "inline":[]}`], 38 | ["windowsUpdate", `{"type": "PowerShell", "name": "5minWait_is_needed_before_windowsUpdate", "inline":["Start-Sleep -Seconds 300"]},{"type": "WindowsUpdate", "searchCriteria": "IsInstalled=0", "filters": ["exclude:$_.Title -like '*Preview*'", "include:$true"]}`] 39 | ]) 40 | 41 | var templateDistribute = new Map([ 42 | ["managedimage", `{"type": "ManagedImage", "imageId": "IMAGE_ID", "location": "", "runOutputName": "ManagedImage_distribute", "artifactTags": {"RunURL": "URL", "GitHubRepo": "GITHUB_REPO", "GithubCommit": "GITHUB_COMMIT"}}`], 43 | ["sharedimagegallery", `{"type": "SharedImage", "galleryImageId": "IMAGE_ID", "replicationRegions": [], "runOutputName": "SharedImage_distribute", "artifactTags": {"RunURL": "URL", "GitHubRepo": "GITHUB_REPO", "GithubCommit": "GITHUB_COMMIT"}}`], 44 | ["vhd", `{"type": "VHD", "runOutputName": "VHD_distribute"}`] 45 | ]) 46 | 47 | export default class BuildTemplate { 48 | private _taskParameters: TaskParameters; 49 | private _client: AzureRestClient; 50 | 51 | constructor(resourceAuthorizer: IAuthorizer, taskParameters: TaskParameters) { 52 | try { 53 | this._taskParameters = taskParameters; 54 | this._client = new AzureRestClient(resourceAuthorizer); 55 | } 56 | catch (error) { 57 | throw Error(error); 58 | } 59 | } 60 | 61 | private async getLatestVersion(subscriptionId: string): Promise { 62 | let httpRequest: WebRequest = { 63 | method: 'GET', 64 | uri: this._client.getRequestUri(`/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmimage/offers/{offer}/skus/{skus}/versions`, { '{subscriptionId}': subscriptionId, '{location}': this._taskParameters.location, '{publisherName}': this._taskParameters.imagePublisher, '{offer}': this._taskParameters.imageOffer, '{skus}': this._taskParameters.imageSku }, ["$orderby=name%20desc", "$top=1"], '2018-06-01') 65 | }; 66 | var latestVersion: string = ""; 67 | try { 68 | var response = await this._client.beginRequest(httpRequest); 69 | if (response.statusCode != 200 || response.body.statusCode == "Failed") { 70 | throw Error(response.statusCode.toString()); 71 | } 72 | if (response.statusCode == 200 && response.body) 73 | latestVersion = response.body[0].name; 74 | } 75 | catch (error) { 76 | throw Error(`failed to get latest image version: request uri ${httpRequest.uri}: ${error}`); 77 | } 78 | return latestVersion; 79 | } 80 | 81 | public async getTemplate(blobUrl: string, imgBuilderId: string, subscriptionId: string): Promise { 82 | var template = defaultTemplate; 83 | template = template.replace("IDENTITY", imgBuilderId); 84 | template = template.replace("VM_SIZE", this._taskParameters.vmSize); 85 | template = template.replace("SOURCE", templateSource.get(this._taskParameters.sourceImageType.toLowerCase())); 86 | template = template.replace("DISTRIBUTE", templateDistribute.get(this._taskParameters.distributeType.toLowerCase())); 87 | var customizers: any; 88 | if (Utils.IsEqual(this._taskParameters.provisioner, "shell") && (this._taskParameters.customizerSource == undefined || this._taskParameters.customizerSource.length == 0)) { 89 | customizers = templateCustomizer.get("shellInline"); 90 | } 91 | else { 92 | customizers = templateCustomizer.get(this._taskParameters.provisioner); 93 | } 94 | if (Utils.IsEqual(this._taskParameters.provisioner, "powershell") && this._taskParameters.windowsUpdateProvisioner) 95 | customizers = customizers + "," + templateCustomizer.get("windowsUpdate"); 96 | template = template.replace("CUSTOMIZE", customizers); 97 | 98 | var templateJson = JSON.parse(template); 99 | templateJson.location = this._taskParameters.location; 100 | if (Utils.IsEqual(templateJson.properties.source.type, "PlatformImage")) { 101 | templateJson.properties.source.publisher = this._taskParameters.imagePublisher; 102 | templateJson.properties.source.offer = this._taskParameters.imageOffer; 103 | templateJson.properties.source.sku = this._taskParameters.imageSku; 104 | if (Utils.IsEqual(this._taskParameters.baseImageVersion, "latest")) 105 | templateJson.properties.source.version = await this.getLatestVersion(subscriptionId); 106 | else 107 | templateJson.properties.source.version = this._taskParameters.baseImageVersion 108 | } 109 | else if (Utils.IsEqual(templateJson.properties.source.type, "ManagedImage")) 110 | templateJson.properties.source.imageId = this._taskParameters.sourceResourceId; 111 | else 112 | templateJson.properties.source.imageVersionId = this._taskParameters.imageVersionId; 113 | 114 | // customize 115 | if (Utils.IsEqual(this._taskParameters.provisioner, "shell")) { 116 | var inline: string = "#\n"; 117 | if (!(this._taskParameters.buildFolder == "")) { 118 | var packageName = `/tmp/${this._taskParameters.buildFolder}`; 119 | templateJson.properties.customize[0].sourceUri = blobUrl; 120 | templateJson.properties.customize[0].destination = `${packageName}.tar.gz`; 121 | inline += `mkdir -p ${packageName}\n` 122 | inline += `sudo tar -xzvf ${templateJson.properties.customize[0].destination} -C ${packageName}\n` 123 | if (this._taskParameters.inlineScript) 124 | inline += `${this._taskParameters.inlineScript}\n`; 125 | templateJson.properties.customize[1].inline = inline.split("\n"); 126 | } 127 | else { 128 | if (this._taskParameters.inlineScript) 129 | inline += `${this._taskParameters.inlineScript}\n`; 130 | templateJson.properties.customize[0].inline = inline.split("\n"); 131 | } 132 | } 133 | else if (Utils.IsEqual(this._taskParameters.provisioner, "powershell")) { 134 | var inline = ""; 135 | if (!(this._taskParameters.buildFolder == "")) { 136 | var packageName = "c:\\" + this._taskParameters.buildFolder; 137 | inline += `Invoke-WebRequest -Uri '${blobUrl}' -OutFile ${packageName}.zip -UseBasicParsing\n` 138 | inline += `Expand-Archive -Path ${packageName}.zip -DestinationPath ${packageName}\n` 139 | } 140 | 141 | if (this._taskParameters.inlineScript) 142 | inline += `${this._taskParameters.inlineScript}\n`; 143 | templateJson.properties.customize[0].inline = inline.split("\n"); 144 | } 145 | 146 | if (Utils.IsEqual(templateJson.properties.distribute[0].type, "ManagedImage")) { 147 | if (this._taskParameters.imageIdForDistribute == "" || this._taskParameters.imageIdForDistribute == undefined) { 148 | var imageDefn = "mi_" + getCurrentTime(); 149 | templateJson.properties.distribute[0].imageId = `/subscriptions/${subscriptionId}/resourceGroups/${this._taskParameters.resourceGroupName}/providers/Microsoft.Compute/images/${imageDefn}`; 150 | } 151 | else { 152 | templateJson.properties.distribute[0].imageId = this._taskParameters.imageIdForDistribute; 153 | } 154 | templateJson.properties.distribute[0].location = this._taskParameters.managedImageLocation; 155 | } 156 | 157 | if (Utils.IsEqual(templateJson.properties.distribute[0].type, "SharedImage")) { 158 | templateJson.properties.distribute[0].galleryImageId = this._taskParameters.galleryImageId; 159 | var regions = this._taskParameters.replicationRegions.split(","); 160 | templateJson.properties.distribute[0].replicationRegions = regions; 161 | } 162 | if (Utils.IsEqual(templateJson.properties.distribute[0].type, "SharedImage") || Utils.IsEqual(templateJson.properties.distribute[0].type, "ManagedImage")) { 163 | templateJson.properties.distribute[0].artifactTags.RunURL = process.env.GITHUB_SERVER_URL + "/" + process.env.GITHUB_REPOSITORY + "/actions/runs/" + process.env.GITHUB_RUN_ID; 164 | templateJson.properties.distribute[0].artifactTags.GitHubRepo = process.env.GITHUB_REPOSITORY; 165 | templateJson.properties.distribute[0].artifactTags.GithubCommit = process.env.GITHUB_SHA; 166 | if (this._taskParameters.distImageTags !== "" && this._taskParameters.distImageTags !== undefined) { 167 | var distImageTags = this._taskParameters.distImageTags.split(","); 168 | for (var i = 0; i < distImageTags.length; i++) { 169 | var distImageTag = distImageTags[i].split(":"); 170 | templateJson.properties.distribute[0].artifactTags[distImageTag[0]] = distImageTag[1]; 171 | } 172 | } 173 | } 174 | 175 | return templateJson; 176 | } 177 | 178 | public addUserCustomisationIfNeeded(blobUrl: string): any { 179 | let json: any = JSON.parse(this._taskParameters.templateJsonFromUser); 180 | let customizers: any = json.properties.customize; 181 | 182 | // add customization for custom source 183 | let fileCustomizer: any; 184 | if (!!this._taskParameters.customizerSource) { 185 | let windowsUpdateCustomizer: any; 186 | if (Utils.IsEqual(this._taskParameters.provisioner, "powershell") && this._taskParameters.windowsUpdateProvisioner) { 187 | windowsUpdateCustomizer = JSON.parse("[" + templateCustomizer.get("windowsUpdate") + "]"); 188 | for (var i = windowsUpdateCustomizer.length - 1; i >= 0; i--) { 189 | customizers.unshift(windowsUpdateCustomizer[i]); 190 | } 191 | } 192 | fileCustomizer = JSON.parse("[" + templateCustomizer.get(this._taskParameters.provisioner) + "]"); 193 | for (var i = fileCustomizer.length - 1; i >= 0; i--) { 194 | customizers.unshift(fileCustomizer[i]); 195 | } 196 | 197 | json.properties.customize = customizers; 198 | if (Utils.IsEqual(this._taskParameters.provisioner, "shell")) { 199 | var inline: string = "#\n"; 200 | if (!(this._taskParameters.buildFolder == "")) { 201 | var packageName = `/tmp/${this._taskParameters.buildFolder}`; 202 | json.properties.customize[0].sourceUri = blobUrl; 203 | json.properties.customize[0].destination = `${packageName}.tar.gz`; 204 | inline += `mkdir -p ${packageName}\n` 205 | inline += `sudo tar -xzvf ${json.properties.customize[0].destination} -C ${packageName}\n` 206 | } 207 | 208 | if (this._taskParameters.inlineScript) 209 | inline += `${this._taskParameters.inlineScript}\n`; 210 | json.properties.customize[1].inline = inline.split("\n"); 211 | } else if (Utils.IsEqual(this._taskParameters.provisioner, "powershell")) { 212 | var inline = ""; 213 | if (!(this._taskParameters.buildFolder == "")) { 214 | var packageName = "c:\\" + this._taskParameters.buildFolder; 215 | inline += `Invoke-WebRequest -Uri '${blobUrl}' -OutFile ${packageName}.zip -UseBasicParsing\n` 216 | inline += `Expand-Archive -Path ${packageName}.zip -DestinationPath ${packageName}\n` 217 | } 218 | 219 | if (this._taskParameters.inlineScript) 220 | inline += `${this._taskParameters.inlineScript}\n`; 221 | json.properties.customize[0].inline = inline.split("\n"); 222 | } 223 | } 224 | 225 | json.properties.customize = customizers; 226 | return json; 227 | } 228 | } -------------------------------------------------------------------------------- /src/ImageBuilder.ts: -------------------------------------------------------------------------------- 1 | import Q = require('q'); 2 | import path = require("path"); 3 | import * as core from '@actions/core'; 4 | import * as exec from '@actions/exec'; 5 | import * as io from '@actions/io'; 6 | import TaskParameters from "./TaskParameters"; 7 | import { getCurrentTime, NullOutstreamStringWritable } from "./Utils"; 8 | import ImageBuilderClient from "./AzureImageBuilderClient"; 9 | import BuildTemplate from "./BuildTemplate"; 10 | import { IAuthorizer } from 'azure-actions-webclient/Authorizer/IAuthorizer'; 11 | import Util = require('util'); 12 | import Utils from "./Utils"; 13 | var fs = require('fs'); 14 | var archiver = require('archiver'); 15 | import * as constants from "./constants"; 16 | import { WebRequest } from 'azure-actions-webclient/WebClient'; 17 | import { ServiceClient as AzureRestClient } from 'azure-actions-webclient/AzureRestClient'; 18 | var azure = require('azure-storage'); 19 | 20 | var azPath: string; 21 | var storageAccountExists: boolean = false; 22 | export default class ImageBuilder { 23 | 24 | private _taskParameters: TaskParameters; 25 | private _aibClient: ImageBuilderClient; 26 | private _buildTemplate: BuildTemplate; 27 | private _blobService: any; 28 | private _client: AzureRestClient; 29 | 30 | private isVhdDistribute: boolean = false; 31 | private templateName: string = ""; 32 | private storageAccount: string = ""; 33 | private containerName: string = ""; 34 | private idenityName: string = ""; 35 | private imgBuilderTemplateExists: boolean = false; 36 | private accountkeys: string = ""; 37 | 38 | constructor(resourceAuthorizer: IAuthorizer) { 39 | try { 40 | this._taskParameters = new TaskParameters(); 41 | this._buildTemplate = new BuildTemplate(resourceAuthorizer, this._taskParameters); 42 | this._aibClient = new ImageBuilderClient(resourceAuthorizer, this._taskParameters); 43 | this._client = new AzureRestClient(resourceAuthorizer); 44 | this.idenityName = this._taskParameters.managedIdentity; 45 | } 46 | catch (error) { 47 | throw (`Error happened while initializing Image builder: ${error}`); 48 | } 49 | } 50 | 51 | async execute() { 52 | try { 53 | azPath = await io.which("az", true); 54 | core.debug("Az module path: " + azPath); 55 | var outStream = ''; 56 | await this.executeAzCliCommand("--version"); 57 | await this.registerFeatures(); 58 | 59 | //GENERAL INPUTS 60 | outStream = await this.executeAzCliCommand("account show"); 61 | var subscriptionId = JSON.parse(`${outStream}`).id.toString(); 62 | 63 | var isCreateBlob = false; 64 | var imgBuilderId = ""; 65 | 66 | if (this._taskParameters.customizerSource != undefined && this._taskParameters.customizerSource != "") { 67 | isCreateBlob = true; 68 | } 69 | 70 | if (!this._taskParameters.isTemplateJsonProvided) { 71 | if (this.idenityName.startsWith("/subscriptions/")) { 72 | imgBuilderId = this.idenityName; 73 | } 74 | else { 75 | imgBuilderId = `/subscriptions/${subscriptionId}/resourcegroups/${this._taskParameters.resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/${this.idenityName}`; 76 | } 77 | } 78 | else { 79 | var template = JSON.parse(this._taskParameters.templateJsonFromUser); 80 | this._taskParameters.location = template.location; 81 | } 82 | 83 | console.log("Using Managed Identity " + this.idenityName); 84 | var blobUrl = ""; 85 | if (isCreateBlob) { 86 | //create a blob service 87 | await this.createStorageAccount(); 88 | this._blobService = azure.createBlobService(this.storageAccount, this.accountkeys); 89 | this.containerName = constants.containerName; 90 | var blobName: string = this._taskParameters.buildFolder + "/" + process.env.GITHUB_RUN_ID + "/" + this._taskParameters.buildFolder + `_${getCurrentTime()}`; 91 | if (Utils.IsEqual(this._taskParameters.provisioner, "powershell")) 92 | blobName = blobName + '.zip'; 93 | else 94 | blobName = blobName + '.tar.gz'; 95 | 96 | blobUrl = await this.uploadPackage(this.containerName, blobName); 97 | core.debug("Blob Url: " + blobUrl); 98 | } 99 | 100 | let templateJson: any = ""; 101 | 102 | if (!this._taskParameters.isTemplateJsonProvided) { 103 | templateJson = await this._buildTemplate.getTemplate(blobUrl, imgBuilderId, subscriptionId); 104 | } else { 105 | templateJson = this._buildTemplate.addUserCustomisationIfNeeded(blobUrl); 106 | } 107 | 108 | this.templateName = this.getTemplateName(); 109 | var runOutputName = this.getRunoutputName(); 110 | templateJson.properties.distribute[0].runOutputName = runOutputName; 111 | this.isVhdDistribute = templateJson.properties.distribute[0].type == "VHD"; 112 | 113 | var templateStr = JSON.stringify(templateJson, null, 2); 114 | console.log("Template Name: " + this.templateName); 115 | console.log("Template: \n" + templateStr); 116 | await this._aibClient.putImageTemplate(templateStr, this.templateName, subscriptionId); 117 | this.imgBuilderTemplateExists = true; 118 | 119 | await this._aibClient.runTemplate(this.templateName, subscriptionId, this._taskParameters.buildTimeoutInMinutes); 120 | var out = await this._aibClient.getRunOutput(this.templateName, runOutputName, subscriptionId); 121 | var templateID = await this._aibClient.getTemplateId(this.templateName, subscriptionId); 122 | var imagebuilderRunStatus = "failed"; 123 | core.setOutput('templateName', this.templateName); 124 | core.setOutput('templateId', templateID); 125 | core.setOutput('run-output-name', runOutputName); 126 | if (out) { 127 | core.setOutput('custom-image-uri', out); 128 | core.setOutput('imagebuilder-run-status', "succeeded"); 129 | imagebuilderRunStatus = "succeeded"; 130 | } 131 | 132 | if (Utils.IsEqual(templateJson.properties.source.type, "PlatformImage")) { 133 | core.setOutput('pirPublisher', templateJson.properties.source.publisher); 134 | core.setOutput('pirOffer', templateJson.properties.source.offer); 135 | core.setOutput('pirSku', templateJson.properties.source.sku); 136 | core.setOutput('pirVersion', templateJson.properties.source.version); 137 | } 138 | 139 | console.log("==============================================================================") 140 | console.log("## task output variables ##"); 141 | console.log("$(imagebuilder-run-status) = ", imagebuilderRunStatus); 142 | console.log("$(imageUri) = ", out); 143 | if (this.isVhdDistribute) { 144 | console.log("$(templateName) = ", this.templateName); 145 | console.log("$(templateId) = ", templateID); 146 | } 147 | console.log("==============================================================================") 148 | } 149 | catch (error) { 150 | throw error; 151 | } 152 | finally { 153 | var outStream = await this.executeAzCliCommand(`group exists -n ${this._taskParameters.resourceGroupName}`); 154 | if (outStream) { 155 | this.cleanup(subscriptionId); 156 | } 157 | } 158 | } 159 | 160 | private async createStorageAccount() { 161 | this.storageAccount = Util.format('%s%s', constants.storageAccountName, getCurrentTime()); 162 | await this.executeAzCliCommand(`storage account create --name "${this.storageAccount}" --resource-group "${this._taskParameters.resourceGroupName}" --location "${this._taskParameters.location}" --sku Standard_RAGRS`); 163 | core.debug("Created storage account " + this.storageAccount); 164 | var outStream = await this.executeAzCliCommand(`storage account keys list -g "${this._taskParameters.resourceGroupName}" -n "${this.storageAccount}"`); 165 | this.accountkeys = JSON.parse(`${outStream}`)[0].value; 166 | storageAccountExists = true; 167 | } 168 | 169 | private async registerFeatures() { 170 | var outStream = await this.executeAzCliCommand(`feature show --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview`); 171 | if (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).properties.state, "Registered")) { 172 | core.info("Registering Microsoft.VirtualMachineImages"); 173 | await this.executeAzCliCommand("feature register --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview"); 174 | outStream = await this.executeAzCliCommand(`feature show --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview`); 175 | while (!Utils.IsEqual(JSON.parse(outStream).properties.state, "Registered")) { 176 | this.sleepFor(1); 177 | outStream = await this.executeAzCliCommand(`feature show --namespace Microsoft.VirtualMachineImages --name VirtualMachineTemplatePreview`); 178 | } 179 | } 180 | 181 | outStream = ''; 182 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.VirtualMachineImages`); 183 | if (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 184 | await this.executeAzCliCommand("provider register -n Microsoft.VirtualMachineImages"); 185 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.VirtualMachineImages`); 186 | while (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 187 | this.sleepFor(1); 188 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.VirtualMachineImages`); 189 | } 190 | } 191 | 192 | outStream = ''; 193 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.Storage`); 194 | if (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 195 | core.info("Registering Microsoft.Storage"); 196 | await this.executeAzCliCommand("provider register -n Microsoft.Storage"); 197 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.Storage`); 198 | while (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 199 | this.sleepFor(1); 200 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.Storage`); 201 | } 202 | } 203 | 204 | outStream = ''; 205 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.Compute`); 206 | if (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 207 | core.info("Registering Microsoft.Compute"); 208 | await this.executeAzCliCommand("provider register -n Microsoft.Compute"); 209 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.Compute`); 210 | while (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 211 | this.sleepFor(1); 212 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.Compute`); 213 | } 214 | } 215 | 216 | outStream = ''; 217 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.KeyVault`); 218 | if (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 219 | core.info("Registering Microsoft.KeyVault"); 220 | await this.executeAzCliCommand("provider register -n Microsoft.KeyVault"); 221 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.KeyVault`); 222 | while (JSON.parse(outStream) && !Utils.IsEqual(JSON.parse(outStream).registrationState, "Registered")) { 223 | this.sleepFor(1); 224 | outStream = await this.executeAzCliCommand(`provider show -n Microsoft.KeyVault`); 225 | } 226 | } 227 | } 228 | 229 | private getTemplateName() { 230 | if (this._taskParameters.isTemplateJsonProvided) { 231 | var templateName = this.getTemplateNameFromProvidedJson(this._taskParameters.templateJsonFromUser); 232 | return templateName == "" ? constants.imageTemplateName + getCurrentTime() : templateName; 233 | } else if (!this._taskParameters.isTemplateJsonProvided && this._taskParameters.imagebuilderTemplateName) { 234 | return this._taskParameters.imagebuilderTemplateName; 235 | } 236 | return constants.imageTemplateName + getCurrentTime(); 237 | } 238 | 239 | private getRunoutputName() { 240 | var runOutputName = this._taskParameters.runOutputName; 241 | if (runOutputName == "") { 242 | if (this._taskParameters.isTemplateJsonProvided) { 243 | var runOutputName = this.getRunoutputNameFromProvidedJson(this._taskParameters.templateJsonFromUser); 244 | return runOutputName == "" ? this.templateName + "_" + process.env.GITHUB_RUN_ID : runOutputName; 245 | } else { 246 | return this.templateName + "_" + process.env.GITHUB_RUN_ID 247 | } 248 | } 249 | 250 | return ""; 251 | } 252 | 253 | private getTemplateNameFromProvidedJson(templateJson: string): string { 254 | var template = JSON.parse(templateJson); 255 | if (template.tags && template.tags.imagebuilderTemplate) { 256 | return template.tags.imagebuilderTemplate; 257 | } 258 | 259 | return ""; 260 | } 261 | 262 | private getRunoutputNameFromProvidedJson(templateJson: string): string { 263 | var template = JSON.parse(templateJson); 264 | if (template.properties.distribute && template.properties.distribute[0].runOutputName) { 265 | return template.properties.distribute[0].runOutputName; 266 | } 267 | 268 | return ""; 269 | } 270 | 271 | private async uploadPackage(containerName: string, blobName: string): Promise { 272 | 273 | var defer = Q.defer(); 274 | var archivedWebPackage: any; 275 | var temp = this._generateTemporaryFile(`${process.env.GITHUB_WORKSPACE}`); 276 | try { 277 | if (Utils.IsEqual(this._taskParameters.provisioner, "powershell")) { 278 | temp = temp + `.zip`; 279 | archivedWebPackage = await this.createArchiveTar(this._taskParameters.buildPath, temp, "zip"); 280 | } 281 | else { 282 | temp = temp + `.tar.gz`; 283 | archivedWebPackage = await this.createArchiveTar(this._taskParameters.buildPath, temp, "tar"); 284 | } 285 | } 286 | catch (error) { 287 | defer.reject(console.log(`unable to create archive build: ${error}`)); 288 | } 289 | console.log(`created archive ` + archivedWebPackage); 290 | 291 | this._blobService.createContainerIfNotExists(containerName, (error: any) => { 292 | if (error) { 293 | defer.reject(console.log(`unable to create container ${containerName} in storage account: ${error}`)); 294 | } 295 | 296 | //upoading package 297 | this._blobService.createBlockBlobFromLocalFile(containerName, blobName, archivedWebPackage, (error: any, result: any) => { 298 | if (error) { 299 | defer.reject(console.log(`unable to create blob ${blobName} in container ${containerName} in storage account: ${error}`)); 300 | } 301 | //generating SAS URL 302 | var startDate = new Date(); 303 | var expiryDate = new Date(startDate); 304 | expiryDate.setFullYear(startDate.getUTCFullYear() + 1); 305 | startDate.setMinutes(startDate.getMinutes() - 5); 306 | 307 | var sharedAccessPolicy = { 308 | AccessPolicy: { 309 | Permissions: azure.BlobUtilities.SharedAccessPermissions.READ, 310 | Start: startDate, 311 | Expiry: expiryDate 312 | } 313 | }; 314 | 315 | var token = this._blobService.generateSharedAccessSignature(containerName, blobName, sharedAccessPolicy); 316 | var blobUrl = this._blobService.getUrl(containerName, blobName, token); 317 | defer.resolve(blobUrl); 318 | }); 319 | }); 320 | return defer.promise; 321 | } 322 | 323 | public async createArchiveTar(folderPath: string, targetPath: string, extension: string) { 324 | var defer = Q.defer(); 325 | console.log('Archiving ' + folderPath + ' to ' + targetPath); 326 | var output = fs.createWriteStream(targetPath); 327 | var archive: any; 328 | 329 | if (Utils.IsEqual(extension, 'zip')) { 330 | archive = archiver('zip', { zlib: { level: 9 } }); 331 | } 332 | else { 333 | archive = archiver('tar', { 334 | gzip: true, 335 | gzipOptions: { 336 | level: 1 337 | } 338 | }); 339 | } 340 | 341 | output.on('close', function () { 342 | console.log(archive.pointer() + ' total bytes'); 343 | core.debug('Successfully created archive ' + targetPath); 344 | defer.resolve(targetPath); 345 | }); 346 | 347 | output.on('error', function (error: any) { 348 | defer.reject(error); 349 | }); 350 | 351 | var stats = fs.statSync(folderPath); 352 | if (stats.isFile()) { 353 | archive.file(folderPath, { name: this._taskParameters.buildFolder }); 354 | } 355 | else { 356 | archive.glob("**", { 357 | cwd: folderPath, 358 | dot: true 359 | }); 360 | } 361 | 362 | archive.pipe(output); 363 | archive.finalize(); 364 | 365 | return defer.promise; 366 | } 367 | 368 | private _generateTemporaryFile(folderPath: string): string { 369 | var randomString = Math.random().toString().split('.')[1]; 370 | var tempPath = path.join(folderPath, '/temp_web_package_' + randomString); 371 | return tempPath; 372 | } 373 | 374 | private async cleanup(subscriptionId: string) { 375 | try { 376 | if (!this.isVhdDistribute && this.imgBuilderTemplateExists) { 377 | await this._aibClient.deleteTemplate(this.templateName, subscriptionId); 378 | console.log(`${this.templateName} got deleted`); 379 | } 380 | 381 | if (storageAccountExists) { 382 | let httpRequest: WebRequest = { 383 | method: 'DELETE', 384 | uri: this._client.getRequestUri(`subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccount}`, { '{subscriptionId}': subscriptionId, '{resourceGroupName}': this._taskParameters.resourceGroupName, '{storageAccount}': this.storageAccount }, [], "2019-06-01") 385 | }; 386 | var response = await this._client.beginRequest(httpRequest); 387 | console.log("storage account " + this.storageAccount + " deleted"); 388 | } 389 | } 390 | catch (error) { 391 | console.log(`Error in cleanup: `, error); 392 | } 393 | } 394 | 395 | async executeAzCliCommand(command: string): Promise { 396 | var outStream: string = ''; 397 | var errorStream: string = ''; 398 | var execOptions: any = { 399 | outStream: new NullOutstreamStringWritable({ decodeStrings: false }), 400 | listeners: { 401 | stdout: (data: any) => outStream += data.toString(), 402 | errline: (data: string) => { 403 | errorStream += data; 404 | } 405 | } 406 | }; 407 | try { 408 | await exec.exec(`"${azPath}" ${command}`, [], execOptions); 409 | return outStream; 410 | } 411 | catch (error) { 412 | if (errorStream != '') 413 | throw (`${errorStream} ${error}`); 414 | else 415 | throw (`${error}`); 416 | } 417 | } 418 | 419 | private sleepFor(sleepDurationInSeconds: any): Promise { 420 | return new Promise((resolve) => { 421 | setTimeout(resolve, sleepDurationInSeconds * 1000); 422 | }); 423 | } 424 | } -------------------------------------------------------------------------------- /src/TaskParameters.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import path = require("path"); 3 | import * as tl from '@actions/core'; 4 | import * as constants from "./constants"; 5 | import Utils from "./Utils"; 6 | var fs = require('fs'); 7 | 8 | export default class TaskParameters { 9 | // image builder inputs 10 | public resourceGroupName: string = ""; 11 | public location: string = ""; 12 | public imagebuilderTemplateName: string; 13 | public isTemplateJsonProvided: boolean = false; 14 | public templateJsonFromUser: string = ''; 15 | public buildTimeoutInMinutes: number = 240; 16 | public vmSize: string = ""; 17 | public managedIdentity: string = ""; 18 | 19 | // source 20 | public sourceImageType: string = ""; 21 | public sourceOSType: string = ""; 22 | public sourceResourceId: string = ""; 23 | public imageVersionId: string = ""; 24 | public baseImageVersion: string = ""; 25 | public imagePublisher: string = ""; 26 | public imageOffer: string = ""; 27 | public imageSku: string = ""; 28 | 29 | //customize 30 | public buildPath: string = ""; 31 | public buildFolder: string = ""; 32 | public blobName: string = ""; 33 | public inlineScript: string; 34 | public provisioner: string = ""; 35 | public windowsUpdateProvisioner: boolean; 36 | public customizerSource: string = ""; 37 | public customizerScript: string = ""; 38 | public customizerWindowsUpdate: string = ""; 39 | 40 | //distribute 41 | public distributeType: string = ""; 42 | public imageIdForDistribute: string = ""; 43 | public replicationRegions: string = ""; 44 | public managedImageLocation: string = ""; 45 | public galleryImageId: string = ""; 46 | public runOutputName: string; 47 | public distImageTags: string = ""; 48 | 49 | constructor() { 50 | var locations = ["eastus", "eastus2", "westcentralus", "westus", "westus2", "westus3", "southcentralus", "northeurope", "westeurope", "southeastasia", "australiasoutheast", "australiaeast", "uksouth", "ukwest", "brazilsouth", "canadacentral", "centralindia", "centralus", "francecentral", "germanywestcentral", "japaneast", "northcentralus", "norwayeast", "switzerlandnorth", "jioindiawest", "uaenorth", "eastasia", "koreacentral", "southafricanorth", "usgovarizona", "usgovvirginia"]; 51 | 52 | console.log("start reading task parameters..."); 53 | 54 | this.imagebuilderTemplateName = tl.getInput(constants.ImageBuilderTemplateName); 55 | if (this.imagebuilderTemplateName.indexOf(".json") > -1) { 56 | this.isTemplateJsonProvided = true; 57 | var data = fs.readFileSync(this.imagebuilderTemplateName, 'utf8'); 58 | this.templateJsonFromUser = JSON.parse(JSON.stringify(data)); 59 | } 60 | 61 | this.resourceGroupName = tl.getInput(constants.ResourceGroupName, { required: true }); 62 | this.buildTimeoutInMinutes = parseInt(tl.getInput(constants.BuildTimeoutInMinutes)); 63 | this.sourceOSType = tl.getInput(constants.SourceOSType, { required: true }); 64 | if (Utils.IsEqual(this.sourceOSType, "windows")) { 65 | this.provisioner = "powershell"; 66 | } 67 | else { 68 | this.provisioner = "shell"; 69 | } 70 | 71 | if (!this.isTemplateJsonProvided) { 72 | //general inputs 73 | this.location = tl.getInput(constants.Location, { required: true }); 74 | if (!(locations.indexOf(this.location.toString().replace(/\s/g, "").toLowerCase()) > -1)) { 75 | throw new Error("location not from available regions or it is not defined"); 76 | } 77 | this.managedIdentity = tl.getInput(constants.ManagedIdentity, { required: true }); 78 | //vm size 79 | this.vmSize = tl.getInput(constants.VMSize); 80 | 81 | //source inputs 82 | this.sourceImageType = tl.getInput(constants.SourceImageType); 83 | var sourceImage = tl.getInput(constants.SourceImage, { required: true }); 84 | if (Utils.IsEqual(this.sourceImageType, constants.platformImageSourceTypeImage) || Utils.IsEqual(this.sourceImageType, constants.marketPlaceSourceTypeImage)) { 85 | this.sourceImageType = constants.platformImageSourceTypeImage; 86 | this._extractImageDetails(sourceImage); 87 | } 88 | else if (Utils.IsEqual(this.sourceImageType, constants.managedImageSourceTypeImage)) { 89 | this.sourceResourceId = sourceImage; 90 | } 91 | else { 92 | this.imageVersionId = sourceImage; 93 | } 94 | } 95 | //customize inputs 96 | this.customizerSource = tl.getInput(constants.CustomizerSource).toString(); 97 | if (this.customizerSource == undefined || this.customizerSource == "" || this.customizerSource == null) { 98 | var artifactsPath = path.join(`${process.env.GITHUB_WORKSPACE}`, "workflow-artifacts"); 99 | if (fs.existsSync(artifactsPath)) { 100 | this.customizerSource = artifactsPath; 101 | } 102 | } 103 | 104 | if (!(this.customizerSource == undefined || this.customizerSource == '' || this.customizerSource == null)) { 105 | var bp = this.customizerSource; 106 | var x = bp.split(path.sep); 107 | this.buildFolder = x[x.length - 1].split(".")[0]; 108 | this.buildPath = path.normalize(bp.trim()); 109 | console.log("Customizer source: " + this.customizerSource); 110 | console.log("Artifacts folder: " + this.buildFolder); 111 | } 112 | 113 | this.customizerScript = tl.getInput(constants.customizerScript).toString(); 114 | this.inlineScript = tl.getInput(constants.customizerScript); 115 | if (Utils.IsEqual(tl.getInput(constants.customizerWindowsUpdate), "true")) { 116 | this.windowsUpdateProvisioner = true; 117 | } 118 | else { 119 | this.windowsUpdateProvisioner = false; 120 | } 121 | 122 | //distribute inputs 123 | if (!this.isTemplateJsonProvided) { 124 | this.distributeType = tl.getInput(constants.DistributeType); 125 | 126 | const distResourceId = tl.getInput(constants.DistResourceId); 127 | const distLocation = tl.getInput(constants.DistLocation); 128 | 129 | if (!(Utils.IsEqual(this.distributeType, "VHD") || Utils.IsEqual(this.distributeType, "ManagedImage"))) { 130 | if (distResourceId == "" || distResourceId == undefined) { 131 | throw Error("Distributor Resource Id is required"); 132 | } 133 | if (distLocation == undefined || distLocation == "") { 134 | throw Error("Distributor Location is required"); 135 | } 136 | } 137 | if (Utils.IsEqual(this.distributeType, constants.managedImageSourceTypeImage)) { 138 | if (distResourceId) { 139 | this.imageIdForDistribute = distResourceId; 140 | } 141 | this.managedImageLocation = this.location; 142 | } 143 | else if (Utils.IsEqual(this.distributeType, constants.sharedImageGallerySourceTypeImage)) { 144 | this.galleryImageId = distResourceId; 145 | this.replicationRegions = distLocation; 146 | } 147 | this.distImageTags = tl.getInput(constants.DistImageTags); 148 | } 149 | 150 | this.runOutputName = tl.getInput(constants.RunOutputName); 151 | 152 | console.log("end reading parameters") 153 | } 154 | 155 | private _extractImageDetails(img: string) { 156 | this.imagePublisher = ""; 157 | this.imageOffer = ""; 158 | this.imageSku = ""; 159 | this.baseImageVersion 160 | var parts = img.split(':'); 161 | if (parts.length != 4) { 162 | throw Error("Platform Base Image should have '{publisher}:{offer}:{sku}:{version}'. All fields are required.") 163 | } 164 | this.imagePublisher = parts[0]; 165 | this.imageOffer = parts[1]; 166 | this.imageSku = parts[2]; 167 | this.baseImageVersion = parts[3]; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import stream = require('stream'); 3 | 4 | export default class Utils { 5 | public static IsEqual(a: string, b: string): boolean { 6 | if (a !== undefined && a != null && b != null && b !== undefined) { 7 | return a.toLowerCase() == b.toLowerCase(); 8 | } 9 | return false; 10 | } 11 | } 12 | 13 | export const getCurrentTime = (): string => { 14 | return new Date().getTime().toString(); 15 | } 16 | 17 | export class NullOutstreamStringWritable extends stream.Writable { 18 | 19 | constructor(options: any) { 20 | super(options); 21 | } 22 | 23 | _write(data: any, encoding: string, callback: Function): void { 24 | if (callback) { 25 | callback(); 26 | } 27 | } 28 | }; -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export var Location = "location"; 2 | export var ResourceGroupName = "resource-group-name"; 3 | export var ImageBuilderTemplateName = "image-builder-template-name"; 4 | export var BuildTimeoutInMinutes = "build-timeout-in-minutes"; 5 | export var VMSize = "vm-size"; 6 | export var ManagedIdentity = "managed-identity"; 7 | 8 | export var SourceImageType = "source-image-type"; 9 | export var SourceOSType = "source-os-type"; 10 | export var SourceImage = "source-image"; 11 | export var platformImageSourceTypeImage = "platformimage"; 12 | export var marketPlaceSourceTypeImage = "marketplace"; 13 | export var managedImageSourceTypeImage = "managedimage"; 14 | export var sharedImageGallerySourceTypeImage = "SharedImageGallery"; 15 | 16 | export var CustomizerSource = "customizer-source"; 17 | export var InlineScript = "inlineScript"; 18 | export var WindowsUpdateProvisioner = "windowsUpdateProvisioner"; 19 | 20 | export var customizerWindowsUpdate = "customizer-windows-update"; 21 | export var customizerScript = "customizer-script"; 22 | export var customizerDestination = "customizer-destination"; 23 | 24 | export var DistributeType = "dist-type"; 25 | export var DistResourceId = "dist-resource-id"; 26 | export var DistLocation = "dist-location"; 27 | export var RunOutputName = "run-output-name"; 28 | export var DistImageTags = "dist-image-tags"; 29 | 30 | export var storageAccountName = "strgacc"; 31 | export var containerName = "imagebuilder-aib-action"; 32 | export var imageTemplateName = "imagebuilderTemplate_"; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ImageBuilder from './ImageBuilder'; 2 | import { AuthorizerFactory } from "azure-actions-webclient/AuthorizerFactory"; 3 | import * as core from '@actions/core'; 4 | 5 | async function main(): Promise { 6 | let azureResourceAuthorizer = await AuthorizerFactory.getAuthorizer(); 7 | var ib = new ImageBuilder(azureResourceAuthorizer); 8 | await ib.execute(); 9 | } 10 | 11 | main().then() 12 | .catch((error) => { 13 | console.log("$(imagebuilder-run-status) = ", "failed"); 14 | core.setOutput('imagebuilder-run-status', "failed"); 15 | core.error(error); 16 | core.setFailed("Action run failed."); 17 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } -------------------------------------------------------------------------------- /tutorial/_imgs/bakedimage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/build-vm-image/49519e8b4a718414222fa83243bc6bc019a3edf1/tutorial/_imgs/bakedimage.png -------------------------------------------------------------------------------- /tutorial/_imgs/role-assignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/build-vm-image/49519e8b4a718414222fa83243bc6bc019a3edf1/tutorial/_imgs/role-assignment.png -------------------------------------------------------------------------------- /tutorial/_imgs/sig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure/build-vm-image/49519e8b4a718414222fa83243bc6bc019a3edf1/tutorial/_imgs/sig.png -------------------------------------------------------------------------------- /tutorial/_imgs/text.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tutorial/how-to-use-action.md: -------------------------------------------------------------------------------- 1 | # Deploying Immutable Infrastructure gets easier with Build Azure Virtual Machine Image Action 2 | 3 | In a traditional mutable infrastructure, virtual machines are constantly updated for configuration changes, software installations, security hardening etc. It is a usual practice for developers or sysops teams to SSH into their virtual machines and install packages manually or run scripts. In other terms, the Virtual machine mutates regularly from its original state. 4 | 5 | As organizations mature, the number of virtual machines increases and the need for automation arises in order to achieve consistency across machines. However, even with automation there are situations where a run fails or behaves slightly differently on a particular machine because of an events like network failure, OS update failure, installation retries etc. When seen across hundreds of virtual machines and across multiple updates, each virtual machine can behave slightly differnt from the rest. This leads to inconsistency, unreliability and errors that are unique per virtual machine. 6 | 7 | To get rid of all the above problems, organizations are now moving towards immutable approach. Once a VM is created from an image, it is never changed. If a configuration needs to be updated or a new software version needs to be installed, this change is first done on a VM image, new VMs are created, tested and then the new image is distributed to be used for creating new VMs. As the traffic is redirected to these newer VMs, the older VMs are decommissioned. 8 | 9 | In order to help you with deploying immutable VMs, we have launched a new [Build Azure Virtual Machine](https://github.com/marketplace/actions/build-azure-virtual-machine-image) action. This action can help you get started very quickly on this journey. It primarily has 3 input categories: 10 | 11 | - Source image: This is the image on which customizations will be done. You can use readily available [azure marketplace images](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/cli-ps-findimage) or provide one that has been customized and created by you. 12 | - Customizations: These are the steps that will be run on the source image. If your CI is in GitHub, then you can easily inject all your built artifacts/ scripts into the custom image and run them for installation. 13 | - Distribution: This is the distribution methodology to be used for the created custom image. This action supports [Shared Image Gallery](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/shared-image-galleries), Managed Image or a VHD. 14 | 15 | Note that internally this action behind the scenes leverages [Azure Image Builder](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/image-builder-overview) service. All the inputs provided in the action are processed and submitted to Azure Image Builder service. 16 | 17 | 18 | ## Sample Walkthrough: 19 | 20 | Lets now quickly see how you can easily start using this action. In the following sections, we'll describe a workflow that: 21 | 22 | - Uses a Microsoft windows server platform image from Azure as the source image 23 | - Injects a webapp to the image 24 | - Configures IIS server on the image 25 | - Distributes it to a Shared Image Gallery 26 | - Creates a VM using the above created custom image 27 | 28 | 29 | ### Pre-requisites: 30 | - *Azure Login Credentials*: In the workflow we will be accessing resources in azure, so you'll need to generate your credentials and store it as a GitHub secret. This secret will be used by Azure Login Action. You can refer [configuring azure credentials for Azure login action](https://github.com/zenithworks/Custom_VM_Image#configure-credentials-for-azure-login-action) to create this. 31 | 32 | - *Shared Image Gallery*: We will be uploading the created custom VM image to a shared image gallery. So this should already be present. Learn [how to create a Shared image gallery](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/shared-images-portal#:~:text=In%20the%20Shared%20image%20gallery,the%20name%20of%20the%20gallery.). Remember to select "Windows" as the operating system when configuring the image definition in the Shared image gallery. This is because we will be customizing a windows image. Also the region should be same as one of the [regions supported](https://docs.microsoft.com/en-us/azure/virtual-machines/windows/image-builder-overview#regions) by Azure Image Builder. Select 'East US', if confused. 33 | ![Image of SharedImageGallery](./_imgs/sig.png) 34 | 35 | 36 | - *User Assigned Managed Identity*: A managed identity is required by Azure Image Builder to distribute images using Shared Image Gallery. For this you'll need to 37 | - Create a [user assigned managed identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-manage-ua-identity-cli) 38 | - Give permission to the above managed identity on the Shared Image Gallery. For this, 39 | - Visit the shared image gallery in Azure portal 40 | - Click on *Access Control(IAM)* in the left section 41 | - Click on *Add* button in the *Add a role assignment* card. 42 | - In the right blade, select 'Contributor' as the *Role*, 'User assigned managed identity' as *Assign access to* and the appropriate subscription 43 | - Then search the identity that you created in the previous step and click on it. It will show up in selected members section. Then click on *Save*. 44 | ![Image of Role Assignment](./_imgs/role-assignment.png) 45 | 46 | 47 | 48 | ### GitHub Workflow 49 | 50 | 1. Checkout application code to the GitHub runner. For this example, my application code is in a webApp folder located at the root of the GitHub repository. The `checkout` step will download all the repository files in the location ${{ GITHUB.WORKSPACE }} in GitHub runner. 51 | 52 | ```yaml 53 | name: create_custom_vm_image 54 | on: [push] 55 | 56 | jobs: 57 | BUILD-CUSTOM-IMAGE: 58 | runs-on: windows-latest 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v2 62 | ``` 63 | 64 | 2. Login to Azure. This steps takes the Azure login credentials (mentioned in prerequisites) from GitHub secret and logs into azure. 65 | 66 | ```yaml 67 | - name: Login via Az module 68 | uses: azure/login@v1 69 | with: 70 | creds: ${{secrets.AZURE_CREDENTIALS}} 71 | ``` 72 | 73 | 3. Create custom image and distribute using Shared Image Gallery 74 | 75 | ```yaml 76 | - name: CREATE APP BAKED VM IMAGE 77 | id: imageBuilder 78 | uses: azure/build-vm-image@v0 79 | with: 80 | location: 'eastus' 81 | resource-group-name: 'raivmdemo-rg' 82 | managed-identity: 'rai-identity2' # Managed identity as mentioned in pre-requisites. 83 | 84 | source-os-type: 'windows' 85 | source-image-type: 'platformImage' 86 | source-image: MicrosoftWindowsServer:WindowsServer:2019-Datacenter:latest #unique identitifier of source image 87 | 88 | customizer-source: '${{ GITHUB.WORKSPACE }}\webApp' # This folder gets injected to the image at directory location C:\ 89 | customizer-script: | 90 | & 'c:\webApp\webconfig.ps1' 91 | 92 | dist-type: 'SharedImageGallery' 93 | dist-resource-id: '/subscriptions/${{ secrets.SUBSCRIPTION_ID }}resourceGroups/raivmdemo-rg/providers/Microsoft.Compute/galleries/appImageGallery/images/AppBakedVMs/versions/0.1.${{ GITHUB.RUN_ID }}' #Replace with the resource id of your shared image gallery's image definition 94 | dist-location: 'eastus' 95 | ``` 96 | 97 | 4. Create a Virtual from the above image 98 | 99 | ```yaml 100 | - name: CREATE VM 101 | uses: azure/CLI@v1 102 | with: 103 | azcliversion: 2.0.72 104 | inlineScript: | 105 | az vm create --resource-group raivmdemo-rg --name "app-vm-${{ GITHUB.RUN_NUMBER }}" --admin-username moala --admin-password "${{ secrets.VM_PWD }}" --location eastus \ 106 | --image "${{ steps.imageBuilder.outputs.custom-image-uri }}" 107 | 108 | ``` 109 | 110 | 111 | Here is a full yaml script including all the above 4 steps: 112 | 113 | 114 | 115 | ```yaml 116 | name: create_custom_vm_image 117 | on: [push] 118 | 119 | jobs: 120 | BUILD-CUSTOM-IMAGE: 121 | runs-on: windows-latest 122 | steps: 123 | - name: Checkout 124 | uses: actions/checkout@v2 125 | 126 | - name: Login via Az module 127 | uses: azure/login@v1 128 | with: 129 | creds: ${{secrets.AZURE_CREDENTIALS}} 130 | 131 | - name: CREATE APP BAKED VM IMAGE 132 | id: imageBuilder 133 | uses: azure/build-vm-image@v0 134 | with: 135 | location: 'eastus' 136 | resource-group-name: 'raivmdemo-rg' 137 | managed-identity: 'rai-identity2' # Managed identity as mentioned in pre-requisites. 138 | 139 | source-os-type: 'windows' 140 | source-image-type: 'platformImage' 141 | source-image: MicrosoftWindowsServer:WindowsServer:2019-Datacenter:latest #unique identitifier of source image 142 | 143 | customizer-source: '${{ GITHUB.WORKSPACE }}\webApp' # This folder gets copied tothe image at location C:\ 144 | customizer-script: | 145 | & 'c:\webApp\webconfig.ps1' 146 | 147 | dist-type: 'SharedImageGallery' 148 | dist-resource-id: '/subscriptions/${{ secrets.SUBSCRIPTION_ID }}resourceGroups/raivmdemo-rg/providers/Microsoft.Compute/galleries/appImageGallery/images/AppBakedVMs/versions/0.1.${{ GITHUB.RUN_ID }}' #Replace with the resource id of your shared image gallery's image definition 149 | dist-location: 'eastus' 150 | 151 | - name: CREATE VM 152 | uses: azure/CLI@v1 153 | with: 154 | azcliversion: 2.0.72 155 | inlineScript: | 156 | az vm create --resource-group raivmdemo-rg --name "app-vm-${{ GITHUB.RUN_NUMBER }}" --admin-username myusername --admin-password "${{ secrets.VM_PWD }}" --location eastus \ 157 | --image "${{ steps.imageBuilder.outputs.custom-image-uri }}" 158 | 159 | ``` 160 | 161 | 162 | ### Result 163 | 164 | On executing the above workflow, it will push a windows image to shared image gallery. The pushed image will be tagged with github repository and run details so that you can trace every image back to the workflow run and can understand the exact changes that were included in this. Also a VM is spun up from this image. ![Image of baked image](./_imgs/bakedimage.png) 165 | --------------------------------------------------------------------------------