use chrono::{DateTime, Utc};
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::attestation::ProcessAttestation;
use crate::boundary::Boundary;
use crate::classification::Classification;
use crate::compliance::ComplianceSpec;
use crate::encapsulates::EncapsulatesSpec;
use crate::identity::Identity;
use crate::intent::Intent;
use crate::lifetime::Lifetime;
use crate::phase::ProcessPhase;
use crate::routing::RoutingSpec;
use crate::signal::ProcessSignal;
use crate::spec::{DependsOn, IdentitySpec, SignalPolicy};
use crate::status::{BoundaryStatus, ComplianceStatus, FluxResourceRef, ProcessCondition};
#[derive(CustomResource, DeriveTataraDomain, Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[kube(
group = "tatara.pleme.io",
version = "v1alpha1",
kind = "Process",
plural = "processes",
shortname = "proc",
namespaced,
status = "ProcessStatus",
printcolumn = r#"{"name":"PID","type":"string","jsonPath":".status.pid"}"#,
printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.classification.pointType"}"#,
printcolumn = r#"{"name":"Substrate","type":"string","jsonPath":".spec.classification.substrate"}"#,
printcolumn = r#"{"name":"Gen","type":"integer","jsonPath":".status.attestation.generation"}"#,
printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defpoint")]
pub struct ProcessSpec {
#[serde(default)]
pub identity: IdentitySpec,
pub classification: Classification,
pub intent: Intent,
#[serde(default)]
pub boundary: Boundary,
#[serde(default)]
pub compliance: ComplianceSpec,
#[serde(default)]
pub depends_on: Vec<DependsOn>,
#[serde(default)]
pub signals: SignalPolicy,
#[serde(default, skip_serializing_if = "Lifetime::is_default")]
pub lifetime: Lifetime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing: Option<RoutingSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encapsulates: Option<EncapsulatesSpec>,
#[serde(default)]
pub suspended: bool,
}
impl Process {
pub const DEFAULT_NAMESPACE: &'static str = "default";
pub const UNNAMED_PLACEHOLDER: &'static str = "unnamed";
pub fn namespace_or_default(&self) -> &str {
self.metadata
.namespace
.as_deref()
.unwrap_or(Self::DEFAULT_NAMESPACE)
}
pub fn name_or_placeholder(&self) -> &str {
self.metadata
.name
.as_deref()
.unwrap_or(Self::UNNAMED_PLACEHOLDER)
}
pub fn coordinates_or_defaults(&self) -> (&str, &str) {
(self.namespace_or_default(), self.name_or_placeholder())
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProcessStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default)]
pub children: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<Identity>,
#[serde(default)]
pub phase: ProcessPhase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase_since: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation: Option<ProcessAttestation>,
#[serde(default)]
pub flux_resources: Vec<FluxResourceRef>,
#[serde(default)]
pub boundary: BoundaryStatus,
#[serde(default)]
pub compliance: ComplianceStatus,
#[serde(default)]
pub signal_queue: Vec<ProcessSignal>,
#[serde(default)]
pub conditions: Vec<ProcessCondition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classification::{ConvergencePointType, SubstrateType};
use crate::intent::NixIntent;
#[test]
fn minimal_spec_serializes() {
let spec = ProcessSpec {
identity: IdentitySpec::default(),
classification: Classification {
point_type: ConvergencePointType::Gate,
substrate: SubstrateType::Observability,
horizon: Default::default(),
calm: Default::default(),
data_classification: Default::default(),
},
intent: Intent {
nix: Some(NixIntent {
flake_ref: "github:pleme-io/k8s".into(),
attribute: "obs".into(),
system: None,
attic_cache: None,
extra_args: vec![],
delegate_to_nix_build: false,
}),
..Intent::default()
},
boundary: Default::default(),
compliance: Default::default(),
depends_on: vec![],
signals: Default::default(),
lifetime: Default::default(),
routing: None,
encapsulates: None,
suspended: false,
};
let yaml = serde_yaml::to_string(&spec).unwrap();
assert!(yaml.contains("pointType: Gate"));
assert!(yaml.contains("substrate: Observability"));
assert!(yaml.contains("flakeRef: github:pleme-io/k8s"));
}
fn empty_spec() -> ProcessSpec {
ProcessSpec {
identity: IdentitySpec::default(),
classification: Classification {
point_type: ConvergencePointType::Gate,
substrate: SubstrateType::Compute,
horizon: Default::default(),
calm: Default::default(),
data_classification: Default::default(),
},
intent: Intent::default(),
boundary: Default::default(),
compliance: Default::default(),
depends_on: vec![],
signals: Default::default(),
lifetime: Default::default(),
routing: None,
encapsulates: None,
suspended: false,
}
}
#[test]
fn default_namespace_constant_is_k8s_canonical_default() {
assert_eq!(Process::DEFAULT_NAMESPACE, "default");
}
#[test]
fn unnamed_placeholder_constant_matches_prior_annotation_writer_fallback() {
assert_eq!(Process::UNNAMED_PLACEHOLDER, "unnamed");
}
#[test]
fn namespace_or_default_falls_back_when_metadata_namespace_is_none() {
let mut p = Process::new("some-proc", empty_spec());
p.metadata.namespace = None;
assert_eq!(p.namespace_or_default(), Process::DEFAULT_NAMESPACE);
}
#[test]
fn namespace_or_default_returns_metadata_slice_when_some() {
let mut p = Process::new("some-proc", empty_spec());
p.metadata.namespace = Some("prod-app".into());
assert_eq!(p.namespace_or_default(), "prod-app");
}
#[test]
fn name_or_placeholder_falls_back_when_metadata_name_is_none() {
let mut p = Process::new("real-name", empty_spec());
p.metadata.name = None;
assert_eq!(p.name_or_placeholder(), Process::UNNAMED_PLACEHOLDER);
}
#[test]
fn name_or_placeholder_returns_metadata_slice_when_some() {
let p = Process::new("api-gateway", empty_spec());
assert_eq!(p.name_or_placeholder(), "api-gateway");
}
#[test]
fn coordinates_or_defaults_composes_both_halves() {
let mut p = Process::new("api", empty_spec());
p.metadata.namespace = Some("staging".into());
assert_eq!(p.coordinates_or_defaults(), ("staging", "api"));
}
#[test]
fn coordinates_or_defaults_falls_back_on_both_slots() {
let mut p = Process::new("scratch", empty_spec());
p.metadata.name = None;
p.metadata.namespace = None;
assert_eq!(
p.coordinates_or_defaults(),
(Process::DEFAULT_NAMESPACE, Process::UNNAMED_PLACEHOLDER)
);
}
#[test]
fn coordinates_or_defaults_mixes_slotted_and_fallback_halves() {
let mut p = Process::new("kept-name", empty_spec());
p.metadata.namespace = Some("prod".into());
assert_eq!(p.coordinates_or_defaults(), ("prod", "kept-name"));
let mut q = Process::new("api", empty_spec());
q.metadata.namespace = None;
assert_eq!(
q.coordinates_or_defaults(),
(Process::DEFAULT_NAMESPACE, "api")
);
}
#[test]
fn coordinates_or_defaults_axis_order_matches_qualified_process_ref() {
let mut p = Process::new("app", empty_spec());
p.metadata.namespace = Some("infra".into());
let (ns, name) = p.coordinates_or_defaults();
assert_eq!(ns, "infra"); assert_eq!(name, "app"); }
}