Skip to content

Fix: Windows Service custom credentials under PowerShell Core - #2115

Merged
HuyPhanNguyen merged 5 commits into
mainfrom
huy/2026/fd-657-winservice-pwsh-core
Aug 10, 2026
Merged

Fix: Windows Service custom credentials under PowerShell Core#2115
HuyPhanNguyen merged 5 commits into
mainfrom
huy/2026/fd-657-winservice-pwsh-core

Conversation

@HuyPhanNguyen

@HuyPhanNguyen HuyPhanNguyen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Background

The Windows Service feature sets a custom service account by calling the WMI Win32_Service.Change method on an object returned by Get-WmiObject. That cmdlet was removed in PowerShell 6. Under pwsh it still resolves, because the inherited PSModulePath pulls in the Desktop-only Management module through the Windows PowerShell compatibility shim, but the object comes back through a serialization boundary. Serialization keeps properties and drops methods, so .change() isn't there and the step dies.

Only steps with the service account set to a custom user reach that line, which is why this went unnoticed until a customer set PowerShell Core as their global default script engine.

Results

Both WMI call sites in the convention now use Get-CimInstance and Invoke-CimMethod. Those ship in Windows PowerShell 3.0 and later as well as PowerShell 7, which matters because Calamari ships one copy of this script for both editions.

Fixes FD-657

Before

With the script engine on PowerShell Core and a custom service account, the step fails and the deployment rolls back:

