Skip to main content

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}