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

dmeden 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 42fb5702f4 `plugin.config` to `plugin.yaml` migration.  (#13070)
42fb5702f4 is described below

commit 42fb5702f4a6103c73e18c52aa37311722b23b0f
Author: Damian Meden <[email protected]>
AuthorDate: Wed Apr 15 09:59:29 2026 +0200

    `plugin.config` to `plugin.yaml` migration.  (#13070)
    
    * Add plugin.yaml as YAML alternative to plugin.config
    
    Introduce plugin.yaml, a YAML-based configuration file for global
    plugins that replaces the legacy line-based plugin.config format.
    
    New capabilities over plugin.config:
    - enabled: false to disable plugins without removing them
    - load_order for explicit plugin loading priority
    - NOTE-level startup log per plugin with load status
    - traffic_ctl plugin list via JSONRPC for runtime introspection
    - traffic_ctl config convert plugin_config for automated migration
    - Fallback: plugin.yaml takes precedence; plugin.config used if absent
    
    * Add inline config field to plugin.yaml
    
    Add the 'config' field to plugin.yaml entries, allowing plugin
    configuration to be embedded directly using a YAML block scalar (|).
    The literal text is written to a temporary file at startup and passed
    to the plugin as an argument. Only scalar values are accepted;
    structured YAML is rejected to preserve quoting semantics that
    plugins like txn_box rely on.
---
 doc/admin-guide/files/index.en.rst                 |   5 +
 doc/admin-guide/files/plugin.config.en.rst         |  26 ++
 doc/admin-guide/files/plugin.yaml.en.rst           | 382 +++++++++++++++++++++
 doc/appendices/command-line/traffic_ctl.en.rst     |  71 ++++
 doc/release-notes/whats-new.en.rst                 |   8 +
 .../config/plugin_config.h                         |  48 +--
 include/mgmt/rpc/handlers/plugins/Plugins.h        |   1 +
 include/proxy/Plugin.h                             |  31 ++
 include/tscore/Filenames.h                         |   7 +-
 src/config/CMakeLists.txt                          |   8 +-
 src/config/plugin_config.cc                        | 190 ++++++++++
 src/config/unit_tests/test_plugin_config.cc        | 206 +++++++++++
 src/mgmt/rpc/handlers/plugins/Plugins.cc           |  32 ++
 src/proxy/Plugin.cc                                | 315 ++++++++++++++++-
 src/proxy/unit_tests/CMakeLists.txt                |   3 +-
 src/proxy/unit_tests/test_PluginYAML.cc            | 324 +++++++++++++++++
 src/traffic_ctl/ConvertConfigCommand.cc            |  47 +++
 src/traffic_ctl/ConvertConfigCommand.h             |   6 +-
 src/traffic_ctl/CtrlCommands.cc                    |  49 +++
 src/traffic_ctl/CtrlCommands.h                     |   2 +
 src/traffic_ctl/jsonrpc/CtrlRPCRequests.h          |  22 ++
 src/traffic_ctl/jsonrpc/ctrl_yaml_codecs.h         |  25 ++
 src/traffic_ctl/traffic_ctl.cc                     |   7 +
 src/traffic_server/RpcAdminPubHandlers.cc          |   2 +
 src/traffic_server/traffic_server.cc               |  10 +-
 .../pluginTest/plugin_yaml/plugin_yaml.test.py     |  90 +++++
 .../convert_plugin_config.test.py                  |  68 ++++
 .../convert_plugin_config/gold/basic.yaml          |  10 +
 .../convert_plugin_config/gold/commented.yaml      |  17 +
 .../convert_plugin_config/gold/quoted.yaml         |   8 +
 .../convert_plugin_config/gold/skip_disabled.yaml  |   8 +
 .../legacy_config/basic.config                     |   3 +
 .../legacy_config/commented.config                 |   5 +
 .../legacy_config/quoted.config                    |   2 +
 34 files changed, 1993 insertions(+), 45 deletions(-)

diff --git a/doc/admin-guide/files/index.en.rst 
b/doc/admin-guide/files/index.en.rst
index 95ce139659..38b1db9b41 100644
--- a/doc/admin-guide/files/index.en.rst
+++ b/doc/admin-guide/files/index.en.rst
@@ -31,6 +31,7 @@ Configuration Files
    logging.yaml.en
    parent.config.en
    plugin.config.en
+   plugin.yaml.en
    records.yaml.en
    remap.config.en
    remap.yaml.en
@@ -63,6 +64,10 @@ Configuration Files
    Control runtime loadable plugins available to |TS|, as well as their
    configurations.
 
+:doc:`plugin.yaml.en`
+   YAML-based alternative to :doc:`plugin.config.en` with support for
+   disabling plugins, explicit load ordering, and inline configuration.
+
 :doc:`records.yaml.en`
    Contains many configuration variables affecting |TS| operation.
 
diff --git a/doc/admin-guide/files/plugin.config.en.rst 
b/doc/admin-guide/files/plugin.config.en.rst
index b255a02b5b..c2e6919e33 100644
--- a/doc/admin-guide/files/plugin.config.en.rst
+++ b/doc/admin-guide/files/plugin.config.en.rst
@@ -23,6 +23,31 @@ plugin.config
 
 .. configfile:: plugin.config
 
+.. warning::
+
+   **Use** :file:`plugin.yaml` **instead.** The :file:`plugin.config` format is
+   maintained for backward compatibility but :file:`plugin.yaml` is the
+   recommended way to configure global plugins. It supports disabling plugins
+   without deleting lines, explicit load ordering, and inline configuration.
+
+   If :file:`plugin.yaml` exists in the configuration directory, |TS| will load
+   plugins from it and ignore :file:`plugin.config` entirely.
+
+   See :doc:`plugin.yaml.en` for the full reference.
+
+   **Migrating:** use ``traffic_ctl`` to convert an existing file 
automatically:
+
+   .. code-block:: bash
+
+      # Preview the converted output on stdout
+      traffic_ctl config convert plugin_config plugin.config -
+
+      # Write directly to plugin.yaml
+      traffic_ctl config convert plugin_config plugin.config plugin.yaml
+
+   Commented-out lines in :file:`plugin.config` are converted to
+   ``enabled: false`` entries.  Pass ``--skip-disabled`` to drop them instead.
+
 Description
 ===========
 
@@ -71,6 +96,7 @@ Examples
 See Also
 ========
 
+:doc:`plugin.yaml.en`,
 :manpage:`TSAPI(3ts)`,
 :manpage:`TSPluginInit(3ts)`,
 :manpage:`remap.config(5)`
diff --git a/doc/admin-guide/files/plugin.yaml.en.rst 
b/doc/admin-guide/files/plugin.yaml.en.rst
new file mode 100644
index 0000000000..772e9e6a2d
--- /dev/null
+++ b/doc/admin-guide/files/plugin.yaml.en.rst
@@ -0,0 +1,382 @@
+.. 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:: ../../common.defs
+
+===========
+plugin.yaml
+===========
+
+.. configfile:: plugin.yaml
+
+The :file:`plugin.yaml` file provides a YAML-based alternative to
+:file:`plugin.config` for configuring global plugins available to |TS|.
+Global plugins are loaded at startup and have global effect on all
+transactions. This is in contrast to plugins specified in
+:file:`remap.config` or :file:`remap.yaml`, whose effects are limited to
+specific mapping rules.
+
+Configuration File Fallback
+============================
+
+|TS| will attempt to load :file:`plugin.yaml` first. If this file is
+not found, it will fall back to loading :file:`plugin.config`. If both
+files exist, only :file:`plugin.yaml` will be used. This allows for a
+gradual migration from the legacy configuration format to YAML.
+
+Format
+======
+
+The :file:`plugin.yaml` file uses YAML syntax with a single required key:
+
+- ``plugins`` (required): A sequence of plugin entries.
+
+Each plugin entry is a YAML mapping with the following fields:
+
+``path``
+--------
+
+**Required.** Path to the ``.so`` file. This path can be absolute or
+relative to the plugin directory (usually
+``/usr/local/libexec/trafficserver``).
+
+``enabled``
+-----------
+
+**Optional.** Boolean. When set to ``false``, the plugin is skipped
+entirely during startup — no ``dlopen``, no ``TSPluginInit``. The
+configuration entry remains in the file for easy re-enabling.
+
+**Default:** ``true``
+
+``params``
+----------
+
+**Optional.** A YAML sequence of string arguments passed to the plugin's
+``TSPluginInit`` function as ``argc/argv``. Arguments that begin
+with ``$`` designate |TS| configuration variables and will be expanded
+to their current value before the plugin is loaded.
+
+``config``
+----------
+
+**Optional.** Inline configuration content specified as a YAML scalar.
+The text is written to a temporary file at startup and the path is
+passed to the plugin as an argument, so existing plugins work without
+modification.
+
+.. tip::
+
+   Use a literal block scalar (``|``) to preserve exact text including
+   newlines and quoting -- this is important for plugins like
+   ``txn_box.so`` that assign special meaning to YAML quoting.
+
+.. note::
+
+   Structured YAML (mappings or sequences) is rejected because
+   re-serializing through a YAML emitter strips quoting semantics that
+   some plugins depend on.  For example, ``txn_box.so`` distinguishes
+   ``"literal"`` (a quoted string) from ``extractor-name`` (an unquoted
+   reference), and that distinction would be lost after a round-trip
+   through ``YAML::Emitter``.  Supporting structured YAML may be
+   revisited in the future.
+
+``load_order``
+--------------
+
+**Optional.** Integer. Provides explicit control over the order in which
+plugins are loaded and therefore the order in which they are chained for
+request processing.
+
+The loading rules are:
+
+1. Plugins **with** ``load_order`` are loaded first, sorted ascending by
+   value (lowest number loads first).
+2. Among plugins with the **same** ``load_order`` value, their relative
+   order in the YAML file is preserved (stable sort).
+3. Plugins **without** ``load_order`` are loaded after all ordered
+   plugins, in the order they appear in the YAML file.
+
+Most deployments do not need ``load_order`` — simply list plugins in the
+desired order in the YAML file. Use ``load_order`` when the file is
+managed by automation tools that may reorder entries, or when you want
+to guarantee a specific plugin loads first regardless of where it
+appears in the file.
+
+**Default:** Unset (YAML sequence order).
+
+Basic Structure
+===============
+
+.. code-block:: yaml
+
+   plugins:
+     - path: stats_over_http.so
+
+     - path: abuse.so
+       params:
+         - etc/trafficserver/abuse.config
+
+     - path: header_rewrite.so
+       params:
+         - etc/trafficserver/header_rewrite.config
+
+     - path: icx.so
+       params:
+         - etc/trafficserver/icx.config
+         - $proxy.config.http.connect_attempts_timeout
+
+     - path: experimental_plugin.so
+       enabled: false
+       params:
+         - --verbose
+
+.. important::
+
+   **Loading order matters.** Plugins are loaded in the order they
+   appear in the YAML file, and this is the order in which they are
+   chained for request processing (hooks are called in load order). If
+   you need a plugin to run before another, place it earlier in the file
+   or assign it a lower ``load_order`` value.
+
+New Features Over plugin.config
+================================
+
+:file:`plugin.yaml` introduces several features not available in the
+legacy :file:`plugin.config` format:
+
+*  **Disable without deleting** — set ``enabled: false`` to skip a
+   plugin without removing or commenting out the line.
+*  **Explicit load ordering** — use ``load_order`` to control loading
+   priority independent of file position.
+*  **Inline configuration** — embed a plugin's config content directly
+   via the ``config`` field instead of maintaining a separate file.
+*  **Variable expansion** — ``$record`` references in ``params`` are
+   expanded to their current value at load time (same as
+   :file:`plugin.config`).
+*  **Startup logging** — each plugin produces a ``NOTE``-level log line
+   showing its load sequence number, path, and status.
+*  **Runtime introspection** — ``traffic_ctl plugin list`` shows the
+   loaded plugins and their status via JSONRPC.
+*  **Automated migration** — ``traffic_ctl config convert plugin_config``
+   converts an existing :file:`plugin.config` to :file:`plugin.yaml`.
+
+Examples
+========
+
+Disabling a Plugin
+------------------
+
+.. code-block:: yaml
+
+   plugins:
+     - path: debug_plugin.so
+       enabled: false
+
+The plugin entry remains in the configuration file but is not loaded.
+Set ``enabled: true`` (or remove the field) to re-enable it.
+
+Plugin Loading Order
+---------------------
+
+By default, plugins load in the order they appear in the YAML file —
+top to bottom. This is the same behavior as :file:`plugin.config` and
+is sufficient for most deployments:
+
+.. code-block:: yaml
+
+   plugins:
+     - path: certifier.so           # loads 1st
+     - path: header_rewrite.so      # loads 2nd
+     - path: stats_over_http.so     # loads 3rd
+
+When ``load_order`` is set, it overrides the file order. Plugins with
+``load_order`` always load before plugins without it:
+
+.. code-block:: yaml
+
+   plugins:
+     - path: stats_over_http.so
+       load_order: 300
+
+     - path: certifier.so
+       load_order: 100
+
+     - path: header_rewrite.so
+       load_order: 200
+
+     - path: xdebug.so
+
+Despite the YAML sequence order, the actual load order is:
+
+1. ``certifier.so`` (load_order: 100)
+2. ``header_rewrite.so`` (load_order: 200)
+3. ``stats_over_http.so`` (load_order: 300)
+4. ``xdebug.so`` (no load_order — loaded last, in file order)
+
+.. tip::
+
+   Use gaps between ``load_order`` values (e.g. 100, 200, 300) so new
+   plugins can be inserted later without renumbering.
+
+Inline Configuration
+--------------------
+
+The ``config`` field lets you embed a plugin's configuration directly in
+:file:`plugin.yaml` instead of maintaining a separate file. At startup, |TS|
+writes the content to a temporary file in the configuration directory and 
passes
+the path of that file to the plugin as an argument — exactly the same way a
+``params`` entry pointing to an external file would work. The plugin reads the
+file as usual; it has no knowledge the content was inlined.
+
+Use the YAML literal block scalar (``|``) to provide the content:
+
+.. code-block:: yaml
+
+   plugins:
+     - path: header_rewrite.so
+       config: |
+         cond %{SEND_RESPONSE_HDR_HOOK}
+            set-header X-Debug "true"
+
+The text after ``|`` is preserved exactly (including newlines and
+indentation). It is written to a temporary file named after the plugin
+(e.g. ``<config_dir>/.header_rewrite_inline_1.conf``). Temporary files
+from a previous run are removed automatically at startup.
+
+This works equally well for plugins that read YAML configuration files.
+The block scalar preserves quoting and formatting that some YAML-consuming
+plugins rely on:
+
+.. code-block:: yaml
+
+   plugins:
+     - path: txn_box.so
+       config: |
+         txn_box:
+           when: proxy-rsp
+           do:
+             - proxy-rsp-field<X-TxnBox>: "inline-config-active"
+
+.. note::
+
+   The ``config`` field and ``params`` can be used together. When both are
+   present, the temporary file path is inserted before the ``params`` entries
+   in the argument vector:
+
+   .. code-block:: yaml
+
+      plugins:
+        - path: header_rewrite.so
+          config: |
+            cond %{SEND_RESPONSE_HDR_HOOK}
+              set-header X-Source "inline"
+          params:
+            - --verbose
+
+   The plugin receives ``argv = ["header_rewrite.so",
+   "<config_dir>/.header_rewrite_inline_1.conf", "--verbose"]``.
+
+   The inline file path is always a bare positional argument at ``argv[1]``.
+   This works for plugins that take a config file as their first argument
+   (e.g., ``header_rewrite.so``, ``txn_box.so``).  Plugins that require a
+   flag before the filename (e.g., ``--config <file>``) should use ``params``
+   pointing to a separate file instead of ``config``.
+
+Configuration Variable Expansion
+---------------------------------
+
+.. code-block:: yaml
+
+   plugins:
+     - path: icx.so
+       params:
+         - etc/trafficserver/icx.config
+         - $proxy.config.http.connect_attempts_timeout
+
+Arguments beginning with ``$`` are expanded to the current value of the
+corresponding |TS| configuration variable before the plugin is loaded.
+
+Migration from plugin.config
+==============================
+
+.. list-table::
+   :header-rows: 1
+   :widths: 50 50
+
+   * - plugin.config
+     - plugin.yaml
+   * - ::
+
+         stats_over_http.so
+     - .. code-block:: yaml
+
+         plugins:
+           - path: stats_over_http.so
+
+   * - ::
+
+         abuse.so etc/trafficserver/abuse.config
+     - .. code-block:: yaml
+
+         plugins:
+           - path: abuse.so
+             params:
+               - etc/trafficserver/abuse.config
+
+   * - ::
+
+         icx.so etc/trafficserver/icx.config 
$proxy.config.http.connect_attempts_timeout
+     - .. code-block:: yaml
+
+         plugins:
+           - path: icx.so
+             params:
+               - etc/trafficserver/icx.config
+               - $proxy.config.http.connect_attempts_timeout
+
+   * - ::
+
+         # header_rewrite.so etc/trafficserver/header_rewrite.config
+     - .. code-block:: yaml
+
+         plugins:
+           - path: header_rewrite.so
+             params:
+               - etc/trafficserver/header_rewrite.config
+             enabled: false
+
+Startup Logging
+===============
+
+When plugins are loaded from :file:`plugin.yaml`, each plugin produces a
+``NOTE``-level log line showing its load sequence number, path, and status:
+
+::
+
+   [NOTE] plugin #1 loading: certifier.so (load_order: 100)
+   [NOTE] plugin #2 loading: header_rewrite.so
+   [NOTE] plugin #3 skipped: experimental_plugin.so (enabled: false)
+
+See Also
+========
+
+:doc:`plugin.config.en`,
+:manpage:`TSAPI(3ts)`,
+:manpage:`TSPluginInit(3ts)`,
+:doc:`remap.config.en`,
+:doc:`remap.yaml.en`
diff --git a/doc/appendices/command-line/traffic_ctl.en.rst 
b/doc/appendices/command-line/traffic_ctl.en.rst
index a60999b10b..dc560fe9cc 100644
--- a/doc/appendices/command-line/traffic_ctl.en.rst
+++ b/doc/appendices/command-line/traffic_ctl.en.rst
@@ -732,6 +732,47 @@ Display the current value of a configuration record.
    Display information about the registered files in |TS|. This includes the 
full file path, config record name, parent config (if any)
    if needs root access and if the file is required in |TS|.
 
+.. program:: traffic_ctl config
+.. option:: convert <type> <args...>
+
+   Convert a legacy configuration file to its YAML equivalent. The conversion
+   runs locally — no running :program:`traffic_server` is required.
+
+   Supported types:
+
+   ``ssl_multicert``
+      Convert ``ssl_multicert.config`` to :file:`ssl_multicert.yaml`.
+
+      .. code-block:: bash
+
+         traffic_ctl config convert ssl_multicert ssl_multicert.config 
ssl_multicert.yaml
+
+   ``storage``
+      Convert ``storage.config`` and ``volume.config`` to
+      :file:`storage.yaml`.
+
+      .. code-block:: bash
+
+         traffic_ctl config convert storage storage.config volume.config 
storage.yaml
+
+   ``plugin_config``
+      Convert :file:`plugin.config` to :file:`plugin.yaml`.
+      Commented-out lines are converted to ``enabled: false`` entries by
+      default. Pass ``--skip-disabled`` to omit them entirely.
+
+      .. code-block:: bash
+
+         # Write to a file
+         traffic_ctl config convert plugin_config plugin.config plugin.yaml
+
+         # Preview on stdout
+         traffic_ctl config convert plugin_config plugin.config -
+
+         # Drop commented-out entries
+         traffic_ctl config convert plugin_config plugin.config plugin.yaml 
--skip-disabled
+
+   For all types, use ``-`` as the output file to write to stdout.
+
 .. program:: traffic_ctl config
 .. option:: ssl-multicert show [--yaml | --json]
 
@@ -993,6 +1034,36 @@ traffic_ctl plugin
 -------------------
 
 .. program:: traffic_ctl plugin
+.. option:: list
+
+   Display the globally loaded plugins and their status.  The output includes
+   the configuration source, each plugin's sequence index, path,
+   ``load_order`` (when any plugin has one), and status.
+
+   Example (with ``load_order``):
+
+   .. code-block:: bash
+
+      $ traffic_ctl plugin list
+      source: plugin.yaml
+        #  plugin                          load_order   status
+        1  certifier.so                    100          loaded
+        2  header_rewrite.so               --           loaded
+        3  debug_plugin.so                 --           disabled
+
+   Example (without ``load_order``):
+
+   .. code-block:: bash
+
+      $ traffic_ctl plugin list
+      source: plugin.yaml
+        #  plugin                          status
+        1  stats_over_http.so              loaded
+        2  header_rewrite.so               loaded
+
+   This command requires a running :program:`traffic_server` instance — it
+   communicates via JSONRPC.
+
 .. option:: msg TAG DATA
 
    :ref:`admin_plugin_send_basic_msg`
diff --git a/doc/release-notes/whats-new.en.rst 
b/doc/release-notes/whats-new.en.rst
index 803cc17d97..2c3da9f1a1 100644
--- a/doc/release-notes/whats-new.en.rst
+++ b/doc/release-notes/whats-new.en.rst
@@ -76,6 +76,14 @@ TS API
 Features
 --------
 
+* Add :file:`plugin.yaml`, a YAML-based replacement for :file:`plugin.config`.
+  New features include disabling plugins without deleting lines
+  (``enabled: false``), explicit ``load_order``, inline ``config`` content, and
+  startup logging. See :doc:`../admin-guide/files/plugin.yaml.en`.
+* traffic_ctl: Add ``plugin list`` to show loaded plugins and their status via
+  JSONRPC.
+* traffic_ctl: Add ``config convert plugin_config`` to migrate
+  :file:`plugin.config` to :file:`plugin.yaml`.
 * Add the ``cqssg`` log field for TLS group name logging
 * traffic_ctl: Add a new :ref:`server <traffic-control-command-server-status>` 
command to show some basic internal
   information
diff --git a/src/traffic_ctl/ConvertConfigCommand.h 
b/include/config/plugin_config.h
similarity index 56%
copy from src/traffic_ctl/ConvertConfigCommand.h
copy to include/config/plugin_config.h
index fd6032f826..db131cba07 100644
--- a/src/traffic_ctl/ConvertConfigCommand.h
+++ b/include/config/plugin_config.h
@@ -1,6 +1,6 @@
 /** @file
 
-  Configuration format conversion command for traffic_ctl.
+  Plugin configuration parsing and marshalling.
 
   @section license License
 
@@ -23,31 +23,35 @@
 
 #pragma once
 
-#include "CtrlCommands.h"
+#include <string>
+#include <vector>
 
-/**
- * Command handler for configuration format conversion.
- *
- * Converts configuration files from legacy formats to YAML.
- * Supports: ssl_multicert, storage
- */
-class ConvertConfigCommand : public CtrlCommand
+#include "config/config_result.h"
+
+namespace config
+{
+
+struct PluginConfigEntry {
+  std::string              path;
+  std::vector<std::string> args;
+  bool                     enabled{true};
+};
+
+using PluginConfigData = std::vector<PluginConfigEntry>;
+
+class PluginConfigParser
 {
 public:
-  /**
-   * Construct the command from parsed arguments.
-   *
-   * @param[in] args Parsed command line arguments.
-   */
-  ConvertConfigCommand(ts::Arguments *args);
+  ConfigResult<PluginConfigData> parse(std::string const &filename);
 
 private:
-  void convert_ssl_multicert();
-  void convert_storage();
-
-  std::string _input_file;
-  std::string _output_file;
+  ConfigResult<PluginConfigData> parse_legacy(std::string_view content);
+};
 
-  // For storage conversion only: optional volume.config path.
-  std::string _volume_config_file;
+class PluginConfigMarshaller
+{
+public:
+  std::string to_yaml(PluginConfigData const &config);
 };
+
+} // namespace config
diff --git a/include/mgmt/rpc/handlers/plugins/Plugins.h 
b/include/mgmt/rpc/handlers/plugins/Plugins.h
index 250b90d4e6..b07d3ed039 100644
--- a/include/mgmt/rpc/handlers/plugins/Plugins.h
+++ b/include/mgmt/rpc/handlers/plugins/Plugins.h
@@ -25,4 +25,5 @@
 namespace rpc::handlers::plugins
 {
 swoc::Rv<YAML::Node> plugin_send_basic_msg(std::string_view const &id, 
YAML::Node const &params);
+swoc::Rv<YAML::Node> get_plugin_list(std::string_view const &id, YAML::Node 
const &params);
 } // namespace rpc::handlers::plugins
diff --git a/include/proxy/Plugin.h b/include/proxy/Plugin.h
index f86f2cbc04..d7af6ef5bb 100644
--- a/include/proxy/Plugin.h
+++ b/include/proxy/Plugin.h
@@ -24,6 +24,8 @@
 #pragma once
 
 #include <string>
+#include <vector>
+#include "config/config_result.h"
 #include "tscore/List.h"
 
 enum class PluginDynamicReloadMode { OFF, ON, COUNT };
@@ -33,6 +35,30 @@ void parsePluginConfig();
 
 bool isPluginDynamicReloadEnabled();
 
+struct PluginYAMLEntry {
+  std::string              path;
+  bool                     enabled{true};
+  int                      load_order{-1};
+  std::vector<std::string> params;
+  std::string              config_literal;
+};
+
+using PluginYAMLEntries = std::vector<PluginYAMLEntry>;
+
+struct PluginLoadSummary {
+  struct Entry {
+    std::string path;
+    int         load_order{-1};
+    bool        enabled{true};
+    bool        loaded{false};
+    int         index{0};
+  };
+  std::string        source;
+  std::vector<Entry> entries;
+};
+
+const PluginLoadSummary &get_plugin_load_summary();
+
 struct PluginRegInfo {
   PluginRegInfo();
   ~PluginRegInfo();
@@ -54,8 +80,13 @@ extern DLL<PluginRegInfo> plugin_reg_list;
 extern PluginRegInfo     *plugin_reg_current;
 
 bool plugin_init(bool validateOnly = false);
+bool plugin_yaml_init(bool validateOnly = false);
 bool plugin_dso_load(const char *path, void *&handle, void *&init, std::string 
&error);
 
+/// Parse plugin.yaml and return sorted entries.
+/// Exposed (non-static) for unit testing.
+config::ConfigResult<PluginYAMLEntries> parse_plugin_yaml(const char 
*yaml_path);
+
 /** Abstract interface class for plugin based continuations.
 
     The primary intended use of this is for logging so that continuations
diff --git a/include/tscore/Filenames.h b/include/tscore/Filenames.h
index 29b4a505c4..b36e282aeb 100644
--- a/include/tscore/Filenames.h
+++ b/include/tscore/Filenames.h
@@ -25,9 +25,10 @@ namespace ts
 {
 namespace filename
 {
-  constexpr const char *STORAGE = "storage.yaml";
-  constexpr const char *RECORDS = "records.yaml";
-  constexpr const char *PLUGIN  = "plugin.config";
+  constexpr const char *STORAGE     = "storage.yaml";
+  constexpr const char *RECORDS     = "records.yaml";
+  constexpr const char *PLUGIN      = "plugin.config";
+  constexpr const char *PLUGIN_YAML = "plugin.yaml";
 
   // These still need to have their corresponding records.yaml settings 
removed.
   constexpr const char *LOGGING       = "logging.yaml";
diff --git a/src/config/CMakeLists.txt b/src/config/CMakeLists.txt
index dd761eeb80..78730f13cb 100644
--- a/src/config/CMakeLists.txt
+++ b/src/config/CMakeLists.txt
@@ -17,10 +17,10 @@
 
 set(CONFIG_PUBLIC_HEADERS
     ${PROJECT_SOURCE_DIR}/include/config/config_result.h 
${PROJECT_SOURCE_DIR}/include/config/ssl_multicert.h
-    ${PROJECT_SOURCE_DIR}/include/config/storage.h
+    ${PROJECT_SOURCE_DIR}/include/config/storage.h 
${PROJECT_SOURCE_DIR}/include/config/plugin_config.h
 )
 
-add_library(tsconfig ssl_multicert.cc storage.cc)
+add_library(tsconfig ssl_multicert.cc storage.cc plugin_config.cc)
 
 add_library(ts::config ALIAS tsconfig)
 
@@ -48,7 +48,9 @@ endif()
 clang_tidy_check(tsconfig)
 
 if(BUILD_TESTING)
-  add_executable(test_tsconfig unit_tests/test_ssl_multicert.cc 
unit_tests/test_storage.cc)
+  add_executable(
+    test_tsconfig unit_tests/test_ssl_multicert.cc unit_tests/test_storage.cc 
unit_tests/test_plugin_config.cc
+  )
 
   target_link_libraries(test_tsconfig PRIVATE tsconfig ts::tscore 
Catch2::Catch2WithMain)
 
diff --git a/src/config/plugin_config.cc b/src/config/plugin_config.cc
new file mode 100644
index 0000000000..417e0c212d
--- /dev/null
+++ b/src/config/plugin_config.cc
@@ -0,0 +1,190 @@
+/** @file
+
+  Plugin configuration parsing and marshalling.
+
+  @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 "config/plugin_config.h"
+
+#include <fstream>
+#include <sstream>
+
+#include <yaml-cpp/yaml.h>
+
+namespace
+{
+
+constexpr char                   KEY_PLUGINS[] = "plugins";
+constexpr swoc::Errata::Severity INFO_SEVERITY{0};
+
+std::vector<std::string>
+tokenize_plugin_line(std::string_view line)
+{
+  std::vector<std::string> tokens;
+  std::size_t              i = 0;
+
+  while (i < line.size()) {
+    while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) {
+      ++i;
+    }
+    if (i >= line.size() || line[i] == '#') {
+      break;
+    }
+
+    if (line[i] == '"') {
+      ++i;
+      std::size_t start = i;
+      while (i < line.size() && line[i] != '"') {
+        ++i;
+      }
+      tokens.emplace_back(line.substr(start, i - start));
+      if (i < line.size()) {
+        ++i;
+      }
+    } else {
+      std::size_t start = i;
+      while (i < line.size() && line[i] != ' ' && line[i] != '\t' && line[i] 
!= '#') {
+        ++i;
+      }
+      tokens.emplace_back(line.substr(start, i - start));
+    }
+  }
+  return tokens;
+}
+
+void
+emit_entry(YAML::Emitter &yaml, config::PluginConfigEntry const &entry)
+{
+  yaml << YAML::BeginMap;
+  yaml << YAML::Key << "path" << YAML::Value << entry.path;
+
+  if (!entry.enabled) {
+    yaml << YAML::Key << "enabled" << YAML::Value << false;
+  }
+
+  if (!entry.args.empty()) {
+    yaml << YAML::Key << "params" << YAML::Value << YAML::BeginSeq;
+    for (auto const &arg : entry.args) {
+      yaml << arg;
+    }
+    yaml << YAML::EndSeq;
+  }
+
+  yaml << YAML::EndMap;
+}
+
+} // namespace
+
+namespace config
+{
+
+ConfigResult<PluginConfigData>
+PluginConfigParser::parse(std::string const &filename)
+{
+  std::ifstream file(filename);
+
+  if (!file.is_open()) {
+    ConfigResult<PluginConfigData> result;
+    result.file_not_found = true;
+    result.errata.note("unable to open '{}'", filename);
+    return result;
+  }
+
+  std::ostringstream ss;
+  ss << file.rdbuf();
+  return parse_legacy(ss.str());
+}
+
+ConfigResult<PluginConfigData>
+PluginConfigParser::parse_legacy(std::string_view content)
+{
+  ConfigResult<PluginConfigData> result;
+  std::istringstream             stream{std::string{content}};
+  std::string                    line;
+  int                            line_no = 0;
+
+  while (std::getline(stream, line)) {
+    ++line_no;
+    std::string_view sv{line};
+
+    std::size_t start = 0;
+    while (start < sv.size() && (sv[start] == ' ' || sv[start] == '\t')) {
+      ++start;
+    }
+    sv = sv.substr(start);
+
+    if (sv.empty()) {
+      continue;
+    }
+
+    bool commented = false;
+    if (sv[0] == '#') {
+      commented = true;
+      sv        = sv.substr(1);
+      start     = 0;
+      while (start < sv.size() && (sv[start] == ' ' || sv[start] == '\t')) {
+        ++start;
+      }
+      sv = sv.substr(start);
+    }
+
+    if (sv.empty()) {
+      continue;
+    }
+
+    auto tokens = tokenize_plugin_line(sv);
+    if (tokens.empty()) {
+      continue;
+    }
+
+    if (tokens[0].find(".so") == std::string::npos) {
+      result.errata.note(INFO_SEVERITY, "skipping line {}: '{}' does not look 
like a plugin path (no .so)", line_no, tokens[0]);
+      continue;
+    }
+
+    PluginConfigEntry entry;
+    entry.path    = std::move(tokens[0]);
+    entry.enabled = !commented;
+    for (std::size_t i = 1; i < tokens.size(); ++i) {
+      entry.args.emplace_back(std::move(tokens[i]));
+    }
+    result.value.emplace_back(std::move(entry));
+  }
+
+  return result;
+}
+
+std::string
+PluginConfigMarshaller::to_yaml(PluginConfigData const &config)
+{
+  YAML::Emitter yaml;
+
+  yaml << YAML::BeginMap;
+  yaml << YAML::Key << KEY_PLUGINS << YAML::Value << YAML::BeginSeq;
+
+  for (auto const &entry : config) {
+    emit_entry(yaml, entry);
+  }
+
+  yaml << YAML::EndSeq << YAML::EndMap;
+  return yaml.c_str();
+}
+
+} // namespace config
diff --git a/src/config/unit_tests/test_plugin_config.cc 
b/src/config/unit_tests/test_plugin_config.cc
new file mode 100644
index 0000000000..69b32c94d4
--- /dev/null
+++ b/src/config/unit_tests/test_plugin_config.cc
@@ -0,0 +1,206 @@
+/** @file
+
+  Unit tests for plugin.config parser and marshaller.
+
+  @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 <filesystem>
+#include <fstream>
+
+#include <yaml-cpp/yaml.h>
+
+#include "config/plugin_config.h"
+
+namespace
+{
+
+class TempFile
+{
+public:
+  TempFile(std::string const &filename, std::string const &content)
+  {
+    _path = std::filesystem::temp_directory_path() / filename;
+    std::ofstream ofs(_path);
+    ofs << content;
+  }
+
+  ~TempFile() { std::filesystem::remove(_path); }
+
+  std::string
+  path() const
+  {
+    return _path.string();
+  }
+
+private:
+  std::filesystem::path _path;
+};
+
+} // namespace
+
+TEST_CASE("plugin_config parser - basic active plugins", "[plugin_config]")
+{
+  TempFile tf("plugin_basic.config", "stats_over_http.so 
_stats\nheader_rewrite.so /etc/trafficserver/rewrite.conf\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 2);
+
+  CHECK(result.value[0].path == "stats_over_http.so");
+  CHECK(result.value[0].enabled == true);
+  REQUIRE(result.value[0].args.size() == 1);
+  CHECK(result.value[0].args[0] == "_stats");
+
+  CHECK(result.value[1].path == "header_rewrite.so");
+  CHECK(result.value[1].enabled == true);
+  REQUIRE(result.value[1].args.size() == 1);
+  CHECK(result.value[1].args[0] == "/etc/trafficserver/rewrite.conf");
+}
+
+TEST_CASE("plugin_config parser - commented lines become disabled", 
"[plugin_config]")
+{
+  TempFile tf("plugin_commented.config", "stats_over_http.so\n# 
cache_promote.so --policy=lru\nxdebug.so\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 3);
+
+  CHECK(result.value[0].path == "stats_over_http.so");
+  CHECK(result.value[0].enabled == true);
+
+  CHECK(result.value[1].path == "cache_promote.so");
+  CHECK(result.value[1].enabled == false);
+  REQUIRE(result.value[1].args.size() == 1);
+  CHECK(result.value[1].args[0] == "--policy=lru");
+
+  CHECK(result.value[2].path == "xdebug.so");
+  CHECK(result.value[2].enabled == true);
+}
+
+TEST_CASE("plugin_config parser - pure comment lines are skipped", 
"[plugin_config]")
+{
+  TempFile tf("plugin_pure_comments.config", "# This is just a comment\n# 
Another comment line\nstats_over_http.so\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  CHECK(result.value[0].path == "stats_over_http.so");
+}
+
+TEST_CASE("plugin_config parser - blank lines", "[plugin_config]")
+{
+  TempFile tf("plugin_blanks.config", 
"\n\nstats_over_http.so\n\nxdebug.so\n\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 2);
+}
+
+TEST_CASE("plugin_config parser - quoted arguments", "[plugin_config]")
+{
+  TempFile tf("plugin_quoted.config", "my_plugin.so \"arg with spaces\" 
second_arg\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  REQUIRE(result.value[0].args.size() == 2);
+  CHECK(result.value[0].args[0] == "arg with spaces");
+  CHECK(result.value[0].args[1] == "second_arg");
+}
+
+TEST_CASE("plugin_config parser - plugin with no args", "[plugin_config]")
+{
+  TempFile tf("plugin_noargs.config", "xdebug.so\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  CHECK(result.value[0].path == "xdebug.so");
+  CHECK(result.value[0].args.empty());
+}
+
+TEST_CASE("plugin_config parser - dollar record references preserved", 
"[plugin_config]")
+{
+  TempFile tf("plugin_dollar.config", "my_plugin.so 
$proxy.config.http.server_ports\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  REQUIRE(result.value[0].args.size() == 1);
+  CHECK(result.value[0].args[0] == "$proxy.config.http.server_ports");
+}
+
+TEST_CASE("plugin_config parser - nonexistent file", "[plugin_config]")
+{
+  auto result = 
config::PluginConfigParser{}.parse("/nonexistent/path/plugin.config");
+
+  CHECK(!result.ok());
+  CHECK(result.file_not_found == true);
+}
+
+TEST_CASE("plugin_config marshaller - basic output", "[plugin_config]")
+{
+  config::PluginConfigData data = {
+    {"stats_over_http.so", {"_stats"},                        true },
+    {"cache_promote.so",   {"--policy=lru", "--buckets=100"}, false},
+    {"xdebug.so",          {},                                true },
+  };
+
+  auto yaml = config::PluginConfigMarshaller{}.to_yaml(data);
+
+  YAML::Node root = YAML::Load(yaml);
+  REQUIRE(root["plugins"]);
+  REQUIRE(root["plugins"].IsSequence());
+  REQUIRE(root["plugins"].size() == 3);
+
+  CHECK(root["plugins"][0]["path"].as<std::string>() == "stats_over_http.so");
+  CHECK_FALSE(root["plugins"][0]["enabled"]);
+
+  CHECK(root["plugins"][1]["path"].as<std::string>() == "cache_promote.so");
+  CHECK(root["plugins"][1]["enabled"].as<bool>() == false);
+  CHECK(root["plugins"][1]["params"].size() == 2);
+
+  CHECK(root["plugins"][2]["path"].as<std::string>() == "xdebug.so");
+  CHECK_FALSE(root["plugins"][2]["params"]);
+}
+
+TEST_CASE("plugin_config round-trip parse then marshal", "[plugin_config]")
+{
+  TempFile tf("plugin_roundtrip.config",
+              "stats_over_http.so _stats\n# cache_promote.so 
--policy=lru\nheader_rewrite.so rewrite.conf\n");
+  auto     result = config::PluginConfigParser{}.parse(tf.path());
+
+  REQUIRE(result.ok());
+
+  auto yaml = config::PluginConfigMarshaller{}.to_yaml(result.value);
+
+  YAML::Node root = YAML::Load(yaml);
+  REQUIRE(root["plugins"].size() == 3);
+
+  CHECK(root["plugins"][0]["path"].as<std::string>() == "stats_over_http.so");
+  CHECK_FALSE(root["plugins"][0]["enabled"]);
+
+  CHECK(root["plugins"][1]["path"].as<std::string>() == "cache_promote.so");
+  CHECK(root["plugins"][1]["enabled"].as<bool>() == false);
+
+  CHECK(root["plugins"][2]["path"].as<std::string>() == "header_rewrite.so");
+  CHECK_FALSE(root["plugins"][2]["enabled"]);
+}
diff --git a/src/mgmt/rpc/handlers/plugins/Plugins.cc 
b/src/mgmt/rpc/handlers/plugins/Plugins.cc
index f4459ea7d2..58e43f26eb 100644
--- a/src/mgmt/rpc/handlers/plugins/Plugins.cc
+++ b/src/mgmt/rpc/handlers/plugins/Plugins.cc
@@ -22,6 +22,7 @@
 #include "mgmt/rpc/handlers/common/ErrorUtils.h"
 
 #include "api/LifecycleAPIHooks.h"
+#include "proxy/Plugin.h"
 
 namespace
 {
@@ -89,4 +90,35 @@ plugin_send_basic_msg(std::string_view const & /* id 
ATS_UNUSED */, YAML::Node c
 
   return resp;
 }
+swoc::Rv<YAML::Node>
+get_plugin_list(std::string_view const & /* id ATS_UNUSED */, YAML::Node const 
& /* params ATS_UNUSED */)
+{
+  swoc::Rv<YAML::Node> resp;
+  try {
+    const auto &summary = get_plugin_load_summary();
+    YAML::Node  data;
+
+    data["source"] = summary.source;
+
+    YAML::Node plugins;
+    for (const auto &e : summary.entries) {
+      YAML::Node plugin;
+
+      plugin["path"]    = e.path;
+      plugin["enabled"] = e.enabled;
+      plugin["status"]  = e.enabled ? "loaded" : "disabled";
+      plugin["index"]   = e.index;
+      if (e.load_order >= 0) {
+        plugin["load_order"] = e.load_order;
+      }
+      plugins.push_back(plugin);
+    }
+    data["plugins"] = plugins;
+
+    resp.result()["data"] = data;
+  } catch (std::exception const &ex) {
+    resp.errata().assign(std::error_code{errors::Codes::PLUGIN}).note("Error 
calling get_plugin_list: {}", ex.what());
+  }
+  return resp;
+}
 } // namespace rpc::handlers::plugins
diff --git a/src/proxy/Plugin.cc b/src/proxy/Plugin.cc
index 5e2d1e6bb7..0cdb620460 100644
--- a/src/proxy/Plugin.cc
+++ b/src/proxy/Plugin.cc
@@ -22,6 +22,9 @@
  */
 
 #include <cstdio>
+#include <algorithm>
+#include <filesystem>
+#include <optional>
 #include "tscore/ink_platform.h"
 #include "tscore/ink_file.h"
 #include "tscore/ParseRules.h"
@@ -30,6 +33,7 @@
 #include "proxy/Plugin.h"
 #include "tscore/ink_cap.h"
 #include "tscore/Filenames.h"
+#include <yaml-cpp/yaml.h>
 
 #define MAX_PLUGIN_ARGS 64
 
@@ -77,8 +81,27 @@ parsePluginConfig()
 
 static const char *plugin_dir = ".";
 
+static void
+plugin_dir_init()
+{
+  static bool once = true;
+
+  if (once) {
+    plugin_dir = ats_stringdup(RecConfigReadPluginDir());
+    once       = false;
+  }
+}
+
 using init_func_t = void (*)(int, char **);
 
+static PluginLoadSummary s_plugin_load_summary;
+
+const PluginLoadSummary &
+get_plugin_load_summary()
+{
+  return s_plugin_load_summary;
+}
+
 // Plugin registration vars
 //
 //    plugin_reg_list has an entry for each plugin
@@ -134,7 +157,7 @@ plugin_dso_load(const char *path, void *&handle, void 
*&init, std::string &error
   return true;
 }
 
-static bool
+bool
 single_plugin_init(int argc, char *argv[], bool validateOnly)
 {
   char        path[PATH_NAME_MAX];
@@ -198,7 +221,7 @@ single_plugin_init(int argc, char *argv[], bool 
validateOnly)
   if (plugin_reg_current->plugin_registered) {
     plugin_reg_list.push(plugin_reg_current);
   } else {
-    Fatal("plugin not registered by calling TSPluginRegister");
+    Fatal("plugin '%s' not registered by calling TSPluginRegister", path);
     return false; // this line won't get called since Fatal brings down ATS
   }
 
@@ -208,7 +231,7 @@ single_plugin_init(int argc, char *argv[], bool 
validateOnly)
 }
 
 static char *
-plugin_expand(char *arg)
+plugin_expand(char *arg, const char *source)
 {
   RecDataT data_type;
   char    *str = nullptr;
@@ -268,7 +291,7 @@ plugin_expand(char *arg)
   }
 
 not_found:
-  Warning("%s: unable to find parameter %s", ts::filename::PLUGIN, arg);
+  Warning("%s: unable to find parameter %s", source, arg);
   return nullptr;
 }
 
@@ -282,13 +305,13 @@ plugin_init(bool validateOnly)
   int            argc;
   int            fd;
   int            i;
-  bool           retVal    = true;
-  static bool    INIT_ONCE = true;
+  bool           retVal     = true;
+  int            load_index = 0;
 
-  if (INIT_ONCE) {
-    plugin_dir = ats_stringdup(RecConfigReadPluginDir());
-    INIT_ONCE  = false;
-  }
+  plugin_dir_init();
+
+  s_plugin_load_summary.source = ts::filename::PLUGIN;
+  s_plugin_load_summary.entries.clear();
 
   Note("%s loading ...", ts::filename::PLUGIN);
   path = RecConfigReadConfigPath(nullptr, ts::filename::PLUGIN);
@@ -350,7 +373,7 @@ plugin_init(bool validateOnly)
     }
 
     for (i = 0; i < argc; i++) {
-      vars[i] = plugin_expand(argv[i]);
+      vars[i] = plugin_expand(argv[i], ts::filename::PLUGIN);
       if (vars[i]) {
         argv[i] = vars[i];
       }
@@ -361,8 +384,14 @@ plugin_init(bool validateOnly)
     } else {
       argv[MAX_PLUGIN_ARGS - 1] = nullptr;
     }
