use std::path::{Path, PathBuf};
use super::DeploymentContract;
use super::error::DeploymentError;
#[cfg(feature = "config-schema")]
#[must_use]
pub fn config_schema_json<T: schemars::JsonSchema>() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(T)).unwrap_or(serde_json::Value::Null)
}
struct Artefact {
name: &'static str,
content: String,
}
fn render(contract: &DeploymentContract) -> Result<Vec<Artefact>, DeploymentError> {
let mut out = Vec::new();
if let Some(schema) = &contract.config_schema {
out.push(Artefact {
name: "config-schema.json",
content: to_json(schema, "config-schema.json")?,
});
out.push(Artefact {
name: "config-schema.yaml",
content: to_yaml(schema, "config-schema.yaml")?,
});
}
if !contract.capabilities.is_empty() {
out.push(Artefact {
name: "capability-catalog.json",
content: to_json(&contract.capabilities, "capability-catalog.json")?,
});
out.push(Artefact {
name: "capability-catalog.yaml",
content: to_yaml(&contract.capabilities, "capability-catalog.yaml")?,
});
}
Ok(out)
}
fn to_json<T: serde::Serialize>(value: &T, what: &str) -> Result<String, DeploymentError> {
let mut s = serde_json::to_string_pretty(value).map_err(|e| DeploymentError::Serialise {
what: what.to_string(),
message: e.to_string(),
})?;
s.push('\n');
Ok(s)
}
fn to_yaml<T: serde::Serialize>(value: &T, what: &str) -> Result<String, DeploymentError> {
let s = serde_yaml_ng::to_string(value).map_err(|e| DeploymentError::Serialise {
what: what.to_string(),
message: e.to_string(),
})?;
Ok(s)
}
pub fn emit_config_artifacts(
contract: &DeploymentContract,
dir: impl AsRef<Path>,
) -> Result<Vec<PathBuf>, DeploymentError> {
let dir = dir.as_ref();
let artefacts = render(contract)?;
if artefacts.is_empty() {
return Ok(Vec::new());
}
std::fs::create_dir_all(dir).map_err(|e| DeploymentError::CreateDir {
path: dir.display().to_string(),
source: e,
})?;
let mut written = Vec::with_capacity(artefacts.len());
for artefact in artefacts {
let path = dir.join(artefact.name);
std::fs::write(&path, artefact.content.as_bytes()).map_err(|e| {
DeploymentError::WriteFile {
path: path.display().to_string(),
source: e,
}
})?;
written.push(path);
}
Ok(written)
}
pub fn check_config_artifact_drift(
contract: &DeploymentContract,
dir: impl AsRef<Path>,
) -> Result<(), DeploymentError> {
let dir = dir.as_ref();
let artefacts = render(contract)?;
for artefact in artefacts {
let path = dir.join(artefact.name);
let committed = std::fs::read_to_string(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
DeploymentError::Drift {
path: path.display().to_string(),
detail: format!(
"artefact is missing -- run `<app> config-schema --dir {}` \
(or generate-artefacts) and commit the output",
dir.display()
),
}
} else {
DeploymentError::ReadFile {
path: path.display().to_string(),
source: e,
}
}
})?;
if committed != artefact.content {
return Err(DeploymentError::Drift {
path: path.display().to_string(),
detail: format!(
"committed content differs from the generated output ({} vs {} bytes) -- \
the Config/catalog changed. Run `<app> config-schema --dir {}` \
(or generate-artefacts) and commit the result.",
committed.len(),
artefact.content.len(),
dir.display()
),
});
}
}
Ok(())
}
pub fn assert_no_config_artifact_drift(contract: &DeploymentContract, dir: impl AsRef<Path>) {
if let Err(e) = check_config_artifact_drift(contract, dir) {
panic!("{e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deployment::{
Capability, FieldSpec, HealthContract, ImageProfile, NativeDepsContract, OciLabels,
};
fn contract_with_catalog() -> DeploymentContract {
DeploymentContract {
app_name: "demo".into(),
binary_name: String::new(),
description: String::new(),
metrics_port: 9090,
health: HealthContract::default(),
env_prefix: "DEMO".into(),
metric_prefix: "demo".into(),
config_mount_path: "/etc/demo/demo.yaml".into(),
image_registry: "ghcr.io/hyperi-io".into(),
extra_ports: vec![],
entrypoint_args: vec![],
secrets: vec![],
default_config: None,
depends_on: vec![],
keda: None,
base_image: "debian:trixie-slim".into(),
native_deps: NativeDepsContract::default(),
image_profile: ImageProfile::Production,
oci_labels: OciLabels::default(),
schema_version: 3,
config_schema: Some(serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "region": { "type": "string" } }
})),
capabilities: vec![
Capability::source("aws")
.maturity("stable")
.field(FieldSpec::string("id").required())
.field(FieldSpec::secret("secret_access_key")),
],
}
}
#[test]
fn emit_writes_four_files_when_both_present() {
let dir = tempfile::tempdir().unwrap();
let written = emit_config_artifacts(&contract_with_catalog(), dir.path()).unwrap();
assert_eq!(written.len(), 4);
for name in [
"config-schema.json",
"config-schema.yaml",
"capability-catalog.json",
"capability-catalog.yaml",
] {
assert!(dir.path().join(name).exists(), "missing {name}");
}
}
#[test]
fn emit_is_deterministic() {
let contract = contract_with_catalog();
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
emit_config_artifacts(&contract, a.path()).unwrap();
emit_config_artifacts(&contract, b.path()).unwrap();
for name in ["config-schema.json", "capability-catalog.json"] {
let av = std::fs::read_to_string(a.path().join(name)).unwrap();
let bv = std::fs::read_to_string(b.path().join(name)).unwrap();
assert_eq!(av, bv, "{name} not deterministic");
}
}
#[test]
fn emit_nothing_when_contract_bare() {
let mut contract = contract_with_catalog();
contract.config_schema = None;
contract.capabilities = vec![];
let dir = tempfile::tempdir().unwrap();
let written = emit_config_artifacts(&contract, dir.path()).unwrap();
assert!(written.is_empty());
}
#[test]
fn drift_check_passes_on_fresh_emit() {
let contract = contract_with_catalog();
let dir = tempfile::tempdir().unwrap();
emit_config_artifacts(&contract, dir.path()).unwrap();
assert!(check_config_artifact_drift(&contract, dir.path()).is_ok());
}
#[test]
fn drift_check_catches_planted_drift() {
let contract = contract_with_catalog();
let dir = tempfile::tempdir().unwrap();
emit_config_artifacts(&contract, dir.path()).unwrap();
std::fs::write(dir.path().join("capability-catalog.json"), "[]\n").unwrap();
let err = check_config_artifact_drift(&contract, dir.path()).unwrap_err();
assert!(matches!(err, DeploymentError::Drift { .. }), "got {err:?}");
}
#[test]
fn drift_check_catches_missing_file() {
let contract = contract_with_catalog();
let dir = tempfile::tempdir().unwrap();
let err = check_config_artifact_drift(&contract, dir.path()).unwrap_err();
assert!(matches!(err, DeploymentError::Drift { .. }), "got {err:?}");
}
}