From 2a37f090a50d16fe9461736a23d4c0f3a627a7ce Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Fri, 27 Nov 2020 19:47:50 -0500 Subject: [PATCH] Add RemoteServer to the taskchampion crate --- Cargo.lock | 161 +++++++++++++++++++++++ cli/src/cmd/shared.rs | 7 +- sync-server/src/api/add_version.rs | 21 +-- sync-server/src/api/get_child_version.rs | 6 +- sync-server/src/server.rs | 13 +- sync-server/src/storage/inmemory.rs | 19 ++- sync-server/src/storage/mod.rs | 5 +- taskchampion/Cargo.toml | 1 + taskchampion/src/server/mod.rs | 2 + taskchampion/src/server/remote.rs | 116 ++++++++++++++++ 10 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 taskchampion/src/server/remote.rs diff --git a/Cargo.lock b/Cargo.lock index 877cd2b16..ac239c40b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,6 +541,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "chunked_transfer" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7477065d45a8fe57167bf3cf8bcd3729b54cfcb81cca49bda2d038ea89ae82ca" + [[package]] name = "clap" version = "2.33.3" @@ -597,6 +603,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3818dfca4b0cb5211a659bbcbb94225b7127407b2b135e650d717bfb78ab10d3" +dependencies = [ + "cookie", + "idna", + "log", + "publicsuffix", + "serde", + "serde_json", + "time 0.2.23", + "url", +] + [[package]] name = "copyless" version = "0.1.5" @@ -733,6 +755,15 @@ dependencies = [ "syn", ] +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + [[package]] name = "failure" version = "0.1.8" @@ -1074,6 +1105,15 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" +[[package]] +name = "js-sys" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca059e81d9486668f12d455a4ea6daa600bd408134cd17e3d3fb5a32d1f016f8" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -1497,6 +1537,28 @@ dependencies = [ "tempfile", ] +[[package]] +name = "publicsuffix" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bbaa49075179162b49acac1c6aa45fb4dafb5f13cf6794276d77bc7fd95757b" +dependencies = [ + "error-chain", + "idna", + "lazy_static", + "regex", + "url", +] + +[[package]] +name = "qstring" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -1744,6 +1806,21 @@ dependencies = [ "quick-error", ] +[[package]] +name = "ring" +version = "0.16.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70017ed5c555d79ee3538fc63ca09c70ad8f317dcadc1adc2c496b60c22bb24f" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi 0.3.9", +] + [[package]] name = "rmp" version = "0.8.9" @@ -1792,6 +1869,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1126dcf58e93cee7d098dbda643b5f92ed724f1f6a63007c1116eed6700c81" +dependencies = [ + "base64 0.12.3", + "log", + "ring", + "sct", + "webpki", +] + [[package]] name = "rusty-fork" version = "0.2.2" @@ -1816,6 +1906,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sct" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3042af939fca8c3453b7af0f1c66e533a15a86169e39de2657310ade8f98d3c" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "semver" version = "0.9.0" @@ -1926,6 +2026,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "standback" version = "0.2.13" @@ -2025,6 +2131,7 @@ dependencies = [ "serde", "serde_json", "tempdir", + "ureq", "uuid", ] @@ -2359,6 +2466,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "ureq" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a599426c7388ab189dfd0eeb84c8d879490abc73e3e62a0b6a40e286f6427ab7" +dependencies = [ + "base64 0.13.0", + "chunked_transfer", + "cookie", + "cookie_store", + "log", + "once_cell", + "qstring", + "rustls", + "url", + "webpki", + "webpki-roots", +] + [[package]] name = "url" version = "2.2.0" @@ -2468,6 +2600,35 @@ version = "0.2.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d649a3145108d7d3fbcde896a468d1bd636791823c9921135218ad89be08307" +[[package]] +name = "web-sys" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf6ef87ad7ae8008e15a355ce696bed26012b7caa21605188cfd8214ab51e2d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab146130f5f790d45f82aeeb09e55a256573373ec64409fc19a6fb82fb1032ae" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f20dea7535251981a9670857150d571846545088359b28e4951d350bdaf179f" +dependencies = [ + "webpki", +] + [[package]] name = "widestring" version = "0.4.3" diff --git a/cli/src/cmd/shared.rs b/cli/src/cmd/shared.rs index 25940bb28..6ae2ff4a3 100644 --- a/cli/src/cmd/shared.rs +++ b/cli/src/cmd/shared.rs @@ -52,8 +52,9 @@ impl CommandInvocation { } pub(super) fn get_server(&self) -> Fallible { - Ok(server::LocalServer::new(Path::new( - "/tmp/task-sync-server", - ))?) + Ok(server::RemoteServer::new( + "http://localhost:8080".into(), + Uuid::parse_str("d5b55cbd-9a82-4860-9a39-41b67893b22f").unwrap(), + )) } } diff --git a/sync-server/src/api/add_version.rs b/sync-server/src/api/add_version.rs index 770ce2abc..d15c7d941 100644 --- a/sync-server/src/api/add_version.rs +++ b/sync-server/src/api/add_version.rs @@ -2,7 +2,7 @@ use crate::api::{ failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, PARENT_VERSION_ID_HEADER, VERSION_ID_HEADER, }; -use crate::server::{add_version, AddVersionResult, ClientId, VersionId}; +use crate::server::{add_version, AddVersionResult, ClientId, VersionId, NO_VERSION_ID}; use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; use futures::StreamExt; @@ -51,10 +51,15 @@ pub(crate) async fn service( // in transit. let mut txn = server_state.txn().map_err(failure_to_ise)?; - let client = txn - .get_client(client_id) - .map_err(failure_to_ise)? - .ok_or_else(|| error::ErrorNotFound("no such client"))?; + // get, or create, the client + let client = match txn.get_client(client_id).map_err(failure_to_ise)? { + Some(client) => client, + None => { + txn.new_client(client_id, NO_VERSION_ID) + .map_err(failure_to_ise)?; + txn.get_client(client_id).map_err(failure_to_ise)?.unwrap() + } + }; let result = add_version(txn, client_id, client, parent_version_id, body.to_vec()) .map_err(failure_to_ise)?; @@ -86,8 +91,7 @@ mod test { // set up the storage contents.. { let mut txn = server_box.txn().unwrap(); - txn.set_client_latest_version_id(client_id, Uuid::nil()) - .unwrap(); + txn.new_client(client_id, Uuid::nil()).unwrap(); } let server_state = ServerState::new(server_box); @@ -123,8 +127,7 @@ mod test { // set up the storage contents.. { let mut txn = server_box.txn().unwrap(); - txn.set_client_latest_version_id(client_id, version_id) - .unwrap(); + txn.new_client(client_id, version_id).unwrap(); } let server_state = ServerState::new(server_box); diff --git a/sync-server/src/api/get_child_version.rs b/sync-server/src/api/get_child_version.rs index 83f9e2224..ed6dff3bd 100644 --- a/sync-server/src/api/get_child_version.rs +++ b/sync-server/src/api/get_child_version.rs @@ -57,8 +57,7 @@ mod test { // set up the storage contents.. { let mut txn = server_box.txn().unwrap(); - txn.set_client_latest_version_id(client_id, Uuid::new_v4()) - .unwrap(); + txn.new_client(client_id, Uuid::new_v4()).unwrap(); txn.add_version(client_id, version_id, parent_version_id, b"abcd".to_vec()) .unwrap(); } @@ -119,8 +118,7 @@ mod test { // create the client, but not the version { let mut txn = server_box.txn().unwrap(); - txn.set_client_latest_version_id(client_id, Uuid::new_v4()) - .unwrap(); + txn.new_client(client_id, Uuid::new_v4()).unwrap(); } let server_state = ServerState::new(server_box); let mut app = test::init_service(App::new().service(app_scope(server_state))).await; diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index 0a404172d..464bff4e1 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -149,14 +149,15 @@ mod test { let client_id = Uuid::new_v4(); let parent_version_id = Uuid::new_v4(); let history_segment = b"abcd".to_vec(); - let client = Client { - latest_version_id: if latest_version_id_nil { - Uuid::nil() - } else { - parent_version_id - }, + let latest_version_id = if latest_version_id_nil { + Uuid::nil() + } else { + parent_version_id }; + txn.new_client(client_id, latest_version_id)?; + let client = txn.get_client(client_id)?.unwrap(); + let result = add_version( txn, client_id, diff --git a/sync-server/src/storage/inmemory.rs b/sync-server/src/storage/inmemory.rs index 91c868d42..cd8943d1e 100644 --- a/sync-server/src/storage/inmemory.rs +++ b/sync-server/src/storage/inmemory.rs @@ -1,5 +1,5 @@ use super::{Client, Storage, StorageTxn, Uuid, Version}; -use failure::Fallible; +use failure::{format_err, Fallible}; use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; @@ -38,19 +38,26 @@ impl<'a> StorageTxn for InnerTxn<'a> { Ok(self.0.clients.get(&client_id).cloned()) } + fn new_client(&mut self, client_id: Uuid, latest_version_id: Uuid) -> Fallible<()> { + if let Some(_) = self.0.clients.get(&client_id) { + return Err(format_err!("Client {} already exists", client_id)); + } + self.0 + .clients + .insert(client_id, Client { latest_version_id }); + Ok(()) + } + fn set_client_latest_version_id( &mut self, client_id: Uuid, latest_version_id: Uuid, ) -> Fallible<()> { if let Some(client) = self.0.clients.get_mut(&client_id) { - client.latest_version_id = latest_version_id; + Ok(client.latest_version_id = latest_version_id) } else { - self.0 - .clients - .insert(client_id, Client { latest_version_id }); + Err(format_err!("Client {} does not exist", client_id)) } - Ok(()) } fn get_version_by_parent( diff --git a/sync-server/src/storage/mod.rs b/sync-server/src/storage/mod.rs index 2915f3c4d..022b7d512 100644 --- a/sync-server/src/storage/mod.rs +++ b/sync-server/src/storage/mod.rs @@ -20,7 +20,10 @@ pub(crate) trait StorageTxn { /// Get information about the given client fn get_client(&mut self, client_id: Uuid) -> Fallible>; - /// Set the client's latest_version_id (creating the client if necessary) + /// Create a new client with the given latest_version_id + fn new_client(&mut self, client_id: Uuid, latest_version_id: Uuid) -> Fallible<()>; + + /// Set the client's latest_version_id fn set_client_latest_version_id( &mut self, client_id: Uuid, diff --git a/taskchampion/Cargo.toml b/taskchampion/Cargo.toml index 1f6681454..3ca9135c9 100644 --- a/taskchampion/Cargo.toml +++ b/taskchampion/Cargo.toml @@ -12,6 +12,7 @@ chrono = { version = "0.4.10", features = ["serde"] } failure = {version = "0.1.5", features = ["derive"] } kv = {version = "0.10.0", features = ["msgpack-value"]} lmdb-rkv = {version = "0.12.3"} +ureq = "1.5.2" [dev-dependencies] proptest = "0.9.4" diff --git a/taskchampion/src/server/mod.rs b/taskchampion/src/server/mod.rs index 26e021957..78c49c6f6 100644 --- a/taskchampion/src/server/mod.rs +++ b/taskchampion/src/server/mod.rs @@ -2,7 +2,9 @@ pub(crate) mod test; mod local; +mod remote; mod types; pub use local::LocalServer; +pub use remote::RemoteServer; pub use types::*; diff --git a/taskchampion/src/server/remote.rs b/taskchampion/src/server/remote.rs new file mode 100644 index 000000000..3f429ae06 --- /dev/null +++ b/taskchampion/src/server/remote.rs @@ -0,0 +1,116 @@ +use crate::server::{AddVersionResult, GetVersionResult, HistorySegment, Server, VersionId}; +use failure::{format_err, Fallible}; +use std::io::Read; +use ureq; +use uuid::Uuid; + +pub struct RemoteServer { + origin: String, + client_id: Uuid, + agent: ureq::Agent, +} + +/// A RemoeServer communicates with a remote server over HTTP (such as with +/// taskchampion-sync-server). +impl RemoteServer { + /// Construct a new RemoteServer. The `origin` is the sync server's protocol and hostname + /// without a trailing slash, such as `https://tcsync.example.com`. Pass a client_id to + /// identify this client to the server. Multiple replicas synchronizing the same task history + /// should use the same client_id. + pub fn new(origin: String, client_id: Uuid) -> RemoteServer { + RemoteServer { + origin, + client_id, + agent: ureq::agent(), + } + } +} + +/// Convert a ureq::Response to an Error +fn resp_to_error(resp: ureq::Response) -> failure::Error { + return format_err!( + "error {}: {}", + resp.status(), + resp.into_string() + .unwrap_or_else(|e| format!("(could not read response: {})", e)) + ); +} + +/// Read a UUID-bearing header or fail trying +fn get_uuid_header(resp: &ureq::Response, name: &str) -> Fallible { + let value = resp + .header(name) + .ok_or_else(|| format_err!("Response does not have {} header", name))?; + let value = Uuid::parse_str(value) + .map_err(|e| format_err!("{} header is not a valid UUID: {}", name, e))?; + Ok(value) +} + +/// Get the body of a request as a HistorySegment +fn into_body(resp: ureq::Response) -> Fallible { + if let Some("application/vnd.taskchampion.history-segment") = resp.header("Content-Type") { + let mut reader = resp.into_reader(); + let mut bytes = vec![]; + reader.read_to_end(&mut bytes)?; + Ok(bytes) + } else { + Err(format_err!("Response did not have expected content-type")) + } +} + +impl Server for RemoteServer { + fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> Fallible { + let url = format!( + "{}/client/{}/add-version/{}", + self.origin, self.client_id, parent_version_id + ); + let resp = self + .agent + .post(&url) + .timeout_connect(10_000) + .timeout_read(60_000) + .set( + "Content-Type", + "application/vnd.taskchampion.history-segment", + ) + .send_bytes(&history_segment); + if resp.ok() { + let version_id = get_uuid_header(&resp, "X-Version-Id")?; + Ok(AddVersionResult::Ok(version_id)) + } else if resp.status() == 409 { + let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?; + Ok(AddVersionResult::ExpectedParentVersion(parent_version_id)) + } else { + Err(resp_to_error(resp)) + } + } + + fn get_child_version(&mut self, parent_version_id: VersionId) -> Fallible { + let url = format!( + "{}/client/{}/get-child-version/{}", + self.origin, self.client_id, parent_version_id + ); + let resp = self + .agent + .get(&url) + .timeout_connect(10_000) + .timeout_read(60_000) + .call(); + + if resp.ok() { + Ok(GetVersionResult::Version { + version_id: get_uuid_header(&resp, "X-Version-Id")?, + parent_version_id: get_uuid_header(&resp, "X-Parent-Version-Id")?, + history_segment: into_body(resp)?, + }) + } else if resp.status() == 404 { + Ok(GetVersionResult::NoSuchVersion) + } else { + Err(resp_to_error(resp)) + } + } +}