cloudpub_sdk/builder.rs
1use anyhow::{bail, Context, Result};
2use cloudpub_client::client::run_client;
3pub use cloudpub_client::config::{ClientConfig, ClientOpts};
4use cloudpub_common::config::MaskedString;
5use cloudpub_common::logging::init_log;
6use dirs::cache_dir;
7use futures::future::FutureExt;
8use parking_lot::RwLock;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::mpsc;
13use tracing::warn;
14
15use crate::connection::{CheckSignalFn, Connection, ConnectionEvent};
16
17/// Строитель для создания и настройки экземпляров `Connection`.
18///
19/// `ConnectionBuilder` предоставляет удобный интерфейс для настройки
20/// параметров соединения перед установлением соединения с сервером CloudPub.
21///
22/// # Пример
23///
24/// ```no_run
25/// # async fn example() -> anyhow::Result<()> {
26/// use cloudpub_sdk::Connection;
27/// use std::time::Duration;
28///
29/// let conn = Connection::builder()
30/// .config_path("/custom/config.toml") // Пользовательский файл конфигурации
31/// .log_level("debug") // Установка уровня логирования
32/// .verbose(true) // Включить вывод в консоль
33/// .credentials("user@example.com", "password") // Учетные данные для аутентификации
34/// .timeout(Duration::from_secs(30)) // Таймаут операций
35/// .build()
36/// .await?;
37/// # Ok(())
38/// # }
39/// ```
40///
41/// # Методы аутентификации
42///
43/// Строитель поддерживает два метода аутентификации:
44///
45/// 1. **На основе токена**: Используйте `token()` для аутентификации с существующим токеном
46/// 2. **Учетные данные**: Используйте `credentials()` или `email()`/`password()` для аутентификации по имени/паролю
47///
48/// # Значения по умолчанию
49///
50/// - Уровень логирования: "info"
51/// - Подробный вывод: false
52/// - Таймаут: 10 секунд
53/// - Путь к конфигурации: Системное расположение по умолчанию (~/.config/cloudpub/client.toml)
54pub struct ConnectionBuilder {
55 config_path: Option<PathBuf>,
56 log_level: String,
57 verbose: bool,
58 init_logging: bool,
59 token: Option<String>,
60 email: Option<String>,
61 password: Option<String>,
62 timeout: Duration,
63 check_signal_fn: Option<CheckSignalFn>,
64}
65
66impl Default for ConnectionBuilder {
67 fn default() -> Self {
68 Self {
69 config_path: None,
70 log_level: "info".to_string(),
71 verbose: false,
72 init_logging: true,
73 token: None,
74 email: None,
75 password: None,
76 timeout: Duration::from_secs(10),
77 check_signal_fn: None,
78 }
79 }
80}
81
82impl ConnectionBuilder {
83 /// Создает новый builder с настройками по умолчанию.
84 ///
85 /// # Пример
86 ///
87 /// ```no_run
88 /// use cloudpub_sdk::ConnectionBuilder;
89 ///
90 /// let builder = ConnectionBuilder::new();
91 /// ```
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 /// Устанавливает путь к файлу конфигурации.
97 ///
98 /// По умолчанию SDK ищет файл конфигурации в стандартном системном расположении.
99 /// Используйте этот метод для указания пользовательского расположения.
100 ///
101 /// # Аргументы
102 ///
103 /// * `path` - Путь к файлу конфигурации
104 ///
105 /// # Пример
106 ///
107 /// ```no_run
108 /// # async fn example() -> anyhow::Result<()> {
109 /// use cloudpub_sdk::Connection;
110 /// use std::path::Path;
111 ///
112 /// let conn = Connection::builder()
113 /// .config_path(Path::new("/etc/cloudpub/config.toml"))
114 /// .build()
115 /// .await?;
116 /// # Ok(())
117 /// # }
118 /// ```
119 pub fn config_path<P: AsRef<Path>>(mut self, path: P) -> Self {
120 self.config_path = Some(path.as_ref().to_path_buf());
121 self
122 }
123
124 /// Устанавливает уровень логирования для SDK.
125 ///
126 /// # Аргументы
127 ///
128 /// * `level` - Уровень логирования: "trace", "debug", "info", "warn", "error"
129 ///
130 /// # Пример
131 ///
132 /// ```no_run
133 /// # async fn example() -> anyhow::Result<()> {
134 /// use cloudpub_sdk::Connection;
135 ///
136 /// // Включить отладочное логирование
137 /// let conn = Connection::builder()
138 /// .log_level("debug")
139 /// .verbose(true) // Также выводить в консоль
140 /// .build()
141 /// .await?;
142 /// # Ok(())
143 /// # }
144 /// ```
145 pub fn log_level<S: Into<String>>(mut self, level: S) -> Self {
146 self.log_level = level.into();
147 self
148 }
149
150 /// Включает или отключает подробное логирование в консоль.
151 ///
152 /// При включении сообщения логов выводятся в stderr в дополнение к файлу логов.
153 /// Это полезно для отладки и разработки.
154 ///
155 /// # Аргументы
156 ///
157 /// * `verbose` - true для включения вывода в консоль, false для отключения
158 ///
159 /// # Пример
160 ///
161 /// ```no_run
162 /// # async fn example() -> anyhow::Result<()> {
163 /// use cloudpub_sdk::Connection;
164 ///
165 /// let conn = Connection::builder()
166 /// .verbose(true) // Включить вывод в консоль
167 /// .build()
168 /// .await?;
169 /// # Ok(())
170 /// # }
171 /// ```
172 pub fn verbose(mut self, verbose: bool) -> Self {
173 self.verbose = verbose;
174 self
175 }
176
177 /// Включает или отключает инициализацию логирования SDK.
178 ///
179 /// По умолчанию `build()` устанавливает глобальный tracing-подписчик
180 /// (`tracing::subscriber::set_global_default`) с записью в файл лога.
181 /// Если в процессе уже установлен собственный подписчик, такая инициализация
182 /// завершится ошибкой. Вызовите `init_logging(false)`, чтобы SDK не трогал
183 /// глобальное состояние логирования: события SDK попадут в подписчик
184 /// приложения, а настройки `log_level()` и `verbose()` игнорируются -
185 /// фильтрация остается за подписчиком приложения.
186 ///
187 /// # Аргументы
188 ///
189 /// * `init` - false, чтобы использовать собственное логирование приложения
190 ///
191 /// # Пример
192 ///
193 /// ```no_run
194 /// # async fn example() -> anyhow::Result<()> {
195 /// use cloudpub_sdk::Connection;
196 ///
197 /// // В приложении уже настроен tracing-подписчик
198 /// let conn = Connection::builder()
199 /// .init_logging(false)
200 /// .build()
201 /// .await?;
202 /// # Ok(())
203 /// # }
204 /// ```
205 pub fn init_logging(mut self, init: bool) -> Self {
206 self.init_logging = init;
207 self
208 }
209
210 /// Устанавливает токен аутентификации.
211 ///
212 /// Используйте этот метод для аутентификации на основе токена. Это взаимоисключающе
213 /// с аутентификацией на основе учетных данных.
214 ///
215 /// # Аргументы
216 ///
217 /// * `token` - Токен аутентификации, полученный при предыдущем входе
218 ///
219 /// # Пример
220 ///
221 /// ```no_run
222 /// # async fn example() -> anyhow::Result<()> {
223 /// use cloudpub_sdk::Connection;
224 ///
225 /// let conn = Connection::builder()
226 /// .token("your-auth-token-here")
227 /// .build()
228 /// .await?;
229 /// # Ok(())
230 /// # }
231 /// ```
232 pub fn token<S: Into<String>>(mut self, token: S) -> Self {
233 self.token = Some(token.into());
234 self.email = None;
235 self.password = None;
236 self
237 }
238
239 /// Устанавливает email и пароль для аутентификации.
240 ///
241 /// Используйте этот метод для аутентификации на основе учетных данных. Это взаимоисключающе
242 /// с аутентификацией на основе токена.
243 ///
244 /// # Аргументы
245 ///
246 /// * `email` - Email адрес пользователя
247 /// * `password` - Пароль пользователя
248 ///
249 /// # Пример
250 ///
251 /// ```no_run
252 /// # async fn example() -> anyhow::Result<()> {
253 /// use cloudpub_sdk::Connection;
254 ///
255 /// let conn = Connection::builder()
256 /// .credentials("user@example.com", "secure-password")
257 /// .build()
258 /// .await?;
259 /// # Ok(())
260 /// # }
261 /// ```
262 pub fn credentials<S: Into<String>>(mut self, email: S, password: S) -> Self {
263 self.email = Some(email.into());
264 self.password = Some(password.into());
265 self.token = None;
266 self
267 }
268
269 /// Устанавливает только email адрес.
270 ///
271 /// Должен использоваться в сочетании с `password()`. Это полезно, когда
272 /// учетные данные получаются отдельно.
273 ///
274 /// # Аргументы
275 ///
276 /// * `email` - Email адрес пользователя
277 ///
278 /// # Пример
279 ///
280 /// ```no_run
281 /// # async fn example() -> anyhow::Result<()> {
282 /// use cloudpub_sdk::Connection;
283 ///
284 /// let conn = Connection::builder()
285 /// .email("user@example.com")
286 /// .password("secure-password")
287 /// .build()
288 /// .await?;
289 /// # Ok(())
290 /// # }
291 /// ```
292 pub fn email<S: Into<String>>(mut self, email: S) -> Self {
293 self.email = Some(email.into());
294 self.token = None;
295 self
296 }
297
298 /// Устанавливает только пароль.
299 ///
300 /// Должен использоваться в сочетании с `email()`. Это полезно, когда
301 /// учетные данные получаются отдельно.
302 ///
303 /// # Аргументы
304 ///
305 /// * `password` - Пароль пользователя
306 ///
307 /// # Пример
308 ///
309 /// ```no_run
310 /// # async fn example() -> anyhow::Result<()> {
311 /// use cloudpub_sdk::Connection;
312 ///
313 /// let email = std::env::var("CLOUDPUB_EMAIL")?;
314 /// let password = std::env::var("CLOUDPUB_PASSWORD")?;
315 ///
316 /// let conn = Connection::builder()
317 /// .email(email)
318 /// .password(password)
319 /// .build()
320 /// .await?;
321 /// # Ok(())
322 /// # }
323 /// ```
324 pub fn password<S: Into<String>>(mut self, password: S) -> Self {
325 self.password = Some(password.into());
326 self.token = None;
327 self
328 }
329
330 /// Устанавливает таймаут для операций.
331 ///
332 /// Этот таймаут применяется ко всем асинхронным операциям, таким как register, publish, ls и т.д.
333 /// По умолчанию 10 секунд.
334 ///
335 /// # Аргументы
336 ///
337 /// * `timeout` - Продолжительность таймаута операции
338 ///
339 /// # Пример
340 ///
341 /// ```no_run
342 /// # async fn example() -> anyhow::Result<()> {
343 /// use cloudpub_sdk::Connection;
344 /// use std::time::Duration;
345 ///
346 /// let conn = Connection::builder()
347 /// .timeout(Duration::from_secs(60)) // Таймаут 1 минута
348 /// .build()
349 /// .await?;
350 /// # Ok(())
351 /// # }
352 /// ```
353 pub fn timeout(mut self, timeout: Duration) -> Self {
354 self.timeout = timeout;
355 self
356 }
357
358 /// Устанавливает таймаут в секундах.
359 ///
360 /// Удобный метод для установки таймаута в секундах вместо Duration.
361 ///
362 /// # Аргументы
363 ///
364 /// * `secs` - Таймаут в секундах
365 ///
366 /// # Пример
367 ///
368 /// ```no_run
369 /// # async fn example() -> anyhow::Result<()> {
370 /// use cloudpub_sdk::Connection;
371 ///
372 /// let conn = Connection::builder()
373 /// .timeout_secs(30) // Таймаут 30 секунд
374 /// .build()
375 /// .await?;
376 /// # Ok(())
377 /// # }
378 /// ```
379 pub fn timeout_secs(mut self, secs: u64) -> Self {
380 self.timeout = Duration::from_secs(secs);
381 self
382 }
383
384 /// Устанавливает функцию для проверки сигналов прерывания.
385 ///
386 /// Это в основном используется языковыми обертками (например, Python) для проверки
387 /// сигналов, таких как Ctrl+C, во время долговременных операций.
388 ///
389 /// # Аргументы
390 ///
391 /// * `check_fn` - Функция, которая возвращает ошибку, если операция должна быть прервана
392 ///
393 /// # Пример
394 ///
395 /// ```no_run
396 /// # async fn example() -> anyhow::Result<()> {
397 /// use cloudpub_sdk::{Connection, CheckSignalFn};
398 /// use std::sync::Arc;
399 /// use std::sync::atomic::{AtomicBool, Ordering};
400 ///
401 /// let interrupted = Arc::new(AtomicBool::new(false));
402 /// let interrupted_clone = interrupted.clone();
403 ///
404 /// let check_signal: CheckSignalFn = Arc::new(move || {
405 /// if interrupted_clone.load(Ordering::Relaxed) {
406 /// anyhow::bail!("Operation interrupted")
407 /// }
408 /// Ok(())
409 /// });
410 ///
411 /// let conn = Connection::builder()
412 /// .check_signal_fn(check_signal)
413 /// .build()
414 /// .await?;
415 ///
416 /// # Ok(())
417 /// # }
418 /// ```
419 pub fn check_signal_fn(mut self, check_fn: CheckSignalFn) -> Self {
420 self.check_signal_fn = Some(check_fn);
421 self
422 }
423
424 /// Создает и устанавливает соединение с сервером CloudPub.
425 ///
426 /// Этот метод:
427 /// 1. Проверяет конфигурацию
428 /// 2. Инициализирует логирование (если не отключено через `init_logging(false)`)
429 /// 3. Загружает или создает файл конфигурации
430 /// 4. Аутентифицируется на сервере (если предоставлены учетные данные)
431 /// 5. Устанавливает соединение
432 /// 6. Ожидает готовности соединения
433 ///
434 /// # Возвращает
435 ///
436 /// Возвращает экземпляр `Connection` при успехе, или ошибку, если:
437 /// - Неверная конфигурация (например, email без пароля)
438 /// - Неудачная аутентификация
439 /// - Не удается установить соединение
440 /// - Происходит таймаут
441 ///
442 /// # Пример
443 ///
444 /// ```no_run
445 /// # async fn example() -> anyhow::Result<()> {
446 /// use cloudpub_sdk::Connection;
447 ///
448 /// // Создание с настройками по умолчанию
449 /// let conn = Connection::builder().build().await?;
450 ///
451 /// // Создание с пользовательской конфигурацией
452 /// let conn = Connection::builder()
453 /// .credentials("user@example.com", "password")
454 /// .log_level("debug")
455 /// .verbose(true)
456 /// .timeout_secs(30)
457 /// .build()
458 /// .await?;
459 /// # Ok(())
460 /// # }
461 /// ```
462 ///
463 /// # Ошибки
464 ///
465 /// Этот метод вернет ошибку, если:
466 /// - Email предоставлен без пароля или наоборот
467 /// - Не удалось инициализировать логирование (например, в процессе уже
468 /// установлен глобальный tracing-подписчик; используйте `init_logging(false)`)
469 /// - Не удалось загрузить или создать файл конфигурации
470 /// - Неудачная аутентификация
471 /// - Неудачное соединение с сервером
472 /// - Таймаут ожидания соединения
473 pub async fn build(self) -> Result<Connection> {
474 // Проверяем аутентификацию, если она предоставлена
475 if self.email.is_some() && self.password.is_none() {
476 bail!("Password is required when email is provided");
477 }
478 if self.password.is_some() && self.email.is_none() {
479 bail!("Email is required when password is provided");
480 }
481
482 // Увеличиваем лимит `nofile` на Linux и Mac
483 if let Err(err) = fdlimit::raise_fd_limit() {
484 warn!("Failed to raise file descriptor limit: {}", err);
485 }
486
487 // Инициализируем логирование, если приложение не использует собственное
488 let _guard = if self.init_logging {
489 // Создаем директорию для логов
490 let log_dir = cache_dir().context("Can't get cache dir")?.join("cloudpub");
491 std::fs::create_dir_all(&log_dir).context("Can't create log dir")?;
492
493 let log_file = log_dir.join("client.log");
494
495 Some(
496 init_log(
497 &self.log_level,
498 &log_file,
499 self.verbose,
500 10 * 1024 * 1024,
501 2,
502 )
503 .context("Failed to initialize logging")?,
504 )
505 } else {
506 None
507 };
508
509 // Загружаем конфигурацию
510 let mut config = if let Some(ref path) = self.config_path {
511 ClientConfig::from_file(path, false)?
512 } else {
513 ClientConfig::load("client.toml", true, false)?
514 };
515
516 // Обрабатываем аутентификацию по токену
517 if let Some(token_value) = self.token {
518 config.token = Some(MaskedString(token_value));
519 }
520
521 let config = Arc::new(RwLock::new(config));
522
523 // Настраиваем каналы
524 let (command_tx, command_rx) = mpsc::channel(1024);
525 let (result_tx, result_rx) = mpsc::channel(1024);
526
527 // Определяем опции на основе аутентификации
528 let opts = if let Some(email_value) = self.email {
529 if let Some(password_value) = self.password {
530 ClientOpts {
531 credentials: Some((email_value, password_value)),
532 ..Default::default()
533 }
534 } else {
535 // Это не должно произойти из-за проверки выше
536 ClientOpts::default()
537 }
538 } else {
539 ClientOpts::default()
540 };
541
542 // Клонируем то, что нам нужно для задачи клиента
543 let config_clone = config.clone();
544
545 // Запускаем задачу клиента
546 let client_handle = tokio::spawn(async move {
547 if let Err(err) = run_client(config_clone, opts, command_rx, result_tx)
548 .boxed()
549 .await
550 {
551 tracing::error!("Client exited with error: {:?}, restarting in 5 sec..", err);
552 }
553 });
554
555 // Создаем соединение со встроенным управлением событиями
556 let connection = Connection::new(
557 config,
558 command_tx,
559 result_rx,
560 self.timeout,
561 self.check_signal_fn,
562 _guard,
563 Some(client_handle),
564 );
565
566 // Ожидаем установления соединения
567 connection
568 .wait_for_event(|event| matches!(event, ConnectionEvent::Connected))
569 .await?;
570
571 Ok(connection)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 // Регрессионный тест: в процессе с уже установленным глобальным
580 // tracing-подписчиком build() без init_logging(false) падал на
581 // set_global_default, а обойти это было нельзя (Connection::new - pub(crate))
582 #[tokio::test]
583 async fn test_build_with_external_subscriber() {
584 tracing::subscriber::set_global_default(tracing_subscriber::registry())
585 .expect("subscriber must not be set yet in this process");
586
587 let err = ConnectionBuilder::new()
588 .build()
589 .await
590 .err()
591 .expect("init_logging defaults to true and must fail");
592 assert!(
593 err.to_string().contains("Failed to initialize logging"),
594 "unexpected error: {err:#}"
595 );
596
597 // С init_logging(false) этап логирования пропускается: сборка доходит
598 // до загрузки конфигурации и падает уже на несуществующем файле
599 let err = ConnectionBuilder::new()
600 .init_logging(false)
601 .config_path("/nonexistent/cloudpub-test/client.toml")
602 .build()
603 .await
604 .err()
605 .expect("nonexistent config path must fail");
606 assert!(
607 !err.to_string().contains("Failed to initialize logging"),
608 "logging must not be initialized: {err:#}"
609 );
610 }
611}