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 ¶ms);
+swoc::Rv<YAML::Node> get_plugin_list(std::string_view const &id, YAML::Node
const ¶ms);
} // 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 ¶ms)
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"