Skip to content

Traffic ctl plugin list format - #13626

Open
brbzull0 wants to merge 5 commits into
apache:masterfrom
brbzull0:traffic-ctl-plugin-list-format
Open

Traffic ctl plugin list format#13626
brbzull0 wants to merge 5 commits into
apache:masterfrom
brbzull0:traffic-ctl-plugin-list-format

Conversation

@brbzull0

@brbzull0 brbzull0 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

traffic_ctl: honor -f json for plugin list

TL;DR

traffic_ctl plugin list -f json printed the human-readable table and no JSON.
The flag was parsed, a printer was constructed, the server returned a correct
payload — the command just never consulted the printer on the success path.

Given a plugin.yaml:

plugins:
  - path: stats_over_http.so
    load_order: 10
  - path: xdebug.so
    params:
      - --enable=x-cache
  - path: header_rewrite.so
    enabled: false

both of these printed the same thing:

$ traffic_ctl plugin list
source: plugin.yaml
  #  plugin                          load_order   status
  1  stats_over_http.so              10           loaded
  2  xdebug.so                       --           loaded
  3  header_rewrite.so               --           disabled

$ traffic_ctl plugin list -f json
source: plugin.yaml
  #  plugin                          load_order   status
  1  stats_over_http.so              10           loaded
  2  xdebug.so                       --           loaded
  3  header_rewrite.so               --           disabled

$ traffic_ctl plugin list -f json | python3 -c 'import json,sys; json.load(sys.stdin)'
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Column 1, char 0 — no JSON at all, not a malformed payload.

The load_order and enabled: false columns above only exist because of the
recent plugin.yaml migration, which makes the payload worth consuming
programmatically in a way the old single-line plugin.config never was.

This moves the table into a PluginListPrinter so the command follows the same
path as every other one, and adds the autests that were impossible to write
before.

Text output is unchanged, byte for byte.


⚠️ Stacked on #13609 — please merge that one first

This branch is built on top of
#13609
(traffic_ctl: emit JSON null instead of YAML tilde), so GitHub shows five
commits and fourteen files
. Only the top two are mine:

Commit Belongs to
Add autests for traffic_ctl plugin list output this PR
traffic_ctl: honor -f json for plugin list this PR
traffic_ctl: address review on the JSON emitter helper #13609
traffic_ctl: route JSON emitters through one helper #13609
traffic_ctl: emit JSON null instead of YAML tilde #13609

Everything under src/config/, src/mgmt/, include/, and
doc/developer-guide/jsonrpc/ in this diff belongs to #13609 — including the
one-line YAML::NodeType::Sequence change in
src/mgmt/rpc/handlers/plugins/Plugins.cc. This PR's own changes are limited
to src/traffic_ctl/ and tests/gold_tests/traffic_ctl/.
Reviewing those
two directories covers it.

Note this is not the plugin.yaml migration — that is already on master and
is untouched here. #13609 is about the JSON emitter writing ~ where JSON
requires null.

Why stacked rather than standalone. On master the server emits plugins: ~
for an empty plugin list, which no JSON parser accepts. So
traffic_ctl_plugin_empty.test.py cannot assert a successful parse until
#13609 lands its LowerNull emitter change together with the
YAML::NodeType::Sequence fix in get_plugin_list. The alternative was to
copy that one-line fix into this branch, which would have duplicated an open PR
and set up a merge conflict between the two. Stacking makes the dependency
visible instead of hiding it.

If reviewers would rather see this stand alone, the empty-plugin-list test can
move into #13609 — that PR is what makes the output parseable, so the
assertion arguably belongs there — leaving this PR as the printer fix plus the
populated-config test, both of which pass on master unchanged.


The bug

PluginCommand::plugin_list() touched _printer exactly once, in the error
branch. Past that it decoded the response and hand-rolled a table into
std::cout:

if (response.is_error()) {
  _printer->write_output(response);   // the only use of _printer
  return;
}

auto info = response.result.as<PluginListResponse>();

std::cout << "source: " << info.source << '\n';   // hand-rolled from here down
...

