• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

0xmichalis / nftbk / 18590579446

17 Oct 2025 10:55AM UTC coverage: 36.423% (-0.4%) from 36.786%
18590579446

push

github

web-flow
chore: migrate tokens json blob to tokens table (#73)

* chore: migrate tokens json blob to tokens table

* chore: address copilot review

* Update src/server/handlers/handle_status.rs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

38 of 174 new or added lines in 5 files covered. (21.84%)

3 existing lines in 2 files now uncovered.

1501 of 4121 relevant lines covered (36.42%)

6.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/bin/cli.rs
1
use anyhow::{Context, Result};
2
use clap::Parser;
3
use dotenv::dotenv;
4
use flate2::read::GzDecoder;
5
use prettytable::{row, Table};
6
use reqwest::Client;
7
use std::env;
8
use std::fs::File;
9
use std::io::Cursor;
10
use std::path::PathBuf;
11
use tar::Archive;
12
use tokio::fs;
13
use tracing::{debug, error, warn};
14
use zip::ZipArchive;
15

16
use nftbk::backup::{backup_from_config, BackupConfig, ChainConfig, TokenConfig};
17
use nftbk::envvar::is_defined;
18
use nftbk::ipfs::IpfsPinningConfig;
19
use nftbk::logging;
20
use nftbk::logging::LogLevel;
21
use nftbk::server::api::{BackupRequest, BackupResponse, StatusResponse, Tokens};
22
use nftbk::server::archive::archive_format_from_user_agent;
23
use nftbk::{ProcessManagementConfig, StorageConfig};
24

25
const BACKUPS_API_PATH: &str = "/v1/backups";
26

27
#[derive(serde::Deserialize)]
28
struct IpfsConfigFile {
29
    ipfs_pinning_provider: Vec<IpfsPinningConfig>,
30
}
31

32
#[derive(Parser, Debug)]
33
#[command(author, version, about, long_about = None)]
34
struct Args {
35
    /// The path to the chains configuration file
36
    #[arg(short = 'c', long, default_value = "config_chains.toml")]
37
    chains_config_path: PathBuf,
38

39
    /// The path to the tokens configuration file
40
    #[arg(short = 't', long, default_value = "config_tokens.toml")]
41
    tokens_config_path: PathBuf,
42

43
    /// The directory to save the backup to
44
    #[arg(short, long, default_value = "nft_backup")]
45
    output_path: Option<PathBuf>,
46

47
    /// Set the log level
48
    #[arg(short, long, value_enum, default_value = "info")]
49
    log_level: LogLevel,
50

51
    /// Delete redundant files in the backup folder
52
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
53
    prune_redundant: bool,
54

55
    /// Request a backup from the server instead of running locally
56
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
57
    server_mode: bool,
58

59
    /// The server address to request backups from
60
    #[arg(long, default_value = "http://127.0.0.1:8080")]
61
    server_address: String,
62

63
    /// Exit on the first error encountered
64
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
65
    exit_on_error: bool,
66

67
    /// Force rerunning a completed backup task
68
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
69
    force: bool,
70

71
    /// List existing backups in the server
72
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
73
    list: bool,
74

75
    /// User-Agent to send to the server (affects archive format)
76
    #[arg(long, default_value = "Linux")]
77
    user_agent: String,
78

79
    /// Disable colored log output
80
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
81
    no_color: bool,
82

83
    /// Path to a TOML file with IPFS provider configuration
84
    #[arg(long)]
85
    ipfs_config: Option<String>,
86

87
    /// Request server to pin downloaded assets on IPFS
88
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
89
    pin_on_ipfs: bool,
90
}
91

92
enum BackupStart {
93
    Created(BackupResponse),
94
    Exists(BackupResponse),
95
    Conflict {
96
        task_id: String,
97
        retry_url: String,
98
        message: String,
99
    },
100
}
101

102
async fn backup_from_server(
×
103
    token_config: TokenConfig,
104
    server_address: String,
105
    output_path: Option<PathBuf>,
106
    force: bool,
107
    user_agent: String,
108
    pin_on_ipfs: bool,
109
) -> Result<()> {
110
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
111
    let client = Client::new();
×
112

113
    // First, try to create the backup
114
    let start = request_backup(
115
        &token_config,
×
116
        &server_address,
×
117
        &client,
×
118
        auth_token.as_deref(),
×
119
        &user_agent,
×
120
        pin_on_ipfs,
×
121
    )
122
    .await?;
×
123

124
    let task_id = match start {
×
125
        BackupStart::Created(resp) => {
×
126
            let task_id = resp.task_id.clone();
×
127
            println!("Task ID: {task_id}");
×
128
            task_id
×
129
        }
130
        BackupStart::Exists(resp) => {
×
131
            let task_id = resp.task_id.clone();
×
132
            println!("Task ID (exists): {task_id}");
×
133
            if force {
×
134
                // Delete and re-request
135
                let _ =
136
                    delete_backup(&client, &server_address, &task_id, auth_token.as_deref()).await;
×
137
                let second_try = request_backup(
138
                    &token_config,
×
139
                    &server_address,
×
140
                    &client,
×
141
                    auth_token.as_deref(),
×
142
                    &user_agent,
×
143
                    pin_on_ipfs,
×
144
                )
145
                .await?;
×
146
                match second_try {
×
147
                    BackupStart::Created(resp2) | BackupStart::Exists(resp2) => {
×
148
                        let task_id2 = resp2.task_id.clone();
×
149
                        println!("Task ID (after delete): {task_id2}");
×
150
                        task_id2
×
151
                    }
152
                    BackupStart::Conflict {
153
                        message,
×
154
                        retry_url,
×
155
                        task_id: _,
156
                    } => {
157
                        anyhow::bail!(
×
158
                            "{}\nCould not create new task after delete. Try POST to {}{}",
×
159
                            message,
160
                            server_address.trim_end_matches('/'),
×
161
                            retry_url
162
                        );
163
                    }
164
                }
165
            } else {
166
                task_id
×
167
            }
168
        }
169
        BackupStart::Conflict {
170
            task_id,
×
171
            retry_url,
×
172
            message,
×
173
        } => {
174
            println!("Task ID: {task_id}");
×
175
            if force {
×
176
                println!("Server response: {message}");
×
177
                // Try to delete the existing task by task_id, then request again
178
                let _ =
179
                    delete_backup(&client, &server_address, &task_id, auth_token.as_deref()).await;
×
180
                let second_try = request_backup(
181
                    &token_config,
×
182
                    &server_address,
×
183
                    &client,
×
184
                    auth_token.as_deref(),
×
185
                    &user_agent,
×
186
                    pin_on_ipfs,
×
187
                )
188
                .await?;
×
189
                match second_try {
×
190
                    BackupStart::Created(resp2) | BackupStart::Exists(resp2) => {
×
191
                        let task_id2 = resp2.task_id.clone();
×
192
                        println!("Task ID (after delete): {task_id2}");
×
193
                        task_id2
×
194
                    }
195
                    BackupStart::Conflict {
196
                        message,
×
197
                        retry_url,
×
198
                        task_id: _,
199
                    } => {
200
                        anyhow::bail!(
×
201
                            "{}\nCould not create new task after delete. Try POST to {}{}",
×
202
                            message,
203
                            server_address.trim_end_matches('/'),
×
204
                            retry_url
205
                        );
206
                    }
207
                }
208
            } else {
209
                anyhow::bail!(
×
210
                    "{}\nRun the CLI with --force true, or POST to {}{}",
×
211
                    message,
212
                    server_address.trim_end_matches('/'),
×
213
                    retry_url
214
                );
215
            }
216
        }
217
    };
218

219
    wait_for_done_backup(&client, &server_address, &task_id, auth_token.as_deref()).await?;
×
220

221
    return download_backup(
×
222
        &client,
×
223
        &server_address,
×
224
        &task_id,
×
225
        output_path.as_ref(),
×
226
        auth_token.as_deref(),
×
227
        &archive_format_from_user_agent(&user_agent),
×
228
    )
229
    .await;
×
230
}
231
async fn delete_backup(
×
232
    client: &Client,
233
    server_address: &str,
234
    task_id: &str,
235
    auth_token: Option<&str>,
236
) -> Result<()> {
237
    let server = server_address.trim_end_matches('/');
×
238
    let url = format!("{server}{BACKUPS_API_PATH}/{task_id}",);
×
239
    let mut req = client.delete(url);
×
240
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
241
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
242
    }
243
    let resp = req
×
244
        .send()
245
        .await
×
246
        .context("Failed to send DELETE to server")?;
247

248
    match resp.status().as_u16() {
×
249
        202 => {
250
            println!("Deletion request sent for backup {task_id}, waiting for completion...");
×
251
            // Poll until backup is actually deleted (returns 404)
252
            loop {
253
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
×
254
                let status_url = format!("{server}{BACKUPS_API_PATH}/{task_id}");
×
255
                let mut status_req = client.get(&status_url);
×
256
                if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
257
                    status_req = status_req
×
258
                        .header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
259
                }
260
                match status_req.send().await {
×
261
                    Ok(status_resp) => {
×
262
                        if status_resp.status().as_u16() == 404 {
×
263
                            println!("Backup {task_id} successfully deleted");
×
264
                            return Ok(());
×
265
                        }
266
                        // Still exists, continue polling
267
                    }
268
                    Err(_) => {
×
269
                        // Network error, continue polling
270
                    }
271
                }
272
            }
273
        }
274
        404 => {
275
            println!("Backup {task_id} already deleted");
×
276
            Ok(())
×
277
        }
278
        409 => {
279
            // In progress; proceed with new request anyway
280
            println!("Existing backup {task_id} is in progress; proceeding to create new request");
×
281
            Ok(())
×
282
        }
283
        code => {
×
284
            let text = resp.text().await.unwrap_or_default();
×
285
            println!("Warning: failed to delete existing backup ({code}): {text}");
×
286
            Ok(())
×
287
        }
288
    }