+
+    ++load_index;
+    std::string plugin_name = (argc > 0) ? argv[0] : "unknown";
+
     retVal = single_plugin_init(argc, argv, validateOnly);
 
+    s_plugin_load_summary.entries.push_back({plugin_name, -1, true, retVal, 
load_index});
+
     for (i = 0; i < argc; i++) {
       ats_free(vars[i]);
     }
@@ -376,3 +405,267 @@ plugin_init(bool validateOnly)
   }
   return retVal;
 }
+
+config::ConfigResult<PluginYAMLEntries>
+parse_plugin_yaml(const char *yaml_path)
+{
+  config::ConfigResult<PluginYAMLEntries> result;
+  YAML::Node                              root;
+
+  try {
+    root = YAML::LoadFile(yaml_path);
+  } catch (const YAML::Exception &e) {
+    result.errata.note("failed to parse: {}", e.what());
+    return result;
+  }
+
+  if (!root["plugins"] || !root["plugins"].IsSequence()) {
+    result.errata.note("missing or invalid 'plugins' sequence");
+    return result;
+  }
+
+  struct IndexedEntry {
+    int             seq_idx;
+    PluginYAMLEntry entry;
+  };
+
+  std::vector<IndexedEntry> indexed;
+  int                       seq_idx = 0;
+
+  for (const auto &node : root["plugins"]) {
+    PluginYAMLEntry entry;
+
+    if (!node["path"]) {
+      result.errata.note("plugin entry #{} missing required 'path' field", 
seq_idx + 1);
+      return result;
+    }
+    entry.path = node["path"].as<std::string>();
+
+    if (auto n = node["enabled"]; n) {
+      entry.enabled = n.as<bool>();
+    }
+    if (auto n = node["load_order"]; n) {
+      entry.load_order = n.as<int>();
+    }
+    if (auto n = node["params"]; n && n.IsSequence()) {
+      for (const auto &p : n) {
+        entry.params.emplace_back(p.as<std::string>());
+      }
+    }
+    if (auto n = node["config"]; n) {
+      if (n.IsScalar()) {
+        entry.config_literal = n.as<std::string>();
+      } else {
+        result.errata.note("plugin '{}': 'config' must be a scalar (use 
literal block '|' for multi-line content)", entry.path);
+        return result;
+      }
+    }
+
+    indexed.push_back({seq_idx++, std::move(entry)});
+  }
+
+  std::stable_sort(indexed.begin(), indexed.end(), [](const IndexedEntry &a, 
const IndexedEntry &b) {
+    const bool a_has = a.entry.load_order >= 0;
+    const bool b_has = b.entry.load_order >= 0;
+
+    if (a_has && b_has) {
+      return a.entry.load_order < b.entry.load_order;
+    }
+    return a_has && !b_has;
+  });
+
+  result.value.reserve(indexed.size());
+  for (auto &[_, entry] : indexed) {
+    result.value.emplace_back(std::move(entry));
+  }
+
+  return result;
+}
+
+/// Write inline config content to a temp file, returning the path on success.
+static std::optional<std::string>
+write_inline_config(const PluginYAMLEntry &entry, int index)
+{
+  char tmp_path[PATH_NAME_MAX];
+
+  std::string_view stem{entry.path};
+  if (auto pos = stem.rfind('/'); pos != std::string_view::npos) {
+    stem = stem.substr(pos + 1);
+  }
+  if (auto pos = stem.rfind('.'); pos != std::string_view::npos) {
+    stem = stem.substr(0, pos);
+  }
+
+  snprintf(tmp_path, sizeof(tmp_path), "%s/.%.*s_inline_%d.conf", 
RecConfigReadConfigDir().c_str(), static_cast<int>(stem.size()),
+           stem.data(), index);
+
+  int fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
+  if (fd < 0) {
+    Error("%s: failed to create temp config for %s: %s", 
ts::filename::PLUGIN_YAML, entry.path.c_str(), strerror(errno));
+    return std::nullopt;
+  }
+
+  auto n = write(fd, entry.config_literal.data(), entry.config_literal.size());
+  close(fd);
+
+  if (n < 0 || static_cast<size_t>(n) != entry.config_literal.size()) {
+    Error("%s: failed to write inline config for %s", 
ts::filename::PLUGIN_YAML, entry.path.c_str());
+    return std::nullopt;
+  }
+
+  return std::string(tmp_path);
+}
+
+/// Build the argv for a single plugin: [path, inline_config_path?, params..., 
$record expansions].
+static std::optional<std::vector<std::string>>
+build_plugin_args(const PluginYAMLEntry &entry, int index)
+{
+  std::vector<std::string> args;
+  args.emplace_back(entry.path);
+
+  if (!entry.config_literal.empty()) {
+    if (auto path = write_inline_config(entry, index); path) {
+      args.emplace_back(std::move(*path));
+    } else {
+      return std::nullopt;
+    }
+  }
+
+  for (const auto &p : entry.params) {
+    args.emplace_back(p);
+  }
+
+  return args;
+}
+
+static void
+log_plugin_load_summary(int loaded, int disabled)
+{
+  Note("%s: %d plugins loaded, %d disabled", ts::filename::PLUGIN_YAML, 
loaded, disabled);
+
+  for (const auto &e : s_plugin_load_summary.entries) {
+    if (e.enabled) {
+      if (e.load_order >= 0) {
+        Note("  #%d %-30s load_order: %-5d loaded", e.index, e.path.c_str(), 
e.load_order);
+      } else {
+        Note("  #%d %-30s                 loaded", e.index, e.path.c_str());
+      }
+    } else {
+      Note("  -- %-30s                 disabled", e.path.c_str());
+    }
+  }
+}
+
+static void
+cleanup_inline_configs()
+{
+  std::string     config_dir = RecConfigReadConfigDir();
+  std::error_code ec;
+
+  try {
+    for (const auto &entry : std::filesystem::directory_iterator(config_dir, 
ec)) {
+      if (!entry.is_regular_file()) {
+        continue;
+      }
+      auto name = entry.path().filename().string();
+      if (name.front() == '.' && name.find("_inline_") != std::string::npos && 
name.ends_with(".conf")) {
+        std::filesystem::remove(entry.path(), ec);
+      }
+    }
+  } catch (const std::exception &e) {
+    Error("%s: error cleaning up inline config files: %s", 
ts::filename::PLUGIN_YAML, e.what());
+  }
+}
+
+bool
+plugin_yaml_init(bool validateOnly)
+{
+  plugin_dir_init();
+
+  ats_scoped_str yaml_path;
+
+  if (!validateOnly) {
+    cleanup_inline_configs();
+  }
+
+  yaml_path = RecConfigReadConfigPath(nullptr, ts::filename::PLUGIN_YAML);
+  if (access(yaml_path, R_OK) != 0) {
+    if (errno != ENOENT) {
+      Error("%s: %s", ts::filename::PLUGIN_YAML, strerror(errno));
+      return false;
+    }
+    return plugin_init(validateOnly);
+  }
+
+  Note("%s loading ...", ts::filename::PLUGIN_YAML);
+
+  auto result = parse_plugin_yaml(yaml_path.get());
+  if (!result.ok()) {
+    Error("%s: %s", ts::filename::PLUGIN_YAML, 
std::string(result.errata.front().text()).c_str());
+    return false;
+  }
+
+  s_plugin_load_summary.source = ts::filename::PLUGIN_YAML;
+  s_plugin_load_summary.entries.clear();
+
+  bool retVal   = true;
+  int  index    = 0;
+  int  loaded   = 0;
+  int  disabled = 0;
+
+  for (const auto &entry : result.value) {
+    ++index;
+
+    if (!entry.enabled) {
+      Note("plugin #%d skipped: %s (enabled: false)", index, 
entry.path.c_str());
+      s_plugin_load_summary.entries.push_back({entry.path, entry.load_order, 
false, false, index});
+      ++disabled;
+      continue;
+    }
+
+    auto args = build_plugin_args(entry, index);
+    if (!args) {
+      return false;
+    }
+
+    if (args->size() > MAX_PLUGIN_ARGS) {
+      Warning("%s: plugin '%s' has %zu args, exceeds typical max (%d)", 
ts::filename::PLUGIN_YAML, entry.path.c_str(), args->size(),
+              MAX_PLUGIN_ARGS);
+    }
+
+    std::vector<char *> argv_ptrs;
+    std::vector<char *> expanded;
+
+    for (auto &a : *args) {
+      char *var = plugin_expand(a.data(), ts::filename::PLUGIN_YAML);
+      expanded.emplace_back(var);
+      argv_ptrs.emplace_back(var ? var : a.data());
+    }
+    argv_ptrs.emplace_back(nullptr);
+
+    if (entry.load_order >= 0) {
+      Note("plugin #%d loading: %s (load_order: %d)", index, 
entry.path.c_str(), entry.load_order);
+    } else {
+      Note("plugin #%d loading: %s", index, entry.path.c_str());
+    }
+
+    retVal = single_plugin_init(static_cast<int>(args->size()), 
argv_ptrs.data(), validateOnly);
+    s_plugin_load_summary.entries.push_back({entry.path, entry.load_order, 
true, retVal, index});
+    ++loaded;
+
+    for (auto *v : expanded) {
+      ats_free(v);
+    }
+
+    if (!retVal) {
+      break;
+    }
+  }
+
+  if (retVal) {
+    log_plugin_load_summary(loaded, disabled);
+  } else {
+    Error("%s failed to load", ts::filename::PLUGIN_YAML);
+  }
+  return retVal;
+}
diff --git a/src/proxy/unit_tests/CMakeLists.txt 
b/src/proxy/unit_tests/CMakeLists.txt
index b21fb32715..72afd432f9 100644
--- a/src/proxy/unit_tests/CMakeLists.txt
+++ b/src/proxy/unit_tests/CMakeLists.txt
@@ -16,7 +16,8 @@
 #######################
 
 add_executable(
-  test_proxy main.cc test_ParentHashConfig.cc 
"${PROJECT_SOURCE_DIR}/src/iocore/net/libinknet_stub.cc" stub.cc
+  test_proxy main.cc test_ParentHashConfig.cc test_PluginYAML.cc
+             "${PROJECT_SOURCE_DIR}/src/iocore/net/libinknet_stub.cc" stub.cc
 )
 
 target_link_libraries(test_proxy PRIVATE Catch2::Catch2WithMain ts::http 
ts::proxy ts::tscore ts::records ts::inkevent)
diff --git a/src/proxy/unit_tests/test_PluginYAML.cc 
b/src/proxy/unit_tests/test_PluginYAML.cc
new file mode 100644
index 0000000000..4df57fa496
--- /dev/null
+++ b/src/proxy/unit_tests/test_PluginYAML.cc
@@ -0,0 +1,324 @@
+/** @file
+
+    Unit tests for plugin.yaml parsing
+
+    @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 <filesystem>
+#include <fstream>
+#include <string>
+
+#include "proxy/Plugin.h"
+
+namespace
+{
+class TempYAML
+{
+public:
+  explicit TempYAML(const std::string &content)
+  {
+    _path = std::filesystem::temp_directory_path() / "test_plugin_yaml.yaml";
+    std::ofstream f(_path);
+    f << content;
+  }
+
+  ~TempYAML() { std::filesystem::remove(_path); }
+
+  const char *
+  path() const
+  {
+    return _path.c_str();
+  }
+
+private:
+  std::filesystem::path _path;
+};
+} // namespace
+
+TEST_CASE("parse_plugin_yaml - minimal valid config", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: stats_over_http.so
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  CHECK(result.value[0].path == "stats_over_http.so");
+  CHECK(result.value[0].enabled == true);
+  CHECK(result.value[0].load_order == -1);
+  CHECK(result.value[0].params.empty());
+  CHECK(result.value[0].config_literal.empty());
+}
+
+TEST_CASE("parse_plugin_yaml - all fields populated", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: abuse.so
+    enabled: true
+    load_order: 100
+    params:
+      - etc/trafficserver/abuse.config
+      - --verbose
+      - --debug
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  CHECK(result.value[0].path == "abuse.so");
+  CHECK(result.value[0].enabled == true);
+  CHECK(result.value[0].load_order == 100);
+  REQUIRE(result.value[0].params.size() == 3);
+  CHECK(result.value[0].params[0] == "etc/trafficserver/abuse.config");
+  CHECK(result.value[0].params[1] == "--verbose");
+  CHECK(result.value[0].params[2] == "--debug");
+}
+
+TEST_CASE("parse_plugin_yaml - enabled false", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: debug_plugin.so
+    enabled: false
+  - path: stats_over_http.so
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 2);
+  CHECK(result.value[0].enabled == false);
+  CHECK(result.value[1].enabled == true);
+}
+
+TEST_CASE("parse_plugin_yaml - load_order sorting", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: third.so
+    load_order: 300
+  - path: first.so
+    load_order: 100
+  - path: second.so
+    load_order: 200
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 3);
+  CHECK(result.value[0].path == "first.so");
+  CHECK(result.value[1].path == "second.so");
+  CHECK(result.value[2].path == "third.so");
+}
+
+TEST_CASE("parse_plugin_yaml - ordered before unordered", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: unordered_first.so
+  - path: unordered_second.so
+  - path: ordered.so
+    load_order: 50
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 3);
+  CHECK(result.value[0].path == "ordered.so");
+  CHECK(result.value[1].path == "unordered_first.so");
+  CHECK(result.value[2].path == "unordered_second.so");
+}
+
+TEST_CASE("parse_plugin_yaml - stable sort preserves sequence order on ties", 
"[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: b.so
+    load_order: 100
+  - path: a.so
+    load_order: 100
+  - path: c.so
+    load_order: 100
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 3);
+  CHECK(result.value[0].path == "b.so");
+  CHECK(result.value[1].path == "a.so");
+  CHECK(result.value[2].path == "c.so");
+}
+
+TEST_CASE("parse_plugin_yaml - inline config literal (scalar)", 
"[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: header_rewrite.so
+    config: |
+      cond %{SEND_RESPONSE_HDR_HOOK}
+         set-header X-Debug "true"
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 1);
+  CHECK(result.value[0].path == "header_rewrite.so");
+  CHECK(result.value[0].config_literal.find("set-header X-Debug") != 
std::string::npos);
+}
+
+TEST_CASE("parse_plugin_yaml - inline config rejects structured YAML mapping", 
"[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: txn_box.so
+    config:
+      when: proxy-req
+      do:
+        - set-header:
+            name: X-Forwarded-For
+            value: inbound-addr-remote
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE_FALSE(result.ok());
+}
+
+TEST_CASE("parse_plugin_yaml - inline config rejects structured YAML 
sequence", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: custom.so
+    config:
+      - rule1
+      - rule2
+      - rule3
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE_FALSE(result.ok());
+}
+
+TEST_CASE("parse_plugin_yaml - missing path field", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - enabled: true
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE_FALSE(result.ok());
+  CHECK(std::string(result.errata.front().text()).find("missing required 
'path' field") != std::string::npos);
+}
+
+TEST_CASE("parse_plugin_yaml - missing plugins key", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+something_else:
+  - path: foo.so
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE_FALSE(result.ok());
+  CHECK(std::string(result.errata.front().text()).find("missing or invalid 
'plugins' sequence") != std::string::npos);
+}
+
+TEST_CASE("parse_plugin_yaml - invalid YAML syntax", "[plugin_yaml]")
+{
+  TempYAML yaml("plugins:\n  - path: foo.so\n  bad indent here\n");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE_FALSE(result.ok());
+  CHECK(std::string(result.errata.front().text()).find("failed to parse") != 
std::string::npos);
+}
+
+TEST_CASE("parse_plugin_yaml - empty plugins list", "[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins: []
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  CHECK(result.value.empty());
+}
+
+TEST_CASE("parse_plugin_yaml - multiple plugins mixed features", 
"[plugin_yaml]")
+{
+  TempYAML yaml(R"(
+plugins:
+  - path: stats_over_http.so
+
+  - path: abuse.so
+    params:
+      - etc/trafficserver/abuse.config
+
+  - path: header_rewrite.so
+    params:
+      - etc/trafficserver/header_rewrite.config
+
+  - path: experimental.so
+    enabled: false
+    params:
+      - --verbose
+)");
+
+  auto result = parse_plugin_yaml(yaml.path());
+
+  REQUIRE(result.ok());
+  REQUIRE(result.value.size() == 4);
+
+  CHECK(result.value[0].path == "stats_over_http.so");
+  CHECK(result.value[0].params.empty());
+
+  CHECK(result.value[1].path == "abuse.so");
+  REQUIRE(result.value[1].params.size() == 1);
+  CHECK(result.value[1].params[0] == "etc/trafficserver/abuse.config");
+
+  CHECK(result.value[2].path == "header_rewrite.so");
+  REQUIRE(result.value[2].params.size() == 1);
+  CHECK(result.value[2].params[0] == 
"etc/trafficserver/header_rewrite.config");
+
+  CHECK(result.value[3].path == "experimental.so");
+  CHECK(result.value[3].enabled == false);
+}
+
+TEST_CASE("parse_plugin_yaml - nonexistent file", "[plugin_yaml]")
+{
+  auto result = parse_plugin_yaml("/tmp/nonexistent_plugin_yaml_test.yaml");
+
+  REQUIRE_FALSE(result.ok());
+  CHECK(std::string(result.errata.front().text()).find("failed to parse") != 
std::string::npos);
+}
diff --git a/src/traffic_ctl/ConvertConfigCommand.cc 
b/src/traffic_ctl/ConvertConfigCommand.cc
index 6bb0415cae..80bcf3eb13 100644
--- a/src/traffic_ctl/ConvertConfigCommand.cc
+++ b/src/traffic_ctl/ConvertConfigCommand.cc
@@ -24,6 +24,7 @@
 #include "ConvertConfigCommand.h"
 #include "config/ssl_multicert.h"
 #include "config/storage.h"
+#include "config/plugin_config.h"
 
 #include <fstream>
 #include <iostream>
@@ -50,6 +51,15 @@ ConvertConfigCommand::ConvertConfigCommand(ts::Arguments 
*args) : CtrlCommand(ar
     _volume_config_file = convert_args[1];
     _output_file        = convert_args[2];
     _invoked_func       = [this]() { convert_storage(); };
+  } else if (args->get("plugin_config")) {
+    auto const &convert_args = args->get("plugin_config");
+    if (convert_args.size() < 2) {
+      throw std::invalid_argument("plugin_config requires <input_file> 
<output_file>");
+    }
+    _input_file    = convert_args[0];
+    _output_file   = convert_args[1];
+    _skip_disabled = args->get("skip-disabled");
+    _invoked_func  = [this]() { convert_plugin_config(); };
   } else {
     throw std::invalid_argument("Unsupported config type for conversion");
   }
@@ -145,3 +155,40 @@ ConvertConfigCommand::convert_storage()
     _printer->write_output("Converted " + _input_file + " + " + 
_volume_config_file + " -> " + _output_file);
   }
 }
+
+void
+ConvertConfigCommand::convert_plugin_config()
+{
+  config::PluginConfigParser                     parser;
+  config::ConfigResult<config::PluginConfigData> result = 
parser.parse(_input_file);
+
+  if (!result.ok()) {
+    std::string error_msg = "Failed to parse input file '" + _input_file + "'";
+    if (!result.errata.empty()) {
+      error_msg += ": ";
+      error_msg += std::string(result.errata.front().text());
+    }
+    _printer->write_output(error_msg);
+    return;
+  }
+
+  if (_skip_disabled) {
+    std::erase_if(result.value, [](const config::PluginConfigEntry &e) { 
return !e.enabled; });
+  }
+
+  config::PluginConfigMarshaller marshaller;
+  std::string const              serialized = marshaller.to_yaml(result.value);
+
+  if (_output_file == "-") {
+    std::cout << serialized << '\n';
+  } else {
+    std::ofstream out(_output_file);
+    if (!out) {
+      _printer->write_output("Failed to open output file '" + _output_file + 
"' for writing");
+      return;
+    }
+    out << serialized << '\n';
+    out.close();
+    _printer->write_output("Converted " + _input_file + " -> " + _output_file);
+  }
+}
diff --git a/src/traffic_ctl/ConvertConfigCommand.h 
b/src/traffic_ctl/ConvertConfigCommand.h
index fd6032f826..8ee4386e87 100644
--- a/src/traffic_ctl/ConvertConfigCommand.h
+++ b/src/traffic_ctl/ConvertConfigCommand.h
@@ -29,7 +29,7 @@
  * Command handler for configuration format conversion.
  *
  * Converts configuration files from legacy formats to YAML.
- * Supports: ssl_multicert, storage
+ * Supports: ssl_multicert, storage, plugin_config
  */
 class ConvertConfigCommand : public CtrlCommand
 {
@@ -44,10 +44,14 @@ public:
 private:
   void convert_ssl_multicert();
   void convert_storage();
+  void convert_plugin_config();
 
   std::string _input_file;
   std::string _output_file;
 
   // For storage conversion only: optional volume.config path.
   std::string _volume_config_file;
+
+  // For plugin_config conversion: drop disabled entries from output.
+  bool _skip_disabled{false};
 };
