-
Notifications
You must be signed in to change notification settings - Fork 20
Tentacle can detect Powershell scripts that don't start, and can abandon them. #1200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
6e4a03f
Add support for powershell start up failire detection
LukeButters 2bf23cb
.
LukeButters 23314d4
.
LukeButters 56b9d7c
.
LukeButters 0ca63bf
.
LukeButters 0e342e9
chore: only windows and powershell.exe seems to be affected by this i…
hnrkndrssn 94f139c
chore: InjectDetectionCode already does the check for if the replacem…
hnrkndrssn 82ea6f8
chore: remove file check as the file create will fail if the file exists
hnrkndrssn 7784a49
chore: make RunningScript.Execute async
hnrkndrssn 5865c2c
chore: use a separate cancel on dispose cancellation token for monito…
hnrkndrssn 4b91a6e
chore: tests should only run on windows
hnrkndrssn 80fe29b
chore: include startup detection code
hnrkndrssn 7b92380
Revert "chore: include startup detection code"
hnrkndrssn 3c112f4
fix: only start script with monitoring if we detect the special comme…
hnrkndrssn 578c344
feat: make duration to wait for powershell to start configurable
hnrkndrssn 7f2fe33
chore: fix failing test due to message mismatch
hnrkndrssn f09bea5
chore: add method for specifying timeout for monitor powershell startup
hnrkndrssn 42f02c3
chore: some cleanup
hnrkndrssn 58bcbc9
Refactor test to a higher level, and don't change contracts
LukeButters 1d6aaed
.
LukeButters cc66c45
.
LukeButters 645d8ce
.
LukeButters e932a6d
Install pwsh on agents
LukeButters cf46bac
.
LukeButters cc05ef4
.
LukeButters 09c9e86
.
LukeButters 5bb0dd6
.
LukeButters 7b82b5a
.
LukeButters 80dd551
From review
LukeButters a33a74a
.
LukeButters 5bf8b4c
Change timeout to 13minutes a unique timeout, that is well above the …
LukeButters File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # PowerShell Startup Detection | ||
|
|
||
| ## Background | ||
|
|
||
| When `powershell.exe` is invoked to run a script it can occasionally start the OS process but silently stall before executing | ||
| any script content — typically due to Group Policy, antivirus, or other startup hooks that block or hang the PowerShell host. | ||
| Tentacle had no way to distinguish this from a legitimately long-running script, so affected deployments would hang indefinitely | ||
| with no useful error. | ||
|
|
||
| The startup detection mechanism lets Tentacle detect and report when PowerShell starts but never executes the script body. | ||
|
|
||
| ## Opting in | ||
|
|
||
| Detection is opt-in. To enable it, include the following marker comment somewhere near the top of your script body (before any code you want to run): | ||
|
|
||
| ```powershell | ||
| # TENTACLE-POWERSHELL-STARTUP-DETECTION | ||
| ``` | ||
|
|
||
| When Tentacle bootstraps the script it replaces this marker with generated detection code. Scripts that do not include the marker are completely unaffected. | ||
|
|
||
| ## What happens at runtime | ||
|
|
||
| When the marker is present, Tentacle: | ||
|
|
||
| 1. Writes a `.octopus_powershell_should_run` file to the script workspace before launching PowerShell. | ||
| 2. Replaces the marker with generated PowerShell that, at the point it executes: | ||
| - Exclusively creates a `.octopus_powershell_started` sentinel file. | ||
| - Verifies the `.octopus_powershell_should_run` file still exists. | ||
| - Exits with code `-47` if either check fails (meaning the monitor already concluded PowerShell never started). | ||
| 3. Runs a monitoring task concurrently with script execution. If the sentinel file is not created within the startup timeout the monitor: | ||
| - Creates the sentinel itself (so any late-starting PowerShell process will exit immediately). | ||
| - Deletes the should-run file (so any late-starting process that somehow missed the sentinel will also exit). | ||
| - Returns exit code `-47` (`PowerShellNeverStartedExitCode`) with a "process did not start within…" message written to the task log. | ||
|
|
||
| ## Startup timeout | ||
|
|
||
| The default timeout is **5 minutes**. It can be overridden by setting the environment variable: | ||
|
|
||
| ``` | ||
| TentaclePowerShellStartupTimeout=<TimeSpan> | ||
| ``` | ||
|
|
||
| For example, `00:02:00` for a 2-minute timeout. | ||
|
|
||
| ## Platform support | ||
|
|
||
| Currently scoped to `powershell.exe`. |
39 changes: 39 additions & 0 deletions
39
source/Octopus.Tentacle.CommonTestUtils/Diagnostics/SerilogSystemLog.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| using System; | ||
| using Octopus.Tentacle.Core.Diagnostics; | ||
| using Octopus.Tentacle.Diagnostics; | ||
| using Serilog; | ||
| using Serilog.Events; | ||
| using LogEvent = Octopus.Tentacle.Diagnostics.LogEvent; | ||
|
|
||
| namespace Octopus.Tentacle.CommonTestUtils.Diagnostics | ||
| { | ||
| public class SerilogSystemLog : SystemLog | ||
| { | ||
| readonly ILogger logger; | ||
|
|
||
| public SerilogSystemLog(ILogger logger) | ||
| { | ||
| this.logger = logger; | ||
| } | ||
|
|
||
| protected override void WriteEvent(LogEvent logEvent) | ||
| { | ||
| var level = ToSerilogLevel(logEvent.Category); | ||
| if (logEvent.Error != null) | ||
| logger.Write(level, logEvent.Error, logEvent.MessageText); | ||
| else | ||
| logger.Write(level, logEvent.MessageText); | ||
| } | ||
|
|
||
| static LogEventLevel ToSerilogLevel(LogCategory category) => category switch | ||
| { | ||
| LogCategory.Trace => LogEventLevel.Verbose, | ||
| LogCategory.Verbose => LogEventLevel.Debug, | ||
| LogCategory.Info => LogEventLevel.Information, | ||
| LogCategory.Warning => LogEventLevel.Warning, | ||
| LogCategory.Error => LogEventLevel.Error, | ||
| LogCategory.Fatal => LogEventLevel.Fatal, | ||
| _ => LogEventLevel.Information | ||
| }; | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
source/Octopus.Tentacle.Contracts/PowerShellStartupDetectionTemplateValues.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| namespace Octopus.Tentacle.Contracts | ||
| { | ||
| public static class PowerShellStartupDetectionTemplateValues | ||
| { | ||
| /// <summary> | ||
| /// Tentacle will use this to prevent the script from running in some cases. | ||
| /// If this is used, it MUST be above anything else run in the PowerShell script. | ||
| /// Otherwise, Tentacle may falsely assume the script has not started. | ||
| /// </summary> | ||
| public const string PowershellStartupDetectionCommentMustBeAtTheStartOfTheScript = "# TENTACLE-POWERSHELL-STARTUP-DETECTION-AND-GUARD-MUST-BE-AT-THE-START-OF-THE-SCRIPT"; | ||
| } | ||
| } |
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
121 changes: 121 additions & 0 deletions
121
...ce/Octopus.Tentacle.Core/Services/Scripts/PowerShellStartup/PowerShellStartupDetection.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| using System; | ||
| using System.IO; | ||
| using Octopus.Tentacle.Contracts; | ||
| using Octopus.Tentacle.Core.Util; | ||
|
|
||
| namespace Octopus.Tentacle.Core.Services.Scripts.PowerShellStartup | ||
| { | ||
| /// <summary> | ||
| /// Provides opt-in detection of PowerShell processes that start but never execute the script body. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// When <c>powershell.exe</c> is invoked to run a script it can occasionally start the OS process | ||
| /// but silently stall before executing any script content. This was seen happening because | ||
| /// CrowdStrike prevented the script body from running. | ||
| /// | ||
| /// When this happens, we get no output from the script AND the script is un-killable. | ||
| /// | ||
| /// To work-around this, powershell scripts can place: # TENTACLE-POWERSHELL-STARTUP-DETECTION | ||
| /// at the start. This will result in Tentacle being able to detect if the script has started | ||
| /// or not. If it has not started, Tentacle will prevent the script from ever progressing and | ||
| /// report the script as failed to start. | ||
| /// </para> | ||
| /// <para> | ||
| /// <b>How it works</b><br/> | ||
| /// Scripts opt in by including the marker comment <c># TENTACLE-POWERSHELL-STARTUP-DETECTION</c> | ||
| /// at the start of the script body, before any work is done in the script. | ||
| /// When Tentacle bootstraps the script via | ||
|
LukeButters marked this conversation as resolved.
|
||
| /// <see cref="InjectDetectionCode"/>, the marker is replaced with generated PowerShell that: | ||
| /// <list type="number"> | ||
| /// <item>Attempts to exclusively create a <c>.octopus_powershell_started</c> sentinel file. | ||
| /// If the file already exists (because <see cref="PowerShellStartupMonitor"/> beat it to | ||
| /// the punch after the timeout), the script exits with code <c>-47</c>.</item> | ||
| /// <item>Checks that the <c>.octopus_powershell_should_run</c> file written by Tentacle before | ||
| /// launch is still present. If it has been deleted by cleanup workspace or by the monitor, | ||
| /// the script exits with code <c>-47</c>.</item> | ||
| /// </list> | ||
| /// When we run the script we also run <see cref="PowerShellStartupMonitor"/> which waits for the timeout window | ||
| /// (default 5 minutes, overridable via <c>TentaclePowerShellStartupTimeout</c>). It will then attempt | ||
| /// to exclusively create the <c>.octopus_powershell_started</c>. | ||
| /// - If it can make the file, the running script is cancelled, although it likely will not cancel. Tentacle | ||
| /// returns exit code <c>-47</c>, without waiting for the script to finish. | ||
| /// - If it can't make the file, then the script started and Tentacle simply waits for the script to complete. | ||
| /// </para> | ||
| /// <para> | ||
| /// <b>Design notes</b><br/> | ||
| /// Detection is entirely opt-in; scripts without the marker are unaffected. | ||
| /// </para> | ||
| /// </remarks> | ||
| public static class PowerShellStartupDetection | ||
| { | ||
| public const string StartedFileName = ".octopus_powershell_started"; | ||
| public const string ShouldRunFileName = ".octopus_powershell_should_run"; | ||
|
|
||
| public static TimeSpan PowerShellStartupTimeout | ||
| { | ||
| get | ||
| { | ||
| var raw = Environment.GetEnvironmentVariable(EnvironmentVariables.TentaclePowerShellStartupTimeout); | ||
|
LukeButters marked this conversation as resolved.
|
||
| return TimeSpan.TryParse(raw, out var value) ? value : TimeSpan.FromMinutes(13); | ||
| } | ||
| } | ||
|
|
||
| public static string GetStartedFilePath(string workingDirectory) | ||
| { | ||
| return Path.Combine(workingDirectory, StartedFileName); | ||
|
LukeButters marked this conversation as resolved.
|
||
| } | ||
|
|
||
| public static string GetShouldRunFilePath(string workingDirectory) | ||
| { | ||
| return Path.Combine(workingDirectory, ShouldRunFileName); | ||
| } | ||
|
|
||
| public static string GenerateDetectionCode() | ||
| { | ||
| return $@" | ||
| # PowerShell startup detection code (auto-generated by Octopus Tentacle) | ||
| & {{ | ||
| $startedFile = '{StartedFileName}' | ||
| $shouldRunFile = '{ShouldRunFileName}' | ||
|
|
||
| try {{ | ||
| # Try to create the started file exclusively | ||
| $fileStream = [System.IO.File]::Open($startedFile, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) | ||
| $fileStream.Close() | ||
| $fileStream.Dispose() | ||
| }} catch {{ | ||
| # If we couldn't create the file, it means either: | ||
| # 1. Another PowerShell instance already created it (race condition) | ||
| # 2. Tentacle already created it (meaning we never started) | ||
| # In either case, we should exit | ||
| write-output ""PowerShell startup detection: Started file already exists, exiting"" | ||
| exit -47 | ||
| }} | ||
|
|
||
| # Check if the should-run file exists | ||
| if (-not (Test-Path $shouldRunFile)) {{ | ||
| write-output ""PowerShell startup detection: Should-run file does not exist, exiting"" | ||
| exit -47 | ||
| }} | ||
| }} | ||
| "; | ||
| } | ||
|
|
||
| public static bool ScriptContainsPowershellStartupDetectionComment(string scriptBody) | ||
| { | ||
| return scriptBody.Contains(PowerShellStartupDetectionTemplateValues.PowershellStartupDetectionCommentMustBeAtTheStartOfTheScript); | ||
| } | ||
|
|
||
| public static (string processedScriptBody, bool shouldMonitorPowerShellStartup) InjectDetectionCode(string scriptBody) | ||
| { | ||
| if (!ScriptContainsPowershellStartupDetectionComment(scriptBody)) | ||
| { | ||
| return (scriptBody, false); | ||
| } | ||
|
|
||
| var detectionCode = GenerateDetectionCode(); | ||
| return (scriptBody.Replace(PowerShellStartupDetectionTemplateValues.PowershellStartupDetectionCommentMustBeAtTheStartOfTheScript, detectionCode), true); | ||
| } | ||
| } | ||
| } | ||
94 changes: 94 additions & 0 deletions
94
source/Octopus.Tentacle.Core/Services/Scripts/PowerShellStartup/PowerShellStartupMonitor.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| using System; | ||
| using System.IO; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Halibut.Util; | ||
| using Octopus.Tentacle.Core.Diagnostics; | ||
|
|
||
| namespace Octopus.Tentacle.Core.Services.Scripts.PowerShellStartup | ||
| { | ||
| class PowerShellStartupMonitor | ||
| { | ||
| readonly string workSpaceWorkingDirectory; | ||
| readonly TimeSpan powerShellStartupTimeout; | ||
| readonly ILog log; | ||
| readonly string taskId; | ||
|
|
||
| public PowerShellStartupMonitor(string workSpaceWorkingDirectory, TimeSpan powerShellStartupTimeout, ILog log, string taskId) | ||
| { | ||
| this.workSpaceWorkingDirectory = workSpaceWorkingDirectory; | ||
| this.powerShellStartupTimeout = powerShellStartupTimeout; | ||
| this.log = log; | ||
| this.taskId = taskId; | ||
| } | ||
|
|
||
| public Task<PowerShellStartupStatus> WaitForStartup(CancellationToken cancellationToken) | ||
| { | ||
| var shouldRunFilePath = PowerShellStartupDetection.GetShouldRunFilePath(workSpaceWorkingDirectory); | ||
|
|
||
| if (!File.Exists(shouldRunFilePath)) | ||
| { | ||
| return Task.FromResult(PowerShellStartupStatus.NotMonitored); | ||
| } | ||
|
|
||
| return Task.Run(async () => | ||
| { | ||
| try | ||
| { | ||
| await DelayWithoutException.Delay(powerShellStartupTimeout, cancellationToken); | ||
|
|
||
| if (cancellationToken.IsCancellationRequested) | ||
| { | ||
| return PowerShellStartupStatus.NotMonitored; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| var startedFilePath = PowerShellStartupDetection.GetStartedFilePath(workSpaceWorkingDirectory); | ||
| // If we can make the file, then the script has not started. | ||
| using var fileStream = File.Open(startedFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); | ||
| log.Warn($"PowerShell startup detection: PowerShell did not start within {powerShellStartupTimeout.TotalMinutes} minutes for task {taskId}"); | ||
| DeleteShouldRunFileToEnsureThePowerShellCanNeverStart(); | ||
| return PowerShellStartupStatus.NeverStarted; | ||
| } | ||
| catch (IOException) | ||
| { | ||
| log.Info($"PowerShell startup detection: PowerShell started late (after {powerShellStartupTimeout.TotalMinutes} minutes) for task {taskId}"); | ||
| return PowerShellStartupStatus.Started; | ||
| } | ||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| return PowerShellStartupStatus.NotMonitored; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| log.Warn(ex, $"Error in PowerShell startup monitoring for task {taskId}"); | ||
| return PowerShellStartupStatus.NotMonitored; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Deletes the should-run file so that if PowerShell does eventually start, the startup guard | ||
| /// will detect its absence and exit immediately. | ||
| /// | ||
| /// Without this, a race exists: if the workspace is cleaned up while PowerShell is blocked, | ||
| /// the started sentinel file disappears. The guard would then be able to create it successfully | ||
| /// and incorrectly conclude that it is safe to proceed. Deleting the should-run file closes | ||
| /// that gap — the guard always checks for it and exits with code -47 if it is missing. | ||
| /// </summary> | ||
| public void DeleteShouldRunFileToEnsureThePowerShellCanNeverStart() | ||
| { | ||
| try | ||
| { | ||
| var shouldRunFilePath = PowerShellStartupDetection.GetShouldRunFilePath(workSpaceWorkingDirectory); | ||
| File.Delete(shouldRunFilePath); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| log.Warn(ex, $"Failed to delete should-run file for task {taskId}"); | ||
| } | ||
| } | ||
| } | ||
| } |
9 changes: 9 additions & 0 deletions
9
source/Octopus.Tentacle.Core/Services/Scripts/PowerShellStartup/PowerShellStartupStatus.cs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| namespace Octopus.Tentacle.Core.Services.Scripts.PowerShellStartup | ||
| { | ||
| public enum PowerShellStartupStatus | ||
| { | ||
| NotMonitored, | ||
| Started, | ||
| NeverStarted | ||
| } | ||
| } |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this because it deviates from what we expect or can we literally not kill the powershell process?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tentacle is unable to kill the script, with whatever the standard kill command dotnet uses.
We have taken dumps and seen crowdstrike is in the dump, I never saw the dump myself though.
I suspect the issue is something like powershell calls something that enters the kernel which hangs. Since it is in the kernel it can never be killed.