289
}
290

291
async fn request_backup(
×
292
    token_config: &TokenConfig,
293
    server_address: &str,
294
    client: &Client,
295
    auth_token: Option<&str>,
296
    user_agent: &str,
297
    pin_on_ipfs: bool,
298
) -> Result<BackupStart> {
299
    let mut backup_req = BackupRequest {
300
        tokens: Vec::new(),
×
301
        pin_on_ipfs,
302
        create_archive: true,
303
    };
304
    for (chain, tokens) in &token_config.chains {
×
305
        backup_req.tokens.push(Tokens {
×
306
            chain: chain.clone(),
×
307
            tokens: tokens.clone(),
×
308
        });
309
    }
310

311
    let server = server_address.trim_end_matches('/');
×
312
    println!("Submitting backup request to server at {server}{BACKUPS_API_PATH} ...");
×
313
    let mut req = client
×
314
        .post(format!("{server}{BACKUPS_API_PATH}"))
×
315
        .json(&backup_req);
×
316
    req = req.header("User-Agent", user_agent);
×
317
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
318
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
319
    }
320
    let resp = req
×
321
        .send()
322
        .await
×
323
        .context("Failed to send backup request to server")?;
324
    let status = resp.status();
×
325
    if status.is_success() {
×
326
        let backup_resp: BackupResponse = resp.json().await.context("Invalid server response")?;
×
327
        if status.as_u16() == 201 {
×
328
            return Ok(BackupStart::Created(backup_resp));
×
329
        } else {
330
            return Ok(BackupStart::Exists(backup_resp));
×
331
        }
332
    }