diff --git a/src/traffic_ctl/CtrlCommands.cc b/src/traffic_ctl/CtrlCommands.cc
index 659bf4f1e7..21700b3154 100644
--- a/src/traffic_ctl/CtrlCommands.cc
+++ b/src/traffic_ctl/CtrlCommands.cc
@@ -23,6 +23,7 @@
 #include <fstream>
 #include <unordered_map>
 #include <chrono>
+#include <iomanip>
 #include <thread>
 #include <csignal>
 #include <unistd.h>
@@ -880,6 +881,8 @@ PluginCommand::PluginCommand(ts::Arguments *args) : 
CtrlCommand(args)
 {
   if (get_parsed_arguments()->get(MSG_STR)) {
     _invoked_func = [&]() { plugin_msg(); };
+  } else if (get_parsed_arguments()->get(LIST_STR)) {
+    _invoked_func = [&]() { plugin_list(); };
   }
   _printer = std::make_unique<GenericPrinter>(parse_print_opts(args));
 }
@@ -898,6 +901,52 @@ PluginCommand::plugin_msg()
   auto                      response = invoke_rpc(request);
   _printer->write_output(response);
 }
+
+void
+PluginCommand::plugin_list()
+{
+  GetPluginListRequest request;
+  auto                 response = invoke_rpc(request);
+
+  if (response.is_error()) {
+    _printer->write_output(response);
+    return;
+  }
+
+  auto info = response.result.as<PluginListResponse>();
+
+  std::cout << "source: " << info.source << '\n';
+
+  bool has_load_order = false;
+  for (const auto &p : info.plugins) {
+    if (p.load_order >= 0) {
+      has_load_order = true;
+      break;
+    }
+  }
+
+  if (has_load_order) {
+    std::cout << "  #  plugin                          load_order   status\n";
+  } else {
+    std::cout << "  #  plugin                          status\n";
+  }
+
+  for (const auto &p : info.plugins) {
+    std::cout << " " << std::right << std::setw(2) << p.index << "  " << 
std::left << std::setw(30) << p.path;
+
+    if (has_load_order) {
+      char order_buf[12];
+      if (p.load_order >= 0) {
+        snprintf(order_buf, sizeof(order_buf), "%d", p.load_order);
+      } else {
+        snprintf(order_buf, sizeof(order_buf), "--");
+      }
+      std::cout << "  " << std::left << std::setw(11) << order_buf;
+    }
+
+    std::cout << "  " << p.status << '\n';
+  }
+}
 
