Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
108ae49
i262: redirect stdout and stderr of started tools to a file to empty …
tt-tiwe Apr 1, 2026
4b1b0f1
i262: improve tests with explicit error messages and ignore path shor…
tt-tiwe Apr 2, 2026
c45963d
i262: stabilize test
tt-tiwe Apr 2, 2026
7d3cea4
i262: try higher timeout
tt-tiwe Apr 2, 2026
124c107
i262: mock process killing
tt-tiwe Apr 7, 2026
d69be61
i262: remove commented code
tt-tiwe Apr 7, 2026
afae7d0
i262: introduce new private data class ExecutionDirectories
tt-tiwe Apr 13, 2026
30131b1
i262: merge the checks for different important directories
tt-tiwe Apr 13, 2026
557afc6
i262: incorporate fallback for tool name sanitizing into the method a…
tt-tiwe Apr 13, 2026
df18e94
i262: add more logging to Test tasks to avoid shutdown of Pipeline step
tt-tiwe Apr 14, 2026
fd15db9
i262: also log, when test gets started
tt-tiwe Apr 14, 2026
475da02
i262: activate standard stream outputs
tt-tiwe Apr 14, 2026
81f6bb4
i262: stabilize tests
tt-tiwe Apr 14, 2026
94718d2
i262: remove stdout from gradle logs
tt-tiwe Apr 14, 2026
c44ee9f
i262: add test that checks that stdout and stderr are written to files
tt-tiwe Apr 14, 2026
466d632
i262: fix test: echo or readFile seem to operate differently on windo…
tt-tiwe Apr 15, 2026
14de54b
i262: fix broken markdown link check by allowing 403 as an http retur…
tt-tiwe Apr 15, 2026
0b09bb4
i262: improve test to actually test the content of the files.
tt-tiwe Apr 15, 2026
c3c2d8b
i262: improve test by using archiveArtifacts feature
tt-tiwe Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/md-link-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: gaurav-nelson/github-action-markdown-link-check@1.0.15

with:
config-file: 'mlc_config.json'
19 changes: 19 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,25 @@ test {
dependsOn 'integrationTest'
}

tasks.withType(Test).configureEach {
testLogging {
events "started", "passed", "skipped", "failed"
exceptionFormat "full"
showExceptions true
showCauses true
showStackTraces true
}

// Print a short progress line for each test class/suite
afterSuite { desc, result ->
if (!desc.parent) {
logger.lifecycle("Test summary [${name}]: ${result.resultType} " +
"(total: ${result.testCount}, passed: ${result.successfulTestCount}, " +
"failed: ${result.failedTestCount}, skipped: ${result.skippedTestCount})")
}
}
}

