Skip to content
Menu
The Lonely Administrator
  • PowerShell Tips & Tricks
  • Books & Training
  • Essential PowerShell Learning Resources
  • Privacy Policy
  • About Me
The Lonely Administrator

Answering the PowerShell Linking Challenge

Posted on December 2, 2020December 2, 2020

A few weeks ago, the Iron Scripter challenge was to move files meeting some criteria to a new location and leave a link behind. As I've written before, these challenges are a great way to test your PowerShell skills and stretch yourself. This challenge has a number of moving parts.

Manage and Report Active Directory, Exchange and Microsoft 365 with
ManageEngine ADManager Plus - Download Free Trial

Exclusive offer on ADManager Plus for US and UK regions. Claim now!

Often with PowerShell some of the hardest work is planning and organization. How are you going to achieve a given task? If you are writing code that someone else will be using, what expectations or assumptions will they have? How are they likely to use your commands? I also think about flexibility and re-use. I try to avoid writing a monolithic function that does several things. Rather, you should focus on small, single-use tasks that you can combine in a pipelined expression. Or orchestrate from a controller script, which is what I will be showing you later.

Testing the Core Command

The core command in this task is New-Item. You can use this cmdlet to create files, folders and links. I'm assuming you will look at full help and examples. I'll give it a spin.

New-Item -ItemType SymbolicLink -Path . -Name procmon.exe -Value D:\OneDrive\tools\Procmon.exe

This command will create a link to D:\OneDrive\tools\Procmon.exe in the current directory.

I can run procmon.exe in C:\Work without caring where the actual file resides. Excellent. The premise of the challenge is to go through a list of files, move them to a destination and leave a link behind.

Move and Link

This sounds like a task I might want to re-use. I don't want to limit what files to process. I want to be able to pass any file to my command and have it move and link. Technically, PowerShell has a Move-Item command, but I'm thinking of the "move and link" process as a single action so I will code it that way. Plus, there are a few other challenge features I want to address.

Here's my function for "move and link".

#requires -version 5.1
Function Move-FileWithLink {
    [CmdletBinding(SupportsShouldProcess)]
    Param(
        [Parameter(Position = 0, Mandatory, HelpMessage = "Specify the path of a file to move.", ValueFromPipelineByPropertyName)]
        [alias("fullname")]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-Path $_ })]
        [string]$Path,

        [Parameter(HelpMessage = "Specify the top-level target path. It will be created if it doesn't exist.")]
        [ValidateNotNullOrEmpty()]
        [string]$Destination = "\\172.16.10.100\backup\lts",

        [switch]$Passthru
    )
    Begin {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"
        #define a timestamped logfile
        $logname = "{0}_MoveLink.log" -f (Get-Date -Format "yyyy_MM-dd-hhmm")
        #using .NET to support cross-platform
        $logfile = Join-Path -Path $([System.io.path]::GetTemppath()) -ChildPath $logname
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Logging activity to $logfile"

        #define a logging helper function
        function log {
            [cmdletbinding(SupportsShouldProcess)]
            param([string]$Message, [string]$LogFile)
            $text = "[{0}] {1}" -f (Get-Date -Format u), $Message
            $text | Out-File -FilePath $logFile -Force -Append
        }

        #set the default parameter value for the log function
        $PSDefaultParameterValues["log:logfile"] = $logfile

        log "Starting: $($myinvocation.MyCommand)"
        $who = "$([System.Environment]::UserDomainName)\$([System.Environment]::UserName)"
        $where = [System.Environment]::MachineName
        log "User: $who"
        log "Computer: $where"

        if (-Not (Test-Path -Path $Destination)) {
            Try {
                log "Creating $destination"
                [void](New-Item -ItemType Directory -Path $Destination -Force -ErrorAction Stop)
            }
            Catch {
                Throw $_
            }
        }
    } #begin
    Process {
        $Path = Convert-Path $path
        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Processing $Path"
        log "Processing $path"
        $parent = Split-Path -Path $path
        $name = Split-Path -Path $path -Leaf
        #get the relative path so it can be reconstructed in the destination
        $target = Join-Path -Path $Destination -ChildPath $parent.Substring(3)

        if ($pscmdlet.ShouldProcess($path, "Move file to $target")) {
            Try {
                #recreate file structure
                if (-Not (Test-Path -Path $Target)) {
                    Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Creating $Target"
                    [void](New-Item -ItemType Directory -Path $Target -Force -ErrorAction Stop)
                    log "Created target $target"
                }
                Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Moving file to $Target"
                $m = Move-Item -Path $path -Destination $target -PassThru -ErrorAction Stop
                log "Moved $path to $target"
            }
            Catch {
                $msg = "Failed to move $path to $target. $($_.Exception.message)"
                Write-Warning $msg
                log $msg
            }
        }

        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Creating link for $path"
        if ($pscmdlet.ShouldProcess($path, "Creating link")) {
            if (Test-Path $m.fullname) {
                Try {
                    $link = New-Item -ItemType SymbolicLink -Name $name -Path $parent -Value $m.FullName -Force -ErrorAction Stop
                    log "Created symboliclink from $($m.fullname)"
                    if ($passthru) {
                        $link
                    }
                }
                Catch {
                    $msg = "Failed to create link to $path from $($m.fullname). $($_.Exception.message)"
                    Write-Warning $msg
                    log $msg
                }
            }
        }

    } #process
    End {
        log "Ending: $($myinvocation.MyCommand)"
        if (Test-Path $logfile) {
            Write-Verbose "[$((Get-Date).TimeofDay) END    ] Activity logged at $logfile"
        }
        if ($PSDefaultParameterValues.ContainsKey("log:logfile")) {
            $PSDefaultParameterValues.Remove("log:logfile")
        }
        Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
    } #end
}

