2013 Scripting Games Event 1

This year, the scripting games events have a title, there will be only a total of 6 events and 5 working days to submit each entry before a 5 days voting period begins. Yeah, this year each event will go “social” 🙂

Event 1 is entitled: An Archival Atrocity.

  • Step 1: read the instructions as many times as required

Beginner 1 event instructions
Advanced 1 event instructions

  • Step 2: create the conditions to test your solution for event 1

For the first event 1, this prepartion step is crucial and is also an opportunity to learn a few things.

    • Create the folders tree

First I need to create the folders structure under C:\Application\Log.
To do this, I’d have type md at command prompt.
In PowerShell, md is actually an alias of mkdir and mkdir itself is a proxy function that calls the New-Item core cmdlet behind the scene.

Get-Content Function:\mkdir


In other words, by getting the content of the mkdir function, we see that it uses the New-Item cmdlet with a “Type” parameter.
But when you look at the help of the New-Item cmdlet you don’t see a parameter named “Type”. The only one that matches what “Type” is supposed to do is “ItemType”.
After digging for a few seconds, it appears that the help doesn’t reveal that the “ItemType” parameter has “Type” as an alias. But the Get-Command cmdlet reveals it:

(Get-Command New-Item).Parameters["ItemType"]


Enough with these intricacies, let’s get the job done and create these folders. I could do:

"App1","ThisAppAlso","OtherApp" | ForEach-Object {            
    New-Item -Path "C:\Application\Log\$_" -Type Directory            
 }

Or just:

"App1","ThisAppAlso","OtherApp"|%{mkdir "C:\Application\Log\$_"}
    • Create some log files

The instructions say that:

the filenames are random GUIDs with a .LOG filename extension.

Ok, let’s do it but first I need to discover the .Net GUID object. Here’s how to generate a random GUID.

[GUID]::NewGuid()

Let’s look at this object by piping it into the Get-Member cmdlet that will show its properties and methods.

[GUID]::NewGuid() | gm

Now let’s create our first .LOG file

# Create 1 file            
$file = (Join-Path -Path C:\Application\Log\App1 -ChildPath "$([GUID]::NewGuid().ToString()).log")
Get-Date > $file            

Ok, we’ve just created a file. Its modification date is today. We need also some files older than 90 days. If we do:

Get-item $file | gm


We can see that the LastWriteTime property of the object can be “set”, i.e., modified.
Let’s change its modification date and look at it

# Change its last modification date            
(Get-Item $file).LastWriteTime = (Get-Date).AddDays(-90)            
Get-Item $file            

Now, I’m ready to create in quick and dirty mode some recent files under the subfolders as well as some files older than 90 days like this:

# Create some sample files            
"App1","ThisAppAlso","OtherApp" | % {            
    $App = $_            
    # Create 10 recent files            
    0..9 | % {            
        $file = (Join-Path -Path "C:\Application\Log\$App" -ChildPath "$([GUID]::NewGuid().ToString()).log")            
        Get-Date | Out-File -FilePath $file            
    }            
    # Create 10 files older than 90 days                
    10..20 | % {            
        $file = (Join-Path -Path "C:\Application\Log\$App" -ChildPath "$([GUID]::NewGuid().ToString()).log")            
        Get-Date | Out-File -FilePath $file            
        (Get-Item $file).LastWriteTime = (Get-Date).AddDays(-90)            
    }            
}
  • Step 3: The heart of the solution

If I had have to submit the Beginner event, I’d have proposed the following one-liner:

Get-ChildItem -Path C:\Application\Log -Directory | ForEach-Object -Process {            
    $Folder = $_            
    $TargetPath = (Join-Path -Path "\\NASServer\Archives" -ChildPath $Folder.Name)            
    # Ensure that the target folder exist before moving files            
    If (-not(Test-Path -Path $TargetPath -PathType Container)) {            
        New-Item -Path $TargetPath -ItemType Directory | Out-Null            
    }            
    try {            
        Get-ChildItem -Path "$($Folder.FullName)\*.LOG" -File |             
        Where-Object { $_.LastWriteTime -le (Get-Date).AddDays(-90) } |            
        Move-Item -Destination $TargetPath -ErrorAction Stop            
    } catch {            
        Write-Warning -Message "Failed to move files because $($_.Exception.Message)"            
    }            
}            

