Chris Fellows
Chris Fellows

Reputation: 49

Error 'Install-Module is not recognised.' when Linux Azure App Service tries to install PS module

I have an ASP.NET Core web app (Linux) as an Azure App Service and a page tries to install the ExchangeOnlineManagement PowerShell module. The script needs to connect to exchange and call Get-EXOMailbox. The script reports PowerShell v7.3.6.

I get this error: "The term 'Install-Module' is not recognized as a name of a cmdlet, function, script file, or executable program"

var scriptContents = "if(-not (Get-Module ExchangeOnlineManagement -ListAvailable))" + Environment.NewLine +
                    "{ " + Environment.NewLine +
                        "Write-Host $PSVersionTable.PSVersion" + Environment.NewLine +
                        "Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force" + Environment.NewLine +
                    "}";
            
using (PowerShell ps = PowerShell.Create())
{
    ps.AddScript(scriptContents);
    var pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false);
}

Upvotes: 1

Views: 533

Answers (1)

mklement0
mklement0

Reputation: 439822

Context:

  • PowerShell (Core) SDK projects built with the PowerShell NuGet package - that is, applications or libraries that host their own copy of PowerShell - do not bundle the same non-built-in modules that stand-alone PowerShell (Core) 7+ installations do.

    • Notably, this means that a PowerShell SDK project does not come with the PowerShellGet module that the Install-Module cmdlet is a part of.

    • Specifically (as of PowerShell 7.4), an SDK project bundles only the following, built-in[1] modules:

      • Microsoft.PowerShell.Host
      • Microsoft.PowerShell.Management
      • Microsoft.PowerShell.Security
      • Microsoft.PowerShell.Utility
    • And does not come with the following external ones:

      • PowerShellGet
      • PackageManagement
      • Microsoft.PowerShell.PSResourceGet
      • Microsoft.PowerShell.Archive
      • ThreadJob
      • PSReadLine
  • On Windows, where there is a predefined PSModulePath environment variable ($env:PSModulePath) that points to legacy Windows PowerShell (all-users only) directories even when running an executable from outside a PowerShell session, you should still be able to call Install-Module, which - in the absence of a -Scope argument - would result in a user-level installation of the targeted module.

    • Due to PowerShell itself augmenting PSModulePath on startup (see below), its user-level module root directory will be targeted ($HOME\Documents\PowerShell\Modules on Windows), which means that stand-alone PowerShell (Core) 7+ sessions will see any installed module there too. (Similarly, a -Scope AllUsers installation from a PowerShell (Core) SDK project would install to the all-users PowerShell (Core) 7+ location, $env:ProgramFiles\PowerShell\Modules on Windows.)
  • On Unix-like platforms - such as in your case (Linux) - there is no predefined PSModulePath environment variable, which explains your symptom.

    • While PowerShell itself (in both editions) defines PSModulePath with default values (also for user-specific locations) when it starts up, in SDK projects it will use the directory of the hosting application as the directory for its system modules, i.e. the ones bundled with PowerShell itself.

Upshot:

An application / library hosting PowerShell may find a PowerShellGet module (which hosts the Install-Module cmdlet) via a preexisting stand-alone PowerShell installation; the same applies to all system modules, i.e. those that come bundled with such an installation:

  • Always on Windows, given that Windows PowerShell ships with Windows.

    • If you happen to start your application from a PowerShell (Core) 7+ session, your application will use the latter's PowerShellGet module.

    • As an aside re non-system modules:

      • If there happens to be a stand-alone PowerShell 7+ installation, your application will also see the modules that were installed by the user there, typically via Install-Module.
  • Only incidentally, if at all, on Unix-like platforms:

    • If you happen to start your application from a PowerShell 7+ session (as opposed to from, say, Bash), your application will use the latter's PowerShellGet module.

    • Otherwise, your application won't find that module.

    • As an aside re non-system modules:

      • If there happens to be a stand-alone PowerShell 7+ installation, your application will also see the modules that were installed by the user there, typically via Install-Module.

Caveat:

  • Even if present, use of Install-Module will invariably target the module directories of the stand-alone PowerShell installation that the module is "borrowed" from - which may be undesired.

Custom on-demand installation of PowerShell modules from applications hosting their own copy of PowerShell:

Since you cannot assume the presence of the Install-Module cmdlet on non-Windows platforms, you'll need custom code to download and install modules on demand.

  • While this is obviously cumbersome, it has the advantage of letting you install private copies of the modules you need, in a local directory of your choice.

  • A potential alternative is to download the PowerShellGet and PackageManagement modules at development time and bundle them with your application / library - see this answer.