333
    if status.as_u16() == 409 {
×
334
        let body: serde_json::Value = resp
×
335
            .json()
336
            .await
×
337
            .context("Invalid conflict response from server")?;
338
        let task_id = body
×
339
            .get("task_id")
340
            .and_then(|v| v.as_str())
×
341
            .unwrap_or_default()
342
            .to_string();
343
        let retry_url = body
×
344
            .get("retry_url")
345
            .and_then(|v| v.as_str())
×
346
            .unwrap_or("")
347
            .to_string();
348
        let message = body
×
349
            .get("error")
350
            .and_then(|v| v.as_str())
×
351
            .unwrap_or(&format!("Server returned conflict for {BACKUPS_API_PATH}"))
×
352
            .to_string();
353
        return Ok(BackupStart::Conflict {
×
354
            task_id,
×
355
            retry_url,
×
356
            message,
×
357
        });
358
    }
359
    let text = resp.text().await.unwrap_or_default();
×
360
    anyhow::bail!("Server error: {}", text);
×
361
}
362

363
async fn wait_for_done_backup(
×
364
    client: &reqwest::Client,
365
    server_address: &str,
366
    task_id: &str,
367
    auth_token: Option<&str>,
368
) -> Result<()> {
369
    let status_url = format!(
×
370
        "{}{}/{}",
371
        server_address.trim_end_matches('/'),
×
372
        BACKUPS_API_PATH,
373
        task_id
374
    );
375
    let mut in_progress_logged = false;
×
376
    loop {
377
        let mut req = client.get(&status_url);
×
378
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
379
            req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
380
        }
381
        let resp = req.send().await;
×
382
        match resp {
×
383
            Ok(r) => {
×
384
                if r.status().is_success() {
×
385
                    let status: StatusResponse = r.json().await.unwrap_or({
×
386
                        // Fallback shape with nulls
387
                        StatusResponse {
×
NEW
388
                            tokens: Vec::new(),
×
NEW
389
                            total_tokens: 0,
×
NEW
390
                            page: 1,
×
NEW
391
                            limit: 50,
×
392
                            archive: nftbk::server::api::SubresourceStatus {
×
393
                                status: None,
×
394
                                fatal_error: None,
×
395
                                error_log: None,
×
396
                            },
397
                            ipfs: nftbk::server::api::SubresourceStatus {
×
398
                                status: None,
×
399
                                fatal_error: None,
×
400
                                error_log: None,
×
401
                            },
402
                        }
403
                    });
404
                    // Aggregate a coarse "overall" view for UX: in_progress if any subresource is in_progress
405
                    let archive_status = status.archive.status.as_deref();
×
406
                    let ipfs_status = status.ipfs.status.as_deref();
×
407
                    let any_in_progress = matches!(archive_status, Some("in_progress"))
×
408
                        || matches!(ipfs_status, Some("in_progress"));
×
409
                    let any_error = matches!(archive_status, Some("error"))
×
410
                        || matches!(ipfs_status, Some("error"))
×
411
                        || status.archive.fatal_error.is_some()
×
412
                        || status.ipfs.fatal_error.is_some();
×
413
                    let all_done = matches!(archive_status, Some("done"))
×
414
                        && (ipfs_status.is_none() || matches!(ipfs_status, Some("done")));
×
415
                    if any_in_progress {
×
416
                        if !in_progress_logged {
×
417
                            println!("Waiting for backup to complete...");
×
418
                            in_progress_logged = true;
×
419
                        }
420
                    } else if all_done {
×
421
                        println!("Backup complete.");
×
422
                        if let Some(ref a) = status.archive.error_log {
×
423
                            if !a.is_empty() {
×
424
                                warn!("{}", a);
×
425
                            }
426
                        }
427
                        if let Some(ref i) = status.ipfs.error_log {
×
428
                            if !i.is_empty() {
×
429
                                warn!("{}", i);
×
430
                            }
431
                        }
432
                        break;
×
433
                    } else if any_error {
×
434
                        let msg = status
×
435
                            .archive
×
436
                            .fatal_error
×
437
                            .clone()
438
                            .or(status.ipfs.fatal_error.clone())
×
439
                            .unwrap_or_else(|| "Unknown error".to_string());
×
440
                        anyhow::bail!("Server error: {}", msg);
×
441
                    } else {
442
                        println!(
×
443
                            "Unknown status: archive={:?} ipfs={:?}",
×
444
                            status.archive.status, status.ipfs.status
×
445
                        );
446
                    }
447
                } else {
448
                    println!("Failed to get status: {}", r.status());
×
449
                }
450
            }
451
            Err(e) => {
×
452
                println!("Error polling status: {e}");
×
453
            }
454
        }
455
        tokio::time::sleep(std::time::Duration::from_secs(10)).await;
×
456
    }
