diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa33bcf152c..da3e0a8f019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: run: python -m pip install psycopg2-binary - name: Run smoketests # Note: clear_database and replication only work in private - run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication + run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication teams - name: Stop containers (Linux) if: always() && runner.os == 'Linux' run: docker compose down diff --git a/Cargo.lock b/Cargo.lock index fa6cb645241..4485b944367 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5804,6 +5804,7 @@ dependencies = [ "itertools 0.12.1", "mimalloc", "percent-encoding", + "pretty_assertions", "regex", "reqwest 0.12.15", "rustyline", @@ -5826,6 +5827,7 @@ dependencies = [ "tar", "tempfile", "termcolor", + "termtree", "thiserror 1.0.69", "tikv-jemalloc-ctl", "tikv-jemallocator", @@ -7137,6 +7139,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-client" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 52ea189d320..380aaa25c8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -267,6 +267,7 @@ tar = "0.4" tempdir = "0.3.7" tempfile = "3.20" termcolor = "1.2.0" +termtree = "0.5.1" thin-vec = "0.2.13" thiserror = "1.0.37" tokio = { version = "1.37", features = ["full"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1ec691324ad..e602139d7e9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -64,6 +64,7 @@ tabled.workspace = true tar.workspace = true tempfile.workspace = true termcolor.workspace = true +termtree.workspace = true thiserror.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true @@ -75,6 +76,9 @@ wasmbin.workspace = true webbrowser.workspace = true clap-markdown.workspace = true +[dev-dependencies] +pretty_assertions.workspace = true + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { workspace = true } tikv-jemalloc-ctl = { workspace = true } diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index e0d5c756137..b05e5d205cd 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -1,7 +1,15 @@ +use std::io; + use crate::common_args; use crate::config::Config; -use crate::util::{add_auth_header_opt, database_identity, get_auth_header}; +use crate::util::{add_auth_header_opt, database_identity, get_auth_header, y_or_n, AuthHeader}; use clap::{Arg, ArgMatches}; +use http::StatusCode; +use itertools::Itertools as _; +use reqwest::Response; +use spacetimedb_client_api_messages::http::{DatabaseDeleteConfirmationResponse, DatabaseTree, DatabaseTreeNode}; +use spacetimedb_lib::Hash; +use tokio::io::AsyncWriteExt as _; pub fn cli() -> clap::Command { clap::Command::new("delete") @@ -22,11 +30,143 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let force = args.get_flag("force"); let identity = database_identity(&config, database, server).await?; - - let builder = reqwest::Client::new().delete(format!("{}/v1/database/{}", config.get_host_url(server)?, identity)); + let host_url = config.get_host_url(server)?; + let request_path = format!("{host_url}/v1/database/{identity}"); let auth_header = get_auth_header(&mut config, false, server, !force).await?; - let builder = add_auth_header_opt(builder, &auth_header); - builder.send().await?.error_for_status()?; + let client = reqwest::Client::new(); + + let response = send_request(&client, &request_path, &auth_header, None).await?; + match response.status() { + StatusCode::PRECONDITION_REQUIRED => { + let confirm = response.json::().await?; + println!("WARNING: Deleting the database {identity} will also delete its children!"); + if !force { + print_database_tree_info(&confirm.database_tree).await?; + } + if y_or_n(force, "Do you want to proceed deleting above databases?")? { + send_request(&client, &request_path, &auth_header, Some(confirm.confirmation_token)) + .await? + .error_for_status()?; + } else { + println!("Aborting"); + } + + Ok(()) + } + StatusCode::OK => Ok(()), + _ => response.error_for_status().map(drop).map_err(Into::into), + } +} + +async fn send_request( + client: &reqwest::Client, + request_path: &str, + auth: &AuthHeader, + confirmation_token: Option, +) -> Result { + let mut builder = client.delete(request_path); + builder = add_auth_header_opt(builder, auth); + if let Some(token) = confirmation_token { + builder = builder.query(&[("token", token)]); + } + builder.send().await +} + +async fn print_database_tree_info(tree: &DatabaseTree) -> io::Result<()> { + tokio::io::stdout() + .write_all(as_termtree(tree).to_string().as_bytes()) + .await +} + +fn as_termtree(tree: &DatabaseTree) -> termtree::Tree { + let mut stack: Vec<(&DatabaseTree, bool)> = vec![]; + stack.push((tree, false)); + + let mut built: Vec> = <_>::default(); + + while let Some((node, visited)) = stack.pop() { + if visited { + let mut term_node = termtree::Tree::new(fmt_tree_node(&node.root)); + term_node.leaves = built.drain(built.len() - node.children.len()..).collect(); + term_node.leaves.reverse(); + built.push(term_node); + } else { + stack.push((node, true)); + stack.extend(node.children.iter().rev().map(|child| (child, false))); + } + } + + built + .pop() + .expect("database tree contains a root and we pushed it last") +} + +fn fmt_tree_node(node: &DatabaseTreeNode) -> String { + format!( + "{}{}", + node.database_identity, + if node.database_names.is_empty() { + <_>::default() + } else { + format!(": {}", node.database_names.iter().join(", ")) + } + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_client_api_messages::http::{DatabaseTree, DatabaseTreeNode}; + use spacetimedb_lib::{sats::u256, Identity}; - Ok(()) + #[test] + fn render_termtree() { + let tree = DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::ONE, + database_names: ["parent".into()].into(), + }, + children: vec![ + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(2)), + database_names: ["child".into()].into(), + }, + children: vec![ + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(3)), + database_names: ["grandchild".into()].into(), + }, + children: vec![], + }, + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(5)), + database_names: [].into(), + }, + children: vec![], + }, + ], + }, + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(4)), + database_names: ["sibling".into(), "bro".into()].into(), + }, + children: vec![], + }, + ], + }; + pretty_assertions::assert_eq!( + "\ +0000000000000000000000000000000000000000000000000000000000000001: parent +├── 0000000000000000000000000000000000000000000000000000000000000004: bro, sibling +└── 0000000000000000000000000000000000000000000000000000000000000002: child + ├── 0000000000000000000000000000000000000000000000000000000000000005 + └── 0000000000000000000000000000000000000000000000000000000000000003: grandchild +", + &as_termtree(&tree).to_string() + ); + } } diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index bfaa96b17c6..0c96c27940e 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -1,9 +1,10 @@ +use anyhow::{ensure, Context}; use clap::Arg; use clap::ArgAction::{Set, SetTrue}; use clap::ArgMatches; use reqwest::{StatusCode, Url}; use spacetimedb_client_api_messages::name::{is_identity, parse_database_name, PublishResult}; -use spacetimedb_client_api_messages::name::{PrePublishResult, PrettyPrintStyle, PublishOp}; +use spacetimedb_client_api_messages::name::{DatabaseNameError, PrePublishResult, PrettyPrintStyle, PublishOp}; use std::path::PathBuf; use std::{env, fs}; @@ -75,6 +76,17 @@ pub fn cli() -> clap::Command { .arg( common_args::anonymous() ) + .arg( + Arg::new("parent") + .help("Domain or identity of a parent for this database") + .long("parent") + .long_help( +"A valid domain or identity of an existing database that should be the parent of this database. + +If a parent is given, the new database inherits the team permissions from the parent. +A parent can only be set when a database is created, not when it is updated." + ) + ) .arg( Arg::new("name|identity") .help("A valid domain or identity for this database") @@ -106,6 +118,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let build_options = args.get_one::("build_options").unwrap(); let num_replicas = args.get_one::("num_replicas"); let break_clients_flag = args.get_flag("break_clients"); + let parent = args.get_one::("parent"); // If the user didn't specify an identity and we didn't specify an anonymous identity, then // we want to use the default identity @@ -113,6 +126,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // easily create a new identity with an email let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; + let (name_or_identity, parent) = + validate_name_and_parent(name_or_identity.map(String::as_str), parent.map(String::as_str))?; + if !path_to_project.exists() { return Err(anyhow::anyhow!( "Project path does not exist: {}", @@ -153,14 +169,11 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E ); let client = reqwest::Client::new(); - // If a domain or identity was provided, we should locally make sure it looks correct and + // If a name was given, ensure to percent-encode it. + // We also use PUT with a name or identity, and POST otherwise. let mut builder = if let Some(name_or_identity) = name_or_identity { - if !is_identity(name_or_identity) { - parse_database_name(name_or_identity)?; - } let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') }; let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set); - let mut builder = client.put(format!("{database_host}/v1/database/{domain}")); if !clear_database { @@ -204,6 +217,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E eprintln!("WARNING: Use of unstable option `--num-replicas`.\n"); builder = builder.query(&[("num_replicas", *n)]); } + if let Some(parent) = parent { + builder = builder.query(&[("parent", parent)]); + } println!("Publishing module..."); @@ -263,6 +279,47 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E Ok(()) } +fn validate_name_or_identity(name_or_identity: &str) -> Result<(), DatabaseNameError> { + if is_identity(name_or_identity) { + Ok(()) + } else { + parse_database_name(name_or_identity).map(drop) + } +} + +fn invalid_parent_name(name: &str) -> String { + format!("invalid parent database name `{name}`") +} + +fn validate_name_and_parent<'a>( + name: Option<&'a str>, + parent: Option<&'a str>, +) -> anyhow::Result<(Option<&'a str>, Option<&'a str>)> { + if let Some(parent) = parent.as_ref() { + validate_name_or_identity(parent).with_context(|| invalid_parent_name(parent))?; + } + + match name { + Some(name) => match name.split_once('/') { + Some((parent_alt, child)) => { + ensure!( + parent.is_none() || parent.is_some_and(|parent| parent == parent_alt), + "cannot specify both --parent and /" + ); + validate_name_or_identity(parent_alt).with_context(|| invalid_parent_name(parent_alt))?; + validate_name_or_identity(child)?; + + Ok((Some(child), Some(parent_alt))) + } + None => { + validate_name_or_identity(name)?; + Ok((Some(name), parent)) + } + }, + None => Ok((None, parent)), + } +} + /// Determine the pretty print style based on the NO_COLOR environment variable. /// /// See: https://no-color.org diff --git a/crates/client-api-messages/src/http.rs b/crates/client-api-messages/src/http.rs index fe966bf5dfc..02cc75968e6 100644 --- a/crates/client-api-messages/src/http.rs +++ b/crates/client-api-messages/src/http.rs @@ -1,6 +1,9 @@ +use std::collections::BTreeSet; +use std::iter; + use serde::{Deserialize, Serialize}; use spacetimedb_lib::metrics::ExecutionMetrics; -use spacetimedb_lib::ProductType; +use spacetimedb_lib::{Hash, Identity, ProductType}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqlStmtResult { @@ -27,3 +30,34 @@ impl SqlStmtStats { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DatabaseTree { + pub root: DatabaseTreeNode, + pub children: Vec, +} + +impl DatabaseTree { + pub fn iter(&self) -> impl Iterator + '_ { + let mut stack = vec![self]; + iter::from_fn(move || { + let node = stack.pop()?; + for child in node.children.iter().rev() { + stack.push(child); + } + Some(&node.root) + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DatabaseTreeNode { + pub database_identity: Identity, + pub database_names: BTreeSet, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DatabaseDeleteConfirmationResponse { + pub database_tree: DatabaseTree, + pub confirmation_token: Hash, +} diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index fd426bb154c..86096392dfc 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -169,6 +169,7 @@ pub struct DatabaseDef { pub num_replicas: Option, /// The host type of the supplied program. pub host_type: HostType, + pub parent: Option, } /// API of the SpacetimeDB control plane. @@ -239,6 +240,7 @@ pub trait ControlStateWriteAccess: Send + Sync { async fn migrate_plan(&self, spec: DatabaseDef, style: PrettyPrintStyle) -> anyhow::Result; async fn delete_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()>; + async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()>; // Energy async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()>; @@ -339,6 +341,10 @@ impl ControlStateWriteAccess for Arc { (**self).delete_database(caller_identity, database_identity).await } + async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { + (**self).clear_database(caller_identity, database_identity).await + } + async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()> { (**self).add_energy(identity, amount).await } diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index ea056f57b2d..0de0617e849 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; +use std::env; use std::num::NonZeroU8; use std::str::FromStr; use std::time::Duration; @@ -20,24 +22,47 @@ use http::StatusCode; use serde::Deserialize; use spacetimedb::database_logger::DatabaseLogger; use spacetimedb::host::module_host::ClientConnectedError; -use spacetimedb::host::ReducerCallError; use spacetimedb::host::ReducerOutcome; -use spacetimedb::host::UpdateDatabaseResult; use spacetimedb::host::{MigratePlanResult, ReducerArgs}; +use spacetimedb::host::{ReducerCallError, UpdateDatabaseResult}; use spacetimedb::identity::Identity; use spacetimedb::messages::control_db::{Database, HostType}; +use spacetimedb_client_api_messages::http::SqlStmtResult; use spacetimedb_client_api_messages::name::{ self, DatabaseName, DomainName, MigrationPolicy, PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult, }; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; use spacetimedb_lib::identity::AuthCtx; -use spacetimedb_lib::{sats, ProductValue, Timestamp}; +use spacetimedb_lib::{sats, Hash, ProductValue, Timestamp}; use spacetimedb_schema::auto_migrate::{ MigrationPolicy as SchemaMigrationPolicy, MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle, }; use super::subscribe::{handle_websocket, HasWebSocketOptions}; +fn require_spacetime_auth_for_creation() -> bool { + env::var("TEMP_REQUIRE_SPACETIME_AUTH").is_ok_and(|v| !v.is_empty()) +} + +// A hacky function to let us restrict database creation on maincloud. +fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { + if !require_spacetime_auth_for_creation() { + return Ok(()); + } + if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { + Ok(()) + } else { + log::trace!( + "Rejecting creation request because auth issuer is {}", + auth.claims.issuer + ); + Err(( + StatusCode::UNAUTHORIZED, + "To create a database, you must be logged in with a SpacetimeDB account.", + ) + .into()) + } +} #[derive(Deserialize)] pub struct CallParams { name_or_identity: NameOrIdentity, @@ -497,38 +522,12 @@ pub struct PublishDatabaseQueryParams { /// /// Users obtain such a hash via the `/database/:name_or_identity/pre-publish POST` route. /// This is a safeguard to require explicit approval for updates which will break clients. - token: Option, + token: Option, #[serde(default)] policy: MigrationPolicy, #[serde(default)] host_type: HostType, -} - -use spacetimedb_client_api_messages::http::SqlStmtResult; -use std::env; - -fn require_spacetime_auth_for_creation() -> bool { - env::var("TEMP_REQUIRE_SPACETIME_AUTH").is_ok_and(|v| !v.is_empty()) -} - -// A hacky function to let us restrict database creation on maincloud. -fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { - if !require_spacetime_auth_for_creation() { - return Ok(()); - } - if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { - Ok(()) - } else { - log::trace!( - "Rejecting creation request because auth issuer is {}", - auth.claims.issuer - ); - Err(( - StatusCode::UNAUTHORIZED, - "To create a database, you must be logged in with a SpacetimeDB account.", - ) - .into()) - } + parent: Option, } pub async fn publish( @@ -540,6 +539,7 @@ pub async fn publish( token, policy, host_type, + parent, }): Query, Extension(auth): Extension, body: Bytes, @@ -556,103 +556,48 @@ pub async fn publish( .into()); } - // You should not be able to publish to a database that you do not own - // so, unless you are the owner, this will fail. - - let (database_identity, db_name) = match &name_or_identity { - Some(noa) => match noa.try_resolve(&ctx).await.map_err(log_and_500)? { - Ok(resolved) => (resolved, noa.name()), - Err(name) => { - // `name_or_identity` was a `NameOrIdentity::Name`, but no record - // exists yet. Create it now with a fresh identity. - allow_creation(&auth)?; - let database_auth = SpacetimeAuth::alloc(&ctx).await?; - let database_identity = database_auth.claims.identity; - let tld: name::Tld = name.clone().into(); - let tld = match ctx - .register_tld(&auth.claims.identity, tld) - .await - .map_err(log_and_500)? - { - name::RegisterTldResult::Success { domain } - | name::RegisterTldResult::AlreadyRegistered { domain } => domain, - name::RegisterTldResult::Unauthorized { .. } => { - return Err(( - StatusCode::UNAUTHORIZED, - axum::Json(PublishResult::PermissionDenied { name: name.clone() }), - ) - .into()) - } - }; - let res = ctx - .create_dns_record(&auth.claims.identity, &tld.into(), &database_identity) - .await - .map_err(log_and_500)?; - match res { - name::InsertDomainResult::Success { .. } => {} - name::InsertDomainResult::TldNotRegistered { .. } - | name::InsertDomainResult::PermissionDenied { .. } => { - return Err(log_and_500("impossible: we just registered the tld")) - } - name::InsertDomainResult::OtherError(e) => return Err(log_and_500(e)), - } - (database_identity, Some(name)) - } - }, - None => { - let database_auth = SpacetimeAuth::alloc(&ctx).await?; - let database_identity = database_auth.claims.identity; - (database_identity, None) - } - }; - - let policy: SchemaMigrationPolicy = match policy { - MigrationPolicy::BreakClients => { - if let Some(token) = token { - Ok(SchemaMigrationPolicy::BreakClients(token)) - } else { - Err(( - StatusCode::BAD_REQUEST, - "Migration policy is set to `BreakClients`, but no migration token was provided.", - )) - } - } - - MigrationPolicy::Compatible => Ok(SchemaMigrationPolicy::Compatible), - }?; + let (database_identity, db_name) = get_or_create_identity_and_name(&ctx, &auth, name_or_identity.as_ref()).await?; log::trace!("Publishing to the identity: {}", database_identity.to_hex()); - let op = { - let exists = ctx - .get_database_by_identity(&database_identity) - .map_err(log_and_500)? - .is_some(); - if !exists { - allow_creation(&auth)?; - } - - if clear && exists { - ctx.delete_database(&auth.claims.identity, &database_identity) - .await - .map_err(log_and_500)?; - } - - if exists { - PublishOp::Updated - } else { - PublishOp::Created - } - }; - + // Check if the database already exists. + let exists = ctx + .get_database_by_identity(&database_identity) + .map_err(log_and_500)? + .is_some(); + // If not, check that the we caller is sufficiently authenticated. + if !exists { + allow_creation(&auth)?; + } + // If the `clear` flag was given, clear the database if it exists. + // NOTE: The `clear_database` method has to check authorization. + if clear && exists { + ctx.clear_database(&auth.claims.identity, &database_identity) + .await + .map_err(log_and_500)?; + } + // Indicate in the response whether we created or updated the database. + let publish_op = if exists { PublishOp::Updated } else { PublishOp::Created }; + // Check that the replication factor looks somewhat sane. let num_replicas = num_replicas .map(|n| { - let n = u8::try_from(n).map_err(|_| (StatusCode::BAD_REQUEST, "Replication factor {n} out of bounds"))?; + let n = u8::try_from(n).map_err(|_| bad_request(format!("Replication factor {n} out of bounds").into()))?; Ok::<_, ErrorResponse>(NonZeroU8::new(n)) }) .transpose()? .flatten(); + // If a parent is given, resolve to an existing database. + let parent = if let Some(name_or_identity) = parent { + let identity = name_or_identity + .resolve(&ctx) + .await + .map_err(|_| bad_request(format!("Parent database {name_or_identity} not found").into()))?; + Some(identity) + } else { + None + }; + let schema_migration_policy = schema_migration_policy(policy, token)?; let maybe_updated = ctx .publish_database( &auth.claims.identity, @@ -661,35 +606,118 @@ pub async fn publish( program_bytes: body.into(), num_replicas, host_type, + parent, }, - policy, + schema_migration_policy, ) .await .map_err(log_and_500)?; - if let Some(updated) = maybe_updated { - match updated { - UpdateDatabaseResult::AutoMigrateError(errs) => { - return Err((StatusCode::BAD_REQUEST, format!("Database update rejected: {errs}")).into()); - } - UpdateDatabaseResult::ErrorExecutingMigration(err) => { - return Err(( - StatusCode::BAD_REQUEST, - format!("Failed to create or update the database: {err}"), - ) - .into()); - } + match maybe_updated { + Some(UpdateDatabaseResult::AutoMigrateError(errs)) => { + Err(bad_request(format!("Database update rejected: {errs}").into())) + } + Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(bad_request( + format!("Failed to create or update the database: {err}").into(), + )), + None + | Some( UpdateDatabaseResult::NoUpdateNeeded | UpdateDatabaseResult::UpdatePerformed - | UpdateDatabaseResult::UpdatePerformedWithClientDisconnect => {} + | UpdateDatabaseResult::UpdatePerformedWithClientDisconnect, + ) => Ok(axum::Json(PublishResult::Success { + domain: db_name.cloned(), + database_identity, + op: publish_op, + })), + } +} + +/// Try to resolve `name_or_identity` to an [Identity] and [DatabaseName]. +/// +/// - If the database exists and has a name registered for it, return that. +/// - If the database does not exist, but `name_or_identity` is a name, +/// try to register the name and return alongside a newly allocated [Identity] +/// - Otherwise, if the database does not exist and `name_or_identity` is `None`, +/// allocate a fresh [Identity] and no name. +/// +async fn get_or_create_identity_and_name<'a>( + ctx: &(impl ControlStateDelegate + NodeDelegate), + auth: &SpacetimeAuth, + name_or_identity: Option<&'a NameOrIdentity>, +) -> axum::response::Result<(Identity, Option<&'a DatabaseName>)> { + match name_or_identity { + Some(noi) => match noi.try_resolve(ctx).await.map_err(log_and_500)? { + Ok(resolved) => Ok((resolved, noi.name())), + Err(name) => { + // `name_or_identity` was a `NameOrIdentity::Name`, but no record + // exists yet. Create it now with a fresh identity. + allow_creation(auth)?; + let database_auth = SpacetimeAuth::alloc(ctx).await?; + let database_identity = database_auth.claims.identity; + create_name(ctx, auth, &database_identity, name).await?; + Ok((database_identity, Some(name))) + } + }, + None => { + let database_auth = SpacetimeAuth::alloc(ctx).await?; + let database_identity = database_auth.claims.identity; + Ok((database_identity, None)) } } +} - Ok(axum::Json(PublishResult::Success { - domain: db_name.cloned(), - database_identity, - op, - })) +/// Try to register `name` for database `database_identity`. +async fn create_name( + ctx: &(impl NodeDelegate + ControlStateDelegate), + auth: &SpacetimeAuth, + database_identity: &Identity, + name: &DatabaseName, +) -> axum::response::Result<()> { + let tld: name::Tld = name.clone().into(); + let tld = match ctx + .register_tld(&auth.claims.identity, tld) + .await + .map_err(log_and_500)? + { + name::RegisterTldResult::Success { domain } | name::RegisterTldResult::AlreadyRegistered { domain } => domain, + name::RegisterTldResult::Unauthorized { .. } => { + return Err(( + StatusCode::UNAUTHORIZED, + axum::Json(PublishResult::PermissionDenied { name: name.clone() }), + ) + .into()) + } + }; + let res = ctx + .create_dns_record(&auth.claims.identity, &tld.into(), database_identity) + .await + .map_err(log_and_500)?; + match res { + name::InsertDomainResult::Success { .. } => Ok(()), + name::InsertDomainResult::TldNotRegistered { .. } | name::InsertDomainResult::PermissionDenied { .. } => { + Err(log_and_500("impossible: we just registered the tld")) + } + name::InsertDomainResult::OtherError(e) => Err(log_and_500(e)), + } +} + +fn schema_migration_policy( + policy: MigrationPolicy, + token: Option, +) -> axum::response::Result { + const MISSING_TOKEN: &str = "Migration policy is set to `BreakClients`, but no migration token was provided."; + + match policy { + MigrationPolicy::BreakClients => token + .map(SchemaMigrationPolicy::BreakClients) + .ok_or_else(|| bad_request(MISSING_TOKEN.into())), + MigrationPolicy::Compatible => Ok(SchemaMigrationPolicy::Compatible), + } +} + +fn bad_request(message: Cow<'static, str>) -> ErrorResponse { + (StatusCode::BAD_REQUEST, message).into() } #[derive(serde::Deserialize)] @@ -724,6 +752,7 @@ pub async fn pre_publish( program_bytes: body.into(), num_replicas: None, host_type: HostType::Wasm, + parent: None, }, style, ) @@ -789,7 +818,7 @@ async fn resolve_and_authenticate( #[derive(Deserialize)] pub struct DeleteDatabaseParams { - name_or_identity: NameOrIdentity, + pub name_or_identity: NameOrIdentity, } pub async fn delete_database( diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 4c33206a5ed..c70a75d1964 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -369,6 +369,28 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { Ok(()) } + async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { + let database = self + .control_db + .get_database_by_identity(database_identity)? + .with_context(|| format!("Database `{database_identity}` does not exist"))?; + + anyhow::ensure!( + &database.owner_identity == caller_identity, + "Permission denied: `{caller_identity}` does not own database `{database_identity}`" + ); + + let mut num_replicas = 0; + for instance in self.control_db.get_replicas_by_database(database.id)? { + self.delete_replica(instance.id).await?; + num_replicas -= 1; + } + + self.schedule_replicas(database.id, num_replicas).await?; + + Ok(()) + } + async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()> { let balance = self .control_db diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index 150171871e9..e33611edb71 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -205,6 +205,7 @@ impl CompiledModule { program_bytes, num_replicas: None, host_type: HostType::Wasm, + parent: None, }, MigrationPolicy::Compatible, ) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 11045a959f1..94860911632 100644 --- a/docs/docs/cli-reference.md +++ b/docs/docs/cli-reference.md @@ -92,6 +92,10 @@ Run `spacetime help publish` for more detailed information. * `-j`, `--js-path ` — UNSTABLE: The system path (absolute or relative) to the javascript file we should publish, instead of building the project. * `--break-clients` — Allow breaking changes when publishing to an existing database identity. This will break existing clients. * `--anonymous` — Perform this action with an anonymous identity +* `--parent ` — A valid domain or identity of an existing database that should be the parent of this database. + + If a parent is given, the new database inherits the team permissions from the parent. + A parent can only be set when a database is created, not when it is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 63486c09b08..2cf3bae62b9 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -365,7 +365,7 @@ def tearDown(self): if "database_identity" in self.__dict__: try: # TODO: save the credentials in publish_module() - self.spacetime("delete", self.database_identity) + self.spacetime("delete", "--yes", self.database_identity) except Exception: pass @@ -374,7 +374,7 @@ def tearDownClass(cls): if hasattr(cls, "database_identity"): try: # TODO: save the credentials in publish_module() - cls.spacetime("delete", cls.database_identity) + cls.spacetime("delete", "--yes", cls.database_identity) except Exception: pass diff --git a/smoketests/tests/teams.py b/smoketests/tests/teams.py new file mode 100644 index 00000000000..2734b147cc5 --- /dev/null +++ b/smoketests/tests/teams.py @@ -0,0 +1,33 @@ +from .. import Smoketest, parse_sql_result, random_string + +class CreateChildDatabase(Smoketest): + AUTOPUBLISH = False + + def test_create_child_database(self): + """ + Test that the owner can add a child database + """ + + parent_name = random_string() + child_name = random_string() + + self.publish_module(parent_name) + parent_identity = self.database_identity + self.publish_module(f"{parent_name}/{child_name}") + child_identity = self.database_identity + + databases = self.query_controldb(parent_identity, child_identity) + self.assertEqual(2, len(databases)) + + self.spacetime("delete", "--yes", parent_name) + + databases = self.query_controldb(parent_identity, child_identity) + self.assertEqual(0, len(databases)) + + def query_controldb(self, parent, child): + res = self.spacetime( + "sql", + "spacetime-control", + f"select * from database where database_identity = 0x{parent} or database_identity = 0x{child}" + ) + return parse_sql_result(str(res))