topology.rs 18 KB

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