Skip to content
Merged
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
84 changes: 84 additions & 0 deletions crates/clickhouse-cloud-api/tests/common/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,12 @@ pub struct CleanupRegistry {
/// being deleted, table drops are redundant but harmless.
tables: Vec<String>,
api_key_ids: Vec<String>,
scaling_schedule_restores: Vec<ScalingScheduleRestore>,
}

pub struct ScalingScheduleRestore {
pub service_id: String,
pub pre_state: ScalingSchedule,
}

impl CleanupRegistry {
Expand Down Expand Up @@ -433,6 +439,30 @@ impl CleanupRegistry {
.retain(|registered| registered != key_id);
}

/// Register a scaling schedule pre-state for restore during cleanup.
///
/// Cleanup runs the restore via `scaling_schedule_upsert` before
/// service deletion so the POST has a valid target. If the service
/// was already deleted in-test, the restore tolerates a 404; on the
/// happy path the caller should call
/// [`unregister_scaling_schedule_restore`] before deleting the
/// service to keep the registry tidy.
pub fn register_scaling_schedule_restore(
&mut self,
service_id: impl Into<String>,
pre_state: ScalingSchedule,
) {
self.scaling_schedule_restores.push(ScalingScheduleRestore {
service_id: service_id.into(),
pre_state,
});
}

pub fn unregister_scaling_schedule_restore(&mut self, service_id: &str) {
self.scaling_schedule_restores
.retain(|registered| registered.service_id != service_id);
}

pub async fn cleanup(
&mut self,
client: &Client,
Expand All @@ -443,6 +473,18 @@ impl CleanupRegistry {
) -> Result<(), String> {
let mut failures = Vec::new();

// Restore scaling schedules before service deletion so the
// restore POST can still find the service. If the service is
// already gone (in-test delete succeeded), 404 is tolerated.
while let Some(restore) = self.scaling_schedule_restores.pop() {
if let Err(error) = restore_scaling_schedule(client, org_id, &restore).await {
failures.push(format!(
"scaling schedule restore {service_id}: {error}",
service_id = restore.service_id
));
}
}

// API keys are cleaned up first; they belong to the org, not a
// specific service, so they outlive service deletion if leaked.
while let Some(key_id) = self.api_key_ids.pop() {
Expand Down Expand Up @@ -510,6 +552,48 @@ impl CleanupRegistry {
}
}

async fn restore_scaling_schedule(
client: &Client,
org_id: &str,
restore: &ScalingScheduleRestore,
) -> TestResult<()> {
eprintln!(" cleanup: restoring scaling schedule pre-state");
let body = ScalingSchedulePostRequest {
entries: restore
.pre_state
.entries
.iter()
.map(scaling_schedule_entry_to_request)
.collect(),
};
match client
.scaling_schedule_upsert(org_id, &restore.service_id, &body)
.await
{
Ok(_) => Ok(()),
// Service deleted before cleanup ran — nothing to restore against.
Err(clickhouse_cloud_api::Error::Api { status: 404, .. }) => Ok(()),
Err(e) => Err(e.into()),
}
}

pub fn scaling_schedule_entry_to_request(
entry: &ScalingScheduleEntry,
) -> ScalingScheduleEntryRequest {
ScalingScheduleEntryRequest {
end_hour_utc: entry.end_hour_utc,
idle_scaling: entry.idle_scaling,
idle_timeout_minutes: entry.idle_timeout_minutes,
max_replica_memory_gb: entry.max_replica_memory_gb,
max_replicas: entry.max_replicas,
min_replica_memory_gb: entry.min_replica_memory_gb,
min_replicas: entry.min_replicas,
name: entry.name.clone(),
start_hour_utc: entry.start_hour_utc,
weekdays: entry.weekdays.clone(),
}
}

async fn ensure_service_gone(
client: &Client,
org_id: &str,
Expand Down
207 changes: 206 additions & 1 deletion crates/clickhouse-cloud-api/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,7 +1213,211 @@ async fn cloud_service_crud_lifecycle() -> TestResult<()> {
})
.await?;

// ── 7. Delete ────────────────────────────────────────────────
// ── 7. Scaling Schedule (Beta) ───────────────────────────────
//
// Exercise the Beta scaling_schedule_{get,upsert,delete} trio
// for shape coverage. Schedule entries are chosen to be inert:
//
// - replica counts and memory match the current service state
// (1 replica, 8 GB), so even if an entry happens to be active
// during the test run it cannot drive any real scaling action;
// - the upsert window covers a single hour (1 a.m. – 2 a.m. UTC)
// on Sunday only, with the same inert replica config so the
// entry's effect is always a no-op regardless of when the
// suite runs.
//
// The pre-state (typically an empty schedule) is captured here
// and restored as a cleanup step, not a test-body step.

log_phase("Scaling Schedule");

let pre_schedule = failures
.run(
&ctx,
StepKind::NonBlocking,
"scaling_schedule get pre-state",
|| {
let client = client.clone();
let org_id = ctx.org_id.clone();
let service_id = service_id.clone();
async move {
// A freshly-created service has no autoscaling schedule
// configured, and the API responds with 404 rather than
// an empty `ScalingSchedule`. Treat that as the canonical
// empty pre-state so the round-trip can still exercise
// upsert/replace; any other error still surfaces.
match client.scaling_schedule_get(&org_id, &service_id).await {
Ok(resp) => resp
.result
.ok_or_else(|| "scaling_schedule get returned no result".into()),
Err(clickhouse_cloud_api::Error::Api { status: 404, .. }) => {
Ok(ScalingSchedule::default())
}
Err(e) => Err(e.into()),
}
}
},
)
.await?;

// Only run the round-trip if we successfully captured the
// pre-state. If the initial GET failed, restoring afterwards
// would risk leaving a synthetic schedule on the service.
if let Some(pre_state) = pre_schedule {
// Skip restore registration when pre-state is empty: the API
// rejects upserts with an empty `entries` array, and there is
// nothing meaningful to restore. Cleanup of synthetic entries
// is still covered by the service-delete teardown below.
if !pre_state.entries.is_empty() {
cleanup
.register_scaling_schedule_restore(service_id.clone(), pre_state.clone());
}
eprintln!(
" captured scaling_schedule pre-state: {} entries",
pre_state.entries.len()
);

// 7a. Upsert a synthetic-but-inert schedule.
let upsert_entry = ScalingScheduleEntryRequest {
name: "clickhousectl-it-upsert-window".to_string(),
weekdays: vec![0], // Sunday only
start_hour_utc: 1,
end_hour_utc: 2,
min_replica_memory_gb: Some(base_memory_gb),
max_replica_memory_gb: Some(base_memory_gb),
min_replicas: Some(base_replicas as i64),
max_replicas: Some(base_replicas as i64),
idle_scaling: Some(true),
idle_timeout_minutes: Some(5),
};

let upserted = failures
.run(
&ctx,
StepKind::NonBlocking,
"scaling_schedule upsert inert window",
|| {
let client = client.clone();
let org_id = ctx.org_id.clone();
let service_id = service_id.clone();
let entry = upsert_entry.clone();
async move {
let body = ScalingSchedulePostRequest {
entries: vec![entry],
};
let resp = client
.scaling_schedule_upsert(&org_id, &service_id, &body)
.await?;
resp.result.ok_or_else(|| {
"scaling_schedule upsert returned no result".into()
})
}
},
)
.await?;

// 7b. GET and confirm the upsert is visible.
if upserted.is_some() {
failures
.run(
&ctx,
StepKind::NonBlocking,
"scaling_schedule get reflects upsert",
|| {
let client = client.clone();
let org_id = ctx.org_id.clone();
let service_id = service_id.clone();
let expected_name = upsert_entry.name.clone();
async move {
let resp = client
.scaling_schedule_get(&org_id, &service_id)
.await?;
let schedule = resp.result.ok_or(
"scaling_schedule get returned no result after upsert",
)?;
if schedule.entries.len() != 1 {
return Err(format!(
"expected 1 entry after upsert, got {}",
schedule.entries.len()
)
.into());
}
let entry = &schedule.entries[0];
if entry.name != expected_name {
return Err(format!(
"upserted entry name mismatch: got {:?}, expected {:?}",
entry.name, expected_name
)
.into());
}
if entry.start_hour_utc != 1 || entry.end_hour_utc != 2 {
return Err(format!(
"upserted entry window mismatch: got {}-{} UTC, expected 1-2",
entry.start_hour_utc, entry.end_hour_utc
)
.into());
}
Ok(())
}
},
)
.await?;
}

// 7c. Delete the schedule.
let deleted = failures
.run(
&ctx,
StepKind::NonBlocking,
"scaling_schedule delete",
|| {
let client = client.clone();
let org_id = ctx.org_id.clone();
let service_id = service_id.clone();
async move {
client
.scaling_schedule_delete(&org_id, &service_id)
.await?;
Ok(())
}
},
)
.await?;

// 7d. GET should now return 404 (no schedule configured).
if deleted.is_some() {
failures
.run(
&ctx,
StepKind::NonBlocking,
"scaling_schedule get returns 404 after delete",
|| {
let client = client.clone();
let org_id = ctx.org_id.clone();
let service_id = service_id.clone();
async move {
match client
.scaling_schedule_get(&org_id, &service_id)
.await
{
Err(clickhouse_cloud_api::Error::Api {
status: 404,
..
}) => Ok(()),
Ok(_) => Err(
"scaling_schedule get returned a schedule after delete"
.into(),
),
Err(e) => Err(e.into()),
}
}
},
)
.await?;
}
}

// ── 8. Delete ────────────────────────────────────────────────

log_phase("Delete");

Expand Down Expand Up @@ -1309,6 +1513,7 @@ async fn cloud_service_crud_lifecycle() -> TestResult<()> {
},
)
.await?;
cleanup.unregister_scaling_schedule_restore(&service_id);
cleanup.unregister_service(&service_id);

failures.finish()
Expand Down
Loading