use super::*;
use boatramp_core::project::{Project, ProjectConfig, ProjectMeta, DEFAULT_PROJECT};
use boatramp_core::time::now_unix;
#[derive(Debug, Deserialize)]
pub(super) struct CreateProjectRequest {
name: String,
#[serde(default)]
display: String,
#[serde(default)]
description: String,
#[serde(default)]
region: Option<String>,
}
pub(super) async fn list_projects(State(deploy): State<DeployStore>) -> Response {
match deploy.list_projects().await {
Ok(projects) => Json(projects).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn create_project(
State(deploy): State<DeployStore>,
Json(req): Json<CreateProjectRequest>,
) -> Response {
let name = req.name.trim();
if let Err(err) = boatramp_core::project::validate_resource_name("project", name) {
return (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response();
}
if name == boatramp_core::project::DEFAULT_PROJECT {
return (
StatusCode::CONFLICT,
format!("project {name:?} is reserved\n"),
)
.into_response();
}
match deploy.get_project(name).await {
Ok(Some(_)) => {
return (
StatusCode::CONFLICT,
format!("project `{name}` already exists\n"),
)
.into_response()
}
Ok(None) => {}
Err(err) => return deploy_error_response(err),
}
let project = Project {
version: boatramp_core::SCHEMA_VERSION,
name: name.to_string(),
created_at: now_unix(),
meta: ProjectMeta {
display: req.display,
description: req.description,
..Default::default()
},
config: ProjectConfig { region: req.region },
secrets_ref: None,
};
match deploy.put_project(&project).await {
Ok(_) => (StatusCode::CREATED, Json(project)).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(super) async fn get_project(
State(deploy): State<DeployStore>,
Path(proj): Path<String>,
) -> Response {
match deploy.get_project(&proj).await {
Ok(Some(project)) => Json(project).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, format!("no project `{proj}`\n")).into_response(),
Err(err) => deploy_error_response(err),
}
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct DeleteProjectQuery {
#[serde(default)]
force: bool,
#[serde(default)]
dry_run: bool,
}
pub(super) async fn delete_project(
State(deploy): State<DeployStore>,
Extension(deprovisioner): Extension<Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>>,
Extension(volumes): Extension<Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>>,
Path(proj): Path<String>,
Query(q): Query<DeleteProjectQuery>,
) -> Response {
if proj == DEFAULT_PROJECT {
return (
StatusCode::CONFLICT,
"the `default` project cannot be deleted\n",
)
.into_response();
}
if q.dry_run {
return match deploy.enumerate_project_resources(&proj).await {
Ok(plan) => (StatusCode::OK, Json(plan)).into_response(),
Err(err) => deploy_error_response(err),
};
}
if q.force {
return force_delete_project(&deploy, deprovisioner, volumes, &proj).await;
}
match deploy.delete_project(&proj).await {
Ok(true) => {
if let Some(deprovisioner) = deprovisioner {
deprovisioner.deprovision_project(&proj).await;
}
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => (StatusCode::NOT_FOUND, format!("no project `{proj}`\n")).into_response(),
Err(DeployError::Conflict(msg)) => {
(StatusCode::CONFLICT, format!("{msg}\n")).into_response()
}
Err(err) => deploy_error_response(err),
}
}
async fn force_delete_project(
deploy: &DeployStore,
deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
proj: &str,
) -> Response {
use boatramp_core::project::ProjectRef;
let plan = match deploy.enumerate_project_resources(proj).await {
Ok(plan) => plan,
Err(err) => return deploy_error_response(err),
};
let exists = match deploy.get_project(proj).await {
Ok(p) => p.is_some(),
Err(err) => return deploy_error_response(err),
};
if !exists && plan.is_empty() {
return (StatusCode::NOT_FOUND, format!("no project `{proj}`\n")).into_response();
}
let pref = ProjectRef::new(proj);
if let Some(deprovisioner) = &deprovisioner {
deprovisioner.deprovision_project(proj).await;
}
for c in &plan.compute {
if let Err(err) = deploy.delete_compute_workload(pref, &c.name).await {
return deploy_error_response(err);
}
}
for name in &plan.functions {
if let Err(err) = deploy.delete_function(pref, name).await {
return deploy_error_response(err);
}
}
for site in &plan.sites {
if let Err(err) = deploy.delete_site(pref, site).await {
return deploy_error_response(err);
}
}
let orphaned = plan.all_volumes();
if !orphaned.is_empty() {
match &volumes {
Some(volumes) => {
for name in &orphaned {
if let Err(err) = volumes.remove(name, true).await {
tracing::warn!(
project = proj,
volume = name,
error = %err,
"force-delete: reclaiming compute volume failed (leaving it orphaned)"
);
}
}
}
None => tracing::warn!(
project = proj,
volumes = ?orphaned,
"force-delete: no volume-capable backend wired; leaving compute volumes on disk"
),
}
}
if let Err(err) = deploy.purge_project(proj).await {
return deploy_error_response(err);
}
(StatusCode::OK, Json(plan)).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::compute::{
ComputeSpec, ComputeVolumes, ComputeWorkload, PlacementConstraints, RestartPolicy,
RootSource, VolumeError, VolumeRef, VolumeStatus,
};
use boatramp_core::deploy::DeployStore;
use boatramp_core::kv::MemoryKv;
use boatramp_core::project::{Project, ProjectRef};
use boatramp_core::sql::TenantDeprovisioner;
use std::sync::Mutex;
struct NullStorage;
#[async_trait::async_trait]
impl boatramp_core::Storage for NullStorage {
async fn get(
&self,
_: &str,
) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn get_range(
&self,
_: &str,
_: u64,
_: Option<u64>,
) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn put(
&self,
_: &str,
_: boatramp_core::ByteStream,
_: boatramp_core::PutMeta,
) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::unsupported("null"))
}
async fn head(
&self,
_: &str,
) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
Ok(())
}
async fn list(
&self,
_: &str,
) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
Ok(Vec::new())
}
}
#[derive(Default)]
struct RecordingDeprovisioner {
projects: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl TenantDeprovisioner for RecordingDeprovisioner {
async fn deprovision_project(&self, project: &str) {
self.projects.lock().unwrap().push(project.to_string());
}
async fn deprovision_site(&self, _project: &str, _site: &str) {}
}
#[derive(Default)]
struct RecordingVolumes {
removed: Mutex<Vec<(String, bool)>>,
}
#[async_trait::async_trait]
impl ComputeVolumes for RecordingVolumes {
async fn list(&self) -> Result<Vec<VolumeStatus>, VolumeError> {
Ok(Vec::new())
}
async fn remove(&self, name: &str, force: bool) -> Result<bool, VolumeError> {
self.removed.lock().unwrap().push((name.to_string(), force));
Ok(true)
}
}
fn deploy() -> DeployStore {
DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()))
}
fn domain_key(host: &str) -> String {
format!("domain/{host}")
}
fn secret_key(project: &str, name: &str) -> String {
format!("project/{project}/secret/{name}")
}
async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let value = if bytes.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
};
(status, value)
}
async fn seed_full_project(deploy: &DeployStore, name: &str) {
let pref = ProjectRef::new(name);
deploy
.put_project(&Project {
version: boatramp_core::SCHEMA_VERSION,
name: name.into(),
created_at: 1,
meta: Default::default(),
config: Default::default(),
secrets_ref: None,
})
.await
.unwrap();
let mut cfg = boatramp_core::config::SiteConfig::default();
cfg.domains.primary = Some(format!("{name}.example"));
deploy.set_site_config(pref, "www", &cfg).await.unwrap();
deploy
.kv()
.put(
&domain_key(&format!("{name}.example")),
boatramp_core::project::DomainOwner::new(name, "www").to_bytes(),
)
.await
.unwrap();
deploy
.put_function(
pref,
&boatramp_core::function::Function::new(
"worker",
boatramp_core::function::Owner::Project(name.into()),
"component-hash",
Default::default(),
Default::default(),
0,
),
)
.await
.unwrap();
let spec = ComputeSpec {
version: boatramp_core::SCHEMA_VERSION,
root: RootSource::Rootfs("r".repeat(64)),
kernel: "k".repeat(64),
kernel_cmdline: None,
vcpus: 1,
mem_mib: 256,
entrypoint: vec!["/app".into()],
env: Default::default(),
port: 8080,
restart: RestartPolicy::Always,
startup_grace_secs: 30,
scale_to_zero: false,
volumes: vec![VolumeRef {
mount: "/data".into(),
name: format!("{name}-data"),
size_mib: 512,
}],
writable_root: false,
cap_add: Vec::new(),
user: None,
isolation: Default::default(),
prefer_backend: None,
bindings: vec![],
};
let hash = deploy.put_compute_spec(&spec).await.unwrap();
deploy
.set_compute_workload(
pref,
&ComputeWorkload {
version: boatramp_core::SCHEMA_VERSION,
name: "pg".into(),
active: hash,
replicas: 1,
placement: PlacementConstraints::default(),
},
)
.await
.unwrap();
deploy
.kv()
.put(&secret_key(name, "api-key"), b"sealed".to_vec())
.await
.unwrap();
}
#[tokio::test]
async fn dry_run_previews_and_mutates_nothing() {
let deploy = deploy();
seed_full_project(&deploy, "acme").await;
let before = deploy.kv().list_prefix("").await.unwrap().len();
let (st, plan) = body_json(
delete_project(
State(deploy.clone()),
Extension(None),
Extension(None),
Path("acme".to_string()),
Query(DeleteProjectQuery {
force: false,
dry_run: true,
}),
)
.await,
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(plan["project"], "acme");
assert_eq!(plan["sites"], serde_json::json!(["www"]));
assert_eq!(plan["functions"], serde_json::json!(["worker"]));
assert_eq!(plan["compute"][0]["name"], "pg");
assert_eq!(
plan["compute"][0]["volumes"],
serde_json::json!(["acme-data"])
);
assert_eq!(plan["secrets"], serde_json::json!(["api-key"]));
let after = deploy.kv().list_prefix("").await.unwrap().len();
assert_eq!(before, after, "dry-run mutated the store");
assert!(deploy.get_project("acme").await.unwrap().is_some());
}
#[tokio::test]
async fn force_cascades_and_leaves_other_projects_untouched() {
let deploy = deploy();
seed_full_project(&deploy, "acme").await;
seed_full_project(&deploy, "keepme").await;
let deprov = Arc::new(RecordingDeprovisioner::default());
let vols = Arc::new(RecordingVolumes::default());
let (st, report) = body_json(
delete_project(
State(deploy.clone()),
Extension(Some(deprov.clone() as Arc<dyn TenantDeprovisioner>)),
Extension(Some(vols.clone() as Arc<dyn ComputeVolumes>)),
Path("acme".to_string()),
Query(DeleteProjectQuery {
force: true,
dry_run: false,
}),
)
.await,
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(report["project"], "acme");
assert!(deploy.get_project("acme").await.unwrap().is_none());
assert!(deploy
.kv()
.list_prefix(&boatramp_core::project::resource_prefix("acme"))
.await
.unwrap()
.is_empty());
assert!(deploy
.kv()
.get(&domain_key("acme.example"))
.await
.unwrap()
.is_none());
assert_eq!(*deprov.projects.lock().unwrap(), vec!["acme".to_string()]);
assert_eq!(
*vols.removed.lock().unwrap(),
vec![("acme-data".to_string(), true)]
);
assert!(deploy.get_project("keepme").await.unwrap().is_some());
assert!(deploy
.get_site_config(ProjectRef::new("keepme"), "www")
.await
.unwrap()
.is_some());
assert!(deploy
.kv()
.get(&domain_key("keepme.example"))
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn non_force_nonempty_is_409_with_enumeration() {
let deploy = deploy();
seed_full_project(&deploy, "acme").await;
let resp = delete_project(
State(deploy.clone()),
Extension(None),
Extension(None),
Path("acme".to_string()),
Query(DeleteProjectQuery {
force: false,
dry_run: false,
}),
)
.await;
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let msg = String::from_utf8_lossy(&bytes);
assert_eq!(status, StatusCode::CONFLICT);
assert!(
msg.contains("still owns resources"),
"409 body should enumerate: {msg}"
);
assert!(deploy.get_project("acme").await.unwrap().is_some());
}
#[tokio::test]
async fn default_project_with_force_is_409() {
let deploy = deploy();
let resp = delete_project(
State(deploy.clone()),
Extension(None),
Extension(None),
Path(DEFAULT_PROJECT.to_string()),
Query(DeleteProjectQuery {
force: true,
dry_run: false,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
}