Skip to content

Commit b33b086

Browse files
[JENKINS-75563] Implement killing windows processes when using container step (#1724)
Co-authored-by: Jesse Glick <jglick@cloudbees.com>
1 parent e9dc9bb commit b33b086

File tree

6 files changed

+358
-17
lines changed

6 files changed

+358
-17
lines changed

src/main/java/org/csanchez/jenkins/plugins/kubernetes/pipeline/ContainerExecDecorator.java

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static org.csanchez.jenkins.plugins.kubernetes.pipeline.Constants.EXIT;
2020

21+
import edu.umd.cs.findbugs.annotations.NonNull;
2122
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
2223
import hudson.AbortException;
2324
import hudson.EnvVars;
@@ -27,12 +28,15 @@
2728
import hudson.Proc;
2829
import hudson.model.Computer;
2930
import hudson.model.Node;
31+
import hudson.remoting.VirtualChannel;
32+
import hudson.slaves.WorkspaceList;
3033
import io.fabric8.kubernetes.client.KubernetesClient;
3134
import io.fabric8.kubernetes.client.KubernetesClientException;
3235
import io.fabric8.kubernetes.client.dsl.ExecListener;
3336
import io.fabric8.kubernetes.client.dsl.ExecWatch;
3437
import java.io.ByteArrayOutputStream;
3538
import java.io.Closeable;
39+
import java.io.FileNotFoundException;
3640
import java.io.FilterOutputStream;
3741
import java.io.IOException;
3842
import java.io.InterruptedIOException;
@@ -46,6 +50,7 @@
4650
import java.util.HashSet;
4751
import java.util.List;
4852
import java.util.Map;
53+
import java.util.Objects;
4954
import java.util.Optional;
5055
import java.util.Set;
5156
import java.util.concurrent.CountDownLatch;
@@ -55,6 +60,7 @@
5560
import java.util.logging.Level;
5661
import java.util.logging.Logger;
5762
import java.util.regex.Matcher;
63+
import org.apache.commons.io.FilenameUtils;
5864
import org.apache.commons.io.output.TeeOutputStream;
5965
import org.csanchez.jenkins.plugins.kubernetes.ContainerTemplate;
6066
import org.csanchez.jenkins.plugins.kubernetes.KubernetesSlave;
@@ -264,6 +270,8 @@ public void setNodeContext(KubernetesNodeContext nodeContext) {
264270
this.nodeContext = nodeContext;
265271
}
266272

273+
private String workspace;
274+
267275
@Override
268276
public Launcher decorate(final Launcher launcher, final Node node) {
269277

@@ -283,6 +291,7 @@ public Proc launch(ProcStarter starter) throws IOException {
283291
String containerWorkingDirFilePathStr = containerWorkingDirFilePath != null
284292
? containerWorkingDirFilePath.getRemote()
285293
: ContainerTemplate.DEFAULT_WORKING_DIR;
294+
workspace = containerWorkingDirFilePathStr;
286295
String containerWorkingDirStr = ContainerTemplate.DEFAULT_WORKING_DIR;
287296
if (slave != null && slave.getPod().isPresent() && containerName != null) {
288297
Optional<String> containerWorkingDir = PodContainerSource.lookupContainerWorkingDir(
@@ -660,23 +669,58 @@ public void onClose(int i, String s) {
660669
@Override
661670
public void kill(Map<String, String> modelEnvVars) throws IOException, InterruptedException {
662671
getListener().getLogger().println("Killing processes");
663-
664672
String cookie = modelEnvVars.get(COOKIE_VAR);
673+
int exitCode;
674+
if (this.isUnix()) {
675+
exitCode = doLaunch(
676+
true,
677+
null,
678+
null,
679+
null,
680+
null,
681+
"sh",
682+
"-c",
683+
"kill \\`grep -l '" + COOKIE_VAR + "=" + cookie
684+
+ "' /proc/*/environ | cut -d / -f 3 \\`")
685+
.join();
686+
} else {
687+
VirtualChannel channel = node.getChannel();
688+
if (channel != null && workspace != null) {
689+
FilePath tmpFolder = WorkspaceList.tempDir(new FilePath(channel, workspace));
690+
if (tmpFolder != null) {
691+
try (var mainScript = withTemporaryScript(tmpFolder, "kill-processes-with-cookie.ps1");
692+
var killProcessScript = withTemporaryScript(tmpFolder, "kill-process-by-id.ps1");
693+
var csCode = withTemporaryScript(tmpFolder, "ProcessEnvironmentReader.cs")) {
694+
exitCode = doLaunch(
695+
true,
696+
null,
697+
null,
698+
null,
699+
null,
700+
"powershell.exe",
701+
"-NoProfile",
702+
"-File",
703+
/* path to file may contain spaces so wrap in double quotes*/
704+
"\"" + mainScript.getRemote() + "\"",
705+
"-cookie",
706+
cookie,
707+
"-csFile",
708+
"\"" + csCode.getRemote() + "\"",
709+
"-killScript",
710+
"\"" + killProcessScript.getRemote() + "\"")
711+
.join();
712+
}
713+
} else {
714+
exitCode = 9099;
715+
}
665716

666-
int exitCode = doLaunch(
667-
true,
668-
null,
669-
null,
670-
null,
671-
null,
672-
// TODO Windows
673-
"sh",
674-
"-c",
675-
"kill \\`grep -l '" + COOKIE_VAR + "=" + cookie
676-
+ "' /proc/*/environ | cut -d / -f 3 \\`")
677-
.join();
678-
679-
getListener().getLogger().println("kill finished with exit code " + exitCode);
717+
} else {
718+
exitCode = 9009;
719+
}
720+
}
721+
getListener()
722+
.getLogger()
723+
.println("Attempt to gracefully kill processes finished with exit code " + exitCode);
680724
}
681725

682726
private void setupEnvironmentVariable(EnvVars vars, PrintStream out, boolean windows) throws IOException {
@@ -691,6 +735,11 @@ private void setupEnvironmentVariable(EnvVars vars, PrintStream out, boolean win
691735
}
692736
}
693737
}
738+
739+
private static TemporaryFile withTemporaryScript(FilePath workspace, String name)
740+
throws IOException, InterruptedException {
741+
return new TemporaryFile(workspace, name);
742+
}
694743
};
695744
}
696745

@@ -909,4 +958,40 @@ private static String[] fixDoubleDollar(String[] envVars) {
909958
.map(ev -> ev.replaceAll("\\$\\$", Matcher.quoteReplacement("$")))
910959
.toArray(String[]::new);
911960
}
961+
962+
private static final class TemporaryFile implements AutoCloseable {
963+
@NonNull
964+
final FilePath filePath;
965+
966+
TemporaryFile(@NonNull FilePath workspace, @NonNull String name) throws IOException, InterruptedException {
967+
var resource = ContainerExecDecorator.class.getResource("scripts/" + name);
968+
if (resource == null) {
969+
throw new FileNotFoundException("Script " + name + " not found in resources!");
970+
}
971+
var tempFile =
972+
workspace.createTempFile(FilenameUtils.getBaseName(name), "." + FilenameUtils.getExtension(name));
973+
tempFile.copyFrom(resource);
974+
this.filePath = tempFile;
975+
}
976+
977+
public String getRemote() {
978+
return filePath.getRemote();
979+
}
980+
981+
@Override
982+
public boolean equals(Object o) {
983+
if (!(o instanceof TemporaryFile that)) return false;
984+
return Objects.equals(filePath, that.filePath);
985+
}
986+
987+
@Override
988+
public int hashCode() {
989+
return Objects.hashCode(filePath);
990+
}
991+
992+
@Override
993+
public void close() throws IOException, InterruptedException {
994+
filePath.delete();
995+
}
996+
}
912997
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System;
2+
using System.Text;
3+
using System.Diagnostics;
4+
using System.ComponentModel;
5+
using System.Runtime.InteropServices;
6+
7+
public class ProcessEnvironmentReader
8+
{
9+
[StructLayout(LayoutKind.Sequential)]
10+
public struct PROCESS_BASIC_INFORMATION
11+
{
12+
public IntPtr Reserved1;
13+
public IntPtr PebBaseAddress;
14+
public IntPtr Reserved2_0;
15+
public IntPtr Reserved2_1;
16+
public IntPtr UniqueProcessId;
17+
public IntPtr Reserved3;
18+
}
19+
20+
[DllImport("ntdll.dll")]
21+
private static extern int NtQueryInformationProcess(
22+
IntPtr ProcessHandle,
23+
int ProcessInformationClass,
24+
ref PROCESS_BASIC_INFORMATION ProcessInformation,
25+
uint ProcessInformationLength,
26+
out uint ReturnLength);
27+
28+
[DllImport("kernel32.dll")]
29+
private static extern IntPtr OpenProcess(
30+
uint dwDesiredAccess,
31+
bool bInheritHandle,
32+
int dwProcessId);
33+
34+
[DllImport("kernel32.dll")]
35+
private static extern bool ReadProcessMemory(
36+
IntPtr hProcess,
37+
IntPtr lpBaseAddress,
38+
byte[] lpBuffer,
39+
int dwSize,
40+
out IntPtr lpNumberOfBytesRead);
41+
42+
[DllImport("kernel32.dll", SetLastError = true)]
43+
static extern bool CloseHandle(IntPtr hHandle);
44+
45+
private const uint PROCESS_QUERY_INFORMATION = 0x0400;
46+
private const uint PROCESS_VM_READ = 0x0010;
47+
private const int ProcessBasicInformation = 0;
48+
49+
public static string ReadEnvironmentBlock(int pid)
50+
{
51+
IntPtr hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
52+
if (hProcess == IntPtr.Zero)
53+
throw new Win32Exception(Marshal.GetLastWin32Error());
54+
55+
try
56+
{
57+
PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION();
58+
uint tmp;
59+
int status = NtQueryInformationProcess(hProcess, ProcessBasicInformation, ref pbi, (uint)Marshal.SizeOf(pbi), out tmp);
60+
if (status != 0)
61+
throw new Win32Exception("NtQueryInformationProcess failed");
62+
63+
// Offsets for Environment variables are different on 32/64 bit
64+
// The following offsets are for Windows x64 - for x86 some offsets would need adjusting!
65+
// PEB is at pbi.PebBaseAddress
66+
// In PEB, offset 0x20 (Win10 x64, might differ!) is ProcessParameters
67+
byte[] procParamsPtr = new byte[IntPtr.Size];
68+
IntPtr bytesRead;
69+
IntPtr processParametersAddr;
70+
71+
// Offset to ProcessParameters
72+
int offsetProcessParameters = 0x20;
73+
if (!ReadProcessMemory(hProcess, pbi.PebBaseAddress + offsetProcessParameters, procParamsPtr, procParamsPtr.Length, out bytesRead))
74+
throw new Win32Exception("ReadProcessMemory (ProcessParameters) failed");
75+
76+
processParametersAddr = (IntPtr)BitConverter.ToInt64(procParamsPtr, 0);
77+
78+
// Offset in RTL_USER_PROCESS_PARAMETERS for Environment = 0x80 (x64)!
79+
int offsetEnvironment = 0x80;
80+
byte[] environmentPtr = new byte[IntPtr.Size];
81+
82+
if (!ReadProcessMemory(hProcess, processParametersAddr + offsetEnvironment, environmentPtr, environmentPtr.Length, out bytesRead))
83+
throw new Win32Exception("ReadProcessMemory (Environment) failed");
84+
85+
IntPtr environmentAddr = (IntPtr)BitConverter.ToInt64(environmentPtr, 0);
86+
87+
// Read an arbitrary chunk (say, 32 KB) where env block should fit
88+
int envSize = 0x8000;
89+
byte[] envData = new byte[envSize];
90+
if (!ReadProcessMemory(hProcess, environmentAddr, envData, envData.Length, out bytesRead))
91+
throw new Win32Exception("ReadProcessMemory (Environment data) failed");
92+
93+
// Environment block is Unicode, ends with two 0 chars.
94+
string env = Encoding.Unicode.GetString(envData);
95+
int end = env.IndexOf("\0\0");
96+
97+
if (end > -1)
98+
env = env.Substring(0, end);
99+
100+
return env.Replace('\0', '\n');
101+
}
102+
finally
103+
{
104+
CloseHandle(hProcess);
105+
}
106+
}
107+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
param(
2+
[string]$processId
3+
)
4+
5+
Add-Type -TypeDefinition @"
6+
using System;
7+
using System.Runtime.InteropServices;
8+
using System.Diagnostics;
9+
10+
public class ProcessControl {
11+
[DllImport("kernel32.dll")]
12+
public static extern bool AttachConsole(int dwProcessId);
13+
[DllImport("kernel32.dll")]
14+
public static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate HandlerRoutine, bool Add);
15+
public delegate bool ConsoleCtrlDelegate(int CtrlType);
16+
[DllImport("kernel32.dll")]
17+
public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
18+
[DllImport("kernel32.dll")]
19+
public static extern bool FreeConsole();
20+
}
21+
"@
22+
23+
try {
24+
$process = Get-Process -Id $processId -ErrorAction SilentlyContinue
25+
26+
if ($process) {
27+
$gracefulShutdown = $false
28+
29+
# Method 1: Try Ctrl+C via console API (most reliable in containers)
30+
[ProcessControl]::FreeConsole() | Out-Null
31+
if ([ProcessControl]::AttachConsole($processId)) {
32+
# Don't disable our handler
33+
[ProcessControl]::GenerateConsoleCtrlEvent(0, 0) | Out-Null
34+
35+
# Wait for process to handle signal
36+
if ($process.WaitForExit(5000)) {
37+
$gracefulShutdown = $true
38+
}
39+
[ProcessControl]::FreeConsole() | Out-Null
40+
}
41+
42+
# Method 2: Try Stop-Process with -Force:$false (more graceful than plain Stop-Process)
43+
if (-not $gracefulShutdown) {
44+
try {
45+
Stop-Process -Id $processId -Force:$false -ErrorAction SilentlyContinue
46+
if ($process.WaitForExit(5000)) {
47+
$gracefulShutdown = $true
48+
}
49+
} catch {
50+
# Ignore, this is best effort
51+
}
52+
}
53+
54+
# Method 3: Last resort - force kill
55+
if (-not $gracefulShutdown) {
56+
try {
57+
$process.Kill()
58+
$process.WaitForExit(2000)
59+
Write-Host "Process $processId forcefully terminated"
60+
} catch {
61+
return 1 # Failed to stop the process
62+
}
63+
}
64+
}
65+
return 0
66+
} catch {
67+
return 1 #Failed to even get the process
68+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
param(
2+
[string]$cookie,
3+
[string]$csFile,
4+
[string]$killScript
5+
)
6+
7+
Add-Type -Path $csFile
8+
$returnCode = 0
9+
$matchedProcessIds = @()
10+
Get-Process | ForEach-Object {
11+
$id = $_.Id
12+
try {
13+
$envBlock = [ProcessEnvironmentReader]::ReadEnvironmentBlock($id)
14+
if (($envBlock.Contains("JENKINS_SERVER_COOKIE=$($cookie)")) -and ($id -ne $PID)) {
15+
$matchedProcessIds += $id
16+
}
17+
} catch {
18+
# Do nothing this is best effort and we expect not to be able to read all processes
19+
}
20+
}
21+
foreach ($processId in $matchedProcessIds) {
22+
& $killScript -processId $processId
23+
$returnCode = $returnCode + $LASTEXITCODE
24+
}
25+
return $returnCode

0 commit comments

Comments
 (0)