tasks.register('unitTest', Test) {
filter {
excludeTestsMatching '*ContainerTest'
Expand Down
3 changes: 3 additions & 0 deletions mlc_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"aliveStatusCodes": [403, 200]
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2021-2024 tracetronic GmbH
* Copyright (c) 2021-2026 tracetronic GmbH
*
* SPDX-License-Identifier: BSD-3-Clause
*/
Expand All @@ -15,11 +15,7 @@ import de.tracetronic.jenkins.plugins.ecutestexecution.util.EnvVarUtil
import de.tracetronic.jenkins.plugins.ecutestexecution.util.PathUtil
import de.tracetronic.jenkins.plugins.ecutestexecution.util.ProcessUtil
import de.tracetronic.jenkins.plugins.ecutestexecution.util.ValidationUtil
import hudson.AbortException
import hudson.EnvVars
import hudson.Extension
import hudson.FilePath
import hudson.Launcher
import hudson.*
import hudson.model.Result
import hudson.model.Run
import hudson.model.TaskListener
Expand All @@ -29,20 +25,19 @@ import hudson.util.FormValidation
import hudson.util.ListBoxModel
import jenkins.security.MasterToSlaveCallable
import org.apache.commons.lang.StringUtils
import org.jenkinsci.plugins.workflow.steps.Step
import org.jenkinsci.plugins.workflow.steps.StepContext
import org.jenkinsci.plugins.workflow.steps.StepDescriptor
import org.jenkinsci.plugins.workflow.steps.StepExecution
import org.jenkinsci.plugins.workflow.steps.SynchronousNonBlockingStepExecution
import org.apache.commons.lang3.SystemUtils
import org.jenkinsci.plugins.workflow.steps.*
import org.kohsuke.stapler.DataBoundConstructor
import org.kohsuke.stapler.DataBoundSetter
import org.kohsuke.stapler.QueryParameter

import java.nio.file.Path
import java.util.concurrent.TimeoutException

class StartToolStep extends Step {

private static final int DEFAULT_TIMEOUT = 60
static final int MAX_WINDOWS_FILE_PATH_LENGTH = 260

private final String toolName
private String workspaceDir
Expand Down Expand Up @@ -134,44 +129,47 @@ class StartToolStep extends Step {
FilePath workspace = context.get(FilePath.class)
TaskListener listener = context.get(TaskListener.class)

String expWorkspaceDir = EnvVarUtil.expandVar(step.workspaceDir, envVars, workspace.getRemote())
String expSettingsDir = EnvVarUtil.expandVar(step.settingsDir, envVars, workspace.getRemote())
String agentWorkspace = workspace.getRemote()

String expWorkspaceDir = EnvVarUtil.expandVar(step.workspaceDir, envVars, agentWorkspace)
String expSettingsDir = EnvVarUtil.expandVar(step.settingsDir, envVars, agentWorkspace)

expWorkspaceDir = PathUtil.makeAbsoluteInPipelineHome(expWorkspaceDir, context)
expSettingsDir = PathUtil.makeAbsoluteInPipelineHome(expSettingsDir, context)

checkWorkspace(expWorkspaceDir, expSettingsDir)
ExecutionDirectories directories = new ExecutionDirectories(expWorkspaceDir, expSettingsDir,
agentWorkspace)

StartToolResult result = context.get(Launcher.class).getChannel().call(
new ExecutionCallable(ETInstallation.getToolInstallationForMaster(context, step.toolName),
expWorkspaceDir, expSettingsDir, step.timeout, step.keepInstance,
step.stopUndefinedTools, envVars, listener))
directories, step.timeout, step.keepInstance, step.stopUndefinedTools, envVars,
listener))
listener.logger.println(result.toString())
listener.logger.flush()
return result

} catch (Exception e) {
context.get(Run.class).setResult(Result.FAILURE)
// there is no friendly option to stop the step execution without an exception
throw new AbortException(e.getMessage())
Exception exception = new AbortException(e.getMessage())
exception.addSuppressed(e)
throw exception
}
}
}

