diff --git a/doc/appendices/command-line/traffic_ctl.en.rst b/doc/appendices/command-line/traffic_ctl.en.rst index d6f933188fe..156c80f4f99 100644 --- a/doc/appendices/command-line/traffic_ctl.en.rst +++ b/doc/appendices/command-line/traffic_ctl.en.rst @@ -114,6 +114,48 @@ Options Path to the runroot file. +.. option:: -e, --error-level + + Set the minimum severity that causes a non-zero exit code. When the server returns an error + response with annotated data entries, ``traffic_ctl`` inspects the ``severity`` of each + annotation and compares the highest severity against this threshold. If the highest severity + is **at or above** the threshold, the process exits with code ``2``; otherwise it exits ``0``. + + Accepted values (case-insensitive): ``diag``, ``debug``, ``status``, ``note``, ``warn``, + ``error``, ``fatal``, ``alert``, ``emergency``. + + Default: ``error``. + + This option only affects error responses that contain annotated data entries (i.e., entries + in the ``data`` array with a ``severity`` field). + + .. note:: + + Protocol-level JSONRPC errors (e.g., ``-32601 Method not found``, ``-32600 Invalid + Request``) and application errors with no ``data`` entries **always exit** ``2`` regardless + of ``--error-level``. These errors indicate a communication or dispatch failure — not an + application-level condition — and are therefore unconditionally treated as hard errors. + + Examples: + + .. code-block:: bash + + # Default: annotations without explicit severity default to DIAG. + # DIAG < ERROR, so exit 0. + $ traffic_ctl server drain # "already draining" is DIAG + $ echo $? + 0 + + # Treat any annotation (including DIAG) as an error + $ traffic_ctl --error-level=diag server drain + $ echo $? + 2 + + # Only fail on fatal or above + $ traffic_ctl --error-level=fatal server drain + $ echo $? + 0 + Subcommands =========== @@ -1299,13 +1341,18 @@ Exit Codes :program:`traffic_ctl` uses the following exit codes: ``0`` - Success. The requested operation completed successfully. + Success. The requested operation completed successfully, or the server returned an error + whose annotations all have severity below the ``--error-level`` threshold (default: ``error``). ``2`` Error. The operation failed. This may be returned when: - The RPC communication with :program:`traffic_server` failed (e.g. socket not found or connection refused). - - The server response contains an error (e.g. invalid record name, malformed request). + - The server response is a protocol-level JSONRPC error (e.g. ``-32601 Method not found``) or + an application error with no ``data`` entries. These **always** exit ``2`` regardless of + ``--error-level``. + - The server response contains data annotations whose highest severity is at or above the + ``--error-level`` threshold. ``3`` Unimplemented. The requested command is not yet implemented. diff --git a/doc/developer-guide/jsonrpc/jsonrpc-node-errors.en.rst b/doc/developer-guide/jsonrpc/jsonrpc-node-errors.en.rst index 079fe548020..7bcaf39d86b 100644 --- a/doc/developer-guide/jsonrpc/jsonrpc-node-errors.en.rst +++ b/doc/developer-guide/jsonrpc/jsonrpc-node-errors.en.rst @@ -49,7 +49,8 @@ different set of errors in the following format: "jsonrpc": "2.0" } -In some cases the data field could be populated: +In some cases the data field could be populated. Each entry in the ``data`` array contains a +``code``, a ``severity``, and a ``message``: .. code-block:: json @@ -61,6 +62,7 @@ In some cases the data field could be populated: "data":[ { "code": 2, + "severity": 5, "message":"Denied privileged API access for uid=XXX gid=XXX" } ] @@ -68,6 +70,27 @@ In some cases the data field could be populated: "id":"5e273ec0-3e3b-4a81-90ec-aeee3d38073f" } +The ``severity`` field is an integer that corresponds to the ``swoc::Errata::Severity`` levels. +It is always present in the response. If the handler does not set an explicit severity for an +annotation, the server defaults to ``0`` (Diag). + +==== =========== +Code Severity +==== =========== +0 Diag +1 Debug +2 Status +3 Note +4 Warn +5 Error +6 Fatal +7 Alert +8 Emergency +==== =========== + +The ``severity`` field is used by :program:`traffic_ctl` to determine the exit code via the +``--error-level`` option. See :ref:`traffic_ctl_jsonrpc` for details. + .. _jsonrpc-node-errors-standard-errors: @@ -128,6 +151,7 @@ Under this error, the `data` field could be populated with the following errors, "data":[ { "code":2, + "severity": 5, "message":"Denied privileged API access for uid=XXX gid=XXX" } ] diff --git a/include/mgmt/rpc/jsonrpc/json/YAMLCodec.h b/include/mgmt/rpc/jsonrpc/json/YAMLCodec.h index cc29dfe2dd9..65536682be5 100644 --- a/include/mgmt/rpc/jsonrpc/json/YAMLCodec.h +++ b/include/mgmt/rpc/jsonrpc/json/YAMLCodec.h @@ -195,6 +195,9 @@ class yamlcpp_json_encoder for (auto const &err : errata) { json << YAML::BeginMap; json << YAML::Key << "code" << YAML::Value << errata.code().value(); + // Default to DIAG for annotations without explicit severity — this is a wire format + // concern, not stored in the Errata model nor in the RPC error model. + json << YAML::Key << "severity" << YAML::Value << static_cast(err.has_severity() ? err.severity() : ERRATA_DIAG); json << YAML::Key << "message" << YAML::Value << std::string{err.text().data(), err.text().size()}; json << YAML::EndMap; } diff --git a/include/shared/rpc/RPCRequests.h b/include/shared/rpc/RPCRequests.h index 0a047d529dd..77f27555385 100644 --- a/include/shared/rpc/RPCRequests.h +++ b/include/shared/rpc/RPCRequests.h @@ -22,6 +22,7 @@ #include #include #include "tsutil/ts_bw_format.h" +#include "tsutil/ts_errata.h" #include #include @@ -68,9 +69,13 @@ struct JSONRPCResponse { struct JSONRPCError { int32_t code; //!< High level error code. std::string message; //!< High level message - // the following data is defined by TS, it will be a key/value pair. - std::vector> data; - friend std::ostream &operator<<(std::ostream &os, const JSONRPCError &err); + struct DataEntry { + int32_t code; + int32_t severity{0}; //!< Per-annotation severity, defaults to DIAG (0). + std::string message; + }; + std::vector data; + friend std::ostream &operator<<(std::ostream &os, const JSONRPCError &err); }; /** @@ -206,9 +211,16 @@ inline std::ostream & operator<<(std::ostream &os, const JSONRPCError &err) { os << "Server Error found:\n"; - os << "[" << err.code << "] " << err.message << '\n'; - for (auto &&[code, message] : err.data) { - os << "- [" << code << "] " << message << '\n'; + os << "[" << err.code << "] " << err.message; + if (!err.data.empty()) { + os << " [code: " << err.data[0].code << "]"; + } + os << '\n'; + + for (auto const &entry : err.data) { + auto sev = static_cast(entry.severity); + swoc::TextView name = sev < Severity_Names.size() ? Severity_Names[sev] : "Unknown"; + os << "- " << name << ": " << entry.message << '\n'; } return os; diff --git a/include/shared/rpc/yaml_codecs.h b/include/shared/rpc/yaml_codecs.h index d719973f312..50b4ecf6983 100644 --- a/include/shared/rpc/yaml_codecs.h +++ b/include/shared/rpc/yaml_codecs.h @@ -65,7 +65,11 @@ template <> struct convert { error.message = helper::try_extract(node, "message"); if (auto data = node["data"]) { for (auto &&err : data) { - error.data.emplace_back(helper::try_extract(err, "code"), helper::try_extract(err, "message")); + shared::rpc::JSONRPCError::DataEntry entry; + entry.code = helper::try_extract(err, "code"); + entry.severity = helper::try_extract(err, "severity", false, 0); + entry.message = helper::try_extract(err, "message"); + error.data.push_back(std::move(entry)); } } return true; diff --git a/src/mgmt/rpc/jsonrpc/unit_tests/test_basic_protocol.cc b/src/mgmt/rpc/jsonrpc/unit_tests/test_basic_protocol.cc index f5d8e525c17..74490677509 100644 --- a/src/mgmt/rpc/jsonrpc/unit_tests/test_basic_protocol.cc +++ b/src/mgmt/rpc/jsonrpc/unit_tests/test_basic_protocol.cc @@ -21,6 +21,7 @@ #include /* catch unit-test framework */ #include +#include #include "mgmt/rpc/jsonrpc/JsonRPCManager.h" #include "mgmt/rpc/jsonrpc/JsonRPC.h" @@ -79,6 +80,14 @@ test_callback_ok_or_error(std::string_view const & /* id ATS_UNUSED */, YAML::No return resp; } +inline swoc::Rv +test_callback_with_severity(std::string_view const & /* id ATS_UNUSED */, YAML::Node const & /* params ATS_UNUSED */) +{ + swoc::Rv resp; + resp.errata().assign(ERR1).note(ERRATA_WARN, "this is a warning").note("this has no severity"); + return resp; +} + static int notificationCallCount{0}; inline void test_nofitication(YAML::Node const & /* params ATS_UNUSED */) @@ -140,7 +149,7 @@ TEST_CASE("Register/call method - respond with errors (data field)", "[method][e R"({"jsonrpc": "2.0", "method": "test_callback_ok_or_error", "params": {"return_error": "yes"}, "id": "14"})"); REQUIRE(json); const std::string_view expected = - R"({"jsonrpc": "2.0", "error": {"code": 9, "message": "Error during execution", "data": [{"code": 9999, "message": "Just an error message to add more meaning to the failure"}]}, "id": "14"})"; + R"({"jsonrpc": "2.0", "error": {"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 0, "message": "Just an error message to add more meaning to the failure"}]}, "id": "14"})"; REQUIRE(*json == expected); } } @@ -184,7 +193,7 @@ TEST_CASE("Basic test, batch calls", "[methods][notifications]") REQUIRE(resp1); const std::string_view expected = - R"([{"jsonrpc": "2.0", "result": {"ran": "ok"}, "id": "13"}, {"jsonrpc": "2.0", "error": {"code": 9, "message": "Error during execution", "data": [{"code": 9999, "message": "Just an error message to add more meaning to the failure"}]}, "id": "14"}])"; + R"([{"jsonrpc": "2.0", "result": {"ran": "ok"}, "id": "13"}, {"jsonrpc": "2.0", "error": {"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 0, "message": "Just an error message to add more meaning to the failure"}]}, "id": "14"}])"; REQUIRE(*resp1 == expected); } } @@ -608,3 +617,19 @@ TEST_CASE("Call method with invalid ID", "[invalid_id]") REQUIRE(*resp == expected); } } + +TEST_CASE("Severity field in error data entries", "[severity]") +{ + JsonRpcUnitTest rpc; + + SECTION("Annotation with severity emits it, annotation without defaults to DIAG (0)") + { + REQUIRE(rpc.add_method_handler("test_callback_with_severity", &test_callback_with_severity)); + + const auto json = rpc.handle_call(R"({"jsonrpc": "2.0", "method": "test_callback_with_severity", "params": {}, "id": "50"})"); + REQUIRE(json); + const std::string_view expected = + R"({"jsonrpc": "2.0", "error": {"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 4, "message": "this is a warning"}, {"code": 9999, "severity": 0, "message": "this has no severity"}]}, "id": "50"})"; + REQUIRE(*json == expected); + } +} diff --git a/src/traffic_ctl/CMakeLists.txt b/src/traffic_ctl/CMakeLists.txt index c967d42e9c9..5d6c57c8854 100644 --- a/src/traffic_ctl/CMakeLists.txt +++ b/src/traffic_ctl/CMakeLists.txt @@ -32,3 +32,12 @@ target_link_libraries(traffic_ctl ts::tscore ts::config libswoc::libswoc yaml-cp install(TARGETS traffic_ctl) clang_tidy_check(traffic_ctl) + +if(BUILD_TESTING) + add_executable(test_traffic_ctl_status unit_tests/test_traffic_ctl_status.cc) + target_include_directories(test_traffic_ctl_status PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries( + test_traffic_ctl_status Catch2::Catch2WithMain ts::tscore ts::tsutil libswoc::libswoc yaml-cpp::yaml-cpp + ) + add_catch2_test(NAME test_traffic_ctl_status COMMAND test_traffic_ctl_status) +endif() diff --git a/src/traffic_ctl/CtrlPrinters.cc b/src/traffic_ctl/CtrlPrinters.cc index 8e82fe77a9b..61a1888ed43 100644 --- a/src/traffic_ctl/CtrlPrinters.cc +++ b/src/traffic_ctl/CtrlPrinters.cc @@ -67,7 +67,7 @@ BasePrinter::write_output(shared::rpc::JSONRPCResponse const &response) } if (response.is_error()) { - App_Exit_Status_Code = CTRL_EX_ERROR; // Set the exit code to error, so we can return it later. + App_Exit_Status_Code = appExitCodeFromResponse(response); // If an error is present, then as per the specs we can ignore the jsonrpc.result field, // so we print the error and we are done here! diff --git a/src/traffic_ctl/TrafficCtlStatus.h b/src/traffic_ctl/TrafficCtlStatus.h index 61e84a80542..c570b992b77 100644 --- a/src/traffic_ctl/TrafficCtlStatus.h +++ b/src/traffic_ctl/TrafficCtlStatus.h @@ -20,10 +20,37 @@ limitations under the License. */ #pragma once +#include + +#include "shared/rpc/RPCRequests.h" +#include "shared/rpc/yaml_codecs.h" +#include "tsutil/ts_errata.h" + constexpr int CTRL_EX_OK = 0; // EXIT_FAILURE can also be used. constexpr int CTRL_EX_ERROR = 2; constexpr int CTRL_EX_UNIMPLEMENTED = 3; constexpr int CTRL_EX_TEMPFAIL = 75; ///< Temporary failure — operation in progress, retry later (EX_TEMPFAIL from sysexits.h). -extern int App_Exit_Status_Code; //!< Global variable to store the exit status code of the application. +extern int App_Exit_Status_Code; //!< Global variable to store the exit status code of the application. +extern swoc::Errata::severity_type App_Exit_Level_Error; //!< Minimum severity to treat as error for exit status. + +inline int +appExitCodeFromResponse(const shared::rpc::JSONRPCResponse &response) +{ + if (!response.is_error()) { + return CTRL_EX_OK; + } + + auto err = response.error.as(); + if (err.data.empty()) { + return CTRL_EX_ERROR; + } + + auto effective_severity = [](auto const &e) { return swoc::Errata::severity_type(e.severity); }; + + auto it = std::max_element(err.data.begin(), err.data.end(), + [&](auto const &a, auto const &b) { return effective_severity(a) < effective_severity(b); }); + + return effective_severity(*it) >= App_Exit_Level_Error ? CTRL_EX_ERROR : CTRL_EX_OK; +} diff --git a/src/traffic_ctl/traffic_ctl.cc b/src/traffic_ctl/traffic_ctl.cc index 1532e465005..1496f467802 100644 --- a/src/traffic_ctl/traffic_ctl.cc +++ b/src/traffic_ctl/traffic_ctl.cc @@ -21,6 +21,7 @@ limitations under the License. */ +#include #include #include @@ -35,9 +36,10 @@ #include "FileConfigCommand.h" #include "SSLMultiCertCommand.h" #include "TrafficCtlStatus.h" +#include "tsutil/ts_errata.h" -// Define the global variable -int App_Exit_Status_Code = CTRL_EX_OK; // Initialize it to a default value +int App_Exit_Status_Code = CTRL_EX_OK; +swoc::Errata::severity_type App_Exit_Level_Error = ERRATA_ERROR; namespace { void @@ -91,7 +93,10 @@ main([[maybe_unused]] int argc, const char **argv) .add_option("--format", "-f", "Use a specific output format {json|rpc}", "", 1, "", "format") .add_option("--read-timeout-ms", "", "Read timeout for RPC (in milliseconds)", "", 1, "10000", "read-timeout") .add_option("--read-attempts", "", "Read attempts for RPC", "", 1, "100", "read-attempts") - .add_option("--watch", "-w", "Execute a program periodically. Watch interval(in seconds) can be passed.", "", 1, "-1", "watch"); + .add_option("--watch", "-w", "Execute a program periodically. Watch interval(in seconds) can be passed.", "", 1, "-1", "watch") + .add_option("--error-level", "-e", + "Minimum severity to treat as error for exit status {diag|debug|status|note|warn|error|fatal|alert|emergency}", "", + 1, "error", "error-level"); auto &config_command = parser.add_command("config", "Manipulate configuration records").require_commands(); auto &metric_command = parser.add_command("metric", "Manipulate performance metrics").require_commands(); @@ -338,6 +343,15 @@ main([[maybe_unused]] int argc, const char **argv) signal_register_handler(SIGINT, handle_signal); auto args = parser.parse(argv); + + auto error_level_str = args.get("error-level").value(); + auto it = + std::find_if(Severity_Names.begin(), Severity_Names.end(), [&](auto name) { return strcasecmp(name, error_level_str) == 0; }); + if (it == Severity_Names.end()) { + throw std::runtime_error(std::string("Unknown error level: ") + std::string(error_level_str)); + } + App_Exit_Level_Error = swoc::Errata::severity_type(std::distance(Severity_Names.begin(), it)); + argparser_runroot_handler(args.get("run-root").value(), argv[0]); Layout::create(); diff --git a/src/traffic_ctl/unit_tests/test_traffic_ctl_status.cc b/src/traffic_ctl/unit_tests/test_traffic_ctl_status.cc new file mode 100644 index 00000000000..89f9309cdbb --- /dev/null +++ b/src/traffic_ctl/unit_tests/test_traffic_ctl_status.cc @@ -0,0 +1,303 @@ +/** @file + + Unit tests for appExitCodeFromResponse and related exit-code logic. + + @section license License + + 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. + */ + +#include + +#include "TrafficCtlStatus.h" +#include "shared/rpc/yaml_codecs.h" +#include "tsutil/ts_errata.h" + +int App_Exit_Status_Code = CTRL_EX_OK; +swoc::Errata::severity_type App_Exit_Level_Error = ERRATA_ERROR; + +namespace +{ + +shared::rpc::JSONRPCResponse +make_success_response() +{ + shared::rpc::JSONRPCResponse resp; + resp.result = YAML::Load(R"({"status": "ok"})"); + return resp; +} + +shared::rpc::JSONRPCResponse +make_error_response(std::string_view json_error) +{ + shared::rpc::JSONRPCResponse resp; + resp.error = YAML::Load(std::string{json_error}); + return resp; +} + +} // namespace + +// --------------------------------------------------------------------------- +// appExitCodeFromResponse +// --------------------------------------------------------------------------- + +TEST_CASE("appExitCodeFromResponse - success response", "[exit_code]") +{ + App_Exit_Level_Error = ERRATA_ERROR; + auto resp = make_success_response(); + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); +} + +TEST_CASE("appExitCodeFromResponse - error with empty data", "[exit_code]") +{ + App_Exit_Level_Error = ERRATA_ERROR; + auto resp = make_error_response(R"({"code": 9, "message": "Error during execution"})"); + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); +} + +TEST_CASE("appExitCodeFromResponse - annotation without severity defaults to DIAG", "[exit_code]") +{ + auto resp = make_error_response( + R"({"code": 9, "message": "Error during execution", "data": [{"code": 9999, "message": "something went wrong"}]})"); + + SECTION("default threshold (ERROR) - DIAG < ERROR, exit 0") + { + App_Exit_Level_Error = ERRATA_ERROR; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } + + SECTION("threshold FATAL - DIAG < FATAL, exit 0") + { + App_Exit_Level_Error = ERRATA_FATAL; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } + + SECTION("threshold DIAG - DIAG >= DIAG, exit 2") + { + App_Exit_Level_Error = ERRATA_DIAG; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } +} + +TEST_CASE("appExitCodeFromResponse - annotation with WARN severity", "[exit_code]") +{ + auto resp = make_error_response( + R"({"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 4, "message": "Server already draining."}]})"); + + SECTION("threshold ERROR - WARN < ERROR, exit 0") + { + App_Exit_Level_Error = ERRATA_ERROR; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } + + SECTION("threshold WARN - WARN >= WARN, exit 2") + { + App_Exit_Level_Error = ERRATA_WARN; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold NOTE - WARN >= NOTE, exit 2") + { + App_Exit_Level_Error = ERRATA_NOTE; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold FATAL - WARN < FATAL, exit 0") + { + App_Exit_Level_Error = ERRATA_FATAL; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } +} + +TEST_CASE("appExitCodeFromResponse - annotation with ERROR severity", "[exit_code]") +{ + auto resp = make_error_response( + R"({"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 5, "message": "hard error"}]})"); + + SECTION("threshold ERROR - ERROR >= ERROR, exit 2") + { + App_Exit_Level_Error = ERRATA_ERROR; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold FATAL - ERROR < FATAL, exit 0") + { + App_Exit_Level_Error = ERRATA_FATAL; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } + + SECTION("threshold WARN - ERROR >= WARN, exit 2") + { + App_Exit_Level_Error = ERRATA_WARN; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } +} + +TEST_CASE("appExitCodeFromResponse - mixed severities picks most severe", "[exit_code]") +{ + auto resp = make_error_response(R"({"code": 9, "message": "Error during execution", "data": [)" + R"({"code": 9999, "severity": 4, "message": "warn msg"},)" + R"({"code": 9999, "severity": 5, "message": "error msg"},)" + R"({"code": 9999, "severity": 3, "message": "note msg"}]})"); + + SECTION("threshold ERROR - most severe is ERROR, exit 2") + { + App_Exit_Level_Error = ERRATA_ERROR; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold FATAL - most severe is ERROR < FATAL, exit 0") + { + App_Exit_Level_Error = ERRATA_FATAL; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } +} + +TEST_CASE("appExitCodeFromResponse - mixed: some with severity, some without", "[exit_code]") +{ + auto resp = make_error_response(R"({"code": 9, "message": "Error during execution", "data": [)" + R"({"code": 9999, "severity": 4, "message": "warn"},)" + R"({"code": 9999, "message": "no severity - defaults to DIAG"}]})"); + + SECTION("threshold ERROR - most severe is WARN (4) < ERROR, exit 0") + { + App_Exit_Level_Error = ERRATA_ERROR; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } + + SECTION("threshold WARN - most severe is WARN (4) >= WARN, exit 2") + { + App_Exit_Level_Error = ERRATA_WARN; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold FATAL - most severe is WARN (4) < FATAL, exit 0") + { + App_Exit_Level_Error = ERRATA_FATAL; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } +} + +TEST_CASE("appExitCodeFromResponse - all annotations only WARN", "[exit_code]") +{ + auto resp = make_error_response(R"({"code": 9, "message": "Error during execution", "data": [)" + R"({"code": 9999, "severity": 4, "message": "warn 1"},)" + R"({"code": 9999, "severity": 4, "message": "warn 2"}]})"); + + SECTION("threshold ERROR - exit 0") + { + App_Exit_Level_Error = ERRATA_ERROR; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } + + SECTION("threshold WARN - exit 2") + { + App_Exit_Level_Error = ERRATA_WARN; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold DIAG - exit 2") + { + App_Exit_Level_Error = ERRATA_DIAG; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } +} + +TEST_CASE("appExitCodeFromResponse - FATAL severity annotation", "[exit_code]") +{ + auto resp = make_error_response( + R"({"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 6, "message": "fatal"}]})"); + + SECTION("threshold FATAL - FATAL >= FATAL, exit 2") + { + App_Exit_Level_Error = ERRATA_FATAL; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_ERROR); + } + + SECTION("threshold EMERGENCY - FATAL < EMERGENCY, exit 0") + { + App_Exit_Level_Error = ERRATA_EMERGENCY; + REQUIRE(appExitCodeFromResponse(resp) == CTRL_EX_OK); + } +} + +// --------------------------------------------------------------------------- +// YAML::convert::decode +// --------------------------------------------------------------------------- + +TEST_CASE("JSONRPCError decode - severity field present", "[decoder]") +{ + auto node = YAML::Load( + R"({"code": 9, "message": "Error during execution", "data": [{"code": 9999, "severity": 4, "message": "a warning"}]})"); + + auto err = node.as(); + REQUIRE(err.code == 9); + REQUIRE(err.message == "Error during execution"); + REQUIRE(err.data.size() == 1); + REQUIRE(err.data[0].code == 9999); + REQUIRE(err.data[0].severity == 4); + REQUIRE(err.data[0].message == "a warning"); +} + +TEST_CASE("JSONRPCError decode - severity field absent", "[decoder]") +{ + auto node = YAML::Load(R"({"code": 9, "message": "Error during execution", "data": [{"code": 9999, "message": "no sev"}]})"); + + auto err = node.as(); + REQUIRE(err.data.size() == 1); + REQUIRE(err.data[0].severity == 0); + REQUIRE(err.data[0].code == 9999); + REQUIRE(err.data[0].message == "no sev"); +} + +TEST_CASE("JSONRPCError decode - mixed severity present and absent", "[decoder]") +{ + auto node = YAML::Load(R"({"code": 9, "message": "err", "data": [)" + R"({"code": 100, "severity": 5, "message": "has sev"},)" + R"({"code": 200, "message": "no sev"},)" + R"({"code": 300, "severity": 0, "message": "diag level"}]})"); + + auto err = node.as(); + REQUIRE(err.data.size() == 3); + + CHECK(err.data[0].severity == 5); + CHECK(err.data[0].code == 100); + + CHECK(err.data[1].severity == 0); + CHECK(err.data[1].code == 200); + + CHECK(err.data[2].severity == 0); + CHECK(err.data[2].code == 300); +} + +TEST_CASE("JSONRPCError decode - no data section", "[decoder]") +{ + auto node = YAML::Load(R"({"code": -32600, "message": "Invalid Request"})"); + + auto err = node.as(); + REQUIRE(err.code == -32600); + REQUIRE(err.message == "Invalid Request"); + REQUIRE(err.data.empty()); +} + +TEST_CASE("JSONRPCError decode - empty data array", "[decoder]") +{ + auto node = YAML::Load(R"({"code": 9, "message": "err", "data": []})"); + + auto err = node.as(); + REQUIRE(err.data.empty()); +} diff --git a/tests/gold_tests/traffic_ctl/traffic_ctl_error_level.test.py b/tests/gold_tests/traffic_ctl/traffic_ctl_error_level.test.py new file mode 100644 index 00000000000..79c17882c8f --- /dev/null +++ b/tests/gold_tests/traffic_ctl/traffic_ctl_error_level.test.py @@ -0,0 +1,143 @@ +# 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. + +Test.Summary = ''' +Test traffic_ctl --error-level flag with severity-aware exit codes. + +Annotations without explicit severity default to DIAG, so they exit 0 with the +default --error-level=error threshold. Only annotations with severity >= the +threshold cause a non-zero exit. + +Covers three categories: + 1. Handlers without explicit severity (annotations default to DIAG). + 2. Protocol-level errors (empty data) and record-level errors (separate code + path) — these always exit 2 regardless of --error-level. + 3. Successful commands always exit 0 regardless of --error-level. +''' + +Test.ContinueOnFail = True + +ts = Test.MakeATSProcess("ts") + +# =================================================================== +# Category 1: Handler WITHOUT explicit severity (defaults to DIAG) +# =================================================================== + +# 1. Drain the server — first time succeeds. +tr = Test.AddTestRun("drain server - first time succeeds") +tr.Processes.Default.Command = 'traffic_ctl server drain' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(ts) +tr.StillRunningAfter = ts +tr.DelayStart = 3 + +# 2. Drain again (default --error-level=error) — "Server already draining" has +# no explicit severity, defaults to DIAG. DIAG < ERROR → exit 0. +tr = Test.AddTestRun("drain again - default error-level, exit 0 for diag") +tr.Processes.Default.Command = 'traffic_ctl server drain' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.StillRunningAfter = ts +tr.Processes.Default.Streams.stdout = Testers.ContainsExpression("Server already draining", "should report already draining") + +# 3. Drain again with --error-level=diag — DIAG >= DIAG → exit 2. +tr = Test.AddTestRun("drain again - error-level=diag, exit 2 for diag") +tr.Processes.Default.Command = 'traffic_ctl --error-level=diag server drain' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 2 +tr.StillRunningAfter = ts + +# 4. Drain again with --error-level=warn — DIAG < WARN → exit 0. +tr = Test.AddTestRun("drain again - error-level=warn, exit 0 for diag") +tr.Processes.Default.Command = 'traffic_ctl --error-level=warn server drain' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.StillRunningAfter = ts + +# 5. Undo drain — first time succeeds. +tr = Test.AddTestRun("undo drain - first time succeeds") +tr.Processes.Default.Command = 'traffic_ctl server drain --undo' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.StillRunningAfter = ts + +# 6. Undo drain again (default) — "Server is not draining" defaults to DIAG. +# DIAG < ERROR → exit 0. +tr = Test.AddTestRun("undo drain again - default error-level, exit 0 for diag") +tr.Processes.Default.Command = 'traffic_ctl server drain --undo' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.StillRunningAfter = ts + +# 7. Undo drain again with --error-level=diag — DIAG >= DIAG → exit 2. +tr = Test.AddTestRun("undo drain again - error-level=diag, exit 2 for diag") +tr.Processes.Default.Command = 'traffic_ctl --error-level=diag server drain --undo' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 2 +tr.StillRunningAfter = ts + +# =================================================================== +# Category 2: Protocol/record errors (always exit 2) +# =================================================================== + +# 8-9. Unknown RPC method — JSONRPC protocol error (METHOD_NOT_FOUND). +# Protocol errors have no data annotations, so appExitCodeFromResponse +# sees empty data → always exit 2 regardless of --error-level. +tr = Test.AddTestRun("unknown rpc method - default error-level, exit 2") +tr.Processes.Default.Command = 'traffic_ctl rpc invoke nonexistent_rpc_method' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 2 +tr.StillRunningAfter = ts + +tr = Test.AddTestRun("unknown rpc method - error-level=fatal, still exit 2") +tr.Processes.Default.Command = 'traffic_ctl --error-level=fatal rpc invoke nonexistent_rpc_method' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 2 +tr.StillRunningAfter = ts + +# 10-11. Bad config get — record-level errors go through a separate code +# path (print_record_error_list) that hard-codes CTRL_EX_ERROR. +# --error-level has no effect here. +tr = Test.AddTestRun("config get bad record - default error-level, exit 2") +tr.Processes.Default.Command = 'traffic_ctl config get nonexistent.record.name' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 2 +tr.StillRunningAfter = ts + +tr = Test.AddTestRun("config get bad record - error-level=fatal, still exit 2") +tr.Processes.Default.Command = 'traffic_ctl --error-level=fatal config get nonexistent.record.name' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 2 +tr.StillRunningAfter = ts + +# =================================================================== +# Category 3: Successful commands — exit 0 regardless of --error-level +# =================================================================== + +# 12. Successful config get — exit 0 with default level. +tr = Test.AddTestRun("config get valid record - exit 0") +tr.Processes.Default.Command = 'traffic_ctl config get proxy.config.http.server_ports' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.StillRunningAfter = ts + +# 13. Successful config get with --error-level=diag — still exit 0. +tr = Test.AddTestRun("config get valid record - error-level=diag, exit 0") +tr.Processes.Default.Command = 'traffic_ctl --error-level=diag config get proxy.config.http.server_ports' +tr.Processes.Default.Env = ts.Env +tr.Processes.Default.ReturnCode = 0 +tr.StillRunningAfter = ts