Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/api/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::header::{
ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderValue,
};
use serde::Serialize;
use serde::de::DeserializeOwned;

Expand Down Expand Up @@ -148,6 +150,33 @@ impl ApiClient {
self.handle_response(response)
}

pub fn put_file(
&self,
url: &str,
file: std::fs::File,
content_length: u64,
) -> Result<(), ApiError> {
let response = self
.client
.put(url)
.header(CONTENT_TYPE, "application/gzip")
.header(CONTENT_LENGTH, content_length)
.body(reqwest::blocking::Body::from(file))
.send()
.map_err(ApiError::NetworkError)?;

if response.status().is_success() {
Ok(())
} else {
let status = response.status();
let body = response.text().map_err(ApiError::NetworkError)?;
Err(ApiError::Other(format!(
"Upload failed ({}): {}",
status, body
)))
}
}

pub fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
let response = self
Expand Down
34 changes: 34 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ pub enum Commands {
#[command(subcommand)]
command: DbCommands,
},
/// Manage archives
Archive {
#[command(subcommand)]
command: ArchiveCommands,
},
/// Manage WAF rules and blocklists
Waf {
#[command(subcommand)]
Expand Down Expand Up @@ -491,6 +496,35 @@ pub enum SslCommands {
},
}

#[derive(Subcommand)]
pub enum ArchiveCommands {
/// Import an archive to a site
Import {
/// Site ID
site_id: String,
/// Path to archive file (.tar.gz)
file: String,
/// Drop all existing tables before import
#[arg(long)]
drop_tables: bool,
/// Disable foreign key checks during import
#[arg(long)]
disable_foreign_keys: bool,
/// Search string for search-and-replace during import
#[arg(long)]
search_replace_from: Option<String>,
/// Replace string for search-and-replace during import
#[arg(long)]
search_replace_to: Option<String>,
/// Wait for import to complete
#[arg(long)]
wait: bool,
/// Seconds between status polls (default: 5)
#[arg(long, default_value = "5")]
poll_interval: u64,
},
}

#[derive(Subcommand)]
pub enum DbCommands {
/// Manage archive import sessions
Expand Down
206 changes: 206 additions & 0 deletions src/commands/archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use std::path::Path;
use std::thread;
use std::time::Duration;

use serde::Serialize;
use serde_json::Value;

use crate::api::{ApiClient, ApiError};
use crate::output::{OutputFormat, format_option, print_json, print_key_value, print_message};

#[derive(Debug, Serialize)]
struct CreateImportSessionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_length: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<ImportOptions>,
}

#[derive(Debug, Serialize)]
struct ImportOptions {
#[serde(skip_serializing_if = "std::ops::Not::not")]
drop_tables: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
disable_foreign_keys: bool,
#[serde(skip_serializing_if = "Option::is_none")]
search_replace: Option<SearchReplace>,
}

#[derive(Debug, Serialize)]
struct SearchReplace {
from: String,
to: String,
}