private void checkWorkspace(String workspaceDir, String settingsDir)
throws IOException, InterruptedException, IllegalArgumentException {
FilePath workspacePath = new FilePath(context.get(Launcher.class).getChannel(), workspaceDir)
if (!workspacePath.exists()) {
throw new AbortException(
"ecu.test workspace directory at ${workspacePath.getRemote()} does not exist! " +
"Please ensure that the path is correctly set and it refers to the desired directory.")
}
private static final class ExecutionDirectories implements Serializable {

FilePath settingsPath = new FilePath(context.get(Launcher.class).getChannel(), settingsDir)
if (!settingsPath.exists()) {
settingsPath.mkdirs()
def listener = context.get(TaskListener.class)
listener.logger.println("ecu.test settings directory created at ${settingsPath.getRemote()}.")
}
private static final long serialVersionUID = 1L

final String ecuTestWorkspaceDir
final String settingsDir
final String agentWorkspace

ExecutionDirectories(String ecuTestWorkspaceDir, String settingsDir, String agentWorkspace) {
this.ecuTestWorkspaceDir = ecuTestWorkspaceDir
this.settingsDir = settingsDir
this.agentWorkspace = agentWorkspace
}
}

Expand All @@ -180,20 +178,19 @@ class StartToolStep extends Step {
private static final long serialVersionUID = 1L

private final ETInstallation installation
private final String workspaceDir
private final String settingsDir
private final ExecutionDirectories executionDirectories
private final int timeout
private final boolean keepInstance
private final boolean stopUndefinedTools
private final EnvVars envVars
private final TaskListener listener

ExecutionCallable(ETInstallation installation, String workspaceDir, String settingsDir, int timeout,
boolean keepInstance, boolean stopUndefinedTools, EnvVars envVars, TaskListener listener) {
ExecutionCallable(ETInstallation installation, ExecutionDirectories executionDirectories, int timeout,
boolean keepInstance, boolean stopUndefinedTools, EnvVars envVars,
TaskListener listener) {
super()
this.installation = installation
this.workspaceDir = workspaceDir
this.settingsDir = settingsDir
this.executionDirectories = executionDirectories
this.timeout = timeout
this.keepInstance = keepInstance
this.stopUndefinedTools = stopUndefinedTools
Expand All @@ -209,9 +206,9 @@ class StartToolStep extends Step {
listener.logger.println("Re-using running instance ${toolName}...")
if (!checkToolConnection()) {
throw new AbortException(
"Timeout of ${this.timeout} seconds exceeded for re-using tracetronic tools! " +
"Please ensure that tracetronic tools are not already stopped or " +
"blocked by another process.")
"Timeout of ${this.timeout} seconds exceeded for re-using tracetronic tools! " +
"Please ensure that tracetronic tools are not already stopped or " +
"blocked by another process.")
}
} else {
if (stopUndefinedTools) {
Expand All @@ -229,7 +226,8 @@ class StartToolStep extends Step {
startTool(toolName)
listener.logger.println("${toolName} started successfully.")
}
return new StartToolResult(installation.getName(), installation.exeFileOnNode.absolutePath.toString(), workspaceDir, settingsDir)
return new StartToolResult(installation.getName(), installation.exeFileOnNode.absolutePath.toString(),
executionDirectories.ecuTestWorkspaceDir, executionDirectories.settingsDir)

} catch (Exception e) {
throw new AbortException(e.getMessage())
Expand All @@ -242,21 +240,33 @@ class StartToolStep extends Step {
* @throws AbortException
*/
private void startTool(String toolName) throws IllegalStateException {
checkDirectories()

ArgumentListBuilder args = new ArgumentListBuilder()
args.add(installation.exeFileOnNode.absolutePath)
args.add('--workspaceDir', workspaceDir)
args.add('-s', settingsDir)
args.add('--workspaceDir', executionDirectories.ecuTestWorkspaceDir)
args.add('-s', executionDirectories.settingsDir)
args.add('--startupAutomated=True')
listener.logger.println(args.toString())

Process process = new ProcessBuilder().command(args.toCommandArray()).start()
File stdoutLogFile = createLogFile(this.executionDirectories.agentWorkspace, toolName, '_tool_out.log')
File stderrLogFile = createLogFile(this.executionDirectories.agentWorkspace, toolName, '_tool_err.log')

boolean isConnected = checkToolConnection()
listener.logger.println("ecu.test stdout: ${stdoutLogFile.absolutePath}")
listener.logger.println("ecu.test stderr: ${stderrLogFile.absolutePath}")

Process process = new ProcessBuilder()
.command(args.toCommandArray())
.redirectError(stderrLogFile)
.redirectOutput(stdoutLogFile)
.start()

boolean isConnected = checkToolConnection()
int exitCode = 0
if (!isConnected) {
try {
try {
exitCode = process.exitValue()
} catch (IllegalThreadStateException ignore){
} catch (IllegalThreadStateException ignore) {
process.destroy()
throw new AbortException(
"Timeout of ${this.timeout} seconds exceeded for connecting to ${toolName}! " +
Expand All @@ -276,6 +286,34 @@ class StartToolStep extends Step {
"within the timeout of ${timeout} seconds.")
}

private void checkDirectories()
throws IOException, InterruptedException, IllegalArgumentException {
File directory = new File(executionDirectories.agentWorkspace)
if (!directory.exists()) {
if (!directory.mkdirs() && !directory.exists()) {
throw new AbortException("Could not create agent workspace directory at ${directory.absolutePath}.")
}
listener.logger.println("Created agent workspace directory: ${directory.absolutePath}")
} else if (!directory.isDirectory()) {
throw new AbortException("Path ${directory.absolutePath} exists but is not a directory.")
}

File workspacePath = new File(executionDirectories.ecuTestWorkspaceDir)
if (!workspacePath.exists()) {
throw new AbortException(
"ecu.test workspace directory at ${workspacePath.absolutePath} does not exist! " +
"Please ensure that the path is correctly set and it refers to the desired directory.")
}

File settingsPath = new File(executionDirectories.settingsDir)
if (!settingsPath.exists()) {
if (!settingsPath.mkdirs() && !settingsPath.exists()) {
throw new AbortException("Could not create ecu.test settings directory at ${settingsPath.absolutePath}.")
}
listener.logger.println("ecu.test settings directory created at ${settingsPath.absolutePath}.")
}
}

/**
* Checks whether the REST API of the tool is available.
* @return true if REST API is available, false if not
Expand All @@ -290,6 +328,81 @@ class StartToolStep extends Step {
}
}

/**
* Creates a File with a sanitized filename based on the toolName.
* @param directory The directory to create the file in
* @param rawToolName The toolName provided by the user
* @param suffix The suffix to add to the sanitized toolName
* @return {@link File} with sanitized filename
*/
static File createLogFile(String directory, String rawToolName, String suffix) {
String safeToolName = sanitizeForFilename(rawToolName)

File file = Path.of(directory).resolve("${safeToolName}${suffix}").toFile()

if (SystemUtils.IS_OS_WINDOWS) {
String absolutePath = file.absolutePath
if (absolutePath.length() > MAX_WINDOWS_FILE_PATH_LENGTH) {
int charactersToShorten = absolutePath.length() - MAX_WINDOWS_FILE_PATH_LENGTH
String truncatedToolName = safeToolName.substring(0, Math.max(0, safeToolName.length() - charactersToShorten))
file = Path.of(directory).resolve("${truncatedToolName}${suffix}").toFile()
}
}

return file
}

/**
* Sanitizes a raw tool name to be used as a filename.
* @param rawName The raw tool name
* @return The name sanitized to be used as filename
*/
static String sanitizeForFilename(String rawName) {
String name = StringUtils.trimToEmpty(rawName)
// Replace characters that are invalid in Windows file names and can be interpreted as path syntax.
name = name.replaceAll($/[<>:"/\\|?*]/$, '_')
// Replace ASCII control characters (including DEL) to avoid invalid or non-printable file names.
name = name.replaceAll(/[\x00-\x1F\x7F]/, '_')
// Normalize whitespace to underscores to keep names readable and shell-friendly.
name = name.replaceAll(/\s+/, '_')
// Neutralize repeated dots so traversal-like inputs cannot survive as meaningful segments.
name = name.replaceAll(/\.\.+/, '_')
// Trim trailing dot/space because Windows does not allow them at the end of a file name.
name = name.replaceAll(/[. ]+$/, '')
// Collapse duplicate underscores introduced by previous replacements.
name = name.replaceAll(/_+/, '_')
name = StringUtils.trimToEmpty(name)

// If no letter (Unicode characters are allowed) or digit is left, use fallback
if (!containsLetterOrDigit(name)) {
Comment thread
tt-tiwe marked this conversation as resolved.
return 'tool'
}

// Sanitize unallowed windows filenames
if (name ==~ /(?i)^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/) {
name = "_${name}"
}

return name
}

private static boolean containsLetterOrDigit(String value) {
if (StringUtils.isEmpty(value)) {
return false
}

int index = 0
while (index < value.length()) {
int codePoint = value.codePointAt(index)
if (Character.isLetterOrDigit(codePoint)) {
return true
}
index += Character.charCount(codePoint)
}

return false
}

@Extension
static final class DescriptorImpl extends StepDescriptor {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@ final class ProcessUtil implements Serializable {
} else {
args.add('sh')
args.add('-c')
args.add("kill")
args.add("\$(ps -eo pid,cmd | awk '/[${taskName}]/ {print \$1}')")
args.add("kill \$(ps -eo pid,cmd | awk '/${taskName}/ {print \$1}')")
}

Process process = new ProcessBuilder().command(args.toCommandArray()).start()
Process process = new ProcessBuilder().command(args.toCommandArray()).inheritIO().start()
if (timeout <= 0) {
return process.waitFor() == 0
} else {
Expand All @@ -48,8 +47,8 @@ final class ProcessUtil implements Serializable {
}

/**
* Kills all processes in the given list, and all their descendant processes, by calling {@link #killProcess
* killProcess} method multiple times.
* Kills all processes in the given list, and all their descendant processes, by calling {@link #killProcess}
* method multiple times.
* @param taskName the task name of the process
* @param timeout the maximum time to wait for process termination, 0 disabled timeout
* @return {@code true} if all processes have exited in timeout, {@code false} otherwise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ class CheckPackageStepIT extends IntegrationTestBase {
getCheckExecutionStatus(_) >>> [null, finishedStatus ]
getCheckResult(_) >> checkReport
}
and:
GroovyMock(ProcessUtil, global: true)
ProcessUtil.killProcesses(_, _) >> true
ProcessUtil.killTTProcesses(_) >> true

and:
WorkflowJob job = jenkins.createProject(WorkflowJob.class, 'pipeline')
Expand Down
Loading
Loading