457
    Ok(())
×
458
}
459

460
async fn download_backup(
×
461
    client: &Client,
462
    server_address: &str,
463
    task_id: &str,
464
    output_path: Option<&PathBuf>,
465
    _auth_token: Option<&str>,
466
    archive_format: &str,
467
) -> Result<()> {
468
    // Step 1: Get download token
469
    let token_url = format!(
×
470
        "{}{}/{}/download-tokens",
471
        server_address.trim_end_matches('/'),
×
472
        BACKUPS_API_PATH,
473
        task_id
474
    );
475
    let mut token_req = client.post(&token_url);
×
476
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
×
477
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
478
    }
479
    let token_resp = token_req
×
480
        .send()
481
        .await
×
482
        .context("Failed to get download token")?;
483
    if !token_resp.status().is_success() {
×
484
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
×
485
    }
486
    let token_json: serde_json::Value =
×
487
        token_resp.json().await.context("Invalid token response")?;
×
488
    let download_token = token_json
×
489
        .get("token")
490
        .and_then(|v| v.as_str())
×
491
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
×
492

493
    // Step 2: Download using token
494
    let download_url = format!(
×
495
        "{}{}/{}/download?token={}",
496
        server_address.trim_end_matches('/'),
×
497
        BACKUPS_API_PATH,
498
        task_id,
499
        urlencoding::encode(download_token)
×
500
    );
