Fix: Windows Service custom credentials under PowerShell Core - #2115
Merged
Conversation
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.
There was a problem hiding this comment.
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 withGet-CimInstance+Invoke-CimMethodfor setting service credentials and readingStartMode. - 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
approved these changes
Aug 7, 2026
hnrkndrssn
left a comment
Contributor
There was a problem hiding this comment.
Nice, this is a sound fix for this issue and a better solution than forcing the step to only support PowerShell Desktop 👍
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
The Windows Service feature sets a custom service account by calling the WMI
Win32_Service.Changemethod on an object returned byGet-WmiObject. That cmdlet was removed in PowerShell 6. Under pwsh it still resolves, because the inheritedPSModulePathpulls 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-CimInstanceandInvoke-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:
sc.exe configsucceeds 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, flippingOctopus.Action.PowerShell.Edition, so Calamari picked the interpreter itself:WindowsPowershell\v1.0\PowerShell.exe(5.1)Wmi returned 2PowerShell\7\pwsh.exe(7.6.3)does not contain a method named 'change'WindowsPowershell\v1.0\PowerShell.exe(5.1)Win32_Service.Change returned 2PowerShell\7\pwsh.exe(7.6.3)Win32_Service.Change returned 2Return 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
StartNameandStartPassword, 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 -Credentialwould have been the obvious alternative but it only exists in PowerShell 6+, so it would break the default Desktop path.-ComputerNameis 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 zeroMicrosoft-Windows-WinRM/Operationalevents,-ComputerName "."produces three, and an instance fetched over WSMan dragsInvoke-CimMethodonto 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
$nullto 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
$customAccountNamethrough verbatim, so a variable that resolved to an empty string sentStartName = "". 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 setServiceAccount = "_CUSTOM"for every case while only one supplies an account name.Testing
ShouldDeployAndInstallWithCustomUserNamepreviously asserted only that the service existed and reachedRunning. It would have passed if the account had never been applied, since the service just starts as LocalSystem. It now readsObjectNameback from the registry and asserts the account.Added
ShouldDeployAndInstallWithCustomUserNameUnderPowerShellCore, which is the same deployment withOctopus.Action.PowerShell.Edition = Core. Nothing in the suite has ever run this convention under pwsh.It does not use
RequiresPowerShellCoreAttribute. That attribute gates onScriptingEnvironment.SafelyGetPowerShellVersion(), which probespowershell.exefirst and returns on the first success, so on Windows it always reports 5.x and would skip the test silently. It followsPowerShellCoreOnWindowsFixtureinstead and resolves the pwsh path throughWindowsPowerShellCoreBootstrapper. Worth fixing that attribute separately, since it is currently referenced nowhere and would not work if it were.Known trade-off
Get-CimInstanceandInvoke-CimMethodneed PowerShell 3.0, whereGet-WmiObjectworked on 2.0.Octopus.Action.PowerShell.CustomPowerShellVersionstill emits-Version <n>, and there is a test assertingCustomPowerShellVersion = 2yields 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