use std::fmt::Write;
use anyhow::{bail, Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use crate::config::{Config, KubernetesTargetConfig, ResolvedTarget};
use crate::targets::{
check_command, resolve_env_flags, CommandOpts, CommandRunner, DeployMode, DeployOutcome,
DeployResult, DeployTarget, SecretValue,
};
fn validate_k8s_name(name: &str, field: &str) -> Result<()> {
if name.is_empty() {
bail!("kubernetes {field} must not be empty");
}
if name.len() > 253 {
bail!(
"kubernetes {field} '{}...' exceeds 253 character limit",
&name[..32]
);
}
let bytes = name.as_bytes();
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
bail!("kubernetes {field} '{name}' must start with a lowercase letter or digit");
}
let last = bytes[bytes.len() - 1];
if !last.is_ascii_lowercase() && !last.is_ascii_digit() {
bail!("kubernetes {field} '{name}' must end with a lowercase letter or digit");
}
if bytes.len() > 2 {
for &b in &bytes[1..bytes.len() - 1] {
if !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'-' {
bail!("kubernetes {field} '{name}' contains invalid character '{}'; only lowercase letters, digits, and hyphens are allowed", b as char);
}
}
}
Ok(())
}
pub struct KubernetesTarget<'a> {
pub config: &'a Config,
pub target_config: &'a KubernetesTargetConfig,
pub runner: &'a dyn CommandRunner,
}
impl KubernetesTarget<'_> {
fn resolve_namespace(&self, env: &str) -> Result<&str> {
self.target_config
.namespace
.get(env)
.map(std::string::String::as_str)
.with_context(|| format!("no kubernetes namespace mapping for '{env}'"))
}
fn secret_name(&self) -> String {
self.target_config
.secret_name
.clone()
.unwrap_or_else(|| format!("{}-secrets", self.config.project))
}
fn generate_manifest(
&self,
secrets: &[SecretValue],
target: &ResolvedTarget,
) -> Result<String> {
let ns = self.resolve_namespace(&target.environment)?;
let name = self.secret_name();
validate_k8s_name(&name, "secret name")?;
validate_k8s_name(ns, "namespace")?;
let mut data_entries = String::new();
for s in secrets {
let encoded = BASE64.encode(s.value.as_bytes());
let _ = writeln!(data_entries, " {}: {}", s.key, encoded);
}
Ok(format!(
"apiVersion: v1\nkind: Secret\nmetadata:\n name: {name}\n namespace: {ns}\ntype: Opaque\ndata:\n{data_entries}"
))
}
}
impl DeployTarget for KubernetesTarget<'_> {
fn name(&self) -> &'static str {
"kubernetes"
}
fn deploy_mode(&self) -> DeployMode {
DeployMode::Batch
}
fn preflight(&self) -> Result<()> {
check_command(self.runner, "kubectl").map_err(|_| {
anyhow::anyhow!(
"kubectl is not installed or not in PATH. Install it from: https://kubernetes.io/docs/tasks/tools/"
)
})?;
let output = self
.runner
.run("kubectl", &["cluster-info"], CommandOpts::default())
.context("failed to run kubectl cluster-info")?;
if !output.success {
anyhow::bail!("kubectl cannot connect to a cluster. Run: kubectl config get-contexts");
}
Ok(())
}
fn deploy_secret(&self, _key: &str, _value: &str, _target: &ResolvedTarget) -> Result<()> {
Ok(())
}
fn deploy_batch(&self, secrets: &[SecretValue], target: &ResolvedTarget) -> Vec<DeployResult> {
let manifest = match self.generate_manifest(secrets, target) {
Ok(m) => m,
Err(e) => {
return secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Failed(e.to_string()),
})
.collect();
}
};
let flag_parts = resolve_env_flags(&self.target_config.env_flags, &target.environment);
let mut args: Vec<&str> = vec!["apply", "-f", "-"];
if let Some(ctx) = self.target_config.context.get(&target.environment) {
args.push("--context");
args.push(ctx);
}
args.extend(flag_parts.iter().map(String::as_str));
let result = self.runner.run(
"kubectl",
&args,
CommandOpts {
stdin: Some(manifest.into_bytes()),
..Default::default()
},
);
match result {
Ok(output) if output.success => secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Success,
})
.collect(),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Failed(stderr.clone()),
})
.collect()
}
Err(e) => secrets
.iter()
.map(|s| DeployResult {
key: s.key.clone(),
outcome: DeployOutcome::Failed(e.to_string()),
})
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::targets::CommandOutput;
use crate::test_support::{ConfigFixture, ErrorCommandRunner, MockCommandRunner};
fn make_config() -> ConfigFixture {
let yaml = r#"
project: myapp
environments: [dev, prod]
targets:
kubernetes:
namespace:
dev: myapp-dev
prod: myapp-prod
context:
prod: prod-cluster
env_flags:
prod: "--dry-run=client"
"#;
ConfigFixture::new(yaml).expect("fixture")
}
fn make_target(env: &str) -> ResolvedTarget {
ResolvedTarget {
service: "kubernetes".to_string(),
app: None,
environment: env.to_string(),
}
}
fn make_secret(key: &str, value: &str) -> SecretValue {
SecretValue {
key: key.to_string(),
value: value.to_string(),
group: "G".to_string(),
}
}
#[test]
fn preflight_success() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: true,
stdout: b"1.28.0".to_vec(),
stderr: vec![],
},
CommandOutput {
success: true,
stdout: b"Kubernetes control plane is running".to_vec(),
stderr: vec![],
},
]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
assert!(target.preflight().is_ok());
let calls = runner.take_calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].args, vec!["--version"]);
assert_eq!(calls[1].args, vec!["cluster-info"]);
}
#[test]
fn preflight_cluster_unreachable() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![
CommandOutput {
success: true,
stdout: b"1.28.0".to_vec(),
stderr: vec![],
},
CommandOutput {
success: false,
stdout: vec![],
stderr: b"connection refused".to_vec(),
},
]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let err = target.preflight().unwrap_err();
assert!(err.to_string().contains("cannot connect to a cluster"));
}
#[test]
fn preflight_missing_cli() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = ErrorCommandRunner::missing_command();
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let err = target.preflight().unwrap_err();
assert!(err.to_string().contains("kubectl is not installed"));
}
#[test]
fn deploy_batch_generates_manifest() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: vec![],
stderr: vec![],
}]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let secrets = vec![
make_secret("DB_HOST", "localhost"),
make_secret("DB_PASS", "s3cret"),
];
let results = target.deploy_batch(&secrets, &make_target("dev"));
assert!(results.iter().all(|r| r.outcome.is_success()));
let calls = runner.take_calls();
assert_eq!(calls[0].program, "kubectl");
assert_eq!(calls[0].args, vec!["apply", "-f", "-"]);
let stdin = String::from_utf8(calls[0].stdin.clone().unwrap()).unwrap();
assert!(stdin.contains("kind: Secret"));
assert!(stdin.contains("namespace: myapp-dev"));
assert!(stdin.contains("name: myapp-secrets"));
assert!(stdin.contains(&BASE64.encode(b"localhost")));
assert!(stdin.contains(&BASE64.encode(b"s3cret")));
}
#[test]
fn deploy_batch_with_context_and_flags() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: vec![],
stderr: vec![],
}]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let secrets = vec![make_secret("KEY", "val")];
target.deploy_batch(&secrets, &make_target("prod"));
let calls = runner.take_calls();
assert!(calls[0].args.contains(&"--context".to_string()));
assert!(calls[0].args.contains(&"prod-cluster".to_string()));
assert!(calls[0].args.contains(&"--dry-run=client".to_string()));
}
#[test]
fn deploy_batch_failure() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: false,
stdout: vec![],
stderr: b"forbidden".to_vec(),
}]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let secrets = vec![make_secret("KEY", "val")];
let results = target.deploy_batch(&secrets, &make_target("dev"));
assert!(!results[0].outcome.is_success());
assert!(results[0]
.outcome
.error_message()
.unwrap()
.contains("forbidden"));
}
#[test]
fn deploy_batch_unknown_namespace() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let secrets = vec![make_secret("KEY", "val")];
let results = target.deploy_batch(&secrets, &make_target("staging"));
assert!(!results[0].outcome.is_success());
assert!(results[0]
.outcome
.error_message()
.unwrap()
.contains("no kubernetes namespace mapping"));
}
#[test]
fn default_secret_name() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let target = KubernetesTarget {
config,
target_config,
runner: &MockCommandRunner::from_outputs(vec![]),
};
assert_eq!(target.secret_name(), "myapp-secrets");
}
#[test]
fn custom_secret_name() {
let dir = tempfile::tempdir().unwrap();
let yaml = r"
project: myapp
environments: [dev]
targets:
kubernetes:
namespace:
dev: ns
secret_name: custom-secret
";
let path = dir.path().join("esk.yaml");
std::fs::write(&path, yaml).unwrap();
let config = Config::load(&path).unwrap();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let target = KubernetesTarget {
config: &config,
target_config,
runner: &MockCommandRunner::from_outputs(vec![]),
};
assert_eq!(target.secret_name(), "custom-secret");
}
#[test]
fn validate_k8s_name_valid() {
assert!(validate_k8s_name("myapp-secrets", "name").is_ok());
assert!(validate_k8s_name("a", "name").is_ok());
assert!(validate_k8s_name("abc123", "name").is_ok());
assert!(validate_k8s_name("my-ns", "namespace").is_ok());
}
#[test]
fn validate_k8s_name_uppercase_fails() {
let err = validate_k8s_name("MyApp", "name").unwrap_err();
assert!(err.to_string().contains("must start with a lowercase"));
}
#[test]
fn validate_k8s_name_newline_fails() {
let err = validate_k8s_name("my\nname", "name").unwrap_err();
assert!(err.to_string().contains("invalid character"));
}
#[test]
fn validate_k8s_name_leading_hyphen_fails() {
let err = validate_k8s_name("-myname", "name").unwrap_err();
assert!(err.to_string().contains("must start with"));
}
#[test]
fn validate_k8s_name_empty_fails() {
let err = validate_k8s_name("", "name").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn validate_k8s_name_too_long_fails() {
let long_name = "a".repeat(254);
let err = validate_k8s_name(&long_name, "name").unwrap_err();
assert!(err.to_string().contains("exceeds 253"));
}
#[test]
fn deploy_batch_empty() {
let fixture = make_config();
let config = fixture.config();
let target_config = config.targets.kubernetes.as_ref().unwrap();
let runner = MockCommandRunner::from_outputs(vec![CommandOutput {
success: true,
stdout: vec![],
stderr: vec![],
}]);
let target = KubernetesTarget {
config,
target_config,
runner: &runner,
};
let results = target.deploy_batch(&[], &make_target("dev"));
assert!(results.is_empty());
}
}