Format-agnostic by construction — every format produced byte-identical output,
whether the source was plugin.yaml or the legacy plugin.config.

-f rpc appeared to work, which made this easy to miss: the wire trace is
emitted by the transport layer through _printer->write_debug(), independently
of whatever the command itself prints.

Every other command hands the response to the printer and lets
BasePrinter::write_output(JSONRPCResponse const &) branch on
is_json_format() — emitting the envelope for JSON, delegating to the derived
write_output(YAML::Node const &) for text. plugin list was the only one
bypassing it.

The fix

Three files, and the table code moves verbatim:

  • CtrlPrinters.h — new PluginListPrinter, alongside the fifteen printers
    already there.
  • CtrlPrinters.cc — the table loop, unchanged, as
    PluginListPrinter::write_output(YAML::Node const &). <iomanip> moves here
    with it.
  • CtrlCommands.cc — pick the printer per subcommand; plugin_list()
    reduces to build, invoke, hand off.
void
PluginCommand::plugin_list()
{
  GetPluginListRequest request;
  auto                 response = invoke_rpc(request);

  _printer->write_output(response);
}

JSON then works through the base class. The explicit response.is_error()
early-out disappears with no behavior change: the old code passed the response
to a GenericPrinter, which resolves to the same non-virtual
BasePrinter::write_output(JSONRPCResponse const &) the new code calls.

Exit codes are unaffected, including on error. Worth noting for reviewers that
BasePrinter::write_output sets App_Exit_Status_Code = CTRL_EX_ERROR only in
text mode — with -f json it emits fullMsg and returns before the
is_error() block, so an RPC error still exits CTRL_EX_OK. That is
pre-existing and global to traffic_ctl, unchanged here, and orthogonal to
this PR.

The --format flag is documented as a global option with no per-command
carve-out, so this brings the code in line with documented behavior rather than
adding a new capability.

Why text mode keeps the table

HostDBStatusPrinter and ServerStatusPrinter both just call
write_output_json(result["data"]) in text mode — they have no human format
at all, so bare traffic_ctl server status already prints JSON. plugin list
is the only command in this family with a real table, and dropping it to match
would be a user-visible regression for no gain. After this change text is for
humans and -f json is for machines.

Tests

Two autests, and the assertion is a real parse, not a gold file. That
distinction is the whole point: a gold file would have matched the table
indefinitely, which is exactly how both the ignored --format flag and the
~ shipped green.

  • traffic_ctl_plugin_output.test.py — populated plugin.config. Asserts the
    text table byte for byte, that -f json parses, and that
    result.data.source is correct.
  • traffic_ctl_plugin_empty.test.py — empty plugin.config. Asserts
    plugins is [], which is the assertion that needs traffic_ctl: emit JSON null instead of YAML tilde #13609 underneath it.
    Separate file because a populated config never reaches this case, and
    because TrafficCtl hardcodes its ATS process name, so two instances cannot
    share one file.

Both use plugin.config rather than plugin.yaml: the autest ATS extension
registers plugin.config as a Disk file but has no plugin.yaml equivalent,
so the DSL cannot write one. This leaves the load_order column and the
disabled status uncovered — both are reachable only through plugin.yaml.
Adding that registration is a reasonable follow-up; it is not needed to prove
the format dispatch works, which is what this PR changes.

The DSL had no plugin() builder, so this adds one, plus a plugin_config
parameter mirroring the existing records_yaml, and two assertion helpers:

  • validate_json_parses() — pipes through json.load and asserts exit 0.
  • validate_json_data_contains() — same comparison as the existing
    validate_json_contains, but descends into result.data first. The existing
    helper only reaches top-level keys, and with -f json those are just
    jsonrpc, result and id, so payload fields were unreachable.

The new helper keeps its inline script single quoted and passes expected values
as one shlex.quote'd JSON argument. The existing validate_json_contains
interpolates them straight into a double-quoted shell word, so a value
containing an apostrophe raises SyntaxError, one containing $ is silently
shell-expanded before the comparison, and $(...) executes. Not fixed here to
keep the diff scoped, but it is a live footgun in that helper.

