hckr.fyi // thoughts

Continuous Deployment with GitHub and Powershell on IIS

by Michael Szul on

One area that is a large focus for me in my work life has been DevOps automations—specifically integrating DevOps with older, legacy and potentially monolithic solutions.

A good example of this is getting continuous deployment to work with IIS on a physical or virtual server.

We utilize GitHub for our build/release cycles, but our servers don't allow direct access to them to outside sources. The opposite is not true. Our servers do have full access to the outside world, so we utilize a combination of GitHub's webhooks and Microsoft Scheduled Tasks to automate deployments.

The first step is to set up the webhook in GitHub, which is pretty self-explanatory. We create a separate hook just for when "Releases" are created.

The URL the webhook connects to looks for the "publish" actions and extracts all the necessary meta data:

const action = req.body.action;
    ...
    if(action === 'published') {
        const tag = req.body.release.tag_name;
        const releaseName = req.body.release.name;
        const repoName = req.body.repository.name;
        const octo = new Octokit({
            auth: process.env.GITHUB_TOKEN
        });
        octo.repos.getReleaseByTag({
            owner: process.env.ORG_NAME,
            repo: repoName,
            tag: tag
        }).then(async (release: any) => {
            const assets = release.data.assets;
            const deploymentAsset = assets.find((e: any) => e.name == 'deploy.yml');
            if(deploymentAsset != null) {
                const deploymentConfiguration = await downloadDeploymentConfiguration(repoName, deploymentAsset);
                const deploymentPackageName = deploymentConfiguration.package;
                const asset = assets.find((e: any) => e.name == deploymentPackageName);
                ...
                downloadAndCopyFiles(tag, asset.id, repoName, releaseName, deploymentConfiguration.deployment_site, deploymentConfiguration.deployment_dir, deploymentConfiguration.deployment_task_name, deploymentConfiguration.deployment_vdir);
            }
        }).catch((err) => console.error(err));
    }
    ...
    

This code is just using the GitHub REST API to grab the release. The release contains an asset called deploy.yml. The only thing this deployment file has is a handful of configuration items for the deployment engine.

Based on the deployment file, the downloadAndCopyFiles() creates a more specialized JSON configuration file that is downloaded to the server.

Once this file is downloaded, a scheduled task that runs every 5 minutes processes it.

function Execute-WithRetry([ScriptBlock] $command) {
        $attemptCount = 0
        $operationIncomplete = $true
        $maxFailures = 5
    
        while ($operationIncomplete -and $attemptCount -lt $maxFailures) {
            $attemptCount = ($attemptCount + 1)
            if ($attemptCount -ge 2) {
                Write-Host "Waiting for $sleepBetweenFailures seconds before retrying..."
                Start-Sleep -s $sleepBetweenFailures
                Write-Host "Retrying..."
            }
            try {
                & $command
                $operationIncomplete = $false
            }
            catch [System.Exception] {
                if ($attemptCount -lt ($maxFailures)) {
                    Write-Host ("Attempt $attemptCount of $maxFailures failed: " + $_.Exception.Message)
                }
                else {
                    throw
                }
            }
        }
    }
    
    $files = Get-ChildItem 'C:\Temp\deployments' -Filter *.json | sort LastWriteTime
    
    $7zipPath = 'C:\Program Files\7-Zip\7z.exe'
    
    if (-not (Test-Path -Path $7zipPath -PathType Leaf)) {
        throw "7zip file '$7zipPath' not found"
    }
    
    Set-Alias 7zip $7zipPath
    
    if($files) {
        foreach ($f in $files) {
            $json = (Get-Content -Path $f -Raw) | ConvertFrom-Json
    
            $source = "$($json.dir).zip"
            $target = "-o$($json.dir)"
            7zip x $source $target
    
            if($json.type -eq 'Application') {
                Execute-WithRetry {
                    if ($json.vdir -eq '') {
                        Import-Module WebAdministration
                        Set-ItemProperty -Path "IIS:\Sites\$($json.site)" -name 'physicalPath' -value $json.dir
                    }
                    else {
                        Import-Module WebAdministration
                        Set-ItemProperty -Path "IIS:\Sites\$($json.site)\$($json.vdir)" -name 'physicalPath' -value $json.dir
                    }
                }
            }
            if($json.type -eq 'ScheduledTask') {
                Execute-WithRetry {
                    $action = New-ScheduledTaskAction -Execute 'node.exe' -Argument 'dist\index.js -t exam-answers' -WorkingDirectory $json.dir
                    Set-ScheduledTask -TaskName $json.vdir -Action $action
                }
            }
            Remove-Item $f
            Remove-Item $source
        }
    }
    
    $deployments = Get-ChildItem 'E:\' -Filter prod-* | sort Name, LastWriteTime -Descending
    if($deployments) {
        $count = 0;
        $prefix = '';
        foreach($d in $deployments) {
            $count++;
            $part = $d.Name.Split('-');
            if($part[1]) {
                if($part[1] -ne $prefix) {
                    $prefix = $part[1];
                    $count = 0;
                }
                if($count -gt 10) {
                    $d.Delete($true);
                }
            }
        }
    }
    

What madness is this? This Powershell script starts with a function declaration of Execute-WithRetry which was lifted from the Octopus Deploy blog. This function is useful because sometimes, attempting to alter IIS in Powershell will fail, but we don't want our deployments to just stop, so we retry up to a number of retries.

When this executes, it checks a temporary folder for JSON deployment files. It also sets the 7zip path because 7zip works better than the native zip. We loop the deployment files so we can deploy each in succession. This could be done asynchronously, but for what I need, that's unnecessary. We extract a handful of variables from the file, check if what's being deployed is a web application or a scheduled task, and then we use the appropriate Powershell modules to update our deployed applications or scheduled task settings.

For the web application, you see that we have to account for the root web site versus and application subfolder (virtual directory). We then remove both the deployment file and the zip file.

The last bit loops through all the folders in the deployment directory, leaving only the last 10 deployments and deleting the rest. This way we always have the last 10 deployments in case we need to roll things back.