I'm working on a rather large build automation tool that is made of 90% PowerShell code.
A lot of the funcitonality is grouped in .psm1 files that load each other by using
Import-Module "$PSScriptRoot/AnotherModule.psm1" -Force
this seemed to be a good idea for developing purposes / testing code etc, but I recently stumbled upon a problem with this approach.
whenever a loaded module force-imports another module that had previously been loaded in the parent/outer scope, it not longer is accessible in this parent/outer scope
now I'm running into a problem, where importing required modules at the beginning of script does not guarantee that the imported modules are actually available during script execution, as one of the imported modules may 'hide' another, previously loaded module :-/
let's say we've got three modules: ModA, ModB, ModC:
ModA:
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot\ModB.psm1" -Force
// module A entrails
ModB:
$ErrorActionPreference = "Stop"
// module B entrails
ModC:
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot\ModA.psm1" -Force
// module C entrails
following pester tests demonstrate this 'module hiding' behavior:
Describe 'powershell module system tests' {
# ModA -> Import ModB -Force
# ModC -> Import ModA -Force
$mod_path = Join-Path $PSScriptRoot "testmod"
$mods = @("ModA", "ModB", "ModC")
It "cleanup leftovers from previous tests" {
Remove-Module $mods -ErrorAction SilentlyContinue
}
It 'is able to import ModA, which internally loads module B' {
Import-Module "$mod_path\ModA.psm1" -Force
Get-Module ModA | Should -Not -BeNullOrEmpty
Get-Module ModB | Should -BeNullOrEmpty
}
It 'is able to import a second module already imported by the first one' {
Get-Module ModA | Should -Not -BeNullOrEmpty
Import-Module "$mod_path\ModB.psm1" -Force
Get-Module ModB | Should -Not -BeNullOrEmpty
}
It 'still knows the first module' {
Get-Module ModA | Should -Not -BeNullOrEmpty
}
It 'is able to import a thid module that also imports the first one' {
Get-Module ModC | Should -BeNullOrEmpty
Import-Module "$mod_path\ModC.psm1" -Force
Get-Module ModC | Should -Not -BeNullOrEmpty
}
It 'does not know A or B any longer, as it has been hidden by C' {
Get-Module ModA | Should -BeNullOrEmpty
Get-Module ModB | Should -BeNullOrEmpty
}
It 'still knows C' {
Get-Module ModC | Should -Not -BeNullOrEmpty
}
It 'cleanup leftovers from this test' {
Remove-Module $mods
}
}
in order to pinpoint the issue(s), I've tried to come up with another pester test that analyzes the codebase and verifies no 'already loaded' module is removed from the global module table when importing another one:
Describe 'verify tool module system hierachy' {
$tool_modules = Get-ChildItem $script:engine_path -Filter "*.psm1"
function UNLoadAllToolModules {
Remove-Module 'Tool-*'
}
function LoadAllToolModules {
$tool_modules | Foreach-Object { Import-Module $_.FullName }
}
It "cleanup leftovers from previous tests" {
UNLoadAllToolModules
}
It "loading of a module does not hide an already loaded module" {
$errMods = $()
$tool_modules | ForEach-Object {
LoadAllToolModules
$pre = Get-Module
$mod = $_
Import-Module $mod.FullName -Force
$post = Get-Module
$mods = Compare-Object $post $pre | Select-Object -Expand InputObject | Select-Object -Expand Name
$mods | Foreach-Object {
"import of '$mod' hides '$_'" | Write-Host
}
if ($mods) {
$errMods += $mod.Name
}
}
$errMods | Should -BeNullOrEmpty
}
}
is there a way to print / debug the global module table, as depicted here, so I can figure out which module is loaded multiple times / how the module tree gets modified after an import?
currently, the only way around this 'issue' (let beside that it's probably a bad design choice to -force import modules in other modules) is to either drop the -force
or use -global
whenever calling Import-Module -Force
on a module contained in this build automation suite/tool.
My answer to this is simple, but may not be workable in your situation.
Scopes in PowerShell are fairly straightforward. However when you start trying to do things like this I'd just suggest you just load everything into the Global scope and be done with it. Otherwise, you will end up chasing your tail a lot.
On an end users machine, using the Global scope is not acceptable (to me anyway). On a build automation system using the Global scope may be acceptable, depending on your situation, as that's all that it will be used for.
If you look at the help for the Import-Module
cmdlet, you'll see this under the -Global
parameter:
> [!TIP] > You should avoid calling `Import-Module` from within a module.
> Instead, declare the target module as a nested module in the parent module's manifest.
> Declaring nested modules improves the discoverability of dependencies.
The Global parameter is equivalent to the Scope parameter with a value of Global.
(I fixed the formatting so it works here).
This suggestion / tip may also work for you.