use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use workload_spec::{
EnvValue, EnvVar, ExposeSpec, HealthProbe, Healthcheck, ImageRef, MeshExpose, MeshIdent,
Millis, NamespaceId, ResourceLimits, RestartPolicy, SchemaVersion, StopPolicy, TenantId,
TierTag, Workload, WorkloadSpec, HOST_NETWORK_ANNOTATION, HOST_NETWORK_VALUE,
};
pub const RUNNER_WORKLOAD_NAME: &str = "yah-cloud-runner";
const CONTAINER_TENANT_DIR: &str = "/etc/almanac";
const CONTAINER_DATA_DIR: &str = "/data";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MesofactRunnerSpec {
pub bind_port: u16,
pub tenant_config_dir: PathBuf,
pub data_dir: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mirror_key: Option<String>,
#[serde(with = "duration_secs_serde")]
pub ready_timeout: Duration,
#[serde(default = "default_liveness_path")]
pub liveness_path: String,
#[serde(default = "default_readiness_path")]
pub readiness_path: String,
}
impl MesofactRunnerSpec {
pub fn into_container_workload(&self, image: ImageRef) -> Workload {
let port = self.bind_port;
let mut env = vec![
literal_env("ALMANAC_PORT", port.to_string()),
literal_env(
"ALMANAC_ENV",
self.env_label.clone().unwrap_or_else(|| "cloud".into()),
),
literal_env("ALMANAC_DIR", CONTAINER_TENANT_DIR.to_string()),
literal_env("ALMANAC_PROJECT_ROOT", CONTAINER_DATA_DIR.to_string()),
];
if let Some(key) = &self.mirror_key {
env.push(literal_env("ALMANAC_MIRROR_KEY", key.clone()));
}
let mut annotations = HashMap::new();
annotations.insert(
HOST_NETWORK_ANNOTATION.to_string(),
HOST_NETWORK_VALUE.to_string(),
);
let grace = Millis::from_secs(5);
let spec = WorkloadSpec {
schema_version: SchemaVersion::V1,
name: RUNNER_WORKLOAD_NAME.into(),
image,
tier: TierTag("infra".into()),
tenant: TenantId::singleton(),
namespace: NamespaceId::singleton(),
replicas: 1,
command: Some(vec![
"/usr/bin/tini".into(),
"--".into(),
"/usr/local/bin/almanac-serve".into(),
]),
entrypoint: None,
workdir: None,
user: None,
env,
secrets: vec![],
volumes: vec![],
resources: ResourceLimits {
memory_mb: 256,
cpu_millis: 512,
ephemeral_storage_mb: 512,
},
depends_on: vec![],
healthcheck: Some(Healthcheck {
probe: HealthProbe::HttpGet {
path: self.liveness_path.clone(),
port,
expect_status: None,
},
interval: Millis::from_secs(10),
timeout: Millis::from_secs(2),
initial_delay: Millis::from_secs(10),
failure_threshold: 3,
}),
restart_policy: RestartPolicy::Always,
archetype: None,
stop_policy: StopPolicy {
signal: 15,
grace_period: grace,
},
expose: ExposeSpec {
mesh: MeshExpose {
identity: MeshIdent(RUNNER_WORKLOAD_NAME.into()),
ports: vec![port],
allow_from: vec![],
},
public: None,
operator: None,
},
labels: HashMap::new(),
annotations,
};
Workload::Container(spec)
}
}
fn literal_env(name: &str, value: String) -> EnvVar {
EnvVar {
name: name.into(),
value: EnvValue::Literal { value },
}
}
fn default_liveness_path() -> String {
"/healthz".into()
}
fn default_readiness_path() -> String {
"/readyz".into()
}
mod duration_secs_serde {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
d.as_secs().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
Ok(Duration::from_secs(u64::deserialize(d)?))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MesofactRunnerRunning {
pub endpoint: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_round_trips_through_serde() {
let spec = MesofactRunnerSpec {
bind_port: 4323,
tenant_config_dir: PathBuf::from("/var/lib/yah-cloud/tenants"),
data_dir: PathBuf::from("/var/lib/yah-cloud/data"),
env_label: Some("cloud".into()),
mirror_key: None,
ready_timeout: Duration::from_secs(30),
liveness_path: "/healthz".into(),
readiness_path: "/readyz".into(),
};
let s = serde_json::to_string(&spec).unwrap();
let round: MesofactRunnerSpec = serde_json::from_str(&s).unwrap();
assert_eq!(round.bind_port, 4323);
assert_eq!(round.env_label.as_deref(), Some("cloud"));
assert_eq!(round.liveness_path, "/healthz");
}
fn sample_spec() -> MesofactRunnerSpec {
MesofactRunnerSpec {
bind_port: 4323,
tenant_config_dir: PathBuf::from("/var/lib/yah-cloud/tenants"),
data_dir: PathBuf::from("/var/lib/yah-cloud/data"),
env_label: Some("cloud".into()),
mirror_key: None,
ready_timeout: Duration::from_secs(30),
liveness_path: "/healthz".into(),
readiness_path: "/readyz".into(),
}
}
fn sample_image() -> ImageRef {
ImageRef {
registry: "localhost".into(),
repository: "yah-cloud-runner".into(),
tag: "r330f11".into(),
digest: workload_spec::testing::test_digest(),
}
}
#[test]
fn lowers_to_infra_host_networked_container() {
let Workload::Container(spec) = sample_spec().into_container_workload(sample_image()) else {
panic!("expected a Container workload");
};
assert!(spec.wants_host_network());
assert_eq!(spec.tier.0, "infra");
assert!(spec
.env
.iter()
.any(|e| e.name == "ALMANAC_PORT"
&& matches!(&e.value, EnvValue::Literal { value } if value == "4323")));
assert_eq!(spec.expose.mesh.ports, vec![4323]);
match &spec.healthcheck.as_ref().unwrap().probe {
HealthProbe::HttpGet { path, port, .. } => {
assert_eq!(path, "/healthz");
assert_eq!(*port, 4323);
}
other => panic!("expected HttpGet probe, got {other:?}"),
}
assert_eq!(
spec.command.as_deref().unwrap(),
["/usr/bin/tini", "--", "/usr/local/bin/almanac-serve"]
);
workload_spec::validate::shape(&spec).expect("lowered spec passes shape validation");
}
#[test]
fn mirror_key_is_threaded_when_present() {
let mut s = sample_spec();
s.mirror_key = Some("keystore:cloud".into());
let Workload::Container(spec) = s.into_container_workload(sample_image()) else {
panic!("expected Container");
};
assert!(spec.env.iter().any(|e| e.name == "ALMANAC_MIRROR_KEY"));
}
#[test]
fn defaults_for_optional_paths() {
let json_src = r#"{
"bind_port": 4323,
"tenant_config_dir": "/etc/yah-cloud/tenants",
"data_dir": "/var/lib/yah-cloud",
"ready_timeout": 30
}"#;
let spec: MesofactRunnerSpec = serde_json::from_str(json_src).unwrap();
assert_eq!(spec.liveness_path, "/healthz");
assert_eq!(spec.readiness_path, "/readyz");
assert!(spec.env_label.is_none());
}
}