use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{fs, process};
use log::{debug, error};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ManifestError {
#[error("Failed to read manifest file: {0}")]
FileReadError(#[from] std::io::Error),
#[error("Failed to parse manifest: {0}")]
ParseError(#[from] serde_yaml::Error),
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Invalid field: {0}")]
InvalidField(String),
#[error("Failed to resolve file() directive: {0}")]
FileIncludeError(String),
#[error("Manifest validation failed: {0}")]
ValidationFailed(String),
}
pub type ManifestResult<T> = Result<T, ManifestError>;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Manifest {
#[serde(default = "default_version")]
pub version: u32,
pub name: String,
#[serde(default)]
pub description: String,
pub providers: Vec<String>,
#[serde(default)]
pub globals: Vec<GlobalVar>,
#[serde(default)]
pub resources: Vec<Resource>,
#[serde(default)]
pub exports: Vec<String>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GlobalVar {
pub name: String,
#[serde(default)]
pub value: serde_yaml::Value,
#[serde(default)]
pub description: String,
#[serde(default)]
pub protected: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Resource {
pub name: String,
#[serde(default = "default_resource_type")]
pub r#type: String,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub sql: Option<String>,
#[serde(default)]
pub run: Option<String>,
#[serde(default)]
pub props: Vec<Property>,
#[serde(default)]
pub exports: Vec<serde_yaml::Value>,
#[serde(default)]
pub protected: Vec<String>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub r#if: Option<String>,
#[serde(default)]
pub skip_validation: Option<bool>,
#[serde(default)]
pub auth: Option<serde_yaml::Value>,
#[serde(default)]
pub return_vals: Option<HashMap<String, Vec<serde_yaml::Value>>>,
}
impl Resource {
pub fn get_return_val_mappings(&self, operation: &str) -> Vec<(String, String)> {
let Some(ref rv) = self.return_vals else {
return vec![];
};
let Some(specs) = rv.get(operation) else {
return vec![];
};
let mut mappings = Vec::new();
for spec in specs {
match spec {
serde_yaml::Value::String(s) => {
mappings.push((s.clone(), s.clone()));
}
serde_yaml::Value::Mapping(m) => {
for (k, v) in m {
if let (Some(src), Some(tgt)) = (k.as_str(), v.as_str()) {
mappings.push((src.to_string(), tgt.to_string()));
}
}
}
_ => {}
}
}
mappings
}
}
fn default_resource_type() -> String {
"resource".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Property {
pub name: String,
#[serde(default)]
pub value: Option<serde_yaml::Value>,
#[serde(default)]
pub values: Option<HashMap<String, PropertyValue>>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub merge: Option<Vec<String>>,
#[serde(default)]
pub protected: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PropertyValue {
pub value: serde_yaml::Value,
}
fn parse_file_directive(s: &str) -> Option<&str> {
let trimmed = s.trim();
if trimmed.starts_with("file(") && trimmed.ends_with(')') {
let inner = trimmed[5..trimmed.len() - 1].trim();
if !inner.is_empty() {
return Some(inner);
}
}
None
}
fn resolve_file_directives(value: &mut serde_yaml::Value, base_dir: &Path) -> ManifestResult<()> {
match value {
serde_yaml::Value::String(s) => {
if let Some(file_path) = parse_file_directive(s) {
let resolved = load_file_contents(file_path, base_dir)?;
*value = resolved;
}
}
serde_yaml::Value::Sequence(seq) => {
let mut i = 0;
while i < seq.len() {
resolve_file_directives(&mut seq[i], base_dir)?;
i += 1;
}
}
serde_yaml::Value::Mapping(map) => {
let keys: Vec<serde_yaml::Value> = map.keys().cloned().collect();
for key in keys {
if let Some(val) = map.get_mut(&key) {
resolve_file_directives(val, base_dir)?;
}
}
}
_ => {}
}
Ok(())
}
fn load_file_contents(file_path: &str, base_dir: &Path) -> ManifestResult<serde_yaml::Value> {
let full_path = base_dir.join(file_path);
debug!(
"Resolving file() directive: {} -> {:?}",
file_path, full_path
);
let content = fs::read_to_string(&full_path).map_err(|e| {
ManifestError::FileIncludeError(format!(
"cannot read '{}' (resolved to {:?}): {}",
file_path, full_path, e
))
})?;
let ext = full_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let mut parsed: serde_yaml::Value = match ext {
"json" => {
let json_val: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
ManifestError::FileIncludeError(format!(
"failed to parse JSON file '{}': {}",
file_path, e
))
})?;
serde_yaml::to_value(&json_val).map_err(|e| {
ManifestError::FileIncludeError(format!(
"failed to convert JSON to YAML value for '{}': {}",
file_path, e
))
})?
}
"yml" | "yaml" => serde_yaml::from_str(&content).map_err(|e| {
ManifestError::FileIncludeError(format!(
"failed to parse YAML file '{}': {}",
file_path, e
))
})?,
_ => {
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&content) {
serde_yaml::to_value(&json_val).map_err(|e| {
ManifestError::FileIncludeError(format!(
"failed to convert parsed content for '{}': {}",
file_path, e
))
})?
} else {
serde_yaml::from_str(&content).map_err(|e| {
ManifestError::FileIncludeError(format!(
"failed to parse file '{}' as JSON or YAML: {}",
file_path, e
))
})?
}
}
};
resolve_file_directives(&mut parsed, base_dir)?;
Ok(parsed)
}
fn resolve_manifest_file_directives(
manifest: &mut Manifest,
base_dir: &Path,
) -> ManifestResult<()> {
for global in &mut manifest.globals {
resolve_file_directives(&mut global.value, base_dir)?;
}
for resource in &mut manifest.resources {
for prop in &mut resource.props {
if let Some(ref mut value) = prop.value {
resolve_file_directives(value, base_dir)?;
}
if let Some(ref mut values) = prop.values {
for env_val in values.values_mut() {
resolve_file_directives(&mut env_val.value, base_dir)?;
}
}
}
}
Ok(())
}
impl Manifest {
pub fn load_from_file(path: &Path) -> ManifestResult<Self> {
let content = fs::read_to_string(path)?;
let mut manifest: Manifest = serde_yaml::from_str(&content)?;
let stack_dir = path.parent().unwrap_or(Path::new("."));
let resources_dir = stack_dir.join("resources");
resolve_manifest_file_directives(&mut manifest, &resources_dir)?;
manifest.validate()?;
Ok(manifest)
}
pub fn load_from_stack_dir(stack_dir: &Path) -> ManifestResult<Self> {
let manifest_path = stack_dir.join("stackql_manifest.yml");
Self::load_from_file(&manifest_path)
}
fn validate(&self) -> ManifestResult<()> {
if self.name.is_empty() {
return Err(ManifestError::MissingField("name".to_string()));
}
if self.providers.is_empty() {
return Err(ManifestError::MissingField("providers".to_string()));
}
for resource in &self.resources {
if resource.name.is_empty() {
return Err(ManifestError::MissingField("resource.name".to_string()));
}
for prop in &resource.props {
if prop.name.is_empty() {
return Err(ManifestError::MissingField("property.name".to_string()));
}
if prop.value.is_none() && prop.values.is_none() && prop.merge.is_none() {
return Err(ManifestError::MissingField(format!(
"Property '{}' in resource '{}' has no value, values, or merge",
prop.name, resource.name
)));
}
}
}
if let Err(errors) = crate::resource::validation::validate_manifest(self) {
let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
return Err(ManifestError::ValidationFailed(messages.join("; ")));
}
Ok(())
}
pub fn get_resource_query_path(&self, stack_dir: &Path, resource: &Resource) -> PathBuf {
let file_name = match &resource.file {
Some(file) => file.clone(),
_none => format!("{}.iql", resource.name),
};
stack_dir.join("resources").join(file_name)
}
pub fn get_property_value<'a>(
property: &'a Property,
env: &str,
) -> Option<&'a serde_yaml::Value> {
if let Some(ref value) = property.value {
return Some(value);
}
if let Some(ref values) = property.values {
if let Some(env_value) = values.get(env) {
return Some(&env_value.value);
}
}
None
}
pub fn find_resource(&self, name: &str) -> Option<&Resource> {
self.resources.iter().find(|r| r.name == name)
}
pub fn globals_as_map(&self) -> HashMap<String, serde_yaml::Value> {
self.globals
.iter()
.map(|g| (g.name.clone(), g.value.clone()))
.collect()
}
pub fn load_from_dir_or_exit(stack_dir: &str) -> Self {
debug!("Loading manifest file from stack directory: {}", stack_dir);
match Self::load_from_stack_dir(Path::new(stack_dir)) {
Ok(manifest) => {
debug!("Stack name: {}", manifest.name);
debug!("Stack description: {}", manifest.description);
debug!("Providers: {:?}", manifest.providers);
debug!("Resources count: {}", manifest.resources.len());
manifest
}
Err(err) => {
error!("Failed to load manifest: {}", err);
process::exit(1);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_test_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("resources")).unwrap();
dir
}
#[test]
fn test_parse_file_directive() {
assert_eq!(
parse_file_directive("file(foo/bar.json)"),
Some("foo/bar.json")
);
assert_eq!(
parse_file_directive(" file( foo/bar.json ) "),
Some("foo/bar.json")
);
assert_eq!(parse_file_directive("file()"), None);
assert_eq!(parse_file_directive("not a directive"), None);
assert_eq!(parse_file_directive("file("), None);
assert_eq!(parse_file_directive("files(foo.json)"), None);
}
#[test]
fn test_resolve_file_directive_json_object() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(
resources_dir.join("stmt.json"),
r#"{"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["*"]}"#,
)
.unwrap();
let mut value = serde_yaml::Value::String("file(stmt.json)".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
assert!(value.is_mapping(), "Expected mapping, got: {:?}", value);
let map = value.as_mapping().unwrap();
assert_eq!(
map.get(serde_yaml::Value::String("Effect".into())),
Some(&serde_yaml::Value::String("Allow".into()))
);
}
#[test]
fn test_resolve_file_directive_json_array() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(
resources_dir.join("statements.json"),
r#"[{"Effect": "Allow"}, {"Effect": "Deny"}]"#,
)
.unwrap();
let mut value = serde_yaml::Value::String("file(statements.json)".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
assert!(value.is_sequence(), "Expected sequence, got: {:?}", value);
let seq = value.as_sequence().unwrap();
assert_eq!(seq.len(), 2);
}
#[test]
fn test_resolve_file_directive_yaml_file() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(
resources_dir.join("stmt.yaml"),
"Effect: Allow\nAction:\n - s3:GetObject\nResource:\n - \"*\"\n",
)
.unwrap();
let mut value = serde_yaml::Value::String("file(stmt.yaml)".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
assert!(value.is_mapping(), "Expected mapping, got: {:?}", value);
}
#[test]
fn test_resolve_file_directive_in_sequence() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(
resources_dir.join("s1.json"),
r#"{"Sid": "stmt1", "Effect": "Allow"}"#,
)
.unwrap();
fs::write(
resources_dir.join("s2.json"),
r#"{"Sid": "stmt2", "Effect": "Deny"}"#,
)
.unwrap();
let mut value = serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("file(s1.json)".to_string()),
serde_yaml::Value::String("file(s2.json)".to_string()),
]);
resolve_file_directives(&mut value, &resources_dir).unwrap();
let seq = value.as_sequence().unwrap();
assert_eq!(seq.len(), 2);
assert!(seq[0].is_mapping());
assert!(seq[1].is_mapping());
}
#[test]
fn test_resolve_file_directive_nested_in_mapping() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(resources_dir.join("stmts.json"), r#"[{"Effect": "Allow"}]"#).unwrap();
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("Statement".into()),
serde_yaml::Value::String("file(stmts.json)".to_string()),
);
map.insert(
serde_yaml::Value::String("Version".into()),
serde_yaml::Value::String("2012-10-17".into()),
);
let mut value = serde_yaml::Value::Mapping(map);
resolve_file_directives(&mut value, &resources_dir).unwrap();
let resolved_map = value.as_mapping().unwrap();
let statement = resolved_map
.get(serde_yaml::Value::String("Statement".into()))
.unwrap();
assert!(statement.is_sequence(), "Statement should be a sequence");
}
#[test]
fn test_resolve_file_directive_subdirectory() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::create_dir_all(resources_dir.join("policies")).unwrap();
fs::write(
resources_dir.join("policies/stmt.json"),
r#"{"Effect": "Allow"}"#,
)
.unwrap();
let mut value = serde_yaml::Value::String("file(policies/stmt.json)".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
assert!(value.is_mapping());
}
#[test]
fn test_resolve_file_directive_missing_file() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
let mut value = serde_yaml::Value::String("file(nonexistent.json)".to_string());
let result = resolve_file_directives(&mut value, &resources_dir);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("nonexistent.json"));
}
#[test]
fn test_resolve_file_directive_leaves_non_directives_alone() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
let mut value = serde_yaml::Value::String("just a normal string".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
assert_eq!(
value,
serde_yaml::Value::String("just a normal string".into())
);
}
#[test]
fn test_resolve_file_directive_leaves_template_vars_alone() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
let mut value =
serde_yaml::Value::String("{{ stack_name }}-{{ stack_env }}-policy".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
assert_eq!(
value,
serde_yaml::Value::String("{{ stack_name }}-{{ stack_env }}-policy".into())
);
}
#[test]
fn test_load_manifest_with_file_directives() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(
resources_dir.join("test_stmt.json"),
r#"[{"Effect": "Allow", "Action": ["s3:*"], "Resource": ["*"]}]"#,
)
.unwrap();
let manifest_content = r#"
version: 1
name: test-stack
description: test
providers:
- aws
resources:
- name: test_resource
props:
- name: policies
value:
- PolicyDocument:
Statement: file(test_stmt.json)
Version: '2012-10-17'
PolicyName: test-policy
"#;
fs::write(dir.path().join("stackql_manifest.yml"), manifest_content).unwrap();
let manifest = Manifest::load_from_stack_dir(dir.path()).unwrap();
let resource = manifest.find_resource("test_resource").unwrap();
let policies_prop = resource
.props
.iter()
.find(|p| p.name == "policies")
.unwrap();
let value = policies_prop.value.as_ref().unwrap();
let seq = value.as_sequence().unwrap();
assert_eq!(seq.len(), 1);
let policy = seq[0].as_mapping().unwrap();
let doc = policy
.get(serde_yaml::Value::String("PolicyDocument".into()))
.unwrap()
.as_mapping()
.unwrap();
let statement = doc
.get(serde_yaml::Value::String("Statement".into()))
.unwrap();
assert!(
statement.is_sequence(),
"Statement should be resolved to a sequence, got: {:?}",
statement
);
}
#[test]
fn test_nested_file_directives() {
let dir = setup_test_dir();
let resources_dir = dir.path().join("resources");
fs::write(
resources_dir.join("inner.json"),
r#"{"Action": ["s3:GetObject"]}"#,
)
.unwrap();
fs::write(
resources_dir.join("outer.yaml"),
"Effect: Allow\nDetails: file(inner.json)\n",
)
.unwrap();
let mut value = serde_yaml::Value::String("file(outer.yaml)".to_string());
resolve_file_directives(&mut value, &resources_dir).unwrap();
let map = value.as_mapping().unwrap();
let details = map
.get(serde_yaml::Value::String("Details".into()))
.unwrap();
assert!(
details.is_mapping(),
"Nested file() should be resolved, got: {:?}",
details
);
}
}