Merge remote-tracking branch 'cli/main'
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
use flake
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
logs/
|
||||||
|
.vscode
|
||||||
|
.direnv
|
||||||
Generated
+3188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "downpour"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.100"
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
chrono = "0.4.43"
|
||||||
|
clap = { version = "4.5.54", features = ["derive"] }
|
||||||
|
console = "0.16.2"
|
||||||
|
dialoguer = "0.12.0"
|
||||||
|
dirs = "6.0.0"
|
||||||
|
droplet-rs = { path = "../droplet-rs", version = "0.14" }
|
||||||
|
fern = { version = "0.7.1", features = ["colored"] }
|
||||||
|
futures = "0.3.31"
|
||||||
|
indicatif = "0.18.3"
|
||||||
|
log = "0.4.29"
|
||||||
|
opendal = { version = "0.55.0", features = ["services-s3"] }
|
||||||
|
rand = "0.9.2"
|
||||||
|
reqwest = { version = "0.13.1", features = ["json"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.148"
|
||||||
|
tokio = { version = "1.48.0", features = ["fs", "macros"] }
|
||||||
|
tokio-util = { version = "0.7.18", features = ["compat"] }
|
||||||
|
url = "2.5.8"
|
||||||
|
webbrowser = "1.0.6"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# downpour
|
||||||
|
A cli tool to upload local files to Drop's depot infrastructure.
|
||||||
Generated
+96
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1768564909,
|
||||||
|
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1744536153,
|
||||||
|
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1768704795,
|
||||||
|
"narHash": "sha256-Y33TAp2BHEcuspYvcmBXXD0qdvjftv73PwyKTDOjoSY=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "4b7472a78857ac789fb26616040f55cfcbd36c6e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
description = "Drop-OSS app development environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
flake-utils,
|
||||||
|
rust-overlay,
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system overlays;
|
||||||
|
};
|
||||||
|
libraries = with pkgs; [
|
||||||
|
glib
|
||||||
|
glibc
|
||||||
|
openssl
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
git
|
||||||
|
rust-bin.nightly.latest.default
|
||||||
|
rust-analyzer
|
||||||
|
cargo-expand
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
buildInputs = libraries;
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export LD_LIBRARY_PATH="${
|
||||||
|
pkgs.lib.makeLibraryPath libraries
|
||||||
|
}:$LD_LIBRARY_PATH"
|
||||||
|
echo "Downpour development environment loaded"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Downpour CLI spec
|
||||||
|
`downpour [command] --opts`
|
||||||
|
## Commands:
|
||||||
|
- new <path/s3 name> <public endpoint> - creates/initalizes a depot at the endpoint. Creates manifest.json and speedtest
|
||||||
|
- connect <s3 endpoint> <key> <secret> [name] - connects to an s3 endpoint and saves the endpoint to some sort of credentials file. Name is either as provided or the hostname of the endpoint
|
||||||
|
- upload <game id> <localpath> <path/s3 name> - uploads game as described before. Should fail if depot isn't initialized with new from above
|
||||||
|
- copy <game id> <version id> <src path/s3 name> <dest path/s3 name> - copies between two depots
|
||||||
|
- mark [exists/absent] <game id> <version id> <path/s3 name> - modifies depot's manifest.json to show content exists or is absent without copying (for third party copies)
|
||||||
|
- rename <public endpoint> <new public endpoint> - renames an endpoint [NEEDS API ROUTES - can't do yet]
|
||||||
|
- delete <public endpoint> - delete an endpoint [NEEDS API ROUTES - can't do yet]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
|
|
||||||
|
use crate::{commands::connect::config_option::ConfigOptionCli, interactive_variable};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
|
||||||
|
/// Specify data file path
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Configures downpour endpoints
|
||||||
|
Connect {
|
||||||
|
#[arg(short, long)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
option: ConfigOptionCli,
|
||||||
|
},
|
||||||
|
/// Uploads new game version to depot
|
||||||
|
Upload {
|
||||||
|
#[clap(flatten)]
|
||||||
|
info: UploadInfoCli,
|
||||||
|
#[arg(short, long)]
|
||||||
|
/// Alias of a given connection
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UploadInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub game_id: String,
|
||||||
|
pub version_id: String,
|
||||||
|
}
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UploadInfoCli {
|
||||||
|
/// Relative path to new version files
|
||||||
|
#[arg(short, long, default_value_t = String::from("."))]
|
||||||
|
pub path: String,
|
||||||
|
/// ID of game to attach to
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub game_id: Option<String>,
|
||||||
|
/// Version ID to attach to
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub version_id: Option<String>,
|
||||||
|
}
|
||||||
|
impl UploadInfoCli {
|
||||||
|
pub fn interactive_configure(self) -> UploadInfo {
|
||||||
|
let path = self.path;
|
||||||
|
interactive_variable!(self, game_id, "Game ID");
|
||||||
|
interactive_variable!(self, version_id, "Version ID");
|
||||||
|
UploadInfo {
|
||||||
|
path,
|
||||||
|
game_id,
|
||||||
|
version_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum UploadStyle {
|
||||||
|
S3,
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use crate::{
|
||||||
|
commands::connect::{
|
||||||
|
config_option::{ConfigOption, ConfigOptionCli},
|
||||||
|
configurable::Configure,
|
||||||
|
speedtest::{SPEEDTEST_PATH, Speedtest},
|
||||||
|
},
|
||||||
|
manifest::DepotManifest,
|
||||||
|
};
|
||||||
|
use dialoguer::{Confirm, theme::ColorfulTheme};
|
||||||
|
use futures::AsyncWriteExt;
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use log::{debug, info};
|
||||||
|
use opendal::Operator;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{collections::HashMap, fs, ops::Not};
|
||||||
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||||
|
|
||||||
|
const CONFIG_DIR: &str = "downpour/config.json";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
configurations: HashMap<String, ConfigOption>,
|
||||||
|
active: Option<String>,
|
||||||
|
}
|
||||||
|
impl Config {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
configurations: HashMap::new(),
|
||||||
|
active: 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)
|
||||||
|
.unwrap_or_else(|_| panic!("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 = Some(name.clone())
|
||||||
|
}
|
||||||
|
self.configurations.insert(name, object);
|
||||||
|
self.save().expect("Failed to save config");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_active(&self) -> Option<&ConfigOption> {
|
||||||
|
if let Some(active) = &self.active {
|
||||||
|
self.configurations.get(active)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn get<T: AsRef<str>>(&self, name: T) -> Option<&ConfigOption> {
|
||||||
|
self.configurations.get(name.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn manage_configuration(
|
||||||
|
config: &mut Config,
|
||||||
|
name: Option<String>,
|
||||||
|
option: ConfigOptionCli,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut name = name;
|
||||||
|
if let Some(name) = &name
|
||||||
|
&& 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(&mut name).await?,
|
||||||
|
};
|
||||||
|
let name = name.expect("Default name was not provided by ConfigOption. This is a bug");
|
||||||
|
config.add_item(name, config_option.clone());
|
||||||
|
let operator = config_option.build()?;
|
||||||
|
|
||||||
|
generate_manifest(&operator).await?;
|
||||||
|
info!("Finished uploading manifest");
|
||||||
|
generate_speedtest(&operator).await?;
|
||||||
|
info!("Finished uploading speedtest");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_speedtest(operator: &Operator) -> anyhow::Result<()> {
|
||||||
|
// Workaround to operator.exists("...") also logging a 404 warning
|
||||||
|
let lister = operator.list_with(SPEEDTEST_PATH).limit(1).await?;
|
||||||
|
if lister.is_empty().not() {
|
||||||
|
info!("Speedtest already exists on Depot. Skipping speedtest upload...");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut writer = operator
|
||||||
|
.writer(SPEEDTEST_PATH)
|
||||||
|
.await?
|
||||||
|
.into_futures_async_write()
|
||||||
|
.compat_write();
|
||||||
|
|
||||||
|
let progress_bar = ProgressBar::new(10_000).with_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("[{elapsed_precise}] [ETA {eta}] {bar} {percent_precise}%")
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut reader = Speedtest::new(|progress| {
|
||||||
|
let progress_int = (progress * 100f32).round() as u64;
|
||||||
|
progress_bar.set_position(progress_int);
|
||||||
|
});
|
||||||
|
let written = tokio::io::copy(&mut reader, &mut writer).await?;
|
||||||
|
progress_bar.finish();
|
||||||
|
debug!("Wrote {} bytes to {:?}", written, operator.info());
|
||||||
|
writer.into_inner().close().await?;
|
||||||
|
debug!("Closed writer");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_manifest(operator: &Operator) -> anyhow::Result<()> {
|
||||||
|
let lister = operator.list_with("manifest.json").limit(1).await?;
|
||||||
|
if lister.is_empty().not() {
|
||||||
|
info!("Manifest already exists on Depot. Skipping manifest upload...");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let data = DepotManifest::new();
|
||||||
|
operator
|
||||||
|
.write("manifest.json", serde_json::to_string(&data)?)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use clap::Subcommand;
|
||||||
|
use opendal::{Operator, layers::LoggingLayer};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
commands::connect::s3::{S3Config, S3ConfigCli},
|
||||||
|
operator_builder::OperatorBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Subcommand, Clone)]
|
||||||
|
pub enum ConfigOptionCli {
|
||||||
|
// Connect to any S3-compatible endpoint
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use crate::commands::connect::config_option::ConfigOption;
|
||||||
|
|
||||||
|
pub trait Configure {
|
||||||
|
async fn configure(self, name: &mut Option<String>) -> anyhow::Result<ConfigOption>;
|
||||||
|
}
|
||||||
@@ -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().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(input))
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod configurable;
|
||||||
|
pub mod s3;
|
||||||
|
#[macro_use]
|
||||||
|
pub mod interactive;
|
||||||
|
pub mod config_option;
|
||||||
|
pub mod speedtest;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use clap::Args;
|
||||||
|
use opendal::Operator;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
commands::connect::{config_option::ConfigOption, configurable::Configure},
|
||||||
|
interactive_variable,
|
||||||
|
operator_builder::OperatorBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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, name: &mut Option<String>) -> 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");
|
||||||
|
if let None = name {
|
||||||
|
*name = Some(endpoint.clone());
|
||||||
|
}
|
||||||
|
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_deref().unwrap_or("/"))
|
||||||
|
.bucket(&self.bucket_name)
|
||||||
|
.disable_config_load();
|
||||||
|
|
||||||
|
let op: Operator = Operator::new(builder)?.finish();
|
||||||
|
|
||||||
|
Ok(op)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use rand::{RngCore, SeedableRng, rng, rngs::StdRng};
|
||||||
|
use tokio::io::AsyncRead;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Speedtest<F: Fn(f32)> {
|
||||||
|
core: rand::rngs::StdRng,
|
||||||
|
to_write: usize,
|
||||||
|
callback: Box<F>,
|
||||||
|
}
|
||||||
|
pub const SPEEDTEST_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
pub const SPEEDTEST_PATH: &str = "speedtest";
|
||||||
|
|
||||||
|
impl<F: Fn(f32)> AsyncRead for Speedtest<F> {
|
||||||
|
fn poll_read(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
_cx: &mut std::task::Context<'_>,
|
||||||
|
buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
let mut s = self;
|
||||||
|
let to_write = buf.remaining().min(s.to_write);
|
||||||
|
|
||||||
|
let filled = {
|
||||||
|
let fill_slice = buf.initialize_unfilled_to(to_write);
|
||||||
|
s.core.fill_bytes(fill_slice);
|
||||||
|
fill_slice.len()
|
||||||
|
};
|
||||||
|
s.to_write = s.to_write.saturating_sub(filled);
|
||||||
|
(s.callback)((1f32 - (s.to_write as f32 / SPEEDTEST_BYTES as f32)) * 100f32);
|
||||||
|
buf.advance(filled);
|
||||||
|
std::task::Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<F: Fn(f32)> Speedtest<F> {
|
||||||
|
pub fn new(callback: F) -> Self {
|
||||||
|
Self {
|
||||||
|
core: StdRng::from_rng(&mut rng()),
|
||||||
|
to_write: SPEEDTEST_BYTES,
|
||||||
|
callback: Box::new(callback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod connect;
|
||||||
|
pub mod upload;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cli::UploadInfo,
|
||||||
|
commands::connect::{config::Config, config_option::ConfigOption},
|
||||||
|
manifest::{CompressionOption, DepotManifest, generate_v2_manifest},
|
||||||
|
operator_builder::OperatorBuilder,
|
||||||
|
};
|
||||||
|
use futures::AsyncWriteExt;
|
||||||
|
use log::info;
|
||||||
|
use opendal::{FuturesAsyncWriter, Operator};
|
||||||
|
use tokio_util::compat::{Compat, FuturesAsyncWriteCompatExt};
|
||||||
|
|
||||||
|
pub async fn upload(
|
||||||
|
info: &UploadInfo,
|
||||||
|
config: Config,
|
||||||
|
name: &Option<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let game_id = &info.game_id;
|
||||||
|
let path = &info.path;
|
||||||
|
let version_id = &info.version_id;
|
||||||
|
|
||||||
|
let operator = get_operator(config, name)?;
|
||||||
|
|
||||||
|
let mut existing_depot_manifest = get_depot_manifest(&operator).await?;
|
||||||
|
|
||||||
|
info!("Uploading chunks");
|
||||||
|
|
||||||
|
let v2_manifest = generate_v2_manifest(
|
||||||
|
Path::new(path),
|
||||||
|
async |id: String| {
|
||||||
|
info!("Uploading chunk id {id}");
|
||||||
|
let writer = operator
|
||||||
|
.writer(&format!("{game_id}/{version_id}/{id}"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_futures_async_write()
|
||||||
|
.compat_write();
|
||||||
|
writer
|
||||||
|
},
|
||||||
|
|writer: Compat<FuturesAsyncWriter>| async {
|
||||||
|
writer.into_inner().close().await.unwrap();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("Finished uploading chunks");
|
||||||
|
|
||||||
|
existing_depot_manifest.append(
|
||||||
|
game_id.to_string(),
|
||||||
|
version_id.to_string(),
|
||||||
|
CompressionOption::None,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_depot_manifest(operator: &Operator) -> Result<DepotManifest, anyhow::Error> {
|
||||||
|
let existing_depot_manifest = operator.read("manifest.json").await?.to_bytes();
|
||||||
|
let existing_depot_manifest: DepotManifest =
|
||||||
|
serde_json::from_slice(existing_depot_manifest.as_ref())?;
|
||||||
|
Ok(existing_depot_manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_operator(config: Config, name: &Option<String>) -> anyhow::Result<Operator> {
|
||||||
|
let operator = match if let Some(name) = name {
|
||||||
|
config
|
||||||
|
.get(name)
|
||||||
|
.ok_or(anyhow::anyhow!("Name does not exist"))?
|
||||||
|
} else {
|
||||||
|
config.get_active().ok_or(anyhow::anyhow!(
|
||||||
|
"No active connection set. Please specify with --name"
|
||||||
|
))?
|
||||||
|
} {
|
||||||
|
ConfigOption::S3(s3_config) => s3_config.build()?,
|
||||||
|
};
|
||||||
|
Ok(operator)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod interface;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use fern::colors::{Color, ColoredLevelConfig};
|
||||||
|
use log::LevelFilter;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub fn configure_logging() -> anyhow::Result<()> {
|
||||||
|
let log_level = env::var("RUST_LOG")
|
||||||
|
.unwrap_or_else(|_| "info".to_string())
|
||||||
|
.parse::<LevelFilter>()?;
|
||||||
|
|
||||||
|
let log_dir = env::var("LOG_FILE_DIR").unwrap_or_else(|_| "logs".to_string());
|
||||||
|
|
||||||
|
fs::create_dir_all(&log_dir)?;
|
||||||
|
|
||||||
|
let colors = ColoredLevelConfig::new()
|
||||||
|
.error(Color::Red)
|
||||||
|
.warn(Color::Yellow)
|
||||||
|
.info(Color::Blue)
|
||||||
|
.debug(Color::Green)
|
||||||
|
.trace(Color::Magenta);
|
||||||
|
|
||||||
|
fern::Dispatch::new()
|
||||||
|
.chain(
|
||||||
|
fern::Dispatch::new()
|
||||||
|
.format(move |out, message, record| {
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{}] {}: {}",
|
||||||
|
chrono::Local::now().format("%H:%M:%S%.3f"),
|
||||||
|
colors.color(record.level()),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.chain(io::stdout()),
|
||||||
|
)
|
||||||
|
.chain(
|
||||||
|
fern::Dispatch::new()
|
||||||
|
.format(|out, message, record| {
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{}] {} {} - {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"),
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.chain(fern::log_file(format!("{}/app.log", log_dir))?),
|
||||||
|
)
|
||||||
|
.level(log_level)
|
||||||
|
.apply()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use crate::commands::connect::config::manage_configuration;
|
||||||
|
use crate::{
|
||||||
|
cli::{Cli, Commands},
|
||||||
|
commands::connect::config::Config,
|
||||||
|
commands::upload,
|
||||||
|
};
|
||||||
|
use clap::Parser;
|
||||||
|
mod cli;
|
||||||
|
mod commands;
|
||||||
|
mod logging;
|
||||||
|
mod manifest;
|
||||||
|
mod operator_builder;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
crate::logging::configure_logging()?;
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let mut config = Config::read();
|
||||||
|
match cli.command {
|
||||||
|
Commands::Connect { name, option } => {
|
||||||
|
manage_configuration(&mut config, name, option).await?
|
||||||
|
}
|
||||||
|
Commands::Upload { info, name } => {
|
||||||
|
let info = info.interactive_configure();
|
||||||
|
upload::interface::upload(&info, config, &name).await?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use std::{collections::HashMap, path::Path};
|
||||||
|
|
||||||
|
use droplet_rs::manifest::{
|
||||||
|
Manifest, generate_manifest_rusty, generate_manifest_rusty_v2,
|
||||||
|
};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use log::info;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::AsyncWrite;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct DepotManifest {
|
||||||
|
content: HashMap<String, DepotManifestGameData>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct DepotManifestGameData {
|
||||||
|
version_id: String,
|
||||||
|
compression: CompressionOption,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub enum CompressionOption {
|
||||||
|
None,
|
||||||
|
Gzip,
|
||||||
|
Zstd,
|
||||||
|
}
|
||||||
|
impl DepotManifest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
content: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn append(&mut self, game_id: String, version_id: String, compression: CompressionOption) {
|
||||||
|
self.content.insert(
|
||||||
|
game_id,
|
||||||
|
DepotManifestGameData {
|
||||||
|
version_id,
|
||||||
|
compression,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_v2_manifest<W, F, CloseF>(dir: &Path, factory: F, closer: CloseF) -> anyhow::Result<Manifest>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
F: AsyncFn(String) -> W,
|
||||||
|
CloseF: AsyncFn(W)
|
||||||
|
{
|
||||||
|
let progress_bar = ProgressBar::new(10_000).with_style(
|
||||||
|
ProgressStyle::default_bar()
|
||||||
|
.template("[{elapsed_precise}] [ETA {eta}] {bar} {percent_precise}%")
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
generate_manifest_rusty_v2(
|
||||||
|
dir,
|
||||||
|
|progress| {
|
||||||
|
let progress_int = (progress * 100f32).round() as u64;
|
||||||
|
progress_bar.set_position(progress_int);
|
||||||
|
},
|
||||||
|
|log| progress_bar.suspend(|| info!("{}", log)),
|
||||||
|
factory,
|
||||||
|
closer
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use opendal::Operator;
|
||||||
|
|
||||||
|
pub trait OperatorBuilder {
|
||||||
|
fn build(&self) -> anyhow::Result<Operator>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user