mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-06 07:26:43 -04:00
Add spacetime lock/unlock to prevent accidental database deletion (#4502)
## Motivation Feature request: "Is there any way we can lock a module to prevent it from being deleted? A bit concerned about some fat finger risk of accidentally deleting prod." ## Solution Adds a database lock mechanism. A locked database cannot be deleted until explicitly unlocked. ### New CLI Commands ```bash # Lock a database to prevent deletion spacetime lock my-database # Attempt to delete a locked database (fails with 403) spacetime delete my-database # Error: Database is locked and cannot be deleted. Run \`spacetime unlock\` first. # Unlock when you actually need to delete spacetime unlock my-database spacetime delete my-database ``` Both commands support `--server` and `--no-config` flags, and resolve the database from `spacetime.json` when no argument is given (same as `spacetime delete`). ### New HTTP API - `POST /v1/database/:name_or_identity/lock` -- Lock a database - `POST /v1/database/:name_or_identity/unlock` -- Unlock a database Both require the same authorization as `DELETE` (owner only). ### Implementation - Lock state stored in a separate `database_locks` sled tree in the standalone control DB (avoids changing the `Database` struct and needing a data migration) - `ControlStateReadAccess::is_database_locked()` and `ControlStateWriteAccess::set_database_lock()` added to the trait - `delete_database` route checks lock state before proceeding; returns `403 Forbidden` with a descriptive message if locked - Locking is idempotent (locking an already-locked database is a no-op, same for unlock) - Lock only prevents deletion, not publishing updates ### What is NOT locked - `spacetime publish` (updating module code) still works on locked databases - Only `spacetime delete` is blocked This matches the intent: protect prod from accidental destruction while allowing normal deployments. --------- Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
7726fe807a
commit
81c9eab86c
@@ -38,6 +38,8 @@ pub fn get_subcommands() -> Vec<Command> {
|
||||
server::cli(),
|
||||
subscribe::cli(),
|
||||
start::cli(),
|
||||
lock::cli(),
|
||||
unlock::cli(),
|
||||
subcommands::version::cli(),
|
||||
]
|
||||
}
|
||||
@@ -67,6 +69,8 @@ pub async fn exec_subcommand(
|
||||
"start" => return start::exec(paths, args).await,
|
||||
"login" => login::exec(config, args).await,
|
||||
"logout" => logout::exec(config, args).await,
|
||||
"lock" => lock::exec(config, args).await,
|
||||
"unlock" => unlock::exec(config, args).await,
|
||||
"version" => return subcommands::version::exec(paths, root_dir, args).await,
|
||||
unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::common_args;
|
||||
use crate::config::Config;
|
||||
use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg};
|
||||
use crate::util::{add_auth_header_opt, database_identity, get_auth_header};
|
||||
use clap::{Arg, ArgMatches};
|
||||
|
||||
pub fn cli() -> clap::Command {
|
||||
clap::Command::new("lock")
|
||||
.about("Lock a database to prevent accidental deletion")
|
||||
.long_about(
|
||||
"Lock a database to prevent it from being deleted.\n\n\
|
||||
A locked database cannot be deleted until it is unlocked with `spacetime unlock`.\n\
|
||||
This is a safety mechanism to protect production databases from accidental deletion.",
|
||||
)
|
||||
.arg(
|
||||
Arg::new("database")
|
||||
.required(false)
|
||||
.help("The name or identity of the database to lock"),
|
||||
)
|
||||
.arg(common_args::server().help("The nickname, host name or URL of the server hosting the database"))
|
||||
.arg(
|
||||
Arg::new("no_config")
|
||||
.long("no-config")
|
||||
.action(clap::ArgAction::SetTrue)
|
||||
.help("Ignore spacetime.json configuration"),
|
||||
)
|
||||
.after_help("Run `spacetime help lock` for more detailed information.\n")
|
||||
}
|
||||
|
||||
pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
|
||||
let server_from_cli = args.get_one::<String>("server").map(|s| s.as_ref());
|
||||
let no_config = args.get_flag("no_config");
|
||||
let database_arg = args.get_one::<String>("database").map(|s| s.as_str());
|
||||
let config_targets = load_config_db_targets(no_config)?;
|
||||
let resolved = resolve_database_arg(
|
||||
database_arg,
|
||||
config_targets.as_deref(),
|
||||
"spacetime lock [database] [--no-config]",
|
||||
)?;
|
||||
let server = server_from_cli.or(resolved.server.as_deref());
|
||||
|
||||
let identity = database_identity(&config, &resolved.database, server).await?;
|
||||
let host_url = config.get_host_url(server)?;
|
||||
let auth_header = get_auth_header(&mut config, false, server, true).await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut builder = client.post(format!("{host_url}/v1/database/{identity}/lock"));
|
||||
builder = add_auth_header_opt(builder, &auth_header);
|
||||
|
||||
let response = builder.send().await?;
|
||||
response.error_for_status()?;
|
||||
|
||||
println!(
|
||||
"Database {} is now locked. It cannot be deleted until unlocked.",
|
||||
identity
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod dns;
|
||||
pub mod generate;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
pub mod lock;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
@@ -17,4 +18,5 @@ pub mod server;
|
||||
pub mod sql;
|
||||
pub mod start;
|
||||
pub mod subscribe;
|
||||
pub mod unlock;
|
||||
pub mod version;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::common_args;
|
||||
use crate::config::Config;
|
||||
use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg};
|
||||
use crate::util::{add_auth_header_opt, database_identity, get_auth_header};
|
||||
use clap::{Arg, ArgMatches};
|
||||
|
||||
pub fn cli() -> clap::Command {
|
||||
clap::Command::new("unlock")
|
||||
.about("Unlock a database to allow deletion")
|
||||
.long_about(
|
||||
"Unlock a database that was previously locked with `spacetime lock`.\n\n\
|
||||
After unlocking, the database can be deleted normally with `spacetime delete`.",
|
||||
)
|
||||
.arg(
|
||||
Arg::new("database")
|
||||
.required(false)
|
||||
.help("The name or identity of the database to unlock"),
|
||||
)
|
||||
.arg(common_args::server().help("The nickname, host name or URL of the server hosting the database"))
|
||||
.arg(
|
||||
Arg::new("no_config")
|
||||
.long("no-config")
|
||||
.action(clap::ArgAction::SetTrue)
|
||||
.help("Ignore spacetime.json configuration"),
|
||||
)
|
||||
.after_help("Run `spacetime help unlock` for more detailed information.\n")
|
||||
}
|
||||
|
||||
pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
|
||||
let server_from_cli = args.get_one::<String>("server").map(|s| s.as_ref());
|
||||
let no_config = args.get_flag("no_config");
|
||||
let database_arg = args.get_one::<String>("database").map(|s| s.as_str());
|
||||
let config_targets = load_config_db_targets(no_config)?;
|
||||
let resolved = resolve_database_arg(
|
||||
database_arg,
|
||||
config_targets.as_deref(),
|
||||
"spacetime unlock [database] [--no-config]",
|
||||
)?;
|
||||
let server = server_from_cli.or(resolved.server.as_deref());
|
||||
|
||||
let identity = database_identity(&config, &resolved.database, server).await?;
|
||||
let host_url = config.get_host_url(server)?;
|
||||
let auth_header = get_auth_header(&mut config, false, server, true).await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut builder = client.post(format!("{host_url}/v1/database/{identity}/unlock"));
|
||||
builder = add_auth_header_opt(builder, &auth_header);
|
||||
|
||||
let response = builder.send().await?;
|
||||
response.error_for_status()?;
|
||||
|
||||
println!("Database {} is now unlocked.", identity);
|
||||
Ok(())
|
||||
}
|
||||
@@ -288,6 +288,9 @@ pub trait ControlStateReadAccess {
|
||||
async fn lookup_database_identity(&self, domain: &str) -> anyhow::Result<Option<Identity>>;
|
||||
async fn reverse_lookup(&self, database_identity: &Identity) -> anyhow::Result<Vec<DomainName>>;
|
||||
async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result<Option<Identity>>;
|
||||
|
||||
// Locks
|
||||
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool>;
|
||||
}
|
||||
|
||||
/// Write operations on the SpacetimeDB control plane.
|
||||
@@ -344,6 +347,14 @@ pub trait ControlStateWriteAccess: Send + Sync {
|
||||
owner_identity: &Identity,
|
||||
domain_names: &[DomainName],
|
||||
) -> anyhow::Result<SetDomainsResult>;
|
||||
|
||||
// Locks
|
||||
async fn set_database_lock(
|
||||
&self,
|
||||
caller_identity: &Identity,
|
||||
database_identity: &Identity,
|
||||
locked: bool,
|
||||
) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -399,6 +410,10 @@ impl<T: ControlStateReadAccess + Send + Sync + Sync + ?Sized> ControlStateReadAc
|
||||
async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result<Option<Identity>> {
|
||||
(**self).lookup_namespace_owner(name).await
|
||||
}
|
||||
|
||||
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool> {
|
||||
(**self).is_database_locked(database_identity).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -454,6 +469,17 @@ impl<T: ControlStateWriteAccess + ?Sized> ControlStateWriteAccess for Arc<T> {
|
||||
.replace_dns_records(database_identity, owner_identity, domain_names)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_database_lock(
|
||||
&self,
|
||||
caller_identity: &Identity,
|
||||
database_identity: &Identity,
|
||||
locked: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
(**self)
|
||||
.set_database_lock(caller_identity, database_identity, locked)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -653,6 +653,14 @@ pub async fn reset<S: NodeDelegate + ControlStateDelegate + Authorization>(
|
||||
ctx.authorize_action(auth.claims.identity, database.database_identity, Action::ResetDatabase)
|
||||
.await?;
|
||||
|
||||
if ctx.is_database_locked(&database_identity).await.map_err(log_and_500)? {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Database is locked and cannot be reset with --delete-data. Run `spacetime unlock` first.",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let num_replicas = num_replicas.map(validate_replication_factor).transpose()?.flatten();
|
||||
ctx.reset_database(
|
||||
&auth.claims.identity,
|
||||
@@ -1053,6 +1061,15 @@ pub async fn delete_database<S: ControlStateDelegate + Authorization>(
|
||||
|
||||
ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase)
|
||||
.await?;
|
||||
|
||||
if ctx.is_database_locked(&database_identity).await.map_err(log_and_500)? {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Database is locked and cannot be deleted. Run `spacetime unlock` first.",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
ctx.delete_database(&auth.claims.identity, &database_identity)
|
||||
.await
|
||||
.map_err(log_and_500)?;
|
||||
@@ -1060,6 +1077,46 @@ pub async fn delete_database<S: ControlStateDelegate + Authorization>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lock_database<S: ControlStateDelegate + Authorization>(
|
||||
State(ctx): State<S>,
|
||||
Path(DeleteDatabaseParams { name_or_identity }): Path<DeleteDatabaseParams>,
|
||||
Extension(auth): Extension<SpacetimeAuth>,
|
||||
) -> axum::response::Result<impl IntoResponse> {
|
||||
let database_identity = name_or_identity.resolve(&ctx).await?;
|
||||
let Some(_database) = worker_ctx_find_database(&ctx, &database_identity).await? else {
|
||||
return Err(StatusCode::NOT_FOUND.into());
|
||||
};
|
||||
|
||||
ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase)
|
||||
.await?;
|
||||
|
||||
ctx.set_database_lock(&auth.claims.identity, &database_identity, true)
|
||||
.await
|
||||
.map_err(log_and_500)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unlock_database<S: ControlStateDelegate + Authorization>(
|
||||
State(ctx): State<S>,
|
||||
Path(DeleteDatabaseParams { name_or_identity }): Path<DeleteDatabaseParams>,
|
||||
Extension(auth): Extension<SpacetimeAuth>,
|
||||
) -> axum::response::Result<impl IntoResponse> {
|
||||
let database_identity = name_or_identity.resolve(&ctx).await?;
|
||||
let Some(_database) = worker_ctx_find_database(&ctx, &database_identity).await? else {
|
||||
return Err(StatusCode::NOT_FOUND.into());
|
||||
};
|
||||
|
||||
ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase)
|
||||
.await?;
|
||||
|
||||
ctx.set_database_lock(&auth.claims.identity, &database_identity, false)
|
||||
.await
|
||||
.map_err(log_and_500)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddNameParams {
|
||||
name_or_identity: NameOrIdentity,
|
||||
@@ -1228,6 +1285,10 @@ pub struct DatabaseRoutes<S> {
|
||||
pub db_reset: MethodRouter<S>,
|
||||
/// GET: /database/: name_or_identity/unstable/timestamp
|
||||
pub timestamp_get: MethodRouter<S>,
|
||||
/// POST: /database/:name_or_identity/lock
|
||||
pub lock_post: MethodRouter<S>,
|
||||
/// POST: /database/:name_or_identity/unlock
|
||||
pub unlock_post: MethodRouter<S>,
|
||||
}
|
||||
|
||||
impl<S> Default for DatabaseRoutes<S>
|
||||
@@ -1253,6 +1314,8 @@ where
|
||||
pre_publish: post(pre_publish::<S>),
|
||||
db_reset: put(reset::<S>),
|
||||
timestamp_get: get(get_timestamp::<S>),
|
||||
lock_post: post(lock_database::<S>),
|
||||
unlock_post: post(unlock_database::<S>),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1277,7 +1340,9 @@ where
|
||||
.route("/sql", self.sql_post)
|
||||
.route("/unstable/timestamp", self.timestamp_get)
|
||||
.route("/pre_publish", self.pre_publish)
|
||||
.route("/reset", self.db_reset);
|
||||
.route("/reset", self.db_reset)
|
||||
.route("/lock", self.lock_post)
|
||||
.route("/unlock", self.unlock_post);
|
||||
|
||||
axum::Router::new()
|
||||
.route("/", self.root_post)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use spacetimedb_smoketests::Smoketest;
|
||||
|
||||
/// Test that a locked database cannot be deleted.
|
||||
#[test]
|
||||
fn test_locked_database_cannot_be_deleted() {
|
||||
let test = Smoketest::builder().precompiled_module("modules-basic").build();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap();
|
||||
|
||||
// Lock the database
|
||||
test.spacetime(&["lock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
|
||||
// Try to delete — should fail
|
||||
let result = test.spacetime(&["delete", "--server", &test.server_url, identity, "--yes"]);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected delete to fail on a locked database, but it succeeded"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that a locked database cannot be reset with --delete-data.
|
||||
#[test]
|
||||
fn test_locked_database_cannot_be_reset() {
|
||||
let mut test = Smoketest::builder()
|
||||
.precompiled_module("modules-basic")
|
||||
.autopublish(false)
|
||||
.build();
|
||||
|
||||
let name = format!("test-lock-reset-{}", std::process::id());
|
||||
test.publish_module_named(&name, false).unwrap();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap();
|
||||
|
||||
// Lock the database
|
||||
test.spacetime(&["lock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
|
||||
// Try to republish with --delete-data — should fail
|
||||
let result = test.publish_module_with_options(&name, true, false);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected publish with --delete-data to fail on a locked database, but it succeeded"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that unlocking a locked database allows deletion.
|
||||
#[test]
|
||||
fn test_unlock_allows_delete() {
|
||||
let test = Smoketest::builder().precompiled_module("modules-basic").build();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap();
|
||||
|
||||
// Lock the database
|
||||
test.spacetime(&["lock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
|
||||
// Verify delete is blocked
|
||||
let result = test.spacetime(&["delete", "--server", &test.server_url, identity, "--yes"]);
|
||||
assert!(result.is_err(), "Expected delete to fail while locked");
|
||||
|
||||
// Unlock the database
|
||||
test.spacetime(&["unlock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
|
||||
// Now delete should succeed
|
||||
test.spacetime(&["delete", "--server", &test.server_url, identity, "--yes"])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Test that locking an already-locked database is idempotent.
|
||||
#[test]
|
||||
fn test_lock_is_idempotent() {
|
||||
let test = Smoketest::builder().precompiled_module("modules-basic").build();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap();
|
||||
|
||||
// Lock twice — second lock should not error
|
||||
test.spacetime(&["lock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
test.spacetime(&["lock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Test that unlocking an already-unlocked database is idempotent.
|
||||
#[test]
|
||||
fn test_unlock_is_idempotent() {
|
||||
let test = Smoketest::builder().precompiled_module("modules-basic").build();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap();
|
||||
|
||||
// Unlock without ever locking — should not error
|
||||
test.spacetime(&["unlock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Test that a non-owner cannot lock or unlock a database.
|
||||
#[test]
|
||||
fn test_non_owner_cannot_lock_or_unlock() {
|
||||
let test = Smoketest::builder().precompiled_module("modules-basic").build();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap().clone();
|
||||
|
||||
// Switch to a new identity
|
||||
test.new_identity().unwrap();
|
||||
|
||||
// Non-owner lock should fail
|
||||
let result = test.spacetime(&["lock", "--server", &test.server_url, &identity]);
|
||||
assert!(result.is_err(), "Expected non-owner lock to fail, but it succeeded");
|
||||
|
||||
// Non-owner unlock should fail
|
||||
let result = test.spacetime(&["unlock", "--server", &test.server_url, &identity]);
|
||||
assert!(result.is_err(), "Expected non-owner unlock to fail, but it succeeded");
|
||||
}
|
||||
|
||||
/// Test that publish without --delete-data still works on a locked database.
|
||||
/// Lock only prevents deletion, not updates.
|
||||
#[test]
|
||||
fn test_locked_database_allows_publish() {
|
||||
let mut test = Smoketest::builder()
|
||||
.precompiled_module("modules-basic")
|
||||
.autopublish(false)
|
||||
.build();
|
||||
|
||||
let name = format!("test-lock-publish-{}", std::process::id());
|
||||
test.publish_module_named(&name, false).unwrap();
|
||||
|
||||
let identity = test.database_identity.as_ref().unwrap();
|
||||
|
||||
// Lock the database
|
||||
test.spacetime(&["lock", "--server", &test.server_url, identity])
|
||||
.unwrap();
|
||||
|
||||
// Republish without --delete-data — should succeed
|
||||
test.publish_module_clear(false).unwrap();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod confirmed_reads;
|
||||
mod connect_disconnect_from_cli;
|
||||
mod create_project;
|
||||
mod csharp_module;
|
||||
mod database_lock;
|
||||
mod default_module_clippy;
|
||||
mod delete_database;
|
||||
mod describe;
|
||||
|
||||
@@ -394,6 +394,19 @@ impl ControlDb {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_database_locked(&self, database_identity: &Identity) -> Result<bool> {
|
||||
let tree = self.db.open_tree("database_locks")?;
|
||||
let key = database_identity.to_be_byte_array();
|
||||
Ok(tree.get(key)?.is_some_and(|v| v.as_ref() == [1u8]))
|
||||
}
|
||||
|
||||
pub fn set_database_lock(&self, database_identity: &Identity, locked: bool) -> Result<()> {
|
||||
let tree = self.db.open_tree("database_locks")?;
|
||||
let key = database_identity.to_be_byte_array();
|
||||
tree.insert(key, &[locked as u8])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_database(&self, id: u64) -> Result<Option<u64>> {
|
||||
let tree = self.db.open_tree("database")?;
|
||||
let tree_by_identity = self.db.open_tree("database_by_identity")?;
|
||||
|
||||
@@ -256,6 +256,10 @@ impl spacetimedb_client_api::ControlStateReadAccess for StandaloneEnv {
|
||||
let name: DatabaseName = name.parse()?;
|
||||
Ok(self.control_db.spacetime_lookup_tld(Tld::from(name))?)
|
||||
}
|
||||
|
||||
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool> {
|
||||
Ok(self.control_db.is_database_locked(database_identity)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -477,6 +481,19 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv {
|
||||
.control_db
|
||||
.spacetime_replace_domains(database_identity, owner_identity, domain_names)?)
|
||||
}
|
||||
|
||||
async fn set_database_lock(
|
||||
&self,
|
||||
_caller_identity: &Identity,
|
||||
database_identity: &Identity,
|
||||
locked: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(_database) = self.control_db.get_database_by_identity(database_identity)? else {
|
||||
anyhow::bail!("Database not found: {}", database_identity.to_abbreviated_hex());
|
||||
};
|
||||
self.control_db.set_database_lock(database_identity, locked)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl spacetimedb_client_api::Authorization for StandaloneEnv {
|
||||
|
||||
@@ -36,6 +36,8 @@ This document contains the help content for the `spacetime` command-line program
|
||||
* [`spacetime server clear`↴](#spacetime-server-clear)
|
||||
* [`spacetime subscribe`↴](#spacetime-subscribe)
|
||||
* [`spacetime start`↴](#spacetime-start)
|
||||
* [`spacetime lock`↴](#spacetime-lock)
|
||||
* [`spacetime unlock`↴](#spacetime-unlock)
|
||||
* [`spacetime version`↴](#spacetime-version)
|
||||
|
||||
## `spacetime`
|
||||
@@ -61,6 +63,8 @@ This document contains the help content for the `spacetime` command-line program
|
||||
* `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes.
|
||||
* `subscribe` — Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes.
|
||||
* `start` — Start a local SpacetimeDB instance
|
||||
* `lock` — Lock a database to prevent accidental deletion
|
||||
* `unlock` — Unlock a database to allow deletion
|
||||
* `version` — Manage installed spacetime versions
|
||||
|
||||
###### **Options:**
|
||||
@@ -618,6 +622,51 @@ Run `spacetime start --help` to see all options.
|
||||
|
||||
|
||||
|
||||
## `spacetime lock`
|
||||
|
||||
Lock a database to prevent it from being deleted.
|
||||
|
||||
A locked database cannot be deleted until it is unlocked with `spacetime unlock`.
|
||||
This is a safety mechanism to protect production databases from accidental deletion.
|
||||
|
||||
**Usage:** `spacetime lock [OPTIONS] [database]`
|
||||
|
||||
Run `spacetime help lock` for more detailed information.
|
||||
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<DATABASE>` — The name or identity of the database to lock
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `-s`, `--server <SERVER>` — The nickname, host name or URL of the server hosting the database
|
||||
* `--no-config` — Ignore spacetime.json configuration
|
||||
|
||||
|
||||
|
||||
## `spacetime unlock`
|
||||
|
||||
Unlock a database that was previously locked with `spacetime lock`.
|
||||
|
||||
After unlocking, the database can be deleted normally with `spacetime delete`.
|
||||
|
||||
**Usage:** `spacetime unlock [OPTIONS] [database]`
|
||||
|
||||
Run `spacetime help unlock` for more detailed information.
|
||||
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<DATABASE>` — The name or identity of the database to unlock
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `-s`, `--server <SERVER>` — The nickname, host name or URL of the server hosting the database
|
||||
* `--no-config` — Ignore spacetime.json configuration
|
||||
|
||||
|
||||
|
||||
## `spacetime version`
|
||||
|
||||
Manage installed spacetime versions
|
||||
|
||||
Reference in New Issue
Block a user