3
\$\begingroup\$

So it's been a while. I present you with a somewhat basic PowerShell script that is designed to deploy our staging environment to production, on a nightly basis.

I've designed this to be robust, and also log information relating to the deployment of staging to production. The idea where was to build it simple enough to maintain, but robust enough to support the use-case I had. (In my case, I want to specify a relative path instead of an absolute path.)

As a result, I have a lot of boilerplate, but it does what I want, and looks clean-enough to me. I'm open for any and all critiques, as this is something we'll be using a lot.

The goal here is to read everything from the staging directory (default to currentdir/Stage), update anything in the production directory (default to currentdir/Production), and create a backup log of anything replaced (default to currentdir/Backups/{DateTimeStamp}). It should create a Deployment.log in the backup directory, just in case things need reviewed later and for tracking purposes, with a consistent format. (Actual format isn't important, but we plan to automate processing of the file at a later date.)

# This script is designed to deploy a directory from a staging location to a
# production location while preserving the full structure and creating a backup
# copy of all files replaced.
[CmdletBinding()]
Param (
    [string]$prodDirName,
    [string]$stageDirName,
    [string]$backupDirName
)

# If verbose, we want the flag for logging and want to write Information
# messages out as well.
$isVerbose = $false
if ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent) {
    Write-Verbose "Verbose specified, including Information logs."
    $InformationPreference = "Continue"
    $isVerbose = $true
}

$dirRoot = $PSScriptRoot
$date    = Get-Date
Write-Output "Starting deployment at $date..."

# These will be needed for parsing the three parameters, as we'll want to
# support a default and always use absolute paths.
Function Default-Parameter {
    Param (
        [string]$originalValue,
        [string]$parameterValue
    )

    if ([string]::IsNullOrWhiteSpace($parameterValue)) {
        return $originalValue
    } else {
        return $parameterValue
    }
}
Function Fix-Path {
    Param ([parameter(ValueFromPipeline)][string]$path)

    if (-not [System.IO.Path]::IsPathRooted($path)) {
        return "$dirRoot/$path"
    } else {
        return $path
    }
}

# Setup directory names and create the backup directory.
$dateStr       = $date.ToString("yyyyMMddHHmmss")
$prodDirName   = Default-Parameter "Production" $prodDirName | Fix-Path
$stageDirName  = Default-Parameter "Stage" $stageDirName | Fix-Path
$backupDirName = Default-Parameter "Backups/$dateStr" $backupDirName | Fix-Path

Write-Output "Creating backup directory: $backupDirName"
New-Item -ItemType Directory -Path $backupDirName | Out-Null

$prodDir   = Get-Item $prodDirName
$stageDir  = Get-Item $stageDirName
$backupDir = Get-Item $backupDirName
$logFile   = "$backupDirName/Deployment.log"

