use crate::source_versions::{AWS_CLI_SOURCE, SOURCE_VERSIONS_FILE, SourceVersions};
use crate::write_source_versions;
use log::{info, warn};
use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
const AWS_CLI_OPERATION_CATALOG_FORMAT_VERSION: u64 = 1;
pub const AWS_CLI_OPERATION_CATALOG_FILE: &str = "aws_cli_operation_catalog.json";
#[derive(Debug, serde::Deserialize)]
struct AwsCliOperationCatalog {
format_version: u64,
adapters: Vec<CatalogAdapter>,
source: AwsCliOperationCatalogSource,
}
#[derive(Debug, serde::Deserialize)]
struct AwsCliOperationCatalogSource {
aws_cli_version: String,
}
#[derive(Debug, serde::Deserialize)]
struct CatalogAdapter {
service: String,
operation: String,
cfn_type: String,
#[serde(default)]
mappings: Vec<CatalogMapping>,
}
#[derive(Debug, serde::Deserialize)]
struct CatalogMapping {
target: String,
}
#[derive(Debug, serde::Deserialize)]
struct CompiledPropertySurface {
#[serde(default)]
properties: HashMap<String, serde_json::Value>,
#[serde(default)]
read_only_properties: BTreeSet<String>,
}
#[derive(Debug)]
pub struct PreservedAwsCliCatalog {
catalog: Vec<u8>,
aws_cli_version: String,
}
fn catalog_path(generated_dir: &Path) -> PathBuf {
generated_dir.join("data").join(AWS_CLI_OPERATION_CATALOG_FILE)
}
fn compiled_schemas_path(generated_dir: &Path) -> PathBuf {
generated_dir.join("schema-validator").join("compiled_schemas.json")
}
pub fn preserve_aws_cli_catalog(generated_dir: &Path) -> anyhow::Result<Option<PreservedAwsCliCatalog>> {
let path = catalog_path(generated_dir);
if !path.is_file() {
return Ok(None);
}
let catalog_bytes = fs::read(&path)
.map_err(|error| anyhow::anyhow!("failed to read committed catalog {}: {error}", path.display()))?;
let catalog = validate_catalog(&catalog_bytes)
.map_err(|error| anyhow::anyhow!("committed catalog {} cannot be preserved: {error}", path.display()))?;
Ok(Some(PreservedAwsCliCatalog {
catalog: catalog_bytes,
aws_cli_version: format!("{AWS_CLI_SOURCE}@{}", catalog.source.aws_cli_version),
}))
}
pub fn restore_aws_cli_catalog(generated_dir: &Path, preserved: PreservedAwsCliCatalog) -> anyhow::Result<()> {
let path = catalog_path(generated_dir);
let stale = stale_mappings(&preserved.catalog, &compiled_schemas_path(generated_dir))?;
anyhow::ensure!(
stale.is_empty(),
"the preserved AWS CLI operation catalog no longer agrees with the refreshed compiled schemas \
({} mapping(s), e.g. {}); rerun sync with --aws-cli-root <DIR> to regenerate it",
stale.len(),
stale.iter().take(3).cloned().collect::<Vec<_>>().join(", ")
);
fs::create_dir_all(generated_dir.join("data"))?;
fs::write(&path, &preserved.catalog)
.map_err(|error| anyhow::anyhow!("failed to restore catalog {}: {error}", path.display()))?;
record_aws_cli_version(generated_dir, preserved.aws_cli_version.clone())?;
warn!(
"Restored the previous AWS CLI operation catalog ({}) without regenerating it; pass --aws-cli-root <DIR> \
to derive it from the refreshed schemas",
preserved.aws_cli_version
);
Ok(())
}
pub fn generate_aws_cli_catalog(upstream_dir: &Path, generated_dir: &Path, aws_cli_root: &Path) -> anyhow::Result<()> {
let botocore_root = aws_cli_root.join("awscli");
let botocore_package = botocore_root.join("botocore").join("__init__.py");
let provider_schemas = upstream_dir.join("schemas");
let compiled_schemas = compiled_schemas_path(generated_dir);
let catalog_path = catalog_path(generated_dir);
let script_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts").join("generate_aws_cli_catalog.py");
anyhow::ensure!(script_path.is_file(), "AWS CLI catalog generator not found at {}", script_path.display());
anyhow::ensure!(aws_cli_root.is_dir(), "AWS CLI checkout not found at {}", aws_cli_root.display());
anyhow::ensure!(
botocore_package.is_file(),
"AWS CLI checkout does not contain botocore at {}",
botocore_package.display()
);
anyhow::ensure!(
provider_schemas.is_dir(),
"provider schemas not found at {}; sync must download them before generating the catalog",
provider_schemas.display()
);
anyhow::ensure!(compiled_schemas.is_file(), "compiled schemas not found at {}", compiled_schemas.display());
info!("Generating AWS CLI operation catalog via {}", script_path.display());
let status = std::process::Command::new("python3")
.arg(&script_path)
.arg("--botocore-root")
.arg(&botocore_root)
.arg("--provider-schemas")
.arg(&provider_schemas)
.arg("--compiled-schemas")
.arg(&compiled_schemas)
.arg("--output")
.arg(&catalog_path)
.status()
.map_err(|error| anyhow::anyhow!("failed to start AWS CLI catalog generator: {error}"))?;
anyhow::ensure!(status.success(), "AWS CLI catalog generator failed with {status}");
let catalog_bytes = fs::read(&catalog_path)
.map_err(|error| anyhow::anyhow!("failed to read generated catalog {}: {error}", catalog_path.display()))?;
let catalog = validate_catalog(&catalog_bytes)?;
let stale = stale_mappings(&catalog_bytes, &compiled_schemas)?;
anyhow::ensure!(
stale.is_empty(),
"generated AWS CLI operation catalog maps properties absent from the compiled schemas: {}",
stale.join(", ")
);
info!("Generated AWS CLI operation catalog with {} adapters at {}", catalog.adapters.len(), catalog_path.display());
record_aws_cli_version(generated_dir, format!("{AWS_CLI_SOURCE}@{}", catalog.source.aws_cli_version))
}
fn record_aws_cli_version(generated_dir: &Path, aws_cli_version: String) -> anyhow::Result<()> {
let source_versions_path = generated_dir.join("data").join(SOURCE_VERSIONS_FILE);
let source_versions = SourceVersions::read(&source_versions_path)
.and_then(|versions| versions.with_aws_cli_version(aws_cli_version.clone()))
.map_err(anyhow::Error::msg)?;
write_source_versions(&source_versions_path, source_versions)?;
info!("Recorded {aws_cli_version} in {}", source_versions_path.display());
Ok(())
}
fn validate_catalog(catalog_bytes: &[u8]) -> anyhow::Result<AwsCliOperationCatalog> {
let catalog: AwsCliOperationCatalog = serde_json::from_slice(catalog_bytes)
.map_err(|error| anyhow::anyhow!("AWS CLI operation catalog is invalid JSON: {error}"))?;
anyhow::ensure!(
catalog.format_version == AWS_CLI_OPERATION_CATALOG_FORMAT_VERSION,
"AWS CLI operation catalog has format version {}, expected {}",
catalog.format_version,
AWS_CLI_OPERATION_CATALOG_FORMAT_VERSION
);
anyhow::ensure!(!catalog.adapters.is_empty(), "AWS CLI operation catalog contains no adapters");
anyhow::ensure!(
!catalog.source.aws_cli_version.trim().is_empty(),
"AWS CLI operation catalog does not record the AWS CLI version"
);
Ok(catalog)
}
fn stale_mappings(catalog_bytes: &[u8], compiled_schemas_path: &Path) -> anyhow::Result<Vec<String>> {
let catalog = validate_catalog(catalog_bytes)?;
let compiled: HashMap<String, CompiledPropertySurface> =
serde_json::from_slice(&fs::read(compiled_schemas_path).map_err(|error| {
anyhow::anyhow!("failed to read compiled schemas {}: {error}", compiled_schemas_path.display())
})?)
.map_err(|error| {
anyhow::anyhow!("failed to parse compiled schemas {}: {error}", compiled_schemas_path.display())
})?;
Ok(stale_mappings_against(&catalog, &compiled))
}
fn stale_mappings_against(
catalog: &AwsCliOperationCatalog,
compiled: &HashMap<String, CompiledPropertySurface>,
) -> Vec<String> {
let mut stale = Vec::new();
for adapter in &catalog.adapters {
let Some(schema) = compiled.get(&adapter.cfn_type) else {
stale.push(format!("{}:{} -> {}", adapter.service, adapter.operation, adapter.cfn_type));
continue;
};
for mapping in &adapter.mappings {
if !schema.properties.contains_key(&mapping.target) || schema.read_only_properties.contains(&mapping.target)
{
stale.push(format!(
"{}:{} -> {}.{}",
adapter.service, adapter.operation, adapter.cfn_type, mapping.target
));
}
}
}
stale
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_CATALOG: &[u8] = br#"{
"format_version": 1,
"adapters": [{
"service": "s3", "operation": "CreateBucket", "cfn_type": "AWS::S3::Bucket", "phase": "create",
"mappings": [{"source": "Bucket", "target": "BucketName"}]
}],
"source": {"aws_cli_version": "2.36.43"}
}"#;
fn compiled(properties: &[&str], read_only: &[&str]) -> HashMap<String, CompiledPropertySurface> {
HashMap::from([(
"AWS::S3::Bucket".to_string(),
CompiledPropertySurface {
properties: properties.iter().map(|name| (name.to_string(), serde_json::json!({}))).collect(),
read_only_properties: read_only.iter().map(|name| name.to_string()).collect(),
},
)])
}
#[test]
fn current_catalog_format_with_adapters_is_valid() {
let catalog = validate_catalog(VALID_CATALOG).expect("catalog should be valid");
assert_eq!(1, catalog.adapters.len());
assert_eq!("2.36.43", catalog.source.aws_cli_version);
}
#[test]
fn unsupported_catalog_format_is_rejected() {
let catalog = br#"{"format_version":2,"adapters":[{"service":"s3","operation":"CreateBucket","cfn_type":"AWS::S3::Bucket"}],"source":{"aws_cli_version":"2.36.43"}}"#;
let error = validate_catalog(catalog).expect_err("unsupported format must fail");
assert!(error.to_string().contains("format version 2, expected 1"));
}
#[test]
fn catalog_without_adapters_is_rejected() {
let catalog = br#"{"format_version":1,"adapters":[],"source":{"aws_cli_version":"2.36.43"}}"#;
let error = validate_catalog(catalog).expect_err("empty adapters must fail");
assert!(error.to_string().contains("contains no adapters"));
}
#[test]
fn catalog_without_aws_cli_version_is_rejected() {
let catalog = br#"{"format_version":1,"adapters":[{"service":"s3","operation":"CreateBucket","cfn_type":"AWS::S3::Bucket"}],"source":{"aws_cli_version":" "}}"#;
let error = validate_catalog(catalog).expect_err("blank version must fail");
assert!(error.to_string().contains("does not record the AWS CLI version"));
let catalog = br#"{"format_version":1,"adapters":[{"service":"s3","operation":"CreateBucket","cfn_type":"AWS::S3::Bucket"}],"source":{}}"#;
assert!(validate_catalog(catalog).is_err(), "missing version must fail");
}
#[test]
fn catalog_agreeing_with_compiled_schemas_has_no_stale_mappings() {
let catalog = validate_catalog(VALID_CATALOG).expect("catalog should be valid");
assert!(stale_mappings_against(&catalog, &compiled(&["BucketName"], &[])).is_empty());
}
#[test]
fn mapping_onto_removed_or_read_only_property_is_stale() {
let catalog = validate_catalog(VALID_CATALOG).expect("catalog should be valid");
assert_eq!(
stale_mappings_against(&catalog, &compiled(&["Arn"], &[])),
vec!["s3:CreateBucket -> AWS::S3::Bucket.BucketName"]
);
assert_eq!(
stale_mappings_against(&catalog, &compiled(&["BucketName"], &["BucketName"])),
vec!["s3:CreateBucket -> AWS::S3::Bucket.BucketName"]
);
}
#[test]
fn mapping_onto_removed_type_is_stale() {
let catalog = validate_catalog(VALID_CATALOG).expect("catalog should be valid");
assert_eq!(stale_mappings_against(&catalog, &HashMap::new()), vec!["s3:CreateBucket -> AWS::S3::Bucket"]);
}
#[test]
fn preserving_a_missing_catalog_yields_none() {
let dir = std::env::temp_dir().join(format!("aws-cli-catalog-preserve-{}", std::process::id()));
fs::create_dir_all(dir.join("data")).expect("temp dir is writable");
assert!(preserve_aws_cli_catalog(&dir).expect("missing catalog is not an error").is_none());
fs::remove_dir_all(&dir).expect("temp dir is removable");
}
#[test]
fn preserved_catalog_carries_bytes_and_source_qualified_version() {
let dir = std::env::temp_dir().join(format!("aws-cli-catalog-preserve-some-{}", std::process::id()));
fs::create_dir_all(dir.join("data")).expect("temp dir is writable");
fs::write(dir.join("data").join(AWS_CLI_OPERATION_CATALOG_FILE), VALID_CATALOG).expect("catalog is writable");
let preserved = preserve_aws_cli_catalog(&dir).expect("valid catalog preserves").expect("catalog exists");
assert_eq!(preserved.catalog, VALID_CATALOG);
assert_eq!(preserved.aws_cli_version, format!("{AWS_CLI_SOURCE}@2.36.43"));
fs::remove_dir_all(&dir).expect("temp dir is removable");
}
}