//------------------------------------------------------------------------------------------------------------------------------------
 DirectRPCCommand::DirectRPCCommand(ts::Arguments *args) : CtrlCommand(args)
 {
diff --git a/src/traffic_ctl/CtrlCommands.h b/src/traffic_ctl/CtrlCommands.h
index 82036f10c5..eb6913109c 100644
--- a/src/traffic_ctl/CtrlCommands.h
+++ b/src/traffic_ctl/CtrlCommands.h
@@ -207,7 +207,9 @@ public:
 
 private:
   static inline const std::string MSG_STR{"msg"};
+  static inline const std::string LIST_STR{"list"};
   void                            plugin_msg();
+  void                            plugin_list();
 };
 // 
-----------------------------------------------------------------------------------------------------------------------------------
 class DirectRPCCommand : public CtrlCommand
diff --git a/src/traffic_ctl/jsonrpc/CtrlRPCRequests.h 
b/src/traffic_ctl/jsonrpc/CtrlRPCRequests.h
index 5644f288a1..528b11b3f0 100644
--- a/src/traffic_ctl/jsonrpc/CtrlRPCRequests.h
+++ b/src/traffic_ctl/jsonrpc/CtrlRPCRequests.h
@@ -192,6 +192,28 @@ struct HostDBGetStatusRequest : shared::rpc::ClientRequest 
{
     return "get_hostdb_status";
   }
 };