# This writes the string to output and the log file, thus preventing us from
# needing to track that throughout the script.
Function Write-Log {
    Param (
        [string]$str,
        [ValidateSet("Verbose","Warning","Output","Information","NoMessage")][string]$type
    )

    if ($type -eq "NoMessage") {
        # We add the "--" prefix to allow easy log file parsing in the case of
        # automated systems which will be reading the "Deployment.log".
        Add-Content $logFile -value "--$str"
    } else {
        switch ($type) {
            "Verbose" { Write-Verbose $str }
            "Warning" { Write-Warning $str }
            "Output" { Write-Output $str }
            "Information" { Write-Information "INFORMATION: $str" }
        }
        Add-Content $logFile -value "$(Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffffff"): $($type): $str"
    }
}
# This will deploy a single file to the target location.
Function Deploy-File {
    Param ([System.IO.FileInfo]$file)

    Write-Log "Deploying file: $($file.FullName)" "Information"
    Write-Log "Source path: $($file.FullName)" "Verbose"
    $backupPath = $file.FullName.Replace($stageDir, $backupDir)
    Write-Log "Backup path: $backupPath" "Verbose"
    $destPath = $file.FullName.Replace($stageDir, $prodDir)
    Write-Log "Destination path: $destPath" "Verbose"

    $destDir = [System.IO.Path]::GetDirectoryName($destPath)
    if (-not [System.IO.Directory]::Exists($destDir)) {
        Write-Log "Create destination directory: $destDir" "Verbose"
        New-Item -ItemType Directory -Path $destDir | Out-Null
    }

    if ([System.IO.File]::Exists($destPath)) {
        if ((Get-FileHash $file.FullName).hash -ne (Get-FileHash $destPath).hash) {   
            $backupDir = [System.IO.Path]::GetDirectoryName($backupPath)
            if (-not [System.IO.Directory]::Exists($backupDir)) {
                Write-Log "Create backup directory: $backupDir" "Verbose"
                New-Item -ItemType Directory -Path $backupDir | Out-Null
            }

            Write-Log "Backup: $destPath to $backupPath" "Output"
            Copy-Item -Path $destPath -Destination $backupPath

            Write-Log "Copy: $($file.FullName) to $destPath" "Output"
            Copy-Item -Path $file.FullName -Destination $destPath
        } else {
            Write-Log "Skip: $($file.FullName)" "Information"
        }
    } else {
        Write-Log "Copy: $($file.FullName) to $destPath" "Output"
        Copy-Item -Path $file.FullName -Destination $destPath
    }
}
# This will deploy a given folder to the target location.
Function Deploy-Directory {
    Param([System.IO.DirectoryInfo]$dir)

    Write-Log "Deploying directory: $($dir.FullName)" "Information"
    Get-ChildItem $dir.FullName | ForEach-Object {
        Write-Log "$($_.Attributes): $($_.FullName)" "Verbose"
        if ($_ -is [System.IO.DirectoryInfo]) {
            Deploy-Directory $_
        } else {
            Deploy-File $_
        }
    }
}

Write-Log "Deployment started: $($date.ToString("yyyy-MM-dd HH:mm:ss"))" "Information"
Write-Log "Deploying: $($prodDir.FullName)" "Information"
Write-Log "Backup set: $dateStr" "Output"
Write-Log "----------------------------------------------------------------------------------------------------------------------------------------------" "NoMessage"
Write-Log "Production: $($prodDir.FullName)" "Verbose"
Write-Log "Staging: $($stageDir.FullName)" "Verbose"
Write-Log "Backup: $($backupDir.FullName)" "Verbose"
Write-Log "Copying files from staging to production" "Output"
Deploy-Directory $stageDir
Write-Log "----------------------------------------------------------------------------------------------------------------------------------------------" "NoMessage"
Write-Log "Deployment finished: $($date.ToString("yyyy-MM-dd HH:mm:ss"))" "Information"

I appreciate any and all critiques.

