topology.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. //! `test(topology)` — Redis topology harness (task 42).
  2. //!
  3. //! In-process fixtures via `redis-test` (no Docker needed):
  4. //! * `topology_sentinel_master_resolves` — a 1-master/1-replica/1-sentinel
  5. //! `RedisSentinelCluster` boots, the master is resolved through
  6. //! `SentinelClientBuilder` exactly like `state::sentinel` does, and a
  7. //! SET/GET round-trip flows through the resolved master.
  8. //! * `topology_cluster_single_slot_roundtrip` — a 3-node `RedisCluster`
  9. //! boots and a `{state}`-tagged key round-trips through
  10. //! `ClusterClient` (the single-slot convention the token store relies on).
  11. //!
  12. //! Shared-service parity for CI environments without a `redis-server`
  13. //! binary:
  14. //! * `topology_single_node_store_roundtrip` — same assertions shape against
  15. //! the shared Redis from `compose.test.yaml`
  16. //! (`docker compose -f compose.test.yaml up -d`) with unique key prefixes,
  17. //! driving the real `Store::put_access`/`get_access` through our key layout.
  18. //!
  19. //! The `redis-test` legs skip gracefully (green, noted on stderr) when no
  20. //! `redis-server` binary is on `PATH`; CI installs one via apt (see
  21. //! `.github/workflows/ci.yml`). Bodies use `Result` + `?`
  22. //! (`unwrap`/`expect`/`panic` are denied workspace-wide).
  23. use std::time::{Duration, Instant};
  24. use d_oauth::{
  25. config::DurabilityMode,
  26. state::RedisConn,
  27. store::{
  28. Store, StoreError,
  29. tokens::{AccessRecord, get_access, put_access},
  30. },
  31. };
  32. use redis::{
  33. ConnectionAddr,
  34. sentinel::{SentinelClientBuilder, SentinelServerType},
  35. };
  36. use redis_test::{
  37. cluster::{RedisCluster, RedisClusterConfiguration},
  38. sentinel::RedisSentinelCluster,
  39. };
  40. mod common;
  41. use common::{redis_url, unique_prefixes};
  42. /// Test-body error.
  43. #[derive(Debug, thiserror::Error)]
  44. enum TestError {
  45. /// The shared test service is unreachable (usually: compose not started).
  46. #[error("test setup: {0}")]
  47. Setup(String),
  48. /// A Redis command failed.
  49. #[error("redis error: {0}")]
  50. Redis(#[from] redis::RedisError),
  51. /// A store operation failed.
  52. #[error("store error: {0}")]
  53. Store(#[from] StoreError),
  54. /// A test assertion observed an unexpected value.
  55. #[error("unexpected test value: {0}")]
  56. Unexpected(String),
  57. }
  58. /// Test body result (no `unwrap`/`expect` under the deny lints).
  59. type TestResult = Result<(), TestError>;
  60. /// Whether the `redis-test` fixtures can spawn (they exec `redis-server`).
  61. fn redis_server_available() -> bool {
  62. std::process::Command::new("redis-server")
  63. .arg("--version")
  64. .output()
  65. .map(|output| output.status.success())
  66. .unwrap_or(false)
  67. }
  68. /// RAII owner of a Sentinel fixture: `stop()` runs even when the test body
  69. /// returns early via `?` (child processes must not outlive the test).
  70. struct SentinelFixture {
  71. inner: Option<RedisSentinelCluster>,
  72. }
  73. impl SentinelFixture {
  74. fn start() -> Self {
  75. Self {
  76. inner: Some(RedisSentinelCluster::new(1, 1, 1)),
  77. }
  78. }
  79. }
  80. impl Drop for SentinelFixture {
  81. fn drop(&mut self) {
  82. if let Some(mut cluster) = self.inner.take() {
  83. cluster.stop();
  84. }
  85. }
  86. }
  87. /// RAII owner of a cluster fixture (same teardown contract as above).
  88. struct ClusterFixture {
  89. inner: Option<RedisCluster>,
  90. }
  91. impl ClusterFixture {
  92. fn start() -> Self {
  93. Self {
  94. inner: Some(RedisCluster::new(RedisClusterConfiguration::default())),
  95. }
  96. }
  97. /// Three masters with one replica each (the node-failover harness).
  98. fn start_with_replicas() -> Self {
  99. Self {
  100. inner: Some(RedisCluster::new(
  101. RedisClusterConfiguration::single_replica_config(),
  102. )),
  103. }
  104. }
  105. }
  106. impl Drop for ClusterFixture {
  107. fn drop(&mut self) {
  108. if let Some(mut cluster) = self.inner.take() {
  109. cluster.stop();
  110. }
  111. }
  112. }
  113. /// Sentinel resolves the current master; reads/writes flow through it.
  114. #[tokio::test]
  115. async fn topology_sentinel_master_resolves() -> TestResult {
  116. if !redis_server_available() {
  117. eprintln!("SKIP topology_sentinel_master_resolves: no redis-server on PATH");
  118. return Ok(());
  119. }
  120. let mut fixture = SentinelFixture::start();
  121. let cluster = fixture
  122. .inner
  123. .as_ref()
  124. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  125. // Only the sentinel addresses are known up front — the master port is
  126. // discovered through Sentinel, exactly like `state::sentinel::connect`.
  127. let mut addrs = Vec::new();
  128. for server in cluster.iter_sentinel_servers() {
  129. match server.host_and_port() {
  130. Some((host, port)) => addrs.push(ConnectionAddr::Tcp(host.to_owned(), port)),
  131. None => return Err(TestError::Unexpected("sentinel has no address".to_owned())),
  132. }
  133. }
  134. if addrs.is_empty() {
  135. return Err(TestError::Unexpected("no sentinel addresses".to_owned()));
  136. }
  137. let mut sentinel =
  138. SentinelClientBuilder::new(addrs, "master0", SentinelServerType::Master)?.build()?;
  139. let master = sentinel.async_get_client().await?;
  140. let mut conn = master.get_multiplexed_async_connection().await?;
  141. let key = "topology-sentinel-probe";
  142. let _: () = redis::cmd("SET")
  143. .arg(key)
  144. .arg("master-ok")
  145. .query_async(&mut conn)
  146. .await?;
  147. let got: String = redis::cmd("GET").arg(key).query_async(&mut conn).await?;
  148. if got != "master-ok" {
  149. return Err(TestError::Unexpected(
  150. "sentinel master round-trip mismatch".to_owned(),
  151. ));
  152. }
  153. if let Some(mut cluster) = fixture.inner.take() {
  154. cluster.stop();
  155. }
  156. Ok(())
  157. }
  158. /// A `{state}`-tagged key round-trips on a real 3-node cluster.
  159. #[tokio::test]
  160. async fn topology_cluster_single_slot_roundtrip() -> TestResult {
  161. if !redis_server_available() {
  162. eprintln!("SKIP topology_cluster_single_slot_roundtrip: no redis-server on PATH");
  163. return Ok(());
  164. }
  165. let mut fixture = ClusterFixture::start();
  166. let cluster = fixture
  167. .inner
  168. .as_ref()
  169. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  170. let mut seeds = Vec::new();
  171. for server in cluster.iter_servers() {
  172. match server.host_and_port() {
  173. Some((_, port)) => seeds.push(format!("redis://127.0.0.1:{port}")),
  174. None => {
  175. return Err(TestError::Unexpected(
  176. "cluster node has no address".to_owned(),
  177. ));
  178. }
  179. }
  180. }
  181. if seeds.is_empty() {
  182. return Err(TestError::Unexpected("no cluster seeds".to_owned()));
  183. }
  184. let client = redis::cluster::ClusterClient::new(seeds)?;
  185. let mut conn = client.get_async_connection().await?;
  186. // The token store keeps multi-key Lua ops in one slot via the `{state}`
  187. // hash tag; the harness pins that the tag round-trips on a real cluster.
  188. let key = "oauth:{state}:topology-probe";
  189. let _: () = redis::cmd("SET")
  190. .arg(key)
  191. .arg("cluster-ok")
  192. .query_async(&mut conn)
  193. .await?;
  194. let got: String = redis::cmd("GET").arg(key).query_async(&mut conn).await?;
  195. if got != "cluster-ok" {
  196. return Err(TestError::Unexpected(
  197. "cluster round-trip mismatch".to_owned(),
  198. ));
  199. }
  200. if let Some(mut cluster) = fixture.inner.take() {
  201. cluster.stop();
  202. }
  203. Ok(())
  204. }
  205. /// Parity leg that needs only the shared services: the real token-store
  206. /// access path against the shared Redis with unique key prefixes, so CI
  207. /// stays green where no `redis-server` binary exists but Docker does.
  208. #[tokio::test]
  209. async fn topology_single_node_store_roundtrip() -> TestResult {
  210. let url = redis_url();
  211. let client = redis::Client::open(url.as_str()).map_err(|error| {
  212. TestError::Setup(format!(
  213. "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
  214. ))
  215. })?;
  216. let manager_config = redis::aio::ConnectionManagerConfig::new()
  217. .set_response_timeout(Some(Duration::from_secs(10)))
  218. .set_connection_timeout(Some(Duration::from_secs(5)));
  219. let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config)
  220. .await
  221. .map_err(|error| {
  222. TestError::Setup(format!(
  223. "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
  224. ))
  225. })?;
  226. let mut conn = RedisConn::Single(manager);
  227. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  228. let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
  229. let token_hash = "topology-parity-probe";
  230. let record = AccessRecord {
  231. client_id: "topology-client".to_owned(),
  232. grant_id: "topology-grant".to_owned(),
  233. scope: "read".to_owned(),
  234. subject: "user-1".to_owned(),
  235. cnf: String::new(),
  236. };
  237. put_access(
  238. &mut conn,
  239. &store,
  240. token_hash,
  241. &record,
  242. Duration::from_secs(300),
  243. )
  244. .await?;
  245. match get_access(&mut conn, &store, token_hash).await? {
  246. Some(found) => {
  247. if found != record {
  248. return Err(TestError::Unexpected(
  249. "stored access record mismatch".to_owned(),
  250. ));
  251. }
  252. }
  253. None => {
  254. return Err(TestError::Unexpected(
  255. "stored access record missing".to_owned(),
  256. ));
  257. }
  258. }
  259. Ok(())
  260. }
  261. /// The current Sentinel master port, resolved by the sentinel itself.
  262. async fn sentinel_master_port(host: &str, port: u16) -> Result<u16, TestError> {
  263. let client = redis::Client::open(format!("redis://{host}:{port}"))?;
  264. let mut conn = client.get_multiplexed_async_connection().await?;
  265. let reply: Vec<String> = redis::cmd("SENTINEL")
  266. .arg("GET-MASTER-ADDR-BY-NAME")
  267. .arg("master0")
  268. .query_async(&mut conn)
  269. .await?;
  270. reply
  271. .get(1)
  272. .and_then(|value| value.parse::<u16>().ok())
  273. .ok_or_else(|| TestError::Unexpected("sentinel has no master address".to_owned()))
  274. }
  275. /// A real `SENTINEL FAILOVER master0` promotes the replica: the resolved
  276. /// master changes and a write round-trips through the promoted node.
  277. #[tokio::test]
  278. async fn topology_sentinel_failover_promotes_replica() -> TestResult {
  279. if !redis_server_available() {
  280. eprintln!("SKIP topology_sentinel_failover_promotes_replica: no redis-server on PATH");
  281. return Ok(());
  282. }
  283. let mut fixture = SentinelFixture::start();
  284. let cluster = fixture
  285. .inner
  286. .as_ref()
  287. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  288. let mut sentinel_addr = None;
  289. for server in cluster.iter_sentinel_servers() {
  290. match server.host_and_port() {
  291. Some((host, port)) => {
  292. sentinel_addr.get_or_insert((host.to_owned(), port));
  293. }
  294. None => return Err(TestError::Unexpected("sentinel has no address".to_owned())),
  295. }
  296. }
  297. let Some((sentinel_host, sentinel_port)) = sentinel_addr else {
  298. return Err(TestError::Unexpected("no sentinel addresses".to_owned()));
  299. };
  300. let old_port = sentinel_master_port(&sentinel_host, sentinel_port).await?;
  301. let admin = redis::Client::open(format!("redis://{sentinel_host}:{sentinel_port}"))?;
  302. let mut admin_conn = admin.get_multiplexed_async_connection().await?;
  303. let _: () = redis::cmd("SENTINEL")
  304. .arg("FAILOVER")
  305. .arg("master0")
  306. .query_async(&mut admin_conn)
  307. .await?;
  308. let deadline = Instant::now() + Duration::from_secs(30);
  309. let new_port = loop {
  310. if let Ok(port) = sentinel_master_port(&sentinel_host, sentinel_port).await
  311. && port != old_port
  312. {
  313. break port;
  314. }
  315. if Instant::now() >= deadline {
  316. return Err(TestError::Unexpected(
  317. "SENTINEL FAILOVER did not promote a new master".to_owned(),
  318. ));
  319. }
  320. tokio::time::sleep(Duration::from_millis(500)).await;
  321. };
  322. let promoted = redis::Client::open(format!("redis://127.0.0.1:{new_port}"))?;
  323. let mut conn = promoted.get_multiplexed_async_connection().await?;
  324. let key = "topology-sentinel-failover-probe";
  325. let _: () = redis::cmd("SET")
  326. .arg(key)
  327. .arg("promoted-ok")
  328. .query_async(&mut conn)
  329. .await?;
  330. let got: String = redis::cmd("GET").arg(key).query_async(&mut conn).await?;
  331. if got != "promoted-ok" {
  332. return Err(TestError::Unexpected(
  333. "promoted master round-trip mismatch".to_owned(),
  334. ));
  335. }
  336. if let Some(mut cluster) = fixture.inner.take() {
  337. cluster.stop();
  338. }
  339. Ok(())
  340. }
  341. /// Whether a `ROLE` reply names a master.
  342. fn role_is_master(role: &redis::Value) -> bool {
  343. match role {
  344. redis::Value::Array(items) => matches!(
  345. items.first(),
  346. Some(redis::Value::BulkString(bytes)) if bytes == b"master"
  347. ),
  348. _ => false,
  349. }
  350. }
  351. /// `CLUSTER NODES` text from one cluster seed.
  352. async fn cluster_nodes(seed: &str) -> Result<String, TestError> {
  353. let client = redis::Client::open(seed)?;
  354. let mut conn = client.get_multiplexed_async_connection().await?;
  355. let text: String = redis::cmd("CLUSTER")
  356. .arg("NODES")
  357. .query_async(&mut conn)
  358. .await?;
  359. Ok(text)
  360. }
  361. /// `CLUSTER INFO` text from one cluster seed.
  362. async fn cluster_info(seed: &str) -> Result<String, TestError> {
  363. let client = redis::Client::open(seed)?;
  364. let mut conn = client.get_multiplexed_async_connection().await?;
  365. let text: String = redis::cmd("CLUSTER")
  366. .arg("INFO")
  367. .query_async(&mut conn)
  368. .await?;
  369. Ok(text)
  370. }
  371. /// The slot spec of the node whose address ends in `port`, if any.
  372. fn node_slots(nodes: &str, port: u16) -> Option<(String, String)> {
  373. let needle = format!(":{port}@");
  374. for line in nodes.lines() {
  375. let fields: Vec<&str> = line.split_whitespace().collect();
  376. let addr = fields.get(1)?;
  377. if !addr.contains(&needle) {
  378. continue;
  379. }
  380. let id = fields.first().copied().unwrap_or_default().to_owned();
  381. let slots = fields.get(8..).unwrap_or_default().join(" ");
  382. return Some((id, slots));
  383. }
  384. None
  385. }
  386. /// A real cluster node failure: the stopped master's slots are served by a
  387. /// different node (its replica) and the cluster returns to `cluster_state:ok`.
  388. #[tokio::test]
  389. async fn topology_cluster_node_failover_heals() -> TestResult {
  390. if !redis_server_available() {
  391. eprintln!("SKIP topology_cluster_node_failover_heals: no redis-server on PATH");
  392. return Ok(());
  393. }
  394. let mut fixture = ClusterFixture::start_with_replicas();
  395. let (seeds, victim_port) = {
  396. let cluster = fixture
  397. .inner
  398. .as_ref()
  399. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  400. let mut seeds = Vec::new();
  401. let mut victim = None;
  402. for server in cluster.iter_servers() {
  403. let Some((host, port)) = server.host_and_port() else {
  404. continue;
  405. };
  406. seeds.push(format!("redis://{host}:{port}"));
  407. if victim.is_none() {
  408. let client = redis::Client::open(format!("redis://{host}:{port}"))?;
  409. let mut conn = client.get_multiplexed_async_connection().await?;
  410. let role: redis::Value = redis::cmd("ROLE").query_async(&mut conn).await?;
  411. if role_is_master(&role) {
  412. victim = Some(port);
  413. }
  414. }
  415. }
  416. let victim = victim.ok_or_else(|| TestError::Unexpected("no cluster master".to_owned()))?;
  417. (seeds, victim)
  418. };
  419. let survivor = seeds
  420. .iter()
  421. .find(|seed| !seed.ends_with(&format!(":{victim_port}")))
  422. .cloned()
  423. .ok_or_else(|| TestError::Unexpected("no surviving seed".to_owned()))?;
  424. let before = cluster_nodes(&survivor).await?;
  425. let (victim_id, victim_slots) = node_slots(&before, victim_port)
  426. .ok_or_else(|| TestError::Unexpected("victim slots not found".to_owned()))?;
  427. if victim_slots.is_empty() || victim_slots == "-" {
  428. return Err(TestError::Unexpected(
  429. "victim master owns no slots".to_owned(),
  430. ));
  431. }
  432. {
  433. let cluster = fixture
  434. .inner
  435. .as_mut()
  436. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  437. for server in &mut cluster.servers {
  438. if server.host_and_port().map(|(_, port)| port) == Some(victim_port) {
  439. server.stop();
  440. }
  441. }
  442. }
  443. let deadline = Instant::now() + Duration::from_secs(60);
  444. let promoted = loop {
  445. let healed = cluster_info(&survivor)
  446. .await
  447. .is_ok_and(|info| info.contains("cluster_state:ok"));
  448. if healed && let Ok(nodes) = cluster_nodes(&survivor).await {
  449. let victim_failed = nodes.lines().any(|line| {
  450. line.split_whitespace().next() == Some(victim_id.as_str())
  451. && line
  452. .split_whitespace()
  453. .nth(2)
  454. .is_some_and(|flags| flags.contains("fail"))
  455. });
  456. let first_slot = victim_slots.split_whitespace().next().unwrap_or_default();
  457. let moved = nodes.lines().any(|line| {
  458. let fields: Vec<&str> = line.split_whitespace().collect();
  459. let id = fields.first().copied().unwrap_or_default();
  460. id != victim_id && fields.get(8..).unwrap_or_default().contains(&first_slot)
  461. });
  462. if victim_failed && moved {
  463. break true;
  464. }
  465. }
  466. if Instant::now() >= deadline {
  467. break false;
  468. }
  469. tokio::time::sleep(Duration::from_millis(500)).await;
  470. };
  471. if !promoted {
  472. return Err(TestError::Unexpected(
  473. "cluster did not promote a replica for the failed master".to_owned(),
  474. ));
  475. }
  476. let client = redis::cluster::ClusterClient::new(seeds.clone())?;
  477. let mut conn = client.get_async_connection().await?;
  478. let key = "oauth:{state}:topology-failover-probe";
  479. let _: () = redis::cmd("SET")
  480. .arg(key)
  481. .arg("cluster-healed")
  482. .query_async(&mut conn)
  483. .await?;
  484. let got: String = redis::cmd("GET").arg(key).query_async(&mut conn).await?;
  485. if got != "cluster-healed" {
  486. return Err(TestError::Unexpected(
  487. "post-failover cluster round-trip mismatch".to_owned(),
  488. ));
  489. }
  490. if let Some(mut cluster) = fixture.inner.take() {
  491. cluster.stop();
  492. }
  493. Ok(())
  494. }