diff --git a/doc/appendices/command-line/traffic_ctl.en.rst b/doc/appendices/command-line/traffic_ctl.en.rst index 1b5444ef670..55dbee55707 100644 --- a/doc/appendices/command-line/traffic_ctl.en.rst +++ b/doc/appendices/command-line/traffic_ctl.en.rst @@ -294,6 +294,60 @@ Display the current value of a configuration record. max: ! 100 +.. program:: traffic_ctl config +.. option:: reset PATH [PATH ...] + + Reset configuration record(s) to their default values. The PATH argument is used as a + regex pattern to match against record names. Multiple paths at once can be provided. + + - ``records`` - Reset all configuration records to defaults + - A partial path like ``proxy.config.http`` or ``records.http`` - Reset all records matching the pattern + - A full record name like ``proxy.config.diags.debug.enabled`` - Reset a specific record + + **Path Format Support** + + Both record name format and YAML format are supported. Paths starting with ``records.`` + are automatically converted to ``proxy.config.`` before matching: + + ====================================== ====================================== + YAML Format Record Name Format + ====================================== ====================================== + ``records.http`` ``proxy.config.http`` + ``records.diags.debug.enabled`` ``proxy.config.diags.debug.enabled`` + ``records.cache.ram_cache.size`` ``proxy.config.cache.ram_cache.size`` + ====================================== ====================================== + + This allows you to use the same path style as in :file:`records.yaml` configuration files. + + Examples: + + Reset all records to defaults: + + .. code-block:: bash + + $ traffic_ctl config reset records + + Reset all HTTP configuration records (both formats are equivalent): + + .. code-block:: bash + + $ traffic_ctl config reset proxy.config.http + $ traffic_ctl config reset records.http + + Reset a specific record: + + .. code-block:: bash + + $ traffic_ctl config reset proxy.config.diags.debug.enabled + + Using YAML-style path for the same record: + + .. code-block:: bash + + $ traffic_ctl config reset records.diags.debug.enabled + + + .. program:: traffic_ctl config .. option:: status diff --git a/src/mgmt/rpc/handlers/config/Configuration.cc b/src/mgmt/rpc/handlers/config/Configuration.cc index cf3f2fc60d5..0c57659d6fa 100644 --- a/src/mgmt/rpc/handlers/config/Configuration.cc +++ b/src/mgmt/rpc/handlers/config/Configuration.cc @@ -206,4 +206,5 @@ reload_config(std::string_view const & /* id ATS_UNUSED */, YAML::Node const & / return resp; } + } // namespace rpc::handlers::config diff --git a/src/traffic_ctl/CtrlCommands.cc b/src/traffic_ctl/CtrlCommands.cc index 6d7a386e276..c2f888e87ef 100644 --- a/src/traffic_ctl/CtrlCommands.cc +++ b/src/traffic_ctl/CtrlCommands.cc @@ -45,6 +45,21 @@ const StringToFormatFlagsMap _Fmt_str_to_enum = { {"json", BasePrinter::Options::FormatFlags::JSON}, {"rpc", BasePrinter::Options::FormatFlags::RPC } }; + +constexpr std::string_view YAML_PREFIX{"records."}; +constexpr std::string_view RECORD_PREFIX{"proxy.config."}; + +/// Convert YAML-style path (records.diags.debug) to record name format (proxy.config.diags.debug). +/// If the path doesn't start with "records.", it's returned unchanged. +std::string +yaml_to_record_name(std::string_view path) +{ + swoc::TextView tv{path}; + if (tv.starts_with(YAML_PREFIX)) { + return std::string{RECORD_PREFIX} + std::string{path.substr(YAML_PREFIX.size())}; + } + return std::string{path}; +} } // namespace BasePrinter::Options::FormatFlags @@ -142,6 +157,9 @@ ConfigCommand::ConfigCommand(ts::Arguments *args) : RecordCommand(args) } else if (args->get(SET_STR)) { _printer = std::make_unique(printOpts); _invoked_func = [&]() { config_set(); }; + } else if (args->get(RESET_STR)) { + _printer = std::make_unique(printOpts); + _invoked_func = [&]() { config_reset(); }; } else if (args->get(STATUS_STR)) { _printer = std::make_unique(printOpts); _invoked_func = [&]() { config_status(); }; @@ -237,6 +255,49 @@ ConfigCommand::config_set() _printer->write_output(response); } +void +ConfigCommand::config_reset() +{ + auto const &paths = get_parsed_arguments()->get(RESET_STR); + + // Build lookup request - always use REGEX to support partial path matching + shared::rpc::RecordLookupRequest lookup_request; + + if (paths.empty() || (paths.size() == 1 && paths[0] == "records")) { + lookup_request.emplace_rec(".*", shared::rpc::REGEX, shared::rpc::CONFIG_REC_TYPES); + } else { + for (auto const &path : paths) { + // Convert YAML-style path (records.*) to record name format (proxy.config.*) + auto record_path = yaml_to_record_name(path); + lookup_request.emplace_rec(record_path, shared::rpc::REGEX, shared::rpc::CONFIG_REC_TYPES); + } + } + + // Lookup matching records + auto lookup_response = invoke_rpc(lookup_request); + if (lookup_response.is_error()) { + _printer->write_output(lookup_response); + return; + } + + // Build reset request from modified records (current != default) + auto const &records = lookup_response.result.as(); + ConfigSetRecordRequest set_request; + + for (auto const &rec : records.recordList) { + if (rec.currentValue != rec.defaultValue) { + set_request.params.push_back(ConfigSetRecordRequest::Params{rec.name, rec.defaultValue}); + } + } + + if (set_request.params.size() == 0) { + std::cout << "No records to reset (all matching records are already at default values)\n"; + return; + } + + _printer->write_output(invoke_rpc(set_request)); +} + void ConfigCommand::config_reload() { diff --git a/src/traffic_ctl/CtrlCommands.h b/src/traffic_ctl/CtrlCommands.h index ebf1e1eafee..c10e697ed5f 100644 --- a/src/traffic_ctl/CtrlCommands.h +++ b/src/traffic_ctl/CtrlCommands.h @@ -128,6 +128,7 @@ class ConfigCommand : public RecordCommand static inline const std::string DIFF_STR{"diff"}; static inline const std::string DEFAULTS_STR{"defaults"}; static inline const std::string SET_STR{"set"}; + static inline const std::string RESET_STR{"reset"}; static inline const std::string COLD_STR{"cold"}; static inline const std::string APPEND_STR{"append"}; static inline const std::string STATUS_STR{"status"}; @@ -141,6 +142,7 @@ class ConfigCommand : public RecordCommand void config_diff(); void config_status(); void config_set(); + void config_reset(); void file_config_set(); void config_reload(); void config_show_file_registry(); diff --git a/src/traffic_ctl/traffic_ctl.cc b/src/traffic_ctl/traffic_ctl.cc index 5599bd64c9a..a018304a3f7 100644 --- a/src/traffic_ctl/traffic_ctl.cc +++ b/src/traffic_ctl/traffic_ctl.cc @@ -134,6 +134,12 @@ main([[maybe_unused]] int argc, const char **argv) "Add type tag to the yaml field. This is needed if the record is not registered inside ATS. [only relevant if --cold set]", "", 1) .add_example_usage("traffic_ctl config set RECORD VALUE"); + config_command + .add_command("reset", "Reset configuration values matching a path pattern to their defaults", "", MORE_THAN_ZERO_ARG_N, + Command_Execute) + .add_example_usage("traffic_ctl config reset records") + .add_example_usage("traffic_ctl config reset proxy.config.http") + .add_example_usage("traffic_ctl config reset proxy.config.http.cache_enabled"); config_command.add_command("registry", "Show configuration file registry", Command_Execute) .add_example_usage("traffic_ctl config registry"); diff --git a/tests/gold_tests/traffic_ctl/traffic_ctl_config_output.test.py b/tests/gold_tests/traffic_ctl/traffic_ctl_config_output.test.py index ecf30a924e6..69f8a1cf7be 100644 --- a/tests/gold_tests/traffic_ctl/traffic_ctl_config_output.test.py +++ b/tests/gold_tests/traffic_ctl/traffic_ctl_config_output.test.py @@ -42,25 +42,27 @@ ##### CONFIG GET -# YAML output +# Test 0: YAML output traffic_ctl.config().get("proxy.config.diags.debug.tags").as_records().validate_with_goldfile("t1_yaml.gold") -# Default output +# Test 1: Default output traffic_ctl.config().get("proxy.config.diags.debug.enabled").validate_with_text("proxy.config.diags.debug.enabled: 1") -# Default output with default. +# Test 2: Default output with default. traffic_ctl.config().get("proxy.config.diags.debug.tags").with_default() \ .validate_with_text("proxy.config.diags.debug.tags: rpc # default http|dns") -# Now same output test but with defaults, traffic_ctl supports adding default value +# Test 3: Now same output test but with defaults, traffic_ctl supports adding default value # when using --records. traffic_ctl.config().get("proxy.config.diags.debug.tags").as_records().with_default().validate_with_goldfile("t2_yaml.gold") +# Test 4: traffic_ctl.config().get( "proxy.config.diags.debug.tags proxy.config.diags.debug.enabled proxy.config.diags.debug.throttling_interval_msec").as_records( ).with_default().validate_with_goldfile("t3_yaml.gold") ##### CONFIG MATCH +# Test 5: traffic_ctl.config().match("threads").with_default().validate_with_goldfile("match.gold") -# The idea is to check the traffic_ctl yaml emitter when a value starts with the +# Test 6: The idea is to check the traffic_ctl yaml emitter when a value starts with the # same prefix of a node like: # diags: # logfile: @@ -70,12 +72,70 @@ traffic_ctl.config().match("diags.logfile").as_records().validate_with_goldfile("t4_yaml.gold") ##### CONFIG DIFF +# Test 7: traffic_ctl.config().diff().validate_with_goldfile("diff.gold") +# Test 8: traffic_ctl.config().diff().as_records().validate_with_goldfile("diff_yaml.gold") ##### CONFIG DESCRIBE -# don't really care about values, but just output and that the command actually went through +# Test 9: don't really care about values, but just output and that the command actually went through traffic_ctl.config().describe("proxy.config.http.server_ports").validate_with_goldfile("describe.gold") -# Make sure that the command returns an exit code of 2 +##### CONFIG RESET +# Test 10: Reset a single modified record (proxy.config.diags.debug.tags is set to "rpc" in records_yaml, +# default is "http|dns", so it should be reset) +traffic_ctl.config().reset("proxy.config.diags.debug.tags").validate_with_text( + "Set proxy.config.diags.debug.tags, please wait 10 seconds for traffic server to sync " + "configuration, restart is not required") +# Test 11: Validate the record was reset to its default value +traffic_ctl.config().get("proxy.config.diags.debug.tags").validate_with_text("proxy.config.diags.debug.tags: http|dns") + +# Test 12: Reset records matching a partial path (proxy.config.diags) +# First set the record back to non-default for this test +traffic_ctl.config().set("proxy.config.diags.debug.tags", "rpc").exec() +# Test 13: Resetting proxy.config.diags should reset all matching modified records under that path +traffic_ctl.config().reset("proxy.config.diags").validate_contains_all( + "Set proxy.config.diags.debug.tags", "Set proxy.config.diags.debug.enabled") +# Test 14: Validate the record was reset to its default value +traffic_ctl.config().get("proxy.config.diags.debug.tags").validate_with_text("proxy.config.diags.debug.tags: http|dns") + +# Test 15: Reset all records using "records" keyword +# First set the record back to non-default for this test +traffic_ctl.config().set("proxy.config.diags.debug.tags", "rpc").exec() +# Test 16: This will reset all modified records (including proxy.config.diags.debug.tags) +# Some may require restart, which is ok, we can use diff anyways as the records that needs +# restart will just change the value but won't have any effect. +traffic_ctl.config().reset("records").exec() +# Validate the diff +# Test 17: Validate the diff +traffic_ctl.config().diff().validate_with_text("") +# Test 18: Validate the record was reset to its default value +traffic_ctl.config().get("proxy.config.diags.debug.tags").validate_with_text("proxy.config.diags.debug.tags: http|dns") + +# # Test resetting when no records need resetting (all already at default) +# # Create a new instance with default values only +# traffic_ctl_default = Make_traffic_ctl(Test, None) +# traffic_ctl_default.config().reset("proxy.config.diags.debug.enabled").validate_with_text( +# "No records to reset (all matching records are already at default values)") + +##### CONFIG RESET with YAML-style paths (records.* format) +# Test 19: Set a record to non-default first +traffic_ctl.config().set("proxy.config.diags.debug.tags", "yaml_test").exec() +# Test 20: Reset using YAML-style path (records.diags.debug.tags instead of proxy.config.diags.debug.tags) +traffic_ctl.config().reset("records.diags.debug.tags").validate_with_text( + "Set proxy.config.diags.debug.tags, please wait 10 seconds for traffic server to sync " + "configuration, restart is not required") +# Test 21: Validate the record was reset to its default value +traffic_ctl.config().get("proxy.config.diags.debug.tags").validate_with_text("proxy.config.diags.debug.tags: http|dns") + +# Test 22: Reset using YAML-style partial path (records.diags) +traffic_ctl.config().set("proxy.config.diags.debug.tags", "yaml_partial_test").exec() +traffic_ctl.config().set("proxy.config.diags.debug.enabled", "1").exec() +# Test 23: Reset using records.diags (YAML format) +traffic_ctl.config().reset("records.diags").validate_contains_all( + "Set proxy.config.diags.debug.tags", "Set proxy.config.diags.debug.enabled") +# Test 24: Validate record was reset +traffic_ctl.config().get("proxy.config.diags.debug.tags").validate_with_text("proxy.config.diags.debug.tags: http|dns") + +# Test 25: Make sure that the command returns an exit code of 2 traffic_ctl.config().get("invalid.should.set.the.exit.code.to.2").validate_with_exit_code(2) diff --git a/tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py b/tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py index afd923b74cd..b8c4b18f74c 100644 --- a/tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py +++ b/tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py @@ -59,27 +59,63 @@ class Common(): Handy class to map common traffic_ctl test options. """ - def __init__(self, tr, finish_callback): + def __init__(self, tr): self._tr = tr - self._finish_callback = finish_callback + + def _finish(self): + """ + Sets the command to the test. Make sure this gets called after + validation is set. Without this call the test will fail. + """ + self._tr.Processes.Default.Command = self._cmd + + def exec(self): + """ + If you need to just run the command with no validation, this is ok in the context of a test, but not to be + used in isolation (as to run traffic_ctl commands) + """ + self._finish() def validate_with_exit_code(self, exit_code: int): """ Sets the exit code for the test. """ self._tr.Processes.Default.ReturnCode = exit_code - self._finish_callback(self) + self._finish() return self def validate_with_text(self, text: str): """ Validate command output matches expected text exactly. + If text is empty, validates that output is completely empty (no newline). Example: traffic_ctl.config().get("proxy.config.product_name").validate_with_text("Apache Traffic Server") + traffic_ctl.config().diff().validate_with_text("") # expects empty output """ - self._tr.Processes.Default.Streams.stdout = MakeGoldFileWithText(text, self._dir, self._tn) - self._finish_callback(self) + self._tr.Processes.Default.Streams.stdout = MakeGoldFileWithText(text, self._dir, self._tn, text != "") + self._finish() + return self + + def validate_contains_all(self, *strings): + """ + Validate command output contains all specified strings (order independent). + Uses Testers.IncludesExpression for each string. + + Example: + traffic_ctl.config().reset("proxy.config.diags").validate_contains_all( + "Set proxy.config.diags.debug.tags", + "Set proxy.config.diags.debug.enabled" + ) + """ + import sys + # Testers and All are injected by autest into the test file's globals + caller_globals = sys._getframe(1).f_globals + _Testers = caller_globals['Testers'] + _All = caller_globals['All'] + testers = [_Testers.IncludesExpression(s, f"should contain: {s}") for s in strings] + self._tr.Processes.Default.Streams.stdout = _All(*testers) + self._finish() return self def validate_result_with_text(self, text: str): @@ -93,7 +129,7 @@ def validate_result_with_text(self, text: str): """ full_text = f'{{\"jsonrpc\": \"2.0\", \"result\": {text}, \"id\": {"``"}}}' self._tr.Processes.Default.Streams.stdout = MakeGoldFileWithText(full_text, self._dir, self._tn) - self._finish_callback(self) + self._finish() return self def validate_json_contains(self, **field_checks): @@ -119,7 +155,7 @@ def validate_json_contains(self, **field_checks): f"for k, expected, actual in failed]; " f"exit(0 if not failed else 1)" f'"') - self._finish_callback(self) + self._finish() return self @@ -129,9 +165,8 @@ class Config(Common): """ def __init__(self, dir, tr, tn): - super().__init__(tr, lambda x: self.__finish()) + super().__init__(tr) self._cmd = "traffic_ctl config " - self._tr = tr self._dir = dir self._tn = tn @@ -147,10 +182,44 @@ def match(self, value): self._cmd = f'{self._cmd} match {value}' return self + def set(self, record, value): + """ + Set a configuration record to a specific value. + + Args: + record: The record name (e.g., "proxy.config.diags.debug.enabled") + value: The value to set + + Example: + traffic_ctl.config().set("proxy.config.diags.debug.enabled", "1") + """ + self._cmd = f'{self._cmd} set {record} {value}' + return self + def describe(self, value): self._cmd = f'{self._cmd} describe {value}' return self + def reset(self, *paths): + """ + Reset configuration values matching path pattern(s) to their defaults. + + Args: + *paths: One or more path patterns (e.g., "records", "proxy.config.http", + "proxy.config.diags.debug.enabled") + + Example: + traffic_ctl.config().reset("records") + traffic_ctl.config().reset("proxy.config.http") + traffic_ctl.config().reset("proxy.config.diags.debug.enabled") + """ + if not paths: + self._cmd = f'{self._cmd} reset records' + else: + paths_str = ' '.join(paths) + self._cmd = f'{self._cmd} reset {paths_str}' + return self + def as_records(self): self._cmd = f'{self._cmd} --records' return self @@ -159,16 +228,9 @@ def with_default(self): self._cmd = f'{self._cmd} --default' return self - def __finish(self): - """ - Sets the command to the test. Make sure this gets called after - validation is set. Without this call the test will fail. - """ - self._tr.Processes.Default.Command = self._cmd - def validate_with_goldfile(self, file: str): self._tr.Processes.Default.Streams.stdout = os.path.join("gold", file) - self.__finish() + self._finish() class Server(Common): @@ -177,9 +239,8 @@ class Server(Common): """ def __init__(self, dir, tr, tn): - super().__init__(tr, lambda x: self.__finish()) + super().__init__(tr) self._cmd = "traffic_ctl server " - self._tr = tr self._dir = dir self._tn = tn @@ -197,21 +258,6 @@ def as_json(self): self._cmd = f'{self._cmd} -f json' return self - """ - If you need to just run the command with no validation, this is ok in the context of a test, but not to be - used in isolation(as to run traffic_ctl commands) - """ - - def exec(self): - self.__finish() - - def __finish(self): - """ - Sets the command to the test. Make sure this gets called after - validation is set. Without this call the test will fail. - """ - self._tr.Processes.Default.Command = self._cmd - class RPC(Common): """ @@ -219,9 +265,8 @@ class RPC(Common): """ def __init__(self, dir, tr, tn): - super().__init__(tr, lambda x: self.__finish()) + super().__init__(tr) self._cmd = "traffic_ctl rpc " - self._tr = tr self._dir = dir self._tn = tn @@ -233,13 +278,6 @@ def invoke(self, handler: str, params={}): return self - def __finish(self): - """ - Sets the command to the test. Make sure this gets called after - validation is set. Without this call the test will fail. - """ - self._tr.Processes.Default.Command = self._cmd - '''