This is an automated email from the ASF dual-hosted git repository.

brbzull0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 07813a3875 Add `_reload` directive support for config reload 
framework. (#13110)
07813a3875 is described below

commit 07813a3875b51a9191591fb12ad2f7409afb51f9
Author: Damian Meden <[email protected]>
AuthorDate: Tue May 5 14:09:38 2026 +0200

    Add `_reload` directive support for config reload framework. (#13110)
    
    * Add _reload directive support to config reload framework
    
    Config handlers need a way to receive operational parameters (e.g.
    scoping a reload to a single entry) without conflating them with
    config content. This adds a reserved _reload key inside the configs
    YAML node that the framework extracts before invoking handlers.
    
    Framework: ConfigContext gains reload_directives() getter; the
    _reload node is extracted in ConfigRegistry::execute_reload() and
    stripped from supplied_yaml(). Fixes stale _passed_configs entries
    not being erased after consumption.
    
    CLI: traffic_ctl config reload gains --directive (-D) flag using
    dot-notation (config_key.directive_key=value). Multiple directives
    are space-separated after a single -D.
    
    Tests: unit tests for parse_directive and ConfigContext directive
    propagation; autest coverage for directive RPC structure handling.
    
    Docs: developer guide and traffic_ctl reference updated.
---
 doc/appendices/command-line/traffic_ctl.en.rst     |  52 ++++
 doc/developer-guide/config-reload-framework.en.rst |  82 ++++++
 include/mgmt/config/ConfigContext.h                |  20 +-
 src/mgmt/config/ConfigContext.cc                   |  15 +-
 src/mgmt/config/ConfigRegistry.cc                  |  35 ++-
 src/records/CMakeLists.txt                         |   1 +
 src/records/unit_tests/test_ReloadDirectives.cc    | 319 +++++++++++++++++++++
 src/traffic_ctl/CtrlCommands.cc                    |  48 ++++
 src/traffic_ctl/traffic_ctl.cc                     |   2 +
 tests/gold_tests/jsonrpc/config_reload_rpc.test.py |  94 ++++++
 10 files changed, 657 insertions(+), 11 deletions(-)

diff --git a/doc/appendices/command-line/traffic_ctl.en.rst 
b/doc/appendices/command-line/traffic_ctl.en.rst
index 44f698714e..5fff291cf1 100644
--- a/doc/appendices/command-line/traffic_ctl.en.rst
+++ b/doc/appendices/command-line/traffic_ctl.en.rst
@@ -414,6 +414,58 @@ Display the current value of a configuration record.
          will return an error for the corresponding key. The JSONRPC response 
will contain
          per-key error details.
 
+   .. option:: --directive, -D <config_key.directive_key=value>
+
+      Pass a reload directive to a specific config handler. Directives are 
operational parameters
+      that modify how the handler performs the reload — for example, scoping a 
reload to a single
+      entry or enabling a dry-run mode. They are distinct from config content 
(``-d``).
+
+      The format is ``config_key.directive_key=value``, parsed by splitting on 
the first ``.``
+      and the first ``=``:
+
+      - ``config_key`` — the registry key (e.g. ``ip_allow``, ``sni``)
+      - ``directive_key`` — the directive name understood by that handler
+      - ``value`` — the directive value (always passed as a string on the wire)
+
+      Multiple directives are passed as space-separated values after a single 
``-D``:
+
+      .. code-block:: bash
+
+         # Single directive
+         $ traffic_ctl config reload -D myconfig.id=foo
+
+         # Multiple directives for the same handler
+         $ traffic_ctl config reload -D myconfig.id=foo myconfig.dry_run=true
+
+         # Directives for different handlers in the same reload
+         $ traffic_ctl config reload -D myconfig.id=foo sni.fqdn=example.com
+
+      On the wire, ``-D myconfig.id=foo`` translates to:
+
+      .. code-block:: json
+
+         { "configs": { "myconfig": { "_reload": { "id": "foo" } } } }
+
+      For complex or nested directive values, use ``-d`` with full YAML 
instead:
+
+      .. code-block:: bash
+
+         $ traffic_ctl config reload -d 'myconfig: { _reload: { id: foo, 
options: { strict: true } } }'
+
+      .. note::
+
+         ``-D`` uses variable-argument parsing and must appear as the **last 
option**
+         on the command line. Any flags placed after ``-D`` will be consumed 
as directive
+         values. ``-D`` and ``-d`` cannot be combined in the same invocation 
due to this
+         same constraint. Use ``-d`` with full YAML when you need both 
directives and
+         inline content in a single reload request.
+
+      .. note::
+
+         Available directives depend on the handler — consult each config's 
documentation for
+         supported directive keys. Directive values are strings on the wire; 
handlers use
+         yaml-cpp's ``as<T>()`` to interpret them as needed.
+
    .. option:: --force, -F
 
       Force a new reload even if one is already in progress. Without this 
