use greentic_deploy_spec::{CapabilitySlot, EnvId, Environment, Revision, RevisionLifecycle};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::environment::runtime_config::materialize_runtime_config;
pub const DEFAULT_RUNTIME_IMAGE: &str = "ghcr.io/greenticai/greentic-start-distroless:develop";
pub const ROUTER_NAME: &str = "gtc-router";
pub const RUNTIME_CONFIG_MAP_NAME: &str = "gtc-runtime-config";
const SERVE_PORT: u16 = 8080;
const STAGE_HOME: &str = "/var/greentic";
const ENV_STORE_SRC: &str = "/etc/greentic/env-store";
const HOME_VOLUME: &str = "greentic-home";
const TMP_VOLUME: &str = "tmp";
const ENV_STORE_SRC_VOLUME: &str = "env-store-src";
pub const ENV_STORE_CONFIG_MAP_NAME: &str = "gtc-env-store";
const STAGE_INIT_IMAGE: &str = "busybox:1.36.1";
pub const DEV_SECRETS_SECRET_NAME: &str = "gtc-dev-secrets";
const DEV_SECRETS_SRC: &str = "/etc/greentic/dev-secrets";
const DEV_SECRETS_VOLUME: &str = "dev-secrets-src";
const CLOUDFLARED_BINARY: &str = "/usr/local/bin/cloudflared";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TunnelMode {
Off,
Cloudflared,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct K8sParams {
pub namespace: String,
pub runtime_image: String,
pub router_replicas: u32,
pub tunnel: TunnelMode,
pub dev_secrets_data: Option<String>,
}
impl K8sParams {
pub fn for_env(env: &Environment) -> Self {
Self {
namespace: namespace_for_env(&env.environment_id),
runtime_image: DEFAULT_RUNTIME_IMAGE.to_string(),
router_replicas: 2,
tunnel: TunnelMode::Off,
dev_secrets_data: None,
}
}
pub fn from_answers(
env: &Environment,
answers: Option<&serde_json::Value>,
) -> Result<Self, String> {
let Some(answers) = answers else {
return Ok(Self::for_env(env));
};
let obj = answers
.as_object()
.ok_or_else(|| "answers must be a JSON object".to_string())?;
let defaults = Self::for_env(env);
const KNOWN_KEYS: &[&str] = &[
"kubeconfig_context",
"namespace",
"runtime_image",
"router_replicas",
"tunnel",
];
for key in obj.keys() {
if !KNOWN_KEYS.contains(&key.as_str()) {
return Err(format!("unknown answer key `{key}`"));
}
}
let namespace = match answer_string(obj, "namespace") {
Some(ns) => {
if !is_dns1123_label(&ns) {
return Err(format!("namespace `{ns}` is not a valid RFC 1123 label"));
}
ns
}
None => defaults.namespace,
};
let runtime_image = match answer_string(obj, "runtime_image") {
Some(img) => {
if !img
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b".-_/:@".contains(&b))
{
return Err(format!("runtime_image `{img}` contains invalid characters"));
}
img
}
None => defaults.runtime_image,
};
let router_replicas = match obj.get("router_replicas") {
None | Some(serde_json::Value::Null) => defaults.router_replicas,
Some(v) => {
let n: u32 = match v {
serde_json::Value::String(s) if s.is_empty() => defaults.router_replicas,
serde_json::Value::String(s) => s
.parse::<u32>()
.map_err(|e| format!("router_replicas `{s}` is not a valid u32: {e}"))?,
serde_json::Value::Number(n) => n
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| format!("router_replicas `{n}` is not a valid u32"))?,
_ => {
return Err(format!(
"router_replicas must be a string or number, got {v}"
));
}
};
if n < 2 {
return Err(format!(
"router_replicas must be >= 2 (HA requirement), got {n}"
));
}
n
}
};
let tunnel = match answer_string(obj, "tunnel") {
Some(s) => match s.as_str() {
"cloudflared" => TunnelMode::Cloudflared,
"off" | "none" => TunnelMode::Off,
other => {
return Err(format!(
"tunnel `{other}` is not valid (expected `cloudflared` or `off`)"
));
}
},
None => defaults.tunnel,
};
Ok(Self {
namespace,
runtime_image,
router_replicas,
tunnel,
dev_secrets_data: defaults.dev_secrets_data,
})
}
}
fn answer_string(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
match obj.get(key) {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(s)) if s.is_empty() => None,
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(other) => Some(other.to_string()),
}
}
#[cfg(feature = "k8s-client")]
pub(crate) fn kubeconfig_context_from_answers(answers: Option<&Value>) -> Option<String> {
answer_string(answers?.as_object()?, "kubeconfig_context")
}
pub fn is_dns1123_label(s: &str) -> bool {
let len = s.len();
if len == 0 || len > 63 {
return false;
}
if s.starts_with('-') || s.ends_with('-') {
return false;
}
s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
pub fn namespace_for_env(env_id: &EnvId) -> String {
let raw = env_id.as_str();
let prefix = "gtc-";
let is_clean = !raw.is_empty()
&& raw
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !raw.starts_with('-')
&& !raw.ends_with('-')
&& (prefix.len() + raw.len()) <= 63;
if is_clean {
return format!("{prefix}{raw}");
}
let hash8 = {
let digest = Sha256::digest(raw.as_bytes());
format!(
"{:02x}{:02x}{:02x}{:02x}",
digest[0], digest[1], digest[2], digest[3]
)
};
let sanitized = sanitize_dns1123_label(raw);
if sanitized.is_empty() {
return format!("{prefix}{hash8}");
}
let max_prefix_len = 63 - prefix.len() - 1 - hash8.len(); let truncated = if sanitized.len() > max_prefix_len {
sanitized[..max_prefix_len].trim_end_matches('-')
} else {
&sanitized
};
if truncated.is_empty() {
format!("{prefix}{hash8}")
} else {
format!("{prefix}{truncated}-{hash8}")
}
}
pub fn worker_name(revision: &Revision) -> String {
format!(
"gtc-worker-{}",
revision.revision_id.0.to_string().to_lowercase()
)
}
fn sanitize_dns1123_label(raw: &str) -> String {
let s: String = raw
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
c
} else {
'-'
}
})
.collect();
s.trim_matches('-').to_string()
}
pub const ENV_LABEL: &str = "greentic.ai/env";
fn common_labels(env: &Environment, component: &str) -> Value {
let mut labels = json!({
"app.kubernetes.io/managed-by": "greentic",
"app.kubernetes.io/component": component,
});
labels[ENV_LABEL] = json!(env.environment_id.as_str());
labels
}
fn worker_selector_labels(env: &Environment, revision: &Revision) -> Value {
let mut labels = common_labels(env, "worker");
let map = labels.as_object_mut().expect("labels are an object");
map.insert(
"greentic.ai/revision".into(),
json!(revision.revision_id.0.to_string()),
);
map.insert(
"greentic.ai/deployment".into(),
json!(revision.deployment_id.0.to_string()),
);
map.insert(
"greentic.ai/bundle".into(),
json!(revision.bundle_id.as_str()),
);
labels
}
fn pod_security_context() -> Value {
json!({
"runAsNonRoot": true,
"runAsUser": 65532,
"runAsGroup": 65532,
"fsGroup": 65532,
"seccompProfile": {"type": "RuntimeDefault"},
})
}
fn container_security_context() -> Value {
json!({
"allowPrivilegeEscalation": false,
"readOnlyRootFilesystem": true,
"capabilities": {"drop": ["ALL"]},
})
}
fn resource_baseline() -> Value {
json!({
"requests": {"cpu": "250m", "memory": "256Mi"},
"limits": {"cpu": "1", "memory": "1Gi"},
})
}
pub fn render_namespace(env: &Environment, params: &K8sParams) -> Value {
json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {
"name": params.namespace,
"labels": common_labels(env, "namespace"),
},
})
}
fn runtime_boot_args(env: &Environment) -> Value {
json!(["start", "--env", env.environment_id.as_str()])
}
fn worker_boot_args(env: &Environment, params: &K8sParams) -> Value {
let mut args = vec![
Value::from("start"),
Value::from("--env"),
Value::from(env.environment_id.as_str()),
];
if params.tunnel == TunnelMode::Cloudflared {
args.extend([
Value::from("--cloudflared"),
Value::from("on"),
Value::from("--cloudflared-binary"),
Value::from(CLOUDFLARED_BINARY),
]);
}
Value::Array(args)
}
fn env_uses_dev_secrets(env: &Environment) -> bool {
env.packs.iter().any(|p| p.slot == CapabilitySlot::Secrets)
}
fn dev_secrets_content_hash(data: Option<&str>) -> String {
let mut hasher = Sha256::new();
match data {
Some(b64) => hasher.update(b64.as_bytes()),
None => hasher.update(b"<empty>"),
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(64);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
hex
}
fn stage_dev_secrets_init_container(env: &Environment) -> Value {
let dev_dir = format!(
"{STAGE_HOME}/.greentic/environments/{}/.greentic/dev",
env.environment_id.as_str()
);
let src = format!("{DEV_SECRETS_SRC}/.dev.secrets.env");
json!({
"name": "stage-dev-secrets",
"image": STAGE_INIT_IMAGE,
"securityContext": container_security_context(),
"command": [
"sh",
"-c",
format!("set -eu; mkdir -p '{dev_dir}'; if [ -f '{src}' ]; then cp '{src}' '{dev_dir}/.dev.secrets.env'; fi"),
],
"volumeMounts": [
{"name": DEV_SECRETS_VOLUME, "mountPath": DEV_SECRETS_SRC, "readOnly": true},
{"name": HOME_VOLUME, "mountPath": STAGE_HOME},
],
})
}
const RAYON_THREADS: &str = "4";
fn runtime_boot_env(env: &Environment) -> Vec<Value> {
vec![
json!({"name": "GREENTIC_ENV_ID", "value": env.environment_id.as_str()}),
json!({"name": "HOME", "value": STAGE_HOME}),
json!({"name": "GREENTIC_GATEWAY_LISTEN_ADDR", "value": "0.0.0.0"}),
json!({"name": "RAYON_NUM_THREADS", "value": RAYON_THREADS}),
]
}
fn runtime_volume_mounts() -> Value {
json!([
{"name": HOME_VOLUME, "mountPath": STAGE_HOME},
{"name": TMP_VOLUME, "mountPath": "/tmp"},
])
}
fn runtime_pod_volumes() -> Vec<Value> {
vec![
json!({"name": HOME_VOLUME, "emptyDir": {}}),
json!({"name": TMP_VOLUME, "emptyDir": {}}),
json!({"name": ENV_STORE_SRC_VOLUME, "configMap": {"name": ENV_STORE_CONFIG_MAP_NAME}}),
]
}
fn env_store_init_container(env: &Environment) -> Value {
let dst = format!(
"{STAGE_HOME}/.greentic/environments/{}",
env.environment_id.as_str()
);
json!({
"name": "stage-env-store",
"image": STAGE_INIT_IMAGE,
"securityContext": container_security_context(),
"command": [
"sh",
"-c",
format!("set -eu; mkdir -p '{dst}'; cp {ENV_STORE_SRC}/environment.json '{dst}/environment.json'"),
],
"volumeMounts": [
{"name": ENV_STORE_SRC_VOLUME, "mountPath": ENV_STORE_SRC, "readOnly": true},
{"name": HOME_VOLUME, "mountPath": STAGE_HOME},
],
})
}
pub fn render_worker_deployment(
env: &Environment,
revision: &Revision,
params: &K8sParams,
) -> Value {
let labels = worker_selector_labels(env, revision);
let mut env_vars = runtime_boot_env(env);
env_vars.extend([
json!({"name": "GREENTIC_REVISION_ID", "value": revision.revision_id.0.to_string()}),
json!({"name": "GREENTIC_DEPLOYMENT_ID", "value": revision.deployment_id.0.to_string()}),
json!({"name": "GREENTIC_BUNDLE_ID", "value": revision.bundle_id.as_str()}),
json!({"name": "GREENTIC_BUNDLE_DIGEST", "value": revision.bundle_digest}),
]);
let mut init_containers = vec![env_store_init_container(env)];
let mut volumes = runtime_pod_volumes();
let uses_dev_secrets = env_uses_dev_secrets(env);
if uses_dev_secrets {
init_containers.push(stage_dev_secrets_init_container(env));
volumes.push(json!({
"name": DEV_SECRETS_VOLUME,
"secret": {"secretName": DEV_SECRETS_SECRET_NAME, "optional": true},
}));
}
let mut pod_annotations = serde_json::Map::new();
if uses_dev_secrets {
let hash = dev_secrets_content_hash(params.dev_secrets_data.as_deref());
pod_annotations.insert(
"greentic.ai/dev-store-hash".to_string(),
Value::String(hash),
);
}
let pod_metadata = if pod_annotations.is_empty() {
json!({"labels": labels})
} else {
json!({"labels": labels, "annotations": Value::Object(pod_annotations)})
};
json!({
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": worker_name(revision),
"namespace": params.namespace,
"labels": labels,
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {
"greentic.ai/revision": revision.revision_id.0.to_string(),
}},
"template": {
"metadata": pod_metadata,
"spec": {
"securityContext": pod_security_context(),
"initContainers": Value::Array(init_containers),
"containers": [{
"name": "worker",
"image": params.runtime_image,
"args": worker_boot_args(env, params),
"securityContext": container_security_context(),
"resources": resource_baseline(),
"ports": [{"name": "http", "containerPort": SERVE_PORT}],
"env": Value::Array(env_vars),
"volumeMounts": runtime_volume_mounts(),
"readinessProbe": {
"httpGet": {"path": "/healthz", "port": SERVE_PORT},
"initialDelaySeconds": 2,
"periodSeconds": 5,
},
}],
"volumes": Value::Array(volumes),
},
},
},
})
}
pub fn render_worker_service(env: &Environment, revision: &Revision, params: &K8sParams) -> Value {
json!({
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": worker_name(revision),
"namespace": params.namespace,
"labels": worker_selector_labels(env, revision),
},
"spec": {
"type": "ClusterIP",
"selector": {
"greentic.ai/revision": revision.revision_id.0.to_string(),
},
"ports": [{"name": "http", "port": SERVE_PORT, "targetPort": SERVE_PORT}],
},
})
}
pub fn render_worker_manifests(
env: &Environment,
revision: &Revision,
params: &K8sParams,
) -> Vec<Value> {
vec![
render_worker_deployment(env, revision, params),
render_worker_service(env, revision, params),
]
}
pub(crate) fn has_cluster_presence(lifecycle: RevisionLifecycle) -> bool {
matches!(
lifecycle,
RevisionLifecycle::Warming | RevisionLifecycle::Ready | RevisionLifecycle::Draining
)
}
pub fn render_router_deployment(env: &Environment, params: &K8sParams) -> Value {
let labels = common_labels(env, "router");
json!({
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": ROUTER_NAME,
"namespace": params.namespace,
"labels": labels,
},
"spec": {
"replicas": params.router_replicas,
"selector": {"matchLabels": labels},
"template": {
"metadata": {"labels": labels},
"spec": {
"securityContext": pod_security_context(),
"topologySpreadConstraints": [{
"maxSkew": 1,
"topologyKey": "kubernetes.io/hostname",
"whenUnsatisfiable": "ScheduleAnyway",
"labelSelector": {"matchLabels": labels},
}],
"initContainers": [env_store_init_container(env)],
"containers": [{
"name": "router",
"image": params.runtime_image,
"args": runtime_boot_args(env),
"securityContext": container_security_context(),
"resources": resource_baseline(),
"ports": [{"name": "http", "containerPort": SERVE_PORT}],
"env": Value::Array(runtime_boot_env(env)),
"volumeMounts": runtime_volume_mounts(),
"readinessProbe": {
"httpGet": {"path": "/healthz", "port": SERVE_PORT},
"initialDelaySeconds": 2,
"periodSeconds": 5,
},
}],
"volumes": Value::Array(runtime_pod_volumes()),
},
},
},
})
}
pub fn render_router_service(env: &Environment, params: &K8sParams) -> Value {
let labels = common_labels(env, "router");
json!({
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": ROUTER_NAME,
"namespace": params.namespace,
"labels": labels,
},
"spec": {
"type": "ClusterIP",
"selector": labels,
"ports": [{"name": "http", "port": SERVE_PORT, "targetPort": SERVE_PORT}],
},
})
}
pub fn render_router_pdb(env: &Environment, params: &K8sParams) -> Value {
let labels = common_labels(env, "router");
json!({
"apiVersion": "policy/v1",
"kind": "PodDisruptionBudget",
"metadata": {
"name": ROUTER_NAME,
"namespace": params.namespace,
"labels": labels,
},
"spec": {
"minAvailable": 1,
"selector": {"matchLabels": labels},
},
})
}
pub fn render_runtime_config_map(env: &Environment, params: &K8sParams) -> Value {
let runtime_config = materialize_runtime_config(env);
let payload = serde_json::to_string(&runtime_config)
.expect("runtime-config projection serializes (pure spec types)");
json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": RUNTIME_CONFIG_MAP_NAME,
"namespace": params.namespace,
"labels": common_labels(env, "router"),
},
"data": {"runtime-config.json": payload},
})
}
pub fn render_env_store_config_map(env: &Environment, params: &K8sParams) -> Value {
let environment_json =
serde_json::to_string(env).expect("Environment serializes (pure spec types)");
json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": ENV_STORE_CONFIG_MAP_NAME,
"namespace": params.namespace,
"labels": common_labels(env, "env-store"),
},
"data": {"environment.json": environment_json},
})
}
fn render_dev_secrets_secret(env: &Environment, params: &K8sParams) -> Value {
let mut data = serde_json::Map::new();
if let Some(b64) = ¶ms.dev_secrets_data {
data.insert(".dev.secrets.env".to_string(), Value::String(b64.clone()));
}
json!({
"apiVersion": "v1",
"kind": "Secret",
"type": "Opaque",
"metadata": {
"name": DEV_SECRETS_SECRET_NAME,
"namespace": params.namespace,
"labels": common_labels(env, "dev-secrets"),
},
"data": Value::Object(data),
})
}
fn env_has_pullable_routed_revision(env: &Environment) -> bool {
env.traffic_splits.iter().any(|split| {
split.entries.iter().any(|entry| {
env.revisions.iter().any(|revision| {
revision.revision_id == entry.revision_id && revision.bundle_source_uri.is_some()
})
})
})
}
pub fn render_network_policies(env: &Environment, params: &K8sParams) -> Vec<Value> {
let router_labels = common_labels(env, "router");
let worker_component = json!({"app.kubernetes.io/component": "worker"});
let dns_ports = json!([
{"protocol": "UDP", "port": 53},
{"protocol": "TCP", "port": 53},
]);
let mut policies = vec![
json!({
"apiVersion": "networking.k8s.io/v1",
"kind": "NetworkPolicy",
"metadata": {
"name": "gtc-default-deny",
"namespace": params.namespace,
"labels": common_labels(env, "network-policy"),
},
"spec": {
"podSelector": {},
"policyTypes": ["Ingress", "Egress"],
},
}),
json!({
"apiVersion": "networking.k8s.io/v1",
"kind": "NetworkPolicy",
"metadata": {
"name": "gtc-allow-dns",
"namespace": params.namespace,
"labels": common_labels(env, "network-policy"),
},
"spec": {
"podSelector": {},
"policyTypes": ["Egress"],
"egress": [{"ports": dns_ports}],
},
}),
json!({
"apiVersion": "networking.k8s.io/v1",
"kind": "NetworkPolicy",
"metadata": {
"name": "gtc-allow-router",
"namespace": params.namespace,
"labels": common_labels(env, "network-policy"),
},
"spec": {
"podSelector": {"matchLabels": router_labels},
"policyTypes": ["Ingress", "Egress"],
"ingress": [{"ports": [{"protocol": "TCP", "port": SERVE_PORT}]}],
"egress": [{
"to": [{"podSelector": {"matchLabels": worker_component}}],
"ports": [{"protocol": "TCP", "port": SERVE_PORT}],
}],
},
}),
json!({
"apiVersion": "networking.k8s.io/v1",
"kind": "NetworkPolicy",
"metadata": {
"name": "gtc-allow-workers",
"namespace": params.namespace,
"labels": common_labels(env, "network-policy"),
},
"spec": {
"podSelector": {"matchLabels": worker_component},
"policyTypes": ["Ingress"],
"ingress": [{
"from": [{"podSelector": {"matchLabels": json!({
"app.kubernetes.io/component": "router",
})}}],
"ports": [{"protocol": "TCP", "port": SERVE_PORT}],
}],
},
}),
];
let pull_egress = if env_has_pullable_routed_revision(env) {
json!([{}])
} else {
json!([])
};
for role in ["worker", "router"] {
policies.push(json!({
"apiVersion": "networking.k8s.io/v1",
"kind": "NetworkPolicy",
"metadata": {
"name": format!("gtc-allow-{role}-egress"),
"namespace": params.namespace,
"labels": common_labels(env, "network-policy"),
},
"spec": {
"podSelector": {"matchLabels": common_labels(env, role)},
"policyTypes": ["Egress"],
"egress": pull_egress.clone(),
},
}));
}
policies
}
pub fn render_environment_manifests(env: &Environment, params: &K8sParams) -> Vec<Value> {
let mut manifests = vec![
render_namespace(env, params),
render_env_store_config_map(env, params),
render_runtime_config_map(env, params),
render_router_deployment(env, params),
render_router_service(env, params),
render_router_pdb(env, params),
];
manifests.extend(render_network_policies(env, params));
if env_uses_dev_secrets(env) {
manifests.push(render_dev_secrets_secret(env, params));
}
manifests
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env_packs::deployer::conformance::build_fixture_env;
fn fixture() -> (Environment, K8sParams) {
let env = build_fixture_env();
let params = K8sParams::for_env(&env);
(env, params)
}
#[test]
fn presence_policy_matches_the_b7_two_state_model() {
use RevisionLifecycle::*;
for (lifecycle, present) in [
(Inactive, false),
(Staged, false),
(Warming, true),
(Ready, true),
(Draining, true),
(Failed, false),
(Archived, false),
] {
assert_eq!(
has_cluster_presence(lifecycle),
present,
"{lifecycle:?} presence policy drifted"
);
}
}
#[test]
fn params_for_env_derive_sandbox_defaults() {
let (env, params) = fixture();
assert_eq!(params.namespace, format!("gtc-{}", env.environment_id));
assert_eq!(params.runtime_image, DEFAULT_RUNTIME_IMAGE);
assert_eq!(params.router_replicas, 2, "plan step 11: router HA ≥ 2");
}
#[test]
fn sanitize_dns1123_label_handles_env_id_charset() {
assert_eq!(sanitize_dns1123_label("Prod.EU_west"), "prod-eu-west");
assert_eq!(sanitize_dns1123_label("x-"), "x");
assert_eq!(sanitize_dns1123_label("..."), "");
}
#[test]
fn namespace_collision_proof_distinct_ids_never_share_namespace() {
let clean = EnvId::try_from("prod-eu-west").unwrap();
let ns_clean = namespace_for_env(&clean);
assert_eq!(ns_clean, "gtc-prod-eu-west");
let lossy = EnvId::try_from("Prod.EU_west").unwrap();
let ns_lossy = namespace_for_env(&lossy);
assert_ne!(
ns_lossy, ns_clean,
"lossy derivation must not collide with the clean id"
);
assert!(
ns_lossy.starts_with("gtc-prod-eu-west-"),
"lossy namespace must start with the sanitized prefix: {ns_lossy}"
);
let suffix = ns_lossy.strip_prefix("gtc-prod-eu-west-").unwrap();
assert_eq!(suffix.len(), 8);
assert!(
suffix.chars().all(|c| c.is_ascii_hexdigit()),
"suffix must be hex: {suffix}"
);
}
#[test]
fn namespace_long_ids_truncated_but_distinct() {
let base = "a".repeat(55);
let id_a = EnvId::try_from(format!("{base}xxxxx")).unwrap();
let id_b = EnvId::try_from(format!("{base}yyyyy")).unwrap();
let ns_a = namespace_for_env(&id_a);
let ns_b = namespace_for_env(&id_b);
assert_ne!(ns_a, ns_b, "truncated long ids must not collide");
assert!(ns_a.len() <= 63, "namespace must be ≤ 63 chars: {ns_a}");
assert!(ns_b.len() <= 63, "namespace must be ≤ 63 chars: {ns_b}");
for ns in [&ns_a, &ns_b] {
assert!(
ns.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"invalid RFC 1123: {ns}"
);
assert!(
!ns.starts_with('-') && !ns.ends_with('-'),
"leading/trailing dash: {ns}"
);
}
}
#[test]
fn namespace_derivation_is_deterministic() {
let id = EnvId::try_from("Prod.EU_west").unwrap();
let a = namespace_for_env(&id);
let b = namespace_for_env(&id);
assert_eq!(a, b, "same id must always produce the same namespace");
}
#[test]
fn namespace_degenerate_id_gets_hash_only() {
let id = EnvId::try_from("...").unwrap();
let ns = namespace_for_env(&id);
assert!(ns.starts_with("gtc-"), "must have gtc- prefix: {ns}");
assert_eq!(ns.len(), 12);
assert!(
ns[4..].chars().all(|c| c.is_ascii_hexdigit()),
"must be gtc-<hex8>: {ns}"
);
}
#[test]
fn worker_name_is_a_lowercase_rfc1123_label() {
let (env, _) = fixture();
let name = worker_name(&env.revisions[0]);
assert!(name.starts_with("gtc-worker-"));
assert!(name.len() <= 63);
assert!(
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"name must be RFC 1123: {name}"
);
assert_ne!(name, worker_name(&env.revisions[1]));
}
#[test]
fn rendering_is_deterministic() {
let (env, params) = fixture();
let a = render_environment_manifests(&env, ¶ms);
let b = render_environment_manifests(&env, ¶ms);
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap(),
"identical inputs must serialize identically"
);
let wa = render_worker_manifests(&env, &env.revisions[0], ¶ms);
let wb = render_worker_manifests(&env, &env.revisions[0], ¶ms);
assert_eq!(
serde_json::to_string(&wa).unwrap(),
serde_json::to_string(&wb).unwrap()
);
}
#[test]
fn worker_deployment_carries_revision_label_and_identity_env() {
let (env, params) = fixture();
let rev = &env.revisions[0];
let d = render_worker_deployment(&env, rev, ¶ms);
let ulid = rev.revision_id.0.to_string();
assert_eq!(
d["metadata"]["labels"]["greentic.ai/revision"],
serde_json::json!(ulid)
);
assert_eq!(
d["spec"]["selector"]["matchLabels"]["greentic.ai/revision"],
serde_json::json!(ulid)
);
let envs = d["spec"]["template"]["spec"]["containers"][0]["env"]
.as_array()
.unwrap();
let names: Vec<&str> = envs.iter().map(|e| e["name"].as_str().unwrap()).collect();
for required in [
"GREENTIC_ENV_ID",
"GREENTIC_REVISION_ID",
"GREENTIC_DEPLOYMENT_ID",
"GREENTIC_BUNDLE_ID",
"GREENTIC_BUNDLE_DIGEST",
] {
assert!(names.contains(&required), "missing env var {required}");
}
}
#[test]
fn every_pod_spec_passes_the_restricted_hardening_gate() {
let (env, params) = fixture();
let pods = [
render_worker_deployment(&env, &env.revisions[0], ¶ms),
render_router_deployment(&env, ¶ms),
];
for d in &pods {
let pod = &d["spec"]["template"]["spec"];
assert_eq!(pod["securityContext"]["runAsNonRoot"], true);
assert_eq!(pod["securityContext"]["runAsUser"], 65532);
assert_eq!(pod["securityContext"]["fsGroup"], 65532);
assert_eq!(
pod["securityContext"]["seccompProfile"]["type"],
"RuntimeDefault"
);
let c = &pod["containers"][0];
assert_eq!(c["securityContext"]["allowPrivilegeEscalation"], false);
assert_eq!(c["securityContext"]["readOnlyRootFilesystem"], true);
assert_eq!(c["securityContext"]["capabilities"]["drop"][0], "ALL");
assert!(c["resources"]["requests"]["cpu"].is_string());
assert!(c["resources"]["limits"]["memory"].is_string());
assert!(c["readinessProbe"]["httpGet"]["path"].is_string());
let ic = &pod["initContainers"][0];
assert_eq!(ic["securityContext"]["allowPrivilegeEscalation"], false);
assert_eq!(ic["securityContext"]["readOnlyRootFilesystem"], true);
assert_eq!(ic["securityContext"]["capabilities"]["drop"][0], "ALL");
}
}
#[test]
fn router_is_ha_with_pdb_and_spread() {
let (env, params) = fixture();
let d = render_router_deployment(&env, ¶ms);
assert_eq!(d["spec"]["replicas"], 2);
assert!(
d["spec"]["template"]["spec"]["topologySpreadConstraints"][0]["topologyKey"]
.is_string()
);
let pdb = render_router_pdb(&env, ¶ms);
assert_eq!(pdb["spec"]["minAvailable"], 1);
let pod = &d["spec"]["template"]["spec"];
assert_eq!(
pod["containers"][0]["args"],
serde_json::json!(["start", "--env", env.environment_id.as_str()])
);
assert_eq!(pod["initContainers"][0]["name"], "stage-env-store");
let cm_volume = pod["volumes"]
.as_array()
.unwrap()
.iter()
.find(|v| v["configMap"]["name"] == ENV_STORE_CONFIG_MAP_NAME);
assert!(
cm_volume.is_some(),
"router mounts the env-store ConfigMap as the init source"
);
}
#[test]
fn runtime_config_map_embeds_the_exact_projection() {
let (env, params) = fixture();
let cm = render_runtime_config_map(&env, ¶ms);
let payload = cm["data"]["runtime-config.json"].as_str().unwrap();
let expected = serde_json::to_string(&materialize_runtime_config(&env)).unwrap();
assert_eq!(payload, expected, "the ConfigMap IS the projection");
}
#[test]
fn env_store_config_map_round_trips_to_a_loadable_environment() {
let (env, params) = fixture();
let cm = render_env_store_config_map(&env, ¶ms);
assert_eq!(cm["metadata"]["name"], ENV_STORE_CONFIG_MAP_NAME);
let json = cm["data"]["environment.json"]
.as_str()
.expect("environment.json key");
let parsed: Environment = serde_json::from_str(json).expect("environment.json parses");
parsed.validate().expect("staged environment validates");
assert_eq!(parsed.environment_id, env.environment_id);
}
#[test]
fn both_pods_boot_the_bundle_less_serve_path() {
let (env, params) = fixture();
let pods = [
render_worker_deployment(&env, &env.revisions[0], ¶ms),
render_router_deployment(&env, ¶ms),
];
for d in &pods {
let pod = &d["spec"]["template"]["spec"];
assert_eq!(
pod["containers"][0]["args"],
serde_json::json!(["start", "--env", env.environment_id.as_str()])
);
let envs = pod["containers"][0]["env"].as_array().unwrap();
let find = |name: &str| envs.iter().find(|e| e["name"] == name).map(|e| &e["value"]);
assert_eq!(find("GREENTIC_GATEWAY_LISTEN_ADDR").unwrap(), "0.0.0.0");
assert_eq!(find("HOME").unwrap(), STAGE_HOME);
assert_eq!(find("RAYON_NUM_THREADS").unwrap(), RAYON_THREADS);
let script = pod["initContainers"][0]["command"][2].as_str().unwrap();
assert!(
script.contains("environment.json"),
"init stages environment.json"
);
assert!(
script.contains(env.environment_id.as_str()),
"into the env's store dir"
);
}
}
#[test]
fn network_policies_default_deny_then_allowlist() {
let (env, params) = fixture();
let policies = render_network_policies(&env, ¶ms);
assert_eq!(policies[0]["metadata"]["name"], "gtc-default-deny");
assert_eq!(policies[0]["spec"]["podSelector"], serde_json::json!({}));
assert!(policies[0]["spec"].get("ingress").is_none());
assert!(policies[0]["spec"].get("egress").is_none());
let names: Vec<&str> = policies
.iter()
.map(|p| p["metadata"]["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"gtc-default-deny",
"gtc-allow-dns",
"gtc-allow-router",
"gtc-allow-workers",
"gtc-allow-worker-egress",
"gtc-allow-router-egress",
]
);
for name in ["gtc-allow-worker-egress", "gtc-allow-router-egress"] {
let egress = policies
.iter()
.find(|p| p["metadata"]["name"] == name)
.unwrap_or_else(|| panic!("{name} is always rendered"));
assert_eq!(egress["spec"]["egress"], serde_json::json!([]));
assert_eq!(
egress["spec"]["podSelector"]["matchLabels"][ENV_LABEL],
env.environment_id.as_str()
);
}
}
fn pullable_fixture() -> (Environment, K8sParams) {
let (mut env, params) = fixture();
env.revisions[0].bundle_source_uri =
Some("oci://registry.example/bundles/demo@sha256:abc123".to_string());
(env, params)
}
#[test]
fn pull_egress_policies_render_for_a_pullable_routed_revision() {
let (env, params) = pullable_fixture();
let policies = render_network_policies(&env, ¶ms);
for role in ["worker", "router"] {
let name = format!("gtc-allow-{role}-egress");
let egress = policies
.iter()
.find(|p| p["metadata"]["name"] == name.as_str())
.unwrap_or_else(|| panic!("{name} is always rendered"));
let selector = &egress["spec"]["podSelector"]["matchLabels"];
assert_eq!(
selector["app.kubernetes.io/component"], role,
"the allowance targets {role} pods"
);
assert_eq!(
selector[ENV_LABEL],
env.environment_id.as_str(),
"scoped to this env's {role}s — not a sibling env sharing the namespace"
);
assert_eq!(egress["spec"]["policyTypes"], serde_json::json!(["Egress"]));
assert_eq!(egress["spec"]["egress"], serde_json::json!([{}]));
}
}
#[test]
fn pull_egress_denies_when_the_pullable_revision_is_not_routed() {
let (mut env, params) = fixture();
env.traffic_splits.clear();
env.revisions[0].bundle_source_uri =
Some("oci://registry.example/bundles/demo@sha256:abc123".to_string());
let policies = render_network_policies(&env, ¶ms);
for role in ["worker", "router"] {
let name = format!("gtc-allow-{role}-egress");
let egress = policies
.iter()
.find(|p| p["metadata"]["name"] == name.as_str())
.unwrap_or_else(|| panic!("{name} is always rendered"));
assert_eq!(
egress["spec"]["egress"],
serde_json::json!([]),
"an unrouted pullable revision leaves {role} egress denied"
);
}
}
#[test]
fn pull_egress_policies_are_stable_env_level_objects() {
let (pullable_env, params) = pullable_fixture();
let (plain_env, _) = fixture();
let pullable = render_environment_manifests(&pullable_env, ¶ms);
let plain = render_environment_manifests(&plain_env, ¶ms);
assert_eq!(
pullable.len(),
plain.len(),
"object count is stable across pullability"
);
for set in [&pullable, &plain] {
for name in ["gtc-allow-worker-egress", "gtc-allow-router-egress"] {
assert!(
set.iter().any(|m| m["metadata"]["name"] == name),
"{name} is always present"
);
}
}
}
#[test]
fn env_store_config_map_preserves_bundle_source_uri() {
let (env, params) = pullable_fixture();
let cm = render_env_store_config_map(&env, ¶ms);
let json = cm["data"]["environment.json"]
.as_str()
.expect("environment.json key");
let parsed: Environment = serde_json::from_str(json).expect("environment.json parses");
let routed = parsed
.revisions
.iter()
.find(|r| r.revision_id == env.revisions[0].revision_id)
.expect("routed revision present after round-trip");
assert_eq!(
routed.bundle_source_uri.as_deref(),
Some("oci://registry.example/bundles/demo@sha256:abc123")
);
}
#[test]
fn environment_manifests_land_in_the_env_namespace_in_apply_order() {
let (env, params) = fixture();
let manifests = render_environment_manifests(&env, ¶ms);
assert_eq!(manifests[0]["kind"], "Namespace");
assert_eq!(manifests[1]["kind"], "ConfigMap");
assert_eq!(manifests[1]["metadata"]["name"], ENV_STORE_CONFIG_MAP_NAME);
assert_eq!(manifests[2]["kind"], "ConfigMap");
assert_eq!(manifests[2]["metadata"]["name"], RUNTIME_CONFIG_MAP_NAME);
assert_eq!(manifests[3]["kind"], "Deployment");
for m in &manifests[1..] {
assert_eq!(
m["metadata"]["namespace"],
serde_json::json!(params.namespace),
"{} must be namespaced",
m["kind"]
);
}
}
#[test]
fn from_answers_none_equals_for_env() {
let env = build_fixture_env();
assert_eq!(
K8sParams::from_answers(&env, None).unwrap(),
K8sParams::for_env(&env),
);
}
#[test]
fn from_answers_empty_object_equals_for_env() {
let env = build_fixture_env();
let empty = serde_json::json!({});
assert_eq!(
K8sParams::from_answers(&env, Some(&empty)).unwrap(),
K8sParams::for_env(&env),
);
}
#[test]
fn from_answers_custom_namespace_propagates() {
let env = build_fixture_env();
let answers = serde_json::json!({"namespace": "my-ns"});
let params = K8sParams::from_answers(&env, Some(&answers)).unwrap();
assert_eq!(params.namespace, "my-ns");
let manifests = render_environment_manifests(&env, ¶ms);
assert_eq!(manifests[0]["metadata"]["name"], "my-ns");
for m in &manifests[1..] {
assert_eq!(
m["metadata"]["namespace"].as_str(),
Some("my-ns"),
"{} namespace mismatch",
m["kind"]
);
}
for rev in &env.revisions {
let workers = render_worker_manifests(&env, rev, ¶ms);
for w in &workers {
assert_eq!(
w["metadata"]["namespace"].as_str(),
Some("my-ns"),
"worker {} namespace mismatch",
w["kind"]
);
}
}
}
#[test]
fn from_answers_custom_runtime_image() {
let env = build_fixture_env();
let answers = serde_json::json!({"runtime_image": "ghcr.io/acme/rt@sha256:abc123"});
let params = K8sParams::from_answers(&env, Some(&answers)).unwrap();
assert_eq!(params.runtime_image, "ghcr.io/acme/rt@sha256:abc123");
let router = render_router_deployment(&env, ¶ms);
assert_eq!(
router["spec"]["template"]["spec"]["containers"][0]["image"].as_str(),
Some("ghcr.io/acme/rt@sha256:abc123")
);
let worker = render_worker_deployment(&env, &env.revisions[0], ¶ms);
assert_eq!(
worker["spec"]["template"]["spec"]["containers"][0]["image"].as_str(),
Some("ghcr.io/acme/rt@sha256:abc123")
);
}
#[test]
fn from_answers_router_replicas_override() {
let env = build_fixture_env();
let answers = serde_json::json!({"router_replicas": "4"});
let params = K8sParams::from_answers(&env, Some(&answers)).unwrap();
assert_eq!(params.router_replicas, 4);
}
#[test]
fn from_answers_router_replicas_as_number() {
let env = build_fixture_env();
let answers = serde_json::json!({"router_replicas": 3});
let params = K8sParams::from_answers(&env, Some(&answers)).unwrap();
assert_eq!(params.router_replicas, 3);
}
#[test]
fn from_answers_router_replicas_one_rejected() {
let env = build_fixture_env();
let answers = serde_json::json!({"router_replicas": "1"});
let err = K8sParams::from_answers(&env, Some(&answers)).unwrap_err();
assert!(err.contains("must be >= 2"), "got: {err}");
}
#[test]
fn from_answers_unknown_key_rejected() {
let env = build_fixture_env();
let answers = serde_json::json!({"bogus_key": "value"});
let err = K8sParams::from_answers(&env, Some(&answers)).unwrap_err();
assert!(err.contains("bogus_key"), "got: {err}");
}
#[test]
fn from_answers_invalid_namespace_rejected() {
let env = build_fixture_env();
let answers = serde_json::json!({"namespace": "MyNS"});
let err = K8sParams::from_answers(&env, Some(&answers)).unwrap_err();
assert!(err.contains("not a valid RFC 1123"), "got: {err}");
}
#[test]
fn from_answers_namespace_too_long_rejected() {
let env = build_fixture_env();
let long = "a".repeat(64);
let answers = serde_json::json!({"namespace": long});
let err = K8sParams::from_answers(&env, Some(&answers)).unwrap_err();
assert!(err.contains("not a valid RFC 1123"), "got: {err}");
}
#[test]
fn from_answers_non_object_rejected() {
let env = build_fixture_env();
let bad = serde_json::json!("not an object");
let err = K8sParams::from_answers(&env, Some(&bad)).unwrap_err();
assert!(err.contains("JSON object"), "got: {err}");
}
#[test]
fn from_answers_empty_string_falls_back_to_default() {
let env = build_fixture_env();
let answers = serde_json::json!({
"namespace": "",
"runtime_image": "",
"router_replicas": "",
});
assert_eq!(
K8sParams::from_answers(&env, Some(&answers)).unwrap(),
K8sParams::for_env(&env),
);
}
#[test]
fn from_answers_null_values_fall_back_to_default() {
let env = build_fixture_env();
let answers = serde_json::json!({
"namespace": null,
"runtime_image": null,
"router_replicas": null,
"kubeconfig_context": null,
});
assert_eq!(
K8sParams::from_answers(&env, Some(&answers)).unwrap(),
K8sParams::for_env(&env),
);
}
#[test]
fn from_answers_kubeconfig_context_ignored() {
let env = build_fixture_env();
let answers = serde_json::json!({"kubeconfig_context": "my-ctx"});
let params = K8sParams::from_answers(&env, Some(&answers)).unwrap();
assert_eq!(params, K8sParams::for_env(&env));
}
#[test]
fn is_dns1123_label_accepts_valid() {
assert!(is_dns1123_label("my-ns"));
assert!(is_dns1123_label("a"));
assert!(is_dns1123_label("abc-123"));
assert!(is_dns1123_label(&"a".repeat(63)));
}
#[test]
fn is_dns1123_label_rejects_invalid() {
assert!(!is_dns1123_label(""));
assert!(!is_dns1123_label("-abc"));
assert!(!is_dns1123_label("abc-"));
assert!(!is_dns1123_label("ABC"));
assert!(!is_dns1123_label("a.b"));
assert!(!is_dns1123_label(&"a".repeat(64)));
}
fn secrets_env() -> Environment {
use greentic_deploy_spec::{EnvPackBinding, PackDescriptor, PackId};
let mut env = build_fixture_env();
env.packs.push(EnvPackBinding {
slot: CapabilitySlot::Secrets,
kind: PackDescriptor::try_new("greentic.secrets.dev-store@1.0.0").unwrap(),
pack_ref: PackId::new("greentic.secrets.dev-store"),
answers_ref: None,
generation: 0,
previous_binding_ref: None,
});
env
}
#[test]
fn non_secrets_env_renders_no_dev_secret_or_staging() {
let (env, params) = fixture();
let env_level = render_environment_manifests(&env, ¶ms);
assert!(
env_level.iter().all(|o| o["kind"] != "Secret"),
"a non-secrets env must not render a dev-store Secret"
);
let d = render_worker_deployment(&env, &env.revisions[0], ¶ms);
let init = d["spec"]["template"]["spec"]["initContainers"]
.as_array()
.unwrap();
assert_eq!(init.len(), 1);
assert_eq!(init[0]["name"], "stage-env-store");
let vols = d["spec"]["template"]["spec"]["volumes"].as_array().unwrap();
assert!(vols.iter().all(|v| v["name"] != DEV_SECRETS_VOLUME));
}
#[test]
fn secrets_env_renders_dev_secrets_secret_and_staging() {
let env = secrets_env();
let params = K8sParams::for_env(&env);
let env_level = render_environment_manifests(&env, ¶ms);
let secrets: Vec<&Value> = env_level.iter().filter(|o| o["kind"] == "Secret").collect();
assert_eq!(secrets.len(), 1, "exactly one dev-store Secret");
assert_eq!(secrets[0]["metadata"]["name"], DEV_SECRETS_SECRET_NAME);
assert_eq!(
env_level.last().unwrap()["kind"],
"Secret",
"Secret is appended last so it never shifts the index-pinned objects"
);
let d = render_worker_deployment(&env, &env.revisions[0], ¶ms);
let init = d["spec"]["template"]["spec"]["initContainers"]
.as_array()
.unwrap();
let names: Vec<&str> = init.iter().map(|c| c["name"].as_str().unwrap()).collect();
assert_eq!(names, ["stage-env-store", "stage-dev-secrets"]);
let cmd = init[1]["command"][2].as_str().unwrap();
assert!(
cmd.contains(".greentic/dev/.dev.secrets.env"),
"stages into the dev-store path: {cmd}"
);
let vols = d["spec"]["template"]["spec"]["volumes"].as_array().unwrap();
let sv = vols
.iter()
.find(|v| v["name"] == DEV_SECRETS_VOLUME)
.expect("dev-secrets volume present");
assert_eq!(sv["secret"]["secretName"], DEV_SECRETS_SECRET_NAME);
assert_eq!(
sv["secret"]["optional"], true,
"an absent Secret must not block worker boot"
);
let ann = &d["spec"]["template"]["metadata"]["annotations"];
assert!(
ann["greentic.ai/dev-store-hash"].is_string(),
"secrets env worker must carry dev-store hash annotation"
);
}
#[test]
fn dev_store_hash_changes_trigger_rolling_restart() {
let env = secrets_env();
let mut params = K8sParams::for_env(&env);
let d1 = render_worker_deployment(&env, &env.revisions[0], ¶ms);
let h1 = d1["spec"]["template"]["metadata"]["annotations"]["greentic.ai/dev-store-hash"]
.as_str()
.unwrap()
.to_string();
params.dev_secrets_data = Some("Zm9vYmFy".to_string());
let d2 = render_worker_deployment(&env, &env.revisions[0], ¶ms);
let h2 = d2["spec"]["template"]["metadata"]["annotations"]["greentic.ai/dev-store-hash"]
.as_str()
.unwrap()
.to_string();
assert_ne!(h1, h2, "changing dev-store data must change the hash");
let d3 = render_worker_deployment(&env, &env.revisions[0], ¶ms);
let h3 = d3["spec"]["template"]["metadata"]["annotations"]["greentic.ai/dev-store-hash"]
.as_str()
.unwrap()
.to_string();
assert_eq!(h2, h3, "same data must produce the same hash");
}
#[test]
fn non_secrets_env_has_no_dev_store_hash_annotation() {
let (env, params) = fixture();
let d = render_worker_deployment(&env, &env.revisions[0], ¶ms);
let ann = &d["spec"]["template"]["metadata"]["annotations"];
assert!(
ann.is_null(),
"non-secrets env worker must not carry annotations"
);
}
#[test]
fn dev_secrets_secret_carries_data_only_when_provided() {
let env = secrets_env();
let mut params = K8sParams::for_env(&env);
let empty = render_dev_secrets_secret(&env, ¶ms);
assert_eq!(empty["data"].as_object().unwrap().len(), 0);
assert_eq!(empty["type"], "Opaque");
params.dev_secrets_data = Some("Zm9vYmFy".to_string());
let filled = render_dev_secrets_secret(&env, ¶ms);
assert_eq!(filled["data"][".dev.secrets.env"], "Zm9vYmFy");
}
#[test]
fn worker_tunnel_flags_only_when_cloudflared() {
let (env, mut params) = fixture();
let id = env.environment_id.as_str();
assert_eq!(params.tunnel, TunnelMode::Off);
let d = render_worker_deployment(&env, &env.revisions[0], ¶ms);
assert_eq!(
d["spec"]["template"]["spec"]["containers"][0]["args"],
json!(["start", "--env", id])
);
params.tunnel = TunnelMode::Cloudflared;
let d = render_worker_deployment(&env, &env.revisions[0], ¶ms);
assert_eq!(
d["spec"]["template"]["spec"]["containers"][0]["args"],
json!([
"start",
"--env",
id,
"--cloudflared",
"on",
"--cloudflared-binary",
CLOUDFLARED_BINARY
])
);
let r = render_router_deployment(&env, ¶ms);
assert_eq!(
r["spec"]["template"]["spec"]["containers"][0]["args"],
json!(["start", "--env", id])
);
}
#[test]
fn from_answers_parses_tunnel() {
let env = build_fixture_env();
let cf = K8sParams::from_answers(&env, Some(&json!({"tunnel": "cloudflared"}))).unwrap();
assert_eq!(cf.tunnel, TunnelMode::Cloudflared);
let off = K8sParams::from_answers(&env, Some(&json!({"tunnel": "off"}))).unwrap();
assert_eq!(off.tunnel, TunnelMode::Off);
let none = K8sParams::from_answers(&env, Some(&json!({}))).unwrap();
assert_eq!(none.tunnel, TunnelMode::Off);
assert!(K8sParams::from_answers(&env, Some(&json!({"tunnel": "ngrok"}))).is_err());
}
}