use crate::version::SchemaVersion;
use greentic_types::EnvId;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OnNotifyAction {
RecordOnly,
#[default]
Stage,
}
impl OnNotifyAction {
pub fn as_str(&self) -> &'static str {
match self {
OnNotifyAction::RecordOnly => "record_only",
OnNotifyAction::Stage => "stage",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"record_only" | "record-only" => Some(OnNotifyAction::RecordOnly),
"stage" => Some(OnNotifyAction::Stage),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum UpdateAction {
RecordOnly,
#[default]
Stage,
Apply,
}
impl UpdateAction {
pub fn as_str(&self) -> &'static str {
match self {
UpdateAction::RecordOnly => "record_only",
UpdateAction::Stage => "stage",
UpdateAction::Apply => "apply",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"record_only" | "record-only" => Some(UpdateAction::RecordOnly),
"stage" => Some(UpdateAction::Stage),
"apply" => Some(UpdateAction::Apply),
_ => None,
}
}
pub fn legacy_on_notify(self) -> OnNotifyAction {
match self {
UpdateAction::RecordOnly => OnNotifyAction::RecordOnly,
UpdateAction::Stage | UpdateAction::Apply => OnNotifyAction::Stage,
}
}
}
impl From<OnNotifyAction> for UpdateAction {
fn from(legacy: OnNotifyAction) -> Self {
match legacy {
OnNotifyAction::RecordOnly => UpdateAction::RecordOnly,
OnNotifyAction::Stage => UpdateAction::Stage,
}
}
}
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 3600;
pub const MIN_POLL_INTERVAL_SECS: u64 = 60;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UpdateChannelConfig {
pub schema: SchemaVersion,
pub environment_id: EnvId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_notify: Option<OnNotifyAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_update: Option<UpdateAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll_interval_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_endpoint: Option<String>,
#[serde(flatten)]
pub unknown: serde_json::Map<String, serde_json::Value>,
}
impl UpdateChannelConfig {
pub fn schema_str() -> &'static str {
SchemaVersion::UPDATE_CHANNEL_V1
}
pub fn disabled(environment_id: EnvId) -> Self {
Self {
schema: SchemaVersion::new(SchemaVersion::UPDATE_CHANNEL_V1),
environment_id,
enabled: None,
on_notify: None,
on_update: None,
poll_interval_secs: None,
plan_endpoint: None,
unknown: serde_json::Map::new(),
}
}
pub fn resolved_enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn resolved_on_notify(&self) -> OnNotifyAction {
self.on_notify.unwrap_or_default()
}
pub fn resolved_action(&self) -> UpdateAction {
match self.on_update {
Some(action) => action,
None => self.resolved_on_notify().into(),
}
}
pub fn set_action(&mut self, action: UpdateAction) {
self.on_update = Some(action);
self.on_notify = Some(action.legacy_on_notify());
}
pub fn resolved_poll_interval_secs(&self) -> u64 {
self.poll_interval_secs
.unwrap_or(DEFAULT_POLL_INTERVAL_SECS)
.max(MIN_POLL_INTERVAL_SECS)
}
pub fn resolved_plan_endpoint(&self) -> Option<&str> {
self.plan_endpoint.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(id: &str) -> EnvId {
EnvId::try_from(id).unwrap()
}
#[test]
fn disabled_resolves_deny_by_default() {
let cfg = UpdateChannelConfig::disabled(env("local"));
assert!(!cfg.resolved_enabled());
assert_eq!(cfg.resolved_on_notify(), OnNotifyAction::Stage);
assert_eq!(
cfg.resolved_poll_interval_secs(),
DEFAULT_POLL_INTERVAL_SECS
);
}
#[test]
fn poll_interval_floored_at_minimum() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.poll_interval_secs = Some(1);
assert_eq!(cfg.resolved_poll_interval_secs(), MIN_POLL_INTERVAL_SECS);
}
#[test]
fn on_notify_round_trips_and_parses() {
assert_eq!(OnNotifyAction::parse("stage"), Some(OnNotifyAction::Stage));
assert_eq!(
OnNotifyAction::parse("record-only"),
Some(OnNotifyAction::RecordOnly)
);
assert_eq!(
OnNotifyAction::parse("record_only"),
Some(OnNotifyAction::RecordOnly)
);
assert_eq!(OnNotifyAction::parse("apply"), None);
let json = serde_json::to_string(&OnNotifyAction::RecordOnly).unwrap();
assert_eq!(json, "\"record_only\"");
}
#[test]
fn json_round_trip_omits_unset_fields() {
let cfg = UpdateChannelConfig::disabled(env("local"));
let json = serde_json::to_value(&cfg).unwrap();
assert!(json.get("enabled").is_none());
assert!(json.get("on_notify").is_none());
assert!(json.get("poll_interval_secs").is_none());
assert!(json.get("plan_endpoint").is_none());
let back: UpdateChannelConfig = serde_json::from_value(json).unwrap();
assert_eq!(back, cfg);
}
#[test]
fn plan_endpoint_round_trips() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.plan_endpoint = Some("https://updates.example.com/plans/latest".into());
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(
json.get("plan_endpoint").and_then(|v| v.as_str()),
Some("https://updates.example.com/plans/latest")
);
let back: UpdateChannelConfig = serde_json::from_value(json).unwrap();
assert_eq!(back.plan_endpoint, cfg.plan_endpoint);
assert_eq!(
back.resolved_plan_endpoint(),
Some("https://updates.example.com/plans/latest")
);
}
#[test]
fn unknown_fields_survive_read_modify_write() {
let on_disk = serde_json::json!({
"schema": UpdateChannelConfig::schema_str(),
"environment_id": "local",
"enabled": true,
"future_field": { "nested": [1, 2, 3] },
});
let mut cfg: UpdateChannelConfig = serde_json::from_value(on_disk).unwrap();
assert_eq!(cfg.enabled, Some(true));
assert_eq!(
cfg.unknown.get("future_field"),
Some(&serde_json::json!({ "nested": [1, 2, 3] }))
);
assert!(!cfg.unknown.contains_key("enabled"));
cfg.on_notify = Some(OnNotifyAction::RecordOnly);
let rewritten = serde_json::to_value(&cfg).unwrap();
assert_eq!(
rewritten.get("future_field"),
Some(&serde_json::json!({ "nested": [1, 2, 3] }))
);
assert_eq!(
rewritten.get("on_notify").and_then(|v| v.as_str()),
Some("record_only")
);
assert_eq!(
rewritten.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn no_unknown_fields_serializes_clean() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.enabled = Some(true);
let json = serde_json::to_value(&cfg).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 3, "unexpected keys: {obj:?}");
}
#[test]
fn resolved_action_defaults_to_stage_never_apply() {
let cfg = UpdateChannelConfig::disabled(env("local"));
assert_eq!(cfg.resolved_action(), UpdateAction::Stage);
}
#[test]
fn resolved_action_maps_legacy_on_notify_when_on_update_unset() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.on_notify = Some(OnNotifyAction::RecordOnly);
assert_eq!(cfg.resolved_action(), UpdateAction::RecordOnly);
cfg.on_notify = Some(OnNotifyAction::Stage);
assert_eq!(cfg.resolved_action(), UpdateAction::Stage);
}
#[test]
fn on_update_wins_over_legacy_on_notify() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.on_notify = Some(OnNotifyAction::Stage);
cfg.on_update = Some(UpdateAction::Apply);
assert_eq!(cfg.resolved_action(), UpdateAction::Apply);
}
#[test]
fn set_action_keeps_legacy_field_safe_under_rollback() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.set_action(UpdateAction::Apply);
assert_eq!(cfg.resolved_action(), UpdateAction::Apply);
assert_eq!(
cfg.on_notify,
Some(OnNotifyAction::Stage),
"an `apply` channel must degrade to `stage` for an older binary"
);
cfg.set_action(UpdateAction::RecordOnly);
assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
}
#[test]
fn old_binary_round_trips_on_update_through_the_catch_all() {
let mut cfg = UpdateChannelConfig::disabled(env("local"));
cfg.enabled = Some(true);
cfg.set_action(UpdateAction::Apply);
let on_disk = serde_json::to_value(&cfg).unwrap();
assert_eq!(
on_disk.get("on_update").and_then(|v| v.as_str()),
Some("apply")
);
assert_eq!(
on_disk.get("on_notify").and_then(|v| v.as_str()),
Some("stage"),
"the legacy field is what an old binary reads"
);
#[derive(serde::Serialize, serde::Deserialize)]
struct LegacyConfig {
schema: SchemaVersion,
environment_id: EnvId,
#[serde(default, skip_serializing_if = "Option::is_none")]
enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
on_notify: Option<OnNotifyAction>,
#[serde(flatten)]
unknown: serde_json::Map<String, serde_json::Value>,
}
let mut legacy: LegacyConfig = serde_json::from_value(on_disk).unwrap();
assert_eq!(legacy.on_notify, Some(OnNotifyAction::Stage));
assert_eq!(
legacy.unknown.get("on_update"),
Some(&serde_json::json!("apply"))
);
legacy.enabled = Some(false);
let rewritten = serde_json::to_value(&legacy).unwrap();
let back: UpdateChannelConfig = serde_json::from_value(rewritten).unwrap();
assert_eq!(back.resolved_action(), UpdateAction::Apply);
assert_eq!(back.enabled, Some(false));
}
#[test]
fn update_action_round_trips_and_parses() {
assert_eq!(UpdateAction::parse("apply"), Some(UpdateAction::Apply));
assert_eq!(UpdateAction::parse("stage"), Some(UpdateAction::Stage));
assert_eq!(
UpdateAction::parse("record-only"),
Some(UpdateAction::RecordOnly)
);
assert_eq!(
UpdateAction::parse("record_only"),
Some(UpdateAction::RecordOnly)
);
assert_eq!(UpdateAction::parse("converge"), None);
assert_eq!(
serde_json::to_string(&UpdateAction::Apply).unwrap(),
"\"apply\""
);
for action in [
UpdateAction::RecordOnly,
UpdateAction::Stage,
UpdateAction::Apply,
] {
assert_eq!(UpdateAction::parse(action.as_str()), Some(action));
}
}
}