Verified the tests are a real regression guard rather than passing for the
wrong reason: feeding the captured pre-fix output to json.load reproduces
Expecting value: line 1 column 1 (char 0) and exits 1.

TOTAL: 2 passed, 0 failed, 0 skipped

Out of scope

-f yaml is not a format traffic_ctl supports. _Fmt_str_to_enum holds
only json and rpc, FormatFlags has no YAML member, and --format
documents {json|rpc}. parse_print_opts looks the string up and silently
keeps NOT_SET on a miss, so -f yaml — and any other unknown value — is
ignored on every command, not just this one. Rejecting unknown format values
is a separate change with wider blast radius.

Scalars are emitted as strings. The JSON emitter double-quotes every
scalar and YAML::Node has already lost the type, so enabled arrives as
"true" and index as "1". Pre-existing and global to traffic_ctl; the
existing validate_json_contains(initialized_done='true') assertion depends
on it.

Known coverage gaps. The load_order column and the disabled status are
untested: plugin.config hardcodes load_order = -1 for every entry, so the
wide header and the -- fallback are unreachable, and the autest ATS extension
registers no plugin.yaml Disk file for the DSL to write. The error path is
also untested — the is_error() early-out this PR removes has no autest
exercising it. Both are worth follow-ups.

admin_plugin_get_list is undocumented. It appears nowhere in
jsonrpc-api.en.rst, while its sibling admin_plugin_send_basic_msg is
referenced from the plugin msg entry. Left alone here; a doc-only follow-up.

Why it matters now

#13609 fixes traffic_ctl emitting YAML's ~ where JSON requires null, and
two commands hit it: hostdb status on an empty HostDB, and plugin list with
no plugins loaded. Its plugin list half could not be asserted, because
-f json produced no JSON to parse — that is the gap this PR closes, which is
why it sits on top rather than beside.

The plugin.yaml migration is the other reason this matters now. plugin list
exists to introspect a format that carries per-entry state — load_order,
enabled — and the whole point of a -f json on that command is letting
tooling read it. A flag that silently returns a fixed-width table instead
defeats that.

Damian Meden added 3 commits August 31, 2026 21:04
The JSON encoders are yaml-cpp emitters, and yaml-cpp spells null as
`~`, so any RPC payload holding a null value was rejected by every
JSON parser. `hostdb status` hit this on every freshly started server
and still exited 0, so callers saw success and then failed to parse.

Initialise the two accumulator nodes as sequences so an empty result
is `[]` rather than null, which also satisfies the published schema,
and set LowerNull on the four JSON emitters so any remaining null is
spelled `null`. Both spellings parse back as null in YAML, so the
server's ability to accept YAML input is unaffected.
The tilde fix set LowerNull at the four emitters on the RPC path, but
SSLMultiCertMarshaller::to_json and StorageMarshaller::to_json build
their own emitters and were missed. Neither can emit a null today, so
nothing is broken, but both are one null away from the same bug.

Collapse the DoubleQuoted/Flow/LowerNull idiom into
ts::Yaml::configure_json_emitter() so a new emitter cannot silently
omit part of it. There is now exactly one place in the tree that puts
an emitter into JSON mode.
Include tsutil/YamlCfg.h directly in CtrlPrinters.cc rather than
relying on it arriving through the codec headers.

Correct the helper comment, which claimed the output was valid JSON
for every node type but null. That holds only for the node shapes
these callers build; a tag, anchor or alias still emits YAML that
JSON does not accept. Say so, and use the canonical yaml-cpp, YAML
and JSON spellings in the comments and the architecture doc.
@brbzull0 brbzull0 added this to the 11.0.0 milestone Sep 3, 2026
@brbzull0 brbzull0 self-assigned this Sep 3, 2026
Copilot AI lite review requested due to automatic review settings September 3, 2026 11:04
@brbzull0 brbzull0 added Plugins JSONRPC JSONRPC 2.0 related work. labels Sep 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The moved table printer now uses snprintf() without an explicit header include (build fragility), and the new AuTest helper hardcodes python3 instead of using the harness interpreter.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes traffic_ctl plugin list so it honors -f json by routing the successful response through the configured printer (matching the behavior of other traffic_ctl commands), and adds AuTest coverage to ensure JSON output is parseable and stable.