flag, the server rejects
diff --git a/doc/developer-guide/config-reload-framework.en.rst 
b/doc/developer-guide/config-reload-framework.en.rst
index 550795be1b..549512fec2 100644
--- a/doc/developer-guide/config-reload-framework.en.rst
+++ b/doc/developer-guide/config-reload-framework.en.rst
@@ -266,8 +266,90 @@ supplied_yaml()
    Returns the YAML node supplied via the RPC ``-d`` flag or ``configs`` 
parameter. If no inline
    content was provided, the returned node is undefined (``operator bool()`` 
returns ``false``).
 
+   The framework strips the reserved ``_reload`` key from the supplied YAML 
before delivering it
+   to the handler, so ``supplied_yaml()`` always contains pure config data.
+
+reload_directives()
+   Returns the YAML map extracted from the ``_reload`` key in the RPC-supplied 
content. If no
+   directives were provided, the returned node is Undefined (``operator 
bool()`` returns ``false``).
+
+   Directives are operational parameters that modify **how** the handler 
performs the reload —
+   they are distinct from config **content**. Common uses include scoping a 
reload to a single
+   entry, enabling a dry-run mode, or passing a version constraint.
+
+   On the wire, directives are nested under ``_reload`` inside the handler's 
``configs`` node:
+
+   .. code-block:: json
+
+      {
+          "configs": {
+              "myconfig": {
+                  "_reload": { "id": "foo", "dry_run": "true" },
+                  "rules": ["rule1", "rule2"]
+              }
+          }
+      }
+
+   The framework extracts ``_reload`` before the handler runs, so:
+
+   - ``reload_directives()`` returns ``{ "id": "foo", "dry_run": "true" }``
+   - ``supplied_yaml()`` returns the remaining content (without ``_reload``)
+   - If ``_reload`` was the only key, ``supplied_yaml()`` is undefined
+
+   Directives and content can coexist. The handler decides how to combine them 
— the framework
+   delivers both without interpretation.
+
+   **Recommended handler pattern:**
+
+   .. code-block:: cpp
+
+      void MyConfig::reconfigure(ConfigContext ctx) {
+          ctx.in_progress();
+
+          if (auto directives = ctx.reload_directives()) {
+              if (auto id_node = directives["id"]; id_node.IsDefined()) {
+                  std::string id = id_node.as<std::string>();
+                  if (!reload_single_entry(id)) {
+                      ctx.fail("Unknown entry: " + id);
+                      return;
+                  }
+                  ctx.complete("Reloaded entry: " + id);
+                  return;
+              }
+          }
+
+          if (auto yaml = ctx.supplied_yaml()) {
+              if (!load_from_yaml(yaml)) {
+                  ctx.fail("Invalid inline content");
+                  return;
+              }
+              ctx.complete("Loaded from inline content");
+              return;
+          }
+
+          if (!load_from_file(config_filename)) {
+              ctx.fail("Failed to parse " + config_filename);
+              return;
+          }
+          ctx.complete("Loaded from file");
+      }
+
+   From :program:`traffic_ctl`, directives are passed via ``--directive`` 
(``-D``):
+
+   .. code-block:: bash
+
+      $ traffic_ctl config reload -D myconfig.id=foo
+
+   See the ``--directive`` option in :ref:`traffic_ctl <traffic_ctl_jsonrpc>` 
for details.
+
+   .. note::
+
+      Directive values are strings on the wire (the JSONRPC transport 
serializes all values as
+      double-quoted strings). Handlers use yaml-cpp's ``as<T>()`` to interpret 
them as needed.
+
 add_dependent_ctx(description)
    Create a child sub-task. The parent aggregates status from all its children.