The function will take a filename as a parameter, copy it to the destination, then create a link in the original location. The function has a hard-coded logging function that will create a text-based log file in the user's %TEMP% folder.

$logname = "{0}_MoveLink.log" -f (Get-Date -Format "yyyy_MM-dd-hhmm")
#using .NET to support cross-platform
$logfile = Join-Path -Path $([System.io.path]::GetTemppath()) -ChildPath $logname

function log {
    [cmdletbinding(SupportsShouldProcess)]
    param([string]$Message, [string]$LogFile)
    $text = "[{0}] {1}" -f (Get-Date -Format u), $Message
    $text | Out-File -FilePath $logFile -Force -Append
}

I wrote a short helper function so that each log entry will include a UTC time stamp. I wanted to keep my code simple, so I temporarily set a PSDefaultParameterValue for the function.

#set the default parameter value for the log function
$PSDefaultParameterValues["log:logfile"] = $logfile

log "Starting: $($myinvocation.MyCommand)"

This makes it easy to log events throughout the script. At the end of the function, I remove the PSDefaultParameterValue.

if ($PSDefaultParameterValues.ContainsKey("log:logfile")) {
    $PSDefaultParameterValues.Remove("log:logfile")
}

The function uses the cmdletbinding attribute to enable -WhatIf. Even though most of the commands I'm using will automatically detect -WhatIf, I opted to create my own -WhatIf code to have a bit more control over the messaging.

if ($pscmdlet.ShouldProcess($path, "Move file to $target")) {
    Try {
        #recreate file structure
        if (-Not (Test-Path -Path $Target)) {
            Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Creating $Target"
            [void](New-Item -ItemType Directory -Path $Target -Force -ErrorAction Stop)
            log "Created target $target"
        }
        Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Moving file to $Target"
        $m = Move-Item -Path $path -Destination $target -PassThru -ErrorAction Stop
        log "Moved $path to $target"
    }
    Catch {
        $msg = "Failed to move $path to $target. $($_.Exception.message)"
        Write-Warning $msg
        log $msg
    }
}

I can use the function like this:

The WhatIf is also detected for the logging function. I'll move and link the file.

And here's what ends up in my log.

[2020-12-02 16:16:37Z] Starting: Move-FileWithLink
[2020-12-02 16:16:37Z] User: PROSPERO\Jeff
[2020-12-02 16:16:37Z] Computer: PROSPERO
[2020-12-02 16:16:37Z] Creating d:\archive
[2020-12-02 16:16:37Z] Processing C:\work\s.clixml
[2020-12-02 16:16:37Z] Created target d:\archive\work
[2020-12-02 16:16:37Z] Moved C:\work\s.clixml to d:\archive\work
[2020-12-02 16:16:37Z] Created symboliclink from D:\archive\work\s.clixml
[2020-12-02 16:16:37Z] Ending: Move-FileWithLink

Using a Controller Script

Now that I have a working tool to move and link files I need an easy way to use it. This is where considering who will be using your command and how, comes into play. The move and link function will take any input. An experienced PowerShell user could simply run a command like this:

But, the challenge was looking for a tool a user could run where they could specify locations. The challenge was also looking for a tool to move and link files last modified before a given date. I wrote a controller script.

This type of script is designed to wrap-around a few core commands and execute them in a structured manner. You could run the commands in the script manually and achieve the same result. Controller scripts can also use parameters and almost any scripting feature you would have in a function.

#requires -version 5.1

<#
move specified files to an alternate location, leaving
linked copies behind

Specify the source folder
Specify the target folder
Specify the last modified age
Support recursion
Support an exclude filter

This could be turned into a function instead of a control script
#>

[CmdletBinding(SupportsShouldProcess)]
Param (
    [parameter(Position = 0, Mandatory, HelpMessage = "The folder to process for old files.", ValueFromPipeline)]
    [ValidateScript({Test-Path $_ })]
    [string]$Path,
    [parameter(Position = 1, Mandatory, HelpMessage = "The top-level destination folder.")]
    [string]$Destination,
    [Parameter(HelpMessage = "Specify the last modified cutoff date.")]
    [datetime]$LastModified = (Get-Date).AddDays(-180).Date,
    [parameter(HelpMessage = "Recurse for files from the given path.")]
    [switch]$Recurse,
    [parameter(HelpMessage = "Specify a file pattern to exclude. Wildcards are permitted.")]
    [string]$Exclude,
    [switch]$Passthru
)

