Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/control-panel/impl/src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ impl User {

pub fn add_deployed_station(&mut self, station: Principal) {
self.deployed_stations.push(station);
}

/// Consumes one unit of the user's daily deploy quota.
///
/// Kept separate from `add_deployed_station` so the quota can be charged before any cycles are
/// spent, rather than only on the fully successful path.
pub fn consume_deploy_quota(&mut self) {
self.user_rate_limiter.add_deployed_station();
}

Expand Down
13 changes: 13 additions & 0 deletions core/control-panel/impl/src/services/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ impl DeployService {
let station_wasm_module = config.station_wasm_module;
let station_wasm_module_extra_chunks = config.station_wasm_module_extra_chunks;

// Station init rejects an empty user list, so this would otherwise fail deterministically
// after the station has already been created and funded.
if input.admins.is_empty() {
return Err(DeployError::Failed {
reason: "At least one admin must be specified.".to_string(),
})?;
}

let can_deploy_station_response = user.can_deploy_station();
match can_deploy_station_response {
CanDeployStation::Allowed(_) => {}
Expand All @@ -70,6 +78,11 @@ impl DeployService {
}
}

// Charged before the first irreversible spend below. Everything past this point costs
// cycles whether or not the deployment completes, so the quota must be consumed even if a
// later step fails.
self.user_service.consume_deploy_quota(&user.id, ctx)?;

// Creates the station canister
let station_canister = create_canister(input.subnet_selection, CANISTER_CREATION_CYCLES)
.await
Expand Down
79 changes: 74 additions & 5 deletions core/control-panel/impl/src/services/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,23 +131,39 @@ impl UserService {
.collect()
}

/// Charges one deployment against the global and per-user daily quotas.
///
/// Must be called before any cycles are spent. Creating and funding a station is irreversible,
/// so a deployment that fails afterwards has still consumed real cycles and must consume quota
/// too; charging only on success let a caller force a post-spend failure and drain the
/// canister's balance without ever advancing either counter.
pub fn consume_deploy_quota(&self, user_id: &UserId, ctx: &CallContext) -> ServiceResult<()> {
let mut user = self.get_user(user_id, ctx)?;
let mut config = canister_config().ok_or(DeployError::Failed {
reason: "Canister config not initialized.".to_string(),
})?;

config.global_rate_limiter.add_deployed_station();
user.consume_deploy_quota();

write_canister_config(config);
self.user_repository.insert(user.to_key(), user);

Ok(())
}

pub fn add_deployed_station(
&self,
user_id: &UserId,
station_canister_id: Principal,
ctx: &CallContext,
) -> ServiceResult<User> {
let mut user = self.get_user(user_id, ctx)?;
let mut config = canister_config().ok_or(DeployError::Failed {
reason: "Canister config not initialized.".to_string(),
})?;

config.global_rate_limiter.add_deployed_station();
user.add_deployed_station(station_canister_id);

user.validate()?;

write_canister_config(config);
self.user_repository.insert(user.to_key(), user.clone());

FUND_MANAGER.with(|fund_manager| {
Expand Down Expand Up @@ -345,6 +361,59 @@ mod tests {
assert!(duplicated_user_result.is_err());
}

#[tokio::test]
async fn consume_deploy_quota_charges_the_user_without_recording_a_station() {
crate::core::test_utils::init_canister_config();

let user: User = mock_user();
let ctx = CallContext::new(user.identity);
let service = UserService::default();

service.user_repository.insert(user.to_key(), user.clone());

for _ in 0..User::MAX_DEPLOYED_STATIONS_PER_DAY {
service
.consume_deploy_quota(&user.id, &ctx)
.expect("Failed to consume deploy quota");
}

let charged = service.user_repository.get(&user.to_key()).unwrap();

assert!(matches!(
charged.can_deploy_station(),
CanDeployStation::QuotaExceeded
));
// The quota is charged for the attempt; the station itself is only recorded on success.
assert!(charged.get_deployed_stations().is_empty());
}

#[tokio::test]
async fn consume_deploy_quota_advances_the_global_limiter() {
crate::core::test_utils::init_canister_config();

let user: User = mock_user();
let ctx = CallContext::new(user.identity);
let service = UserService::default();

service.user_repository.insert(user.to_key(), user.clone());

let before = canister_config()
.unwrap()
.global_rate_limiter
.remaining_quota();

service
.consume_deploy_quota(&user.id, &ctx)
.expect("Failed to consume deploy quota");

let after = canister_config()
.unwrap()
.global_rate_limiter
.remaining_quota();

assert_eq!(before.unwrap() - 1, after.unwrap());
}

#[tokio::test]
async fn can_remove_user() {
crate::core::test_utils::init_canister_config();
Expand Down
Loading