501
    println!("Downloading archive ...");
×
502
    let resp = client
×
503
        .get(&download_url)
×
504
        .send()
505
        .await
×
506
        .context("Failed to download archive")?;
507
    if !resp.status().is_success() {
×
508
        anyhow::bail!("Failed to download archive: {}", resp.status());
×
509
    }
510
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
×
511
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
×
512

513
    println!("Extracting backup to {}...", output_path.display());
×
514
    match archive_format {
×
515
        "tar.gz" => {
×
516
            let gz = GzDecoder::new(Cursor::new(bytes));
×
517
            let mut archive = Archive::new(gz);
×
518
            archive
×
519
                .unpack(&output_path)
×
520
                .context("Failed to extract backup archive (tar.gz)")?;
521
        }
522
        "zip" => {
×
523
            let mut zip =
×
524
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
×
525
            for i in 0..zip.len() {
×
526
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
×
527
                let outpath = match file.enclosed_name() {
×
528
                    Some(path) => output_path.join(path),
×
529
                    None => continue,
×
530
                };
531
                if file.name().ends_with('/') {
×
532
                    std::fs::create_dir_all(&outpath)
×
533
                        .context("Failed to create directory from zip")?;
534
                } else {
535
                    if let Some(p) = outpath.parent() {
×
536
                        std::fs::create_dir_all(p)
×
537
                            .context("Failed to create parent directory for zip file")?;
538
                    }
539
                    let mut outfile =
×
540
                        File::create(&outpath).context("Failed to create file from zip")?;
×
541
                    std::io::copy(&mut file, &mut outfile)
×
542
                        .context("Failed to extract file from zip")?;
543
                }
544
            }
545
        }
546
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
×
547
    }
548
    println!("Backup extracted to {}", output_path.display());
×
549
    Ok(())
×
550
}
551

552
async fn list_server_backups(server_address: &str) -> Result<()> {
×
553
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
554
    let client = Client::new();
×
555
    let url = format!(
×
556
        "{}{}",
557
        server_address.trim_end_matches('/'),
×
558
        BACKUPS_API_PATH
559
    );
560
    let mut req = client.get(&url);
×
561
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
562
        req = req.header(
×
563
            "Authorization",
×
564
            format!("Bearer {}", auth_token.as_deref().unwrap()),
×
565
        );
566
    }
567
    let resp = req
×
568
        .send()
569
        .await
×
570
        .context("Failed to fetch backups from server")?;
571
    if !resp.status().is_success() {
×
572
        let text = resp.text().await.unwrap_or_default();
×
573
        anyhow::bail!("Server error: {}", text);
×
574
    }
575
    let backups: serde_json::Value = resp.json().await.context("Invalid server response")?;
×
576
    let arr = backups
×
577
        .as_array()
578
        .ok_or_else(|| anyhow::anyhow!("Expected array response"))?;
×
579
    let mut table = Table::new();
×
580
    table.add_row(row!["Task ID", "Status", "Error", "Error Log", "NFT Count"]);
×
581
    for entry in arr {
×
582
        let task_id = entry.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
×
583
        let status = entry.get("status").and_then(|v| v.as_str()).unwrap_or("");
×
584
        let error = entry.get("error").and_then(|v| v.as_str()).unwrap_or("");
×
585
        let archive_error_log = entry
×
586
            .get("archive_error_log")
587
            .and_then(|v| v.as_str())
×
588
            .unwrap_or("");
589
        let ipfs_error_log = entry
×
590
            .get("ipfs_error_log")
591
            .and_then(|v| v.as_str())
×
592
            .unwrap_or("");
593
        let combined = [archive_error_log, ipfs_error_log]
×
594
            .iter()
595
            .filter(|s| !s.is_empty())
×
596
            .cloned()
597
            .collect::<Vec<_>>()
598
            .join(" | ");
599
        let nft_count = entry.get("nft_count").and_then(|v| v.as_u64()).unwrap_or(0);
×
600
        table.add_row(row![task_id, status, error, combined, nft_count]);
×
601
    }