Changes:

  • Add a PluginListPrinter and use it for traffic_ctl plugin list so JSON format works on the success path.
  • Centralize “JSON-compatible yaml-cpp emitter” configuration via ts::Yaml::configure_json_emitter() (incl. null emission).
  • Add gold tests and test utilities to validate both text output presence and JSON parsing/structure for plugin list output (including empty plugin lists).
File summaries
File Description
tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py Adds JSON result-data matcher helper and plugin command wrapper; supports injecting plugin.config lines into the ATS process.
tests/gold_tests/traffic_ctl/traffic_ctl_plugin_output.test.py New AuTest covering plugin list output in JSON and basic text mode smoke validation.
tests/gold_tests/traffic_ctl/traffic_ctl_plugin_empty.test.py New AuTest ensuring empty plugin list emits plugins: [] and JSON parses.
src/traffic_ctl/CtrlPrinters.h Declares new PluginListPrinter.
src/traffic_ctl/CtrlPrinters.cc Implements PluginListPrinter table rendering and switches JSON emission to shared emitter configuration.
src/traffic_ctl/CtrlCommands.cc Selects PluginListPrinter for the plugin list subcommand and routes output through _printer.
src/mgmt/rpc/handlers/plugins/Plugins.cc Ensures empty plugin lists are emitted as an empty sequence (not null).
src/mgmt/rpc/handlers/hostdb/HostDB.cc Ensures empty hostdb partition lists are emitted as an empty sequence (not null).
src/config/storage.cc Routes JSON emission through ts::Yaml::configure_json_emitter().
src/config/ssl_multicert.cc Routes JSON emission through ts::Yaml::configure_json_emitter().
include/tsutil/YamlCfg.h Introduces ts::Yaml::configure_json_emitter() to centralize JSON-ish yaml-cpp emitter configuration.
include/shared/rpc/yaml_codecs.h Uses ts::Yaml::configure_json_emitter() for request encoding output.
include/mgmt/rpc/jsonrpc/json/YAMLCodec.h Uses ts::Yaml::configure_json_emitter() and updates related documentation/comments.
doc/developer-guide/jsonrpc/jsonrpc-architecture.en.rst Documents null emission behavior (null vs ~) for JSON compatibility.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/traffic_ctl/CtrlPrinters.cc
Comment thread tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py
Damian Meden added 2 commits September 3, 2026 13:23
plugin_list() formatted its table straight to std::cout and only
consulted the printer on the error path, so --format was silently
ignored on success and the output could not be consumed as JSON.

Move the table into a PluginListPrinter so the command follows the
same path as every other one: the base class dispatches on format,
emitting the JSON-RPC envelope for -f json and delegating to the
printer for text. Text output is unchanged.
Assert the payload through -f json rather than the text table. Column
widths are a presentation detail, so pinning them down byte for byte
only makes the test brittle against cosmetic changes. Text mode gets a
smoke check that it still renders a table.

The parse is the point. A gold file would have matched unparseable
output indefinitely, which is how the ignored --format flag shipped
green in the first place.

The empty plugin.config case gets its own test because a populated
config never reaches it, and because TrafficCtl hardcodes its ATS
process name, so two instances cannot share one file.

Adds the plugin() command builder the DSL was missing, along with a
plugin_config parameter and a structural json assertion helper.
Copilot AI review requested due to automatic review settings September 3, 2026 11:23
@brbzull0
brbzull0 force-pushed the traffic-ctl-plugin-list-format branch from fae05ff to e8dfbee Compare September 3, 2026 11:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change correctly restores format dispatch for plugin list without altering text output and adds targeted AuTests that validate real JSON parsing and payload structure.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

JSONRPC JSONRPC 2.0 related work. Plugins

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants