You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by dm...@apache.org on 2026/10/06 15:02:09 UTC
(trafficserver) branch master updated: hostdb: keep records in filtered status output (#13732)
This is an automated email from the ASF dual-hosted git repository.
brbzull0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 285d26a11f hostdb: keep records in filtered status output (#13732)
285d26a11f is described below
commit 285d26a11f6969af408a49f6cab85c361957f3d3
Author: Damian Meden <dm...@apache.org>
AuthorDate: Tue Oct 6 17:02:01 2026 +0200
hostdb: keep records in filtered status output (#13732)
get_hostdb_status created a partition's "records" key only as a side
effect of push_back inside the record loop. With a HOSTNAME filter, a
partition that held entries but no match was still listed, with its id
and no records key, so `traffic_ctl hostdb status <name>` returned bare
{"id": N} stubs next to the real matches.
Construct "records" as an empty sequence before the loop, as #13609 did
for "partitions", so every listed partition carries the array. Add an
autest that populates HostDB through DNS, with the default 64 partitions
and with a single one, and checks each filtered answer against the
unfiltered one.
Fixes: #13710
---
src/mgmt/rpc/handlers/hostdb/HostDB.cc | 3 +
.../replay/hostdb_status_filter.replay.yaml | 61 ++++++
.../traffic_ctl_hostdb_status_filter.test.py | 217 +++++++++++++++++++++
3 files changed, 281 insertions(+)
diff --git a/src/mgmt/rpc/handlers/hostdb/HostDB.cc b/src/mgmt/rpc/handlers/hostdb/HostDB.cc
index 8d5e921d79..259aa6d503 100644
--- a/src/mgmt/rpc/handlers/hostdb/HostDB.cc
+++ b/src/mgmt/rpc/handlers/hostdb/HostDB.cc
@@ -101,6 +101,9 @@ template <> struct convert<HostDBCache> {
Node partition_node;
partition_node["id"] = i;
+ // Always a sequence, so a partition whose entries are all filtered out by `hostname`
+ // emits `"records": []` rather than dropping the key.
+ partition_node["records"] = Node{YAML::NodeType::Sequence};
for (auto const &record_handle : partition_records) {
HostDBRecord *record = record_handle.get();
diff --git a/tests/gold_tests/traffic_ctl/replay/hostdb_status_filter.replay.yaml b/tests/gold_tests/traffic_ctl/replay/hostdb_status_filter.replay.yaml
new file mode 100644
index 0000000000..df4b74b24e
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/replay/hostdb_status_filter.replay.yaml
@@ -0,0 +1,61 @@
+# 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.
+
+# One transaction per origin name. Each request makes ATS resolve its origin
+# through DNS, which is what puts the name into HostDB.
+
+meta:
+ version: "1.0"
+
+sessions:
+- transactions:
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ url: /one
+ headers:
+ fields:
+ - [ Host, one.hostdb.test ]
+ - [ uuid, one ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+
+ proxy-response:
+ status: 200
+
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ url: /two
+ headers:
+ fields:
+ - [ Host, two.hostdb.test ]
+ - [ uuid, two ]
+
+ server-response:
+ status: 200
+ reason: OK
+ headers:
+ fields:
+ - [ Content-Length, 0 ]
+
+ proxy-response:
+ status: 200
diff --git a/tests/gold_tests/traffic_ctl/traffic_ctl_hostdb_status_filter.test.py b/tests/gold_tests/traffic_ctl/traffic_ctl_hostdb_status_filter.test.py
new file mode 100644
index 0000000000..47e853eac5
--- /dev/null
+++ b/tests/gold_tests/traffic_ctl/traffic_ctl_hostdb_status_filter.test.py
@@ -0,0 +1,217 @@
+'''
+Verify that filtering hostdb status by HOSTNAME keeps the records array.
+'''
+# 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.
+
+import json
+
+from jsonrpc import Request, Response
+
+Test.Summary = '''
+Every partition in hostdb status output carries a `records` array, including a
+partition whose records are all excluded by the HOSTNAME filter.
+
+The filter can only empty a partition that holds records, and an empty HostDB
+has no partitions to show, so HostDB is populated through DNS first. That is
+why traffic_ctl_json_null.test.py, which needs an empty HostDB, cannot cover
+this.
+'''
+
+Test.ContinueOnFail = True
+
+# The unfiltered answer of each ATS process, as {partition id: sorted record
+# names}, recorded by its first query. A filter changes what each partition
+# holds, never which partitions are listed, so every filtered answer is checked
+# against it. A stream tester runs as soon as its test run's process exits, so
+# this is filled in before the next test run starts.
+unfiltered = {}
+
+
+def partition_names(partitions: object) -> tuple[dict | None, str]:
+ '''Map each partition id to the sorted names of its records.
+
+ Every partition has to be an object with an `id` no other partition has,
+ and a `records` array. Returns (None, reason) when one is not.
+ '''
+ if not isinstance(partitions, list) or not partitions:
+ return (None, f'partitions should be a non-empty array, got {partitions!r}')
+ names = {}
+ for partition in partitions:
+ if not isinstance(partition, dict) or 'id' not in partition:
+ return (None, f'partition carries no id: {partition!r}')
+ if not isinstance(partition.get('records'), list):
+ return (None, f'partition carries no records array: {partition!r}')
+ if partition['id'] in names:
+ return (None, f'partition {partition["id"]} is listed twice')
+ names[partition['id']] = sorted(record['metadata']['name'] for record in partition['records'])
+ return (names, '')
+
+
+def check_unfiltered(ts: str, partitions: object, expected_names: list[str], expected_ids: list[str] | None) -> tuple[bool, str]:
+ '''Check the unfiltered answer of `ts`, and keep it for its filtered checks.
+
+ `expected_ids`, when given, are the partition ids the answer has to list.
+ '''
+ names, reason = partition_names(partitions)
+ if names is None:
+ return (False, reason)
+ if not all(names.values()):
+ return (False, f'unfiltered output lists an empty partition: {names}')
+ found = {name for records in names.values() for name in records}
+ if found != set(expected_names):
+ return (False, f'records named {sorted(found)}, expected {sorted(expected_names)}')
+ if expected_ids is not None and sorted(names) != sorted(expected_ids):
+ return (False, f'partitions {sorted(names)} listed, expected {sorted(expected_ids)}')
+ unfiltered[ts] = names
+ return (True, f'{len(names)} partition(s): {names}')
+
+
+def check_filtered(ts: str, partitions: object, hostname: str) -> tuple[bool, str]:
+ '''Check a filtered answer of `ts` against its unfiltered one.
+
+ The same partitions have to be listed, each holding exactly its unfiltered
+ records whose names contain `hostname`, so a partition whose records all
+ fail the filter carries an empty `records` array.
+ '''
+ names, reason = partition_names(partitions)
+ if names is None:
+ return (False, reason)
+ if ts not in unfiltered:
+ return (False, f'no unfiltered answer to compare against; got {names}')
+ expected = {pid: [name for name in records if hostname in name] for pid, records in unfiltered[ts].items()}
+ if names != expected:
+ return (False, f'got {names}, expected {expected}')
+ return (True, f'{len(names)} partition(s): {names}')
+
+
+class HostDBStatusFilterTest:
+ '''Populate HostDB with two names, then query it with and without a filter.
+
+ One ATS process keeps the default 64 partitions, where the two names
+ usually land in partitions of their own; the hash that places them
+ includes the origin port. The other has a single partition, which both
+ names share and whose id has to be 0.
+ '''
+
+ replay_file = 'replay/hostdb_status_filter.replay.yaml'
+
+ # Neither name contains the other, so a filter on one excludes the other.
+ one = 'one.hostdb.test'
+ two = 'two.hostdb.test'
+
+ def __init__(self) -> None:
+ self._server = Test.MakeVerifierServerProcess('server', self.replay_file)
+
+ self._dns = Test.MakeDNServer('dns')
+ self._dns.addRecords(records={self.one: ['127.0.0.1'], self.two: ['127.0.0.1']})
+
+ self._ts = self._make_ts('ts', {})
+ self._ts1 = self._make_ts('ts1', {'proxy.config.hostdb.partitions': 1})
+
+ def _make_ts(self, name: str, records: dict) -> 'Process':
+ ts = Test.MakeATSProcess(name, enable_cache=False)
+ ts.Disk.records_config.update(
+ {
+ 'proxy.config.dns.nameservers': f'127.0.0.1:{self._dns.Variables.Port}',
+ 'proxy.config.dns.resolv_conf': 'NULL',
+ **records,
+ })
+ for host in (self.one, self.two):
+ ts.Disk.remap_config.AddLine(f'map http://{host}/ http://{host}:{self._server.Variables.http_port}/')
+ return ts
+
+ def _populate_hostdb(self) -> None:
+ # The DNS and origin servers serve both ATS processes. Only the first
+ # run starts them: starting a running process again repeats its setup,
+ # which fails.
+ for ts in (self._ts, self._ts1):
+ tr = Test.AddTestRun(f'Resolve both origins through {ts.Name}, which puts both names into its HostDB')
+ tr.AddVerifierClientProcess(f'client_{ts.Name}', self.replay_file, http_ports=[ts.Variables.port])
+ if ts is self._ts:
+ tr.Processes.Default.StartBefore(self._dns)
+ tr.Processes.Default.StartBefore(self._server)
+ tr.Processes.Default.StartBefore(ts)
+ tr.StillRunningAfter = ts
+
+ def _rpc_status(self, ts: 'Process', description: str, hostname: str, expected_ids: list[str] | None = None) -> None:
+ '''Check the get_hostdb_status response as the server sends it.'''
+
+ def check(resp: Response) -> tuple[bool, str]:
+ if resp.is_error():
+ return (False, resp.error_as_str())
+ partitions = resp.result['data']['partitions']
+ if not hostname:
+ return check_unfiltered(ts.Name, partitions, [self.one, self.two], expected_ids)
+ return check_filtered(ts.Name, partitions, hostname)
+
+ tr = Test.AddTestRun(description)
+ tr.AddJsonRPCClientRequest(ts, Request.get_hostdb_status(hostname=hostname))
+ tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(check)
+ tr.StillRunningAfter = ts
+
+ def _cli_status(self, ts: 'Process', description: str, hostname: str) -> None:
+ '''Check what traffic_ctl hostdb status HOSTNAME prints.'''
+
+ def check(info, tester) -> tuple[bool, str, str]:
+ desc = f'Check traffic_ctl hostdb status {hostname}'
+ with open(tester.GetContent(info), 'r') as stdout:
+ raw = stdout.read()
+ try:
+ doc = json.loads(raw)
+ except ValueError as ex:
+ return (False, desc, f'Output is not JSON: {ex}\nOutput was:\n{raw}')
+ passed, reason = check_filtered(ts.Name, doc.get('partitions'), hostname)
+ return (passed, desc, f'{reason}\nOutput was:\n{raw}' if not passed else reason)
+
+ tr = Test.AddTestRun(description)
+ tr.Processes.Default.Command = f'traffic_ctl hostdb status {hostname}'
+ tr.Processes.Default.Env = ts.Env
+ tr.Processes.Default.ReturnCode = 0
+ tr.Processes.Default.Streams.stdout = Testers.Lambda(check)
+ tr.StillRunningAfter = ts
+
+ def run(self) -> None:
+ self._populate_hostdb()
+
+ # The baseline: without a filter, both names are there, and every
+ # partition listed holds at least one record.
+ self._rpc_status(self._ts, 'get_hostdb_status without a filter', '')
+
+ # A filter matching one name. A partition holding only the other name
+ # is still listed, with an empty `records` array. Whether the two names
+ # share a partition depends on their hashes, so the no-match cases
+ # below are the ones that empty a partition unconditionally.
+ self._rpc_status(self._ts, 'get_hostdb_status filtered to one name', self.one)
+ self._cli_status(self._ts, 'traffic_ctl hostdb status filtered to one name', self.one)
+
+ # HOSTNAME matches any record whose name contains it, not only one
+ # that equals it or starts with it.
+ self._rpc_status(self._ts, 'get_hostdb_status filtered to part of one name', 'ne.hostdb')
+
+ # A filter matching nothing. Every partition is still listed, each
+ # with an empty `records` array.
+ self._rpc_status(self._ts, 'get_hostdb_status filtered to no name', 'no.such.host')
+ self._cli_status(self._ts, 'traffic_ctl hostdb status filtered to no name', 'no.such.host')
+
+ # One partition holding both names. The filter has to keep one of its
+ # records and drop the other, or drop both and leave `records` empty.
+ self._rpc_status(self._ts1, 'get_hostdb_status of one partition without a filter', '', expected_ids=['0'])
+ self._rpc_status(self._ts1, 'get_hostdb_status of one partition filtered to one name', self.one)
+ self._rpc_status(self._ts1, 'get_hostdb_status of one partition filtered to no name', 'no.such.host')
+
+
+HostDBStatusFilterTest().run()