use lightshuttle_export::{Emitter, ExportArtifacts, HelmEmitter, KubernetesEmitter, lower};
use lightshuttle_manifest::Manifest;
mod common;
const STACK: &str = r"
project:
name: shop
version: 1.4.0
export:
helm:
chart_name: shop-chart
resources:
db:
postgres:
version: '16'
password: devsecret
volume: dbdata
cache:
redis:
version: '7'
api:
container:
image: alpine:3.20
ports:
- 8080:80
env:
LOG_LEVEL: info
API_TOKEN: t0ken
depends_on: [db]
";
fn artifacts(yaml: &str) -> ExportArtifacts {
let manifest = Manifest::parse(yaml).expect("manifest parses");
let model = lower(&manifest).expect("lowering succeeds");
HelmEmitter.emit(&model).expect("emit succeeds")
}
fn file<'a>(artifacts: &'a ExportArtifacts, name: &str) -> &'a str {
let found = artifacts
.files
.iter()
.find(|f| f.path.to_str() == Some(name))
.unwrap_or_else(|| panic!("missing file {name}"));
found.contents.as_str()
}
#[test]
fn matches_golden_files() {
let a = artifacts(STACK);
assert_eq!(
file(&a, "Chart.yaml"),
include_str!("golden/helm/Chart.yaml"),
"Chart.yaml drifted"
);
assert_eq!(
file(&a, "values.yaml"),
include_str!("golden/helm/values.yaml"),
"values.yaml drifted"
);
assert_eq!(
file(&a, "templates/db.yaml"),
include_str!("golden/helm/db.yaml"),
"templates/db.yaml drifted"
);
assert_eq!(
file(&a, "templates/cache.yaml"),
include_str!("golden/helm/cache.yaml"),
"cache.yaml drifted from the golden file"
);
}
#[test]
fn values_carry_resource_knobs() {
let values = {
let a = artifacts(STACK);
file(&a, "values.yaml").to_owned()
};
assert!(values.contains("replicas: 1"));
assert!(values.contains("repository: postgres"));
assert!(values.contains("LOG_LEVEL: info"), "env in values");
assert!(
values.contains("API_TOKEN: '***'"),
"secret placeholder in values"
);
}
#[test]
fn templates_reference_values() {
let a = artifacts(STACK);
let db = file(&a, "templates/db.yaml");
assert!(db.contains(r#"index .Values.services "db""#), "got:\n{db}");
assert!(db.contains("replicas: {{ $svc.replicas }}"), "got:\n{db}");
assert!(db.contains("range $k, $v := $svc.env"), "got:\n{db}");
}
const PORTLESS_STACK: &str = r"
project:
name: shop
resources:
worker:
container:
image: alpine:3.20
env:
DB_URL: postgres://db:5432/app
DB_PASSWORD: s3cret
";
#[test]
fn portless_service_emits_no_helm_service() {
let a = artifacts(PORTLESS_STACK);
let worker_template = file(&a, "templates/worker.yaml");
assert!(
!worker_template.contains("kind: Service"),
"worker has no ports so no Service block should be emitted, got:\n{worker_template}"
);
}
#[test]
fn mixed_env_routes_to_values() {
let a = artifacts(PORTLESS_STACK);
let values = file(&a, "values.yaml");
let worker_template = file(&a, "templates/worker.yaml");
assert!(
values.contains("DB_URL"),
"DB_URL missing from values.yaml, got:\n{values}"
);
assert!(
values.contains("DB_PASSWORD: '***'"),
"DB_PASSWORD placeholder missing from values.yaml, got:\n{values}"
);
assert!(
!values.contains("s3cret"),
"real secret value leaked into values.yaml, got:\n{values}"
);
assert!(
worker_template.contains("range $k, $v := $svc.env"),
"env range missing from worker template, got:\n{worker_template}"
);
assert!(
worker_template.contains("range $k, $v := $svc.secrets"),
"secrets range missing from worker template, got:\n{worker_template}"
);
}
#[test]
fn portless_worker_matches_golden() {
let a = artifacts(PORTLESS_STACK);
assert_eq!(
file(&a, "templates/worker.yaml"),
include_str!("golden/helm/worker.yaml"),
"templates/worker.yaml drifted from the golden file"
);
}
#[test]
fn emits_resolved_command_as_args() {
let a = artifacts(STACK);
let cache = file(&a, "templates/cache.yaml");
assert!(
cache.contains(" args:\n - redis-server\n"),
"cache args missing, got:\n{cache}"
);
assert!(
!cache.contains(" command:\n - redis-server\n"),
"redis-server must be args, not command: command is the entrypoint in Kubernetes, got:\n{cache}"
);
}
fn argv_block<'a>(text: &'a str, header: &str) -> Vec<&'a str> {
let after = text.split(header).nth(1).unwrap_or_else(|| {
panic!("header {header:?} not found in:\n{text}");
});
after
.lines()
.take_while(|line| line.starts_with(" - "))
.collect()
}
fn raw_arg_scalar(text: &str, header: &str) -> String {
let after = text.split(header).nth(1).unwrap_or_else(|| {
panic!("header {header:?} not found in:\n{text}");
});
let mut lines = after.lines();
let first = lines
.next()
.unwrap_or_else(|| panic!("at least one line after header {header:?} in:\n{text}"));
let first = first
.strip_prefix(" - ")
.unwrap_or_else(|| panic!("list item prefix missing in {first:?}"));
let mut out = first.to_owned();
for line in lines {
if line.starts_with(" - ")
|| line == "---"
|| (!line.is_empty() && !line.starts_with(' '))
{
break;
}
out.push('\n');
out.push_str(line);
}
out
}
#[test]
fn helm_quotes_scalars_like_the_kubernetes_emitter() {
let yaml = r"
project:
name: shop
resources:
svc:
container:
image: alpine:3.20
entrypoint: ['sh', '-c']
command: ['echo a: b']
";
let manifest = Manifest::parse(yaml).expect("manifest parses");
let model = lower(&manifest).expect("lowering succeeds");
let helm = HelmEmitter.emit(&model).expect("helm emit succeeds");
let kubernetes = KubernetesEmitter
.emit(&model)
.expect("kubernetes emit succeeds");
let helm_svc = helm
.files
.iter()
.find(|f| f.path.to_str() == Some("templates/svc.yaml"))
.expect("helm template emitted")
.contents
.as_str();
let kubernetes_svc = kubernetes
.files
.iter()
.find(|f| f.path.to_str() == Some("svc.yaml"))
.expect("kubernetes manifest emitted")
.contents
.as_str();
assert_eq!(
argv_block(helm_svc, " command:\n"),
argv_block(kubernetes_svc, " command:\n"),
"Helm command: block quoted differently from Kubernetes, got helm:\n{helm_svc}\nkubernetes:\n{kubernetes_svc}"
);
assert_eq!(
argv_block(helm_svc, " args:\n"),
argv_block(kubernetes_svc, " args:\n"),
"Helm args: block quoted differently from Kubernetes, got helm:\n{helm_svc}\nkubernetes:\n{kubernetes_svc}"
);
let args = argv_block(helm_svc, " args:\n");
assert!(
args.contains(&" - 'echo a: b'"),
"the colon-space argument must be quoted, got:\n{helm_svc}"
);
}
#[test]
#[ignore = "requires helm on the host"]
fn output_passes_helm_lint() {
use std::io::Write;
if !common::tool_available("helm") {
eprintln!("skipping: helm not found on PATH");
return;
}
let dir = tempfile::tempdir().expect("temp dir");
let chart = dir.path().join("chart");
for f in &artifacts(STACK).files {
let path = chart.join(&f.path);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::File::create(&path)
.and_then(|mut file| file.write_all(f.contents.as_bytes()))
.expect("write chart file");
}
let output = std::process::Command::new("helm")
.arg("lint")
.arg(&chart)
.output()
.expect("helm runs");
assert!(
output.status.success(),
"helm lint rejected the chart:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn entrypoint_becomes_helm_command_and_command_becomes_args() {
let yaml = r"
project:
name: shop
version: 1.4.0
export:
helm:
chart_name: shop-chart
resources:
svc:
container:
image: alpine:3.20
entrypoint: ['sh', '-c']
command: ['echo hi']
";
let a = artifacts(yaml);
let svc = file(&a, "templates/svc.yaml");
assert!(
svc.contains(" command:\n - sh\n - -c\n"),
"the manifest entrypoint must become the chart command, got:\n{svc}"
);
assert!(
svc.contains(" args:\n - echo hi\n"),
"the manifest command must become args, got:\n{svc}"
);
}
#[test]
fn helm_escapes_template_braces_to_close_the_injection() {
for original in [
"redis {{ .Values.x }} arg",
"{{{",
"{{{{",
"a}}b",
"echo a: b {{ .V }}",
"line1 {{ .V }}\nline2",
] {
let yaml = format!(
r"
project:
name: shop
resources:
svc:
container:
image: alpine:3.20
command: [{original:?}]
"
);
let a = artifacts(&yaml);
let svc = file(&a, "templates/svc.yaml");
let scalar = raw_arg_scalar(svc, " args:\n");
let without_escapes = scalar.replace(r#"{{ "{{" }}"#, "");
assert!(
!without_escapes.contains("{{"),
"a raw, unescaped `{{{{` opener survived in the arg for {original:?}, got:\n{scalar}"
);
if original.contains("{{") {
assert!(
scalar.contains(r#"{{ "{{" }}"#),
"the `{{{{` opener must be escaped to the Helm literal for {original:?}, got:\n{scalar}"
);
}
let rendered = scalar.replace(r#"{{ "{{" }}"#, "{{");
let round_tripped: String = serde_norway::from_str(&rendered).unwrap_or_else(|e| {
panic!("the rendered scalar reparses as YAML for {original:?}: {e}\ngot:\n{rendered}")
});
assert_eq!(
round_tripped, original,
"the arg must round-trip through the escape/render/parse chain to the exact original value for {original:?}"
);
}
}
fn reparse_first_document(svc: &str) -> serde_norway::Value {
let first_doc = svc.split("---").next().expect("at least one document");
let without_directive = first_doc
.split_once('\n')
.expect("template has a leading directive line")
.1;
serde_norway::from_str(without_directive).expect("the emitted chart must reparse as YAML")
}
#[test]
fn multiline_arg_round_trips_through_the_emitted_template() {
let original = "set -e\necho one\nexec app";
let yaml = format!(
r"
project:
name: shop
resources:
svc:
container:
image: alpine:3.20
entrypoint: ['sh', '-c']
command: [{original:?}]
"
);
let a = artifacts(&yaml);
let svc = file(&a, "templates/svc.yaml");
let parsed = reparse_first_document(svc);
let args = parsed["spec"]["template"]["spec"]["containers"][0]["args"]
.as_sequence()
.expect("args is a sequence");
assert_eq!(args.len(), 1, "expected exactly one arg, got: {args:?}");
assert_eq!(
args[0].as_str(),
Some(original),
"the multi-line arg must round-trip exactly, got: {:?}",
args[0]
);
}
#[test]
fn trailing_newline_arg_round_trips_through_the_emitted_template() {
let original = "set -e\necho one\n\n";
let yaml = format!(
r"
project:
name: shop
resources:
svc:
container:
image: alpine:3.20
entrypoint: ['sh', '-c']
command: [{original:?}]
"
);
let a = artifacts(&yaml);
let svc = file(&a, "templates/svc.yaml");
let parsed = reparse_first_document(svc);
let args = parsed["spec"]["template"]["spec"]["containers"][0]["args"]
.as_sequence()
.expect("args is a sequence");
assert_eq!(args.len(), 1, "expected exactly one arg, got: {args:?}");
assert_eq!(
args[0].as_str(),
Some(original),
"the trailing newlines in the arg must round-trip exactly, got: {:?}",
args[0]
);
}