Skip to content

Commit b643dc1

Browse files
authored
Feature: Retry with exponential backoff when Octopus Deploy is unavailable (#186)
* Feature: Retry with exponential backoff when Octopus Deploy is unavailable (#186) When the Octopus Deploy server is unavailable (connection refused, 502/503/504, DNS failure, timeouts), the TeamCity plugin now retries CLI execution with exponential backoff instead of failing the build immediately. - OctopusErrorClassifier: distinguishes transient errors (retry) from permanent errors (fail fast) by parsing CLI stdout/stderr patterns for both .NET and Go CLIs - OctopusCliRetryExecutor: retry loop with exponential backoff (5s initial, 2m max per attempt, 15m total timeout), configurable via env vars (OCTOPUS_RETRY_ENABLED, OCTOPUS_RETRY_TIMEOUT, OCTOPUS_RETRY_INITIAL_DELAY, OCTOPUS_RETRY_MAX_DELAY) - CaptureWriter: Output.Writer decorator for capturing CLI output for error classification in the legacy build process path - Both legacy (OctopusBuildProcess) and new CLI (CLIBuildProcess) paths use the shared retry executor * fix: resolve symlink mismatch in Zip Slip check on macOS The extractTarGzResource() Zip Slip security check compared destDir.resolve(entry).normalize() against destDir.toRealPath(). On macOS, /var is a symlink to /private/var, so toRealPath() resolved to /private/var/folders/... while the entry path kept /var/folders/..., causing startsWith() to fail and throw a false-positive Zip Slip error. Fix: resolve destDir to its real path once upfront, then use the resolved path consistently for both entry resolution and the security check. * docs: add code comments explaining retry workflow for debugging Adds Javadoc and inline comments to the retry infrastructure classes (OctopusCliRetryExecutor, OctopusErrorClassifier, CaptureWriter) and both build process integration points (OctopusBuildProcess, CLIBuildProcess) to help engineers unfamiliar with the codebase understand the retry flow.
1 parent 92c75d7 commit b643dc1

9 files changed

Lines changed: 941 additions & 95 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2000-2012 Octopus Deploy Pty. Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package octopus.teamcity.agent;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
22+
/**
23+
* Decorates an {@link Output.Writer} to capture all written lines for later inspection, while still
24+
* forwarding each line to the delegate (typically the TeamCity build log).
25+
*
26+
* <p>Used by the legacy CLI path ({@link OctopusBuildProcess}) to collect stdout/stderr so that the
27+
* combined output can be passed to {@link OctopusErrorClassifier} for transient-vs-permanent
28+
* failure classification after the CLI process exits.
29+
*
30+
* <p>The Go CLI path ({@link octopus.teamcity.agent.cli.CLIBuildProcess}) does not use this class
31+
* because it merges stdout/stderr via {@code ProcessBuilder.redirectErrorStream(true)} and captures
32+
* output inline with a StringBuilder.
33+
*
34+
* <p>Not thread-safe; callers must ensure writes are complete before calling {@link
35+
* #getCapturedOutput()}.
36+
*/
37+
public class CaptureWriter implements Output.Writer {
38+
private final Output.Writer delegate;
39+
private final List<String> lines = new ArrayList<String>();
40+
41+
public CaptureWriter(Output.Writer delegate) {
42+
this.delegate = delegate;
43+
}
44+
45+
@Override
46+
public void write(String text) {
47+
delegate.write(text);
48+
lines.add(text);
49+
}
50+
51+
public String getCapturedOutput() {
52+
StringBuilder sb = new StringBuilder();
53+
for (String line : lines) {
54+
sb.append(line).append('\n');
55+
}
56+
return sb.toString();
57+
}
58+
}

octopus-agent/src/main/java/octopus/teamcity/agent/EmbeddedResourceExtractor.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ public void extractCliTo(String destinationPath, String octopusVersion, String o
140140

141141
public void extractTarGzResource(String resourcePath, Path destDir) throws IOException {
142142
Files.createDirectories(destDir);
143+
Path realDestDir = destDir.toRealPath();
143144

144145
try (InputStream fi = getClass().getResourceAsStream(resourcePath)) {
145146
if (fi == null) {
@@ -153,9 +154,9 @@ public void extractTarGzResource(String resourcePath, Path destDir) throws IOExc
153154
TarArchiveEntry entry;
154155
byte[] buffer = new byte[4096];
155156
while ((entry = tarIn.getNextTarEntry()) != null) {
156-
Path entryPath = destDir.resolve(entry.getName()).normalize();
157+
Path entryPath = realDestDir.resolve(entry.getName()).normalize();
157158

158-
if (!entryPath.startsWith(destDir.toRealPath())) {
159+
if (!entryPath.startsWith(realDestDir)) {
159160
throw new IOException(
160161
"Zip Slip detected! Entry is outside of the target directory: " + entry.getName());
161162
}

octopus-agent/src/main/java/octopus/teamcity/agent/OctopusBuildProcess.java

Lines changed: 76 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@
3939
public abstract class OctopusBuildProcess implements BuildProcess {
4040
private final AgentRunningBuild runningBuild;
4141
private final BuildRunnerContext context;
42-
private Process process;
42+
private volatile Process process;
4343
private File extractedTo;
44-
private Output.ReaderThread standardError;
45-
private Output.ReaderThread standardOutput;
4644
private boolean isFinished;
4745
private final BuildProgressLogger logger;
46+
private OctopusCliRetryExecutor.CliResult cliResult;
47+
private OctopusCliRetryExecutor retryExecutor;
4848

4949
protected OctopusBuildProcess(
5050
@NotNull AgentRunningBuild runningBuild, @NotNull BuildRunnerContext context) {
@@ -58,6 +58,9 @@ protected OctopusBuildProcess(
5858
public void start() throws RunBuildException {
5959
extractOctoExe();
6060

61+
retryExecutor =
62+
new OctopusCliRetryExecutor(logger, context.getBuildParameters().getEnvironmentVariables());
63+
6164
OctopusCommandBuilder arguments = createCommand();
6265
startOcto(arguments);
6366
}
@@ -118,48 +121,83 @@ private void startOcto(final OctopusCommandBuilder command) throws RunBuildExcep
118121
logger.progressMessage(getLogMessage());
119122

120123
try {
121-
final String octopusVersion = getSelectedOctopusVersion();
122-
123-
final ArrayList<String> arguments = new ArrayList<>();
124-
if (useExe) {
125-
arguments.add(new File(extractedTo, octopusVersion + "/octo.exe").getAbsolutePath());
126-
} else if (useOcto) {
127-
arguments.add("octo");
128-
} else {
129-
arguments.add("dotnet");
130-
String dllPath = new File(extractedTo, octopusVersion + "/Core/octo.dll").getAbsolutePath();
131-
arguments.add(dllPath);
132-
}
133-
134-
arguments.addAll(Arrays.asList(realCommand));
135-
136-
final String extensionVersion = getClass().getPackage().getImplementationVersion();
137-
138-
final ProcessBuilder builder = new ProcessBuilder();
139-
140-
Map<String, String> programEnvironmentVariables =
141-
context.getBuildParameters().getEnvironmentVariables();
142-
Map<String, String> environment = builder.environment();
143-
environment.put("OCTOEXTENSION", extensionVersion);
144-
environment.putAll(programEnvironmentVariables);
124+
final Boolean finalUseExe = useExe;
125+
final Boolean finalUseOcto = useOcto;
145126

146-
process = builder.command(arguments).directory(context.getWorkingDirectory()).start();
127+
// Execute the CLI process, wrapped in retry logic. On transient failures (connection
128+
// refused, 502/503/504, DNS errors, timeouts), the retry executor will re-launch the
129+
// entire CLI process with exponential backoff. The CaptureWriter on stdout/stderr
130+
// collects output so the error classifier can inspect it after each attempt.
131+
cliResult =
132+
retryExecutor.executeWithRetry(
133+
() -> executeOctoProcess(realCommand, finalUseExe, finalUseOcto));
147134

148-
final LoggingProcessListener listener = new LoggingProcessListener(logger);
135+
logger.message("octo exit code: " + cliResult.getExitCode());
149136

150-
standardError = new Output.ReaderThread(process.getErrorStream(), listener::onErrorOutput);
151-
standardOutput =
152-
new Output.ReaderThread(process.getInputStream(), listener::onStandardOutput);
153-
154-
standardError.start();
155-
standardOutput.start();
156137
} catch (IOException e) {
157138
final String message = "Error from octo: " + e.getMessage();
158139
Logger.getInstance(getClass().getName()).error(message, e);
159140
throw new RunBuildException(message);
141+
} catch (InterruptedException e) {
142+
isFinished = true;
143+
final String message = "Unable to wait for octo: " + e.getMessage();
144+
Logger.getInstance(getClass().getName()).error(message, e);
145+
throw new RunBuildException(message);
160146
}
161147
}
162148

149+
private OctopusCliRetryExecutor.CliResult executeOctoProcess(
150+
String[] realCommand, boolean useExe, boolean useOcto)
151+
throws IOException, InterruptedException {
152+
final String octopusVersion = getSelectedOctopusVersion();
153+
154+
final ArrayList<String> arguments = new ArrayList<>();
155+
if (useExe) {
156+
arguments.add(new File(extractedTo, octopusVersion + "/octo.exe").getAbsolutePath());
157+
} else if (useOcto) {
158+
arguments.add("octo");
159+
} else {
160+
arguments.add("dotnet");
161+
String dllPath = new File(extractedTo, octopusVersion + "/Core/octo.dll").getAbsolutePath();
162+
arguments.add(dllPath);
163+
}
164+
165+
arguments.addAll(Arrays.asList(realCommand));
166+
167+
final String extensionVersion = getClass().getPackage().getImplementationVersion();
168+
169+
final ProcessBuilder builder = new ProcessBuilder();
170+
171+
Map<String, String> programEnvironmentVariables =
172+
context.getBuildParameters().getEnvironmentVariables();
173+
Map<String, String> environment = builder.environment();
174+
environment.put("OCTOEXTENSION", extensionVersion);
175+
environment.putAll(programEnvironmentVariables);
176+
177+
process = builder.command(arguments).directory(context.getWorkingDirectory()).start();
178+
179+
final LoggingProcessListener listener = new LoggingProcessListener(logger);
180+
181+
CaptureWriter stderrCapture = new CaptureWriter(listener::onErrorOutput);
182+
CaptureWriter stdoutCapture = new CaptureWriter(listener::onStandardOutput);
183+
184+
Output.ReaderThread standardError =
185+
new Output.ReaderThread(process.getErrorStream(), stderrCapture);
186+
Output.ReaderThread standardOutput =
187+
new Output.ReaderThread(process.getInputStream(), stdoutCapture);
188+
189+
standardError.start();
190+
standardOutput.start();
191+
192+
int exitCode = process.waitFor();
193+
194+
standardError.join();
195+
standardOutput.join();
196+
197+
String combinedOutput = stdoutCapture.getCapturedOutput() + stderrCapture.getCapturedOutput();
198+
return new OctopusCliRetryExecutor.CliResult(exitCode, combinedOutput);
199+
}
200+
163201
private String getSelectedOctopusVersion() {
164202
final Map<String, String> parameters = getContext().getRunnerParameters();
165203
final OctopusConstants constants = OctopusConstants.Instance;
@@ -199,26 +237,10 @@ public void interrupt() {
199237
@Override
200238
@NotNull
201239
public BuildFinishedStatus waitFor() throws RunBuildException {
202-
int exitCode;
203-
204-
try {
205-
exitCode = process.waitFor();
206-
207-
standardError.join();
208-
standardOutput.join();
209-
210-
logger.message("octo exit code: " + exitCode);
211-
logger.activityFinished("Octopus Deploy", BLOCK_TYPE_BUILD_STEP);
212-
213-
isFinished = true;
214-
} catch (InterruptedException e) {
215-
isFinished = true;
216-
final String message = "Unable to wait for octo: " + e.getMessage();
217-
Logger.getInstance(getClass().getName()).error(message, e);
218-
throw new RunBuildException(message);
219-
}
240+
logger.activityFinished("Octopus Deploy", BLOCK_TYPE_BUILD_STEP);
241+
isFinished = true;
220242

221-
if (exitCode == 0) {
243+
if (cliResult != null && cliResult.getExitCode() == 0) {
222244
return BuildFinishedStatus.FINISHED_SUCCESS;
223245
}
224246

0 commit comments

Comments
 (0)