topology.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  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. if let Some(server) = cluster.iter_sentinel_servers().next()
  139. && let Some((host, port)) = server.host_and_port()
  140. {
  141. wait_for_sentinel(host, port).await?;
  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 the shared services: the real token-store
  212. /// access path against the shared Redis with unique key prefixes, so CI
  213. /// stays green where no `redis-server` binary exists but Docker does.
  214. #[tokio::test]
  215. async fn topology_single_node_store_roundtrip() -> TestResult {
  216. let url = redis_url();
  217. let client = redis::Client::open(url.as_str()).map_err(|error| {
  218. TestError::Setup(format!(
  219. "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
  220. ))
  221. })?;
  222. let manager_config = redis::aio::ConnectionManagerConfig::new()
  223. .set_response_timeout(Some(Duration::from_secs(10)))
  224. .set_connection_timeout(Some(Duration::from_secs(5)));
  225. let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config)
  226. .await
  227. .map_err(|error| {
  228. TestError::Setup(format!(
  229. "cannot reach shared test Redis at {url}: {error}; run `docker compose -f compose.test.yaml up -d`"
  230. ))
  231. })?;
  232. let mut conn = RedisConn::Single(manager);
  233. let (token_prefix, ratelimit_prefix) = unique_prefixes();
  234. let store = Store::from_parts(token_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
  235. let token_hash = "topology-parity-probe";
  236. let record = AccessRecord {
  237. client_id: "topology-client".to_owned(),
  238. grant_id: "topology-grant".to_owned(),
  239. scope: "read".to_owned(),
  240. subject: "user-1".to_owned(),
  241. cnf: String::new(),
  242. aud: Vec::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. /// Wait until the sentinel is reachable and resolves a master.
  283. ///
  284. /// The `redis-test` fixture readiness-waits only the master and replica, never
  285. /// the sentinel, so under a loaded runner the sentinel can lag behind them and
  286. /// the first sentinel command fails with `Connection refused`.
  287. async fn wait_for_sentinel(host: &str, port: u16) -> TestResult {
  288. let deadline = Instant::now() + Duration::from_secs(30);
  289. loop {
  290. if sentinel_master_port(host, port).await.is_ok() {
  291. return Ok(());
  292. }
  293. if Instant::now() >= deadline {
  294. return Err(TestError::Unexpected(
  295. "sentinel never became reachable".to_owned(),
  296. ));
  297. }
  298. tokio::time::sleep(Duration::from_millis(250)).await;
  299. }
  300. }
  301. /// Whether Sentinel reports at least one replica with a live replication
  302. /// link (`master-link-status: ok`). `SENTINEL FAILOVER` refuses with
  303. /// `NOGOODSLAVE` while the only replica is still handshaking, so the
  304. /// failover harness gates on this before issuing the failover.
  305. async fn sentinel_replica_synced(host: &str, port: u16) -> bool {
  306. let Ok(client) = redis::Client::open(format!("redis://{host}:{port}")) else {
  307. return false;
  308. };
  309. let Ok(mut conn) = client.get_multiplexed_async_connection().await else {
  310. return false;
  311. };
  312. let Ok(replicas): Result<Vec<HashMap<String, String>>, _> = redis::cmd("SENTINEL")
  313. .arg("REPLICAS")
  314. .arg("master0")
  315. .query_async(&mut conn)
  316. .await
  317. else {
  318. return false;
  319. };
  320. replicas.iter().any(|replica| {
  321. replica
  322. .get("master-link-status")
  323. .is_some_and(|status| status == "ok")
  324. })
  325. }
  326. /// A real `SENTINEL FAILOVER master0` promotes the replica: the resolved
  327. /// master changes and a write round-trips through the promoted node.
  328. #[tokio::test]
  329. async fn topology_sentinel_failover_promotes_replica() -> TestResult {
  330. if !redis_server_available() {
  331. eprintln!("SKIP topology_sentinel_failover_promotes_replica: no redis-server on PATH");
  332. return Ok(());
  333. }
  334. let mut fixture = SentinelFixture::start();
  335. let cluster = fixture
  336. .inner
  337. .as_ref()
  338. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  339. let mut sentinel_addr = None;
  340. for server in cluster.iter_sentinel_servers() {
  341. match server.host_and_port() {
  342. Some((host, port)) => {
  343. sentinel_addr.get_or_insert((host.to_owned(), port));
  344. }
  345. None => return Err(TestError::Unexpected("sentinel has no address".to_owned())),
  346. }
  347. }
  348. let Some((sentinel_host, sentinel_port)) = sentinel_addr else {
  349. return Err(TestError::Unexpected("no sentinel addresses".to_owned()));
  350. };
  351. wait_for_sentinel(&sentinel_host, sentinel_port).await?;
  352. let old_port = sentinel_master_port(&sentinel_host, sentinel_port).await?;
  353. // The replica link is still handshaking right after fixture boot on loaded
  354. // runners; Sentinel refuses the failover with `NOGOODSLAVE` until the link
  355. // is up, so wait for a synced replica first.
  356. let sync_deadline = Instant::now() + Duration::from_secs(30);
  357. loop {
  358. if sentinel_replica_synced(&sentinel_host, sentinel_port).await {
  359. break;
  360. }
  361. if Instant::now() >= sync_deadline {
  362. return Err(TestError::Unexpected(
  363. "sentinel replica never finished syncing".to_owned(),
  364. ));
  365. }
  366. tokio::time::sleep(Duration::from_millis(250)).await;
  367. }
  368. let admin = redis::Client::open(format!("redis://{sentinel_host}:{sentinel_port}"))?;
  369. let mut admin_conn = admin.get_multiplexed_async_connection().await?;
  370. let _: () = redis::cmd("SENTINEL")
  371. .arg("FAILOVER")
  372. .arg("master0")
  373. .query_async(&mut admin_conn)
  374. .await?;
  375. let deadline = Instant::now() + Duration::from_secs(30);
  376. let new_port = loop {
  377. if let Ok(port) = sentinel_master_port(&sentinel_host, sentinel_port).await
  378. && port != old_port
  379. {
  380. break port;
  381. }
  382. if Instant::now() >= deadline {
  383. return Err(TestError::Unexpected(
  384. "SENTINEL FAILOVER did not promote a new master".to_owned(),
  385. ));
  386. }
  387. tokio::time::sleep(Duration::from_millis(500)).await;
  388. };
  389. let promoted = redis::Client::open(format!("redis://127.0.0.1:{new_port}"))?;
  390. let mut conn = promoted.get_multiplexed_async_connection().await?;
  391. let key = "topology-sentinel-failover-probe";
  392. let _: () = redis::cmd("SET")
  393. .arg(key)
  394. .arg("promoted-ok")
  395. .query_async(&mut conn)
  396. .await?;
  397. let got: String = redis::cmd("GET").arg(key).query_async(&mut conn).await?;
  398. if got != "promoted-ok" {
  399. return Err(TestError::Unexpected(
  400. "promoted master round-trip mismatch".to_owned(),
  401. ));
  402. }
  403. if let Some(mut cluster) = fixture.inner.take() {
  404. cluster.stop();
  405. }
  406. Ok(())
  407. }
  408. /// Whether a `ROLE` reply names a master.
  409. fn role_is_master(role: &redis::Value) -> bool {
  410. match role {
  411. redis::Value::Array(items) => matches!(
  412. items.first(),
  413. Some(redis::Value::BulkString(bytes)) if bytes == b"master"
  414. ),
  415. _ => false,
  416. }
  417. }
  418. /// `CLUSTER NODES` text from one cluster seed.
  419. async fn cluster_nodes(seed: &str) -> Result<String, TestError> {
  420. let client = redis::Client::open(seed)?;
  421. let mut conn = client.get_multiplexed_async_connection().await?;
  422. let text: String = redis::cmd("CLUSTER")
  423. .arg("NODES")
  424. .query_async(&mut conn)
  425. .await?;
  426. Ok(text)
  427. }
  428. /// `CLUSTER INFO` text from one cluster seed.
  429. async fn cluster_info(seed: &str) -> Result<String, TestError> {
  430. let client = redis::Client::open(seed)?;
  431. let mut conn = client.get_multiplexed_async_connection().await?;
  432. let text: String = redis::cmd("CLUSTER")
  433. .arg("INFO")
  434. .query_async(&mut conn)
  435. .await?;
  436. Ok(text)
  437. }
  438. /// The slot spec of the node whose address ends in `port`, if any.
  439. fn node_slots(nodes: &str, port: u16) -> Option<(String, String)> {
  440. let needle = format!(":{port}@");
  441. for line in nodes.lines() {
  442. let fields: Vec<&str> = line.split_whitespace().collect();
  443. let addr = fields.get(1)?;
  444. if !addr.contains(&needle) {
  445. continue;
  446. }
  447. let id = fields.first().copied().unwrap_or_default().to_owned();
  448. let slots = fields.get(8..).unwrap_or_default().join(" ");
  449. return Some((id, slots));
  450. }
  451. None
  452. }
  453. /// A real cluster node failure: the stopped master's slots are served by a
  454. /// different node (its replica) and the cluster returns to `cluster_state:ok`.
  455. #[tokio::test]
  456. async fn topology_cluster_node_failover_heals() -> TestResult {
  457. if !redis_server_available() {
  458. eprintln!("SKIP topology_cluster_node_failover_heals: no redis-server on PATH");
  459. return Ok(());
  460. }
  461. let mut fixture = ClusterFixture::start_with_replicas();
  462. let (seeds, victim_port) = {
  463. let cluster = fixture
  464. .inner
  465. .as_ref()
  466. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  467. let mut seeds = Vec::new();
  468. let mut victim = None;
  469. for server in cluster.iter_servers() {
  470. let Some((host, port)) = server.host_and_port() else {
  471. continue;
  472. };
  473. seeds.push(format!("redis://{host}:{port}"));
  474. if victim.is_none() {
  475. let client = redis::Client::open(format!("redis://{host}:{port}"))?;
  476. let mut conn = client.get_multiplexed_async_connection().await?;
  477. let role: redis::Value = redis::cmd("ROLE").query_async(&mut conn).await?;
  478. if role_is_master(&role) {
  479. victim = Some(port);
  480. }
  481. }
  482. }
  483. let victim = victim.ok_or_else(|| TestError::Unexpected("no cluster master".to_owned()))?;
  484. (seeds, victim)
  485. };
  486. let survivor = seeds
  487. .iter()
  488. .find(|seed| !seed.ends_with(&format!(":{victim_port}")))
  489. .cloned()
  490. .ok_or_else(|| TestError::Unexpected("no surviving seed".to_owned()))?;
  491. let before = cluster_nodes(&survivor).await?;
  492. let (victim_id, victim_slots) = node_slots(&before, victim_port)
  493. .ok_or_else(|| TestError::Unexpected("victim slots not found".to_owned()))?;
  494. if victim_slots.is_empty() || victim_slots == "-" {
  495. return Err(TestError::Unexpected(
  496. "victim master owns no slots".to_owned(),
  497. ));
  498. }
  499. {
  500. let cluster = fixture
  501. .inner
  502. .as_mut()
  503. .ok_or(TestError::Unexpected("fixture missing".to_owned()))?;
  504. for server in &mut cluster.servers {
  505. if server.host_and_port().map(|(_, port)| port) == Some(victim_port) {
  506. server.stop();
  507. }
  508. }
  509. }
  510. let deadline = Instant::now() + Duration::from_secs(60);
  511. let promoted = loop {
  512. let healed = cluster_info(&survivor)
  513. .await
  514. .is_ok_and(|info| info.contains("cluster_state:ok"));
  515. if healed && let Ok(nodes) = cluster_nodes(&survivor).await {
  516. let victim_failed = nodes.lines().any(|line| {
  517. line.split_whitespace().next() == Some(victim_id.as_str())
  518. && line
  519. .split_whitespace()
  520. .nth(2)
  521. .is_some_and(|flags| flags.contains("fail"))
  522. });
  523. let first_slot = victim_slots.split_whitespace().next().unwrap_or_default();
  524. let moved = nodes.lines().any(|line| {
  525. let fields: Vec<&str> = line.split_whitespace().collect();
  526. let id = fields.first().copied().unwrap_or_default();
  527. id != victim_id && fields.get(8..).unwrap_or_default().contains(&first_slot)
  528. });
  529. if victim_failed && moved {
  530. break true;
  531. }
  532. }
  533. if Instant::now() >= deadline {
  534. break false;
  535. }
  536. tokio::time::sleep(Duration::from_millis(500)).await;
  537. };
  538. if !promoted {
  539. return Err(TestError::Unexpected(
  540. "cluster did not promote a replica for the failed master".to_owned(),
  541. ));
  542. }
  543. let client = redis::cluster::ClusterClient::new(seeds.clone())?;
  544. let mut conn = client.get_async_connection().await?;
  545. let key = "oauth:{state}:topology-failover-probe";
  546. let _: () = redis::cmd("SET")
  547. .arg(key)
  548. .arg("cluster-healed")
  549. .query_async(&mut conn)
  550. .await?;
  551. let got: String = redis::cmd("GET").arg(key).query_async(&mut conn).await?;
  552. if got != "cluster-healed" {
  553. return Err(TestError::Unexpected(
  554. "post-failover cluster round-trip mismatch".to_owned(),
  555. ));
  556. }
  557. if let Some(mut cluster) = fixture.inner.take() {
  558. cluster.stop();
  559. }
  560. Ok(())
  561. }