Skip to content

Commit c6cad96

Browse files
ducktape: end-to-end shadow-link SR preflight ducktape
Two-cluster coverage of the SR preflight over validate_only and real create, positive and negative, asserting the specific failure message.
1 parent f63d7fc commit c6cad96

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Copyright 2026 Redpanda Data, Inc.
2+
#
3+
# Use of this software is governed by the Business Source License
4+
# included in the file licenses/BSL.md
5+
#
6+
# As of the Change Date specified in that file, in accordance with
7+
# the Business Source License, use of this software will be governed
8+
# by the Apache License, Version 2.0
9+
10+
from typing import Any
11+
12+
from connectrpc.errors import ConnectError, ConnectErrorCode
13+
from ducktape.utils.util import wait_until
14+
15+
from rptest.clients.admin.proto.redpanda.core.admin.v2 import shadow_link_pb2
16+
from rptest.clients.rpk import RpkTool
17+
from rptest.services.cluster import TestContext, cluster
18+
from rptest.services.multi_cluster_services import SecondaryClusterArgs
19+
from rptest.services.redpanda import SchemaRegistryConfig
20+
from rptest.tests.cluster_linking_test_base import ShadowLinkTestBase
21+
from rptest.util import expect_exception
22+
23+
SR_API_SYNC_FEATURE = "shadow_link_sr_api_sync"
24+
25+
_SCHEMA = (
26+
'{"type":"record","name":"preflight_record",'
27+
'"fields":[{"name":"f1","type":"string"}]}'
28+
)
29+
30+
31+
class ShadowLinkSchemaRegistryPreflightTest(ShadowLinkTestBase):
32+
"""End-to-end coverage for the Schema Registry API-sync
33+
preflight checks that run on shadow-link creation (including the
34+
validate_only pre-flight): source Schema Registry reachability and target
35+
context emptiness. Both clusters run Schema Registry so the source is a real
36+
reachable endpoint and the target registry is queryable."""
37+
38+
LINK_NAME = "sr-preflight-link"
39+
40+
def __init__(self, test_context: TestContext, *args: Any, **kwargs: Any):
41+
super().__init__(
42+
test_context=test_context,
43+
num_brokers=3,
44+
secondary_cluster_args=SecondaryClusterArgs(
45+
schema_registry_config=SchemaRegistryConfig()
46+
),
47+
schema_registry_config=SchemaRegistryConfig(),
48+
*args,
49+
**kwargs,
50+
)
51+
52+
def _source_sr_url(self) -> str:
53+
return self.source_cluster_service.schema_reg(limit=1)
54+
55+
def _validate_request(
56+
self,
57+
source_url: str,
58+
destination: shadow_link_pb2.SchemaRegistryContextDestination | None = None,
59+
validate_only: bool = True,
60+
) -> shadow_link_pb2.CreateShadowLinkRequest:
61+
req = self.create_default_link_request(
62+
link_name=self.LINK_NAME,
63+
mirror_all_acls=False,
64+
mirror_all_groups=False,
65+
mirror_all_topics=False,
66+
)
67+
api = shadow_link_pb2.SchemaRegistrySyncOptions.ShadowSchemaRegistryApi(
68+
source_url=source_url
69+
)
70+
if destination is not None:
71+
api.destination.CopyFrom(destination)
72+
req.shadow_link.configurations.schema_registry_sync_options.shadow_schema_registry_api.CopyFrom(
73+
api
74+
)
75+
# validate_only runs the preflight without creating the link; when False
76+
# the same checks gate a real create.
77+
req.validate_only = validate_only
78+
return req
79+
80+
def _seed_subject(self, rpk: RpkTool, subject: str) -> None:
81+
rpk.create_schema_from_str(subject, _SCHEMA)
82+
83+
def setUp(self):
84+
super().setUp()
85+
# The API-sync path is feature-gated; ensure it is active so the
86+
# request reaches the preflight checks rather than the feature gate.
87+
self.target_cluster_service.set_feature_active(SR_API_SYNC_FEATURE, True)
88+
# Wait for the shadow-link admin service to be ready so the first
89+
# create/validate request does not race a still-initializing controller
90+
# and return a transient 'unavailable'.
91+
wait_until(
92+
lambda: self.list_links() is not None,
93+
timeout_sec=30,
94+
backoff_sec=1,
95+
retry_on_exc=True,
96+
err_msg="shadow link admin service did not become ready",
97+
)
98+
99+
@cluster(num_nodes=6) # 3 target + 3 source brokers
100+
def test_validate_reachable_empty_target_ok(self):
101+
"""Source reachable and the (identity-mapped) target context empty:
102+
validation succeeds."""
103+
# A source subject puts the default context in scope for identity
104+
# mapping, so the check actually queries the (empty) target context.
105+
self._seed_subject(self.source_cluster_rpk, "source-only-value")
106+
107+
req = self._validate_request(self._source_sr_url())
108+
# Raises ConnectError on failure; success returns normally.
109+
self.create_link_with_request(req=req)
110+
111+
# validate_only must not have created the link.
112+
assert self.LINK_NAME not in [link.name for link in self.list_links()], (
113+
"validate_only unexpectedly created the link"
114+
)
115+
116+
@cluster(num_nodes=6)
117+
def test_validate_source_unreachable(self):
118+
"""An unreachable source Schema Registry URL fails preflight."""
119+
req = self._validate_request("http://schema-registry.invalid:8081")
120+
with expect_exception(
121+
ConnectError,
122+
lambda e: e.code == ConnectErrorCode.FAILED_PRECONDITION
123+
and "source schema registry" in str(e),
124+
):
125+
self.create_link_with_request(req=req)
126+
127+
@cluster(num_nodes=6)
128+
def test_validate_target_context_not_empty(self):
129+
"""A populated target context (identity-mapped from a populated source
130+
context) fails preflight."""
131+
# Same subject on both clusters: source puts the default context in
132+
# scope, target makes that context non-empty.
133+
self._seed_subject(self.source_cluster_rpk, "shared-value")
134+
self._seed_subject(self.target_cluster_rpk, "shared-value")
135+
136+
req = self._validate_request(self._source_sr_url())
137+
with expect_exception(
138+
ConnectError,
139+
lambda e: e.code == ConnectErrorCode.FAILED_PRECONDITION
140+
and "context(s) not empty" in str(e),
141+
):
142+
self.create_link_with_request(req=req)
143+
144+
@cluster(num_nodes=6)
145+
def test_create_blocked_when_target_context_not_empty(self):
146+
"""The same preflight gates a real create (validate_only=False), not
147+
just the dry run: a populated target context must reject the create and
148+
leave no link behind."""
149+
# Same subject on both clusters: source puts the default context in
150+
# scope, target makes that context non-empty.
151+
self._seed_subject(self.source_cluster_rpk, "shared-value")
152+
self._seed_subject(self.target_cluster_rpk, "shared-value")
153+
154+
req = self._validate_request(self._source_sr_url(), validate_only=False)
155+
with expect_exception(
156+
ConnectError,
157+
lambda e: e.code == ConnectErrorCode.FAILED_PRECONDITION
158+
and "context(s) not empty" in str(e),
159+
):
160+
self.create_link_with_request(req=req)
161+
162+
# A rejected create must not leave a partially-created link behind.
163+
assert self.LINK_NAME not in [link.name for link in self.list_links()], (
164+
"preflight-rejected create unexpectedly left the link behind"
165+
)
166+
167+
@cluster(num_nodes=6)
168+
def test_create_succeeds_when_reachable_and_target_empty(self):
169+
"""The positive real-create path: with a reachable source and an empty
170+
target context, a create (validate_only=False) passes preflight and the
171+
link is actually persisted. Guards against preflight over-blocking a
172+
valid SR-API link on the create path."""
173+
# A source subject puts the default context in scope for identity
174+
# mapping; the target is left empty so the emptiness check passes.
175+
self._seed_subject(self.source_cluster_rpk, "source-only-value")
176+
177+
req = self._validate_request(self._source_sr_url(), validate_only=False)
178+
# Raises ConnectError on failure; success returns normally.
179+
self.create_link_with_request(req=req)
180+
181+
assert self.LINK_NAME in [link.name for link in self.list_links()], (
182+
"create passed preflight but the link was not persisted"
183+
)
184+
185+
@cluster(num_nodes=6)
186+
def test_validate_exact_mapping_checks_destination_context(self):
187+
"""Exact mapping keys emptiness off the destination context: a subject
188+
in the target's default context fails when it is a mapping
189+
destination."""
190+
self._seed_subject(self.target_cluster_rpk, "dest-value")
191+
192+
destination = shadow_link_pb2.SchemaRegistryContextDestination(
193+
exact=shadow_link_pb2.SchemaRegistryExactContextMappings(
194+
mappings=[
195+
shadow_link_pb2.SchemaRegistryContextMap(
196+
source=".source-ctx", destination="."
197+
)
198+
]
199+
)
200+
)
201+
req = self._validate_request(self._source_sr_url(), destination=destination)
202+
with expect_exception(
203+
ConnectError,
204+
lambda e: e.code == ConnectErrorCode.FAILED_PRECONDITION
205+
and "context(s) not empty" in str(e),
206+
):
207+
self.create_link_with_request(req=req)

tools/type-checking/type-check-strictness.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
"rptest/tests/security_report_test.py",
197197
"rptest/tests/services_self_test.py",
198198
"rptest/tests/shadow_indexing_firewall_test.py",
199+
"rptest/tests/shadow_link_sr_preflight_test.py",
199200
"rptest/tests/storage_failure_injection_test.py",
200201
"rptest/tests/strict_data_init_test.py",
201202
"rptest/tests/tiered_storage_enable_test.py",

0 commit comments

Comments
 (0)