Automated version control of SMA Runbooks to GitHub (or Git, or GitLab)

As this is the first post on my blog, please feel free to read something about myself on the About page.

The challenge

While working with Windows Azure Pack (WAPack) and Service Management Automation (SMA) for the last 18 months, the biggest drawback for me: Version Control of Runbooks that are stored in SMA. In short: It’s not there. Yes, we have the “Draft” and “Publish” status, but that is really limited. Yes, we can export with PowerShell and store the Runbooks offline (i.e. Using SMART), it does a great job, but limited as it does not track the actual changes line-by-line inside your Runbook.

Beside TFS, there are some other great (free or paid) version control repositories available. Git being one of them. Either hosted on GitHub.com, build yourself on premise/build yourself in the Azure Cloud or get GitLab from the VM Depot (ready to run on Azure). Assumption is that you have one of these in place for your organization. When you have TFS already running as your version control repository, look here.

So how does Git work? That is probably too detailed to explain here, and me being not the best person to ask. Assuming your have your repository running in a few clicks, I found the following documentation a valuable resource to start reading: http://git-scm.com/doc

At least you need a Git Client. If you have PowerShell 5.0 (WMF5) you can use OneGet to get it from Chocolatey.org, or from the internal NuGet Repository in your organization. Or use the old fashioned download, get the Git Client from here: http://git-scm.com/download/win

Play around, explore, start with the following commands on your Windows command line: “git clone <url>”, “git init”, “git add .”, “git commit -m done”, “git push” and “git pull”.

And those are the commands we want to Automate in a SMA Runbook, to export all our valuable SMA Runbooks to a proper version control repository and keep track of all changes line-by-line. What I have done until now is one-way, assuming SMA is your “golden source” always pushing changes to Git.

Prerequisites

I assume that certain prerequisites are already in place:

  • SMA Runbook workers on level UR4 with Windows Server 2012R2 and PowerShell 4.0
  • The binaries of the Git Client copied to “C:\Git” on all your SMA Runbook workers. With “C:\Git\bin\git.exe” being the path to the executable, no need to install, just copy the installed “Git” directory to the SMA Runbook workers from a valid source (like your workstation, running PowerShell 5.0 that was getting it from Chocolately.org).
  • A Git repository filled with at least 1 file (best practice: Place a file named “README.md” with the first line of text telling something about this repository)
  • The URL for your Git repository, like “git@fqdnname.domain.com:USERNAME/REPOSITORY.git”
  • The Public/Private keypair files (“.\id_rsa” and “.\id_rsa.pub”) without password, that is already working with your Git repository of choice via SSH. We will explain how to secure those keys with SMA.
  • The “.\known_hosts” file which references to your Git repository being trustworthy.
  • The firewall should be open from you SMA Runbook workers towards the Git repository.

 Preparing SMA

We also assume certain other things are in place before we can run our Runbook:

 Creating a SMA Variable for the Key-pair files

One of the jobs in preparing SMA is to create a variable of the type Complex (Encrypted) with  content the following 3 files: Public Key, Private Key, Known Hosts. The script below does create this SMA Variable.

$WebServiceEndpoint=&quot;https://sma.domain.com&quot;
$complexvarname=&quot;GitKey-Export-SMARunbooks&quot;

$pvtfilename=&quot;.\id_rsa&quot;
$pubfilename=&quot;.\id_rsa.pub&quot;
$knownhostsfilename=&quot;.\known_hosts&quot;

