feat: Migrate to Apache opendal

This commit is contained in:
quexeky
2026-01-25 21:04:03 +11:00
parent 2518d9e023
commit 8c8e9ad4c9
20 changed files with 443 additions and 772 deletions
+136
View File
@@ -0,0 +1,136 @@
use crate::{commands::{connect::{
config_option::{ConfigOption, ConfigOptionCli},
configurable::Configure,
s3::S3Config,
}, upload::speedtest::Speedtest}, manifest::DepotManifest};
use dialoguer::{Confirm, theme::ColorfulTheme};
use futures::AsyncWriteExt;
use log::{debug, info, warn};
use opendal::Operator;
use serde::{Deserialize, Serialize};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use std::{collections::HashMap, fs, ops::Not};
const CONFIG_DIR: &str = "downpour/config.json";
#[derive(Serialize, Deserialize)]
pub struct Config {
configurations: HashMap<String, ConfigOption>,
active_s3: Option<String>,
}
impl Config {
pub fn new() -> Self {
Self {
configurations: HashMap::new(),
active_s3: None,
}
}
pub fn exists(&self, name: &String) -> bool {
self.configurations.contains_key(name)
}
pub fn save(&self) -> anyhow::Result<()> {
let json = serde_json::to_string(self)?;
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
fs::create_dir_all(save_path.parent().unwrap())?;
fs::write(save_path, json)?;
Ok(())
}
pub fn read() -> Self {
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
if fs::exists(&save_path).expect(&format!("Could not read save path {:#?}", &save_path)) {
serde_json::from_str(&fs::read_to_string(save_path).unwrap()).unwrap()
} else {
Config::new()
}
}
pub fn add_item(&mut self, name: String, object: ConfigOption) {
if matches!(object, ConfigOption::S3(..)) {
self.active_s3 = Some(name.clone())
}
self.configurations.insert(name, object);
self.save().expect("Failed to save config");
}
pub fn get_active_s3(&self) -> Option<S3Config> {
if let Some(active_s3) = &self.active_s3 {
self.configurations
.iter()
.filter_map(|(name, option)| {
if *name == *active_s3 {
match option {
ConfigOption::S3(s3_config) => Some(s3_config),
_ => {
warn!("Name {} is not of type 'S3'", name);
None
}
}
} else {
None
}
})
.next()
.cloned()
.map(|c| c.into())
} else {
None
}
}
pub fn get<T: AsRef<String>>(&self, name: T) -> Option<&ConfigOption> {
self.configurations.get(name.as_ref())
}
}
pub async fn manage_configuration(
config: &mut Config,
name: &String,
option: &ConfigOptionCli,
) -> anyhow::Result<()> {
if config.exists(&name) {
let confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"An entry already exists with the name \"{}\". Would you like to overwrite it?",
&name
))
.interact()?;
if !confirm {
return Err(anyhow::anyhow!("User cancelled action"));
}
}
let config_option = match option {
ConfigOptionCli::S3(s3_config_cli) => s3_config_cli.clone().configure().await?,
};
config.add_item(name.clone(), config_option.clone());
let operator = config_option.build()?;
generate_speedtest(&operator).await?;
generate_manifest(&operator).await?;
Ok(())
}
async fn generate_speedtest(operator: &Operator) -> anyhow::Result<()> {
if operator.exists("speedtest").await?.not() {
info!("Speedtest already exists on Depot. Skipping speedtest upload...");
return Ok(())
}
let mut writer = operator.writer("speedtest").await?.into_futures_async_write().compat_write();
let mut reader = Speedtest::new();
let written = tokio::io::copy(&mut reader, &mut writer).await?;
debug!("Wrote {} bytes to {:?}", written, operator.info());
writer.into_inner().close().await?;
Ok(())
}
async fn generate_manifest(operator: &Operator) -> anyhow::Result<()> {
info!("Manifest already exists on Depot. Skipping manifest upload...");
if operator.exists("manifest.json").await?.not() {
return Ok(())
}
let data = DepotManifest::new();
operator.write("manifest.json", serde_json::to_string(&data)?).await?;
Ok(())
}
+26
View File
@@ -0,0 +1,26 @@
use clap::Subcommand;
use opendal::{Operator, layers::LoggingLayer};
use serde::{Deserialize, Serialize};
use crate::commands::{
connect::s3::{S3Config, S3ConfigCli},
upload::uploadable::OperatorBuilder,
};
#[derive(Subcommand, Clone)]
pub enum ConfigOptionCli {
S3(S3ConfigCli),
}
#[derive(Serialize, Deserialize, Clone)]
pub enum ConfigOption {
S3(S3Config),
}
impl ConfigOption {
pub fn build(&self) -> anyhow::Result<Operator> {
Ok(match self {
ConfigOption::S3(s3_config) => s3_config.build()?,
}
.layer(LoggingLayer::default()))
}
}
+5
View File
@@ -0,0 +1,5 @@
use crate::commands::connect::config_option::ConfigOption;
pub trait Configure {
async fn configure(self) -> anyhow::Result<ConfigOption>;
}
+47
View File
@@ -0,0 +1,47 @@
use std::str::FromStr;
use dialoguer::{Input, theme::ColorfulTheme};
#[macro_export]
macro_rules! interactive_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
$var
} else {
crate::commands::connect::interactive::query_variable($prompt).unwrap()
};
};
}
#[macro_export]
macro_rules! interactive_optional_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
Some($var)
} else {
crate::commands::connect::interactive::query_optional_variable($prompt).unwrap()
};
};
}
pub fn query_variable<T: Clone + FromStr + ToString>(prompt: impl ToString) -> dialoguer::Result<T>
where
<T as FromStr>::Err: ToString,
{
Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.interact_text()
}
pub fn query_optional_variable<T: Clone + FromStr + ToString>(
prompt: impl ToString,
) -> dialoguer::Result<Option<T>>
where
<T as FromStr>::Err: ToString,
{
let input: T = Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.allow_empty(true)
.interact_text()?;
if input.to_string().len() == 0 {
return Ok(None);
}
Ok(Some(input))
}
+6
View File
@@ -0,0 +1,6 @@
pub mod config;
pub mod configurable;
pub mod s3;
#[macro_use]
pub mod interactive;
pub mod config_option;
+66
View File
@@ -0,0 +1,66 @@
use clap::Args;
use opendal::Operator;
use serde::{Deserialize, Serialize};
use crate::{
commands::{
connect::{config_option::ConfigOption, configurable::Configure},
upload::uploadable::OperatorBuilder,
},
interactive_variable,
};
#[derive(Args, Clone)]
pub struct S3ConfigCli {
key_id: Option<String>,
secret_key: Option<String>,
endpoint: Option<String>,
region: Option<String>,
bucket_name: Option<String>,
root: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Config {
key_id: String,
secret_key: String,
endpoint: String,
region: String,
bucket_name: String,
root: Option<String>,
}
impl Configure for S3ConfigCli {
async fn configure(self) -> anyhow::Result<ConfigOption> {
interactive_variable!(self, key_id, "S3 Key ID");
interactive_variable!(self, secret_key, "S3 Secret Key");
interactive_variable!(self, region, "S3 Region");
interactive_variable!(self, bucket_name, "S3 Bucket Name");
interactive_variable!(self, endpoint, "S3 Endpoint");
Ok(ConfigOption::S3(S3Config {
secret_key,
key_id,
region,
bucket_name,
endpoint,
root: self.root,
}))
}
}
impl OperatorBuilder for S3Config {
fn build(&self) -> anyhow::Result<Operator> {
let builder = opendal::services::S3::default()
.access_key_id(&self.key_id)
.secret_access_key(&self.secret_key)
.region(&self.region)
.endpoint(&self.endpoint)
.root(self.root.as_ref().map(|s| s.as_str()).unwrap_or("/"))
.bucket(&self.bucket_name)
.disable_config_load();
let op: Operator = Operator::new(builder)?.finish();
Ok(op)
}
}