use crate::ScaffoldError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AtlasDatabaseUrl {
SecretKeyRef {
name: String,
key: String,
},
Inline(String),
}
#[derive(Clone, Debug)]
pub struct AtlasSchemaSpec {
pub name: String,
pub namespace: Option<String>,
pub database: AtlasDatabaseUrl,
pub dev_url: Option<String>,
pub sql: String,
}
pub fn render_atlas_schema(spec: &AtlasSchemaSpec) -> Result<String, ScaffoldError> {
validate_k8s_name(&spec.name, "AtlasSchema name")?;
if let Some(namespace) = trimmed_non_empty(spec.namespace.as_deref()) {
validate_k8s_name(namespace, "AtlasSchema namespace")?;
}
let name = spec.name.trim();
if spec.sql.trim().is_empty() {
return Err(ScaffoldError::new(
"AtlasSchema has no schema SQL to apply (no tables registered?)",
));
}
let mut out = String::new();
out.push_str("apiVersion: db.atlasgo.io/v1alpha1\n");
out.push_str("kind: AtlasSchema\n");
out.push_str("metadata:\n");
out.push_str(&format!(" name: {name}\n"));
if let Some(namespace) = trimmed_non_empty(spec.namespace.as_deref()) {
out.push_str(&format!(" namespace: {namespace}\n"));
}
out.push_str("spec:\n");
match &spec.database {
AtlasDatabaseUrl::SecretKeyRef { name: secret, key } => {
let secret = secret.trim();
let key = key.trim();
if secret.is_empty() || key.is_empty() {
return Err(ScaffoldError::new(
"AtlasSchema secret reference needs both a secret name and a key",
));
}
out.push_str(" urlFrom:\n");
out.push_str(" secretKeyRef:\n");
out.push_str(&format!(" name: {secret}\n"));
out.push_str(&format!(" key: {key}\n"));
}
AtlasDatabaseUrl::Inline(url) => {
let url = url.trim();
if url.is_empty() {
return Err(ScaffoldError::new(
"AtlasSchema inline database URL is empty",
));
}
out.push_str(&format!(" url: {}\n", yaml_quote(url)));
}
}
if let Some(dev_url) = trimmed_non_empty(spec.dev_url.as_deref()) {
out.push_str(&format!(" devURL: {}\n", yaml_quote(dev_url)));
}
out.push_str(" schema:\n");
out.push_str(" sql: |\n");
for line in spec.sql.trim_end().lines() {
if line.is_empty() {
out.push('\n');
} else {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
fn trimmed_non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn validate_k8s_name(value: &str, field: &str) -> Result<(), ScaffoldError> {
let name = value.trim();
if name.is_empty() {
return Err(ScaffoldError::new(format!("{field} must not be empty")));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(ScaffoldError::new(format!(
"{field} `{name}` must contain only lowercase letters, digits, and hyphens"
)));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(ScaffoldError::new(format!(
"{field} `{name}` must not start or end with a hyphen"
)));
}
Ok(())
}
fn yaml_quote(value: &str) -> String {
serde_json::to_string(value).expect("string serialization should succeed")
}
#[cfg(test)]
mod tests {
use super::*;
fn secret_spec() -> AtlasSchemaSpec {
AtlasSchemaSpec {
name: "orders".to_string(),
namespace: None,
database: AtlasDatabaseUrl::SecretKeyRef {
name: "orders-db".to_string(),
key: "url".to_string(),
},
dev_url: None,
sql: "CREATE TABLE orders (id text PRIMARY KEY);".to_string(),
}
}
#[test]
fn renders_secret_ref_resource_with_indented_sql() {
let yaml = render_atlas_schema(&secret_spec()).unwrap();
assert!(yaml.contains("apiVersion: db.atlasgo.io/v1alpha1\n"));
assert!(yaml.contains("kind: AtlasSchema\n"));
assert!(yaml.contains(" name: orders\n"));
assert!(
yaml.contains(" urlFrom:\n secretKeyRef:\n name: orders-db\n key: url\n")
);
assert!(yaml.contains(" sql: |\n CREATE TABLE orders (id text PRIMARY KEY);\n"));
assert!(!yaml.contains("namespace:"));
assert!(!yaml.contains("devURL:"));
}
#[test]
fn namespace_and_dev_url_are_optional_and_quoted() {
let mut spec = secret_spec();
spec.namespace = Some("data".to_string());
spec.dev_url = Some("docker://postgres/16/dev".to_string());
let yaml = render_atlas_schema(&spec).unwrap();
assert!(yaml.contains(" namespace: data\n"));
assert!(yaml.contains(" devURL: \"docker://postgres/16/dev\"\n"));
}
#[test]
fn inline_url_is_quoted_to_survive_special_characters() {
let mut spec = secret_spec();
spec.database =
AtlasDatabaseUrl::Inline("postgres://u:p@host:5432/db?sslmode=disable".to_string());
let yaml = render_atlas_schema(&spec).unwrap();
assert!(yaml.contains(" url: \"postgres://u:p@host:5432/db?sslmode=disable\"\n"));
assert!(!yaml.contains("urlFrom:"));
}
#[test]
fn multi_statement_sql_keeps_every_line_indented() {
let mut spec = secret_spec();
spec.sql = "CREATE TABLE a (id text);\nCREATE TABLE b (id text);".to_string();
let yaml = render_atlas_schema(&spec).unwrap();
assert!(yaml.contains(" CREATE TABLE a (id text);\n CREATE TABLE b (id text);\n"));
}
#[test]
fn empty_name_or_sql_is_rejected() {
let mut blank_name = secret_spec();
blank_name.name = " ".to_string();
assert!(render_atlas_schema(&blank_name).is_err());
let mut blank_sql = secret_spec();
blank_sql.sql = "\n \n".to_string();
assert!(render_atlas_schema(&blank_sql).is_err());
}
#[test]
fn invalid_kubernetes_names_are_rejected() {
for bad in [
"Orders",
"orders_db",
"orders.db",
"-orders",
"orders-",
"a b",
] {
let mut spec = secret_spec();
spec.name = bad.to_string();
assert!(
render_atlas_schema(&spec).is_err(),
"expected `{bad}` to be rejected"
);
}
let mut bad_ns = secret_spec();
bad_ns.namespace = Some("Data".to_string());
assert!(render_atlas_schema(&bad_ns).is_err());
let mut ok = secret_spec();
ok.name = "orders-2".to_string();
assert!(render_atlas_schema(&ok).is_ok());
}
#[test]
fn incomplete_secret_ref_is_rejected() {
let mut spec = secret_spec();
spec.database = AtlasDatabaseUrl::SecretKeyRef {
name: "orders-db".to_string(),
key: " ".to_string(),
};
assert!(render_atlas_schema(&spec).is_err());
}
}