[string]$pvtmultiline=$null
[string]$pubmultiline=$null
[string]$knownhostsmultiline=$null
Get-Content $pvtfilename|%{$pvtmultiline+=($_+&quot;`n&quot;)}
Get-Content $pubfilename|%{$pubmultiline+=($_+&quot;`n&quot;)}
Get-Content $knownhostsfilename|%{$knownhostsmultiline+=($_+&quot;`n&quot;)}

$keyscomplextype=@{
  &quot;PrivateKey&quot;=($pvtmultiline|ConvertTo-Json)
  &quot;PublicKey&quot;=($pubmultiline|ConvertTo-Json)
  &quot;KnownHosts&quot;=($knownhostsmultiline|ConvertTo-Json)
}

# Remove -Encrypted switch when Testing outside SMA
Set-SmaVariable -Name $complexvarname -Value $keyscomplextype -WebServiceEndpoint $WebServiceEndpoint -Encrypt

 

The actual Runbook

Note: The entire script can be found at the end of this post

I will not be discussing the Runbook in detail here. Basically is comes down to one big inlinescript after some rudimentary Variables as preperation.

Inside the inlinescript, you can distinguish the following sections:

  • Preparing Windows (User) Environment variables
  • Exporting the Keypair files (imported above)
  • Starting the first git.exe commands to setup the Git environment: Username, Email and Default push mode
  • Now switch to using sh.exe instead of git.exe (git is still used as part of the -c switch)

Reason: sh.exe automatically reads and uses the Keypair files correctly without having to setup additional command-line switches or Environment variables.

  • Pulling your initial Clone from the Git repository with “–depth 1” as only need to control and push back the latest version and not years of history
  • Removing all Runbooks from the $gitrunbooksubdir (“.\Runbooks”) directory  locally, that were just cloned/copied there using the “git clone <uri> –depth 1” command
  • Newly Export all Published SMA Runbooks to the same, just cleared directory $gitrunbooksubdir
  • Letting Git itself sort out the differences between the Removed files and newly Exported files:
    •  Using the commands “git add . –all”, “git commit -m <timestamp>” and “git push”
  • Finally, cleaning up all temporary Files, Directories, Keys and Environment variables

The result

As an end result you will see the Runbook in your Git repository. Some quick screenshot below:

sc_git1

An example change of a Runbook editted in SMA. Showing below how you will find this back in Git:

sc_git2

Another tip

Schedule this Runbook, once a day, twice a day, hourly and never worry about losing track of changes in your Runbooks again.

sc_git3

The entire script (for download) to be used as SMA Runbook:

Note: Click on Expand, to Expand this section and see the code.

workflow Export-RunbooksToGit
{
    param (
        [string]$GitKey=&quot;GitKey-Export-SMARunbooks&quot;,
        [string]$GitSSHProjectURL=&quot;git@github.com:USERNAME/export-smarunbooks.git&quot;,
        [string]$GitProjectName=&quot;export-smarunbooks&quot;,
        [string]$GitRunbookSubdirName=&quot;Runbooks&quot;,
        [string]$GitWin32Path=&quot;C:\Git\bin&quot;,
        [string]$SmaConnectionName=&quot;SmaConnection&quot;
    )
        
    # === BEGIN-SECTION FOR PREPARING VARIABLE RELATED TO SMARUNBOOK ENVIRONMENT ===
    $SMAConnection=Get-AutomationConnection -Name $SmaConnectionName

    $SMAUserName=inlinescript{($Using:SMAConnection).Get_Item(&quot;UserName&quot;)}
    $SMAUserPassword=inlinescript{($Using:SMAConnection).Get_Item(&quot;UserPassword&quot;)}
    $SMASecureUserPassword=ConvertTo-SecureString -AsPlainText -String $SMAUserPassword -Force
    $SMAWebServicePort=inlinescript{($Using:SMAConnection).Get_Item(&quot;WebServicePort&quot;)}
    $SMAWebServiceHostnameWithProtocol=inlinescript{($Using:SMAConnection).Get_Item(&quot;WebServiceHostnameWithProtocol&quot;)}
    $SMACredential=New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $SMAUserName,$SMASecureUserPassword        
    # === END-SECTION FOR PREPARING VARIABLE RELATED TO SMARUNBOOK ENVIRONMENT ===

    # Reading Public/Private keys from SMA Variable Assets
    $GitKeyComplex=Get-AutomationVariable -Name $GitKey    

    inlinescript {
        $VerbosePreference=[System.Management.Automation.ActionPreference]$Using:VerbosePreference
        $DebugPreference=[System.Management.Automation.ActionPreference]$Using:DebugPreference

        $workdir=[System.IO.Path]::GetTempPath()+[System.IO.Path]::GetRandomFileName()        

        $gitprojectdir=$workdir+&quot;\&quot;+$Using:GitProjectName
        $gittestdir=$gitprojectdir+&quot;\.git\&quot;
        $gitrunbooksubdir=$gitprojectdir+&quot;\&quot;+$Using:GitRunbookSubdirName

        $pvtsinglelinefromsma=$Using:GitDeployKeyComplex.PrivateKey|ConvertFrom-Json
        $pubsinglelinefromsma=$Using:GitDeployKeyComplex.PublicKey|ConvertFrom-Json
        $knownhostssinglelinefromsma=$Using:GitDeployKeyComplex.KnownHosts|ConvertFrom-Json
        $sshdir=$env:USERPROFILE+&quot;\.ssh&quot;
        $pvtfilename=$sshdir+&quot;\id_rsa&quot;
        $pubfilename=$sshdir+&quot;\id_rsa.pub&quot;
        $knownhostsfilename=$sshdir+&quot;\known_hosts&quot;

        # Create WORK directory
        if (-not (Test-Path $workdir)) {$tmpnewitem=New-Item -Force -ItemType Directory -Path $workdir}

        # Write SSH Security Keys required for Git connection
        if (-not (Test-Path $sshdir)) {$tmpnewitem=New-Item -Force -ItemType Directory -Path $sshdir}
        [IO.File]::WriteAllText($pvtfilename,$pvtsinglelinefromsma)
        [IO.File]::WriteAllText($pubfilename,$pubsinglelinefromsma)
        [IO.File]::WriteAllText($knownhostsfilename,$knownhostssinglelinefromsma)

        # $Using:GitWin32Bin add to environment 'path' (appending to 'current user')
        $ExistingUserpath=[System.Environment]::GetEnvironmentVariable(&quot;PATH&quot;,&quot;User&quot;)
        $UserpathElements=@([System.Environment]::GetEnvironmentVariable(&quot;PATH&quot;,&quot;User&quot;) -split &quot;;&quot;)
        $UserpathElements+=$Using:GitWin32Path
        $NewUserPath=$UserpathElements -join &quot;;&quot;
        [System.Environment]::SetEnvironmentVariable(&quot;PATH&quot;,$NewUserPath,&quot;User&quot;)

        # USERPROFILE and HOME environment varables are not available inside Workflow/inlinescript/Start-Process-combo
        [System.Environment]::SetEnvironmentVariable(&quot;HOME&quot;,$env:USERPROFILE,&quot;User&quot;)
        [System.Environment]::SetEnvironmentVariable(&quot;USERPROFILE&quot;,$env:USERPROFILE,&quot;User&quot;)

        #Start-Process -FilePath $env:ComSpec -Wait -UseNewEnvironment
        $OutputFile=[System.IO.Path]::GetTempFileName()
        $ErrorFile=[System.IO.Path]::GetTempFileName()

        &quot;=-=-=configname&quot;|Write-Debug
        $confignameargument=&quot;config --global user.name `&quot;SMARBW &quot;+$env:COMPUTERNAME+&quot;`&quot;&quot;
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\git.exe&quot;) -Wait -UseNewEnvironment -LoadUserProfile -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList $confignameargument -WorkingDirectory $workdir
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        &quot;=-=-=configemail&quot;|Write-Debug
        $configemailargument=&quot;config --global user.email `&quot;&quot;+$env:COMPUTERNAME+&quot;@domain.com`&quot;&quot;
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\git.exe&quot;) -Wait -UseNewEnvironment -LoadUserProfile -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList $configemailargument -WorkingDirectory $workdir
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        &quot;=-=-=configpushsimple&quot;|Write-Debug
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\git.exe&quot;) -Wait -UseNewEnvironment -LoadUserProfile -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList &quot;config --global push.default simple&quot; -WorkingDirectory $workdir
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        &quot;=-=-=clone&quot;|Write-Debug
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\sh.exe&quot;) -Wait `
                -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList (&quot;--login -c '/bin/git clone &quot;+$Using:GitSSHProjectURL+&quot; --depth 1'&quot;) -WorkingDirectory $workdir
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        # TEST SECTION - If the &quot;git clone&quot; command failed, the directory does not exist
        if (-not (Test-Path $gittestdir)) {
            &quot;Error - This directory should exist after the git.exe-clone command, directory does not exist: &quot;+$gittestdir|Write-Output

            # Cleanup (identical to Cleanup in error/exit section below)
            if (Test-Path $workdir) {Remove-Item -Force -Recurse -Path $workdir}
            if (Test-Path $sshdir) {Remove-Item -Force -Recurse -Path $sshdir}
            if (Test-Path ($Env:USERPROFILE+&quot;\.gitconfig&quot;)) {
                Remove-Item -Force -Recurse -Path ($Env:USERPROFILE+&quot;\.gitconfig&quot;)}
            if (Test-Path ($Env:USERPROFILE+&quot;\.bash_history&quot;))  {
                Remove-Item -Force -Recurse -Path ($Env:USERPROFILE+&quot;\.bash_history&quot;)}
            if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
            if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}
            [System.Environment]::SetEnvironmentVariable(&quot;PATH&quot;,$ExistingUserpath,&quot;User&quot;)
            [System.Environment]::SetEnvironmentVariable(&quot;HOME&quot;,$null,&quot;User&quot;)
            [System.Environment]::SetEnvironmentVariable(&quot;USERPROFILE&quot;,$null,&quot;User&quot;)

            &quot;Error - This directory should exist after the git.exe-clone command, directory does not exist: &quot;+$gittestdir|Write-Error
        }

        # Although cloned, we need a Subdirectory for exporting the Runbooks to
        # But first, we want changes, also deletions of Runbooks, so let's remove everything :)
        if (Test-Path $gitrunbooksubdir) {Remove-Item -Force -Recurse -Path $gitrunbooksubdir}
        if (-not (Test-Path $gitrunbooksubdir)) {$tmpnewitem=New-Item -Force -ItemType Directory -Path $gitrunbooksubdir}

        # Exporting the Published Runbooks
        $runbooks=Get-SmaRunbook -WebServiceEndpoint $Using:SMAWebServiceHostnameWithProtocol -Port $Using:SMAWebServicePort -Credential $Using:SMACredential
        foreach ($runbook in $runbooks) {
                 Write-Debug &quot;RunbookName/ID:&quot;
                 $runbook.RunbookName|Write-Debug
                 $runbook.RunbookID|Write-Debug
                 $runbookdefinition=Get-SmaRunbookDefinition `
                    -Id $runbook.RunbookID `
                    -Type Published `
                    -WebServiceEndpoint $Using:SMAWebServiceHostnameWithProtocol `
                    -Port $Using:SMAWebServicePort `
                    -AuthenticationType &quot;Windows&quot; `
                    -Credential $Using:SMACredential
                 $runbookdefinition.Content|Set-Content -Path ($gitrunbooksubdir+&quot;\&quot;+$runbook.RunbookName+&quot;.ps1&quot;) -Force
        }

        # === BEGIN RUNBOOK OUTPUT ===
        # Pre-output for RunBook of what will be processed by the git.exe commands        
        $runbooks|select RunbookName,LastModifiedTime,LastModifiedBy|Sort-Object -Property RunbookName|ft -a|Out-File -FilePath $OutputFile -Force
        Get-Content $OutputFile
        &quot;---&quot;|Write-Output
        if (Test-Path $gitprojectdir) {
            Get-ChildItem -File $gitprojectdir|select Length,LastWriteTime,FullName|Sort-Object -Property FullName|ft -a|Out-File -FilePath $OutputFile -Force
            Get-Content $OutputFile
            &quot;---&quot;|Write-Output
        }

        if (Test-Path $gitrunbooksubdir) {
            Get-ChildItem -File $gitrunbooksubdir|select Length,LastWriteTime,Name|Sort-Object -Property Name|ft -a|Out-File -FilePath $OutputFile -Force
            Get-Content $OutputFile
            &quot;---&quot;|Write-Output
        }
        # === END RUNBOOK OUTPUT ===

        &quot;=-=-=adddot&quot;|Write-Debug
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\sh.exe&quot;) -Wait `
                -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList (&quot;--login -c '/bin/git add --all .'&quot;) -WorkingDirectory $gitprojectdir
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        &quot;=-=-=commit&quot;|Write-Debug
        $commitcomment=(&quot;RunBook-&quot;+(Get-Date -Format o).Split(&quot;+&quot;)[0])
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\sh.exe&quot;) -Wait `
                -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList (&quot;--login -c '/bin/git commit -v -m &quot;+$commitcomment+&quot;'&quot;) -WorkingDirectory $gitprojectdir
        $keepcommitinformation=Get-Content $OutputFile
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        &quot;=-=-=push&quot;|Write-Debug
        Start-Process -FilePath ($Using:GitWin32Path+&quot;\sh.exe&quot;) -Wait `
                -RedirectStandardOutput $OutputFile  -RedirectStandardError $ErrorFile `
                -ArgumentList &quot;--login -c '/bin/git push'&quot; -WorkingDirectory $gitprojectdir
        $keeppushinformation=Get-Content $OutputFile
        Get-Content $OutputFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        Get-Content $ErrorFile|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;#*&quot;}|Write-Debug;if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}

        # === BEGIN RUNBOOK OUTPUT 2nd ===
        # The output for RunBook of what &quot;git commit&quot; and &quot;git push&quot; actually did
        $keepcommitinformation|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}
        $keeppushinformation|where {$_ -ne &quot;&quot; -and $_ -notlike &quot;Welcome to Git*&quot; -and $_ -notlike &quot;*git help*&quot; -and $_ -notlike &quot;#*&quot;}
        # === END RUNBOOK OUTPUT 2nd ===

        # Cleanup (identical to Cleanup in error/exit section above)
        if (Test-Path $workdir) {Remove-Item -Force -Recurse -Path $workdir}
        if (Test-Path $sshdir) {Remove-Item -Force -Recurse -Path $sshdir}
        if (Test-Path ($Env:USERPROFILE+&quot;\.gitconfig&quot;)) {
            Remove-Item -Force -Recurse -Path ($Env:USERPROFILE+&quot;\.gitconfig&quot;)}
        if (Test-Path ($Env:USERPROFILE+&quot;\.bash_history&quot;))  {
            Remove-Item -Force -Recurse -Path ($Env:USERPROFILE+&quot;\.bash_history&quot;)}
        if (Test-Path $OutputFile) {Remove-Item -Force -Path $OutputFile}
        if (Test-Path $ErrorFile) {Remove-Item -Force -Path $ErrorFile}
        [System.Environment]::SetEnvironmentVariable(&quot;PATH&quot;,$ExistingUserpath,&quot;User&quot;)
        [System.Environment]::SetEnvironmentVariable(&quot;HOME&quot;,$null,&quot;User&quot;)
        [System.Environment]::SetEnvironmentVariable(&quot;USERPROFILE&quot;,$null,&quot;User&quot;)
    }
}
Advertisements