Setting custom service credentials for AtlasNTI.MessageTranslation
InvalidOperation: Method invocation failed because [Deserialized.System.Management.ManagementObject#root\cimv2\Win32_Service] does not contain a method named 'change'.
At ...\Octopus.Features.WindowsService_BeforePostDeploy.ps1:155 char:2
+ $result = $wmiService.change($null, $null, $null, $null, $null, $ .
Script ... returned non-zero exit code: 1

sc.exe config succeeds on the line before, so only the credential path breaks. The workaround is overriding each affected step back to Windows PowerShell, one step at a time, in every project that deploys a service.

After

The step works on both editions. I ran the credential block through a rebuilt Calamari via run-script, flipping Octopus.Action.PowerShell.Edition, so Calamari picked the interpreter itself:

Convention script Executable Calamari chose Result
before this change WindowsPowershell\v1.0\PowerShell.exe (5.1) invoked, Wmi returned 2
before this change PowerShell\7\pwsh.exe (7.6.3) does not contain a method named 'change'
after this change WindowsPowershell\v1.0\PowerShell.exe (5.1) invoked, Win32_Service.Change returned 2
after this change PowerShell\7\pwsh.exe (7.6.3) invoked, Win32_Service.Change returned 2

Return code 2 is access denied, because the test ran unelevated against an existing service. That's the point: a return code means the method resolved, bound StartName and StartPassword, and reached the service control provider, which is exactly what the old code could not do under Core. Desktop behaviour is unchanged, which matters because Desktop is what gets selected when the edition variable is unset.

The password still never reaches a command line. CIM marshals it over the same local COM/DCOM channel the WMI call used, so it stays out of process command lines and out of Event 4688. That constraint is why the original code avoided sc.exe config obj= password=, and it still holds. Set-Service -Credential would have been the obvious alternative but it only exists in PowerShell 6+, so it would break the default Desktop path.

-ComputerName is deliberately absent. Supplying it, even as ".", switches the CIM cmdlets from the local DCOM channel to WSMan, which would put the password on the wire. I measured this: the PR's form produces zero Microsoft-Windows-WinRM/Operational events, -ComputerName "." produces three, and an instance fetched over WSMan drags Invoke-CimMethod onto that session with it. There's a comment on the line, because it looks like an omission someone would later "fix" for parity with the old -computer ".".

Argument handling

The old positional call used $null to mean "leave this field alone", so the arguments are built conditionally rather than passed as a fixed list. An empty password therefore still means "don't touch the password".

One behaviour change worth calling out. The old code passed $customAccountName through verbatim, so a variable that resolved to an empty string sent StartName = "". Omitting it instead would mean the service silently keeps its existing identity, which is the wrong direction for a failure: you asked for a custom account and got LocalSystem with a green deployment. So a blank account name now fails loudly.

That check distinguishes a variable that is absent from one that is set but blank, because those arrive differently and only the second is a misconfiguration. Verified through Calamari: an absent variable arrives as $null, a blank one arrives as "". This matters for the existing tests, which set ServiceAccount = "_CUSTOM" for every case while only one supplies an account name.

Testing

ShouldDeployAndInstallWithCustomUserName previously asserted only that the service existed and reached Running. It would have passed if the account had never been applied, since the service just starts as LocalSystem. It now reads ObjectName back from the registry and asserts the account.

Added ShouldDeployAndInstallWithCustomUserNameUnderPowerShellCore, which is the same deployment with Octopus.Action.PowerShell.Edition = Core. Nothing in the suite has ever run this convention under pwsh.

It does not use RequiresPowerShellCoreAttribute. That attribute gates on ScriptingEnvironment.SafelyGetPowerShellVersion(), which probes powershell.exe first and returns on the first success, so on Windows it always reports 5.x and would skip the test silently. It follows PowerShellCoreOnWindowsFixture instead and resolves the pwsh path through WindowsPowerShellCoreBootstrapper. Worth fixing that attribute separately, since it is currently referenced nowhere and would not work if it were.

Known trade-off

Get-CimInstance and Invoke-CimMethod need PowerShell 3.0, where Get-WmiObject worked on 2.0. Octopus.Action.PowerShell.CustomPowerShellVersion still emits -Version <n>, and there is a test asserting CustomPowerShellVersion = 2 yields PSVersion 2.0. So a target with the optional PS2 engine installed, pinned to version 2, using a custom service account, would break. I think that is acceptable given Calamari is net8.0-only and modern Windows ships 3.0+, but it is a deliberate choice rather than an oversight.

Pre-requisites

  • I have read How we use GitHub Issues for help deciding when and where it's appropriate to make an issue.
  • I have considered informing or consulting the right people, according to the ownership map.
  • I have considered the appropriate target version for this PR.
  • I have considered appropriate testing for my change.
    • Automated testing / Exploratory testing / Nothing required?
  • I have considered manually testing my changes on a branch instance to check correctness, stability and performance on Octopus Cloud.
  • I have considered safety nets to reduce any time-to-recovery for my change.

Get-WmiObject was removed in PowerShell Core, so the custom credentials path died with a method-not-found error. CIM works on both editions and still keeps the password off the command line.
A blank account name previously left the service on its existing identity with a green deployment. The custom-account test now reads the account back from the registry, and there's a PowerShell Core variant of it.
The SCM rewrites a local account into the .\name form rather than storing what was passed, so comparing against the full NT account name failed.
@HuyPhanNguyen
HuyPhanNguyen marked this pull request as ready for review August 7, 2026 01:20
@HuyPhanNguyen
HuyPhanNguyen requested a review from a team August 7, 2026 01:20
@hnrkndrssn
hnrkndrssn requested a review from Copilot August 7, 2026 01:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request updates the Windows Service deployment convention to work correctly when executed under PowerShell Core by replacing legacy WMI (Get-WmiObject + .Change()) calls with CIM-based equivalents that preserve method invocation across PowerShell editions.

Changes:

  • Replaced Get-WmiObject/Win32_Service.Change() usage with Get-CimInstance + Invoke-CimMethod for setting service credentials and reading StartMode.
  • Added validation for a blank (but set) custom account name to fail loudly instead of silently keeping the prior identity.
  • Strengthened Windows Service fixture assertions to verify the applied logon account and added a PowerShell Core variant of the test.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
source/Calamari/Scripts/Octopus.Features.WindowsService_BeforePostDeploy.ps1 Switches Windows service WMI calls to CIM and adds validation around custom service credentials.
source/Calamari.Tests/Fixtures/Deployment/DeployWindowsServiceFixture.cs Adds a PowerShell Core coverage test and asserts the service logon identity via registry/SID.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


if ($serviceAccount -eq "_CUSTOM") {
# dont use sc.exe to set the username / password, as it may be logged to the windows audit log if process creation event logs are enabled
# dont use sc.exe to set the username / password, as it may be logged to the windows audit log if process creation event logs are enabled
Comment on lines +158 to +159
$cimService = Get-CimInstance -ClassName Win32_Service -Filter "name='$($serviceName -replace "'", "\'")'"
$changeArguments = @{}
Comment on lines +108 to +110
var path = new WindowsPowerShellCoreBootstrapper(new WindowsPhysicalFileSystem()).PathToPowerShellExecutable(new CalamariVariables());
if (!File.Exists(path))
Assert.Inconclusive("PowerShell Core is not installed on this machine");

@hnrkndrssn hnrkndrssn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this is a sound fix for this issue and a better solution than forcing the step to only support PowerShell Desktop 👍

HuyPhanNguyen and others added 2 commits August 7, 2026 12:42
…via PATH in the test

PathToPowerShellExecutable can return a bare pwsh.exe for PATH resolution, so File.Exists alone marked the Core test inconclusive even where Core was installed.
@HuyPhanNguyen
HuyPhanNguyen merged commit f77dbb1 into main Aug 10, 2026
29 checks passed
@HuyPhanNguyen
HuyPhanNguyen deleted the huy/2026/fd-657-winservice-pwsh-core branch August 10, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants