use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sherpack_core::{PackMetadata, Values};
use std::collections::HashMap;
pub const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::minutes(5);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredRelease {
pub name: String,
pub namespace: String,
pub version: u32,
pub state: ReleaseState,
pub pack: PackMetadata,
pub values: Values,
#[serde(default)]
pub values_provenance: ValuesProvenance,
pub manifest: String,
#[serde(default)]
pub hooks: Vec<crate::hooks::Hook>,
#[serde(default)]
pub labels: HashMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub notes: Option<String>,
}
impl StoredRelease {
pub fn for_install(
name: String,
namespace: String,
pack: PackMetadata,
values: Values,
manifest: String,
) -> Self {
let now = Utc::now();
Self {
name,
namespace,
version: 1,
state: ReleaseState::PendingInstall {
started_at: now,
timeout: DEFAULT_OPERATION_TIMEOUT,
},
pack,
values,
values_provenance: ValuesProvenance::default(),
manifest,
hooks: Vec::new(),
labels: HashMap::new(),
created_at: now,
updated_at: now,
notes: None,
}
}
pub fn for_upgrade(previous: &StoredRelease, values: Values, manifest: String) -> Self {
let now = Utc::now();
Self {
name: previous.name.clone(),
namespace: previous.namespace.clone(),
version: previous.version + 1,
state: ReleaseState::PendingUpgrade {
started_at: now,
timeout: DEFAULT_OPERATION_TIMEOUT,
previous_version: previous.version,
},
pack: previous.pack.clone(),
values,
values_provenance: previous.values_provenance.clone(),
manifest,
hooks: previous.hooks.clone(),
labels: previous.labels.clone(),
created_at: now,
updated_at: now,
notes: previous.notes.clone(),
}
}
pub fn storage_key(&self) -> String {
format!("sh.sherpack.release.v1.{}.v{}", self.name, self.version)
}
pub fn is_terminal(&self) -> bool {
matches!(
self.state,
ReleaseState::Deployed
| ReleaseState::Failed { .. }
| ReleaseState::Uninstalled
| ReleaseState::Superseded
)
}
pub fn is_stuck(&self) -> bool {
self.state.is_stale()
}
pub fn mark_deployed(&mut self) {
self.state = ReleaseState::Deployed;
self.updated_at = Utc::now();
}
pub fn mark_failed(&mut self, reason: String, recoverable: bool) {
self.state = ReleaseState::Failed {
reason,
recoverable,
failed_at: Utc::now(),
};
self.updated_at = Utc::now();
}
pub fn mark_superseded(&mut self) {
self.state = ReleaseState::Superseded;
self.updated_at = Utc::now();
}
pub fn mark_uninstalled(&mut self) {
self.state = ReleaseState::Uninstalled;
self.updated_at = Utc::now();
}
pub fn try_auto_recover(&mut self) -> bool {
if let Some(new_state) = self.state.auto_recover() {
self.state = new_state;
self.updated_at = Utc::now();
true
} else {
false
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(tag = "status", rename_all = "kebab-case")]
#[non_exhaustive]
pub enum ReleaseState {
#[default]
Deployed,
Failed {
reason: String,
recoverable: bool,
failed_at: DateTime<Utc>,
},
Uninstalled,
Superseded,
PendingInstall {
started_at: DateTime<Utc>,
#[serde(with = "duration_serde")]
timeout: Duration,
},
PendingUpgrade {
started_at: DateTime<Utc>,
#[serde(with = "duration_serde")]
timeout: Duration,
previous_version: u32,
},
PendingRollback {
started_at: DateTime<Utc>,
#[serde(with = "duration_serde")]
timeout: Duration,
target_version: u32,
},
PendingUninstall {
started_at: DateTime<Utc>,
#[serde(with = "duration_serde")]
timeout: Duration,
},
Recovering {
from_status: String,
attempt: u32,
started_at: DateTime<Utc>,
},
}
impl ReleaseState {
pub fn is_pending(&self) -> bool {
matches!(
self,
Self::PendingInstall { .. }
| Self::PendingUpgrade { .. }
| Self::PendingRollback { .. }
| Self::PendingUninstall { .. }
| Self::Recovering { .. }
)
}
pub fn is_stale(&self) -> bool {
let now = Utc::now();
match self {
Self::PendingInstall {
started_at,
timeout,
}
| Self::PendingUpgrade {
started_at,
timeout,
..
}
| Self::PendingRollback {
started_at,
timeout,
..
}
| Self::PendingUninstall {
started_at,
timeout,
} => now.signed_duration_since(*started_at) > *timeout,
Self::Recovering { started_at, .. } => {
now.signed_duration_since(*started_at) > Duration::minutes(2)
}
_ => false,
}
}
pub fn elapsed(&self) -> Option<Duration> {
let now = Utc::now();
match self {
Self::PendingInstall { started_at, .. }
| Self::PendingUpgrade { started_at, .. }
| Self::PendingRollback { started_at, .. }
| Self::PendingUninstall { started_at, .. }
| Self::Recovering { started_at, .. } => Some(now.signed_duration_since(*started_at)),
_ => None,
}
}
pub fn auto_recover(&self) -> Option<ReleaseState> {
if self.is_stale() {
Some(ReleaseState::Failed {
reason: format!("Operation timed out (was: {})", self.status_name()),
recoverable: true,
failed_at: Utc::now(),
})
} else {
None
}
}
pub fn status_name(&self) -> &'static str {
match self {
Self::Deployed => "deployed",
Self::Failed { .. } => "failed",
Self::Uninstalled => "uninstalled",
Self::Superseded => "superseded",
Self::PendingInstall { .. } => "pending-install",
Self::PendingUpgrade { .. } => "pending-upgrade",
Self::PendingRollback { .. } => "pending-rollback",
Self::PendingUninstall { .. } => "pending-uninstall",
Self::Recovering { .. } => "recovering",
}
}
}
impl std::fmt::Display for ReleaseState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed { reason, .. } => write!(f, "failed: {}", reason),
Self::Recovering {
from_status,
attempt,
..
} => {
write!(f, "recovering from {} (attempt {})", from_status, attempt)
}
other => write!(f, "{}", other.status_name()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ValuesProvenance {
#[serde(default)]
pub sources: HashMap<String, ValueSource>,
}
impl ValuesProvenance {
pub fn record(&mut self, path: &str, source: ValueSource) {
self.sources.insert(path.to_string(), source);
}
pub fn get_source(&self, path: &str) -> Option<&ValueSource> {
self.sources.get(path)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ValueSource {
SchemaDefault,
PackDefault,
ValuesFile { path: String, line: Option<u32> },
CommandLine {
flag: String,
timestamp: DateTime<Utc>,
user: Option<String>,
},
Environment { var: String },
Merged { sources: Vec<String> },
}
impl std::fmt::Display for ValueSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SchemaDefault => write!(f, "schema default"),
Self::PackDefault => write!(f, "pack default"),
Self::ValuesFile { path, line } => {
if let Some(l) = line {
write!(f, "{}:{}", path, l)
} else {
write!(f, "{}", path)
}
}
Self::CommandLine {
flag,
timestamp,
user,
} => {
if let Some(u) = user {
write!(
f,
"--set {} by {} at {}",
flag,
u,
timestamp.format("%Y-%m-%d %H:%M")
)
} else {
write!(
f,
"--set {} at {}",
flag,
timestamp.format("%Y-%m-%d %H:%M")
)
}
}
Self::Environment { var } => write!(f, "env ${}", var),
Self::Merged { sources } => write!(f, "merged from: {}", sources.join(", ")),
}
}
}
mod duration_serde {
use chrono::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
duration.num_seconds().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let seconds = i64::deserialize(deserializer)?;
Ok(Duration::seconds(seconds))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_release_state_stale_detection() {
let old_time = Utc::now() - Duration::minutes(10);
let state = ReleaseState::PendingInstall {
started_at: old_time,
timeout: Duration::minutes(5),
};
assert!(state.is_stale());
assert!(state.auto_recover().is_some());
}
#[test]
fn test_release_state_not_stale() {
let state = ReleaseState::PendingInstall {
started_at: Utc::now(),
timeout: Duration::minutes(5),
};
assert!(!state.is_stale());
assert!(state.auto_recover().is_none());
}
#[test]
fn test_terminal_states() {
assert!(!ReleaseState::Deployed.is_pending());
assert!(
ReleaseState::PendingInstall {
started_at: Utc::now(),
timeout: Duration::minutes(5),
}
.is_pending()
);
}
#[test]
fn test_storage_key() {
let release = StoredRelease::for_install(
"myapp".to_string(),
"default".to_string(),
PackMetadata {
name: "test".to_string(),
version: semver::Version::new(1, 0, 0),
description: None,
app_version: None,
kube_version: None,
home: None,
icon: None,
sources: vec![],
keywords: vec![],
maintainers: vec![],
annotations: Default::default(),
},
Values::new(),
"apiVersion: v1".to_string(),
);
assert_eq!(release.storage_key(), "sh.sherpack.release.v1.myapp.v1");
}
}