602
    table.printstd();
×
603
    Ok(())
×
604
}
605

606
#[tokio::main]
607
async fn main() -> Result<()> {
×
608
    dotenv().ok();
×
609
    let args = Args::parse();
×
610
    logging::init(args.log_level, !args.no_color);
×
611
    debug!(
×
612
        "Starting {} {} (commit {})",
×
613
        env!("CARGO_BIN_NAME"),
614
        env!("CARGO_PKG_VERSION"),
615
        env!("GIT_COMMIT")
616
    );
617

618
    if args.server_mode && args.list {
×
619
        return list_server_backups(&args.server_address).await;
×
620
    }
621

622
    let tokens_content = fs::read_to_string(&args.tokens_config_path)
×
623
        .await
×
624
        .context("Failed to read tokens config file")?;
625
    let token_config: TokenConfig =
×
626
        toml::from_str(&tokens_content).context("Failed to parse tokens config file")?;
×
627

628
    if args.server_mode {
×
629
        return backup_from_server(
×
630
            token_config,
×
631
            args.server_address,
×
632
            args.output_path,
×
633
            args.force,
×
634
            args.user_agent,
×
635
            args.pin_on_ipfs,
×
636
        )
637
        .await;
×
638
    }
639

640
    let chains_content = fs::read_to_string(&args.chains_config_path)
×
641
        .await
×
642
        .context("Failed to read chains config file")?;
643
    let mut chain_config: ChainConfig =
×
644
        toml::from_str(&chains_content).context("Failed to parse chains config file")?;
×
645
    chain_config.resolve_env_vars()?;
×
646

647
    // Load IPFS provider configuration from file if provided
648
    let ipfs_pinning_configs = if args.ipfs_config.is_none() {
×
649
        Vec::new()
×
650
    } else {
651
        let path = args.ipfs_config.as_ref().unwrap();
×
652
        let contents = std::fs::read_to_string(path)
×
653
            .with_context(|| format!("Failed to read IPFS config file '{path}'"))?;
×
654
        let config: IpfsConfigFile = toml::from_str(&contents)
×
655
            .with_context(|| format!("Failed to parse IPFS config file '{path}'"))?;
×
656
        config.ipfs_pinning_provider
×
657
    };
658

659
    let output_path = args.output_path.clone();
×
660
    let backup_config = BackupConfig {
661
        chain_config,
662
        token_config,
663
        storage_config: StorageConfig {
×
664
            output_path: output_path.clone(),
665
            prune_redundant: args.prune_redundant,
666
            ipfs_pinning_configs,
667
        },
668
        process_config: ProcessManagementConfig {
×
669
            exit_on_error: args.exit_on_error,
670
            shutdown_flag: None,
671
        },
672
        task_id: None, // CLI doesn't have a task ID
673
    };
674
    let (archive_out, ipfs_out) = backup_from_config(backup_config, None).await?;
×
675
    // Write combined error log to file if present
676
    let mut merged = Vec::new();
×
677
    if !archive_out.errors.is_empty() {
×
678
        merged.extend(archive_out.errors);
×
679
    }
680
    if !ipfs_out.errors.is_empty() {
×
681
        merged.extend(ipfs_out.errors);
×
682
    }
683
    if !merged.is_empty() {
×
684
        if let Some(ref out_path) = output_path {
×
685
            let mut log_path = out_path.clone();
×
686
            log_path.set_extension("log");
×
687
            let log_content = merged.join("\n") + "\n";
×
688
            use tokio::io::AsyncWriteExt;
689
            let mut file = tokio::fs::File::create(&log_path).await?;
×
690
            file.write_all(log_content.as_bytes()).await?;
×
691
            error!("Error log written to {}", log_path.display());
×
692
        }
693
    }
694
    Ok(())
×
695
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc