use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Intent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nix: Option<NixIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flux: Option<FluxIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lisp: Option<LispIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<ContainerIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aplicacao: Option<AplicacaoIntent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guest: Option<GuestIntent>,
}
#[derive(Clone, Debug)]
pub enum IntentVariant<'a> {
Nix(&'a NixIntent),
Flux(&'a FluxIntent),
Lisp(&'a LispIntent),
Container(&'a ContainerIntent),
Aplicacao(&'a AplicacaoIntent),
Guest(&'a GuestIntent),
}
impl IntentVariant<'_> {
pub fn kind(&self) -> IntentKind {
match self {
Self::Nix(_) => IntentKind::Nix,
Self::Flux(_) => IntentKind::Flux,
Self::Lisp(_) => IntentKind::Lisp,
Self::Container(_) => IntentKind::Container,
Self::Aplicacao(_) => IntentKind::Aplicacao,
Self::Guest(_) => IntentKind::Guest,
}
}
pub fn canonical_bytes(&self) -> Vec<u8> {
match self {
Self::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
Self::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
Self::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
Self::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
Self::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
Self::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_lisp::DeriveClosedSet)]
#[closed_set(via = "as_str", generate_unknown, display)]
pub enum IntentKind {
Nix,
Flux,
Lisp,
Container,
Aplicacao,
Guest,
}
impl IntentKind {
pub const ALL: [Self; 6] = [
Self::Nix,
Self::Flux,
Self::Lisp,
Self::Container,
Self::Aplicacao,
Self::Guest,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Nix => "nix",
Self::Flux => "flux",
Self::Lisp => "lisp",
Self::Container => "container",
Self::Aplicacao => "aplicacao",
Self::Guest => "guest",
}
}
pub fn select<'a>(self, intent: &'a Intent) -> Option<IntentVariant<'a>> {
match self {
Self::Nix => intent.nix.as_ref().map(IntentVariant::Nix),
Self::Flux => intent.flux.as_ref().map(IntentVariant::Flux),
Self::Lisp => intent.lisp.as_ref().map(IntentVariant::Lisp),
Self::Container => intent.container.as_ref().map(IntentVariant::Container),
Self::Aplicacao => intent.aplicacao.as_ref().map(IntentVariant::Aplicacao),
Self::Guest => intent.guest.as_ref().map(IntentVariant::Guest),
}
}
}
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
pub enum IntentError {
#[error("intent has no variant set (one of {0} required)")]
Empty(&'static str),
#[error("intent has multiple variants set; exactly one required")]
Ambiguous,
}
const INTENT_KIND_LIST: &str = "nix/flux/lisp/container/aplicacao/guest";
impl Intent {
pub fn variant(&self) -> Result<IntentVariant<'_>, IntentError> {
use crate::tagged_union::{resolve, ResolveError};
resolve(IntentKind::ALL.into_iter().map(|k| k.select(self))).map_err(|e| match e {
ResolveError::None => IntentError::Empty(INTENT_KIND_LIST),
ResolveError::Many => IntentError::Ambiguous,
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct NixIntent {
pub flake_ref: String,
pub attribute: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attic_cache: Option<String>,
#[serde(default)]
pub extra_args: Vec<String>,
#[serde(default)]
pub delegate_to_nix_build: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FluxIntent {
pub git_repository: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_repository_namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_namespace: Option<String>,
#[serde(default = "default_true")]
pub decrypt_sops: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub helm_chart: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub helm_values: Option<BTreeMap<String, serde_json::Value>>,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LispIntent {
pub source: String,
#[serde(default = "default_reader")]
pub reader: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub bindings: BTreeMap<String, serde_json::Value>,
}
fn default_reader() -> String {
"tatara-lisp".to_string()
}
fn default_version() -> String {
"v1".to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AplicacaoIntent {
pub chart_ref: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub profile: String,
#[serde(default)]
#[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
pub values_overlay: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_timeout: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContainerIntent {
pub image: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replicas: Option<i32>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub workload_kind: WorkloadKind,
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
Default,
tatara_lisp::DeriveClosedSet,
)]
#[serde(rename_all = "PascalCase")]
#[closed_set(via = "as_str", generate_unknown, display)]
pub enum WorkloadKind {
#[default]
Deployment,
StatefulSet,
DaemonSet,
Job,
CronJob,
}
impl WorkloadKind {
pub const ALL: [Self; 5] = [
Self::Deployment,
Self::StatefulSet,
Self::DaemonSet,
Self::Job,
Self::CronJob,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Deployment => "Deployment",
Self::StatefulSet => "StatefulSet",
Self::DaemonSet => "DaemonSet",
Self::Job => "Job",
Self::CronJob => "CronJob",
}
}
pub const fn api_version(self) -> &'static str {
match self {
Self::Deployment | Self::StatefulSet | Self::DaemonSet => "apps/v1",
Self::Job | Self::CronJob => "batch/v1",
}
}
pub const fn is_batch(self) -> bool {
match self {
Self::Job | Self::CronJob => true,
Self::Deployment | Self::StatefulSet | Self::DaemonSet => false,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GuestIntent {
#[schemars(schema_with = "crate::schema_helpers::preserve_unknown_object")]
pub spec: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_remote_build: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_intent_errors() {
let i = Intent::default();
match i.variant().unwrap_err() {
IntentError::Empty(list) => assert_eq!(list, INTENT_KIND_LIST),
other => panic!("expected Empty, got {other:?}"),
}
}
#[test]
fn exactly_one_ok() {
let i = Intent {
nix: Some(NixIntent {
flake_ref: "github:a/b".into(),
attribute: "x".into(),
system: None,
attic_cache: None,
extra_args: vec![],
delegate_to_nix_build: false,
}),
..Intent::default()
};
assert!(matches!(i.variant().unwrap(), IntentVariant::Nix(_)));
}
#[test]
fn two_variants_ambiguous() {
let i = Intent {
nix: Some(NixIntent {
flake_ref: "a".into(),
attribute: "b".into(),
system: None,
attic_cache: None,
extra_args: vec![],
delegate_to_nix_build: false,
}),
flux: Some(FluxIntent {
git_repository: "g".into(),
path: "p".into(),
git_repository_namespace: None,
target_namespace: None,
decrypt_sops: true,
helm_chart: None,
helm_values: None,
}),
..Intent::default()
};
assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
}
#[test]
fn guest_intent_selects_its_variant() {
let i = Intent {
guest: Some(GuestIntent {
spec: serde_json::json!({
"name": "fast-fn",
"kind": { "kind": "wasm", "runtime": "wasmtime",
"wasiPreview": "p2",
"component": { "kind": "flake",
"value": {"url":"github:x/y","attr":"wasi"} },
"features": { "simd": true } },
"cmdline": []
}),
state_dir: None,
allow_remote_build: Some(true),
}),
..Intent::default()
};
match i.variant().unwrap() {
IntentVariant::Guest(g) => {
assert_eq!(g.spec["name"], "fast-fn");
assert_eq!(g.allow_remote_build, Some(true));
}
other => panic!("expected Guest, got {other:?}"),
}
}
#[test]
fn guest_plus_nix_is_ambiguous() {
let i = Intent {
nix: Some(NixIntent {
flake_ref: "github:a/b".into(),
attribute: "x".into(),
system: None,
attic_cache: None,
extra_args: vec![],
delegate_to_nix_build: false,
}),
guest: Some(GuestIntent {
spec: serde_json::json!({"name": "x"}),
state_dir: None,
allow_remote_build: None,
}),
..Intent::default()
};
assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
}
#[test]
fn aplicacao_intent_selects_its_variant() {
let i = Intent {
aplicacao: Some(AplicacaoIntent {
chart_ref: "oci://ghcr.io/pleme-io/charts/lareira-akeyless-deployment".into(),
version: "0.5.5".into(),
profile: "gateway-with-internal-saas".into(),
values_overlay: serde_json::json!({ "cluster": { "name": "test-01" } }),
release_name: None,
target_namespace: None,
install_timeout: Some("25m".into()),
}),
..Intent::default()
};
match i.variant().unwrap() {
IntentVariant::Aplicacao(a) => {
assert_eq!(a.profile, "gateway-with-internal-saas");
assert_eq!(a.version, "0.5.5");
assert_eq!(a.install_timeout.as_deref(), Some("25m"));
}
other => panic!("expected Aplicacao, got {other:?}"),
}
}
#[test]
fn intent_kind_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<IntentKind>();
}
#[test]
fn intent_kind_display_matches_as_str() {
for kind in IntentKind::ALL {
assert_eq!(kind.to_string(), kind.as_str());
}
}
#[test]
fn intent_kind_as_str_matches_intent_field_name() {
for kind in IntentKind::ALL {
let i = match kind {
IntentKind::Nix => Intent {
nix: Some(NixIntent {
flake_ref: "f".into(),
attribute: "a".into(),
system: None,
attic_cache: None,
extra_args: vec![],
delegate_to_nix_build: false,
}),
..Intent::default()
},
IntentKind::Flux => Intent {
flux: Some(FluxIntent {
git_repository: "g".into(),
path: "p".into(),
git_repository_namespace: None,
target_namespace: None,
decrypt_sops: true,
helm_chart: None,
helm_values: None,
}),
..Intent::default()
},
IntentKind::Lisp => Intent {
lisp: Some(LispIntent {
source: "()".into(),
reader: "tatara-lisp".into(),
version: "v1".into(),
bindings: BTreeMap::new(),
}),
..Intent::default()
},
IntentKind::Container => Intent {
container: Some(ContainerIntent {
image: "x".into(),
replicas: None,
command: vec![],
args: vec![],
env: BTreeMap::new(),
workload_kind: WorkloadKind::default(),
}),
..Intent::default()
},
IntentKind::Aplicacao => Intent {
aplicacao: Some(AplicacaoIntent {
chart_ref: "x".into(),
version: "1".into(),
profile: String::new(),
values_overlay: serde_json::Value::Null,
release_name: None,
target_namespace: None,
install_timeout: None,
}),
..Intent::default()
},
IntentKind::Guest => Intent {
guest: Some(GuestIntent {
spec: serde_json::json!({"name": "x"}),
state_dir: None,
allow_remote_build: None,
}),
..Intent::default()
},
};
let v = serde_json::to_value(&i).expect("Intent serializes");
let obj = v.as_object().expect("Intent serializes to object");
let keys: Vec<&String> = obj.keys().collect();
assert_eq!(
keys.len(),
1,
"exactly one slot populated for kind {kind:?}, got {keys:?}"
);
assert_eq!(
keys[0],
kind.as_str(),
"as_str() must match serde field name for {kind:?}"
);
}
}
#[test]
fn intent_kind_round_trips_through_variant_kind() {
for kind in IntentKind::ALL {
let i = single_slot_intent(kind);
let v = kind.select(&i).expect("populated slot must select");
assert_eq!(v.kind(), kind, "round-trip failed for {kind:?}");
assert_eq!(
i.variant().expect("exactly-one variant").kind(),
kind,
"variant() resolver disagreed on {kind:?}"
);
}
}
#[test]
fn intent_error_empty_lists_every_kind_in_canonical_order() {
assert_eq!(
<IntentKind as tatara_lisp::ClosedSet>::labels_joined("/"),
INTENT_KIND_LIST,
);
}
#[test]
fn intent_variant_canonical_bytes_matches_inner_serialize() {
for kind in IntentKind::ALL {
let i = single_slot_intent(kind);
let v = i.variant().expect("exactly-one variant");
let via_method = v.canonical_bytes();
let expected: Vec<u8> = match &v {
IntentVariant::Nix(n) => serde_json::to_vec(n).unwrap_or_default(),
IntentVariant::Flux(f) => serde_json::to_vec(f).unwrap_or_default(),
IntentVariant::Lisp(l) => serde_json::to_vec(l).unwrap_or_default(),
IntentVariant::Container(c) => serde_json::to_vec(c).unwrap_or_default(),
IntentVariant::Aplicacao(a) => serde_json::to_vec(a).unwrap_or_default(),
IntentVariant::Guest(g) => serde_json::to_vec(g).unwrap_or_default(),
};
assert_eq!(
via_method, expected,
"canonical_bytes mismatch for {kind:?}"
);
assert!(!via_method.is_empty(), "{kind:?} produced empty bytes");
}
}
fn single_slot_intent(kind: IntentKind) -> Intent {
match kind {
IntentKind::Nix => Intent {
nix: Some(NixIntent {
flake_ref: "github:a/b".into(),
attribute: "x".into(),
system: None,
attic_cache: None,
extra_args: vec![],
delegate_to_nix_build: false,
}),
..Intent::default()
},
IntentKind::Flux => Intent {
flux: Some(FluxIntent {
git_repository: "g".into(),
path: "p".into(),
git_repository_namespace: None,
target_namespace: None,
decrypt_sops: true,
helm_chart: None,
helm_values: None,
}),
..Intent::default()
},
IntentKind::Lisp => Intent {
lisp: Some(LispIntent {
source: "()".into(),
reader: "tatara-lisp".into(),
version: "v1".into(),
bindings: BTreeMap::new(),
}),
..Intent::default()
},
IntentKind::Container => Intent {
container: Some(ContainerIntent {
image: "ghcr.io/x:1".into(),
replicas: Some(1),
command: vec![],
args: vec![],
env: BTreeMap::new(),
workload_kind: WorkloadKind::default(),
}),
..Intent::default()
},
IntentKind::Aplicacao => Intent {
aplicacao: Some(AplicacaoIntent {
chart_ref: "oci://ghcr.io/x".into(),
version: "0.1.0".into(),
profile: String::new(),
values_overlay: serde_json::Value::Null,
release_name: None,
target_namespace: None,
install_timeout: None,
}),
..Intent::default()
},
IntentKind::Guest => Intent {
guest: Some(GuestIntent {
spec: serde_json::json!({"name": "guest-1"}),
state_dir: None,
allow_remote_build: None,
}),
..Intent::default()
},
}
}
#[test]
fn workload_kind_is_well_formed_closed_set() {
tatara_lisp::assert_closed_set_well_formed::<WorkloadKind>();
}
#[test]
fn workload_kind_as_str_matches_serde() {
for kind in WorkloadKind::ALL {
let serialized = serde_json::to_string(&kind).expect("serialize");
let unquoted = serialized
.trim_start_matches('"')
.trim_end_matches('"')
.to_string();
assert_eq!(
unquoted,
kind.as_str(),
"as_str drift for {kind:?}: as_str={} serde={unquoted}",
kind.as_str()
);
}
}
#[test]
fn workload_kind_display_matches_as_str() {
for kind in WorkloadKind::ALL {
assert_eq!(kind.to_string(), kind.as_str());
}
}
#[test]
fn unknown_workload_kind_errors() {
use std::str::FromStr;
for bad in ["deployment", "JOB", "ReplicaSet", "Pod"] {
let err = WorkloadKind::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn workload_kind_default_is_deployment() {
assert_eq!(WorkloadKind::default(), WorkloadKind::Deployment);
}
#[test]
fn workload_kind_projection_truth_table() {
let table: &[(WorkloadKind, &str, bool)] = &[
(WorkloadKind::Deployment, "apps/v1", false),
(WorkloadKind::StatefulSet, "apps/v1", false),
(WorkloadKind::DaemonSet, "apps/v1", false),
(WorkloadKind::Job, "batch/v1", true),
(WorkloadKind::CronJob, "batch/v1", true),
];
assert_eq!(table.len(), WorkloadKind::ALL.len());
for (kind, api, batch) in table {
assert_eq!(kind.api_version(), *api, "api_version drift for {kind:?}");
assert_eq!(kind.is_batch(), *batch, "is_batch drift for {kind:?}");
assert_eq!(
kind.is_batch(),
kind.api_version() == "batch/v1",
"is_batch / api_version partition disagrees for {kind:?}"
);
}
}
#[test]
fn aplicacao_plus_flux_is_ambiguous() {
let i = Intent {
aplicacao: Some(AplicacaoIntent {
chart_ref: "x".into(),
version: "1".into(),
profile: String::new(),
values_overlay: serde_json::Value::Null,
release_name: None,
target_namespace: None,
install_timeout: None,
}),
flux: Some(FluxIntent {
git_repository: "g".into(),
path: "p".into(),
git_repository_namespace: None,
target_namespace: None,
decrypt_sops: true,
helm_chart: None,
helm_values: None,
}),
..Intent::default()
};
assert_eq!(i.variant().unwrap_err(), IntentError::Ambiguous);
}
}