|
| 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) |
0 commit comments