\$\endgroup\$
  • \$\begingroup\$ Wouldn't it be easier to deploy using the robocopy.exe tool? \$\endgroup\$ – t3chb0t Apr 11 '18 at 6:26
  • \$\begingroup\$ @t3chb0t For the basic stuff, sure. But I have some SQL that needs done during deployments (not included, doing that in a separate question) that makes it easier to build one script to do the whole thing. (Though I do like the robocopy idea, it just isn't robust enough for us.) :) \$\endgroup\$ – 410_Gone Apr 11 '18 at 12:31
4
\$\begingroup\$

Just some random notes. Your code seems pretty good to me in general.


# This script is designed to deploy a directory from a staging location to a
# production location while preserving the full structure and creating a backup
# copy of all files replaced.

You should use comment-based help instead. That way people can type help yourscript -detail. It doesn't take much extra effort to use:

<#
.SYNOPSIS
Deploys a directory from a staging location to a production location

.DESCRIPTION
Blah.

.PARAMETER prodDirName
Explain what this is.

.PARAMETER stageDirName
Explain what this is.

.PARAMETER backupDirName
Explain what this is.

.EXAMPLE
Blah.
#>

[CmdletBinding()]
Param (
    [string]$prodDirName,
    [string]$stageDirName,
    [string]$backupDirName
)

I think the parameters here should start with a captital letter since that is the convention.


$isVerbose = $false

$isVerbose isn't used anywhere, so it should be deleted:


# These will be needed for parsing the three parameters, as we'll want to
# support a default and always use absolute paths.
Function Default-Parameter {
    Param (
        [string]$originalValue,
        [string]$parameterValue
    )

Personally I think it's more readable to put the parameters inline:

Function Default-Parameter([string]$originalValue, [string]$parameterValue) {

Function Fix-Path {
    Param ([parameter(ValueFromPipeline)][string]$path)

It seems odd that this one takes pipelined input but Default-Parameter doesn't. It doesn't really matter, but the inconsistency is puzzling.


    if (-not [System.IO.Path]::IsPathRooted($path)) {
        return "$dirRoot/$path"
    } else {
        return $path
    }

As a general rule for if/else in any programming lanuage, it's best to put the positive case first. That makes the code easier to read and think about.


$prodDirName   = Default-Parameter "Production" $prodDirName | Fix-Path
$stageDirName  = Default-Parameter "Stage" $stageDirName | Fix-Path
$backupDirName = Default-Parameter "Backups/$dateStr" $backupDirName | Fix-Path

As another general rule, it's best not to overwrite parameters, for a few reasons:

  • When reading the code, you have to think about whether the variable has been overwritten or not. That makes it harder to reason about the code.
  • A reader of the code may not even notice that the variable gets overwritten.
  • If you are stopped at a breakpoint in the debugger, you may want to see what the original parameter was, but you can't if is has been overwritten.

The same comments apply to function arguments.

Just make a new variable. They don't cost anything.


$backupPath = $file.FullName.Replace($stageDir, $backupDir)

You can use the Powershell -replace operator:

$backupPath = $file.FullName -replace $stageDir, $backupDir

On a final note, you might like to add this somewhere at the top of the script to prevent certain kinds of programming errors:

Set-StrictMode -Version Latest

It may be appropriate to add the following too. It turns "non-terminating errors" into terminating ones. I think in general that is the best policy.

$ErrorActionPreference = 'Stop'
\$\endgroup\$
  • \$\begingroup\$ Very nice, much appreciated on all of this. The only thing I'll defend is that Fix-Path takes a pipeline parameter and Default-Parameter does not: I didn't really see a good value to pipeline to Default-Parameter, I didn't really know if the originalValue would be better, or the parameterValue would be better, so left both as explicit. Any thoughts on that? \$\endgroup\$ – 410_Gone Apr 11 '18 at 12:57
  • 1
    \$\begingroup\$ @202_accepted, right, I agree it would be a bit awkward to have Default-Parameter take pipelined input. The main use of pipelined input is when you are operating on a stream of data. Maybe you are operating on a 13GB file and you don't want to load the whole thing into memory all at once. Or maybe you are operating on some stream that never ends, so you simply can't load it all into memory. The benefit with pipelined input is that you can operate on one item at a time. In you case, you don't really have pipelined data. There is only one string in the pipeline. \$\endgroup\$ – Dangph Apr 12 '18 at 7:20
  • 1
    \$\begingroup\$ @202_accepted, I would be inclined to make Fix-Path into a regular function myself. But I don't have a strong opinion on that. How you did it is fine. \$\endgroup\$ – Dangph Apr 12 '18 at 7:22
  • \$\begingroup\$ Awesome, thanks! I appreciate the insight, I'll go ahead and drop the piping from Fix-Path. Really appreciate your time and insight on this. :) \$\endgroup\$ – 410_Gone Apr 16 '18 at 15:32
  • 1
    \$\begingroup\$ Be aware that -replace uses a regex-pattern for the first input, while string-replace ("".Replace("oldvalue","newvalue")) doesn't. This means the backslash needs to be escaped like \\, . can match any character ++. You can escape the pattern (old value) using [regex]::Escape("myoldvalue") \$\endgroup\$ – Frode F. May 1 '18 at 10:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.