Yes, it requires Powershell version 3.0. I first went with the ‘Recurse’ parameter of the Get-Childitem (dir) cmdlet but it doesn’t allow to fully use the power of the pipeline as the above code: Get-ChildItem, Where, Move-Item. Without over thinking the event, notice that I’ve also added a quick way to test if the destination folder exist. It went wrong when I forgot to create the application folders. It’s actually a requirement as the instructions state that:

You need to maintain the subfolder structure, so that files from C:\Application\Log\App1 get moved to \\NASServer\Archives\App1, and so forth.

Notice also the two new handy “File” and “Directory” switches used along with the Get-ChildItem cmdlet that allow to easily select only files or folders.
I didn’t add a second Where filter script to get only filenames that matches GUIDs. If I thought it was a strong requirement, I’d have added something like:

Where-Object {($_.LastWriteTime -le (Get-Date).AddDays(-90)) -and            
($_.Name -match "[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}")            
}

Last thing, Get-ChildItem expects a string as path that’s why I used the following construction:

"$($Folder.FullName)\*.LOG"
  • Step 4: Create an advanced function

To move from the Beginner solution to the Advanced one, I’ve to:

    • Find a name to the function that respects the Verbs and Noun even if the “Fix-ArchivalAtrocity” is legal.
    • Add a [cmdletbinding()]…
    • Write the parameter block, define all the parameters types, whether they are mandatory or not, have a default value,…
    • Write the help of the function
    • Enclose the solution in the Begin{},Process{},End{} template.

Let’s look a the result and the overhead of the above requirements:

#Requires -Version 3            
            
Function Move-LogFilesToArchive {            
<#

.SYNOPSIS
    Move files with .LOG filename extension older than n days by preserving the subfolders structure

.DESCRIPTION
    Move files with .LOG filename extension older than 90 days by default from a source path to a destination path by preserving the subfolders structure

.PARAMETER Path
    String that represents the source path where directories containing log files are located.

.PARAMETER Destination
    String that represents the target path where files will be moved to. Subfolders will be created if they don't exist

.PARAMETER Age
    Integer that represents how old are files in days, minimum is 0 and maximum is 734982.

.EXAMPLE
    Move-LogFilesToArchive -Path C:\Application\Log -Destination \\NASServer\Archives
    The above command will move *.LOG files older than 90 days from subfolders located in C:\Application\Log to \\NASServer\Archives and preserve subfolder names

.EXAMPLE
    Move-LogFilesToArchive -Path C:\Application\Log -Destination \\NASServer\Archives -Age 180
    The above command will move *.LOG files older than 180 days from subfolders located in C:\Application\Log to \\NASServer\Archives and preserve subfolder names

.EXAMPLE
    Move-LogFilesToArchive -Path C:\Application\Log -Destination "\\NASServer\Archives" -WhatIf
    Using the -Whatif parameter would list the operations the command would perform and the items that would be affected.
    Instead of executing the command, messages that describe the effect of the command are displayed.

.EXAMPLE
    Move-LogFilesToArchive -Path C:\Application\Log -Destination "\\NASServer\Archives" -Confirm
    Using the -Confirm parameter would list the operations the function would perform and prompts you for confirmation before executing the command.

#>
            
[CmdletBinding(ConfirmImpact="Low",SupportsShouldProcess)]            
Param(            
    [parameter(mandatory)]            
    [ValidateNotNullOrEmpty()]            
    [ValidateScript({Test-Path -Path $_ -PathType Container})]            
    [string]$Path,            
            
            
    [parameter(mandatory)]            
    [ValidateNotNullOrEmpty()]            
    [ValidateScript({Test-Path -Path $_ -PathType Container})]            
    [string]$Destination,            
                
    [parameter()]            
    [ValidateRange(0,734982)]            
    [int]$Age=90            
)            
Begin {}            
Process {            
    Get-ChildItem -Path $Path -Directory | ForEach-Object -Process {            
        $Folder = $_            
        $TargetPath = (Join-Path -Path $Destination -ChildPath $Folder.Name)            
        # Ensure that the target folder exist before moving files            
        If (-not(Test-Path -Path $TargetPath -PathType Container)) {            
            try {            
                New-Item -Path $TargetPath -ItemType Directory -ErrorAction Stop | Out-Null            
            } catch {            
                Write-Warning -Message "Failed to create the directory $TargetPath because $($_.Exception.Message)"            
                break            
            }            
        }            
        try {            
            # Use the new File switch to avoid directories            
            Get-ChildItem -Path "$($Folder.FullName)\*.LOG" -File |             
            Where-Object { $_.LastWriteTime -le (Get-Date).AddDays(-$Age) } |            
            Move-Item -Destination $TargetPath -ErrorAction Stop            
        } catch {            
            Write-Warning -Message "Failed to move files because $($_.Exception.Message)"            
        }            
    }            
}            
End{}            
}