#[allow(clippy::too_many_arguments)]
pub fn import(
client: &ApiClient,
site_id: &str,
file: &str,
drop_tables: bool,
disable_foreign_keys: bool,
search_replace_from: Option<String>,
search_replace_to: Option<String>,
wait: bool,
poll_interval: u64,
format: OutputFormat,
) -> Result<(), ApiError> {
let path = Path::new(file);

if !path.exists() {
return Err(ApiError::Other(format!("File not found: {}", file)));
}

let metadata =
std::fs::metadata(path).map_err(|e| ApiError::Other(format!("Cannot read file: {}", e)))?;

let content_length = metadata.len();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(file)
.to_string();

// Build import options
let search_replace = match (search_replace_from, search_replace_to) {
(Some(from), Some(to)) => Some(SearchReplace { from, to }),
_ => None,
};

let options = if drop_tables || disable_foreign_keys || search_replace.is_some() {
Some(ImportOptions {
drop_tables,
disable_foreign_keys,
search_replace,
})
} else {
None
};

let body = CreateImportSessionRequest {
filename: Some(filename.clone()),
content_length: Some(content_length),
options,
};

// Step 1: Create import session
if format == OutputFormat::Table {
print_message("Creating import session...");
}

let response: Value =
client.post(&format!("/api/v1/vector/sites/{}/imports", site_id), &body)?;

let data = &response["data"];
let import_id = data["id"]
.as_str()
.ok_or_else(|| ApiError::Other("Missing import ID in response".to_string()))?;
let upload_url = data["upload_url"]
.as_str()
.ok_or_else(|| ApiError::Other("Missing upload URL in response".to_string()))?;

if format == OutputFormat::Table {
print_message(&format!("Import ID: {}", import_id));
}

// Step 2: Upload file
let size_mb = content_length as f64 / 1_048_576.0;
if format == OutputFormat::Table {
print_message(&format!("Uploading {} ({:.1} MB)...", filename, size_mb));
}

let file_handle = std::fs::File::open(path)
.map_err(|e| ApiError::Other(format!("Cannot open file: {}", e)))?;

client.put_file(upload_url, file_handle, content_length)?;

if format == OutputFormat::Table {
print_message("Upload complete.");
}

// Step 3: Trigger import
if format == OutputFormat::Table {
print_message("Starting import...");
}

let run_response: Value = client.post_empty(&format!(
"/api/v1/vector/sites/{}/imports/{}/run",
site_id, import_id
))?;

if format == OutputFormat::Table {
print_message("Import started.");
}

// Step 4: Poll if --wait
if wait {
if format == OutputFormat::Table {
print_message("\nWaiting for import to complete...");
}

loop {
thread::sleep(Duration::from_secs(poll_interval));

let status_response: Value = client.get(&format!(
"/api/v1/vector/sites/{}/imports/{}",
site_id, import_id
))?;

let status_data = &status_response["data"];
let status = status_data["status"].as_str().unwrap_or("unknown");

match status {
"completed" => {
if format == OutputFormat::Json {
print_json(&status_response);
} else {
let duration = format_option(
&status_data["duration_ms"].as_u64().map(|v| v.to_string()),
);
print_message(&format!("Status: completed (duration: {}ms)", duration));
}
return Ok(());
}
"failed" => {
if format == OutputFormat::Json {
print_json(&status_response);
return Ok(());
}
let error_msg =
format_option(&status_data["error_message"].as_str().map(String::from));
return Err(ApiError::Other(format!("Import failed: {}", error_msg)));
}
_ => {
if format == OutputFormat::Table {
print_message(&format!("Status: {}", status));
}
}
}
}
}

// Final output
if format == OutputFormat::Json {
print_json(&run_response);
} else {
print_key_value(vec![
("Import ID", import_id.to_string()),
(
"Status",
run_response["data"]["status"]
.as_str()
.unwrap_or("-")
.to_string(),
),
]);
print_message("\nCheck status with:");
print_message(&format!(
" vector db import-session status {} {}",
site_id, import_id
));
}

Ok(())
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod account;
pub mod archive;
pub mod auth;
pub mod backup;
pub mod db;
Expand Down
39 changes: 34 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ use std::process;
use api::{ApiClient, ApiError, EXIT_SUCCESS};
use cli::{
AccountApiKeyCommands, AccountCommands, AccountSecretCommands, AccountSshKeyCommands,
AuthCommands, BackupCommands, BackupDownloadCommands, Cli, Commands, DbCommands,
DbExportCommands, DbImportSessionCommands, DeployCommands, EnvCommands, EnvDbCommands,
EnvDomainChangeCommands, EnvSecretCommands, EventCommands, McpCommands, RestoreCommands,
SiteCommands, SiteSshKeyCommands, SslCommands, WafAllowedReferrerCommands,
ArchiveCommands, AuthCommands, BackupCommands, BackupDownloadCommands, Cli, Commands,
DbCommands, DbExportCommands, DbImportSessionCommands, DeployCommands, EnvCommands,
EnvDbCommands, EnvDomainChangeCommands, EnvSecretCommands, EventCommands, McpCommands,
RestoreCommands, SiteCommands, SiteSshKeyCommands, SslCommands, WafAllowedReferrerCommands,
WafBlockedIpCommands, WafBlockedReferrerCommands, WafCommands, WafRateLimitCommands,
WebhookCommands,
};
use commands::{
account, auth, backup, db, deploy, env, event, mcp, restore, site, ssl, waf, webhook,
account, archive, auth, backup, db, deploy, env, event, mcp, restore, site, ssl, waf, webhook,
};
use config::{Config, Credentials};
use output::{OutputFormat, print_error, print_json, print_message, print_table};
Expand Down Expand Up @@ -47,6 +47,7 @@ fn run(command: Commands, format: OutputFormat) -> Result<(), ApiError> {
Commands::Deploy { command } => run_deploy(command, format),
Commands::Ssl { command } => run_ssl(command, format),
Commands::Db { command } => run_db(command, format),
Commands::Archive { command } => run_archive(command, format),
Commands::Waf { command } => run_waf(command, format),
Commands::Account { command } => run_account(command, format),
Commands::Backup { command } => run_backup(command, format),
Expand Down Expand Up @@ -349,6 +350,34 @@ fn run_db_export(
}
}

fn run_archive(command: ArchiveCommands, format: OutputFormat) -> Result<(), ApiError> {
let client = get_client()?;

match command {
ArchiveCommands::Import {
site_id,
file,
drop_tables,
disable_foreign_keys,
search_replace_from,
search_replace_to,
wait,
poll_interval,
} => archive::import(
&client,
&site_id,
&file,
drop_tables,
disable_foreign_keys,
search_replace_from,
search_replace_to,
wait,
poll_interval,
format,
),
}
}

fn run_waf(command: WafCommands, format: OutputFormat) -> Result<(), ApiError> {
let client = get_client()?;

Expand Down
Loading