Begin {
    Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Starting $($myinvocation.mycommand)"

    #dot source the function that moves files and creates links
    . $PSScriptRoot\Move-FileLink.ps1

    $getParams = @{
        ErrorAction = "Stop"
        File = $True
    }
    if ($Recurse) {
        $getParams.Add("Recurse",$True)
    }
    $moveParams = @{
        Destination = $Destination
        Passthru = $Passthru
    }
    Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Finding files last modified before $LastModified"
    if ($Exclude) {
        Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Excluding files that match the pattern $Exclude"
    }
    Write-Verbose "[$((Get-Date).TimeofDay) BEGIN  ] Using a destination of $destination"

} #begin

Process {
    #convert to a complete file-system path
    $Path = Convert-Path $Path
    Write-Verbose "[$((Get-Date).TimeofDay) PROCESS] Processing $Path"
    $getParams.Path = $Path

    #build a filter for Where-Object. -Exclude only works with Get-Childitem when using
    #recurse, which the user might not want.
    if ($Exclude) {
        Get-ChildItem @getParams | Where-object {($_.LastWriteTime -lt $LastModified) -AND ($_.name -notlike $Exclude)} |
        Move-FileWithLink @moveParams
    }
    else {
        Get-ChildItem @getParams | Where-Object { $_.LastWriteTime -lt $LastModified } | Move-FileWithLink @moveParams
    }
} #process

End {
    Write-Verbose "[$((Get-Date).TimeofDay) END    ] Ending $($myinvocation.mycommand)"
} #end

It wouldn't take much to convert this into a function if I wanted to. In fact, I often start with stand-alone scripts because I can run them without the extra step of dot-sourcing. Once I'm happy with the code, adding the function bits is trivial.

Let's see this script in action.

The Verbose output makes it clear what the command is going to do.

Summary

The core command is flexible and re-usable beyond the challenge requirements. The rest of the challenge requirements are met in the control script. I think many people overlook this concept. Not everything has to be put into a function. A control script is a great way of ensuring consistency. You can sign these scripts, check them into source control, and manage them the same way you handle your PowerShell modules.

If you feel you need to improve your PowerShell scripting skills, consider grabbing a copy of The PowerShell Scripting and Toolmaking Book.


Behind the PowerShell Pipeline

Share this:

  • Click to share on X (Opens in new window) X
  • Click to share on Facebook (Opens in new window) Facebook
  • Click to share on Mastodon (Opens in new window) Mastodon
  • Click to share on LinkedIn (Opens in new window) LinkedIn
  • Click to share on Pocket (Opens in new window) Pocket
  • Click to share on Reddit (Opens in new window) Reddit
  • Click to print (Opens in new window) Print
  • Click to email a link to a friend (Opens in new window) Email

Like this:

Like Loading...

Related

2 thoughts on “Answering the PowerShell Linking Challenge”

  1. Raj says:
    December 4, 2020 at 10:03 am

    Hi Jeff,

    I am searching PS script to find just percentage usage memory of one of the service ( ex: sqlservices on windows server) on windows servers out of its total memory.
    Such that if sql services consumes consistently more than 70% of memory.I can i ask sql team to increase the memory.

    As when we see either in task manger or process explore or perfmon- we will get private memory ,shared memory , however that i can show the accurate usage in generic words ( straight forward).

    Or is there any simple way i missing, either on systeminternal tools or windows itself.
    Request your help.
    Note: i followed all Pluralsight or udemy courses i couldn’t any course or chapter related to percentge of the memory usage.

    1. Jeffery Hicks says:
      December 4, 2020 at 12:40 pm

      I don’t have anything that exactly meets your needs. You would need to identify the process associated with the service then look at the memory. Or if you know the process name, use that. You can add up with Workingset values and divide by the total amount of installed memory. This would make a good Iron Scripter challenge.,

Comments are closed.

reports

Powered by Buttondown.

Join me on Mastodon

The PowerShell Practice Primer
Learn PowerShell in a Month of Lunches Fourth edition


Get More PowerShell Books

Other Online Content

github



PluralSightAuthor

Active Directory ADSI Automation Backup Books CIM CLI conferences console Friday Fun FridayFun Function functions Get-WMIObject GitHub hashtable HTML Hyper-V Iron Scripter ISE Measure-Object module modules MrRoboto new-object objects Out-Gridview Pipeline PowerShell PowerShell ISE Profile prompt Registry Regular Expressions remoting SAPIEN ScriptBlock Scripting Techmentor Training VBScript WMI WPF Write-Host xml

©2025 The Lonely Administrator | Powered by SuperbThemes!
%d