diff --git a/Cargo.lock b/Cargo.lock index 376e8f798..b0988f0e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,6 +752,33 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half 2.4.1", +] + [[package]] name = "cipher" version = "0.3.0" @@ -811,15 +838,6 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" -[[package]] -name = "cmake" -version = "0.1.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" -dependencies = [ - "cc", -] - [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1136,50 +1154,7 @@ checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" [[package]] name = "dfx-core" version = "0.0.1" -source = "git+https://github.com/dfinity/sdk.git?tag=0.22.0#d0c8be188d1a19c1908f148e3bc5331b62de780a" -dependencies = [ - "aes-gcm", - "argon2", - "backoff", - "bip32", - "byte-unit", - "bytes", - "candid", - "clap", - "dialoguer", - "directories-next", - "dunce", - "flate2", - "handlebars", - "hex", - "humantime-serde", - "ic-agent", - "ic-identity-hsm", - "ic-utils", - "k256 0.11.6", - "keyring", - "lazy_static", - "reqwest", - "ring 0.16.20", - "schemars", - "sec1 0.3.0", - "semver", - "serde", - "serde_json", - "sha2 0.10.8", - "slog", - "tar", - "tempfile", - "thiserror", - "time", - "tiny-bip39", - "url", -] - -[[package]] -name = "dfx-core" -version = "0.0.1" -source = "git+https://github.com/dfinity/sdk.git?rev=75c080ebae22a70578c06ddf1eda0b18ef091845#75c080ebae22a70578c06ddf1eda0b18ef091845" +source = "git+https://github.com/dfinity/sdk.git?rev=bb5f8b58afa94b1950f5e1a750e0491457ad88d1#bb5f8b58afa94b1950f5e1a750e0491457ad88d1" dependencies = [ "aes-gcm", "argon2", @@ -1227,9 +1202,10 @@ dependencies = [ "candid", "candid_parser", "cap-std", + "ciborium", "clap", "dateparser", - "dfx-core 0.0.1 (git+https://github.com/dfinity/sdk.git?tag=0.22.0)", + "dfx-core", "hex", "ic-agent", "ic-asset", @@ -1577,7 +1553,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" dependencies = [ "crc32fast", - "libz-ng-sys", "miniz_oxide 0.8.0", ] @@ -1838,6 +1813,16 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "handlebars" version = "4.5.0" @@ -2116,13 +2101,13 @@ dependencies = [ [[package]] name = "ic-asset" version = "0.20.0" -source = "git+https://github.com/dfinity/sdk.git?rev=75c080ebae22a70578c06ddf1eda0b18ef091845#75c080ebae22a70578c06ddf1eda0b18ef091845" +source = "git+https://github.com/dfinity/sdk.git?rev=bb5f8b58afa94b1950f5e1a750e0491457ad88d1#bb5f8b58afa94b1950f5e1a750e0491457ad88d1" dependencies = [ "backoff", "brotli", "candid", "derivative", - "dfx-core 0.0.1 (git+https://github.com/dfinity/sdk.git?rev=75c080ebae22a70578c06ddf1eda0b18ef091845)", + "dfx-core", "flate2", "futures", "futures-intrusive", @@ -2311,7 +2296,7 @@ dependencies = [ [[package]] name = "ic-certified-assets" version = "0.2.5" -source = "git+https://github.com/dfinity/sdk.git?rev=75c080ebae22a70578c06ddf1eda0b18ef091845#75c080ebae22a70578c06ddf1eda0b18ef091845" +source = "git+https://github.com/dfinity/sdk.git?rev=bb5f8b58afa94b1950f5e1a750e0491457ad88d1#bb5f8b58afa94b1950f5e1a750e0491457ad88d1" dependencies = [ "base64 0.13.1", "candid", @@ -2568,6 +2553,7 @@ dependencies = [ "flate2", "hex", "ic-cdk 0.13.5", + "ic-certified-assets", "ic-ledger-types", "itertools 0.13.0", "lazy_static", @@ -2811,16 +2797,6 @@ dependencies = [ "redox_syscall 0.5.3", ] -[[package]] -name = "libz-ng-sys" -version = "1.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4436751a01da56f1277f323c80d584ffad94a3d14aecd959dd0dff75aa73a438" -dependencies = [ - "cmake", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -4296,7 +4272,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" dependencies = [ - "half", + "half 1.8.3", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index b1b928fb8..6c2659956 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,19 +37,19 @@ candid = "0.10.3" candid_parser = "0.1.3" canfund = "0.2.0" cap-std = "3.1.0" +ciborium = "0.2.2" clap = { version = "4.5.7", features = ["derive"] } dateparser = "0.2" -dfx-core = { git = "https://github.com/dfinity/sdk.git", tag = "0.22.0" } +dfx-core = { git = "https://github.com/dfinity/sdk.git", rev = "bb5f8b58afa94b1950f5e1a750e0491457ad88d1" } flate2 = "1.0" convert_case = "0.6" futures = "0.3" getrandom = { version = "0.2", features = ["custom"] } hex = "0.4" -# The ic-agent matches the one sed by bthe ic-agent = { git = "https://github.com/dfinity/agent-rs.git", rev = "be929fd7967249c879f48f2f494cbfc5805a7d98" } -ic-asset = { git = "https://github.com/dfinity/sdk.git", rev = "75c080ebae22a70578c06ddf1eda0b18ef091845" } +ic-asset = { git = "https://github.com/dfinity/sdk.git", rev = "bb5f8b58afa94b1950f5e1a750e0491457ad88d1" } ic-certification = { git = "https://github.com/dfinity/response-verification", rev = "da70db93832f88ecc556ae082612aedec47d3816" } -ic-certified-assets = { git = "https://github.com/dfinity/sdk.git", rev = "75c080ebae22a70578c06ddf1eda0b18ef091845" } +ic-certified-assets = { git = "https://github.com/dfinity/sdk.git", rev = "bb5f8b58afa94b1950f5e1a750e0491457ad88d1" } ic-http-certification = { git = "https://github.com/dfinity/response-verification", rev = "da70db93832f88ecc556ae082612aedec47d3816" } ic-representation-independent-hash = { git = "https://github.com/dfinity/response-verification", rev = "da70db93832f88ecc556ae082612aedec47d3816" } ic-cdk = "0.13.2" diff --git a/core/station/api/src/request.rs b/core/station/api/src/request.rs index 5b47963b2..291d9def5 100644 --- a/core/station/api/src/request.rs +++ b/core/station/api/src/request.rs @@ -46,7 +46,7 @@ pub enum RequestStatusCodeDTO { Failed = 7, } -#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum RequestApprovalStatusDTO { Approved, Rejected, diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 4e63857f8..b511282e8 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -12,6 +12,7 @@ flate2 = { workspace = true } hex = { workspace = true } orbit-essentials = { path = '../../libs/orbit-essentials', version = '0.0.2-alpha.5' } ic-cdk = { workspace = true } +ic-certified-assets = { workspace = true } ic-ledger-types = { workspace = true } itertools = { workspace = true } lazy_static = { workspace = true } diff --git a/tests/integration/src/dfx_orbit.rs b/tests/integration/src/dfx_orbit.rs index b6affa7a1..d4e308bab 100644 --- a/tests/integration/src/dfx_orbit.rs +++ b/tests/integration/src/dfx_orbit.rs @@ -1,253 +1,16 @@ -use crate::{ - setup::create_canister, - utils::{add_user_with_name, update_raw, COUNTER_WAT}, - CanisterIds, -}; -use candid::Principal; -use dfx_orbit::{dfx_extension_api::OrbitExtensionAgent, station_agent::StationConfig, DfxOrbit}; -use itertools::Itertools; -use pocket_ic::PocketIc; -use rand::Rng; -use rand_chacha::{rand_core::SeedableRng, ChaCha8Rng}; -use station_api::UserDTO; -use std::{ - cell::RefCell, - collections::BTreeMap, - future::Future, - hash::{DefaultHasher, Hash, Hasher}, - path::Path, - sync::Mutex, -}; -use tempfile::tempdir; -use tokio::runtime::Runtime; +use std::{cell::RefCell, sync::Mutex}; mod assets; mod canister_call; mod me; mod review; +mod setup; +mod util; thread_local! {static PORT: RefCell = const { RefCell::new(4943) };} static AGENT_MUTEX: Mutex<()> = Mutex::new(()); const DFX_ROOT: &str = "DFX_CONFIG_ROOT"; -//TODO: Generate these on the fly during tests -const TEST_PRINCIPAL: &str = "m4cdf-2jslu-ubcta-5c3e2-wfw77-rgplv-t5hro-otcp5-mnalj-c7du7-iqe"; -const TEST_KEY: &str = " ------BEGIN EC PRIVATE KEY----- -MHQCAQEEIDcHb4eKisFoFBFDFFVm8O1fyMsfRYZLnRzPKHguq/xnoAcGBSuBBAAK -oUQDQgAE3ftLvU0hwcEmiKeqbF2xSFnZ6VfiK0rTnesWxjtTgGCjBdHjs7/8asWP -fWFfV2VlxcuclBtqo9YhTLvlIv+tHA== ------END EC PRIVATE KEY----- -"; -const IDENTITY_JSON: &str = " -{ - \"default\": \"default\" -}"; - -/// The test setup needs to be configurable -/// -/// This struct allows to gradually introduce configurations into the `dfx_orbit` tests -/// to allow testing more fine grained controls -#[derive(Debug, Clone, Default)] -struct DfxOrbitTestConfig { - /// Sets the asset canisters to be defined in the dfx.json, maps name tp list of paths - asset_canisters: BTreeMap>, -} - -fn dfx_orbit_test(env: &mut PocketIc, config: DfxOrbitTestConfig, test_func: F) -> F::Output -where - F: Future, -{ - // NOTE: While DFX_CONFIG_ROOT can only be set with the help of an env variable - // this section of the test can not run in parallel. - // Therefore we run this part of the test in a critical section, while the setup test - // can run in parllel. Hopefully we will be able to fix this in the future. - let _crit = AGENT_MUTEX.lock().unwrap(); - - // Store current dir and DFX_CONFIG_ROOT - let current_dir = std::env::current_dir().unwrap(); - let current_config_root = std::env::var(DFX_ROOT).ok(); - - // Create a temporary directory and change to it - let tmp_dir = tempdir().unwrap(); - std::env::set_current_dir(tmp_dir.path()).unwrap(); - std::env::set_var(DFX_ROOT, tmp_dir.path()); - - // Pick a random port for the test. - // If multiple dfx-orbit tests are run in parallel, they would get mixed up, if they ended - // up using the same port. We pick a random port between 10_000 and 20_000 based on the name - // of the current thread. - // The dfx.json file is set up accordingly later in the test - let port = PORT.with(|port| { - // When 'RUST_TEST_THREADS=1', all tests run in the same thread and that thread will - // be unnamed. This is not a problem, since in that case we don't get a port collision - let thread = std::thread::current(); - let name = thread.name().unwrap_or("test_thread"); - - let mut hasher = DefaultHasher::new(); - name.hash(&mut hasher); - let seed = hasher.finish(); - - let mut rng = ChaCha8Rng::seed_from_u64(seed); - let value: u16 = rng.gen_range(10_000..20_000); - - // Set and also return the port - *port.borrow_mut() = value; - *port.borrow() - }); - - setup_test_dfx_json(tmp_dir.path(), config); - setup_identity(tmp_dir.path()); - - // Start the live environment - env.make_live(Some(port)); - - // Execute the test function in an asynchronous runtime - let runtime = Runtime::new().unwrap(); - let result = runtime.block_on(test_func); - - // Stop the live environment - env.stop_live(); - - // Restore current dir and DFX_CONFIG_ROOT - std::env::set_current_dir(current_dir).unwrap(); - if let Some(root) = current_config_root { - std::env::set_var(DFX_ROOT, root) - } - - result -} - -/// Setup default identity at `dfx_root`, such that we can load the identity and use it for -/// tests -fn setup_identity(dfx_root: &Path) { - let conf_path = dfx_root.join(".config").join("dfx"); - let default_id_path = conf_path.join("identity").join("default"); - std::fs::create_dir_all(&default_id_path).unwrap(); - - std::fs::write(conf_path.join("identity.json"), IDENTITY_JSON).unwrap(); - std::fs::write(default_id_path.join("identity.pem"), TEST_KEY).unwrap(); -} - -/// Sets up a custom `dfx.json` from the provided `config` -fn setup_test_dfx_json(dfx_root: &Path, config: DfxOrbitTestConfig) { - let port = PORT.with(|port| *port.borrow()); - let dfx_json = test_dfx_json_from_template(config, port); - std::fs::write(dfx_root.join("dfx.json"), dfx_json).unwrap(); -} - -/// Generate a custom `dfx.json` from the provided `config` -fn test_dfx_json_from_template(config: DfxOrbitTestConfig, port: u16) -> String { - let asset_canisters = config - .asset_canisters - .iter() - .map(|(name, sources)| { - ( - name, - sources - .iter() - .map(|source| format!("\"{source}\"")) - .join(","), - ) - }) - .map(|(name, sources)| { - format!("\"{name}\": {{ \"source\": [{sources}], \"type\": \"assets\"}}") - }) - .join(","); - - format!( - "{{ - \"canisters\": {{ - {asset_canisters} - }}, - \"networks\": {{ - \"test\": {{ - \"providers\": [ - \"http://localhost:{port}\" - ], - \"type\": \"persistent\" - }} - }} - }}" - ) -} - -/// Setup the station agent for the test -async fn setup_dfx_orbit(station_id: Principal) -> DfxOrbit { - // Setup a logger with highest log level. Capture logging by test harness - use slog::Drain; - let decorator = slog_term::PlainDecorator::new(slog_term::TestStdoutWriter); - let drain = slog_term::FullFormat::new(decorator) - .build() - .filter_level(slog::Level::Trace) - .fuse(); - let drain = slog_async::Async::new(drain).build().fuse(); - let logger = slog::Logger::root(drain, slog::o!()); - - let port = PORT.with(|port| *port.borrow()); - - let orbit_agent = OrbitExtensionAgent::new().unwrap(); - let config = StationConfig { - name: String::from("Test"), - station_id, - network: String::from("test"), - url: format!("http://localhost:{}", port), - }; - DfxOrbit::new(orbit_agent, config, None, logger) - .await - .unwrap() -} - -/// Create the dfx user's identities and add them to the station -fn setup_dfx_user(env: &PocketIc, canister_ids: &CanisterIds) -> (Principal, UserDTO) { - let dfx_principal = Principal::from_text(TEST_PRINCIPAL).unwrap(); - let dfx_user = add_user_with_name( - env, - String::from("dfx_user"), - dfx_principal, - vec![], - canister_ids.station, - ); - - (dfx_principal, dfx_user) -} - -/// Install the counter canister under given `canister_id` into the running IC -fn setup_counter_canister(env: &mut PocketIc, canister_ids: &CanisterIds) -> Principal { - // create and install the counter canister - let canister_id = create_canister(env, canister_ids.station); - let module_bytes = wat::parse_str(COUNTER_WAT).unwrap(); - env.install_canister( - canister_id, - module_bytes.clone(), - vec![], - Some(canister_ids.station), - ); - - // the counter should initially be set at 0 - let ctr = update_raw(&*env, canister_id, Principal::anonymous(), "read", vec![]).unwrap(); - assert_eq!(ctr, 0_u32.to_le_bytes()); - canister_id -} - -/// Fetches an asset from the local host and port -/// -/// This is a bit tricky, as the boundary node uses the `Referer` header to determine the -/// resource being fetched. -async fn fetch_asset(canister_id: Principal, path: &str) -> Vec { - let port = PORT.with(|port| *port.borrow()); - let local_url = format!("http://localhost:{}{}", port, path); - let referer = format!("http://localhost:{}?canisterId={}", port, canister_id); - - reqwest::Client::new() - .get(local_url) - .header("Referer", referer) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap() - .into() -} +// TODO: Integration test for update settings +// TODO: Use the arguments in the system tests to cover more code of the actual tool diff --git a/tests/integration/src/dfx_orbit/assets.rs b/tests/integration/src/dfx_orbit/assets.rs index 183bf6f73..e934ad7aa 100644 --- a/tests/integration/src/dfx_orbit/assets.rs +++ b/tests/integration/src/dfx_orbit/assets.rs @@ -1,175 +1,255 @@ -use super::DfxOrbitTestConfig; +use super::{ + setup::{dfx_orbit_test, setup_dfx_user, DfxOrbitTestConfig}, + util::{permit_call_operation, set_auto_approve, set_four_eyes_on_call}, +}; use crate::{ - dfx_orbit::{ - canister_call::permit_call_operation, dfx_orbit_test, fetch_asset, setup_dfx_orbit, - setup_dfx_user, - }, + dfx_orbit::{setup::setup_dfx_orbit, util::fetch_asset}, setup::{create_canister, get_canister_wasm, setup_new_env, WALLET_ADMIN_USER}, - utils::execute_request, + utils::{add_user, execute_request, user_test_id}, CanisterIds, TestEnv, }; -use dfx_orbit::DfxOrbit; +use candid::{Nat, Principal}; +use dfx_orbit::args::{ + request::{ + asset::{RequestAssetActionArgs, RequestAssetArgs, RequestAssetUploadArgs}, + RequestArgs, RequestArgsActions, + }, + verify::{ + VerifyArgs, VerifyArgsAction, VerifyAssetActionArgs, VerifyAssetArgs, VerifyAssetUploadArgs, + }, +}; +use ic_certified_assets::types::{GrantPermissionArguments, Permission}; use pocket_ic::PocketIc; use rand::{thread_rng, Rng}; use station_api::{ - AddRequestPolicyOperationInput, CallExternalCanisterResourceTargetDTO, CreateRequestInput, - ExecutionMethodResourceTargetDTO, GetRequestInput, RequestOperationInput, RequestPolicyRuleDTO, - RequestSpecifierDTO, ValidationMethodResourceTargetDTO, + CallExternalCanisterOperationInput, CanisterMethodDTO, GetRequestInput, RequestOperationInput, }; use std::{ collections::BTreeMap, - path::Path, + path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tempfile::Builder; +const ASSET_CANISTER_NAME: &str = "test_asset_upload"; + #[test] -fn assets_upload() { +fn asset_upload() { let TestEnv { mut env, canister_ids, .. } = setup_new_env(); - let (dfx_principal, _dfx_user) = setup_dfx_user(&env, &canister_ids); + let asset_canister = setup_asset_canister(&mut env, &canister_ids); - // Install the assets canister under orbit control - let asset_canister = create_canister(&env, canister_ids.station); - let asset_canister_wasm = get_canister_wasm("assetstorage"); - env.install_canister( - asset_canister, - asset_canister_wasm, - candid::encode_args(()).unwrap(), - Some(canister_ids.station), - ); + let (dfx_principal, _dfx_user) = setup_dfx_user(&env, &canister_ids); + let other_user = user_test_id(1); + add_user(&env, other_user, vec![], canister_ids.station); - // As admin: Grant the user the call permission, set auto-approval for external calls + // As admin: Grant the user the call and prepare permissions permit_call_operation(&env, &canister_ids); set_auto_approve(&env, &canister_ids); + grant_prepare_permission(&env, &canister_ids, asset_canister, dfx_principal); - // Setup a tmpdir, and store two assets in it - // We generate the assets dyniamically, since we want to make sure we are not - // fetching old assets - // NOTE: Currently, the local asset computation skips hidden files while the - // remote version does not. This creates an issue if we just used tempdir(), as that - // uses `.` prefix. - let asset_dir = Builder::new().prefix("asset").tempdir().unwrap(); - let asset_a = format!( - "This is the current time: {}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - ); - let asset_b = format!("This is a random number: {}", thread_rng().gen::()); - - std::fs::create_dir_all(asset_dir.path().join("subdir")).unwrap(); - std::fs::write(asset_dir.path().join("system_time"), &asset_a).unwrap(); - std::fs::write( - asset_dir.path().join("subdir").join("random_number"), - &asset_b, - ) - .unwrap(); + let (asset_dir, assets) = setup_assets(); let mut asset_canisters = BTreeMap::new(); asset_canisters.insert( - String::from("test_asset_upload"), - vec![asset_dir.path().to_str().unwrap().to_string()], + ASSET_CANISTER_NAME.into(), + vec![asset_dir.to_str().unwrap().to_string()], ); - let config = DfxOrbitTestConfig { asset_canisters }; + let config = DfxOrbitTestConfig { + asset_canisters, + canister_ids: vec![(ASSET_CANISTER_NAME.into(), asset_canister)], + }; dfx_orbit_test(&mut env, config, async { // Setup the station agent let dfx_orbit = setup_dfx_orbit(canister_ids.station).await; - // As dfx user: Request to have Prepare permission for asset_canister - let _response = dfx_orbit - .station - .request(CreateRequestInput { - operation: DfxOrbit::grant_permission_request(asset_canister, dfx_principal) - .unwrap(), - title: None, - summary: None, - execution_plan: None, - }) - .await - .unwrap(); + let request_args = RequestAssetUploadArgs { + canister: ASSET_CANISTER_NAME.into(), + ignore_evidence: false, + files: vec![], + }; + let request = RequestArgs { + title: None, + summary: None, + action: RequestArgsActions::Asset(RequestAssetArgs { + action: RequestAssetActionArgs::Upload(request_args), + }), + } + .into_request(&dfx_orbit) + .await + .unwrap(); + let _ = dfx_orbit.station.request(request.clone()).await.unwrap(); + // NOTE: We need to wait until the certified state becomes available. + // Since we are in live mode, we can not simply advance pocketIC by some + // ticks, but actially need to wait. tokio::time::sleep(Duration::from_secs(1)).await; - // Test that we can retreive the sources from `dfx.json` - let sources = dfx_orbit.as_path_bufs("test_asset_upload", &[]).unwrap(); - let sources_path = sources - .iter() - .map(|pathbuf| pathbuf.as_path()) - .collect::>(); + // Test that we can fetch the assets + for (asset_path, expected_asset) in assets { + let req = fetch_asset(asset_canister, &asset_path).await; + let test_asset_a = String::from_utf8_lossy(&req); + assert_eq!(expected_asset, test_asset_a); + } + }); +} - // As dfx user: Request to upload new files to the asset canister - let (batch_id, evidence) = dfx_orbit - .upload(asset_canister, &sources_path, false) - .await - .unwrap(); +#[test] +fn asset_validation() { + let TestEnv { + mut env, + canister_ids, + .. + } = setup_new_env(); - let response = dfx_orbit - .station - .request(CreateRequestInput { - operation: DfxOrbit::commit_batch_input( - asset_canister, - batch_id.clone(), - evidence.clone(), - ) - .unwrap(), - title: None, - summary: None, - execution_plan: None, - }) - .await - .unwrap(); + let asset_canister = setup_asset_canister(&mut env, &canister_ids); + + let (dfx_principal, _dfx_user) = setup_dfx_user(&env, &canister_ids); + let other_user = user_test_id(1); + add_user(&env, other_user, vec![], canister_ids.station); + + // As admin: Grant the user the call and prepare permissions + permit_call_operation(&env, &canister_ids); + set_four_eyes_on_call(&env, &canister_ids); + grant_prepare_permission(&env, &canister_ids, asset_canister, dfx_principal); + + let (asset_dir, _) = setup_assets(); - // Check whether the request passes the asset check - let response = dfx_orbit + let mut asset_canisters = BTreeMap::new(); + asset_canisters.insert( + ASSET_CANISTER_NAME.into(), + vec![asset_dir.to_str().unwrap().to_string()], + ); + let config = DfxOrbitTestConfig { + asset_canisters, + canister_ids: vec![(ASSET_CANISTER_NAME.into(), asset_canister)], + }; + + dfx_orbit_test(&mut env, config, async { + // Setup the station agent + let dfx_orbit = setup_dfx_orbit(canister_ids.station).await; + + let request_args = RequestAssetUploadArgs { + canister: ASSET_CANISTER_NAME.into(), + ignore_evidence: false, + files: vec![], + }; + + let request = RequestArgs { + title: None, + summary: None, + action: RequestArgsActions::Asset(RequestAssetArgs { + action: RequestAssetActionArgs::Upload(request_args), + }), + } + .into_request(&dfx_orbit) + .await + .unwrap(); + let request = dfx_orbit.station.request(request.clone()).await.unwrap(); + + // Check that the request verifies + let verify_args = VerifyAssetUploadArgs { + canister: ASSET_CANISTER_NAME.into(), + batch_id: Nat::from(1u64), + files: vec![], + }; + let req_response = dfx_orbit .station .review_id(GetRequestInput { - request_id: response.request.id, + request_id: request.request.id.clone(), }) .await .unwrap(); - DfxOrbit::check_evidence(&response, asset_canister, batch_id, hex::encode(evidence)) - .unwrap(); + VerifyArgs { + request_id: request.request.id, + and_approve: false, + or_reject: false, + action: VerifyArgsAction::Asset(VerifyAssetArgs { + action: VerifyAssetActionArgs::Upload(verify_args), + }), + } + .verify(&dfx_orbit, &req_response) + .await + .unwrap(); + }); +} - // NOTE: We need to wait until the certified state becomes available. - // Since we are in live mode, we can not simply advance pocketIC by some - // ticks, but actially need to wait. - tokio::time::sleep(Duration::from_secs(1)).await; +/// Setup a tmpdir, and store assets in it +/// +/// We generate the assets dynamically, since we want to make sure we are not +/// fetching old assets +/// NOTE: Currently, the local asset computation skips hidden files while the +/// remote version does not. This creates an issue if we just used tempdir(), as that +/// uses `.` prefix. +fn setup_assets() -> (PathBuf, BTreeMap) { + let asset_dir = Builder::new().prefix("asset").tempdir().unwrap(); + let mut assets = BTreeMap::new(); + + let asset_a = format!( + "This is the current time: {}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let asset_a_path = PathBuf::from("system_time"); + std::fs::write(asset_dir.path().join(&asset_a_path), &asset_a).unwrap(); + assets.insert( + asset_a_path.into_os_string().into_string().unwrap(), + asset_a, + ); - // Check that the new files are being served by the asset canister - let req = fetch_asset(asset_canister, "/system_time").await; - let test_asset_a = String::from_utf8_lossy(&req); - assert_eq!(asset_a, test_asset_a); + let asset_b = format!("This is a random number: {}", thread_rng().gen::()); + let asset_b_path = PathBuf::from("subdir").join("random_number"); + std::fs::create_dir_all(asset_dir.path().join("subdir")).unwrap(); + std::fs::write(asset_dir.path().join(&asset_b_path), &asset_b).unwrap(); + assets.insert( + asset_b_path.into_os_string().into_string().unwrap(), + asset_b, + ); - let req = fetch_asset(asset_canister, "/subdir/random_number").await; - let test_asset_b = String::from_utf8_lossy(&req); - assert_eq!(asset_b, test_asset_b); - }); + (asset_dir.into_path(), assets) } -/// Set four eyes principle for canister calls -pub(crate) fn set_auto_approve(env: &PocketIc, canister_ids: &CanisterIds) { - let add_request_policy = - RequestOperationInput::AddRequestPolicy(AddRequestPolicyOperationInput { - specifier: RequestSpecifierDTO::CallExternalCanister( - CallExternalCanisterResourceTargetDTO { - validation_method: ValidationMethodResourceTargetDTO::No, - execution_method: ExecutionMethodResourceTargetDTO::Any, - }, - ), - rule: RequestPolicyRuleDTO::AutoApproved, - }); - execute_request( - env, - WALLET_ADMIN_USER, - canister_ids.station, - add_request_policy, - ) - .unwrap(); +/// Install the assets canister under orbit control +fn setup_asset_canister(env: &mut PocketIc, canister_ids: &CanisterIds) -> Principal { + let asset_canister = create_canister(env, canister_ids.station); + let asset_canister_wasm = get_canister_wasm("assetstorage"); + env.install_canister( + asset_canister, + asset_canister_wasm, + candid::encode_args(()).unwrap(), + Some(canister_ids.station), + ); + asset_canister +} + +fn grant_prepare_permission( + env: &PocketIc, + canister_ids: &CanisterIds, + asset_canister: Principal, + to_principal: Principal, +) { + let arg = GrantPermissionArguments { + to_principal, + permission: Permission::Prepare, + }; + let arg = candid::encode_one(arg).unwrap(); + + let request = RequestOperationInput::CallExternalCanister(CallExternalCanisterOperationInput { + validation_method: None, + execution_method: CanisterMethodDTO { + canister_id: asset_canister, + method_name: String::from("grant_permission"), + }, + arg: Some(arg), + execution_method_cycles: None, + }); + + execute_request(env, WALLET_ADMIN_USER, canister_ids.station, request).unwrap(); } diff --git a/tests/integration/src/dfx_orbit/canister_call.rs b/tests/integration/src/dfx_orbit/canister_call.rs index ff914315d..a10367193 100644 --- a/tests/integration/src/dfx_orbit/canister_call.rs +++ b/tests/integration/src/dfx_orbit/canister_call.rs @@ -1,24 +1,24 @@ use crate::{ dfx_orbit::{ - dfx_orbit_test, setup_counter_canister, setup_dfx_orbit, setup_dfx_user, DfxOrbitTestConfig, + setup::{ + dfx_orbit_test, setup_counter_canister, setup_dfx_orbit, setup_dfx_user, + DfxOrbitTestConfig, + }, + util::{permit_call_operation, set_four_eyes_on_call}, }, - setup::{setup_new_env, WALLET_ADMIN_USER}, - utils::{ - add_user, execute_request, submit_request_approval, update_raw, user_test_id, - wait_for_request, - }, - CanisterIds, TestEnv, + setup::setup_new_env, + utils::{add_user, submit_request_approval, update_raw, user_test_id, wait_for_request}, + TestEnv, }; use candid::Principal; -use pocket_ic::PocketIc; -use station_api::{ - AddRequestPolicyOperationInput, AuthScopeDTO, CallExternalCanisterOperationInput, - CallExternalCanisterResourceTargetDTO, CanisterMethodDTO, CreateRequestInput, - EditPermissionOperationInput, ExecutionMethodResourceTargetDTO, - ExternalCanisterResourceActionDTO, QuorumDTO, RequestApprovalStatusDTO, RequestOperationInput, - RequestPolicyRuleDTO, RequestSpecifierDTO, ResourceDTO, UserSpecifierDTO, - ValidationMethodResourceTargetDTO, +use dfx_orbit::args::{ + request::{ + canister::{RequestCanisterActionArgs, RequestCanisterArgs, RequestCanisterCallArgs}, + RequestArgs, RequestArgsActions, + }, + verify::{VerifyArgs, VerifyArgsAction, VerifyCanisterActionArgs, VerifyCanisterArgs}, }; +use station_api::{GetRequestInput, RequestApprovalStatusDTO}; /// Test a canister call through orbit using the station agent #[test] @@ -38,34 +38,59 @@ fn canister_call() { permit_call_operation(&env, &canister_ids); set_four_eyes_on_call(&env, &canister_ids); - let request_counter_canister_set = CreateRequestInput { - operation: RequestOperationInput::CallExternalCanister( - CallExternalCanisterOperationInput { - validation_method: None, - execution_method: CanisterMethodDTO { - canister_id, - method_name: String::from("set"), - }, - arg: Some(42_u32.to_le_bytes().to_vec()), - execution_method_cycles: None, - }, - ), - title: None, - summary: None, - execution_plan: None, + let config = DfxOrbitTestConfig { + canister_ids: vec![(String::from("counter"), canister_id)], + ..Default::default() }; - let request = dfx_orbit_test(&mut env, DfxOrbitTestConfig::default(), async { + let inner_args = RequestCanisterCallArgs { + canister: String::from("counter"), + method_name: String::from("set"), + argument: None, + arg_file: None, + raw_arg: Some(String::from("2a000000")), + with_cycles: None, + }; + + let request = dfx_orbit_test(&mut env, config, async { // Setup the station agent let dfx_orbit = setup_dfx_orbit(canister_ids.station).await; // Call the counter canister - let request = dfx_orbit + let request = RequestArgs { + title: None, + summary: None, + action: RequestArgsActions::Canister(RequestCanisterArgs { + action: RequestCanisterActionArgs::Call(inner_args.clone()), + }), + } + .into_request(&dfx_orbit) + .await + .unwrap(); + + let request = dfx_orbit.station.request(request.clone()).await.unwrap(); + + // Check that the request verifies + let req_response = dfx_orbit .station - .request(request_counter_canister_set.clone()) + .review_id(GetRequestInput { + request_id: request.request.id.clone(), + }) .await .unwrap(); + VerifyArgs { + request_id: request.request.id.clone(), + and_approve: false, + or_reject: false, + action: VerifyArgsAction::Canister(VerifyCanisterArgs { + action: VerifyCanisterActionArgs::Call(inner_args), + }), + } + .verify(&dfx_orbit, &req_response) + .await + .unwrap(); + request.request }); @@ -86,45 +111,3 @@ fn canister_call() { let ctr = update_raw(&env, canister_id, Principal::anonymous(), "read", vec![]).unwrap(); assert_eq!(ctr, 42_u32.to_le_bytes()); } - -// TODO: Test with insufficient permissions - -/// Allow anyone to create change canister requests -pub(crate) fn permit_call_operation(env: &PocketIc, canister_ids: &CanisterIds) { - let add_permission = RequestOperationInput::EditPermission(EditPermissionOperationInput { - resource: ResourceDTO::ExternalCanister(ExternalCanisterResourceActionDTO::Call( - CallExternalCanisterResourceTargetDTO { - validation_method: ValidationMethodResourceTargetDTO::No, - execution_method: ExecutionMethodResourceTargetDTO::Any, - }, - )), - auth_scope: Some(AuthScopeDTO::Authenticated), - user_groups: None, - users: None, - }); - execute_request(env, WALLET_ADMIN_USER, canister_ids.station, add_permission).unwrap(); -} - -/// Set four eyes principle for canister calls -pub(crate) fn set_four_eyes_on_call(env: &PocketIc, canister_ids: &CanisterIds) { - let add_request_policy = - RequestOperationInput::AddRequestPolicy(AddRequestPolicyOperationInput { - specifier: RequestSpecifierDTO::CallExternalCanister( - CallExternalCanisterResourceTargetDTO { - validation_method: ValidationMethodResourceTargetDTO::No, - execution_method: ExecutionMethodResourceTargetDTO::Any, - }, - ), - rule: RequestPolicyRuleDTO::Quorum(QuorumDTO { - approvers: UserSpecifierDTO::Any, - min_approved: 2, - }), - }); - execute_request( - env, - WALLET_ADMIN_USER, - canister_ids.station, - add_request_policy, - ) - .unwrap(); -} diff --git a/tests/integration/src/dfx_orbit/me.rs b/tests/integration/src/dfx_orbit/me.rs index ed3a127e3..6bc123d70 100644 --- a/tests/integration/src/dfx_orbit/me.rs +++ b/tests/integration/src/dfx_orbit/me.rs @@ -1,5 +1,5 @@ use crate::{ - dfx_orbit::{dfx_orbit_test, setup_dfx_orbit, setup_dfx_user, DfxOrbitTestConfig}, + dfx_orbit::setup::{dfx_orbit_test, setup_dfx_orbit, setup_dfx_user, DfxOrbitTestConfig}, setup::setup_new_env, TestEnv, }; diff --git a/tests/integration/src/dfx_orbit/review.rs b/tests/integration/src/dfx_orbit/review.rs index 15ca5550d..14bae4901 100644 --- a/tests/integration/src/dfx_orbit/review.rs +++ b/tests/integration/src/dfx_orbit/review.rs @@ -1,24 +1,23 @@ use candid::Principal; -use pocket_ic::PocketIc; + use station_api::{ - AuthScopeDTO, CallExternalCanisterOperationInput, CanisterMethodDTO, - EditPermissionOperationInput, GetNextApprovableRequestInput, GetRequestInput, - ListRequestsInput, ListRequestsOperationTypeDTO, RequestOperationInput, - RequestResourceActionDTO, ResourceDTO, + CallExternalCanisterOperationInput, CanisterMethodDTO, GetNextApprovableRequestInput, + GetRequestInput, ListRequestsInput, ListRequestsOperationTypeDTO, RequestOperationInput, }; use crate::{ dfx_orbit::{ - canister_call::{permit_call_operation, set_four_eyes_on_call}, - dfx_orbit_test, setup_counter_canister, setup_dfx_orbit, DfxOrbitTestConfig, - TEST_PRINCIPAL, + setup::{ + dfx_orbit_test, setup_counter_canister, setup_dfx_orbit, DfxOrbitTestConfig, + TEST_PRINCIPAL, + }, + util::{permit_call_operation, permit_list_reads, set_four_eyes_on_call}, }, - setup::{setup_new_env, WALLET_ADMIN_USER}, + setup::setup_new_env, utils::{ - add_user, add_user_with_name, execute_request, submit_request, update_raw, user_test_id, - wait_for_request, + add_user, add_user_with_name, submit_request, update_raw, user_test_id, wait_for_request, }, - CanisterIds, TestEnv, + TestEnv, }; #[test] @@ -141,14 +140,3 @@ fn review() { let ctr = update_raw(&env, canister_id, Principal::anonymous(), "read", vec![]).unwrap(); assert_eq!(ctr, 42_u32.to_le_bytes()); } - -/// Allow anyone to read request list -pub(crate) fn permit_list_reads(env: &PocketIc, canister_ids: &CanisterIds) { - let add_permission = RequestOperationInput::EditPermission(EditPermissionOperationInput { - resource: ResourceDTO::Request(RequestResourceActionDTO::List), - auth_scope: Some(AuthScopeDTO::Authenticated), - user_groups: None, - users: None, - }); - execute_request(env, WALLET_ADMIN_USER, canister_ids.station, add_permission).unwrap(); -} diff --git a/tests/integration/src/dfx_orbit/setup.rs b/tests/integration/src/dfx_orbit/setup.rs new file mode 100644 index 000000000..ccb3d6154 --- /dev/null +++ b/tests/integration/src/dfx_orbit/setup.rs @@ -0,0 +1,237 @@ +use crate::{ + setup::create_canister, + utils::{add_user_with_name, update_raw, COUNTER_WAT}, + CanisterIds, +}; +use candid::Principal; +use dfx_orbit::{dfx_extension_api::OrbitExtensionAgent, station_agent::StationConfig, DfxOrbit}; +use itertools::Itertools; +use pocket_ic::PocketIc; +use rand::Rng; +use rand_chacha::{rand_core::SeedableRng, ChaCha8Rng}; +use station_api::UserDTO; +use std::{ + collections::BTreeMap, + future::Future, + hash::{DefaultHasher, Hash, Hasher}, + path::Path, +}; +use tempfile::tempdir; +use tokio::runtime::Runtime; + +use super::{AGENT_MUTEX, DFX_ROOT, PORT}; + +//TODO: Generate these on the fly during tests +pub(super) const TEST_PRINCIPAL: &str = + "m4cdf-2jslu-ubcta-5c3e2-wfw77-rgplv-t5hro-otcp5-mnalj-c7du7-iqe"; +const TEST_KEY: &str = " +-----BEGIN EC PRIVATE KEY----- +MHQCAQEEIDcHb4eKisFoFBFDFFVm8O1fyMsfRYZLnRzPKHguq/xnoAcGBSuBBAAK +oUQDQgAE3ftLvU0hwcEmiKeqbF2xSFnZ6VfiK0rTnesWxjtTgGCjBdHjs7/8asWP +fWFfV2VlxcuclBtqo9YhTLvlIv+tHA== +-----END EC PRIVATE KEY----- +"; +const IDENTITY_JSON: &str = " +{ + \"default\": \"default\" +}"; + +/// The test setup needs to be configurable +/// +/// This struct allows to gradually introduce configurations into the `dfx_orbit` tests +/// to allow testing more fine grained controls +#[derive(Debug, Clone, Default)] +pub(super) struct DfxOrbitTestConfig { + // TODO: Set network name + /// Sets the asset canisters to be defined in the dfx.json, maps name to list of paths + pub(super) asset_canisters: BTreeMap>, + /// Mapping of canister names to their principal ids + pub(super) canister_ids: Vec<(String, Principal)>, +} + +pub(super) fn dfx_orbit_test( + env: &mut PocketIc, + config: DfxOrbitTestConfig, + test_func: F, +) -> F::Output +where + F: Future, +{ + // NOTE: While DFX_CONFIG_ROOT can only be set with the help of an env variable + // this section of the test can not run in parallel. + // Therefore we run this part of the test in a critical section, while the setup test + // can run in parllel. Hopefully we will be able to fix this in the future. + let _crit = AGENT_MUTEX.lock().unwrap(); + + // Create a temporary directory and change to it + let tmp_dir = tempdir().unwrap(); + std::env::set_current_dir(tmp_dir.path()).unwrap(); + std::env::set_var(DFX_ROOT, tmp_dir.path()); + + // Pick a random port for the test. + // If multiple dfx-orbit tests are run in parallel, they would get mixed up, if they ended + // up using the same port. We pick a random port between 10_000 and 20_000 based on the name + // of the current thread. + // The dfx.json file is set up accordingly later in the test + let port = PORT.with(|port| { + // When 'RUST_TEST_THREADS=1', all tests run in the same thread and that thread will + // be unnamed. This is not a problem, since in that case we don't get a port collision + let thread = std::thread::current(); + let name = thread.name().unwrap_or("test_thread"); + + let mut hasher = DefaultHasher::new(); + name.hash(&mut hasher); + let seed = hasher.finish(); + + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let value: u16 = rng.gen_range(10_000..20_000); + + // Set and also return the port + *port.borrow_mut() = value; + *port.borrow() + }); + + setup_test_dfx_json(tmp_dir.path(), &config); + setup_test_canister_ids_json(tmp_dir.path(), &config); + setup_identity(tmp_dir.path()); + + // Start the live environment + env.make_live(Some(port)); + + // Execute the test function in an asynchronous runtime + let runtime = Runtime::new().unwrap(); + let result = runtime.block_on(test_func); + + // Stop the live environment + env.stop_live(); + + result +} + +/// Setup default identity at `dfx_root`, such that we can load the identity and use it for +/// tests +fn setup_identity(dfx_root: &Path) { + let conf_path = dfx_root.join(".config").join("dfx"); + let default_id_path = conf_path.join("identity").join("default"); + std::fs::create_dir_all(&default_id_path).unwrap(); + + std::fs::write(conf_path.join("identity.json"), IDENTITY_JSON).unwrap(); + std::fs::write(default_id_path.join("identity.pem"), TEST_KEY).unwrap(); +} + +/// Sets up a custom `dfx.json` from the provided `config` +fn setup_test_dfx_json(dfx_root: &Path, config: &DfxOrbitTestConfig) { + let port = PORT.with(|port| *port.borrow()); + let dfx_json = test_dfx_json_from_template(config, port); + std::fs::write(dfx_root.join("dfx.json"), dfx_json).unwrap(); +} + +/// Generate a custom `dfx.json` from the provided `config` +fn test_dfx_json_from_template(config: &DfxOrbitTestConfig, port: u16) -> String { + let asset_canisters = config + .asset_canisters + .iter() + .map(|(name, sources)| { + ( + name, + sources + .iter() + .map(|source| format!("\"{source}\"")) + .join(","), + ) + }) + .map(|(name, sources)| { + format!("\"{name}\": {{ \"source\": [{sources}], \"type\": \"assets\"}}") + }) + .join(","); + + format!( + "{{ + \"canisters\": {{ + {asset_canisters} + }}, + \"networks\": {{ + \"test\": {{ + \"providers\": [ + \"http://localhost:{port}\" + ], + \"type\": \"persistent\" + }} + }} + }}" + ) +} + +fn setup_test_canister_ids_json(dfx_root: &Path, config: &DfxOrbitTestConfig) { + let canister_ids = test_canister_ids_json(config); + dbg!(&canister_ids); + std::fs::write(dfx_root.join("canister_ids.json"), canister_ids).unwrap(); +} + +/// Generate a custom canister_ids.json to lookup from +fn test_canister_ids_json(config: &DfxOrbitTestConfig) -> String { + let canisters = config + .canister_ids + .iter() + .map(|(name, principal)| format!("\"{name}\": {{\"test\": \"{principal}\"}}")) + .join(","); + format!("{{ {canisters} }}") +} + +/// Setup the station agent for the test +pub(super) async fn setup_dfx_orbit(station_id: Principal) -> DfxOrbit { + // Setup a logger with highest log level. Capture logging by test harness + use slog::Drain; + let decorator = slog_term::PlainDecorator::new(slog_term::TestStdoutWriter); + let drain = slog_term::FullFormat::new(decorator) + .build() + .filter_level(slog::Level::Debug) + .fuse(); + let drain = slog_async::Async::new(drain).build().fuse(); + let logger = slog::Logger::root(drain, slog::o!()); + + let port = PORT.with(|port| *port.borrow()); + + let orbit_agent = OrbitExtensionAgent::new().unwrap(); + let config = StationConfig { + name: String::from("Test"), + station_id, + network: String::from("test"), + url: format!("http://localhost:{}", port), + }; + DfxOrbit::new(orbit_agent, config, None, logger) + .await + .unwrap() +} + +/// Create the dfx user's identities and add them to the station +pub(super) fn setup_dfx_user(env: &PocketIc, canister_ids: &CanisterIds) -> (Principal, UserDTO) { + let dfx_principal = Principal::from_text(TEST_PRINCIPAL).unwrap(); + let dfx_user = add_user_with_name( + env, + String::from("dfx_user"), + dfx_principal, + vec![], + canister_ids.station, + ); + + (dfx_principal, dfx_user) +} + +/// Install the counter canister under given `canister_id` into the running IC +pub(super) fn setup_counter_canister(env: &mut PocketIc, canister_ids: &CanisterIds) -> Principal { + // create and install the counter canister + let canister_id = create_canister(env, canister_ids.station); + let module_bytes = wat::parse_str(COUNTER_WAT).unwrap(); + env.install_canister( + canister_id, + module_bytes.clone(), + vec![], + Some(canister_ids.station), + ); + + // the counter should initially be set at 0 + let ctr = update_raw(&*env, canister_id, Principal::anonymous(), "read", vec![]).unwrap(); + assert_eq!(ctr, 0_u32.to_le_bytes()); + canister_id +} diff --git a/tests/integration/src/dfx_orbit/util.rs b/tests/integration/src/dfx_orbit/util.rs new file mode 100644 index 000000000..ef44b84a3 --- /dev/null +++ b/tests/integration/src/dfx_orbit/util.rs @@ -0,0 +1,104 @@ +use crate::{setup::WALLET_ADMIN_USER, utils::execute_request, CanisterIds}; + +use super::PORT; +use candid::Principal; +use pocket_ic::PocketIc; +use station_api::{ + AddRequestPolicyOperationInput, AuthScopeDTO, CallExternalCanisterResourceTargetDTO, + EditPermissionOperationInput, ExecutionMethodResourceTargetDTO, + ExternalCanisterResourceActionDTO, QuorumDTO, RequestOperationInput, RequestPolicyRuleDTO, + RequestResourceActionDTO, RequestSpecifierDTO, ResourceDTO, UserSpecifierDTO, + ValidationMethodResourceTargetDTO, +}; + +/// Fetches an asset from the local host and port +/// +/// This is a bit tricky, as the boundary node uses the `Referer` header to determine the +/// resource being fetched. +pub(super) async fn fetch_asset(canister_id: Principal, path: &str) -> Vec { + let port = PORT.with(|port| *port.borrow()); + let local_url = format!("http://localhost:{}/{}", port, path); + let referer = format!("http://localhost:{}?canisterId={}", port, canister_id); + + reqwest::Client::new() + .get(local_url) + .header("Referer", referer) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() + .into() +} + +/// Allow anyone to create change canister requests +pub(super) fn permit_call_operation(env: &PocketIc, canister_ids: &CanisterIds) { + let add_permission = RequestOperationInput::EditPermission(EditPermissionOperationInput { + resource: ResourceDTO::ExternalCanister(ExternalCanisterResourceActionDTO::Call( + CallExternalCanisterResourceTargetDTO { + validation_method: ValidationMethodResourceTargetDTO::No, + execution_method: ExecutionMethodResourceTargetDTO::Any, + }, + )), + auth_scope: Some(AuthScopeDTO::Authenticated), + user_groups: None, + users: None, + }); + execute_request(env, WALLET_ADMIN_USER, canister_ids.station, add_permission).unwrap(); +} + +/// Set four eyes principle for canister calls +pub(super) fn set_four_eyes_on_call(env: &PocketIc, canister_ids: &CanisterIds) { + let add_request_policy = + RequestOperationInput::AddRequestPolicy(AddRequestPolicyOperationInput { + specifier: RequestSpecifierDTO::CallExternalCanister( + CallExternalCanisterResourceTargetDTO { + validation_method: ValidationMethodResourceTargetDTO::No, + execution_method: ExecutionMethodResourceTargetDTO::Any, + }, + ), + rule: RequestPolicyRuleDTO::Quorum(QuorumDTO { + approvers: UserSpecifierDTO::Any, + min_approved: 2, + }), + }); + execute_request( + env, + WALLET_ADMIN_USER, + canister_ids.station, + add_request_policy, + ) + .unwrap(); +} + +/// Allow anyone to read request list +pub(super) fn permit_list_reads(env: &PocketIc, canister_ids: &CanisterIds) { + let add_permission = RequestOperationInput::EditPermission(EditPermissionOperationInput { + resource: ResourceDTO::Request(RequestResourceActionDTO::List), + auth_scope: Some(AuthScopeDTO::Authenticated), + user_groups: None, + users: None, + }); + execute_request(env, WALLET_ADMIN_USER, canister_ids.station, add_permission).unwrap(); +} + +pub(super) fn set_auto_approve(env: &PocketIc, canister_ids: &CanisterIds) { + let add_request_policy = + RequestOperationInput::AddRequestPolicy(AddRequestPolicyOperationInput { + specifier: RequestSpecifierDTO::CallExternalCanister( + CallExternalCanisterResourceTargetDTO { + validation_method: ValidationMethodResourceTargetDTO::No, + execution_method: ExecutionMethodResourceTargetDTO::Any, + }, + ), + rule: RequestPolicyRuleDTO::AutoApproved, + }); + execute_request( + env, + WALLET_ADMIN_USER, + canister_ids.station, + add_request_policy, + ) + .unwrap(); +} diff --git a/tests/integration/src/utils.rs b/tests/integration/src/utils.rs index 75ec017b6..fd9148e7f 100644 --- a/tests/integration/src/utils.rs +++ b/tests/integration/src/utils.rs @@ -19,6 +19,7 @@ use station_api::{ SystemInfoDTO, SystemInfoResponse, UserDTO, UserSpecifierDTO, UserStatusDTO, UuidDTO, }; use std::io::Write; +use std::path::PathBuf; use std::time::Duration; use upgrader_api::{GetDisasterRecoveryStateResponse, GetLogsInput, GetLogsResponse}; @@ -674,9 +675,8 @@ pub fn compress_to_gzip(data: &[u8]) -> Vec { /// Creates a file in the `assets` folder with the given name and content. pub fn create_file(name: &str, content: &[u8]) { - let current_dir = std::env::current_dir().expect("Failed to get current directory"); let relative_path = std::path::Path::new("assets").join(name); - let absolute_path = current_dir.join(relative_path); + let absolute_path = test_dir().join(relative_path); if let Some(parent_dir) = absolute_path.parent() { std::fs::create_dir_all(parent_dir).expect("Failed to create directories"); @@ -687,9 +687,8 @@ pub fn create_file(name: &str, content: &[u8]) { /// Reads the content of a file in the `assets` folder with the given name. pub fn read_file(name: &str) -> Option> { - let current_dir = std::env::current_dir().expect("Failed to get current directory"); let relative_path = std::path::Path::new("assets").join(name); - let absolute_path = current_dir.join(relative_path); + let absolute_path = test_dir().join(relative_path); if !absolute_path.exists() { return None; @@ -698,6 +697,10 @@ pub fn read_file(name: &str) -> Option> { std::fs::read(absolute_path).ok() } +fn test_dir() -> PathBuf { + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get CARGO_MANIFEST_DIR")) +} + /// Converts the given data to a SHA-256 hash and returns it as a hex string. pub fn sha256_hex(data: &[u8]) -> String { let mut hasher = sha2::Sha256::new(); diff --git a/tools/dfx-orbit/Cargo.toml b/tools/dfx-orbit/Cargo.toml index 613629d62..491251688 100644 --- a/tools/dfx-orbit/Cargo.toml +++ b/tools/dfx-orbit/Cargo.toml @@ -14,6 +14,7 @@ anyhow.workspace = true candid.workspace = true candid_parser.workspace = true clap.workspace = true +ciborium.workspace = true serde.workspace = true serde_bytes.workspace = true serde_json.workspace = true diff --git a/tools/dfx-orbit/README.md b/tools/dfx-orbit/README.md index 921737348..6824fba9d 100644 --- a/tools/dfx-orbit/README.md +++ b/tools/dfx-orbit/README.md @@ -63,17 +63,19 @@ Tell the command line tool where to find the orbit station: - Log in to Orbit. - Navigate to station settings. - Copy the wallet ID -- Store the station details locally. If your wallet is called `shiny` and is running locally, the command is: +- Store the station details locally. + ``` - dfx-orbit station add shiny --station-id "$WALLET_ID" --network ic + dfx-orbit station add [STATION_NAME] --station-id [STATION_ID] --network ic ``` + - Verify that the station is in your list of stations: ``` dfx-orbit station list ``` - If you have multiple stations, set this as your default: ``` - dfx-orbit station use shiny + dfx-orbit station use [STATION_NAME] ``` - Show the station details ``` @@ -88,22 +90,6 @@ Tell the command line tool where to find the orbit station: dfx-orbit me ``` -### Grant permission to make requests - -You can check which permissions you have with: - -``` -dfx-orbit me | jq .Ok.privileges -``` - -Initially you are likely to have only permission to see your own profile: - -``` -[ - "Capabilities" -] -``` - ## Make canister calls with Orbit Instead of using `dfx canister call CANISTER METHOD ARGUMENTS` use `dfx-orbit request canister call CANISTER METHOD ARGUMENTS`. @@ -141,24 +127,18 @@ dfx canister info MY_CANISTER --network MY_CANISTER_NETWORK ### Upgrade canisters -#### Request permission to make upgrade requests +#### Request a canister upgrade -This will allow you to propose upgrades to `MY_CANISTER`: +Suppose that you have built a new Wasm. To upgrade your canister to the new Wasm: ``` -dfx-orbit request permission upgrade-canister MY_CANISTER +dfx-orbit request canister install --mode upgrade [CANISTER_NAME] --wasm [WASM_PATH] ``` -This will create an Orbit request. Once approved you will be able to propose canister upgrades. - -> :warning: **The Orbit GUI does not currently show this proposal unless you enter the proposal URL directly, under /en/settings/requests?reqid=THE_ID** - -#### Request a canister upgrade - -Suppose that you have built a new Wasm and put a copy at `./MY-CANISTER.wasm.gz`. To upgrade your canister to the new Wasm: +Then a verifier can verify this request, using: ``` -dfx-orbit request canister install --mode upgrade MY_CANISTER --wasm ./MY-CANISTER.wasm.gz +dfx-orbit verify [REQUEST_ID] canister install --mode upgrade [CANISTER_NAME] --wasm [WASM_PATH] ``` ### Upload assets to a canister @@ -171,7 +151,13 @@ If not, please transfer the control of the canister to the orbit station. Note: Uploaded assets are not published. They are only prepared for release. ``` -dfx-orbit request asset prepare-permission frontend +dfx-orbit request asset permission [CANISTER_NAME] prepare +``` + +Similarly, you can validate a request using + +``` +dfx-orbit verify [REQUEST_ID] asset permission [CANISTER_NAME] prepare ``` In case you want to verify, whether you have the `Prepare` permission on the asset canister, @@ -193,7 +179,7 @@ dfx identity get-principal A developer may upload one or more directories of HTTP assets with: ``` -dfx-orbit request asset upload CANISTER_NAME SOME_DIR/ OTHER_DIR/ +dfx-orbit request asset upload [CANISTER_NAME] --files [SOME_DIR] ``` This will upload the assets to the asset canister and then request the orbit station to publish @@ -204,7 +190,7 @@ the assets. After the request has been made, the reviewers can locally verify the request: ``` -dfx-orbit verify asset upload CANISTER REQUEST_ID BATCH_ID SOME_DIR/ OTHER_DIR/ +dfx-orbit verify [REQUEST_ID ] asset upload CANISTER --batch-id [BATCH_ID] --files [SOME_DIR] ``` > The verifiers needs to have the same set of data as was used in the request. diff --git a/tools/dfx-orbit/src/args/request.rs b/tools/dfx-orbit/src/args/request.rs index 0f06a9a10..c0d95b0e6 100644 --- a/tools/dfx-orbit/src/args/request.rs +++ b/tools/dfx-orbit/src/args/request.rs @@ -16,15 +16,14 @@ use station_api::CreateRequestInput; pub struct RequestArgs { /// Title of the request #[clap(long)] - title: Option, + pub title: Option, /// Summary of the request #[clap(long)] - summary: Option, + pub summary: Option, - // TODO: Summary file as an alternative to summary #[clap(subcommand)] - action: RequestArgsActions, + pub action: RequestArgsActions, } #[derive(Debug, Clone, Subcommand)] @@ -40,19 +39,14 @@ pub enum RequestArgsActions { } impl RequestArgs { - pub(crate) async fn into_create_request_input( - self, - dfx_orbit: &DfxOrbit, - ) -> anyhow::Result { + pub async fn into_request(self, dfx_orbit: &DfxOrbit) -> anyhow::Result { let operation = match self.action { RequestArgsActions::Canister(canister_args) => { - canister_args.into_create_request_input(dfx_orbit)? - } - RequestArgsActions::Asset(asset_args) => { - asset_args.into_create_request_input(dfx_orbit).await? + canister_args.into_request(dfx_orbit).await? } + RequestArgsActions::Asset(asset_args) => asset_args.into_request(dfx_orbit).await?, RequestArgsActions::Permission(permission_args) => { - permission_args.into_create_request_input(dfx_orbit)? + permission_args.into_request(dfx_orbit)? } }; diff --git a/tools/dfx-orbit/src/args/request/asset.rs b/tools/dfx-orbit/src/args/request/asset.rs index 2d3080821..24d45c18b 100644 --- a/tools/dfx-orbit/src/args/request/asset.rs +++ b/tools/dfx-orbit/src/args/request/asset.rs @@ -1,70 +1,174 @@ use crate::DfxOrbit; -use clap::{Parser, Subcommand}; -use station_api::RequestOperationInput; +use anyhow::bail; +use candid::{Nat, Principal}; +use clap::{Parser, Subcommand, ValueEnum}; +use ic_certified_assets::types::{ + DeleteBatchArguments, GrantPermissionArguments, Permission, RevokePermissionArguments, +}; +use sha2::{Digest, Sha256}; +use station_api::{ + CallExternalCanisterOperationInput, CanisterMethodDTO, GetRequestResponse, RequestOperationDTO, + RequestOperationInput, +}; #[derive(Debug, Clone, Parser)] pub struct RequestAssetArgs { #[clap(subcommand)] - pub(crate) action: RequestAssetActionArgs, + pub action: RequestAssetActionArgs, } #[derive(Debug, Clone, Subcommand)] #[clap(version, about, long_about = None)] pub enum RequestAssetActionArgs { - /// Request to grant this user Prepare permission for the asset canister - PreparePermission(RequestAssetPreparePermissionArgs), - /// Upload assets to an asset canister + /// Request to grant a user permissions for an asset canister + Permission(RequestAssetPermissionArgs), + /// Upload assets to an asset canister, and then request to commit to it Upload(RequestAssetUploadArgs), + /// Commit to an already prepared batch + Commit(RequestAssetCommitArgs), + /// Cancel an upload + CancelUpload(RequestAssetCancelUploadArgs), } impl RequestAssetArgs { - pub(crate) async fn into_create_request_input( + pub(crate) async fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { match self.action { - RequestAssetActionArgs::PreparePermission(args) => { - args.into_create_request_input(dfx_orbit) - } - RequestAssetActionArgs::Upload(args) => args.into_create_request_input(dfx_orbit).await, + RequestAssetActionArgs::Permission(args) => args.into_request(dfx_orbit), + RequestAssetActionArgs::Upload(args) => args.into_request(dfx_orbit).await, + RequestAssetActionArgs::Commit(args) => args.into_request(dfx_orbit).await, + RequestAssetActionArgs::CancelUpload(args) => args.into_request(dfx_orbit), } } } -// TODO: Verification call for this request #[derive(Debug, Clone, Parser)] -pub struct RequestAssetPreparePermissionArgs { +pub struct RequestAssetPermissionArgs { /// The name of the asset canister targeted by this action - pub(crate) canister: String, - // TODO: Allow to specify principal to use instead of self + pub canister: String, + /// The type of permission to grant / revoke + pub permission: AssetPermissionTypeArgs, + /// The principal to grant the prepare permission to (defaults to self) + #[clap(short, long)] + pub target: Option, + /// Request to revoke (rather than grant) the permission + #[clap(short, long)] + pub revoke: bool, } -impl RequestAssetPreparePermissionArgs { - pub(crate) fn into_create_request_input( +impl RequestAssetPermissionArgs { + pub(crate) fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { let me = dfx_orbit.own_principal()?; + let target = self.target.unwrap_or(me); + let asset_canister = dfx_orbit.canister_id(&self.canister)?; + + Ok(RequestOperationInput::CallExternalCanister( + CallExternalCanisterOperationInput { + validation_method: None, + execution_method: CanisterMethodDTO { + canister_id: asset_canister, + method_name: self.method_name(), + }, + arg: Some(self.encoded_args(target)?), + execution_method_cycles: None, + }, + )) + } + + pub(crate) fn verify( + &self, + dfx_orbit: &DfxOrbit, + request: &GetRequestResponse, + ) -> anyhow::Result<()> { let asset_canister = dfx_orbit.canister_id(&self.canister)?; - DfxOrbit::grant_permission_request(asset_canister, me) + let expected_method = self.method_name(); + + let me = dfx_orbit.own_principal()?; + let target = self.target.unwrap_or(me); + let arg = self.encoded_args(target)?; + let computed_arg_checksum = hex::encode(Sha256::digest(arg)); + + verify_call( + request, + &asset_canister, + &expected_method, + &Some(computed_arg_checksum), + )?; + + Ok(()) + } + + fn method_name(&self) -> String { + match self.revoke { + false => String::from("grant_permission"), + true => String::from("revoke_permission"), + } + } + + fn encoded_args(&self, target: Principal) -> anyhow::Result> { + match self.revoke { + false => { + let arg = GrantPermissionArguments { + to_principal: target, + permission: self.permission.into(), + }; + Ok(candid::encode_one(arg)?) + } + true => { + let arg = RevokePermissionArguments { + of_principal: target, + permission: self.permission.into(), + }; + Ok(candid::encode_one(arg)?) + } + } + } +} + +/// Canister installation mode equivalent to `dfx canister install --mode XXX` and `orbit_station_api::CanisterInstallMode`. +#[derive(Copy, Clone, Eq, PartialEq, Debug, ValueEnum)] +pub enum AssetPermissionTypeArgs { + /// Permission to prepare asset upload (is needed by the uploading developer) + Prepare, + /// Permission to commit a batch (should only be granted to the orbit station itself) + Commit, + /// Permission to grant and revoke the other permissions of the asset cansister + /// (should not be needed if the orbit station is the controller) + ManagePermissions, +} + +impl From for Permission { + fn from(value: AssetPermissionTypeArgs) -> Self { + match value { + AssetPermissionTypeArgs::Prepare => Permission::Prepare, + AssetPermissionTypeArgs::Commit => Permission::Commit, + AssetPermissionTypeArgs::ManagePermissions => Permission::ManagePermissions, + } } } #[derive(Debug, Clone, Parser)] pub struct RequestAssetUploadArgs { /// The name of the asset canister targeted by this action - pub(crate) canister: String, + pub canister: String, /// Do not abort the upload, if the evidence does not match between local and remote calculation #[clap(long)] - pub(crate) ignore_evidence: bool, + pub ignore_evidence: bool, - /// The source directories to upload (multiple values possible) - pub(crate) files: Vec, + /// The source directories to upload + /// (multiple values possible, picks up sources from dfx.json by default) + #[clap(short, long)] + pub files: Vec, } impl RequestAssetUploadArgs { - pub(crate) async fn into_create_request_input( + pub(crate) async fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { @@ -81,3 +185,118 @@ impl RequestAssetUploadArgs { DfxOrbit::commit_batch_input(canister_id, batch_id, evidence) } } + +#[derive(Debug, Clone, Parser)] +pub struct RequestAssetCommitArgs { + /// The name of the asset canister targeted by this action + pub canister: String, + + /// The batch ID to commit to + #[clap(short, long)] + pub batch_id: Nat, + + /// Provide the evidence string manually rather than recomputing it + #[clap(short, long, conflicts_with = "files")] + pub evidence: Option, + + /// The source directories to upload + /// (multiple values possible, picks up sources from dfx.json by default) + #[clap(short, long, conflicts_with = "evidence")] + pub files: Vec, + + /// Only print computed evidence and terminate + #[clap(long)] + pub dry_run: bool, +} + +impl RequestAssetCommitArgs { + async fn into_request(self, dfx_orbit: &DfxOrbit) -> anyhow::Result { + let canister_id = dfx_orbit.canister_id(&self.canister)?; + let asset_agent = dfx_orbit.asset_agent(canister_id)?; + + let evidence = match self.evidence { + Some(evidence) => evidence, + None => { + let pathbufs = dfx_orbit.as_path_bufs(&self.canister, &self.files)?; + let paths = DfxOrbit::as_paths(&pathbufs); + asset_agent.compute_evidence(&paths).await? + } + }; + println!("Batch id: {}", self.batch_id); + println!("Evidence: {evidence}"); + + if self.dry_run { + bail!("Dry-run: aborting commit"); + } + + let evidence = hex::decode(evidence)?.into(); + DfxOrbit::commit_batch_input(canister_id, self.batch_id, evidence) + } +} + +#[derive(Debug, Clone, Parser)] +pub struct RequestAssetCancelUploadArgs { + /// The name of the asset canister targeted by this action + pub canister: String, + + /// The batch ID to cancel + #[clap(short, long)] + pub batch_id: Nat, +} + +impl RequestAssetCancelUploadArgs { + fn into_request(self, dfx_orbit: &DfxOrbit) -> anyhow::Result { + let canister_id = dfx_orbit.canister_id(&self.canister)?; + DfxOrbit::cancel_batch_input(canister_id, self.batch_id) + } + + pub(crate) fn verify( + &self, + dfx_orbit: &DfxOrbit, + request: &GetRequestResponse, + ) -> anyhow::Result<()> { + let asset_canister = dfx_orbit.canister_id(&self.canister)?; + let args = DeleteBatchArguments { + batch_id: self.batch_id.clone(), + }; + let encoded_args: String = hex::encode(candid::encode_one(args)?); + + verify_call( + request, + &asset_canister, + "delete_batch", + &Some(encoded_args), + )?; + Ok(()) + } +} + +fn verify_call( + request: &GetRequestResponse, + expected_canister_id: &Principal, + expected_method: &str, + expected_arg_checksum: &Option, +) -> anyhow::Result<()> { + let RequestOperationDTO::CallExternalCanister(operation) = &request.request.operation else { + bail!("The request is not a call external canister request"); + }; + if &operation.execution_method.canister_id != expected_canister_id { + bail!( + "The request targets an unexpected canister. Expected: {}, actual: {}", + expected_canister_id, + operation.execution_method.canister_id + ); + } + if operation.execution_method.method_name != expected_method { + bail!( + "The method of this request is not \"{}\" but \"{}\" instead", + expected_method, + operation.execution_method.method_name + ); + } + if &operation.arg_checksum != expected_arg_checksum { + bail!("Argument checksum does not match"); + } + + Ok(()) +} diff --git a/tools/dfx-orbit/src/args/request/canister.rs b/tools/dfx-orbit/src/args/request/canister.rs index 07722c247..29970aebe 100644 --- a/tools/dfx-orbit/src/args/request/canister.rs +++ b/tools/dfx-orbit/src/args/request/canister.rs @@ -2,28 +2,28 @@ use crate::DfxOrbit; use anyhow::{bail, Context}; +use candid::Principal; use clap::{Parser, Subcommand, ValueEnum}; use sha2::{Digest, Sha256}; use slog::{info, Logger}; use station_api::{ CallExternalCanisterOperationInput, CanisterInstallMode, CanisterMethodDTO, - ChangeExternalCanisterOperationInput, GetRequestResponse, RequestOperationDTO, - RequestOperationInput, + ChangeExternalCanisterOperationInput, ConfigureExternalCanisterOperationInput, + ConfigureExternalCanisterOperationKindDTO, DefiniteCanisterSettingsInput, GetRequestResponse, + RequestOperationDTO, RequestOperationInput, }; +use std::collections::BTreeSet; // TODO: Support Canister create + integration test // TODO: Canister get response functionality - -// TODO: Support Canister create + integration test -// TODO: Support Canister install check -// TODO: Canister get response functionality +// ^ Utility function to get the latests response directly printed, to get UX similar to dfx canister call /// Request canister operations through Orbit #[derive(Debug, Clone, Parser)] pub struct RequestCanisterArgs { /// The operation to request #[clap(subcommand)] - action: RequestCanisterActionArgs, + pub action: RequestCanisterActionArgs, } #[derive(Debug, Clone, Subcommand)] @@ -33,31 +33,30 @@ pub enum RequestCanisterActionArgs { Install(RequestCanisterInstallArgs), /// Request to call a canister method Call(RequestCanisterCallArgs), + /// Update a canister's settings (i.e its controller, compute allocation, or memory allocation.) + UpdateSettings(RequestCanisterUpdateSettingsArgs), } impl RequestCanisterArgs { /// Converts the CLI arg type into the equivalent Orbit API type. - pub(crate) fn into_create_request_input( + pub(crate) async fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { - self.action.into_create_request_input(dfx_orbit) + self.action.into_request(dfx_orbit).await } } impl RequestCanisterActionArgs { /// Converts the CLI arg type into the equivalent Orbit API type. - pub(crate) fn into_create_request_input( + pub(crate) async fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { match self { - RequestCanisterActionArgs::Install(change_args) => { - change_args.into_create_request_input(dfx_orbit) - } - RequestCanisterActionArgs::Call(call_args) => { - call_args.into_create_request_input(dfx_orbit) - } + RequestCanisterActionArgs::Install(args) => args.into_request(dfx_orbit), + RequestCanisterActionArgs::Call(args) => args.into_request(dfx_orbit), + RequestCanisterActionArgs::UpdateSettings(args) => args.into_request(dfx_orbit).await, } } } @@ -66,30 +65,33 @@ impl RequestCanisterActionArgs { #[derive(Debug, Clone, Parser)] pub struct RequestCanisterCallArgs { /// The canister name or ID. - canister: String, + pub canister: String, /// The name of the method to call. - method_name: String, - /// The argument to pass to the method. - argument: Option, + pub method_name: String, + /// The candid argument to pass to the method. + pub argument: Option, // TODO: The format of the argument. // #[clap(short, long)] // r#type: Option, - /// Pass the argument as a file. + /// Pass the argument as a candid encoded file. #[clap(short = 'f', long, conflicts_with = "argument")] - arg_file: Option, + pub arg_file: Option, + /// Pass the argument as a raw hex string. + #[clap(short = 'f', long, conflicts_with = "argument, arg_file")] + pub raw_arg: Option, /// Specifies the amount of cycles to send on the call. #[clap(short, long)] - with_cycles: Option, + pub with_cycles: Option, } impl RequestCanisterCallArgs { /// Converts the CLI arg stype into the equivalent Orbit API type. - pub(crate) fn into_create_request_input( + pub(crate) fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { let canister_id = dfx_orbit.canister_id(&self.canister)?; - let arg = candid_from_string_or_file(&self.argument, &self.arg_file)?; + let arg = parse_arguments(&self.argument, &self.arg_file, &self.raw_arg)?; Ok(RequestOperationInput::CallExternalCanister( CallExternalCanisterOperationInput { @@ -110,7 +112,7 @@ impl RequestCanisterCallArgs { request: &GetRequestResponse, ) -> anyhow::Result<()> { let canister_id = dfx_orbit.canister_id(&self.canister)?; - let arg = candid_from_string_or_file(&self.argument, &self.arg_file)?; + let arg = parse_arguments(&self.argument, &self.arg_file, &self.raw_arg)?; let arg_checksum = arg.map(|arg| hex::encode(Sha256::digest(arg))); let RequestOperationDTO::CallExternalCanister(op) = &request.request.operation else { @@ -151,9 +153,8 @@ pub struct RequestCanisterInstallArgs { /// The canister name or ID. canister: String, /// The installation mode. - #[clap(long, value_enum, rename_all = "kebab-case", default_value = "install")] + #[clap(long, value_enum, rename_all = "kebab-case")] mode: CanisterInstallModeArgs, - // TODO: On verify, allow a --wasm-hash instead /// The path to the wasm file to install (can also be a wasm.gz). #[clap(short, long)] wasm: String, @@ -167,7 +168,7 @@ pub struct RequestCanisterInstallArgs { impl RequestCanisterInstallArgs { /// Converts the CLI arg type into the equivalent Orbit API type. - pub(crate) fn into_create_request_input( + pub(crate) fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { @@ -234,7 +235,7 @@ impl RequestCanisterInstallArgs { let module = std::fs::read(&self.wasm) .with_context(|| "Could not read Wasm file")? .to_vec(); - let args = candid_from_string_or_file(&self.argument, &self.arg_file)?; + let args = parse_arguments(&self.argument, &self.arg_file, &None)?; Ok((module, args)) } @@ -271,13 +272,120 @@ impl From for CanisterInstallModeArgs { } } -fn candid_from_string_or_file( +#[derive(Debug, Clone, Parser)] +pub struct RequestCanisterUpdateSettingsArgs { + /// The canister name or ID. + canister: String, + + /// Add a principal to the list of controllers of the canister + #[clap(long)] + pub(crate) add_controller: Vec, + + /// Removes a principal from the list of controllers of the canister + #[clap(long)] + pub(crate) remove_controller: Vec, +} + +impl RequestCanisterUpdateSettingsArgs { + pub(crate) async fn into_request( + self, + dfx_orbit: &DfxOrbit, + ) -> anyhow::Result { + let canister_id = dfx_orbit.canister_id(&self.canister)?; + let controllers = get_new_controller_set( + dfx_orbit, + canister_id, + self.add_controller, + self.remove_controller, + ) + .await?; + + let operations = ConfigureExternalCanisterOperationInput { + canister_id, + kind: ConfigureExternalCanisterOperationKindDTO::NativeSettings( + DefiniteCanisterSettingsInput { + controllers: Some(controllers), + compute_allocation: None, + memory_allocation: None, + freezing_threshold: None, + reserved_cycles_limit: None, + }, + ), + }; + + Ok(RequestOperationInput::ConfigureExternalCanister(operations)) + } + + pub(crate) async fn verify( + &self, + dfx_orbit: &DfxOrbit, + request: &GetRequestResponse, + ) -> anyhow::Result<()> { + let canister_id = dfx_orbit.canister_id(&self.canister)?; + let controllers = get_new_controller_set( + dfx_orbit, + canister_id, + self.add_controller.clone(), + self.remove_controller.clone(), + ) + .await?; + + let RequestOperationDTO::ConfigureExternalCanister(op) = &request.request.operation else { + bail!("This request is not a configure external canister request"); + }; + if op.canister_id != canister_id { + bail!( + "Mismatch of canister ids: request: {}, local: {}", + op.canister_id, + canister_id + ); + } + let ConfigureExternalCanisterOperationKindDTO::NativeSettings(op) = &op.kind else { + bail!("This request is not a native setting request"); + }; + if op.controllers.as_ref() != Some(&controllers) { + bail!( + "Mismatch in the controller sets: request: {:?}, local {:?}", + op.controllers, + controllers + ); + } + + Ok(()) + } +} + +async fn get_new_controller_set( + dfx_orbit: &DfxOrbit, + canister_id: Principal, + add: Vec, + remove: Vec, +) -> anyhow::Result> { + // Transform into maps to deduplicates + let old_controllers = dfx_orbit.get_controllers(canister_id).await?; + let controllers = old_controllers + .iter() + .chain(add.iter()) + .collect::>(); + let remove = remove.iter().collect::>(); + + let new_controllers = controllers + .difference(&remove) + .map(|&&v| v) + .collect::>(); + + Ok(new_controllers) +} + +fn parse_arguments( arg_string: &Option, arg_path: &Option, + raw_arg: &Option, ) -> anyhow::Result>> { // TODO: It would be really nice to be able to use `blob_from_arguments(..)` here, as in dfx, to get all the nice things such as help composing the argument. // First try to read the argument file, if it was provided - Ok(arg_path + + let candid = arg_path .as_ref() .map(std::fs::read_to_string) .transpose()? @@ -289,7 +397,11 @@ fn candid_from_string_or_file( .with_context(|| "Invalid Candid values".to_string())? .to_bytes() }) - .transpose()?) + .transpose()?; + + let raw_arg = raw_arg.as_ref().map(hex::decode).transpose()?; + let arg = candid.or(raw_arg); + Ok(arg) } fn log_hashes(logger: &Logger, name: &str, local: &Option, remote: &Option) { diff --git a/tools/dfx-orbit/src/args/request/permission.rs b/tools/dfx-orbit/src/args/request/permission.rs index 77cab118b..206ea476c 100644 --- a/tools/dfx-orbit/src/args/request/permission.rs +++ b/tools/dfx-orbit/src/args/request/permission.rs @@ -25,7 +25,7 @@ pub enum RequestPermissionArgs { impl RequestPermissionArgs { /// Converts the CLI arg type into the equivalent Orbit API type. - pub(crate) fn into_create_request_input( + pub(crate) fn into_request( self, dfx_orbit: &DfxOrbit, ) -> anyhow::Result { diff --git a/tools/dfx-orbit/src/args/review/id.rs b/tools/dfx-orbit/src/args/review/id.rs index 662d94ff0..9ac82bd1d 100644 --- a/tools/dfx-orbit/src/args/review/id.rs +++ b/tools/dfx-orbit/src/args/review/id.rs @@ -8,24 +8,10 @@ pub struct ReviewIdArgs { /// The ID of the request to review. pub(crate) request_id: String, /// Prompt the user to approve the request - #[clap( - short, - long, - action, - value_name = "REASON", - conflicts_with = "reject", - default_missing_value = "None" - )] + #[clap(short, long, action, value_name = "REASON", conflicts_with = "reject")] pub(crate) approve: Option>, /// Prompt the user to reject the request - #[clap( - short, - long, - action, - value_name = "REASON", - conflicts_with = "approve", - default_missing_value = "None" - )] + #[clap(short, long, action, value_name = "REASON", conflicts_with = "approve")] pub(crate) reject: Option>, } diff --git a/tools/dfx-orbit/src/args/verify.rs b/tools/dfx-orbit/src/args/verify.rs index 9789b4ad2..72dc4fc3a 100644 --- a/tools/dfx-orbit/src/args/verify.rs +++ b/tools/dfx-orbit/src/args/verify.rs @@ -2,77 +2,89 @@ mod asset; mod canister; use crate::DfxOrbit; -use asset::VerifyAssetArgs; -use canister::VerifyCanisterArgs; +pub use asset::{VerifyAssetActionArgs, VerifyAssetArgs, VerifyAssetUploadArgs}; +pub use canister::{VerifyCanisterActionArgs, VerifyCanisterArgs}; use clap::{Parser, Subcommand}; -use station_api::GetRequestInput; +use station_api::GetRequestResponse; #[derive(Debug, Clone, Parser)] pub struct VerifyArgs { /// The ID of the request to verify - pub(crate) request_id: String, + pub request_id: String, /// Approve the request, if the validation succeeds #[clap(short = 'a', long)] - pub(crate) and_approve: bool, + pub and_approve: bool, /// Reject the request, if the validation fails #[clap(short = 'r', long)] - pub(crate) or_reject: bool, + pub or_reject: bool, /// The type of request to verify #[clap(subcommand)] - pub(crate) action: VerifyArgsAction, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum VerifyArgsAction { - /// Manage assets stored in an asset canister through Orbit - Asset(VerifyAssetArgs), - /// Request canister operations through Orbit - Canister(VerifyCanisterArgs), + pub action: VerifyArgsAction, } impl VerifyArgs { - pub(crate) async fn verify(self, dfx_orbit: &DfxOrbit) -> anyhow::Result<()> { - // TODO: Move fetching the request and displaying it up a level - let request = dfx_orbit - .station - .review_id(GetRequestInput { - request_id: self.request_id.clone(), - }) - .await?; - - println!( - "{}", - dfx_orbit.display_get_request_response(request.clone())? - ); + pub async fn verify( + &self, + dfx_orbit: &DfxOrbit, + request: &GetRequestResponse, + ) -> anyhow::Result<()> { // TODO: Don't allow non-pending requests to be verified, since they might no longer be // verifiable after the execution - let verified = match self.action { - VerifyArgsAction::Asset(args) => args.verify(dfx_orbit, &request).await, - VerifyArgsAction::Canister(args) => args.verify(dfx_orbit, &request), + match &self.action { + VerifyArgsAction::Asset(args) => args.verify(dfx_orbit, request).await?, + VerifyArgsAction::Canister(args) => args.verify(dfx_orbit, request).await?, }; + Ok(()) + } + + pub(crate) async fn conditionally_execute_actions( + &self, + dfx_orbit: &DfxOrbit, + verified: anyhow::Result<()>, + ) -> anyhow::Result<()> { match verified { Ok(()) => { - println!("Verification successful!"); if self.and_approve { - dfx_core::cli::ask_for_consent("Do you want to approve the request?")?; - dfx_orbit.station.approve(self.request_id, None).await?; + dfx_core::cli::ask_for_consent( + "Verification successful, approve the request?", + )?; + dfx_orbit + .station + .approve(self.request_id.clone(), None) + .await?; + } else { + println!("Verification successful!"); } } Err(err) => { - println!("Verification failed: {err}"); if self.or_reject { - dfx_core::cli::ask_for_consent("Do you want to reject the request?")?; - dfx_orbit.station.reject(self.request_id, None).await?; + dfx_core::cli::ask_for_consent(&format!( + "Verification failed: {err}. Reject the request?" + ))?; + dfx_orbit + .station + .reject(self.request_id.clone(), None) + .await?; + } else { + println!("Verification failed!"); }; return Err(err); } - } + }; Ok(()) } } + +#[derive(Debug, Clone, Subcommand)] +pub enum VerifyArgsAction { + /// Manage assets stored in an asset canister through Orbit + Asset(VerifyAssetArgs), + /// Request canister operations through Orbit + Canister(VerifyCanisterArgs), +} diff --git a/tools/dfx-orbit/src/args/verify/asset.rs b/tools/dfx-orbit/src/args/verify/asset.rs index 0d1dc0c68..9902f06ad 100644 --- a/tools/dfx-orbit/src/args/verify/asset.rs +++ b/tools/dfx-orbit/src/args/verify/asset.rs @@ -1,4 +1,7 @@ -use crate::DfxOrbit; +use crate::{ + args::request::asset::{RequestAssetCancelUploadArgs, RequestAssetPermissionArgs}, + DfxOrbit, +}; use candid::Nat; use clap::{Parser, Subcommand}; use station_api::GetRequestResponse; @@ -7,25 +10,30 @@ use station_api::GetRequestResponse; pub struct VerifyAssetArgs { /// The operation to verify #[clap(subcommand)] - pub(crate) action: VerifyAssetActionArgs, + pub action: VerifyAssetActionArgs, } #[derive(Debug, Clone, Subcommand)] #[clap(version, about, long_about = None)] pub enum VerifyAssetActionArgs { - // TODO: Verify Request Permission + /// Request to grant a user permissions for an asset canister + Permission(RequestAssetPermissionArgs), /// Upload assets to an asset canister Upload(VerifyAssetUploadArgs), + /// Cancel an uppload + CancelUpload(RequestAssetCancelUploadArgs), } impl VerifyAssetArgs { pub(crate) async fn verify( - self, + &self, dfx_orbit: &DfxOrbit, request: &GetRequestResponse, ) -> anyhow::Result<()> { - match self.action { + match &self.action { VerifyAssetActionArgs::Upload(args) => args.verify(dfx_orbit, request).await?, + VerifyAssetActionArgs::Permission(args) => args.verify(dfx_orbit, request)?, + VerifyAssetActionArgs::CancelUpload(args) => args.verify(dfx_orbit, request)?, } Ok(()) @@ -35,13 +43,16 @@ impl VerifyAssetArgs { #[derive(Debug, Clone, Parser)] pub struct VerifyAssetUploadArgs { /// The name of the asset canister targeted by this action - pub(crate) canister: String, + pub canister: String, /// The batch ID to commit to - pub(crate) batch_id: Nat, + #[clap(short, long)] + pub batch_id: Nat, - /// The source directories of the asset upload (multiple values possible) - pub(crate) files: Vec, + /// The source directories to upload + /// (multiple values possible, picks up sources from dfx.json by default) + #[clap(short, long)] + pub files: Vec, } impl VerifyAssetUploadArgs { diff --git a/tools/dfx-orbit/src/args/verify/canister.rs b/tools/dfx-orbit/src/args/verify/canister.rs index 1d15fdc94..be397cf7b 100644 --- a/tools/dfx-orbit/src/args/verify/canister.rs +++ b/tools/dfx-orbit/src/args/verify/canister.rs @@ -2,7 +2,9 @@ use clap::{Parser, Subcommand}; use station_api::GetRequestResponse; use crate::{ - args::request::canister::{RequestCanisterCallArgs, RequestCanisterInstallArgs}, + args::request::canister::{ + RequestCanisterCallArgs, RequestCanisterInstallArgs, RequestCanisterUpdateSettingsArgs, + }, DfxOrbit, }; @@ -10,7 +12,7 @@ use crate::{ pub struct VerifyCanisterArgs { /// The operation to verify #[clap(subcommand)] - pub(crate) action: VerifyCanisterActionArgs, + pub action: VerifyCanisterActionArgs, } #[derive(Debug, Clone, Subcommand)] @@ -18,19 +20,24 @@ pub struct VerifyCanisterArgs { pub enum VerifyCanisterActionArgs { /// Verify upgrade the canister wasm Install(RequestCanisterInstallArgs), - ///Verify call a canister method + /// Verify call a canister method Call(RequestCanisterCallArgs), + /// Verify an update settings request + UpdateSettings(RequestCanisterUpdateSettingsArgs), } impl VerifyCanisterArgs { - pub(crate) fn verify( - self, + pub(crate) async fn verify( + &self, dfx_orbit: &DfxOrbit, request: &GetRequestResponse, ) -> anyhow::Result<()> { - match self.action { + match &self.action { VerifyCanisterActionArgs::Install(args) => args.verify(dfx_orbit, request)?, VerifyCanisterActionArgs::Call(args) => args.verify(dfx_orbit, request)?, + VerifyCanisterActionArgs::UpdateSettings(args) => { + args.verify(dfx_orbit, request).await? + } } Ok(()) diff --git a/tools/dfx-orbit/src/cli.rs b/tools/dfx-orbit/src/cli.rs index 05ae6ac67..4a287575c 100644 --- a/tools/dfx-orbit/src/cli.rs +++ b/tools/dfx-orbit/src/cli.rs @@ -11,6 +11,7 @@ use crate::{ }; use anyhow::Context; use slog::trace; +use station_api::GetRequestInput; /// A command line tool for interacting with Orbit on the Internet Computer. pub async fn exec(args: DfxOrbitArgs) -> anyhow::Result<()> { @@ -48,16 +49,30 @@ pub async fn exec(args: DfxOrbitArgs) -> anyhow::Result<()> { DfxOrbitSubcommands::Request(request_args) => { let request = dfx_orbit .station - .request(request_args.into_create_request_input(&dfx_orbit).await?) + .request(request_args.into_request(&dfx_orbit).await?) .await?; dfx_orbit.print_create_request_info(&request); Ok(()) } DfxOrbitSubcommands::Verify(verify_args) => { - verify_args.verify(&dfx_orbit).await?; + let request = dfx_orbit + .station + .review_id(GetRequestInput { + request_id: verify_args.request_id.clone(), + }) + .await?; + + println!( + "{}", + dfx_orbit.display_get_request_response(request.clone())? + ); + + let verified = verify_args.verify(&dfx_orbit, &request).await; + verify_args + .conditionally_execute_actions(&dfx_orbit, verified) + .await?; - println!("Request passes verification"); Ok(()) } DfxOrbitSubcommands::Review(review_args) => dfx_orbit.exec_review(review_args).await, diff --git a/tools/dfx-orbit/src/cli/asset/upload.rs b/tools/dfx-orbit/src/cli/asset/upload.rs index 38a4fb90e..cd969b7ad 100644 --- a/tools/dfx-orbit/src/cli/asset/upload.rs +++ b/tools/dfx-orbit/src/cli/asset/upload.rs @@ -2,7 +2,7 @@ use super::AssetAgent; use crate::DfxOrbit; use anyhow::bail; use candid::{Nat, Principal}; -use ic_certified_assets::types::CommitProposedBatchArguments; +use ic_certified_assets::types::{CommitProposedBatchArguments, DeleteBatchArguments}; use serde_bytes::ByteBuf; use slog::{info, warn}; use station_api::{CallExternalCanisterOperationInput, CanisterMethodDTO, RequestOperationInput}; @@ -58,6 +58,26 @@ impl DfxOrbit { }, )) } + + pub fn cancel_batch_input( + canister_id: Principal, + batch_id: Nat, + ) -> anyhow::Result { + let args = DeleteBatchArguments { batch_id }; + let arg = candid::encode_one(args)?; + + Ok(RequestOperationInput::CallExternalCanister( + CallExternalCanisterOperationInput { + validation_method: None, + execution_method: CanisterMethodDTO { + canister_id, + method_name: String::from("delete_batch"), + }, + arg: Some(arg), + execution_method_cycles: None, + }, + )) + } } impl AssetAgent<'_> { diff --git a/tools/dfx-orbit/src/cli/asset/util.rs b/tools/dfx-orbit/src/cli/asset/util.rs index e907bd12b..bbcfbd33d 100644 --- a/tools/dfx-orbit/src/cli/asset/util.rs +++ b/tools/dfx-orbit/src/cli/asset/util.rs @@ -1,35 +1,9 @@ use crate::DfxOrbit; use anyhow::bail; -use candid::Principal; use dfx_core::config::model::dfinity::CanisterTypeProperties; -use ic_certified_assets::types::{GrantPermissionArguments, Permission}; -use station_api::{CallExternalCanisterOperationInput, CanisterMethodDTO, RequestOperationInput}; use std::path::{Path, PathBuf}; impl DfxOrbit { - pub fn grant_permission_request( - asset_canister: Principal, - to_principal: Principal, - ) -> anyhow::Result { - let args = GrantPermissionArguments { - to_principal, - permission: Permission::Prepare, - }; - let arg = candid::encode_one(args)?; - - Ok(RequestOperationInput::CallExternalCanister( - CallExternalCanisterOperationInput { - validation_method: None, - execution_method: CanisterMethodDTO { - canister_id: asset_canister, - method_name: String::from("grant_permission"), - }, - arg: Some(arg), - execution_method_cycles: None, - }, - )) - } - pub fn as_path_bufs(&self, canister: &str, paths: &[String]) -> anyhow::Result> { if paths.is_empty() { let canister_config = self.get_canister_config(canister)?; diff --git a/tools/dfx-orbit/src/cli/review.rs b/tools/dfx-orbit/src/cli/review.rs index 81cb6118e..5d1dcfe02 100644 --- a/tools/dfx-orbit/src/cli/review.rs +++ b/tools/dfx-orbit/src/cli/review.rs @@ -57,10 +57,10 @@ impl DfxOrbit { self.station.submit(submit).await?; info!(self.logger, "Submitted response"); }; - } else { + } else if args.approve.is_some() || args.reject.is_some() { warn!( self.logger, - "Can not approve/reject the request since it has already completed", + "Can't approve/reject request. Only requests that are pending can be approved or rejected.", ); } diff --git a/tools/dfx-orbit/src/cli/review/display.rs b/tools/dfx-orbit/src/cli/review/display.rs index 761873ef1..b123a0b2f 100644 --- a/tools/dfx-orbit/src/cli/review/display.rs +++ b/tools/dfx-orbit/src/cli/review/display.rs @@ -1,16 +1,22 @@ use crate::DfxOrbit; use candid::Principal; -use itertools::Itertools; use station_api::{ CallExternalCanisterOperationDTO, CanisterInstallMode, ChangeExternalCanisterOperationDTO, - GetRequestResponse, ListRequestsResponse, RequestOperationDTO, RequestStatusDTO, + EvaluatedRequestPolicyRuleDTO, EvaluationStatusDTO, GetRequestResponse, ListRequestsResponse, + RequestAdditionalInfoDTO, RequestApprovalDTO, RequestApprovalStatusDTO, RequestDTO, + RequestOperationDTO, RequestStatusDTO, +}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::Write, }; -use std::{collections::HashMap, fmt::Write}; use tabled::{ settings::{Settings, Style}, Table, }; +// TODO: Factor this out into multiple files + impl DfxOrbit { pub(crate) fn display_list(&self, data: ListRequestsResponse) -> String { let add_info = data @@ -28,9 +34,8 @@ impl DfxOrbit { .map(|add_info| add_info.requester_name.clone()) .unwrap_or(String::from("-")), request.title.clone(), - self.display_request_operation(&request.operation) - .to_string(), - self.display_request_status(&request.status).to_string(), + display_request_operation(&request.operation).to_string(), + display_request_status(&request.status).to_string(), ] }); let titled_iter = std::iter::once([ @@ -63,28 +68,28 @@ impl DfxOrbit { writeln!( output, "Operation: {}", - self.display_request_operation(&base_info.operation) + display_request_operation(&base_info.operation) + )?; + writeln!( + output, + "Request URL: {}", + self.station.request_url(&base_info.id) )?; writeln!(output, "Title: {}", base_info.title)?; - if let Some(summary) = base_info.summary { + if let Some(ref summary) = base_info.summary { writeln!(output, "Summary: {}", summary)? } writeln!(output, "Requested by: {}", add_info.requester_name)?; + + display_poll_state_overiew(&mut output, &base_info, &add_info)?; + display_approvers_and_rejectors(&mut output, &base_info, &add_info)?; + writeln!( output, - "Approved by: {}", - add_info - .approvers - .into_iter() - .map(|approver| format!("\n\t{}", approver.name)) - .join("") - )?; - writeln!( - output, - "Status: {}", - self.display_request_status(&base_info.status) + "Execution Status: {}", + display_request_status(&base_info.status) )?; - if let Some(additional_status) = self.display_additional_stats_info(&base_info.status) { + if let Some(additional_status) = display_additional_stats_info(&base_info.status) { writeln!(output, "{}", additional_status)?; } @@ -177,57 +182,203 @@ impl DfxOrbit { None => format!("{}", canister_id), } } +} + +fn display_approvers_and_rejectors( + writer: &mut W, + base_info: &RequestDTO, + add_info: &RequestAdditionalInfoDTO, +) -> anyhow::Result<()> { + let usernames: BTreeMap = add_info + .approvers + .iter() + .map(|user| (user.id.clone(), user.name.clone())) + .collect(); + + let (approvers, rejectors): (Vec<_>, Vec<_>) = base_info + .approvals + .iter() + .partition(|approval| approval.status == RequestApprovalStatusDTO::Approved); + + if !approvers.is_empty() { + write!(writer, "Approved by: ")?; + display_request_approvals(writer, approvers, &usernames)?; + } + if !rejectors.is_empty() { + write!(writer, "Rejected by: ")?; + display_request_approvals(writer, rejectors, &usernames)?; + } + + Ok(()) +} + +fn display_poll_state_overiew( + writer: &mut W, + base_info: &RequestDTO, + add_info: &RequestAdditionalInfoDTO, +) -> anyhow::Result<()> { + let Some(evaluation_result) = &add_info.evaluation_result else { + return Ok(()); + }; + + let approval_status: BTreeMap = base_info + .approvals + .iter() + .map(|approval| (approval.approver_id.clone(), approval.status.clone())) + .collect(); + + for result in &evaluation_result.policy_results { + let status = match result.status { + EvaluationStatusDTO::Approved => "Approved", + EvaluationStatusDTO::Rejected => "Rejected", + EvaluationStatusDTO::Pending => "Pending", + }; + writeln!(writer, "Poll State: {status}")?; + + display_evaluated_rule(writer, &result.evaluated_rule, &approval_status)?; + } + + Ok(()) +} - fn display_request_operation(&self, op: &RequestOperationDTO) -> &'static str { - match op { - RequestOperationDTO::Transfer(_) => "Transfer", - RequestOperationDTO::AddAccount(_) => "AddAccount", - RequestOperationDTO::EditAccount(_) => "EditAccount", - RequestOperationDTO::AddAddressBookEntry(_) => "AddAddressBookEntry", - RequestOperationDTO::EditAddressBookEntry(_) => "EditAddressBookEntry", - RequestOperationDTO::RemoveAddressBookEntry(_) => "RemoveAddressBookEntry", - RequestOperationDTO::AddUser(_) => "AddUser", - RequestOperationDTO::EditUser(_) => "EditUser", - RequestOperationDTO::AddUserGroup(_) => "AddUserGroup", - RequestOperationDTO::EditUserGroup(_) => "EditUserGroup", - RequestOperationDTO::RemoveUserGroup(_) => "RemoveUserGroup", - RequestOperationDTO::SystemUpgrade(_) => "SystemUpgrade", - RequestOperationDTO::SetDisasterRecovery(_) => "SetDisasterRecovery", - RequestOperationDTO::ChangeExternalCanister(_) => "ChangeExternalCanister", - RequestOperationDTO::CreateExternalCanister(_) => "CreateExternalCanister", - RequestOperationDTO::ConfigureExternalCanister(_) => "ConfigureExternalCanister", - RequestOperationDTO::CallExternalCanister(_) => "CallExternalCanister", - RequestOperationDTO::FundExternalCanister(_) => "FundExternalCanister", - RequestOperationDTO::EditPermission(_) => "EditPermission", - RequestOperationDTO::AddRequestPolicy(_) => "AddRequestPolicy", - RequestOperationDTO::EditRequestPolicy(_) => "EditRequestPolicy", - RequestOperationDTO::RemoveRequestPolicy(_) => "RemoveRequestPolicy", - RequestOperationDTO::ManageSystemInfo(_) => "ManageSystemInfo", +fn display_evaluated_rule( + writer: &mut W, + rule: &EvaluatedRequestPolicyRuleDTO, + status: &BTreeMap, +) -> anyhow::Result<()> { + match rule { + EvaluatedRequestPolicyRuleDTO::AutoApproved => { + writeln!(writer, "The request will be auto-approved")? + } + EvaluatedRequestPolicyRuleDTO::QuorumPercentage { + total_possible_approvers, + min_approved, + approvers, + } => display_quorum_state( + writer, + *total_possible_approvers, + *min_approved, + approvers, + status, + )?, + EvaluatedRequestPolicyRuleDTO::Quorum { + total_possible_approvers, + min_approved, + approvers, + } => display_quorum_state( + writer, + *total_possible_approvers, + *min_approved, + approvers, + status, + )?, + EvaluatedRequestPolicyRuleDTO::AllowListedByMetadata { .. } => (), + EvaluatedRequestPolicyRuleDTO::AllowListed => { + writeln!(writer, "The request is allow-listed")? } + EvaluatedRequestPolicyRuleDTO::AnyOf(_rule) => (), + EvaluatedRequestPolicyRuleDTO::AllOf(_rule) => (), + EvaluatedRequestPolicyRuleDTO::Not(_rule) => (), } - fn display_request_status(&self, status: &RequestStatusDTO) -> &'static str { - match status { - RequestStatusDTO::Created => "Created", - RequestStatusDTO::Approved => "Approved", - RequestStatusDTO::Rejected => "Rejected", - RequestStatusDTO::Cancelled { .. } => "Cancelled", - RequestStatusDTO::Scheduled { .. } => "Scheduled", - RequestStatusDTO::Processing { .. } => "Processing", - RequestStatusDTO::Completed { .. } => "Completed", - RequestStatusDTO::Failed { .. } => "Failed", + Ok(()) +} + +fn display_quorum_state( + writer: &mut W, + eligible: usize, + required: usize, + approvers: &[String], + status: &BTreeMap, +) -> anyhow::Result<()> { + write!(writer, "Number of eligible voters: {eligible},")?; + write!(writer, " necessary quorum: {required},")?; + write!(writer, " voted: {},", approvers.len())?; + + let approved = approvers + .iter() + .filter_map(|voter| status.get(voter)) + .filter(|&status| status == &RequestApprovalStatusDTO::Approved) + .count(); + write!(writer, " approved: {approved},")?; + + let rejected = approvers + .iter() + .filter_map(|voter| status.get(voter)) + .filter(|&status| status == &RequestApprovalStatusDTO::Rejected) + .count(); + writeln!(writer, " rejected: {rejected}")?; + + Ok(()) +} + +fn display_request_approvals( + writer: &mut W, + list: Vec<&RequestApprovalDTO>, + usernames: &BTreeMap, +) -> anyhow::Result<()> { + for user in list { + let name = usernames + .get(&user.approver_id) + .unwrap_or(&user.approver_id); + write!(writer, "\n\t{}", name)?; + if let Some(reason) = &user.status_reason { + write!(writer, " (Reason: \"{}\")", reason)?; } } + writeln!(writer)?; + Ok(()) +} - fn display_additional_stats_info(&self, status: &RequestStatusDTO) -> Option { - match status { - RequestStatusDTO::Cancelled { reason } => { - reason.clone().map(|reason| format!("Reason: {}", reason)) - } - RequestStatusDTO::Failed { reason } => { - reason.clone().map(|reason| format!("Reason: {}", reason)) - } - _ => None, +fn display_request_operation(op: &RequestOperationDTO) -> &'static str { + match op { + RequestOperationDTO::Transfer(_) => "Transfer", + RequestOperationDTO::AddAccount(_) => "AddAccount", + RequestOperationDTO::EditAccount(_) => "EditAccount", + RequestOperationDTO::AddAddressBookEntry(_) => "AddAddressBookEntry", + RequestOperationDTO::EditAddressBookEntry(_) => "EditAddressBookEntry", + RequestOperationDTO::RemoveAddressBookEntry(_) => "RemoveAddressBookEntry", + RequestOperationDTO::AddUser(_) => "AddUser", + RequestOperationDTO::EditUser(_) => "EditUser", + RequestOperationDTO::AddUserGroup(_) => "AddUserGroup", + RequestOperationDTO::EditUserGroup(_) => "EditUserGroup", + RequestOperationDTO::RemoveUserGroup(_) => "RemoveUserGroup", + RequestOperationDTO::SystemUpgrade(_) => "SystemUpgrade", + RequestOperationDTO::SetDisasterRecovery(_) => "SetDisasterRecovery", + RequestOperationDTO::ChangeExternalCanister(_) => "ChangeExternalCanister", + RequestOperationDTO::CreateExternalCanister(_) => "CreateExternalCanister", + RequestOperationDTO::ConfigureExternalCanister(_) => "ConfigureExternalCanister", + RequestOperationDTO::CallExternalCanister(_) => "CallExternalCanister", + RequestOperationDTO::FundExternalCanister(_) => "FundExternalCanister", + RequestOperationDTO::EditPermission(_) => "EditPermission", + RequestOperationDTO::AddRequestPolicy(_) => "AddRequestPolicy", + RequestOperationDTO::EditRequestPolicy(_) => "EditRequestPolicy", + RequestOperationDTO::RemoveRequestPolicy(_) => "RemoveRequestPolicy", + RequestOperationDTO::ManageSystemInfo(_) => "ManageSystemInfo", + } +} + +fn display_request_status(status: &RequestStatusDTO) -> &'static str { + match status { + RequestStatusDTO::Created => "Created", + RequestStatusDTO::Approved => "Approved", + RequestStatusDTO::Rejected => "Rejected", + RequestStatusDTO::Cancelled { .. } => "Cancelled", + RequestStatusDTO::Scheduled { .. } => "Scheduled", + RequestStatusDTO::Processing { .. } => "Processing", + RequestStatusDTO::Completed { .. } => "Completed", + RequestStatusDTO::Failed { .. } => "Failed", + } +} + +fn display_additional_stats_info(status: &RequestStatusDTO) -> Option { + match status { + RequestStatusDTO::Cancelled { reason } => { + reason.clone().map(|reason| format!("Reason: {}", reason)) + } + RequestStatusDTO::Failed { reason } => { + reason.clone().map(|reason| format!("Reason: {}", reason)) } + _ => None, } } diff --git a/tools/dfx-orbit/src/cli/station.rs b/tools/dfx-orbit/src/cli/station.rs index 1c48d3d6c..b47b89ace 100644 --- a/tools/dfx-orbit/src/cli/station.rs +++ b/tools/dfx-orbit/src/cli/station.rs @@ -15,7 +15,7 @@ pub fn exec(orbit_agent: OrbitExtensionAgent, args: StationArgs) -> anyhow::Resu .with_context(|| "Failed to add station to local dfx config")?; } StationArgs::List(_list_args) => { - let stations = orbit_agent.list_stations(); + let stations = orbit_agent.list_stations()?; let ans = ListResponse { stations }; // Note: The formatted ans is a sequence of complete lines, so an additional newline, as provided by println, is not needed. print!("{ans}"); diff --git a/tools/dfx-orbit/src/lib.rs b/tools/dfx-orbit/src/lib.rs index 3d0652c7f..29c76472d 100644 --- a/tools/dfx-orbit/src/lib.rs +++ b/tools/dfx-orbit/src/lib.rs @@ -10,7 +10,7 @@ pub mod dfx_extension_api; pub mod local_config; pub mod station_agent; -use anyhow::anyhow; +use anyhow::{anyhow, bail, Context}; use candid::Principal; pub use cli::asset::AssetAgent; use dfx_core::{ @@ -71,8 +71,14 @@ impl DfxOrbit { self.interface.config(), )?; - let canister_id = Principal::from_text(canister_name) - .or_else(|_| canister_id_store.get(canister_name))?; + let canister_id = Principal::from_text(canister_name).or_else(|_| { + canister_id_store.get(canister_name).with_context(|| { + format!( + "Failed to look up principal id for canister named \"{}\"", + canister_name + ) + }) + })?; Ok(canister_id) } @@ -134,4 +140,28 @@ impl DfxOrbit { Ok(canister_config.clone()) } + + pub async fn get_controllers(&self, canister_id: Principal) -> anyhow::Result> { + let blob = self + .interface + .agent() + .read_state_canister_info(canister_id, "controllers") + .await?; + let value: ciborium::Value = ciborium::from_reader(&blob[..])?; + let ciborium::Value::Array(array) = value else { + bail!("Expected an array as result from controllers endpoint") + }; + let result = array + .into_iter() + .map(|value| match value { + ciborium::Value::Bytes(bytes) => Principal::try_from(bytes) + .with_context(|| String::from("Failed to parse principal")), + _ => Err(anyhow!( + "Controllers array contained values that are not bytes" + )), + }) + .collect::, _>>()?; + + Ok(result) + } } diff --git a/tools/dfx-orbit/src/local_config.rs b/tools/dfx-orbit/src/local_config.rs index 487c96698..3ac76e8b1 100644 --- a/tools/dfx-orbit/src/local_config.rs +++ b/tools/dfx-orbit/src/local_config.rs @@ -59,20 +59,21 @@ impl OrbitExtensionAgent { } /// Lists all Orbit stations in the local dfx configuration. - pub fn list_stations(&self) -> Vec { + pub fn list_stations(&self) -> anyhow::Result> { // Get all entries in the station dir that are valid station configs. + let default_station = self.default_station_name()?; let stations_dir = self.stations_dir().expect("Failed to get stations dir"); - stations_dir + let stations = stations_dir .entries() - .expect("Failed to read stations dir") + .with_context(|| "Failed to read stations dir")? // Filter out directory entries that could not be read. (Maybe we have no permissions to access the file or something like that?) .filter_map(|entry| entry.ok()) // Filter out entries that are not files. .filter(|dir_entry| { dir_entry .file_type() - .expect("Failed to get file type") - .is_file() + .map(|entry| entry.is_file()) + .unwrap_or(false) }) // Filter out entries that don't have the .json suffix. Return the filename without the suffix. This is the station name. .filter_map(|dir_entry| { @@ -84,7 +85,14 @@ impl OrbitExtensionAgent { }) // Filter out entries that are not valid station configs. .filter(|station_name| self.station(station_name).is_ok()) - .collect() + // Add a little tick next to the station name if it is the default station + .map(|name| match &default_station { + Some(default_name) if default_name == &name => format!("{} (*)", name), + _ => name, + }) + .collect(); + + Ok(stations) } /// Adds a new Orbit station to the local dfx configuration. diff --git a/tools/dfx-orbit/src/main.rs b/tools/dfx-orbit/src/main.rs index f74ff8f34..5f1de7b4c 100644 --- a/tools/dfx-orbit/src/main.rs +++ b/tools/dfx-orbit/src/main.rs @@ -8,13 +8,15 @@ use tokio::runtime::Builder; fn main() { let args = DfxOrbitArgs::parse(); + //print!("Args: {}", args); let runtime = Builder::new_current_thread() .enable_all() .build() .expect("Unable to create a runtime"); runtime.block_on(async { if let Err(err) = lib::cli::exec(args).await { - println!("Failed to execute command: {}", err) + println!("Failed to execute command: {}", err); + std::process::exit(1); } }); } diff --git a/tools/dfx-orbit/src/station_agent.rs b/tools/dfx-orbit/src/station_agent.rs index a2cb62a58..29e59e2e3 100644 --- a/tools/dfx-orbit/src/station_agent.rs +++ b/tools/dfx-orbit/src/station_agent.rs @@ -123,8 +123,10 @@ impl StationAgent { /// The URL for a request in the Orbit UI. pub fn request_url(&self, request_id: &str) -> String { format!( - "{}/en/settings/requests?reqid={}", - self.config.url, request_id + "{}?reqid={}&sid={}", + self.config.url, + request_id, + self.config.station_id.to_text() ) } }