+   Child contexts inherit both ``supplied_yaml()`` and ``reload_directives()`` 
from the parent.
 
 All methods support ``swoc::bwprint`` format strings:
 
diff --git a/include/mgmt/config/ConfigContext.h 
b/include/mgmt/config/ConfigContext.h
index 49807c02dd..725b0a2be4 100644
--- a/include/mgmt/config/ConfigContext.h
+++ b/include/mgmt/config/ConfigContext.h
@@ -173,19 +173,35 @@ public:
   [[nodiscard]] ConfigContext add_dependent_ctx(std::string_view description = 
"");
 
   /// Get supplied YAML node (for RPC-based reloads).
-  /// A default-constructed YAML::Node is Undefined (operator bool() == false).
+  /// Returns Undefined when no content was provided (operator bool() == 
false).
   /// @code
   ///   if (auto yaml = ctx.supplied_yaml()) { /* use yaml node */ }
   /// @endcode
   /// @return copy of the supplied YAML node (cheap — YAML::Node is internally 
reference-counted).
   [[nodiscard]] YAML::Node supplied_yaml() const;
 
+  /// Get reload directives extracted from the _reload key.
+  /// Directives are operational parameters that modify how the handler 
performs
+  /// the reload (e.g. scope to a single entry, dry-run) — distinct from 
config content.
+  /// The framework extracts _reload from the supplied node before passing 
content
+  /// to the handler, so supplied_yaml() never contains _reload.
+  /// Returns Undefined when no directives were provided (operator bool() == 
false).
+  /// @code
+  ///   if (auto directives = ctx.reload_directives()) { /* use directives */ }
+  /// @endcode
+  /// @return copy of the directives YAML node (cheap — YAML::Node is 
internally reference-counted).
+  [[nodiscard]] YAML::Node reload_directives() const;
+
 private:
   /// Set supplied YAML node. Only ConfigRegistry should call this during 
reload setup.
   void set_supplied_yaml(YAML::Node node);
 
+  /// Set reload directives. Only ConfigRegistry should call this during 
reload setup.
+  void set_reload_directives(YAML::Node node);
+
   std::weak_ptr<ConfigReloadTask> _task;
-  YAML::Node                      _supplied_yaml; ///< for no content, this 
will just be empty
+  YAML::Node                      _supplied_yaml{YAML::NodeType::Undefined};
+  YAML::Node                      
_reload_directives{YAML::NodeType::Undefined};
 
   friend class ReloadCoordinator;
   friend class config::ConfigRegistry;
diff --git a/src/mgmt/config/ConfigContext.cc b/src/mgmt/config/ConfigContext.cc
index 7dd81a58ca..4604911b5b 100644
--- a/src/mgmt/config/ConfigContext.cc
+++ b/src/mgmt/config/ConfigContext.cc
@@ -149,7 +149,8 @@ ConfigContext::add_dependent_ctx(std::string_view 
description)
     // child task will get the full content of the parent task
     // TODO: eventually we can have a "key" passed so child module
     // only gets their node of interest.
-    child._supplied_yaml = _supplied_yaml;
+    child._supplied_yaml     = _supplied_yaml;
+    child._reload_directives = _reload_directives;
     return child;
   }
   return {};
@@ -167,6 +168,18 @@ ConfigContext::supplied_yaml() const
   return _supplied_yaml;
 }
 