I’m not very happy with my function name but it’s better than my first idea.

My function has two cmldets that take some actions: New-Item and Move-Item. As I fully use the power of the pipeline and that these two cmdlets supports the -Whatif and -Confirm parameters, I can add the SupportsShouldProcess and ConfirmImpact arguments to the CmdletBinding attribute.

Instead of using my old habits and validating the pathes passed as parameter in the Begin block, I decided to experiment and go with the ValidateScript attribute. 2 lines instead of this code:

if (-not(Test-Path -Path $Destination -PathType Container)) {            
    Write-Warning -Message "$Destination isn't available or path isn't a directory"            
    break            
}            
if (-not(Test-Path -Path $Path -PathType Container)) {            
    Write-Warning -Message "$Path isn't available or path isn't a directory"            
    break            
}

Last thing, you may have noticed a strange upper limit for the ValidateRange. It actually represents the number of days between the 25th of April and the lowest date that can be represented by the console. As you can see, it’s a huge number that should cover the vast majority of use cases.

Look at what happens when I add the maximum integer to the current date

(Get-Date).AddDays(([int32]::MaxValue))


Exception calling “AddDays” with “1” argument(s): “Value to add was out of range.
I’ve already figured out what the biggest date that can be displayed by the console in this blog post. As I’ll substract days, I need to figure out what the smallest date could be. Well, it’s:

(Get-Date -Year 0001 -Day 1 -Month 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0)


Exception calling “AddMilliseconds” with “1” argument(s): “The added or subtracted value results in an un-representable DateTime.
Finally, here’s how I came up with 734982:

New-TimeSpan -Start (Get-Date -Year 0001 -Day 1 -Month 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0) -End (Get-Date)
  • Step 5: Test the code

I tested my code on a Windows 7 box with PowerShell 3.0 as well as a Windows 8. Coding on one computer, executing the code and then testing the code on another vanilla computer sound as a good practice.
When testing the code, I also switch the strict-mode to its strictest version so that I can see if the code I wrote respects good practices.

Set-StrictMode -Version latest

The only problem I found when testing the code is the following. I tested the code on a vanilla computer and forgot to create target directories. I also did some stupid thing when testing the -Confirm mode. I answered ‘No’ to create the target folder performed by the New-Item cmdlet and ‘Yes’ to the following move-item prompts. Stupid, isn’t it? Now, when I relaunch the function, I got an error with the New-Item cmdlet that wasn’t handled. That’s why I chose to enclose the New-item cmdlet in a try/catch block.

  • Conclusion

This exercice was a great learning experience but you know, in real life, I would have used a very pragmatic solution like this one:

robocopy C:\Application\Log \\NASServer\Archives *.LOG /S /r:0 /MOV /MINAGE:90 >NUL 2>&1

If it was a brain-teaser for the shortest one-liner, PowerShell wouldn’t win 😎

Advertisements

3 thoughts on “2013 Scripting Games Event 1

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s