topology.rs 17 KB

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