This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 5d7706c0c417 CAMEL-25784: camel-jbang - camel run keeps the log of the
previous run, camel log --previous (#28107)
5d7706c0c417 is described below
commit 5d7706c0c417562ae64eaaee8bcd6c84cdcaa518
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Oct 19 21:41:39 2026 +0200
CAMEL-25784: camel-jbang - camel run keeps the log of the previous run,
camel log --previous (#28107)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 8 +
.../ROOT/pages/camel-jbang-configuration.adoc | 3 +
.../modules/ROOT/pages/camel-jbang-managing.adoc | 27 ++
.../ROOT/pages/jbang-commands/camel-jbang-log.adoc | 1 +
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../apache/camel/dsl/jbang/core/commands/Run.java | 58 ++-
.../jbang/core/commands/action/CamelLogAction.java | 97 +++-
.../dsl/jbang/core/commands/process/Dirty.java | 5 +
.../camel/dsl/jbang/core/common/PreviousRun.java | 517 +++++++++++++++++++++
.../jbang/core/commands/RunPreviousLogTest.java | 126 +++++
.../core/commands/action/CamelLogActionTest.java | 90 ++++
.../dsl/jbang/core/commands/process/DirtyTest.java | 21 +
.../dsl/jbang/core/common/PreviousRunTest.java | 309 ++++++++++++
13 files changed, 1241 insertions(+), 23 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 6045480f4d23..a29fa617f76d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1837,3 +1837,11 @@ With live reloading (such as `camel run --dev`), a
change to application.propert
`PropertiesReload` has a new default method, `onChanged`, which a properties
file that changed calls with the properties
that changed. A context reload (such as for secret rotation) still calls
`onReload`.
+
+=== camel-jbang - camel run keeps the log of the previous run
+
+When `camel run` ends, its log is no longer deleted. It is kept as
`~/.camel/<name>-previous.log`, by the name of the
+integration, with `~/.camel/<name>-previous.json`, which says when and how the
run ended. The next run of that name that
+ends replaces both, and at most the last 10 MB of a log are kept. `camel log
<name> --previous` prints the log, like
+`kubectl logs --previous` in Kubernetes. The log of an integration that was
killed is kept the same way, the next time a
+previous run is looked up. `camel dirty` does not count these files.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc
index 86fa276a28eb..357fe0a30c2f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-configuration.adoc
@@ -131,6 +131,9 @@ camel dirty
camel dirty --clean
----
+The log of the previous run of an integration (`~/.camel/<name>-previous.log`,
see `camel log --previous`) is kept on
+purpose, so `camel dirty` leaves it.
+
An error is printed as one line that starts with `ERROR:`. To see the stack
trace of every error, set the
environment variable `CAMEL_CLI_DEBUG` to `true`. See
xref:camel-jbang-exit-codes.adoc[Exit Codes and Output]
for what each exit code means.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
index 9293339bb643..4a7d5e65a271 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc
@@ -628,6 +628,33 @@ camel log chuck --grep=error
By default, `log` tails continuously. Use `--follow=false` to output matching
lines and exit.
+=== The log of the previous run
+
+When an integration stops, its log is kept as the log of its previous run,
like `kubectl logs --previous` in Kubernetes.
+Use `--previous` to see why an integration stopped, such as one that failed to
start:
+
+[source,bash]
+----
+$ camel log chuck --previous
+The previous run of chuck (pid 80093) ended at 2026-10-19 10:32:01 (2m3s ago)
with exit code 70: Failed to create route: route1 ...
+2026-10-19 10:32:00.912 INFO 80093 --- [ main]
org.apache.camel.main.MainSupport : Apache Camel (JBang) 4.24.0 is starting
+...
+----
+
+The first line, on stderr, says which run it was and how it ended. The log
follows on stdout.
+A run that Ctrl+C or SIGTERM stopped ended with exit code 130 or 143
(`(SIGINT)` or `(SIGTERM)` follows the code): the
+JVM exits with 128 and the number of the signal, as a shell and Kubernetes
show it.
+The options `--tail`, `--since`, `--grep`, `--find` and `--startup` work as
they do for a running integration.
+When you run `camel log` with the name of an integration that is not running,
the error says how its previous run ended.
+
+Each name keeps the log of its last run that ended, as
`~/.camel/<name>-previous.log`.
+The next run of that name that ends replaces it. Like Kubernetes, at most the
last 10 MB of a log are kept.
+An integration that was killed, or whose JVM crashed, leaves its log behind.
+That log becomes the log of its previous run the next time you look one up.
+
+NOTE: The log is kept for the `jbang` runtime, which runs Camel in the JVM of
the Camel CLI. This is the default, also
+with `--background`. The runtimes that run with Maven
(`--runtime=spring-boot`, `quarkus` or `main`) do not keep it.
+
== Configuring logging levels
View and change logging levels at runtime:
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-log.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-log.adoc
index 80e617648309..de0278434bfd 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-log.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-log.adoc
@@ -25,6 +25,7 @@ camel log [options]
| `--grep` | Filter logs to only output lines matching text (ignore case). |
| String
| `--logging-color` | Use colored logging | true | boolean
| `--prefix` | Print prefix with running Camel integration name. auto=only
prefix when running multiple integrations. true=always prefix. false=prefix
off. | auto | String
+| `--previous` | Print the log of the previous run of the integration instead:
the last run of that name that ended, such as one that failed to start (like
kubectl logs --previous). | false | boolean
| `--since` | Return logs newer than a relative duration like 5s, 2m, or 1h.
The value is in seconds if no unit specified. | | String
| `--startup` | Only shows logs from the starting phase to make it quick to
look at how Camel was started. | false | boolean
| `--tail` | The number of lines from the end of the logs to show. Use -1 to
read from the beginning. Use 0 to read only new lines. Defaults to showing all
logs from beginning. | -1 | int
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index fcf519c28b5a..0e2ca16f0d15 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -21,7 +21,7 @@
{ "name": "infra", "fullName": "infra", "description": "List and Run
external services for testing and prototyping", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.infra.InfraCommand", "options": [ {
"names": "--json", "description": "Output in JSON Format", "javaType":
"boolean", "type": "boolean" }, { "names": "-h,--help", "description": "Display
the help and sub-commands", "javaType": "boolean", "type": "boolean" } ],
"subcommands": [ { "name": "get", "fullName": "infra [...]
{ "name": "init", "fullName": "init", "description": "Creates a new Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Init",
"options": [ { "names": "--clean-dir,--clean-directory", "description":
"Whether to clean directory first (deletes all files in directory)",
"javaType": "boolean", "type": "boolean" }, { "names": "--dir,--directory",
"description": "Directory relative path where the new Camel integration will be
saved", "defaultValue": ".", "javaType" [...]
{ "name": "jolokia", "fullName": "jolokia", "description": "Attach Jolokia
JVM Agent to a running Camel integration", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.process.Jolokia", "options": [ {
"names": "--port", "description": "To use a specific port number when attaching
Jolokia JVM Agent (default a free port is found in range 8778-9999)",
"javaType": "int", "type": "integer" }, { "names": "--stop", "description":
"Stops the Jolokia JVM Agent in the running Camel inte [...]
- { "name": "log", "fullName": "log", "description": "Tail logs from running
Camel integrations", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelLogAction", "options": [
{ "names": "--duration", "description": "How long to keep watching or
following, in millis or as a duration such as 30s (default: until Enter is
pressed)", "javaType": "long", "type": "integer" }, { "names": "--find",
"description": "Find and highlight matching text (ignore case).", "javaType":
"j [...]
+ { "name": "log", "fullName": "log", "description": "Tail logs from running
Camel integrations", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelLogAction", "options": [
{ "names": "--duration", "description": "How long to keep watching or
following, in millis or as a duration such as 30s (default: until Enter is
pressed)", "javaType": "long", "type": "integer" }, { "names": "--find",
"description": "Find and highlight matching text (ignore case).", "javaType":
"j [...]
{ "name": "nano", "fullName": "nano", "description": "Nano editor to edit
file", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Nano",
"options": [ { "names": "-h,--help", "description": "Display the help and
sub-commands", "javaType": "boolean", "type": "boolean" } ] },
{ "name": "overview", "fullName": "overview", "description": "High-level
overview of the integrations in a project, optionally explained by AI\/LLM",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.Overview", "options":
[ { "names": "--ai", "description": "Ask an LLM to explain the project (an
overview, the capabilities, labels and notes of routes that have none) and save
it in camel-summary.md, marked as AI-assisted", "javaType": "boolean", "type":
"boolean" }, { "names": " [...]
{ "name": "plugin", "fullName": "plugin", "description": "Manage plugins
that add sub-commands to this CLI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.plugin.PluginCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "add",
"fullName": "plugin add", "description": "Add new plugin", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.plugin.PluginA [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
index c81ca8ef00ee..e6bf47779e3a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java
@@ -50,6 +50,7 @@ import org.apache.camel.CamelContext;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.ai.YamlLoadFailureReport;
+import
org.apache.camel.dsl.jbang.core.commands.exceptionhandler.CliExecutionExceptionHandler;
import org.apache.camel.dsl.jbang.core.common.BackgroundLog;
import org.apache.camel.dsl.jbang.core.common.ChildProcess;
import org.apache.camel.dsl.jbang.core.common.CliException;
@@ -65,6 +66,7 @@ import org.apache.camel.dsl.jbang.core.common.MavenCommand;
import org.apache.camel.dsl.jbang.core.common.Plugin;
import org.apache.camel.dsl.jbang.core.common.PluginExporter;
import org.apache.camel.dsl.jbang.core.common.PluginHelper;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
import org.apache.camel.dsl.jbang.core.common.Printer;
import org.apache.camel.dsl.jbang.core.common.ProfileCompletionCandidates;
import org.apache.camel.dsl.jbang.core.common.PropertyResolver;
@@ -178,7 +180,8 @@ public class Run extends CamelCommand {
boolean transformMessageRun;
boolean debugRun;
- private Path logFile;
+ // package-private for RunPreviousLogTest
+ Path logFile;
public long spawnPid;
private Printer quietPrinter;
@@ -3052,26 +3055,63 @@ public class Run extends CamelCommand {
protected int runKameletMain(KameletMain main) throws Exception {
Recording jfrRecording = startJfrRecording();
+ PreviousRun.Keeper previousLog = null;
+ if (logFile != null && keepsPreviousLog()) {
+ // CAMEL-25784: the log is kept as the previous log of the
integration when the run ends, and so are the
+ // logs that runs which were killed left behind
+ PreviousRun.keepOrphans();
+ previousLog = new PreviousRun.Keeper(logFile, () -> runName(main));
+ }
+ Integer exitCode = null;
+ String failure = null;
try {
main.start();
if (hasNoRoutes(main)) {
// it would run doing nothing: a file without routes, or a
mistake in it
- printer().printErr(files.isEmpty()
- ? "No routes found" : "No routes found in: " +
String.join(", ", files));
+ failure = files.isEmpty() ? "No routes found" : "No routes
found in: " + String.join(", ", files);
+ printer().printErr(failure);
main.stop();
- return CliExitCode.FAILURE;
+ exitCode = CliExitCode.FAILURE;
+ return exitCode;
}
main.run();
+ int code = main.getExitCode();
+ // no exit code is set when Camel is stopped from outside (such as
camel stop)
+ exitCode = code == Integer.MIN_VALUE ? CliExitCode.OK : code;
+ return exitCode;
+ } catch (Exception | Error e) {
+ // such as a route that failed to start
+ failure = e.getMessage() != null ? e.getMessage() :
e.getClass().getName();
+ exitCode = new
CliExecutionExceptionHandler(getMain()).getExitCode(e);
+ throw e;
} finally {
stopJfrRecording(jfrRecording);
- // cleanup and delete log file
- if (logFile != null) {
+ if (previousLog != null) {
+ previousLog.ended(exitCode, failure);
+ } else if (logFile != null) {
+ // cleanup and delete log file
FileUtil.deleteFile(logFile);
}
}
- int code = main.getExitCode();
- // no exit code is set when Camel is stopped from outside (such as
camel stop)
- return code == Integer.MIN_VALUE ? CliExitCode.OK : code;
+ }
+
+ /**
+ * Whether the log of the run is kept as the previous log of the
integration when the run ends (CAMEL-25784):
+ * {@code camel run} on the command line, not a Camel that another command
runs in its JVM (such as
+ * {@code camel cmd send --local} and {@code camel transform}).
+ */
+ boolean keepsPreviousLog() {
+ return spec != null && !exportRun && !transformRun &&
!transformMessageRun && !scriptRun;
+ }
+
+ /**
+ * The name of the integration, as {@code camel ps} and {@code camel log}
find it: the name of its CamelContext,
+ * which is {@code camel.main.name}, else the name of the run when Camel
did not get that far.
+ */
+ private String runName(KameletMain main) {
+ CamelContext context = main.getCamelContext();
+ String answer = context != null ? context.getName() : null;
+ return answer != null && !answer.isBlank() ? answer : name;
}
/**
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogAction.java
index 50f900d2e97f..f10009dbeb5d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogAction.java
@@ -33,14 +33,17 @@ import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
+import java.util.StringJoiner;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.regex.Pattern;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.commands.CommandHelper;
import org.apache.camel.dsl.jbang.core.commands.DurationMixin;
+import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
import org.apache.camel.util.StopWatch;
import org.apache.camel.util.StringHelper;
@@ -55,7 +58,8 @@ import picocli.CommandLine;
"%nExamples:",
" camel log hello",
" camel log *",
- " camel log hello --tail=50" })
+ " camel log hello --tail=50",
+ " camel log hello --previous" })
public class CamelLogAction extends ActionBaseCommand {
private static final int NAME_MAX_WIDTH = 25;
@@ -75,7 +79,8 @@ public class CamelLogAction extends ActionBaseCommand {
}
}
- @CommandLine.Parameters(description = "Name or pid of running Camel
integration. (default selects all)", arity = "0..1")
+ @CommandLine.Parameters(description = "Name or pid of running Camel
integration, or of the run that ended with --previous. (default selects all)",
+ arity = "0..1")
String name = "*";
@CommandLine.Option(names = { "--logging-color" }, defaultValue = "true",
arity = "0..1", fallbackValue = "true",
@@ -118,6 +123,11 @@ public class CamelLogAction extends ActionBaseCommand {
description = "Filter logs to only output lines
matching text (ignore case).", arity = "0..*")
String[] grep;
+ @CommandLine.Option(names = { "--previous" }, defaultValue = "false",
+ description = "Print the log of the previous run of
the integration instead: the last run of that name that ended,"
+ + " such as one that failed to start
(like kubectl logs --previous).")
+ boolean previous;
+
String findAnsi;
Pattern[] grepPatterns;
Pattern[] findPatterns;
@@ -135,7 +145,8 @@ public class CamelLogAction extends ActionBaseCommand {
@Override
public Integer doCall() throws Exception {
- follow = isFollowing(follow, durationOption.getDuration());
+ // a previous run has ended, so there is nothing to follow
+ follow = !previous && isFollowing(follow,
durationOption.getDuration());
if (follow && EnvironmentHelper.isEmbedded()) {
follow = false;
printer().printTip("press F3 to switch to the Log tab for live log
streaming");
@@ -153,11 +164,15 @@ public class CamelLogAction extends ActionBaseCommand {
grepPatterns = quoteAndCompilePatterns(grep);
}
- // find new pids
- updatePids(rows);
- if (rows.isEmpty() && !follow) {
- // shown once: a name that matches no running integration is not
found
- findProcesses(name);
+ if (previous) {
+ previousRun(rows);
+ } else {
+ // find new pids
+ updatePids(rows);
+ if (rows.isEmpty() && !follow) {
+ // shown once: a name that matches no running integration is
not found
+ findRunning();
+ }
}
if (!rows.isEmpty()) {
// read existing log files (skip by tail/since)
@@ -217,6 +232,60 @@ public class CamelLogAction extends ActionBaseCommand {
return 0;
}
+ /**
+ * The previous run of the integration (CAMEL-25784): the one that matches
the name, after a line on stderr that
+ * says which run it was and how it ended.
+ *
+ * @throws CliException with exit code 3 when no previous run, or more
than one, matches
+ */
+ private void previousRun(Map<Long, Row> rows) {
+ boolean all = name == null || name.isBlank() || "*".equals(name);
+ List<PreviousRun.Info> found = PreviousRun.find(name);
+ if (found.isEmpty()) {
+ throw CliException.notFound(all ? "No previous run of a Camel
integration" : "No previous run matches: " + name);
+ }
+ if (found.size() > 1) {
+ StringJoiner names = new StringJoiner(", ");
+ found.forEach(i -> names.add(i.name()));
+ throw CliException.notFound((all ? "There are " : "Name " + name +
" matches ") + found.size()
+ + " previous runs: " + names + ".
Specify the name of one.");
+ }
+ PreviousRun.Info info = found.get(0);
+ printer().printDiagnostic(info.describe());
+ Row row = new Row();
+ row.pid = Long.toString(info.pid());
+ row.name = info.name();
+ row.file = info.log();
+ nameMaxWidth = Math.max(NAME_MIN_WIDTH, Math.min(NAME_MAX_WIDTH,
row.name.length()));
+ rows.put(info.pid(), row);
+ }
+
+ /**
+ * Fails when a name given matches no running integration, saying where
the log of its previous run is, which tells
+ * why it is not running.
+ */
+ private void findRunning() {
+ try {
+ if (findProcesses(name).isEmpty()) {
+ // nothing runs: the newest previous run (findAll is newest
first)
+ List<PreviousRun.Info> found = PreviousRun.findAll();
+ if (!found.isEmpty()) {
+ printer().printTip(previousRunHint(found.get(0)));
+ }
+ }
+ } catch (CliException e) {
+ List<PreviousRun.Info> found = PreviousRun.find(name);
+ if (found.size() != 1) {
+ throw e;
+ }
+ throw CliException.notFound(e.getMessage() + ". " +
previousRunHint(found.get(0)));
+ }
+ }
+
+ private static String previousRunHint(PreviousRun.Info info) {
+ return "camel log " + info.name() + " --previous prints the log of its
previous run, which " + info.howItEnded();
+ }
+
private void updatePids(Map<Long, Row> rows) {
List<Long> pids = findPids(name);
ProcessHandle.allProcesses()
@@ -267,7 +336,7 @@ public class CamelLogAction extends ActionBaseCommand {
for (Row row : rows.values()) {
if (row.reader == null) {
- Path file = logFile(row.pid, row.name);
+ Path file = logFile(row);
if (Files.exists(file)) {
row.reader = new
LineNumberReader(Files.newBufferedReader(file));
if (tail == 0) {
@@ -440,8 +509,8 @@ public class CamelLogAction extends ActionBaseCommand {
}
}
- private static Path logFile(String pid) {
- return logFile(pid, null);
+ private static Path logFile(Row row) {
+ return row.file != null ? row.file : logFile(row.pid, row.name);
}
private static Path logFile(String pid, String appName) {
@@ -458,7 +527,7 @@ public class CamelLogAction extends ActionBaseCommand {
private void tailStartupLogFiles(Map<Long, Row> rows) throws Exception {
for (Row row : rows.values()) {
- Path log = logFile(row.pid, row.name);
+ Path log = logFile(row);
if (Files.exists(log)) {
row.fifo = new ArrayDeque<>();
row.reader = new
LineNumberReader(Files.newBufferedReader(log));
@@ -480,7 +549,7 @@ public class CamelLogAction extends ActionBaseCommand {
private void tailLogFiles(Map<Long, Row> rows, int tail, Date limit)
throws Exception {
for (Row row : rows.values()) {
- Path log = logFile(row.pid, row.name);
+ Path log = logFile(row);
if (Files.exists(log)) {
row.reader = new
LineNumberReader(Files.newBufferedReader(log));
String line;
@@ -595,6 +664,8 @@ public class CamelLogAction extends ActionBaseCommand {
private static class Row {
String pid;
String name;
+ // the log of a previous run, else the log of the running integration
+ Path file;
Queue<String> fifo;
LineNumberReader reader;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/Dirty.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/Dirty.java
index 6dcb4cae0dcb..5f887eb9b9b5 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/Dirty.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/Dirty.java
@@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.List;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
import org.apache.camel.util.FileUtil;
import picocli.CommandLine;
@@ -51,6 +52,10 @@ public class Dirty extends ProcessBaseCommand {
List<File> dirty = new ArrayList<>();
for (File f : files) {
+ if (PreviousRun.isPreviousRunFile(f.getName())) {
+ // kept on purpose: the log of the previous run of an
integration (camel log --previous)
+ continue;
+ }
// the files of a process that is alive are in use (such as the
launcher of an integration that runs in a
// child process, whose Camel has stopped), and so are the files
of ourselves
Long pid = pidOf(f.getName());
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
new file mode 100644
index 000000000000..c6343cb1a8c6
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PreviousRun.java
@@ -0,0 +1,517 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.common;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import sun.misc.Signal;
+import sun.misc.SignalHandler;
+
+/**
+ * The log of the previous run of an integration, as <tt>kubectl logs
--previous</tt> shows the log of the previous
+ * instance of a container in Kubernetes.
+ * <p/>
+ * When <tt>camel run</tt> ends (it was stopped, it failed to start, or it
stopped by itself), its log is kept as
+ * <tt>~/.camel/<name>-previous.log</tt>, by the name of the
integration, with
+ * <tt>~/.camel/<name>-previous.json</tt>, which says when and how the
run ended. The next run of that name that
+ * ends replaces both. Like Kubernetes, at most the last 10 MB of a log are
kept.
+ * <p/>
+ * A process that is gone without keeping its log (it was killed, or its JVM
crashed) leaves its
+ * <tt>~/.camel/<pid>.log</tt> behind. That log is kept for it, under
the name in its status file, the next time
+ * the previous runs are looked up, and the next time an integration is run.
+ */
+public final class PreviousRun {
+
+ /**
+ * The end of a log that is kept, at most: Kubernetes keeps at most 10 MB
of the log of a container by default.
+ */
+ static final long MAX_SIZE = 10L * 1024 * 1024;
+
+ /**
+ * How old the log of a process that is gone may get, when there is no
name to keep it by, before it is removed.
+ */
+ static final long ORPHAN_AGE = TimeUnit.DAYS.toMillis(1);
+
+ /**
+ * What a process that is gone without keeping its log ended with, as far
as is known.
+ */
+ static final String GONE = "the process was gone without stopping Camel
(killed, or its JVM crashed)";
+
+ private static final String LOG_SUFFIX = "-previous.log";
+ private static final String INFO_SUFFIX = "-previous.json";
+ private static final Pattern PID_LOG = Pattern.compile("(\\d+)\\.log");
+ private static final int MAX_FAILURE = 500;
+ private static final DateTimeFormatter TIME
+ = DateTimeFormatter.ofPattern("yyyy-MM-dd
HH:mm:ss").withZone(ZoneId.systemDefault());
+
+ /**
+ * A run that ended, and the log it kept.
+ *
+ * @param name the name of the integration
+ * @param pid the pid it ran as, 0 when not known
+ * @param directory the directory it ran in, or <tt>null</tt>
+ * @param started when it started, in epoch millis, 0 when not known
+ * @param ended when it ended, in epoch millis
+ * @param exitCode the exit code, or <tt>null</tt> when not known (such
as of a process that was killed)
+ * @param signal the signal that stopped the run, such as
<tt>SIGTERM</tt>, or <tt>null</tt>
+ * @param failure why it ended, when it failed (such as the error it
failed to start with), or <tt>null</tt>
+ * @param log the log it kept
+ */
+ public record Info(String name, long pid, String directory, long started,
long ended, Integer exitCode,
+ String signal, String failure, Path log) {
+
+ /**
+ * Which run it was, and how it ended, in one line.
+ */
+ public String describe() {
+ return "The previous run of " + name + (pid > 0 ? " (pid " + pid +
")" : "") + " " + howItEnded();
+ }
+
+ /**
+ * How the run ended: when, with which exit code, and why when it
failed.
+ */
+ public String howItEnded() {
+ StringBuilder sb = new StringBuilder("ended at
").append(TIME.format(Instant.ofEpochMilli(ended)));
+ long ago = System.currentTimeMillis() - ended;
+ if (ago >= 0) {
+ sb.append(" (").append(TimeUtils.printDuration(ago,
false)).append(" ago)");
+ }
+ if (exitCode != null) {
+ sb.append(" with exit code ").append(exitCode);
+ }
+ if (signal != null) {
+ sb.append(" (").append(signal).append(')');
+ }
+ if (failure != null) {
+ sb.append(": ").append(failure);
+ }
+ return sb.toString();
+ }
+ }
+
+ private PreviousRun() {
+ }
+
+ /**
+ * Keeps the log of a run of this JVM that ended as the previous log of
its name.
+ *
+ * @param log the log of the run
+ * @param name the name of the integration
+ * @param pid the pid it ran as, 0 when not known
+ * @param exitCode the exit code, or <tt>null</tt> when not known
+ * @param failure why it ended, when it failed, or <tt>null</tt>
+ * @return the kept log, or <tt>null</tt> when there was no log
to keep
+ */
+ public static Path keep(Path log, String name, long pid, Integer exitCode,
String failure) {
+ return keep(log, thisRun(name, pid, exitCode, null, failure));
+ }
+
+ // a run of this JVM that ends now
+ private static Info thisRun(String name, long pid, Integer exitCode,
String signal, String failure) {
+ long started =
ProcessHandle.current().info().startInstant().map(Instant::toEpochMilli).orElse(0L);
+ return new Info(
+ name, pid, System.getProperty("user.dir"), started,
System.currentTimeMillis(), exitCode, signal,
+ failure, null);
+ }
+
+ /**
+ * Keeps the log of a run that ended as the previous log of its name, in
place of the one kept before.
+ *
+ * @return the kept log, or <tt>null</tt> when there was no log to keep
+ */
+ static Path keep(Path log, Info info) {
+ if (log == null || info.name() == null || info.name().isBlank() ||
!Files.isRegularFile(log)) {
+ return null;
+ }
+ Path dir = CommandLineHelper.getCamelDir();
+ String file = fileName(info.name());
+ Path target = dir.resolve(file + LOG_SUFFIX);
+ try {
+ // what was kept about the run before goes first, so a keep that
fails (such as on a full disk) does not
+ // leave it next to the log of this run
+ Files.deleteIfExists(dir.resolve(file + INFO_SUFFIX));
+ if (Files.size(log) > MAX_SIZE || !move(log, target)) {
+ copyEnd(log, target, MAX_SIZE);
+ try {
+ Files.deleteIfExists(log);
+ } catch (IOException e) {
+ // still open (Windows): removed when the process is gone
(see keepOrphans)
+ }
+ }
+ JsonObject jo = new JsonObject();
+ jo.put("name", info.name());
+ if (info.pid() > 0) {
+ jo.put("pid", info.pid());
+ }
+ if (info.directory() != null) {
+ jo.put("directory", info.directory());
+ }
+ if (info.started() > 0) {
+ jo.put("started", info.started());
+ }
+ jo.put("ended", info.ended());
+ if (info.exitCode() != null) {
+ jo.put("exitCode", info.exitCode());
+ }
+ if (info.signal() != null) {
+ jo.put("signal", info.signal());
+ }
+ if (info.failure() != null) {
+ String failure = info.failure().strip();
+ jo.put("failure", failure.length() > MAX_FAILURE ?
failure.substring(0, MAX_FAILURE) + "..." : failure);
+ }
+ Files.writeString(dir.resolve(file + INFO_SUFFIX), jo.toJson());
+ return target;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * The previous run of each name, the newest first.
+ */
+ public static List<Info> findAll() {
+ keepOrphans();
+ List<Info> answer = new ArrayList<>();
+ File[] files = CommandLineHelper.getCamelDir().toFile().listFiles((d,
n) -> n.endsWith(INFO_SUFFIX));
+ if (files != null) {
+ for (File f : files) {
+ Info info = read(f.toPath());
+ if (info != null) {
+ answer.add(info);
+ }
+ }
+ }
+ answer.sort(Comparator.comparingLong(Info::ended).reversed());
+ return answer;
+ }
+
+ /**
+ * The previous runs that match the name or pid, as a running integration
is found (see {@link ProcessFinder}): a
+ * number is the pid it ran as, and a name is a prefix (<tt>foo</tt>
matches <tt>foo-bar</tt>) unless it ends with
+ * <tt>!</tt>, and may use wildcards. A name that is the whole name of a
previous run matches only that run.
+ *
+ * @param nameOrPid a name, a pattern or a pid; <tt>null</tt> or
<tt>*</tt> for every previous run
+ */
+ public static List<Info> find(String nameOrPid) {
+ List<Info> all = findAll();
+ if (nameOrPid == null || nameOrPid.isBlank() || "*".equals(nameOrPid))
{
+ return all;
+ }
+ if (nameOrPid.matches("\\d+")) {
+ long pid = Long.parseLong(nameOrPid);
+ return all.stream().filter(i -> i.pid() == pid).toList();
+ }
+ boolean exact = nameOrPid.endsWith("!");
+ String name = exact ? nameOrPid.substring(0, nameOrPid.length() - 1) :
nameOrPid;
+ List<Info> same = all.stream().filter(i ->
i.name().equals(name)).toList();
+ if (exact || !same.isEmpty()) {
+ return same;
+ }
+ String pattern = name.endsWith("*") ? name : name + "*";
+ return all.stream().filter(i -> PatternHelper.matchPattern(i.name(),
pattern)).toList();
+ }
+
+ /**
+ * Whether the file in the Camel directory is a log of a previous run, or
what it says about that run.
+ */
+ public static boolean isPreviousRunFile(String fileName) {
+ return fileName.endsWith(LOG_SUFFIX) || fileName.endsWith(INFO_SUFFIX);
+ }
+
+ /**
+ * Keeps the logs that processes which are gone left behind (they were
killed, or their JVM crashed), under the name
+ * in their status file. A log older than the previous run of its name is
removed, and so is a log without a name
+ * that is older than a day.
+ */
+ public static void keepOrphans() {
+ File[] files = CommandLineHelper.getCamelDir().toFile().listFiles((d,
n) -> PID_LOG.matcher(n).matches());
+ if (files == null) {
+ return;
+ }
+ long now = System.currentTimeMillis();
+ for (File f : files) {
+ Matcher m = PID_LOG.matcher(f.getName());
+ if (!m.matches()) {
+ continue;
+ }
+ long pid;
+ try {
+ pid = Long.parseLong(m.group(1));
+ } catch (NumberFormatException e) {
+ continue;
+ }
+ if
(ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false)) {
+ // still running (or its pid is taken by another process)
+ continue;
+ }
+ long modified = f.lastModified();
+ JsonObject status =
readJson(CommandLineHelper.getCamelDir().resolve(pid + "-status.json"));
+ String name = status != null && status.get("context") instanceof
JsonObject context
+ ? context.getString("name") : null;
+ if (name != null && !name.isBlank()) {
+ Info before =
read(CommandLineHelper.getCamelDir().resolve(fileName(name) + INFO_SUFFIX));
+ // the time of the kept log, not when its run ended, so both
times come from the file system (a
+ // network file system has a clock of its own)
+ if (before == null || before.log().toFile().lastModified() <
modified) {
+ String directory = status.get("runtime") instanceof
JsonObject runtime
+ ? runtime.getString("directory") : null;
+ // when the process was gone is not known: the last write
of its log is the closest time to it
+ keep(f.toPath(), new Info(name, pid, directory, 0,
modified, null, null, GONE, null));
+ continue;
+ }
+ } else if (now - modified < ORPHAN_AGE) {
+ continue;
+ }
+ // older than the previous run of its name, or without a name to
keep it by
+ try {
+ Files.deleteIfExists(f.toPath());
+ } catch (IOException e) {
+ // removed by camel dirty --clean
+ }
+ }
+ }
+
+ /**
+ * Keeps the log of the run of this JVM once, when the run ends: see
{@link #ended(Integer, String)}. When the JVM
+ * exits before the run has ended (such as on Ctrl+C, while Camel stops),
the log is kept as the JVM exits: the run
+ * gets a few seconds to end and say how it ended, else the log is kept
without that.
+ * <p/>
+ * A run that Ctrl+C or SIGTERM stops ends with the exit code of the JVM:
128 and the number of the signal (130 for
+ * SIGINT, 143 for SIGTERM), as a shell and Kubernetes show it. The keeper
watches these signals for that, and
+ * passes each on to the handler of the JVM, which runs the shutdown hooks
and exits.
+ */
+ public static final class Keeper {
+
+ /**
+ * How long a JVM that exits waits for the run to end, in seconds.
+ */
+ static final int EXIT_WAIT_SECONDS = 10;
+
+ /**
+ * The signals the JVM exits on, running its shutdown hooks.
+ */
+ private static final List<String> STOP_SIGNALS = List.of("INT",
"TERM");
+
+ private final Path log;
+ private final Supplier<String> name;
+ private final Thread hook;
+ private final CountDownLatch ended = new CountDownLatch(1);
+ private final List<Watch> watches = new ArrayList<>();
+ private volatile Signal stoppedBy;
+ private boolean kept;
+
+ // a signal that stops the JVM: the handler of the keeper, and the
handler of the JVM it passes the signal on to
+ private record Watch(Signal signal, SignalHandler handler,
AtomicReference<SignalHandler> jvm) {
+ }
+
+ /**
+ * A run that has started, and keeps its log when it ends.
+ *
+ * @param log the log of the run
+ * @param name the name of the integration, asked for when the run ends
+ */
+ public Keeper(Path log, Supplier<String> name) {
+ this.log = log;
+ this.name = name;
+ this.hook = new Thread(() -> exiting(EXIT_WAIT_SECONDS),
"CamelKeepPreviousLog");
+ Runtime.getRuntime().addShutdownHook(hook);
+ watchSignals();
+ }
+
+ /**
+ * The run has ended: keeps its log as the previous log of its name.
+ *
+ * @param exitCode the exit code, or <tt>null</tt> when not known
+ * @param failure why it ended, when it failed, or <tt>null</tt>
+ */
+ public void ended(Integer exitCode, String failure) {
+ keep(exitCode, failure);
+ ended.countDown();
+ try {
+ Runtime.getRuntime().removeShutdownHook(hook);
+ // the JVM goes on (such as in a test): the signals go to the
handlers of the JVM again
+ unwatchSignals();
+ } catch (IllegalStateException e) {
+ // the JVM is exiting: the hook finds the log kept
+ }
+ }
+
+ /**
+ * The signal stopped the JVM; visible for tests, as a test cannot
send it.
+ */
+ void stoppedBy(Signal signal) {
+ stoppedBy = signal;
+ }
+
+ private void watchSignals() {
+ for (String name : STOP_SIGNALS) {
+ try {
+ Signal signal = new Signal(name);
+ AtomicReference<SignalHandler> jvm = new
AtomicReference<>();
+ SignalHandler handler = s -> {
+ stoppedBy = s;
+ SignalHandler next = jvm.get();
+ if (next != null) {
+ next.handle(s);
+ } else {
+ // what the JVM does on the signal
+ System.exit(128 + s.getNumber());
+ }
+ };
+ SignalHandler before = Signal.handle(signal, handler);
+ if (before == SignalHandler.SIG_DFL || before ==
SignalHandler.SIG_IGN) {
+ // the JVM does not exit on it here (such as a signal
that is ignored): left as it was
+ Signal.handle(signal, before);
+ } else {
+ jvm.set(before);
+ watches.add(new Watch(signal, handler, jvm));
+ }
+ } catch (IllegalArgumentException e) {
+ // a signal this platform does not have, or that the JVM
keeps (such as with -Xrs)
+ }
+ }
+ }
+
+ private void unwatchSignals() {
+ for (Watch watch : watches) {
+ try {
+ SignalHandler current = Signal.handle(watch.signal(),
watch.jvm().get());
+ if (current != watch.handler()) {
+ // a handler that came after the keeper stays
+ Signal.handle(watch.signal(), current);
+ }
+ } catch (IllegalArgumentException e) {
+ // left as it is
+ }
+ }
+ watches.clear();
+ }
+
+ // the JVM exits: the run gets the seconds to end, and say how it ended
+ void exiting(int waitSeconds) {
+ try {
+ ended.await(waitSeconds, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ keep(null, null);
+ }
+
+ private synchronized void keep(Integer exitCode, String failure) {
+ if (!kept) {
+ kept = true;
+ Signal signal = stoppedBy;
+ // the JVM exits with the exit code of the signal, whatever
the run answered
+ PreviousRun.keep(log, thisRun(name.get(),
ProcessHandle.current().pid(),
+ signal != null ? Integer.valueOf(128 +
signal.getNumber()) : exitCode,
+ signal != null ? "SIG" + signal.getName() : null,
failure));
+ }
+ }
+ }
+
+ // the name of an integration as a file name: what is not a letter, a
digit, a dot, a dash or an underscore
+ // becomes an underscore
+ static String fileName(String name) {
+ String answer = name.replaceAll("[^A-Za-z0-9._-]", "_");
+ return answer.isBlank() || answer.startsWith(".") ? "_" + answer :
answer;
+ }
+
+ private static boolean move(Path log, Path target) {
+ try {
+ Files.move(log, target, StandardCopyOption.REPLACE_EXISTING);
+ return true;
+ } catch (IOException e) {
+ // such as a log that is still open (Windows)
+ return false;
+ }
+ }
+
+ // the end of the log, at most max bytes, from the start of a line
+ static void copyEnd(Path log, Path target, long max) throws IOException {
+ try (RandomAccessFile in = new RandomAccessFile(log.toFile(), "r");
+ OutputStream out = new
BufferedOutputStream(Files.newOutputStream(target))) {
+ long from = Math.max(0, in.length() - max);
+ in.seek(from);
+ if (from > 0) {
+ int b;
+ do {
+ b = in.read();
+ } while (b != -1 && b != '\n');
+ }
+ byte[] buffer = new byte[8192];
+ int n;
+ while ((n = in.read(buffer)) != -1) {
+ out.write(buffer, 0, n);
+ }
+ }
+ }
+
+ // a previous run, with its log, or null
+ private static Info read(Path info) {
+ JsonObject jo = readJson(info);
+ String name = jo != null ? jo.getString("name") : null;
+ if (name == null) {
+ return null;
+ }
+ String file = info.getFileName().toString();
+ Path log = info.resolveSibling(file.substring(0, file.length() -
INFO_SUFFIX.length()) + LOG_SUFFIX);
+ if (!Files.isRegularFile(log)) {
+ return null;
+ }
+ return new Info(
+ name, jo.getLongOrDefault("pid", 0), jo.getString("directory"),
+ jo.getLongOrDefault("started", 0),
jo.getLongOrDefault("ended", 0), jo.getInteger("exitCode"),
+ jo.getString("signal"), jo.getString("failure"), log);
+ }
+
+ private static JsonObject readJson(Path file) {
+ try {
+ if (!Files.isRegularFile(file)) {
+ return null;
+ }
+ String text = Files.readString(file);
+ return text.isBlank() ? null : Jsoner.deserialize(text,
(JsonObject) null);
+ } catch (Exception e) {
+ // being written, or gone
+ return null;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RunPreviousLogTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RunPreviousLogTest.java
new file mode 100644
index 000000000000..ed1805fff95c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/RunPreviousLogTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.FailedToStartRouteException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.main.KameletMain;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * CAMEL-25784: camel run keeps its log as the previous log of the integration
when it ends.
+ */
+class RunPreviousLogTest extends CamelCommandBaseTestSupport {
+
+ private Path log;
+
+ @BeforeEach
+ void logOfTheRun() throws Exception {
+ useTestHomeDir();
+ Files.createDirectories(CommandLineHelper.getCamelDir());
+ log = Files.write(CommandLineHelper.getCamelDir().resolve("4242.log"),
List.of("Starting", "Failed"));
+ }
+
+ @Test
+ void theLogOfARunThatFailedToStartIsKept() throws Exception {
+ KameletMain main = mock(KameletMain.class);
+ doThrow(new FailedToStartRouteException("route1",
"boom")).when(main).start();
+ Run run = commandLineRun();
+
+ assertThatThrownBy(() ->
run.runKameletMain(main)).isInstanceOf(FailedToStartRouteException.class);
+
+ // Camel did not get as far as a CamelContext: the name of the run
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.pid()).isEqualTo(ProcessHandle.current().pid());
+ // the exit code the CLI exits with
+ assertThat(info.exitCode()).isEqualTo(CliExitCode.INTERNAL);
+ assertThat(info.failure()).isEqualTo("Failed to start route: route1
because: boom");
+ assertThat(Files.readAllLines(info.log())).containsExactly("Starting",
"Failed");
+ assertThat(log).doesNotExist();
+ }
+
+ @Test
+ void theLogOfARunWithoutRoutesIsKeptByTheNameOfItsCamelContext() throws
Exception {
+ KameletMain main = mock(KameletMain.class);
+ DefaultCamelContext context = new DefaultCamelContext();
+ context.getCamelContextExtension().setName("orders-eu");
+ when(main.getCamelContext()).thenReturn(context);
+ Run run = commandLineRun();
+
+ assertThat(run.runKameletMain(main)).isEqualTo(CliExitCode.FAILURE);
+
+ PreviousRun.Info info = only(PreviousRun.find("orders-eu"));
+ assertThat(info.exitCode()).isEqualTo(CliExitCode.FAILURE);
+ assertThat(info.failure()).isEqualTo("No routes found");
+ }
+
+ @Test
+ void theLogOfARunThatWasStoppedIsKept() throws Exception {
+ KameletMain main = mock(KameletMain.class);
+ // no exit code: stopped from outside, such as by camel stop
+ when(main.getExitCode()).thenReturn(Integer.MIN_VALUE);
+ Run run = commandLineRun();
+
+ assertThat(run.runKameletMain(main)).isZero();
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.exitCode()).isZero();
+ assertThat(info.failure()).isNull();
+ }
+
+ @Test
+ void aCamelThatAnotherCommandRunsRemovesItsLog() throws Exception {
+ KameletMain main = mock(KameletMain.class);
+ // such as camel cmd send --local, which runs a Camel of its own
+ Run run = new Run(new CamelJBangMain().withPrinter(printer));
+ run.logFile = log;
+
+ assertThat(run.runKameletMain(main)).isZero();
+
+ assertThat(log).doesNotExist();
+ assertThat(PreviousRun.findAll()).isEmpty();
+ }
+
+ // camel run on the command line
+ private Run commandLineRun() {
+ Run run = new Run(new CamelJBangMain().withPrinter(printer));
+ new CommandLine(run);
+ run.name = "orders";
+ run.logFile = log;
+ return run;
+ }
+
+ private static PreviousRun.Info only(List<PreviousRun.Info> found) {
+ assertThat(found).hasSize(1);
+ return found.get(0);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogActionTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogActionTest.java
index 1380faaf597c..c7873d39c790 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogActionTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelLogActionTest.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.action;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@@ -26,6 +27,7 @@ import
org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.common.CliException;
import org.apache.camel.dsl.jbang.core.common.CliExitCode;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.PreviousRun;
import org.apache.camel.dsl.jbang.core.common.StringPrinter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -166,6 +168,82 @@ class CamelLogActionTest extends ActionCommandTestSupport {
assertThat(lines().get(1)).startsWith("orders").endsWith(": Failed");
}
+ @Test
+ void previousPrintsTheLogOfTheRunThatEnded() throws Exception {
+ keepPreviousRun("orders", line(-300, "Starting"), line(-1, "Failed to
start route route1"));
+
+ assertThat(callWithSingleProcess(command("--previous"))).isZero();
+
+ // which run it was, and how it ended, on stderr; the log on stdout
+ assertThat(printer.getStderr()).startsWith("The previous run of orders
(pid 4242) ended at ")
+ .endsWith(" with exit code 70: Failed to start route route1
because: boom");
+ assertThat(stdoutLines()).hasSize(2)
+ .last().asString().endsWith(": Failed to start route route1");
+ }
+
+ @Test
+ void previousTakesTheOptionsOfTheLog() throws Exception {
+ keepPreviousRun("orders", line(-300, "Starting"), line(-60, "Slow"),
line(-1, "Failed to start route route1"));
+
+ assertThat(callWithSingleProcess(command("--previous", "--tail=2",
"--grep=start"))).isZero();
+
+ assertThat(stdoutLines()).hasSize(2)
+ .first().asString().endsWith(": Starting");
+ }
+
+ @Test
+ void previousPrintsTheLogOfAnIntegrationThatWasKilled() throws Exception {
+ // the process is gone, and left its log and its status file
+ writeLog(TEST_PID + ".log", line(-60, "Started"), line(-1,
"Processing"));
+
+ assertThat(callWithSingleProcess(command("--previous"))).isZero();
+
+ assertThat(printer.getStderr()).startsWith("The previous run of orders
(pid " + TEST_PID + ") ended at ")
+ .endsWith("ago): " + "the process was gone without stopping
Camel (killed, or its JVM crashed)");
+ assertThat(stdoutLines()).hasSize(2);
+ }
+
+ @Test
+ void previousOfANameWithoutPreviousRunIsNotFound() throws Exception {
+ keepPreviousRun("orders", line(-1, "Failed"));
+ CamelLogAction command = new CamelLogAction(new
CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command, "payments", "--previous");
+
+ assertThatThrownBy(() -> callWithSingleProcess(command))
+ .isInstanceOfSatisfying(CliException.class,
+ e ->
assertThat(e.getExitCode()).isEqualTo(CliExitCode.NOT_FOUND))
+ .hasMessage("No previous run matches: payments");
+ }
+
+ @Test
+ void previousOfANameThatMatchesSeveralRunsAsksForOne() throws Exception {
+ keepPreviousRun("orders", line(-1, "Failed"));
+ keepPreviousRun("orders-eu", line(-1, "Failed"));
+ CamelLogAction command = new CamelLogAction(new
CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command, "ord", "--previous");
+
+ assertThatThrownBy(() -> callWithSingleProcess(command))
+ .isInstanceOfSatisfying(CliException.class,
+ e ->
assertThat(e.getExitCode()).isEqualTo(CliExitCode.NOT_FOUND))
+ .hasMessageStartingWith("Name ord matches 2 previous runs: ")
+ .hasMessageEndingWith(". Specify the name of one.");
+ }
+
+ @Test
+ void aNameThatIsNotRunningSaysWhereTheLogOfItsPreviousRunIs() throws
Exception {
+ writeStatusFile(TEST_PID, "orders");
+ keepPreviousRun("payments", line(-1, "Failed"));
+ CamelLogAction command = new CamelLogAction(new
CamelJBangMain().withPrinter(printer));
+ CommandLine.populateCommand(command, "payments", "--follow=false");
+
+ assertThatThrownBy(() -> callWithSingleProcess(command))
+ .isInstanceOfSatisfying(CliException.class,
+ e ->
assertThat(e.getExitCode()).isEqualTo(CliExitCode.NOT_FOUND))
+ .hasMessageStartingWith("No running Camel integration matches:
payments. camel log payments --previous"
+ + " prints the log of its previous
run, which ended at ")
+ .hasMessageEndingWith(" with exit code 70: Failed to start
route route1 because: boom");
+ }
+
private CamelLogAction command(String... args) throws Exception {
writeStatusFile(TEST_PID, "orders");
CamelLogAction command = new CamelLogAction(new
CamelJBangMain().withPrinter(printer));
@@ -175,6 +253,11 @@ class CamelLogActionTest extends ActionCommandTestSupport {
return command;
}
+ // the log only (stdout), without the highlighting of --find and --grep
+ private List<String> stdoutLines() {
+ return printer.getStdout().lines().map(l ->
l.replaceAll("\u001B\\[[;\\d]*m", "")).toList();
+ }
+
// without the highlighting of --find and --grep
private List<String> lines() throws Exception {
return printer.getLines().stream().map(l ->
l.replaceAll("\u001B\\[[;\\d]*m", "")).toList();
@@ -184,6 +267,13 @@ class CamelLogActionTest extends ActionCommandTestSupport {
Files.write(CommandLineHelper.getCamelDir().resolve(name),
List.of(lines));
}
+ // a run of the name that failed to start
+ private static void keepPreviousRun(String name, String... lines) throws
Exception {
+ Path log = CommandLineHelper.getCamelDir().resolve("4242.log");
+ Files.write(log, List.of(lines));
+ PreviousRun.keep(log, name, 4242, 70, "Failed to start route route1
because: boom");
+ }
+
private static String line(long seconds, String message) {
return line(seconds, "o.a.c.Test", message);
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/DirtyTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/DirtyTest.java
index 0863681675bc..9e1026d7c90e 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/DirtyTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/DirtyTest.java
@@ -102,6 +102,27 @@ class DirtyTest extends ProcessCommandTestSupport {
}
}
+ @Test
+ void testTheLogOfAPreviousRunIsNotDirty() throws Exception {
+ // kept on purpose (CAMEL-25784), and the next run of that name
replaces it
+
Files.writeString(CommandLineHelper.getCamelDir().resolve("orders-previous.log"),
"Started");
+
Files.writeString(CommandLineHelper.getCamelDir().resolve("orders-previous.json"),
"{}");
+ // a name can be a number
+
Files.writeString(CommandLineHelper.getCamelDir().resolve("123-previous.log"),
"Started");
+
+ Dirty command = new Dirty(new CamelJBangMain().withPrinter(printer));
+ command.clean = true;
+
+ try (MockedStatic<ProcessHandle> mocked =
mockStatic(ProcessHandle.class)) {
+ int exit = command.doCall();
+
+ assertEquals(0, exit);
+ assertEquals("", printer.getOutput().trim());
+
assertTrue(Files.exists(CommandLineHelper.getCamelDir().resolve("orders-previous.log")));
+
assertTrue(Files.exists(CommandLineHelper.getCamelDir().resolve("123-previous.log")));
+ }
+ }
+
@Test
void testFileBelongsToTheWholePid() {
// pid 12 does not own the files of pid 123
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PreviousRunTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PreviousRunTest.java
new file mode 100644
index 000000000000..7dfe2d5b71cc
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PreviousRunTest.java
@@ -0,0 +1,309 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.common;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import sun.misc.Signal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25784: the log of a run that ended is kept by the name of the
integration, as kubectl logs --previous shows the
+ * log of the previous instance of a container.
+ */
+class PreviousRunTest {
+
+ // pids no process has (above the highest pid of Linux and macOS)
+ private static final long GONE_PID = 999_999_991L;
+ private static final long GONE_PID_2 = 999_999_992L;
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+ private Path camelDir;
+
+ @BeforeEach
+ void useHome() throws Exception {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ camelDir = CommandLineHelper.getCamelDir();
+ Files.createDirectories(camelDir);
+ }
+
+ @AfterEach
+ void restoreHome() {
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void theLogIsKeptByTheNameOfTheIntegration() throws Exception {
+ Path log = writeLog("123.log", "Starting", "Failed to start route
route1");
+
+ Path kept = PreviousRun.keep(log, "orders", 123, 70, "Failed to start
route route1 because: boom");
+
+ assertThat(kept).isEqualTo(camelDir.resolve("orders-previous.log"));
+ assertThat(log).doesNotExist();
+ assertThat(Files.readAllLines(kept)).containsExactly("Starting",
"Failed to start route route1");
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.pid()).isEqualTo(123);
+ assertThat(info.exitCode()).isEqualTo(70);
+ assertThat(info.failure()).isEqualTo("Failed to start route route1
because: boom");
+ assertThat(info.directory()).isEqualTo(System.getProperty("user.dir"));
+ assertThat(info.started()).isPositive();
+ assertThat(info.ended()).isGreaterThanOrEqualTo(info.started());
+ assertThat(info.describe()).startsWith("The previous run of orders
(pid 123) ended at ")
+ .endsWith(" ago) with exit code 70: Failed to start route
route1 because: boom");
+ }
+
+ @Test
+ void theNextRunThatEndsReplacesIt() throws Exception {
+ PreviousRun.keep(writeLog("123.log", "first run"), "orders", 123, 70,
"boom");
+ PreviousRun.keep(writeLog("456.log", "second run"), "orders", 456, 0,
null);
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.pid()).isEqualTo(456);
+ assertThat(info.exitCode()).isZero();
+ assertThat(info.failure()).isNull();
+ assertThat(info.describe()).endsWith(" with exit code 0");
+ assertThat(Files.readAllLines(info.log())).containsExactly("second
run");
+ }
+
+ @Test
+ void aRunIsFoundAsARunningIntegrationIs() throws Exception {
+ PreviousRun.keep(writeLog("5.log", "orders"), "orders", 5, 0, null);
+ PreviousRun.keep(writeLog("6.log", "orders-eu"), "orders-eu", 6, 0,
null);
+ PreviousRun.keep(writeLog("7.log", "payments"), "payments", 7, 0,
null);
+
+ // the whole name of a run is that run, a part of it a prefix
+
assertThat(names(PreviousRun.find("orders"))).containsExactly("orders");
+
assertThat(names(PreviousRun.find("ord"))).containsExactlyInAnyOrder("orders",
"orders-eu");
+
assertThat(names(PreviousRun.find("orders-eu!"))).containsExactly("orders-eu");
+ assertThat(names(PreviousRun.find("ord!"))).isEmpty();
+
assertThat(names(PreviousRun.find("pay*"))).containsExactly("payments");
+ // a number is the pid it ran as
+ assertThat(names(PreviousRun.find("7"))).containsExactly("payments");
+ assertThat(PreviousRun.find("*")).hasSize(3);
+ assertThat(PreviousRun.find("invoices")).isEmpty();
+ }
+
+ @Test
+ void theNewestRunComesFirst() throws Exception {
+ PreviousRun.keep(writeLog("5.log", "x"), new
PreviousRun.Info("orders", 5, null, 0, 1000, 0, null, null, null));
+ PreviousRun.keep(writeLog("6.log", "x"), new
PreviousRun.Info("payments", 6, null, 0, 3000, 0, null, null, null));
+ PreviousRun.keep(writeLog("7.log", "x"), new
PreviousRun.Info("invoices", 7, null, 0, 2000, 0, null, null, null));
+
+ assertThat(names(PreviousRun.findAll())).containsExactly("payments",
"invoices", "orders");
+ }
+
+ @Test
+ void aLargeLogKeepsItsEndFromTheStartOfALine() throws Exception {
+ Path log = writeLog("123.log", "first line", "second line", "third");
+ Path target = camelDir.resolve("copy.log");
+
+ // 10 bytes from the end starts in the second line
+ PreviousRun.copyEnd(log, target, 10);
+
+ assertThat(Files.readString(target)).isEqualTo("third" +
System.lineSeparator());
+ }
+
+ @Test
+ void theLogOfAKilledProcessIsKeptByTheNameInItsStatus() throws Exception {
+ writeLog(GONE_PID + ".log", "Started", "Processing");
+ writeStatus(GONE_PID, "orders");
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+
+ assertThat(info.pid()).isEqualTo(GONE_PID);
+ assertThat(info.exitCode()).isNull();
+ assertThat(info.failure()).isEqualTo(PreviousRun.GONE);
+ assertThat(info.directory()).isEqualTo("/work/orders");
+ assertThat(Files.readAllLines(info.log())).containsExactly("Started",
"Processing");
+ assertThat(camelDir.resolve(GONE_PID + ".log")).doesNotExist();
+ }
+
+ @Test
+ void theLogOfAKilledProcessOlderThanThePreviousRunIsRemoved() throws
Exception {
+ PreviousRun.keep(writeLog("123.log", "newer run"), "orders", 123, 0,
null);
+ Path old = writeLog(GONE_PID + ".log", "older run");
+ Files.setLastModifiedTime(old, FileTime.from(Instant.now().minus(1,
ChronoUnit.HOURS)));
+ writeStatus(GONE_PID, "orders");
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+
+ assertThat(Files.readAllLines(info.log())).containsExactly("newer
run");
+ assertThat(old).doesNotExist();
+ }
+
+ @Test
+ void theLogOfAKilledProcessIsComparedByTheTimesOfTheFiles() throws
Exception {
+ // a run kept by a JVM whose clock is a day ahead of the file system
(a network file system)
+ long ahead = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1);
+ Path kept = PreviousRun.keep(writeLog("123.log", "older run"),
+ new PreviousRun.Info("orders", 123, null, 0, ahead, 0, null,
null, null));
+ Files.setLastModifiedTime(kept, FileTime.from(Instant.now().minus(2,
ChronoUnit.HOURS)));
+ Path killed = writeLog(GONE_PID + ".log", "newer run, killed");
+ Files.setLastModifiedTime(killed, FileTime.from(Instant.now().minus(1,
ChronoUnit.HOURS)));
+ writeStatus(GONE_PID, "orders");
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+
+ assertThat(info.pid()).isEqualTo(GONE_PID);
+ assertThat(Files.readAllLines(info.log())).containsExactly("newer run,
killed");
+ }
+
+ @Test
+ void aKeepThatFailsLeavesNoInfoOfTheRunBefore() throws Exception {
+ PreviousRun.keep(writeLog("123.log", "first run"), "orders", 123, 0,
null);
+ // the log cannot be written: a directory that is not empty has its
name
+ Path kept = camelDir.resolve("orders-previous.log");
+ Files.delete(kept);
+ Files.createDirectories(kept.resolve("in-the-way"));
+ Path log = writeLog("456.log", "second run");
+
+ assertThat(PreviousRun.keep(log, "orders", 456, 70, "boom")).isNull();
+
+ // what was kept about the first run is not left next to something
else, and the log of the second run stays
+ assertThat(camelDir.resolve("orders-previous.json")).doesNotExist();
+ assertThat(PreviousRun.find("orders")).isEmpty();
+ assertThat(log).exists();
+ }
+
+ @Test
+ void theLogOfAProcessWithoutNameIsRemovedAfterADay() throws Exception {
+ Path old = writeLog(GONE_PID + ".log", "old");
+ Files.setLastModifiedTime(old, FileTime.from(Instant.now().minus(2,
ChronoUnit.DAYS)));
+ Path recent = writeLog(GONE_PID_2 + ".log", "recent");
+ Files.setLastModifiedTime(recent, FileTime.from(Instant.now().minus(1,
ChronoUnit.HOURS)));
+
+ assertThat(PreviousRun.findAll()).isEmpty();
+
+ assertThat(old).doesNotExist();
+ assertThat(recent).exists();
+ }
+
+ @Test
+ void theLogOfARunningProcessIsLeftAlone() throws Exception {
+ long pid = ProcessHandle.current().pid();
+ Path log = writeLog(pid + ".log", "running");
+ Files.setLastModifiedTime(log, FileTime.from(Instant.now().minus(2,
ChronoUnit.DAYS)));
+ writeStatus(pid, "orders");
+
+ assertThat(PreviousRun.findAll()).isEmpty();
+
+ assertThat(log).exists();
+ }
+
+ @Test
+ void aRunThatEndedKeepsItsLogOnce() throws Exception {
+ Path log = writeLog("123.log", "the run");
+ PreviousRun.Keeper keeper = new PreviousRun.Keeper(log, () ->
"orders");
+
+ keeper.ended(0, null);
+ // a log of the same name afterwards (such as another run in this JVM)
is not kept as the JVM exits
+ writeLog("123.log", "after the run");
+ keeper.exiting(0);
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.pid()).isEqualTo(ProcessHandle.current().pid());
+ assertThat(info.exitCode()).isZero();
+ assertThat(Files.readAllLines(info.log())).containsExactly("the run");
+ }
+
+ @Test
+ void aRunThatASignalStoppedEndsWithTheExitCodeOfTheJvm() throws Exception {
+ Path log = writeLog("123.log", "the run");
+ PreviousRun.Keeper keeper = new PreviousRun.Keeper(log, () ->
"orders");
+ // SIGTERM, on which the JVM exits with 128 + the number of the signal
+ keeper.stoppedBy(new Signal("TERM"));
+
+ // Camel stopped as it should, so the run answers 0
+ keeper.ended(0, null);
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.exitCode()).isEqualTo(128 + new
Signal("TERM").getNumber());
+ assertThat(info.signal()).isEqualTo("SIGTERM");
+ assertThat(info.describe()).endsWith(" with exit code " +
info.exitCode() + " (SIGTERM)");
+ }
+
+ @Test
+ void aJvmThatExitsKeepsTheLogOfARunThatDidNotEnd() throws Exception {
+ Path log = writeLog("123.log", "the run");
+ PreviousRun.Keeper keeper = new PreviousRun.Keeper(log, () ->
"orders");
+ try {
+ // the run gets a few seconds to end: not in a test
+ keeper.exiting(0);
+
+ PreviousRun.Info info = only(PreviousRun.find("orders"));
+ assertThat(info.exitCode()).isNull();
+ assertThat(Files.readAllLines(info.log())).containsExactly("the
run");
+ } finally {
+ keeper.ended(null, null);
+ }
+ }
+
+ @Test
+ void aNameIsAFileName() {
+
assertThat(PreviousRun.fileName("orders-eu.v2")).isEqualTo("orders-eu.v2");
+ assertThat(PreviousRun.fileName("my app/1")).isEqualTo("my_app_1");
+ assertThat(PreviousRun.fileName("..")).isEqualTo("_..");
+ }
+
+ @Test
+ void theFilesOfAPreviousRun() {
+
assertThat(PreviousRun.isPreviousRunFile("orders-previous.log")).isTrue();
+
assertThat(PreviousRun.isPreviousRunFile("orders-previous.json")).isTrue();
+ assertThat(PreviousRun.isPreviousRunFile("123.log")).isFalse();
+ assertThat(PreviousRun.isPreviousRunFile("123-status.json")).isFalse();
+ }
+
+ private Path writeLog(String name, String... lines) throws Exception {
+ return Files.write(camelDir.resolve(name), List.of(lines));
+ }
+
+ private void writeStatus(long pid, String name) throws Exception {
+ JsonObject context = new JsonObject();
+ context.put("name", name);
+ JsonObject runtime = new JsonObject();
+ runtime.put("directory", "/work/" + name);
+ JsonObject root = new JsonObject();
+ root.put("context", context);
+ root.put("runtime", runtime);
+ Files.writeString(camelDir.resolve(pid + "-status.json"),
root.toJson());
+ }
+
+ private static PreviousRun.Info only(List<PreviousRun.Info> found) {
+ assertThat(found).hasSize(1);
+ return found.get(0);
+ }
+
+ private static List<String> names(List<PreviousRun.Info> found) {
+ return found.stream().map(PreviousRun.Info::name).toList();
+ }
+}