+//------------------------------------------------------------------------------------------------------------------------------------
+struct GetPluginListRequest : shared::rpc::ClientRequest {
+  using super = shared::rpc::ClientRequest;
+  std::string
+  get_method() const override
+  {
+    return "admin_plugin_get_list";
+  }
+};
+
+struct PluginListResponse {
+  struct PluginInfo {
+    std::string path;
+    bool        enabled{false};
+    std::string status;
+    int         index{0};
+    int         load_order{-1};
+  };
+  std::string             source;
+  std::vector<PluginInfo> plugins;
+};
+
 
//------------------------------------------------------------------------------------------------------------------------------------
 struct BasicPluginMessageRequest : shared::rpc::ClientRequest {
   using super = BasicPluginMessageRequest;
diff --git a/src/traffic_ctl/jsonrpc/ctrl_yaml_codecs.h 
b/src/traffic_ctl/jsonrpc/ctrl_yaml_codecs.h
index d128915e7d..96a98886d8 100644
--- a/src/traffic_ctl/jsonrpc/ctrl_yaml_codecs.h
+++ b/src/traffic_ctl/jsonrpc/ctrl_yaml_codecs.h
@@ -182,6 +182,31 @@ template <> struct convert<HostDBGetStatusRequest::Params> 
{
   }
 };
 