The following code automates the manual steps documented in Manual Package Download for directly downloading and installing a module from the PowerShell Gallery:

  • Specify the full path of the local directory below in which modules downloaded on demand should be installed.

  • Limitations of the code:

    • Dependencies, i.e. additional modules that your target module(s) require to function must be explicitly downloaded as well.

    • For simplicity, the code simply downloads the latest stable version of the specified (module), though adding support for versioning doesn't require much more effort.

  • See the source-code comments for details.

# Make the Write-Verbose statements below produce output.
# Set to $false to silence them.
$verbose = $true

# The name(s) of the module(s) to download.
# If a module has *dependencies*, i.e. requires other modules to function,
# append them to the array.
# SEE NOTE ABOUT VERSIONS BELOW.
$requiredModules = @('ExchangeOnlineManagement')

# Where to install manually downloaded modules.
# Note: Be sure to use a FULL PATH.
$modulesRootDir = "$HOME\.MyApp\Modules"

# Add the root dir. of all manually installed modules to $env:PSModulePath,
# if necessary.
if (($env:PSModulePath -split [IO.Path]::PathSeparator) -notcontains $modulesRootDir) {
  $env:PSModulePath += [IO.Path]::PathSeparator + $modulesRootDir
}

# Determine which modules need to be dowloaded, if any.
$missingModules = 
  Compare-Object -PassThru $requiredModules @(Get-Module -ListAvailable $requiredModules | ForEach-Object Name)

# Download and install any missing modules.
foreach ($moduleName in $missingModules) {

  # Silence the progress display during download and ZIP archive extraction.
  # Note: For this to be effective for Expand-Archive, the *global* variable must be set.
  $prevProgressPreference = $global:ProgressPreference
  $global:ProgressPreference = 'SilentlyContinue'

  try {
  
    # NOTE RE VERSIONING: 
    # For simplicity, this code does NOT support versioning, which means:
    #   * The *latest stable version* of each module is downloaded.
    #   * Such a version is placed directly in directory named for the module, 
    #     i.e. installation of *multiple versions*, side by side - which would
    #     require version-specific subdirs. - is *not* supported here.
    # To download a specific version, simply append /<version> to the URL below, e.g.:
    #       https://www.powershellgallery.com/api/v2/package/PSReadLine/2.2.6
  
    # Derive the download URL, the local installation dir., and path of the temp. ZIP file.
    $downloadUrl = 'https://www.powershellgallery.com/api/v2/package/{0}' -f $moduleName
    $moduleDir = Join-Path $modulesRootDir $moduleName
    $tempZipFile = Join-Path $moduleDir "$moduleName.zip"
  
    Write-Verbose -Verbose:$verbose "Downloading and installing module $moduleName to $moduleDir..."
    
    # Make sure the target directory exists.
    $null = New-Item -ErrorAction Stop -ItemType Directory -Force $moduleDir    
  
    # Download the *.nupkg file, as a *.zip file (which it technically is)
    Invoke-WebRequest -ErrorAction Stop $downloadUrl -OutFile $tempZipFile
      
    # Extract the contents of the *.zip file.
    Expand-Archive -ErrorAction Stop -Force -LiteralPath $tempZipFile -DestinationPath $moduleDir
    
    # Clean up files that aren't needed locally.
    Get-Item $moduleDir\* -Include *.zip, _rels, '`[Content_Types`].xml', *.nuspec, package |
      Remove-Item -Recurse -Force
  }
  finally {
    $global:ProgressPreference = $prevProgressPreference
  }

}

# Now you can import the modules - either explicitly, as in this example, 
# or implicitly, by module auto-loading via $env:PSModulePath
Write-Verbose -Verbose:$verbose "Importing module $($requiredModules[0])..."
Import-Module -ErrorAction Stop $requiredModules[0]

[1] Built-in refers to those modules whose implementing assemblies (*.dll) are part of PowerShell itself (built directly from the source-code repository), whereas the additional, external modules that a stand-alone PowerShell installation ships with are copied from elsewhere as part of the packaging process. In the $PSHOME/Modules folder, you can distinguish between those two module types as follows: built-in modules have only a *.psd1 file in their folder (which references the relevant *.dll located directly in $PSHOME).

Upvotes: 2

Related Questions