use chrono::{DateTime, Utc};
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::pool::AllocationRef;
#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[kube(
group = "tatara.pleme.io",
version = "v1alpha1",
kind = "EphemeralAllocation",
plural = "ephemeralallocations",
shortname = "ealloc",
namespaced,
status = "AllocationStatus",
printcolumn = r#"{"name":"Pool","type":"string","jsonPath":".spec.poolRef.name"}"#,
printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
printcolumn = r#"{"name":"Process","type":"string","jsonPath":".status.assignedProcess.name"}"#,
printcolumn = r#"{"name":"Requestor","type":"string","jsonPath":".spec.requestor.kind"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct AllocationSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool_ref: Option<AllocationRef>,
pub requestor: Requestor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Requestor {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
#[serde(default)]
pub pr_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
}
impl Requestor {
#[must_use]
pub fn known_kind(&self) -> Option<RequestorKind> {
self.kind.parse().ok()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_lisp::DeriveClosedSet)]
#[closed_set(via = "as_str", generate_unknown, display)]
pub enum RequestorKind {
GithubPr,
Manual,
CiRun,
Scheduled,
}
impl RequestorKind {
pub const ALL: [Self; 4] = [Self::GithubPr, Self::Manual, Self::CiRun, Self::Scheduled];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::GithubPr => "github-pr",
Self::Manual => "manual",
Self::CiRun => "ci-run",
Self::Scheduled => "scheduled",
}
}
}
impl From<RequestorKind> for String {
fn from(k: RequestorKind) -> Self {
k.as_str().to_owned()
}
}
impl From<RequestorKind> for &'static str {
fn from(k: RequestorKind) -> Self {
k.as_str()
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AllocationStatus {
#[serde(default)]
pub phase: AllocationPhase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase_since: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound_pool: Option<AllocationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_process: Option<AllocationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allocated_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default)]
pub conditions: Vec<AllocationCondition>,
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
tatara_lisp::DeriveClosedSet,
)]
#[serde(rename_all = "PascalCase")]
#[closed_set(via = "as_str", generate_unknown, display)]
pub enum AllocationPhase {
Pending,
Queued,
Bound,
Releasing,
Released,
NoMatchingPool,
Failed,
}
impl Default for AllocationPhase {
fn default() -> Self {
Self::Pending
}
}
impl AllocationPhase {
pub const ALL: [Self; 7] = [
Self::Pending,
Self::Queued,
Self::Bound,
Self::Releasing,
Self::Released,
Self::NoMatchingPool,
Self::Failed,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "Pending",
Self::Queued => "Queued",
Self::Bound => "Bound",
Self::Releasing => "Releasing",
Self::Released => "Released",
Self::NoMatchingPool => "NoMatchingPool",
Self::Failed => "Failed",
}
}
pub const fn is_terminal(self) -> bool {
match self {
Self::Released | Self::Failed => true,
Self::Pending | Self::Queued | Self::Bound | Self::Releasing | Self::NoMatchingPool => {
false
}
}
}
pub const fn needs_pool_routing(self) -> bool {
match self {
Self::Pending | Self::Queued | Self::NoMatchingPool => true,
Self::Bound | Self::Releasing | Self::Released | Self::Failed => false,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AllocationCondition {
pub type_: String,
pub status: String,
pub reason: String,
pub message: String,
pub last_transition_time: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
#[test]
fn requestor_minimum_shape_round_trips() {
let r = Requestor {
kind: "github-pr".into(),
repo: Some("pleme-io/akeyless-deployment".into()),
branch: Some("fix-something".into()),
pr_number: Some(123),
sha: Some("abc123def".into()),
pr_labels: vec!["needs-akeyless".into()],
actor: Some("drzln".into()),
};
let yaml = serde_yaml::to_string(&r).unwrap();
assert!(yaml.contains("kind: github-pr"));
assert!(yaml.contains("prNumber: 123"));
let back: Requestor = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(back.kind, "github-pr");
assert_eq!(back.pr_number, Some(123));
}
#[test]
fn allocation_status_defaults_pending() {
let s = AllocationStatus::default();
assert_eq!(s.phase, AllocationPhase::Pending);
assert!(s.bound_pool.is_none());
assert!(s.assigned_process.is_none());
}
#[test]
fn allocation_phase_round_trips_via_serde() {
for p in [
AllocationPhase::Pending,
AllocationPhase::Queued,
AllocationPhase::Bound,
AllocationPhase::Releasing,
AllocationPhase::Released,
AllocationPhase::NoMatchingPool,
AllocationPhase::Failed,
] {
let s = serde_yaml::to_string(&p).unwrap();
let back: AllocationPhase = serde_yaml::from_str(&s).unwrap();
assert_eq!(back, p);
}
}
#[test]
fn allocation_phase_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<AllocationPhase>();
}
#[test]
fn allocation_phase_as_str_matches_serde() {
for phase in AllocationPhase::ALL {
let serialized = serde_json::to_string(&phase).expect("serialize");
let unquoted = serialized
.trim_start_matches('"')
.trim_end_matches('"')
.to_string();
assert_eq!(
unquoted,
phase.as_str(),
"as_str drift for {phase:?}: as_str={} serde={unquoted}",
phase.as_str()
);
}
}
#[test]
fn allocation_phase_display_matches_as_str() {
for phase in AllocationPhase::ALL {
assert_eq!(phase.to_string(), phase.as_str());
}
}
#[test]
fn unknown_allocation_phase_errors() {
for bad in [
"pending",
"BOUND",
"no-matching-pool",
"release",
"failed_state",
"Reaped",
] {
let err = AllocationPhase::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn allocation_phase_predicate_truth_tables() {
assert!(!AllocationPhase::Pending.is_terminal());
assert!(AllocationPhase::Pending.needs_pool_routing());
assert!(!AllocationPhase::Queued.is_terminal());
assert!(AllocationPhase::Queued.needs_pool_routing());
assert!(!AllocationPhase::Bound.is_terminal());
assert!(!AllocationPhase::Bound.needs_pool_routing());
assert!(!AllocationPhase::Releasing.is_terminal());
assert!(!AllocationPhase::Releasing.needs_pool_routing());
assert!(AllocationPhase::Released.is_terminal());
assert!(!AllocationPhase::Released.needs_pool_routing());
assert!(!AllocationPhase::NoMatchingPool.is_terminal());
assert!(AllocationPhase::NoMatchingPool.needs_pool_routing());
assert!(AllocationPhase::Failed.is_terminal());
assert!(!AllocationPhase::Failed.needs_pool_routing());
}
#[test]
fn allocation_phase_terminal_excludes_routing() {
for phase in AllocationPhase::ALL {
assert!(
!(phase.is_terminal() && phase.needs_pool_routing()),
"{phase:?} is both terminal and routing-eligible",
);
}
}
#[test]
fn allocation_phase_default_is_pending_and_routes() {
let d = AllocationPhase::default();
assert_eq!(d, AllocationPhase::Pending);
assert!(!d.is_terminal());
assert!(d.needs_pool_routing());
}
#[test]
fn requestor_kind_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<RequestorKind>();
}
#[test]
fn requestor_kind_canonical_names_pinned() {
assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
assert_eq!(RequestorKind::Manual.as_str(), "manual");
assert_eq!(RequestorKind::CiRun.as_str(), "ci-run");
assert_eq!(RequestorKind::Scheduled.as_str(), "scheduled");
}
#[test]
fn requestor_kind_from_str_rejects_open_kinds() {
for bad in [
"github_pr",
"GithubPr",
"operator-custom-kind",
"ci_run",
"Scheduled",
] {
let err = bad.parse::<RequestorKind>().unwrap_err();
assert_eq!(err, UnknownRequestorKind(bad.to_string()));
}
}
#[test]
fn requestor_kind_display_delegates_to_as_str() {
for k in RequestorKind::ALL {
assert_eq!(format!("{k}"), k.as_str());
}
}
#[test]
fn requestor_kind_into_string_matches_as_str() {
for k in RequestorKind::ALL {
let s: String = k.into();
assert_eq!(s, k.as_str());
}
}
#[test]
fn known_kind_decodes_built_requestors() {
for k in RequestorKind::ALL {
let r = Requestor {
kind: k.into(),
repo: None,
branch: None,
pr_number: None,
sha: None,
pr_labels: vec![],
actor: None,
};
assert_eq!(r.known_kind(), Some(k), "round-trip failed for {k:?}");
}
}
#[test]
fn known_kind_returns_none_for_open_kinds() {
let r = Requestor {
kind: "operator-custom-kind".into(),
repo: None,
branch: None,
pr_number: None,
sha: None,
pr_labels: vec![],
actor: None,
};
assert_eq!(r.known_kind(), None);
}
#[test]
fn requestor_kind_matches_existing_fixture_literals() {
assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
assert_eq!(RequestorKind::Manual.as_str(), "manual");
}
#[test]
fn allocation_spec_omits_optional_fields() {
let s = AllocationSpec {
pool_ref: None,
requestor: Requestor {
kind: "manual".into(),
repo: None,
branch: None,
pr_number: None,
sha: None,
pr_labels: vec![],
actor: None,
},
ttl: None,
note: None,
};
let yaml = serde_yaml::to_string(&s).unwrap();
assert!(!yaml.contains("poolRef"));
assert!(!yaml.contains("ttl"));
assert!(!yaml.contains("note"));
}
}