A while ago I decided to split a number of my modules up as they were becoming messy. Instead of standalone modules I now have modular modules.
The split introduced something of a version management problem for me, so I made up a set of module management functions and scripts to see me through from development to release and maintenance of released modules.
This article explores the module tree and looks at the installation and update functionality.
Modular modules
The module structure I use is hierarchical, the root module contains functions which are shared by other modules, functions involved in management, and functions which have no better home.
A few modules are still standalone, these are only bound to the hierarchical structure by the update mechanisms I incorporated into Indented.
Managing updates
Module management comes in three flavours.
- Manual updates
- Semi-automatic updates
- Automatic updates
All automatic components are part of Indented.Common and may be viewed in full by exploring the Indented.Common.psm1 file.
As automatic update components are part of Indented.Common they cannot be used to perform the very first installation of Indented.Common. Taking a bit of a hint from Lee Holmes I wrote a web-installation script which may be used to install Indented.Common.
Manual updates
Manual updating is, of course, fully supported. Files can be downloaded manually from their respective home pages, installation is entirely in the control of the user.
Dependencies must still be honoured, manifest files (psd1) technically enforce this through use of the RequiredModules option.
Semi-automatic updates
My web server hosts a module version control file which is consumed by Get-IndentedModule. The file is updated every time I update and release a version of one of my modules. This file is merged with the output from "Get-Module -ListAvailable" to give a picture of local vs server versions.
# Grab the list of modules from the web server (if possible). Merge everything into a single authoritative list.
$ModuleList = @()
$ModuleList += Get-WebContent http://www.indented.co.uk/ps-modules/modulelist.csv | ConvertFrom-Csv
# Required call to show information about loaded modules from paths other than $env:PsModulePath.
$ModuleList += Get-Module Indented.*
# Add information for modules available under $env:PsModulePath which are not loaded.
$ModuleList += Get-Module Indented.* -ListAvailable | Where-Object { -not (Get-Module $_.Name) }
Updates are still manually invoked, a user can elect to update a module using Install-IndentedModule. For example:
Get-IndentedModule Indented.Common | Install-IndentedModule
An attempt is made to reduce the number of calls to the web server, if running in a pipeline the call executes during begin.
begin {
if ($PsCmdLet.ParameterSetName -eq 'Name') {
# Call back to Get-IndentedModule to get the information we need.
Get-IndentedModule $Name | Install-IndentedModule -ModulePath $ModulePath
}
}
Automatic updates
Automatic updates, if enabled (it’s disabled by default), execute for installed modules only. If a module has not been installed one of the two manual processes above must be used.
Get-IndentedModule |
Where-Object { $_.LocalVersion -ne "Not installed" -and $_.ServerVersion -ne "Not available" } |
ForEach-Object {
Write-Host "Start-IndentedAutoUpdate: Starting update for $($_.Name)" -ForegroundColor Cyan
$_ | Install-IndentedModule
}
To minimise the degree of intrusion the update process is executed when Indented.Common loads rather than through a scheduling mechanism. This means removal of the module remains nothing more than deletion of the module folder. The command is found at the very bottom of Indented.Common.psm1.
$AutoUpdateConfigurationFile = "$PsScriptRoot\AutoUpdateConfiguration.csv"
Start-IndentedAutoUpdate
To keep modules clean, an update (through Install-IndentedModule) will purge existing content.
# Delete the current instance of the module (files only).
if (Test-Path "$ModulePath\$($ModuleDescription.Name)") {
Write-Verbose "Install-IndentedModule: Removing existing version of $($ModuleDescription.Name)"
Remove-Item "$ModulePath\$($ModuleDescription.Name)" -Recurse
}
This also destroys the file which dictates how updates are performed if Indented.Common is updated. To overcome this the automatic update function, Start-IndentedAutoUpdate, reads and caches the file in memory, writing back much of the same content to a new file.
$AutoUpdateConfiguration = Get-IndentedAutoUpdate
# ...
$AutoUpdateConfiguration | Set-IndentedAutoUpdate -NextUpdate (((Get-Date) + $AutoUpdateConfiguration.Frequency).ToString())
Finally, to attempt to avoid errors finding a way in, the meaningful content of the auto-update configuration file is hashed. The hash is checked to verify the consistency, if the consistency check fails the file is dumped and regenerated. Perhaps overkill, but it was interesting to write.
if ($Updated) {
# Update the change record
$AutoUpdateConfiguration.LastModifiedBy = "$($env:UserDomain)\$($env:Username)"
$AutoUpdateConfiguration.LastModified = (Get-Date).ToUniversalTime().ToString("u")
$AutoUpdateConfiguration.Hash = NewIndentedAutoUpdateHash $AutoUpdateConfiguration
# Export the modified configuration.
$AutoUpdateConfiguration |
Select-Object EnableAutoUpdate, Frequency, NextUpdate, LastModifiedBy, LastModified, Hash |
Export-Csv $AutoUpdateConfigurationFile -NoTypeInformation
}
Development to test and release
The script to execute this is a bit too environment specific to share. The general process is as follows:
- If test release copy the module $env:PsModulePath (first value).
- If live release:
-
- Update version numbers (minor unless explicitly stated to be a major update),
- Commit pending changes into TFS (tagged with the version number as a comment).
- Copy the module to $env:PsModulePath (first value).
- Package the module into a zip file and upload the zip file to my web server.
- Regenerate the module list consumed by Get-IndentedModule and upload the file to my web server.
A cron job on the web server takes it from there, setting appropriate permissions and putting the file in the right place.
Version number updates are handled via a pair of functions which deal with reading and writing module manifest files.
Get-ModuleManifest is a simple function which attempts to read the manifest as a hash table from a fixed location (the folder I use for development).
function Get-ModuleManifest {
# .SYNOPSIS
# Get an existing module manifest file.
# .DESCRIPTION
# Get the content of an existing manifest file.
# .PARAMETER Name
# The name of the module within the TFS workspace.
# .INPUTS
# System.String
# .OUTPUTS
# System.Collections.HashTable
# .EXAMPLE
# Get-ModuleManifest Indented.Common
[CmdLetBinding()]
param(
[Parameter(Mandatory = $true)]
[String]$Name
)
$ModuleManifestFile = "$WorkspacePath\$Name\$Name.psd1"
if (-not (Test-Path $ModuleManifestFile)) {
Write-Error "Get-ModuleManifest: Manifest file does not exist"
} else {
return Invoke-Expression (Get-Content $ModuleManifestFile | Out-String)
}
}
Update-ModuleManifest allows arbitrary changes to properties, then uses splatting to pass the changed manifest information back to New-ModuleManifest.
function Update-ModuleManifest {
# .SYNOPSIS
# Update an existing module manifest file.
# .DESCRIPTION
# Update-ModuleManifest changes properties in an existing module manifest file.
#
# Update-ModuleManifest validates the Property parameter, but does not validate the value which is being set.
# .PARAMETER Name
# The name of the module within the TFS workspace.
# .PARAMETER Property
# The property within the manifest to update.
# .PARAMETER Value
# An arbitrary value to set. Validation of the value is left to New-ModuleManifest.
# .INPUTS
# System.String
# System.Object
# .EXAMPLE
# Update-ModuleManifest "SomeModule" -Property ModuleVersion -Value "1.9"
# .EXAMPLE
# Update-ModuleManifest "SomeModule" -Property Guid -Value ([Guid]::NewGuid())
[CmdLetBinding()]
param(
[Parameter(Mandatory = $true)]
[String]$Name,
[Parameter(Mandatory = $true)]
[ValidateSet('AliasesToExport', 'Author', 'CLRVersion', 'CmdletsToExport', 'CompanyName', 'Copyright', 'DefaultCommandPrefix', 'Description', 'DotNetFrameworkVersion', 'FileList', 'FormatsToProcess', 'FunctionsToExport', 'GUID', 'HelpInfoURI', 'ModuleList', 'ModuleVersion', 'NestedModules', 'PowerShellHostName', 'PowerShellHostVersion', 'PowerShellVersion', 'PrivateData', 'ProcessorArchitecture', 'RequiredAssemblies', 'RequiredModules', 'RootModule', 'ScriptsToProcess', 'TypesToProcess', 'VariablesToExport')]
[String]$Property,
[Parameter(Mandatory = $true)]
$Value
)
$ModuleManifestFile = "$WorkspacePath\$Name\$Name.psd1"
if (-not (Test-Path $ModuleManifestFile)) {
Write-Error "Update-ModuleManifest: Manifest file does not exist"
} else {
$ModuleManifest = Get-ModuleManifest $Name
if ($ModuleManifest.Contains($Property)) {
$ModuleManifest[$Property] = $Value
} else {
$ModuleManifest.Add($Property, $Value)
}
New-ModuleManifest -Path $ModuleManifestFile @ModuleManifest
}
}
Perhaps the most frustrating thing about this process is the need to continually restart PowerShell to get the updated instance of the module. In the end I simplified that process by setting up a shortcut in exactly the way I wish, then simply creating a new process based on it:
function Restart-Console {
Start-Process "C:\Stuff\PowerShell.lnk"
exit
}
I like it, it’s extremely simple and leaves me with a console which has focus. No need to reach for the mouse and no need to alt-tab through everything else.