Category: PowerShell

  • How to make a PowerShell module

    Assuming you have a few PowerShell functions that you would like to gather into one package to easily distribute, you will want to make a module. This will allow you to load your functions on a specific computer and then call the functions without any further effort on your part.

    At work, I use Azure DevOps so I wrote this to take my code and bundle the functions into a module, test the module is operational, and save the module as an artifact that can be downloaded. If you don’t want to use ADO, you can adapt the code as necessary.

    I didn’t figure this all out on my own, here’s where I learned all this-

    These instructions are an amalgamation of those listed in the links and simplified for me.

    1. Make folder structure
      1. Add your functions and tests
    2. Make configuration file, from sample
    3. Copy build file, from GITHUB
    4. Copy Pester Tests, from GITHUB
    5. Copy YAML file to define build process, from GITHUB
    6. Create pipeline in Azure DevOps and output artifact
    7. Review code to download new artifact

    To hold the sample code, I have a GITHUB repo with sample files-

    https://github.com/chinkes5/PowerShellModule

    Step 1

    First is to organize what you have. The folder structure and a few key file names will drive a lot of how the module is found and used. All the folders described are relative to the same root folder in your Azure DevOps repository.

    The functions you have should be named with one file having a single function and the name of the file the same as the function within. Those that are public, that is functions for general consumption, should be put in a folder named ‘Public’. If you have private functions that you don’t want generally exposed, you can make a folder ‘Private’ as well.

    Make a folder ‘Tests’. There will be a few standard tests to make sure the module can be built. If you have written any Pester tests for your functions, add them here as well. The expected format will be [function-name].test.ps1

    Step 2

    In the root folder, make a json configuration file called ‘module-config.json’. Here is a sample to follow:

    {
        "name": "My_PS_Module",
        "description": "PowerShell module for use by my team for standard operations",
        "Author": "John Chinkes",
        "CompanyName": "Your Company Name",
        "CopyrightStartYear": 2024,
        "GUID": "your-guid-value",
        "PowerShellVersion": "5.0"
      }

    The build will use this information to make a PSM1, PSD1, and NuSpec files.

    Step 3

    You will want a build file to assemble all the functions and create your manifest file. Use the following, calling it ‘build.ps1’:

    Here’s the file in GITHUB- https://github.com/chinkes5/PowerShellModule/blob/main/build.ps1

    The values you put in the config file will be found and swapped out by this process. Also, the names of all the functions will be added. When you call the module, it will read the public and private folders to expose the public functions. To add new functions to your module, just re-run this build file and they will be added!

    If you want an explanation of what’s going on inside this file, see this post.

    Step 4

    Optionally, make Pester tests for your functions and the module. How to make Pester tests is outside the scope of this document but the following basic test is recommended:

    Here’s the file in GITHUB- https://github.com/chinkes5/PowerShellModule/blob/main/test.ps1. It will print out several paths so you can see where it’s working and then invoke-pester on the ‘Tests’ folder. The output will be an xml file that Azure DevOps will display with the build.

    Save this file as test.ps1 in the root of your module folder.

    Step 5

    The output of the pipeline will not actually be a module, but a NuGet file that can be loaded to on the destination computer.

    At noted at the beginning, I’m working with Azure DevOps for my CI/CD tool. Here is a sample YAML file to build the module. Please be careful with the spacing and formatting when you copy! You will want to swap out the path to your code in the repository. At the end of the file, swap out the name of your feed in Azure DevOps Artifacts.

    Here’s the file in GITHUB- https://github.com/chinkes5/PowerShellModule/blob/main/module.yaml. Save this as Module.YAML in the root of your module directory.

    Step 6

    Create the pipeline to output a new module to your Azure DevOps Artifacts. You will want to check in all the code and structure you have created above.

    1. Sign in to Azure DevOps:
    2. Navigate to Pipelines:
      • In the left-hand menu, click on Pipelines.
      • Click on New pipeline.
    3. Select the Repository:
      • Choose the location of your code (e.g., Azure Repos Git, GitHub, etc.).
      • Select the repository where your YAML file is located.
    4. Configure the Pipeline:
      • In the Configure step, select Existing Azure Pipelines YAML file.
      • Choose the Module.YAML file from your repository.
    5. Review and Run:
      • Review the YAML file content.
      • Click on Save and run to start the pipeline.

    After the pipeline has run, you should be able to check for a new artifact with your module.

    Step 7

    You will need a PAT with permissions to read and download packages or set the permissions to allow for public consumption. Use this code to register your Azure DevOps Artifacts on a destination computer, swapping out your PAT, a name for your repo, and the exact URL to your DevOps Artifact:

    $patToken = "Your PAT goes here" | ConvertTo-SecureString -AsPlainText -Force 
    $credsAzureDevopsServices = New-Object System.Management.Automation.PSCredential("username", $patToken) 
    
    $srePackageSource = "Name you call your repo" 
    packageSourceList = Get-PackageSource 
    if ($packageSourceList.Name -notcontains $srePackageSource) { 
        Write-Output "Adding package source to be able to get $srePackageSource..." 
        $regParam = @{ 
            Name               = $srePackageSource         SourceLocation     = "https://pkgs.dev.azure.com/[collection]/_packaging/youPathTo/nuget/v2"
            PublishLocation    = "https://pkgs.dev.azure.com/[collection]/_packaging/ youPathTo/nuget/v2"
            InstallationPolicy = 'Trusted' 
            Credential         = $credsAzureDevopsServices
            Verbose            = $true
        }
        Register-PSRepository @regParam 
    } 
    else { 
        Write-Output "Package source of $srePackageSource found." 
    } 
    
    $psRepoList = Get-PSRepository 
    if ($psRepoList.Name -notcontains $srePackageSource) { 
        Write-Output "Registering package source $srePackageSource..." 
        $pakParam = @{ 
            Name         = $srePackageSource 
            Location     = "https://pkgs.dev.azure.com/[collection]/_packaging/youPathTo/nuget/v2" 
            ProviderName = 'NuGet'
            Trusted      = $true 
            SkipValidate = $true 
            Credential   = $credsAzureDevopsServices 
        }
        Register-PackageSource @pakParam 
    }
    else {
        Write-Output "PowerShell repository $srePackageSource found." 
    }

    Then use PowerShellGet to find and download the module, note the reuse of the credential from prior script. Swap out the name used for your repo and the name of the module in this code:

    Write-Output "Finding Your Module..." 
    Find-Module -Name 'Name of Module' -Repository 'Name you call your repo' -Credential $credsAzureDevopsServices -Verbose x
    Write-Output "Downloading Your Module..." 
    Save-Module -Name 'Name of Module' -Repository 'Name you call your repo' -Path ($env:PSModulePath -split ';')[1] -Credential $credsAzureDevopsServices -Verbose 
    Write-Output "Importing Your Module..." 
    Import-Module -Name “Name of Module” -Force -Scope Global

    You now have a module and downloaded it on a destination computer!

  • PowerShell Regex Matching

    I was working on a project where I needed to understand the naming convention for the servers. Since they had been made by several teams over several years, there was no convention. It was a giant pain in the ass.

    I wrote a function that would accept the server name and then try to parse it out. While there was not a strong standard, there was a few soft standards I could guess at. It took a while to figure out but PowerShell -match returns an array, if you use regex groups. All I needed was a few regex patterns and then I could start to decode these server names!

    The servers were in different data centers, so some had DEN, ’cause they were in Denver data center (see?). But some were in CINC (with four letters, not even keeping three letters) as they were in the Cincinnati data center. The next few characters in the name gave some hint as to purpose; WEB, or SQL or something more obscure like OTOPS, again with varying number of letters. Last, the server could have a number, 01, 02, etc. or a letter, A, B, C if it was part of a set. But then we had a few that were part of 01 set but there were several of those so you got 01A, 01B, 01C.

    There are several great regex tools on-line to show you how your pattern is working and what the rules are. For the server names I wound up with:

    (?<datacenter>den|cinc)(?<role>\w+)(?<countNum>\d{2})(?<countLet>[a-d])

    PowerShell isn’t case sensitive, so we’ll ignore those differences. Also, I’m using named groups, those are defined with ?<datacenter> where the name of the group is data center. Then everything that matches within the ( ) for that group winds up in $Matches.datacenter. $Matches is a built-in variable and can be referenced by index number if not using named groups.

    switch -regex ($serverName){
      "(?<datacenter>den|cinc)(?<role>\w+)(?<countNum>\d{2})(?<countLet>[a-f])" {
        $datacenter = $Matches.datacenter
        $role       = $Matches.role
        $countNum   = $Matches.countNum
        $countLet   = $Matches.countLet
        break
      }
      "(?<datacenter>den|cinc)(?<role>\w+)(?<countNum>\d{2})" {
        $datacenter = $Matches.datacenter
        $role       = $Matches.role
        $countNum   = $Matches.countNum
        $countLet   = "None"
        break
      }
    }

    This is what I wound up with; the switch takes the name of the server passed to my function, checks it against a few different regex patterns, and where there is a match for all groups of the pattern, executes the code block.

    In the code block I’m pulling the named group and assigning to my values to spit out at the end of the function. Additionally, you see the second pattern is for when there are only numbers in the name. In the running code I had a few more patterns to match all the options used when creating servers. Note the break in the script block, that’s because the switch will keep matching patterns all the way down the statement! The second pattern will give me “None” for my count letter value and not an error or something unexpected.

    I didn’t figure all this out on my own, Kevin’s article on Regex showed me the way. You can get the details on named groups here.

  • parse array for valid usernames

    I was helping a colleague with a list of email addresses and trying to get a valid username out of it. He is going to get a list of emails every week so writing a script to parse it out for him seemed the best idea. This is only part of the process; there are steps to actually create the account if not already existing and more. So this is mainly an example of text parsing to get the info you want based on a few simple rules.

    clear
    $emails = "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "Perumel.Aranin.Senthenekrishnin"
    $usernames = @()
    
    function Get-Username() {
    <#
    .SYNOPSIS
     takes a given email address and makes a username based on specific rules
    .DESCRIPTION
     Converts a given email address to a username. 
     The username cannot be more than 20 char long, if the first and last name combined are too long only first initial is used
     If the email has a number in it, it is retained in the new username
    .PARAMETER
     -email: the email to work with
     -userName: if the email is not parsable, a username can be given to use instead (or in addition to)
    .EXAMPLE
     Get-Username -email "[email protected]" -userName "jchinkes"
    .NOTES
    #>
     Param(
     [Parameter(Mandatory=$True)][string]$email,
     [string]$userName
     )
     $fname = $null
     $lname = $null
     $nameNum = $null
     try {
     #split the email (do we need a test for valid email address?)
     $splitString1 = $email.split('@')
     #take the username of email and split on dot
     $splitString2 = $splitString1[0].split('.')
     
     if($splitString2.Count -le 1){
     #error checking if there is no first or last name
     #is there a better test?
     #use $userName here somehow
     }
     
     if($splitString2.Count -ge 3){
     #more than three items in the array
     if($splitString2[2] -match "^[\d\.]+$"){
     #the third item is a number
     $nameNum = $splitString2[2]
     $lname = $splitString2[1]
     }
     else {
     #third item is a name
     $lname = $splitString2[2]
     }
     }
     else {
     #two part user name
     $lname = $splitString2[1]
     }
     $fname = $splitString2[0]
     
     if($nameNum -ne $null){
     #eval name to see if needs trimming
     if ($fname.get_Length() + $lname.get_Length() -gt 18){
     #trim fname and keep lname
     $fname = $fname.Substring(0,1)
     if ($lname.get_Length() -gt 16){
     #need to trim lname too!
     $lname = $lname.Substring(0,16)
     }
     }
     return "$fname.$lname.$nameNum"
     }
     else {
     if ($fname.get_Length() + $lname.get_Length() -gt 20){
     #trim fname and keep lname
     $fname = $fname.Substring(0,1)
     if ($lname.get_Length() -gt 18){
     #need to trim lname too!
     $lname = $lname.Substring(0,18)
     }
     }
     return "$fname.$lname"
     }
     }
     catch {
     "There was an error- $($_.Exception.Message)"
     }
    }
    
    
    foreach($email in $emails){
     $usernames += Get-Username -email $email
    }
    
    $usernames