diff --git a/core/src/main/java/org/apache/accumulo/core/cli/CommandOutputEnvelope.java b/core/src/main/java/org/apache/accumulo/core/cli/CommandOutputEnvelope.java
new file mode 100644
index 00000000000..c9e12b6b2db
--- /dev/null
+++ b/core/src/main/java/org/apache/accumulo/core/cli/CommandOutputEnvelope.java
@@ -0,0 +1,130 @@
+/*
+ * 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
+ *
+ * https://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.accumulo.core.cli;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+import com.google.gson.Gson;
+
+/**
+ * A stable, versioned outer wrapper for all admin command JSON output.
+ *
+ *
+ * Every command that supports --json output wraps its command-specific data in this envelope. This
+ * provides a consistent structure that scripts can rely on regardless of which command produced the
+ * output:
+ *
+ *
+ * {
+ * "command": "accumulo admin fate --summary",
+ * "version": "1",
+ * "reportTime": "2026-06-04T12:00:00Z",
+ * "status": "OK"
+ * },
+ * "output": { ... command-specific payload... }
+ * }
+ *
+ *
+ *
+ * The {@link CommandStatus#version} field is a stability contract. When a breaking change is made
+ * to the envelope structure, the version will be incremented. Scripts should check this field and
+ * handle the version they were written against.
+ *
+ */
+public class CommandOutputEnvelope {
+
+ /**
+ * Current envelop schema version. Increment this if a breaking structural change is made to the
+ * envelope fields (not to the {@code output} field, data changes command specific).
+ */
+ public static final String VERSION = "1.0";
+ private static final DateTimeFormatter ISO_FMT = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+ private static final Gson PRETTY_GSON =
+ new Gson().newBuilder().setPrettyPrinting().disableJdkUnsafe().create();
+
+ public static class CommandStatus {
+ private String command;
+ private String version;
+ private String reportTime;
+ private String statusMessage;
+
+ @SuppressWarnings("unused")
+ private CommandStatus() {}
+
+ private CommandStatus(String command, String statusMessage) {
+ this.command = command;
+ this.version = VERSION;
+ this.reportTime = ISO_FMT.format(ZonedDateTime.now(ZoneId.systemDefault()));
+ this.statusMessage = statusMessage;
+ }
+
+ public String getCommand() {
+ return command;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public String getReportTime() {
+ return reportTime;
+ }
+
+ public String getStatusMessage() {
+ return statusMessage;
+ }
+ }
+
+ private CommandStatus status;
+ private Object output;
+
+ @SuppressWarnings("unused")
+ private CommandOutputEnvelope() {}
+
+ private CommandOutputEnvelope(String command, String statusMessage, Object output) {
+ this.status = new CommandStatus(command, statusMessage);
+ this.output = output;
+ }
+
+ public static CommandOutputEnvelope of(String command, Object data) {
+ return new CommandOutputEnvelope(command, "OK", data);
+ }
+
+ public static CommandOutputEnvelope error(String command, String message) {
+ return new CommandOutputEnvelope(command, "ERROR" + message, null);
+ }
+
+ public String toJson() {
+ return PRETTY_GSON.toJson(this);
+ }
+
+ public static CommandOutputEnvelope fromJson(String json) {
+ return PRETTY_GSON.fromJson(json, CommandOutputEnvelope.class);
+ }
+
+ public CommandStatus getStatus() {
+ return status;
+ }
+
+ public Object getOutput() {
+ return output;
+ }
+}
diff --git a/core/src/main/java/org/apache/accumulo/core/cli/CommandReport.java b/core/src/main/java/org/apache/accumulo/core/cli/CommandReport.java
new file mode 100644
index 00000000000..ba33faa578e
--- /dev/null
+++ b/core/src/main/java/org/apache/accumulo/core/cli/CommandReport.java
@@ -0,0 +1,51 @@
+/*
+ * 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
+ *
+ * https://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.accumulo.core.cli;
+
+import java.util.List;
+
+/**
+ * Implemented by all command report classes that support both human-readable and computer readable
+ * outputs.
+ *
+ * Json output is always wrapped in a {@link CommandOutputEnvelope} to provide a stable, versioned
+ * outer structure that scripts can depend on, regardless of which command is used.
+ *
+ *
+ * Usage pattern is a command's execute() method:
+ *
+ *
+ * CommandReport report = buildReport(context, options);
+ * if (options.json()) {
+ * System.out.println(report.toEnvelopedJson("accumulo admin 'my-command'"));
+ * } else {
+ * report.formatLines().forEach(System.out::println);
+ * }
+ *
+ */
+public interface CommandReport {
+ List formatLines();
+
+ Object getData();
+
+ default String toEnvelopedJson(String commandName) {
+ return CommandOutputEnvelope.of(commandName, getData()).toJson();
+ }
+
+}
diff --git a/core/src/main/java/org/apache/accumulo/core/cli/ServerOpts.java b/core/src/main/java/org/apache/accumulo/core/cli/ServerOpts.java
index 4d8b39e191a..d0a4c387143 100644
--- a/core/src/main/java/org/apache/accumulo/core/cli/ServerOpts.java
+++ b/core/src/main/java/org/apache/accumulo/core/cli/ServerOpts.java
@@ -53,6 +53,10 @@ public List split(String value) {
+ " Expected format: -o = [-o =]")
private List overrides = new ArrayList<>();
+ @Parameter(names = {"-j", "--json"},
+ description = "Print output in JSON format. Output is wrapped in standard envelope with command, version, reportTime, status and data fields.")
+ public boolean json = false;
+
private SiteConfiguration siteConfig = null;
public synchronized SiteConfiguration getSiteConfiguration() {
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/ServerKeywordExecutable.java b/server/base/src/main/java/org/apache/accumulo/server/util/ServerKeywordExecutable.java
index 04251f250df..38f6b2524fd 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/ServerKeywordExecutable.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/ServerKeywordExecutable.java
@@ -19,6 +19,7 @@
package org.apache.accumulo.server.util;
import org.apache.accumulo.core.cli.BaseKeywordExecutable;
+import org.apache.accumulo.core.cli.CommandOutputEnvelope;
import org.apache.accumulo.core.cli.ServerOpts;
import org.apache.accumulo.core.conf.AccumuloConfiguration;
import org.apache.accumulo.core.conf.Property;
@@ -49,6 +50,11 @@ public synchronized ServerContext getServerContext() {
return context;
}
+ protected String getInvokeCommand() {
+ String group = commandGroup().key();
+ return "accumulo" + (group.isBlank() ? "" : " " + group) + " " + keyword();
+ }
+
@Override
public void doExecute(JCommander cl, OPTS options) throws Exception {
// Login as the server on secure HDFS
@@ -58,6 +64,12 @@ public void doExecute(JCommander cl, OPTS options) throws Exception {
SecurityUtil.serverLogin(conf);
}
execute(cl, options);
+ } catch (Exception e) {
+ if (options.json) {
+ System.out
+ .println(CommandOutputEnvelope.error(getInvokeCommand(), e.getMessage()).toJson());
+ }
+ throw e;
}
}
}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/adminCommand/Fate.java b/server/base/src/main/java/org/apache/accumulo/server/util/adminCommand/Fate.java
index a3caf0f9757..c53cdba7410 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/adminCommand/Fate.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/adminCommand/Fate.java
@@ -137,10 +137,6 @@ static class FateOpts extends ServerOpts {
description = "[...] Print a summary of FaTE transactions. Print only the FateId's specified or print all transactions if empty. Use -s to only print those with certain states. Use -t to only print those with certain FateInstanceTypes. Use -j to print the transactions in json.")
boolean summarize;
- @Parameter(names = {"-j", "--json"},
- description = "Print transactions in json. Only useful for --summary command.")
- boolean printJson;
-
@Parameter(names = {"-s", "--state"},
description = "... Print transactions in the state(s) {NEW, IN_PROGRESS, FAILED_IN_PROGRESS, FAILED, SUCCESSFUL}")
List states = new ArrayList<>();
@@ -383,8 +379,8 @@ private void summarizeFateTx(ServerContext context, FateOpts cmd, AdminUtil
static class ServiceStatusCmdOpts extends ServerOpts {
- @Parameter(names = "--json", description = "provide output in json format")
- boolean json = false;
-
@Parameter(names = "--showHosts",
description = "provide a summary of service counts with host details")
boolean showHosts = false;
@@ -106,17 +103,15 @@ public void execute(JCommander cl, ServiceStatusCmdOpts options) throws Exceptio
ServiceStatusReport report = new ServiceStatusReport(services, options.showHosts);
if (options.json) {
- System.out.println(report.toJson());
+ System.out.println(report.toEnvelopedJson(getInvokeCommand()));
} else {
- StringBuilder sb = new StringBuilder(8192);
- report.report(sb);
- System.out.println(sb);
+ report.formatLines().forEach(System.out::println);
}
}
/**
- * The manager paths in ZooKeeper are: {@code /accumulo/[IID]/managers/lock/zlock#[NUM]} with the
- * lock data providing a service descriptor with host and port.
+ * op The manager paths in ZooKeeper are: {@code /accumulo/[IID]/managers/lock/zlock#[NUM]} with
+ * the lock data providing a service descriptor with host and port.
*/
@VisibleForTesting
StatusSummary getManagerStatus(ServerContext context) {
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/fateCommand/FateSummaryReport.java b/server/base/src/main/java/org/apache/accumulo/server/util/fateCommand/FateSummaryReport.java
index 704536cd955..e20b276fe3b 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/fateCommand/FateSummaryReport.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/fateCommand/FateSummaryReport.java
@@ -32,6 +32,7 @@
import java.util.TreeMap;
import java.util.TreeSet;
+import org.apache.accumulo.core.cli.CommandReport;
import org.apache.accumulo.core.fate.AdminUtil;
import org.apache.accumulo.core.fate.Fate;
import org.apache.accumulo.core.fate.FateId;
@@ -41,7 +42,7 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
-public class FateSummaryReport {
+public class FateSummaryReport implements CommandReport {
private Map statusCounts = new TreeMap<>();
private Map cmdCounts = new TreeMap<>();
@@ -154,6 +155,7 @@ public static FateSummaryReport fromJson(final String jsonString) {
*
* @return formatted report lines.
*/
+ @Override
public List formatLines() {
List lines = new ArrayList<>();
@@ -185,4 +187,9 @@ public List formatLines() {
return lines;
}
+
+ @Override
+ public Object getData() {
+ return this;
+ }
}
diff --git a/server/base/src/main/java/org/apache/accumulo/server/util/serviceStatus/ServiceStatusReport.java b/server/base/src/main/java/org/apache/accumulo/server/util/serviceStatus/ServiceStatusReport.java
index 0c628cd6046..8547e02d2f3 100644
--- a/server/base/src/main/java/org/apache/accumulo/server/util/serviceStatus/ServiceStatusReport.java
+++ b/server/base/src/main/java/org/apache/accumulo/server/util/serviceStatus/ServiceStatusReport.java
@@ -25,9 +25,12 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.apache.accumulo.core.cli.CommandReport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -39,7 +42,19 @@
/**
* Wrapper for JSON formatted report.
*/
-public class ServiceStatusReport {
+public class ServiceStatusReport implements CommandReport {
+
+ @Override
+ public List formatLines() {
+ StringBuilder sb = new StringBuilder(8192);
+ report(sb);
+ return Arrays.asList(sb.toString().split("\n"));
+ }
+
+ @Override
+ public Object getData() {
+ return this;
+ }
private static class HostExclusionStrategy implements ExclusionStrategy {
diff --git a/test/src/main/java/org/apache/accumulo/test/fate/FateOpsCommandsITBase.java b/test/src/main/java/org/apache/accumulo/test/fate/FateOpsCommandsITBase.java
index 5fb90e60c86..a5980168cdf 100644
--- a/test/src/main/java/org/apache/accumulo/test/fate/FateOpsCommandsITBase.java
+++ b/test/src/main/java/org/apache/accumulo/test/fate/FateOpsCommandsITBase.java
@@ -47,6 +47,7 @@
import java.util.function.Predicate;
import java.util.stream.Collectors;
+import org.apache.accumulo.core.cli.CommandOutputEnvelope;
import org.apache.accumulo.core.client.Accumulo;
import org.apache.accumulo.core.client.AccumuloClient;
import org.apache.accumulo.core.client.IteratorSetting;
@@ -88,6 +89,9 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
public abstract class FateOpsCommandsITBase extends SharedMiniClusterBase
implements FateTestRunner {
private static final Logger log = LoggerFactory.getLogger(FateOpsCommandsITBase.class);
@@ -134,7 +138,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
String result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- FateSummaryReport report = FateSummaryReport.fromJson(result);
+ FateSummaryReport report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertTrue(report.getStatusCounts().isEmpty());
@@ -157,7 +161,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -179,7 +183,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -198,7 +202,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -218,7 +222,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -241,7 +245,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -259,7 +263,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -281,7 +285,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -303,7 +307,7 @@ protected void testFateSummaryCommand(FateStore store, ServerConte
result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- report = FateSummaryReport.fromJson(result);
+ report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
assertNotEquals(0, report.getReportTime());
assertFalse(report.getStatusCounts().isEmpty());
@@ -516,7 +520,7 @@ protected void testTransactionNameAndStep(FateStore store, ServerC
String result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- FateSummaryReport report = FateSummaryReport.fromJson(result);
+ FateSummaryReport report = parseFateSummaryFromEnvelope(result);
// Validate transaction name and transaction step from summary command
@@ -904,7 +908,7 @@ private Map getFateIdsFromSummary() throws Exception {
String result = p.readStdOut();
result = result.lines().filter(line -> !line.matches(".*(INFO|DEBUG|WARN|ERROR).*"))
.collect(Collectors.joining("\n"));
- FateSummaryReport report = FateSummaryReport.fromJson(result);
+ FateSummaryReport report = parseFateSummaryFromEnvelope(result);
assertNotNull(report);
Map fateIdToStatus = new HashMap<>();
report.getFateDetails().forEach((d) -> {
@@ -990,4 +994,18 @@ protected void cleanupFateOps() throws Exception {
args.toArray(new String[0]));
assertEquals(0, p.getProcess().waitFor());
}
+
+ private FateSummaryReport parseFateSummaryFromEnvelope(String json) {
+ CommandOutputEnvelope envelope = CommandOutputEnvelope.fromJson(json);
+ assertNotNull(envelope);
+ assertNotNull(envelope.getStatus());
+ assertEquals(CommandOutputEnvelope.VERSION, envelope.getStatus().getVersion());
+ assertEquals("OK", envelope.getStatus().getStatusMessage());
+ assertNotNull(envelope.getStatus().getReportTime());
+ assertNotNull(envelope.getStatus().getCommand());
+ assertTrue(envelope.getStatus().getCommand().contains("fate"));
+ Gson gson = new GsonBuilder().disableJdkUnsafe().create();
+ String dataJson = gson.toJson(envelope.getOutput());
+ return FateSummaryReport.fromJson(dataJson);
+ }
}