+void
+ConfigContext::set_reload_directives(YAML::Node node)
+{
+  _reload_directives = node;
+}
+
+YAML::Node
+ConfigContext::reload_directives() const
+{
+  return _reload_directives;
+}
+
 namespace config
 {
 ConfigContext
diff --git a/src/mgmt/config/ConfigRegistry.cc 
b/src/mgmt/config/ConfigRegistry.cc
index 0a3382f846..776fa265d2 100644
--- a/src/mgmt/config/ConfigRegistry.cc
+++ b/src/mgmt/config/ConfigRegistry.cc
@@ -431,15 +431,17 @@ ConfigRegistry::execute_reload(const std::string &key)
 {
   Dbg(dbg_ctl, "Executing reload for config '%s'", key.c_str());
 
-  // Single lock for both lookups: passed config (from RPC) and registry entry
   YAML::Node passed_config;
+  bool       has_passed_config{false};
   Entry      entry_copy;
   {
-    std::shared_lock lock(_mutex);
+    std::unique_lock lock(_mutex);
 
     if (auto pc_it = _passed_configs.find(key); pc_it != 
_passed_configs.end()) {
-      passed_config = pc_it->second;
-      Dbg(dbg_ctl, "Retrieved passed config for '%s'", key.c_str());
+      passed_config     = pc_it->second;
+      has_passed_config = true;
+      _passed_configs.erase(pc_it);
+      Dbg(dbg_ctl, "Retrieved and consumed passed config for '%s'", 
key.c_str());
     }
 
     if (auto it = _entries.find(key); it != _entries.end()) {
@@ -455,14 +457,31 @@ ConfigRegistry::execute_reload(const std::string &key)
   // Create context with subtask tracking
   // For rpc reload: use key as description, no filename (source: rpc)
   // For file reload: use key as description, filename indicates source: file
-  std::string filename = passed_config.IsDefined() ? "" : 
entry_copy.resolve_filename();
+  std::string filename = has_passed_config ? "" : 
entry_copy.resolve_filename();
   auto        ctx      = 
ReloadCoordinator::Get_Instance().create_config_context(entry_copy.key, 
entry_copy.key, filename);
   ctx.in_progress();
 
-  if (passed_config.IsDefined()) {
-    // Passed config mode: store YAML node directly for handler to use via 
supplied_yaml()
+  if (has_passed_config) {
     Dbg(dbg_ctl, "Config '%s' reloading from rpc-supplied content", 
entry_copy.key.c_str());
-    ctx.set_supplied_yaml(passed_config);
+
+    // Extract _reload directives before passing content to the handler.
+    // This keeps supplied_yaml() clean (pure config data) and provides
+    // reload_directives() as a separate accessor for operational parameters.
+    if (passed_config.IsMap() && passed_config["_reload"]) {
+      auto directives = passed_config["_reload"];
+      if (!directives.IsMap()) {
+        Warning("Config '%s': _reload must be a YAML map, ignoring 
directives", entry_copy.key.c_str());
+      } else {
+        Dbg(dbg_ctl, "Config '%s' has reload directives", 
entry_copy.key.c_str());
+        ctx.set_reload_directives(directives);
+      }
+      passed_config.remove("_reload");
+    }
+
+    // After stripping _reload, pass remaining content (if any) as 
supplied_yaml
+    if (passed_config.size() > 0) {
+      ctx.set_supplied_yaml(passed_config);
+    }
   } else {
     Dbg(dbg_ctl, "Config '%s' reloading from file '%s'", 
entry_copy.key.c_str(), filename.c_str());
   }
diff --git a/src/records/CMakeLists.txt b/src/records/CMakeLists.txt
index 6412c6cee9..ac93f68173 100644
--- a/src/records/CMakeLists.txt
+++ b/src/records/CMakeLists.txt
@@ -52,6 +52,7 @@ if(BUILD_TESTING)
     unit_tests/test_RecRegister.cc
     unit_tests/test_ConfigReloadTask.cc
     unit_tests/test_ConfigRegistry.cc
+    unit_tests/test_ReloadDirectives.cc
     unit_tests/test_RecDumpRecords.cc
   )
   target_link_libraries(test_records PRIVATE records configmanager inkevent 
Catch2::Catch2 ts::tscore libswoc::libswoc)
diff --git a/src/records/unit_tests/test_ReloadDirectives.cc 
b/src/records/unit_tests/test_ReloadDirectives.cc
new file mode 100644
index 0000000000..5b90b60708
--- /dev/null
+++ b/src/records/unit_tests/test_ReloadDirectives.cc
@@ -0,0 +1,319 @@
+/** @file
+
+  Unit tests for reload directives: ConfigContext directive accessors,
+  framework extraction logic, and CLI parse_directive() format.
+
+  @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 <catch2/catch_test_macros.hpp>
+
+#include "mgmt/config/ConfigContext.h"
+#include "mgmt/config/ConfigReloadTrace.h"
+
+#include <yaml-cpp/yaml.h>
+#include <string>
+#include <string_view>
+
+// ─── parse_directive: standalone copy of the traffic_ctl parsing logic 
────────
+//
+// The actual function lives in an anonymous namespace in CtrlCommands.cc.
+// We reproduce the identical logic here so we can unit-test the format parsing
+// without pulling in the full traffic_ctl binary and its dependencies.
+namespace
+{
+bool
+parse_directive(std::string_view dir, YAML::Node &configs, std::string 
&error_out)
+{
+  auto dot = dir.find('.');
+  if (dot == std::string_view::npos || dot == 0) {
+    error_out = "Invalid directive format '" + std::string(dir) + "'. 
Expected: config_key.directive_key=value";
+    return false;
+  }
+
+  auto eq = dir.find('=', dot + 1);
+  if (eq == std::string_view::npos || eq == dot + 1) {
+    error_out = "Invalid directive format '" + std::string(dir) + "'. 
Expected: config_key.directive_key=value";
+    return false;
+  }
+
+  std::string config_key{dir.substr(0, dot)};
+  std::string directive_key{dir.substr(dot + 1, eq - dot - 1)};
+  std::string value{dir.substr(eq + 1)};
+
+  configs[config_key]["_reload"][directive_key] = value;
+  return true;
+}
+} // namespace
+
+// ─── parse_directive format tests 
─────────────────────────────────────────────
+
+TEST_CASE("parse_directive: valid single directive", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE(parse_directive("myconfig.id=foo", configs, err));
+  REQUIRE(configs["myconfig"]["_reload"]["id"].as<std::string>() == "foo");
+}
+
+TEST_CASE("parse_directive: value with equals signs", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE(parse_directive("plugin.url=http://x.com/a=b";, configs, err));
+  REQUIRE(configs["plugin"]["_reload"]["url"].as<std::string>() == 
"http://x.com/a=b";);
+}
+
+TEST_CASE("parse_directive: value with dots", "[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE(parse_directive("plugin.fqdn=foo.example.com", configs, err));
+  REQUIRE(configs["plugin"]["_reload"]["fqdn"].as<std::string>() == 
"foo.example.com");
+}
+
+TEST_CASE("parse_directive: empty value is allowed", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE(parse_directive("myconfig.flag=", configs, err));
+  REQUIRE(configs["myconfig"]["_reload"]["flag"].as<std::string>() == "");
+}
+
+TEST_CASE("parse_directive: multiple directives for same config", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE(parse_directive("myconfig.id=foo", configs, err));
+  REQUIRE(parse_directive("myconfig.dry_run=true", configs, err));
+
+  REQUIRE(configs["myconfig"]["_reload"]["id"].as<std::string>() == "foo");
+  REQUIRE(configs["myconfig"]["_reload"]["dry_run"].as<std::string>() == 
"true");
+}
+
+TEST_CASE("parse_directive: multiple directives for different configs", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE(parse_directive("myconfig.id=foo", configs, err));
+  REQUIRE(parse_directive("sni.fqdn=example.com", configs, err));
+
+  REQUIRE(configs["myconfig"]["_reload"]["id"].as<std::string>() == "foo");
+  REQUIRE(configs["sni"]["_reload"]["fqdn"].as<std::string>() == 
"example.com");
+}
+
+TEST_CASE("parse_directive: rejects missing dot", "[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE_FALSE(parse_directive("nodot", configs, err));
+  REQUIRE(err.find("Invalid directive format") != std::string::npos);
+}
+
+TEST_CASE("parse_directive: rejects leading dot", "[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE_FALSE(parse_directive(".key=value", configs, err));
+  REQUIRE(err.find("Invalid directive format") != std::string::npos);
+}
+
+TEST_CASE("parse_directive: rejects missing equals", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE_FALSE(parse_directive("config.key", configs, err));
+  REQUIRE(err.find("Invalid directive format") != std::string::npos);
+}
+
+TEST_CASE("parse_directive: rejects empty directive key", 
"[config][directive][parse]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  REQUIRE_FALSE(parse_directive("config.=value", configs, err));
+  REQUIRE(err.find("Invalid directive format") != std::string::npos);
+}
+
+// ─── ConfigContext directive accessor tests 
───────────────────────────────────
+
+TEST_CASE("ConfigContext: reload_directives on default context has no keys", 
"[config][context][directive]")
+{
+  ConfigContext ctx;
+
+  // Members are initialized as Undefined, so operator bool() is false.
+  YAML::Node const directives = ctx.reload_directives();
+  REQUIRE_FALSE(directives.IsDefined());
+  REQUIRE_FALSE(directives);
+  REQUIRE_FALSE(directives["id"].IsDefined());
+}
+
+TEST_CASE("ConfigContext: supplied_yaml on default context has no content", 
"[config][context][directive]")
+{
+  ConfigContext ctx;
+
+  auto yaml = ctx.supplied_yaml();
+  REQUIRE_FALSE(yaml.IsDefined());
+  REQUIRE_FALSE(yaml);
+  REQUIRE_FALSE(yaml.IsMap());
+  REQUIRE_FALSE(yaml.IsSequence());
+}
+
+TEST_CASE("ConfigContext: reload_directives round-trip via task", 
"[config][context][directive]")
+{
+  auto          task = std::make_shared<ConfigReloadTask>("test-dir-1", 
"test", false, nullptr);
+  ConfigContext ctx(task, "test_handler");
+
+  YAML::Node directives;
+  directives["id"]      = "foo";
+  directives["dry_run"] = "true";
+
+  // Use the private setter via a ConfigContext that has a live task.
+  // Since set_reload_directives is private and friend-accessible only from
+  // ConfigRegistry/ReloadCoordinator, we test through the public interface
+  // after setting up the state that execute_reload would create.
+
+  // Simulate what ConfigRegistry::execute_reload() does:
+  // Build a passed_config with _reload, then manually extract
+  YAML::Node passed_config;
+  passed_config["_reload"]["id"]      = "foo";
+  passed_config["_reload"]["dry_run"] = "true";
+  passed_config["data"]               = "some content";
+
+  // Extract _reload (same logic as execute_reload)
+  if (passed_config.IsMap() && passed_config["_reload"]) {
+    auto dir = passed_config["_reload"];
+    if (dir.IsMap()) {
+      // We can't call set_reload_directives directly (private).
+      // But we can verify the extraction logic works on the YAML node.
+      REQUIRE(dir["id"].as<std::string>() == "foo");
+      REQUIRE(dir["dry_run"].as<std::string>() == "true");
+      passed_config.remove("_reload");
+    }
+  }
+
+  // After extraction, passed_config should only have "data"
+  REQUIRE_FALSE(passed_config["_reload"].IsDefined());
+  REQUIRE(passed_config["data"].as<std::string>() == "some content");
+  REQUIRE(passed_config.size() == 1);
+}
+
+TEST_CASE("ConfigContext: _reload extraction with directives only", 
"[config][context][directive]")
+{
+  YAML::Node passed_config;
+  passed_config["_reload"]["id"] = "bar";
+
+  // Extract
+  YAML::Node directives;
+  if (passed_config.IsMap() && passed_config["_reload"]) {
+    directives = passed_config["_reload"];
+    passed_config.remove("_reload");
+  }
+
+  REQUIRE(directives.IsDefined());
+  REQUIRE(directives["id"].as<std::string>() == "bar");
+
+  // After extraction with only _reload, the map should be empty
+  REQUIRE(passed_config.size() == 0);
+}
+
+TEST_CASE("ConfigContext: _reload extraction with content only", 
"[config][context][directive]")
+{
+  YAML::Node passed_config;
+  passed_config["rules"].push_back("rule1");
+  passed_config["rules"].push_back("rule2");
+
+  bool extracted = false;
+  if (passed_config.IsMap() && passed_config["_reload"]) {
+    extracted = true;
+    passed_config.remove("_reload");
+  }
+
+  // No _reload key present — extraction did not fire
+  REQUIRE_FALSE(extracted);
+
+  // Content untouched
+  REQUIRE(passed_config["rules"].size() == 2);
+}
+
+TEST_CASE("ConfigContext: _reload non-map is rejected", 
"[config][context][directive]")
+{
+  YAML::Node passed_config;
+  passed_config["_reload"] = "scalar_value";
+  passed_config["data"]    = "content";
+
+  bool extracted = false;
+  bool rejected  = false;
+  if (passed_config.IsMap() && passed_config["_reload"]) {
+    auto dir = passed_config["_reload"];
+    if (!dir.IsMap()) {
+      rejected = true;
+    } else {
+      extracted = true;
+    }
+    passed_config.remove("_reload");
+  }
+
+  REQUIRE(rejected);
+  REQUIRE_FALSE(extracted);
+  // _reload is still removed even when rejected
+  REQUIRE_FALSE(passed_config["_reload"].IsDefined());
+  REQUIRE(passed_config["data"].as<std::string>() == "content");
+}
+
+// ─── Wire format integration: -D flag produces correct YAML structure 
─────────
+
+TEST_CASE("Wire format: -D produces _reload nested under config key", 
"[config][directive][wire]")
+{
+  YAML::Node  configs;
+  std::string err;
+
+  parse_directive("myconfig.id=foo", configs, err);
+
+  // Verify the structure matches what the server expects
+  REQUIRE(configs.IsMap());
+  REQUIRE(configs["myconfig"].IsMap());
+  REQUIRE(configs["myconfig"]["_reload"].IsMap());
+  REQUIRE(configs["myconfig"]["_reload"]["id"].as<std::string>() == "foo");
+}
+
+TEST_CASE("Wire format: -D combined with -d content", 
"[config][directive][wire]")
+{
+  YAML::Node configs;
+
+  // Simulate -d providing content
+  configs["myconfig"]["rules"].push_back("rule1");
+
+  // Then -D adding directives
+  std::string err;
+  parse_directive("myconfig.id=foo", configs, err);
+
+  // Both coexist under the same config key
+  REQUIRE(configs["myconfig"]["rules"].size() == 1);
+  REQUIRE(configs["myconfig"]["_reload"]["id"].as<std::string>() == "foo");
+}
diff --git a/src/traffic_ctl/CtrlCommands.cc b/src/traffic_ctl/CtrlCommands.cc
index 03dee5075a..1f64bd45db 100644
--- a/src/traffic_ctl/CtrlCommands.cc
+++ b/src/traffic_ctl/CtrlCommands.cc
@@ -81,6 +81,33 @@ display_errors(BasePrinter *printer, 
std::vector<ConfigReloadResponse::Error> co
     }
   }
 }
+
+/// Parse a single --directive (-D) argument "config_key.directive_key=value"
+/// and inject into configs[config_key]["_reload"][directive_key].
+/// Returns true on success, sets error_out on parse failure.
+bool
+parse_directive(std::string_view dir, YAML::Node &configs, std::string 
&error_out)
+{
+  auto dot = dir.find('.');
+  if (dot == std::string_view::npos || dot == 0) {
+    error_out = "Invalid directive format '" + std::string(dir) + "'. 
Expected: config_key.directive_key=value";
+    return false;
+  }
+
+  auto eq = dir.find('=', dot + 1);
+  if (eq == std::string_view::npos || eq == dot + 1) {
+    error_out = "Invalid directive format '" + std::string(dir) + "'. 
Expected: config_key.directive_key=value";
+    return false;
+  }
+
+  std::string config_key{dir.substr(0, dot)};
+  std::string directive_key{dir.substr(dot + 1, eq - dot - 1)};
+  std::string value{dir.substr(eq + 1)};
+
+  configs[config_key]["_reload"][directive_key] = value;
+  return true;
+}
+
 } // namespace
 
 BasePrinter::Options::FormatFlags
@@ -557,6 +584,27 @@ ConfigCommand::config_reload()
     }
   }
 
+  // Parse --directive (-D) arguments into configs[key]["_reload"][directive] 
= value
+  auto dir_args = get_parsed_arguments()->get("directive");
+  for (auto const &dir : dir_args) {
+    if (dir.empty()) {
+      continue;
+    }
+    if (dir[0] == '-') {
+      _printer->write_output("Error: '" + dir +
+                             "' looks like a flag, not a directive. "
+                             "Place -D as the last option on the command 
line.");
+      App_Exit_Status_Code = CTRL_EX_ERROR;
+      return;
+    }
+    std::string err;
+    if (!parse_directive(dir, configs, err)) {
+      _printer->write_output("Error: " + err);
+      App_Exit_Status_Code = CTRL_EX_ERROR;
+      return;
+    }
+  }
+
   using ConfigError = config::reload::errors::ConfigReloadError;
 
   auto contains_error = [](std::vector<ConfigReloadResponse::Error> const 
&errors, ConfigError error) -> bool {
diff --git a/src/traffic_ctl/traffic_ctl.cc b/src/traffic_ctl/traffic_ctl.cc
index 0ac3021f1c..9697aaa05d 100644
--- a/src/traffic_ctl/traffic_ctl.cc
+++ b/src/traffic_ctl/traffic_ctl.cc
@@ -164,6 +164,8 @@ main([[maybe_unused]] int argc, const char **argv)
     //   -d @-                      - read config from stdin
     //   -d "yaml: content"         - inline yaml string
     .add_option("--data", "-d", "Inline config data (@file, @- for stdin, or 
yaml string)", "", MORE_THAN_ZERO_ARG_N, "")
+    .add_option("--directive", "-D", "Pass a reload directive to a config 
handler (format: config_key.directive_key=value)", "",
+                MORE_THAN_ZERO_ARG_N, "")
     .add_option(
       "--initial-wait", "-w",
       "Initial wait before first poll, giving the server time to schedule all 
handlers (seconds). Accepts fractional values", "", 1,
diff --git a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py 
b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py
index bf5f3265f6..55d3810345 100644
--- a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py
+++ b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py
@@ -395,3 +395,97 @@ def validate_large_config(resp: Response):
 
 tr.Processes.Default.Streams.stdout = 
Testers.CustomJSONRPCResponse(validate_large_config)
 tr.StillRunningAfter = ts
+
+# ============================================================================
+# Test 11: Reload directive for registered FileOnly config (sni)
+# Directive-only request — sni is FileOnly, so the RPC handler rejects with 
6011.
+# Verifies the _reload structure is handled gracefully through the RPC stack.
+# ============================================================================
+tr = Test.AddTestRun("Reload directive for FileOnly config (sni)")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts, Request.admin_config_reload(configs={"sni": 
{"_reload": {"fqdn": "*.example.com"}}}))
+
+
+def validate_directive_fileonly(resp: Response):
+    '''sni is FileOnly — directive-only request rejected with 6011'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if not errors:
+        return (False, f"Expected rejection for FileOnly config, got: 
{result}")
+
+    error_str = str(errors)
+    if '6011' in error_str:
+        return (True, f"Directive-only correctly rejected for FileOnly config: 
{errors}")
+    return (False, f"Expected error 6011, got: {errors}")
+
+
+tr.Processes.Default.Streams.stdout = 
Testers.CustomJSONRPCResponse(validate_directive_fileonly)
+tr.StillRunningAfter = ts
+
+# ============================================================================
+# Test 12: Reload directive for unregistered config (virtualhost)
+# virtualhost is not registered yet — should get 6010.
+# This is the intended use case once the virtualhost handler is registered.
+# ============================================================================
+tr = Test.AddTestRun("Reload directive for unregistered config (virtualhost)")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts, 
Request.admin_config_reload(configs={"virtualhost": {"_reload": {"id": 
"myhost.example.com"}}}))
+
+
+def validate_directive_unregistered(resp: Response):
+    '''virtualhost is not registered — rejected with 6010'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if not errors:
+        return (False, f"Expected error for unregistered config, got: 
{result}")
+
+    error_str = str(errors)
+    if '6010' in error_str or 'not registered' in error_str:
+        return (True, f"Directive for unregistered config rejected: {errors}")
+    return (False, f"Expected error 6010, got: {errors}")
+
+
+tr.Processes.Default.Streams.stdout = 
Testers.CustomJSONRPCResponse(validate_directive_unregistered)
+tr.StillRunningAfter = ts
+
+# ============================================================================
+# Test 13: Directives mixed with content for FileOnly config (ip_allow)
+# _reload directives alongside actual config content — still rejected with 
6011.
+# ============================================================================
+tr = Test.AddTestRun("Directives mixed with content for FileOnly config")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(
+    ts,
+    Request.admin_config_reload(
+        configs={
+            "ip_allow": {
+                "_reload": {
+                    "validate_only": "true"
+                },
+                "rules": [{
+                    "apply": "in",
+                    "ip_addrs": "0/0",
+                    "action": "allow"
+                }]
+            }
+        }))
+
+
+def validate_directive_mixed(resp: Response):
+    '''ip_allow is FileOnly — mixed directive+content rejected with 6011'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if not errors:
+        return (False, f"Expected rejection, got: {result}")
+
+    error_str = str(errors)
+    if '6011' in error_str:
+        return (True, f"Mixed directive+content correctly rejected: {errors}")
+    return (False, f"Expected error 6011, got: {errors}")
+
+
+tr.Processes.Default.Streams.stdout = 
Testers.CustomJSONRPCResponse(validate_directive_mixed)
+tr.StillRunningAfter = ts

Reply via email to