//------------------------------------------------------------------------------------------------------------------------------------
+template <> struct convert<PluginListResponse> {
+  static bool
+  decode(Node const &node, PluginListResponse &out)
+  {
+    if (auto data = node["data"]; data) {
+      out.source = helper::try_extract<std::string>(data, "source");
+      for (const auto &p : data["plugins"]) {
+        PluginListResponse::PluginInfo info;
+
+        info.path    = helper::try_extract<std::string>(p, "path");
+        info.enabled = helper::try_extract<bool>(p, "enabled");
+        info.status  = helper::try_extract<std::string>(p, "status");
+        if (p["index"]) {
+          info.index = p["index"].as<int>();
+        }
+        if (p["load_order"]) {
+          info.load_order = p["load_order"].as<int>();
+        }
+        out.plugins.emplace_back(std::move(info));
+      }
+    }
+    return true;
+  }
+};
+//------------------------------------------------------------------------------------------------------------------------------------
 template <> struct convert<BasicPluginMessageRequest::Params> {
   static Node
   encode(BasicPluginMessageRequest::Params const &params)
diff --git a/src/traffic_ctl/traffic_ctl.cc b/src/traffic_ctl/traffic_ctl.cc
index 9ca08ef8ad..6fbfc7f01d 100644
--- a/src/traffic_ctl/traffic_ctl.cc
+++ b/src/traffic_ctl/traffic_ctl.cc
@@ -219,6 +219,11 @@ main([[maybe_unused]] int argc, const char **argv)
     .add_example_usage("traffic_ctl config convert storage <storage.config> 
<volume.config> <output_file>")
     .add_example_usage("traffic_ctl config convert storage storage.config 
volume.config storage.yaml")
     .add_example_usage("traffic_ctl config convert storage storage.config 
volume.config -  # output to stdout");
+  convert_command.add_command("plugin_config", "Convert plugin.config to 
plugin.yaml", "", 2, Command_Execute)
+    .add_example_usage("traffic_ctl config convert plugin_config <input_file> 
<output_file>")
+    .add_example_usage("traffic_ctl config convert plugin_config plugin.config 
plugin.yaml")
+    .add_example_usage("traffic_ctl config convert plugin_config plugin.config 
-  # output to stdout")
+    .add_option("--skip-disabled", "", "Omit commented-out (disabled) plugins 
from the output");
 
   // host commands
   host_command.add_command("status", "Get one or more host statuses", "", 
MORE_THAN_ZERO_ARG_N, Command_Execute)
@@ -256,6 +261,8 @@ main([[maybe_unused]] int argc, const char **argv)
   plugin_command
     .add_command("msg", "Send message to plugins - a TAG and the message 
DATA(optional)", "", MORE_THAN_ONE_ARG_N, Command_Execute)
     .add_example_usage("traffic_ctl plugin msg TAG DATA");
+  plugin_command.add_command("list", "Show globally loaded plugins and their 
status", "", 0, Command_Execute)
+    .add_example_usage("traffic_ctl plugin list");
 
   // server commands
   server_command.add_command("backtrace", "Show a full stack trace of the 
traffic_server process",
diff --git a/src/traffic_server/RpcAdminPubHandlers.cc 
b/src/traffic_server/RpcAdminPubHandlers.cc
index f68f7f2f67..1e053db966 100644
--- a/src/traffic_server/RpcAdminPubHandlers.cc
+++ b/src/traffic_server/RpcAdminPubHandlers.cc
@@ -56,6 +56,8 @@ register_admin_jsonrpc_handlers()
   using namespace rpc::handlers::plugins;
   rpc::add_method_handler("admin_plugin_send_basic_msg", 
&plugin_send_basic_msg, &core_ats_rpc_service_provider_handle,
                           {{rpc::RESTRICTED_API}});
+  rpc::add_method_handler("admin_plugin_get_list", &get_plugin_list, 
&core_ats_rpc_service_provider_handle,
+                          {{rpc::NON_RESTRICTED_API}});
 
   // server
   using namespace rpc::handlers::server;
diff --git a/src/traffic_server/traffic_server.cc 
b/src/traffic_server/traffic_server.cc
index 1e15c64c0b..ff55185f02 100644
--- a/src/traffic_server/traffic_server.cc
+++ b/src/traffic_server/traffic_server.cc
@@ -1017,11 +1017,11 @@ cmd_verify(char * /* cmd ATS_UNUSED */)
   }
 
   api_init();
-  if (!plugin_init(true)) {
+  if (!plugin_yaml_init(true)) {
     exitStatus |= (1 << 2);
-    fprintf(stderr, "ERROR: Failed to load %s, exitStatus %d\n\n", 
ts::filename::PLUGIN, exitStatus);
+    fprintf(stderr, "ERROR: Failed to load plugins, exitStatus %d\n\n", 
exitStatus);
   } else {
-    fprintf(stderr, "INFO: Successfully loaded %s\n\n", ts::filename::PLUGIN);
+    fprintf(stderr, "INFO: Successfully loaded plugins\n\n");
   }
 
   if (!urlRewriteVerify()) {
@@ -2357,7 +2357,9 @@ main(int /* argc ATS_UNUSED */, const char **argv)
 
     // Init plugins as soon as logging is ready.
     api_init();
-    (void)plugin_init(); // plugin.config
+    if (!plugin_yaml_init()) {
+      Warning("plugin initialization failed");
+    }
 
     {
       std::unique_lock<std::mutex> lock(pluginInitMutex);
diff --git a/tests/gold_tests/pluginTest/plugin_yaml/plugin_yaml.test.py 
b/tests/gold_tests/pluginTest/plugin_yaml/plugin_yaml.test.py
new file mode 100644
index 0000000000..34623850ff
--- /dev/null
+++ b/tests/gold_tests/pluginTest/plugin_yaml/plugin_yaml.test.py
@@ -0,0 +1,90 @@
+'''
+Test plugin.yaml loading with inline config and enabled/disabled plugins.
+'''
+#  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 that plugin.yaml is loaded instead of plugin.config, that inline config
+works with header_rewrite.so, and that enabled: false skips a plugin.
+'''
+
+Test.ContinueOnFail = True
+
+server = Test.MakeOriginServer("server")
+
+request_header = {"headers": "GET /test HTTP/1.1\r\nHost: 
example.com\r\n\r\n", "timestamp": "1469733493.993", "body": ""}
+response_header = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", 
"timestamp": "1469733493.993", "body": ""}
+server.addResponse("sessionlog.json", request_header, response_header)
+
+ts = Test.MakeATSProcess("ts", enable_cache=False)
+
+ts.Disk.records_config.update(
+    {
+        'proxy.config.url_remap.remap_required': 0,
+        'proxy.config.diags.debug.enabled': 1,
+        'proxy.config.diags.debug.tags': 'header_rewrite|plugin',
+    })
+
+ts.Disk.remap_config.AddLine("map http://example.com 
http://127.0.0.1:{0}".format(server.Variables.Port))
+
+# Write plugin.yaml into the config directory. ATS will prefer this over
+# plugin.config.  Uses inline config (scalar literal) with header_rewrite.so
+# to set a custom response header.  xdebug.so is listed but disabled.
+ts.Disk.MakeConfigFile("plugin.yaml").update(
+    {
+        "plugins":
+            [
+                {
+                    "path": "header_rewrite.so",
+                    "config": "cond %{SEND_RESPONSE_HDR_HOOK}\n  set-header 
X-Plugin-YAML \"loaded-from-inline\"\n",
+                },
+                {
+                    "path": "xdebug.so",
+                    "enabled": False,
+                    "params": ["--enable=x-cache"],
+                },
+            ]
+    })
+
+# Test 1: Verify header_rewrite loaded via plugin.yaml sets the response 
header.
+tr = Test.AddTestRun("Verify inline config sets response header")
+tr.Processes.Default.StartBefore(server, 
ready=When.PortOpen(server.Variables.Port))
+tr.Processes.Default.StartBefore(ts)
+tr.MakeCurlCommand('-s -D- -o /dev/null -H "Host: example.com" 
http://127.0.0.1:{0}/test'.format(ts.Variables.port), ts=ts)
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Streams.stdout = Testers.ContainsExpression(
+    "X-Plugin-YAML: loaded-from-inline", "Response should contain 
X-Plugin-YAML header set by inline config")
+tr.StillRunningAfter = server
+tr.StillRunningAfter = ts
+
+# Test 2: Verify xdebug is NOT loaded (enabled: false). Send the X-Debug
+# header and confirm the X-Cache header is absent from the response.
+tr = Test.AddTestRun("Verify disabled plugin is not loaded")
+tr.MakeCurlCommand(
+    '-s -D- -o /dev/null -H "Host: example.com" -H "X-Debug: x-cache" 
http://127.0.0.1:{0}/test'.format(ts.Variables.port), ts=ts)
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Streams.stdout = Testers.ExcludesExpression(
+    "X-Cache:", "Response should NOT contain X-Cache header since xdebug is 
disabled")
+tr.StillRunningAfter = server
+tr.StillRunningAfter = ts
+
+# Test 3: Verify the diags.log shows plugin.yaml was used.
+tr = Test.AddTestRun("Verify plugin.yaml loading logged")
+tr.Processes.Default.Command = "echo check diags.log"
+tr.Processes.Default.ReturnCode = 0
+ts.Disk.diags_log.Content += Testers.ContainsExpression("plugin.yaml loading", 
"diags.log should indicate plugin.yaml was loaded")
+ts.Disk.diags_log.Content += Testers.ContainsExpression("skipped", "diags.log 
should indicate a plugin was skipped")
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/convert_plugin_config.test.py
 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/convert_plugin_config.test.py
new file mode 100644
index 0000000000..4e691730a9
--- /dev/null
+++ 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/convert_plugin_config.test.py
@@ -0,0 +1,68 @@
+'''
+Test the traffic_ctl config convert plugin_config command.
+'''
+#  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 config convert plugin_config command.'
+
+ts = Test.MakeATSProcess("ts", enable_cache=False)
+
+# Test 1: Basic plugin.config conversion.
+tr = Test.AddTestRun("Test basic plugin.config conversion")
+tr.Setup.Copy('legacy_config/basic.config')
+tr.Processes.Default.Command = 'traffic_ctl config convert plugin_config 
basic.config -'
+tr.Processes.Default.Streams.stdout = "gold/basic.yaml"
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Env = ts.Env
+tr.Processes.Default.StartBefore(ts)
+tr.StillRunningAfter = ts
+
+# Test 2: Commented-out lines become enabled: false.
+tr = Test.AddTestRun("Test commented lines converted to disabled entries")
+tr.Setup.Copy('legacy_config/commented.config')
+tr.Processes.Default.Command = 'traffic_ctl config convert plugin_config 
commented.config -'
+tr.Processes.Default.Streams.stdout = "gold/commented.yaml"
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Env = ts.Env
+tr.StillRunningAfter = ts
+
+# Test 3: Quoted arguments.
+tr = Test.AddTestRun("Test plugin.config with quoted arguments")
+tr.Setup.Copy('legacy_config/quoted.config')
+tr.Processes.Default.Command = 'traffic_ctl config convert plugin_config 
quoted.config -'
+tr.Processes.Default.Streams.stdout = "gold/quoted.yaml"
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Env = ts.Env
+tr.StillRunningAfter = ts
+
+# Test 4: Output to file instead of stdout.
+tr = Test.AddTestRun("Test output to file")
+tr.Setup.Copy('legacy_config/basic.config')
+tr.Processes.Default.Command = 'traffic_ctl config convert plugin_config 
basic.config generated.yaml > /dev/null && cat generated.yaml'
+tr.Processes.Default.Streams.stdout = "gold/basic.yaml"
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Env = ts.Env
+tr.StillRunningAfter = ts
+
+# Test 5: --skip-disabled omits commented-out plugins from output.
+tr = Test.AddTestRun("Test --skip-disabled drops disabled entries")
+tr.Setup.Copy('legacy_config/commented.config')
+tr.Processes.Default.Command = 'traffic_ctl config convert plugin_config 
--skip-disabled commented.config -'
+tr.Processes.Default.Streams.stdout = "gold/skip_disabled.yaml"
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.Env = ts.Env
+tr.StillRunningAfter = ts
diff --git a/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/basic.yaml 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/basic.yaml
new file mode 100644
index 0000000000..9b7c646778
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/basic.yaml
@@ -0,0 +1,10 @@
+plugins:
+  - path: stats_over_http.so
+    params:
+      - _stats
+  - path: header_rewrite.so
+    params:
+      - /etc/trafficserver/rewrite.conf
+  - path: xdebug.so
+    params:
+      - --enable=x-cache
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/commented.yaml 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/commented.yaml
new file mode 100644
index 0000000000..9dfecffd24
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/commented.yaml
@@ -0,0 +1,17 @@
+plugins:
+  - path: stats_over_http.so
+    params:
+      - _stats
+  - path: cache_promote.so
+    enabled: false
+    params:
+      - --policy=lru
+      - --buckets=100
+  - path: header_rewrite.so
+    params:
+      - /etc/trafficserver/rewrite.conf
+  - path: slice.so
+    enabled: false
+    params:
+      - --blockbytes=1048576
+  - path: xdebug.so
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/quoted.yaml 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/quoted.yaml
new file mode 100644
index 0000000000..28fbf1a023
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/quoted.yaml
@@ -0,0 +1,8 @@
+plugins:
+  - path: regex_remap.so
+    params:
+      - maps_https.config
+      - --no-query
+  - path: header_rewrite.so
+    params:
+      - /etc/trafficserver/my rules.conf
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/skip_disabled.yaml 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/skip_disabled.yaml
new file mode 100644
index 0000000000..ecb17a980b
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/convert_plugin_config/gold/skip_disabled.yaml
@@ -0,0 +1,8 @@
+plugins:
+  - path: stats_over_http.so
+    params:
+      - _stats
+  - path: header_rewrite.so
+    params:
+      - /etc/trafficserver/rewrite.conf
+  - path: xdebug.so
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/basic.config 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/basic.config
new file mode 100644
index 0000000000..6f590c26f3
--- /dev/null
+++ 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/basic.config
@@ -0,0 +1,3 @@
+stats_over_http.so _stats
+header_rewrite.so /etc/trafficserver/rewrite.conf
+xdebug.so --enable=x-cache
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/commented.config
 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/commented.config
new file mode 100644
index 0000000000..10499b53eb
--- /dev/null
+++ 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/commented.config
@@ -0,0 +1,5 @@
+stats_over_http.so _stats
+# cache_promote.so --policy=lru --buckets=100
+header_rewrite.so /etc/trafficserver/rewrite.conf
+# slice.so --blockbytes=1048576
+xdebug.so
diff --git 
a/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/quoted.config
 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/quoted.config
new file mode 100644
index 0000000000..429923813d
--- /dev/null
+++ 
b/tests/gold_tests/traffic_ctl/convert_plugin_config/legacy_config/quoted.config
@@ -0,0 +1,2 @@
+regex_remap.so "maps_https.config" --no-query
+header_rewrite.so "/etc/trafficserver/my rules.conf"

Reply via email to