use serde::Serialize;
use crate::core::Column as _;
use crate::sql::{Auto, FetcherPool, Pool, UpdaterPool};
use super::error::TenancyError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunState {
Pending,
Running,
Succeeded,
Failed,
}
impl RunState {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
}
}
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw {
"pending" => Self::Pending,
"running" => Self::Running,
"succeeded" => Self::Succeeded,
_ => Self::Failed,
}
}
#[must_use]
pub fn is_terminal(self) -> bool {
matches!(self, Self::Succeeded | Self::Failed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind {
Provision,
Migrate,
}
impl RunKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Provision => "provision",
Self::Migrate => "migrate",
}
}
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw {
"migrate" => Self::Migrate,
_ => Self::Provision,
}
}
}
#[derive(crate::Model, Debug, Clone, Serialize)]
#[rustango(
table = "rustango_provisioning_runs",
scope = "registry",
display = "slug",
admin(
list_display = "slug, state, started_at, finished_at",
ordering = "-started_at",
)
)]
pub struct ProvisioningRun {
#[rustango(primary_key)]
pub id: crate::sql::Auto<i64>,
#[rustango(max_length = 16, index, default = "'provision'")]
pub kind: String,
#[rustango(max_length = 100, index)]
pub slug: String,
pub org_id: Option<i64>,
#[rustango(max_length = 16, index)]
pub state: String,
#[rustango(max_length = 16)]
pub storage_mode: String,
#[rustango(max_length = 16)]
pub backend_kind: String,
#[rustango(max_length = 500)]
pub database_url: Option<String>,
#[rustango(max_length = 150)]
pub requested_by: Option<String>,
#[rustango(max_length = 200, unique)]
pub idempotency_key: Option<String>,
pub error: Option<String>,
#[rustango(auto_now_add)]
pub started_at: crate::sql::Auto<chrono::DateTime<chrono::Utc>>,
pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(crate::Model, Debug, Clone, Serialize)]
#[rustango(
table = "rustango_provisioning_events",
scope = "registry",
display = "message",
admin(list_display = "run_id, seq, step, status", ordering = "run_id, seq")
)]
pub struct ProvisioningEvent {
#[rustango(primary_key)]
pub id: crate::sql::Auto<i64>,
#[rustango(fk = "rustango_provisioning_runs", on = "id", index)]
pub run_id: i64,
pub seq: i64,
#[rustango(max_length = 32)]
pub step: String,
#[rustango(max_length = 16)]
pub status: String,
pub message: String,
#[rustango(auto_now_add)]
pub at: crate::sql::Auto<chrono::DateTime<chrono::Utc>>,
}
pub async fn open_run(
registry: &Pool,
slug: &str,
storage_mode: &str,
backend_kind: &str,
database_url: Option<&str>,
requested_by: Option<&str>,
idempotency_key: Option<&str>,
) -> Result<ProvisioningRun, TenancyError> {
let mut run = ProvisioningRun {
id: Auto::default(),
kind: RunKind::Provision.as_str().to_owned(),
slug: slug.to_owned(),
org_id: None,
state: RunState::Running.as_str().to_owned(),
storage_mode: storage_mode.to_owned(),
backend_kind: backend_kind.to_owned(),
database_url: database_url.map(crate::sql::connect_diagnosis::redact),
requested_by: requested_by.map(ToOwned::to_owned),
idempotency_key: idempotency_key.map(ToOwned::to_owned),
error: None,
started_at: Auto::default(),
finished_at: None,
};
run.insert_pool(registry).await?;
Ok(run)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RunText(String);
impl RunText {
#[must_use]
pub fn fixed(text: &'static str) -> Self {
Self(text.to_owned())
}
#[must_use]
pub fn user_facing(e: &TenancyError) -> Option<Self> {
e.user_facing().map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RunText {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub(crate) struct RunLog {
run_id: i64,
next_ref: std::sync::atomic::AtomicU32,
refs: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
impl RunLog {
pub(crate) fn new(run_id: i64) -> Self {
Self {
run_id,
next_ref: std::sync::atomic::AtomicU32::new(1),
refs: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
pub(crate) fn failure(&self, slug: &str, what: &'static str, e: &TenancyError) -> RunText {
RunText::user_facing(e).unwrap_or_else(|| self.withhold(slug, what.to_owned(), e))
}
pub(crate) fn withheld(
&self,
slug: &str,
what: &'static str,
cause: &dyn std::fmt::Display,
) -> RunText {
self.withhold(slug, what.to_owned(), cause)
}
fn withhold(&self, slug: &str, what: String, cause: &dyn std::fmt::Display) -> RunText {
let mut refs = self
.refs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(reference) = refs.get(slug) {
return RunText(format!("{what} (ref {reference})"));
}
let n = self
.next_ref
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let reference = format!("{}.{n}", self.run_id);
let body = tracing::error_span!(
"provisioning_run",
org = slug,
run_id = self.run_id,
reference = %reference
)
.in_scope(|| crate::error::server_error_body("tenancy::provisioning_run", cause));
refs.insert(slug.to_owned(), reference.clone());
RunText(format!("{what} ({body}; ref {reference})"))
}
pub(crate) fn migration(
&self,
event: &super::migrate::TenantMigrationEvent,
) -> (&'static str, &'static str, RunText) {
use super::migrate::{Chain, TenantMigrationEvent as T};
use crate::migrate::{MigrationEvent as M, Outcome};
let chain_name = |c: Chain| match c {
Chain::System => "system",
Chain::Project => "app",
};
match event {
T::Planned { tenants } => (
"plan",
"info",
RunText(format!("{tenants} tenant(s) to migrate")),
),
T::TenantStarted { slug, index, total } => (
"tenant",
"started",
RunText(format!("{slug} ({index}/{total})")),
),
T::Migration { slug, chain, event } => {
let chain = chain_name(*chain);
match event {
M::Planned { total } => (
"migration",
"info",
RunText(format!("{slug} {chain}: {total} pending")),
),
M::Started { name, .. } => (
"migration",
"info",
RunText(format!("{slug} {chain}: {name} started")),
),
M::Finished {
name,
outcome,
elapsed,
..
} => {
let verb = match outcome {
Outcome::Ran => "applied",
Outcome::RanPartial { .. } => "applied (partial)",
Outcome::Faked => "faked",
};
(
"migration",
"info",
RunText(format!(
"{slug} {chain}: {verb} {name} ({:.1}s)",
elapsed.as_secs_f64()
)),
)
}
M::Failed { name, error, .. } => (
"migration",
"failed",
self.withhold(slug, format!("{slug} {chain}: {name} failed"), error),
),
}
}
T::TenantFinished {
slug,
applied,
error,
..
} => match error {
None => (
"tenant",
"ok",
RunText(format!("{slug}: {applied} applied")),
),
Some(e) => (
"tenant",
"failed",
self.withhold(slug, format!("{slug} failed"), e),
),
},
}
}
}
pub async fn append_event_text(
registry: &Pool,
run_id: i64,
seq: i64,
step: &str,
status: &str,
message: &RunText,
) -> Result<(), TenancyError> {
let mut event = ProvisioningEvent {
id: Auto::default(),
run_id,
seq,
step: step.to_owned(),
status: status.to_owned(),
message: message.as_str().to_owned(),
at: Auto::default(),
};
event.insert_pool(registry).await?;
Ok(())
}
pub async fn attach_org(registry: &Pool, run_id: i64, org_id: i64) -> Result<(), TenancyError> {
ProvisioningRun::objects()
.where_(ProvisioningRun::id.eq(run_id))
.update()
.set("org_id", org_id)
.execute_pool(registry)
.await?;
Ok(())
}
#[deprecated(note = "use append_event_text / finish_run_text")]
pub async fn append_event(
registry: &Pool,
run_id: i64,
seq: i64,
step: &str,
status: &str,
message: &str,
) -> Result<(), TenancyError> {
append_event_text(
registry,
run_id,
seq,
step,
status,
&RunText(message.to_owned()),
)
.await
}
#[deprecated(note = "use append_event_text / finish_run_text")]
pub async fn finish_run(
registry: &Pool,
run_id: i64,
state: RunState,
error: Option<&str>,
) -> Result<(), TenancyError> {
let error = error.map(|e| RunText(e.to_owned()));
finish_run_text(registry, run_id, state, error.as_ref()).await
}
pub async fn finish_run_text(
registry: &Pool,
run_id: i64,
state: RunState,
error: Option<&RunText>,
) -> Result<(), TenancyError> {
ProvisioningRun::objects()
.where_(ProvisioningRun::id.eq(run_id))
.where_(ProvisioningRun::state.eq(RunState::Running.as_str().to_owned()))
.update()
.set("state", state.as_str())
.set("error", error.map(RunText::as_str))
.set("finished_at", chrono::Utc::now())
.execute_pool(registry)
.await?;
Ok(())
}
pub async fn run_by_id(
registry: &Pool,
run_id: i64,
) -> Result<Option<ProvisioningRun>, TenancyError> {
Ok(ProvisioningRun::objects()
.where_(ProvisioningRun::id.eq(run_id))
.fetch(registry)
.await?
.into_iter()
.next())
}
pub async fn run_by_idempotency_key(
registry: &Pool,
key: &str,
) -> Result<Option<ProvisioningRun>, TenancyError> {
Ok(ProvisioningRun::objects()
.where_(ProvisioningRun::idempotency_key.eq(Some(key.to_owned())))
.fetch(registry)
.await?
.into_iter()
.next())
}
pub async fn events_since(
registry: &Pool,
run_id: i64,
after_seq: i64,
) -> Result<Vec<ProvisioningEvent>, TenancyError> {
Ok(ProvisioningEvent::objects()
.where_(ProvisioningEvent::run_id.eq(run_id))
.where_(ProvisioningEvent::seq.gt(after_seq))
.order_by(&[("seq", false)])
.fetch(registry)
.await?)
}
pub async fn open_migrate_run(
registry: &Pool,
slug: Option<&str>,
requested_by: Option<&str>,
) -> Result<ProvisioningRun, TenancyError> {
let mut run = ProvisioningRun {
id: Auto::default(),
kind: RunKind::Migrate.as_str().to_owned(),
slug: slug.unwrap_or_default().to_owned(),
org_id: None,
state: RunState::Running.as_str().to_owned(),
storage_mode: String::new(),
backend_kind: String::new(),
database_url: None,
requested_by: requested_by.map(ToOwned::to_owned),
idempotency_key: None,
error: None,
started_at: Auto::default(),
finished_at: None,
};
run.insert_pool(registry).await?;
Ok(run)
}
pub async fn recent_runs(
registry: &Pool,
limit: i64,
offset: i64,
) -> Result<Vec<ProvisioningRun>, TenancyError> {
recent_runs_filtered(registry, None, None, limit, offset).await
}
pub async fn recent_runs_filtered(
registry: &Pool,
kind: Option<&str>,
state: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<ProvisioningRun>, TenancyError> {
let mut q = ProvisioningRun::objects();
if let Some(k) = kind {
q = q.where_(ProvisioningRun::kind.eq(k.to_owned()));
}
if let Some(s) = state {
q = q.where_(ProvisioningRun::state.eq(s.to_owned()));
}
Ok(q
.order_by(&[("id", true)])
.limit(limit)
.offset(offset)
.fetch(registry)
.await?)
}
pub const STALE_RUN_AFTER: std::time::Duration = std::time::Duration::from_secs(3600);
async fn is_stale(
registry: &Pool,
run: &ProvisioningRun,
after: std::time::Duration,
) -> Result<bool, TenancyError> {
if RunState::parse(&run.state) != RunState::Running {
return Ok(false);
}
let Some(id) = run.id.get().copied() else {
return Ok(false);
};
let last_event = ProvisioningEvent::objects()
.where_(ProvisioningEvent::run_id.eq(id))
.order_by(&[("seq", true)])
.limit(1)
.fetch(registry)
.await?
.into_iter()
.next()
.and_then(|e| e.at.get().copied());
let Some(last) = last_event.or_else(|| run.started_at.get().copied()) else {
return Ok(false);
};
let after = chrono::Duration::from_std(after).unwrap_or(chrono::Duration::MAX);
Ok(chrono::Utc::now() - last > after)
}
async fn close_interrupted(registry: &Pool, id: i64) -> Result<bool, TenancyError> {
let n = ProvisioningRun::objects()
.where_(ProvisioningRun::id.eq(id))
.where_(ProvisioningRun::state.eq(RunState::Running.as_str().to_owned()))
.update()
.set("state", RunState::Failed.as_str())
.set(
"error",
Some("interrupted: the process running it stopped before it finished"),
)
.set("finished_at", chrono::Utc::now())
.set("idempotency_key", None::<String>)
.execute_pool(registry)
.await?;
Ok(n > 0)
}
pub async fn reap_stale_runs(
registry: &Pool,
after: std::time::Duration,
) -> Result<usize, TenancyError> {
let running: Vec<ProvisioningRun> = ProvisioningRun::objects()
.where_(ProvisioningRun::state.eq(RunState::Running.as_str().to_owned()))
.fetch(registry)
.await?;
let mut closed = 0;
for run in running {
if is_stale(registry, &run, after).await?
&& close_interrupted(registry, run.id.get().copied().unwrap_or_default()).await?
{
closed += 1;
}
}
Ok(closed)
}
pub async fn release_for_retry(
registry: &Pool,
run: &ProvisioningRun,
stale_after: std::time::Duration,
) -> Result<bool, TenancyError> {
let Some(id) = run.id.get().copied() else {
return Ok(false);
};
if is_stale(registry, run, stale_after).await? {
return close_interrupted(registry, id).await;
}
if RunState::parse(&run.state) != RunState::Failed {
return Ok(false);
}
let n = ProvisioningRun::objects()
.where_(ProvisioningRun::id.eq(id))
.where_(ProvisioningRun::state.eq(RunState::Failed.as_str().to_owned()))
.update()
.set("idempotency_key", None::<String>)
.execute_pool(registry)
.await?;
Ok(n > 0)
}
pub async fn org_left_by_failed_run(registry: &Pool, org_id: i64) -> Result<bool, TenancyError> {
let creator = ProvisioningRun::objects()
.where_(ProvisioningRun::org_id.eq(Some(org_id)))
.where_(ProvisioningRun::kind.eq(RunKind::Provision.as_str().to_owned()))
.order_by(&[("id", false)])
.limit(1)
.fetch(registry)
.await?
.into_iter()
.next();
Ok(creator.is_some_and(|r| RunState::parse(&r.state) == RunState::Failed))
}
pub(crate) fn forget_unsucceeded_runs(
org_id: i64,
) -> Result<crate::core::UpdateQuery, crate::sql::ExecError> {
ProvisioningRun::objects()
.where_(ProvisioningRun::org_id.eq(Some(org_id)))
.where_(ProvisioningRun::kind.eq(RunKind::Provision.as_str().to_owned()))
.where_(ProvisioningRun::state.ne(RunState::Succeeded.as_str().to_owned()))
.update()
.set("org_id", None::<i64>)
.compile()
.map_err(crate::sql::ExecError::from)
}
pub async fn prune_runs(
registry: &Pool,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<usize, TenancyError> {
let stale: Vec<ProvisioningRun> = ProvisioningRun::objects()
.where_(ProvisioningRun::finished_at.lt(Some(cutoff)))
.fetch(registry)
.await?;
let mut removed = 0;
for run in stale {
let Some(id) = run.id.get().copied() else {
continue;
};
let events: Vec<ProvisioningEvent> = ProvisioningEvent::objects()
.where_(ProvisioningEvent::run_id.eq(id))
.fetch(registry)
.await?;
for event in events {
event.delete_pool(registry).await?;
}
run.delete_pool(registry).await?;
removed += 1;
}
Ok(removed)
}