Skip to main content

template_model/
region_enums.rs

1//! Shared logic for validating region-scoped instance-type / node-type enum
2//! values, used identically by the schema validator and both rule engines so
3//! they cannot diverge.
4//!
5//! Each embedded enum document is a `{ "<region>": ... }` map. When a region is
6//! configured, a value is checked against that one region's allowed set. When no
7//! region is configured, it is checked against the **union of every region** -
8//! flagged only when it is invalid in every region - so a value valid in the
9//! caller's actual target region is not reported just because it is unavailable
10//! in the platform-default region. The synthetic non-region keys some documents
11//! carry (`all`, `description`) are excluded by intersecting the document's keys
12//! with [`AWS_REGIONS`].
13//!
14//! Two document shapes exist:
15//! - **Flat**: `{ "<region>": { "enum": [...] } }` (EC2, DocDB, AmazonMQ, …).
16//! - **Conditional** (RDS `DBInstanceClass` / `DBClusterInstanceClass`): each
17//!   region maps to an `allOf` of if/then branches keyed on Engine/LicenseModel;
18//!   a value is valid in a region only when it is in the intersection of every
19//!   matching branch's enum.
20
21use crate::message::{quote, render_str_list};
22use crate::model::SemanticModel;
23use crate::regions::AWS_REGIONS;
24use crate::resolved_value::resolved_value_at_path;
25use crate::resolver::ResolvedValue;
26use std::collections::{BTreeSet, HashSet};
27
28/// Resource types and property paths whose instance-type / node-type value is
29/// validated against a region-scoped enum. This is the single list of what
30/// "region-scoped validation" covers, used to decide whether the no-region
31/// best-effort advisory applies to a template. A `{}` segment matches every
32/// element of a list. Both rule engines validate exactly these paths; keeping the
33/// set here documents the advisory's trigger in one place.
34pub const REGION_SCOPED_TYPE_PATHS: &[(&str, &str)] = &[
35    ("AWS::EC2::Instance", "Properties.InstanceType"),
36    ("AWS::GameLift::Fleet", "Properties.EC2InstanceType"),
37    ("AWS::EMR::InstanceTypeConfig", "Properties.InstanceType"),
38    ("AWS::EMR::InstanceFleetConfig", "Properties.InstanceType"),
39    ("AWS::ManagedBlockchain::Node", "Properties.NodeConfiguration.InstanceType"),
40    ("AWS::DocDB::DBInstance", "Properties.DBInstanceClass"),
41    ("AWS::AppStream::Fleet", "Properties.InstanceType"),
42    ("AWS::ElastiCache::CacheCluster", "Properties.CacheNodeType"),
43    ("AWS::DAX::Cluster", "Properties.NodeType"),
44    ("AWS::Neptune::DBInstance", "Properties.DBInstanceClass"),
45    ("AWS::Redshift::Cluster", "Properties.NodeType"),
46    ("AWS::AmazonMQ::Broker", "Properties.HostInstanceType"),
47    ("AWS::RDS::DBInstance", "Properties.DBInstanceClass"),
48    ("AWS::RDS::DBCluster", "Properties.DBClusterInstanceClass"),
49    ("AWS::SageMaker::DataQualityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
50    ("AWS::SageMaker::ModelBiasJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
51    ("AWS::SageMaker::ModelExplainabilityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
52    ("AWS::SageMaker::ModelQualityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
53    (
54        "AWS::SageMaker::MonitoringSchedule",
55        "Properties.MonitoringScheduleConfig.MonitoringJobDefinition.MonitoringResources.ClusterConfig.InstanceType",
56    ),
57    ("AWS::Elasticsearch::Domain", "Properties.ElasticsearchClusterConfig.InstanceType"),
58    ("AWS::OpenSearchService::Domain", "Properties.ClusterConfig.InstanceType"),
59    (
60        "AWS::SageMaker::InferenceExperiment",
61        "Properties.ModelVariants.{}.InfrastructureConfig.RealTimeInferenceConfig.InstanceType",
62    ),
63    (
64        "AWS::SageMaker::ModelPackage",
65        "Properties.ValidationSpecification.ValidationProfiles.{}.TransformJobDefinition.TransformResources.InstanceType",
66    ),
67    ("AWS::SageMaker::Cluster", "Properties.InstanceGroups.{}.InstanceType"),
68    ("AWS::SageMaker::Cluster", "Properties.RestrictedInstanceGroups.{}.InstanceType"),
69];
70
71/// Whether the template contains at least one region-scoped instance-type /
72/// node-type value that resolves to a concrete string - i.e. a value that the
73/// region-scoped enum rules actually validate. Used to decide whether the
74/// no-region best-effort advisory is worth emitting: a template with no such
75/// value had nothing validated best-effort, so the advisory would be noise. The
76/// result depends only on the model, so it is identical across engines.
77pub fn template_has_region_scoped_value(model: &SemanticModel) -> bool {
78    REGION_SCOPED_TYPE_PATHS.iter().any(|(rtype, path)| {
79        model.resources_of_type(rtype).iter().any(|rid| resolves_to_concrete_string(model, rid, path))
80    })
81}
82
83/// Whether `path` on `rid` resolves to any concrete string, collapsing an
84/// `Fn::If` to its true branch and treating a `{}` wildcard as a match against
85/// every list element - mirroring how the enum rules read the value.
86fn resolves_to_concrete_string(model: &SemanticModel, rid: &str, path: &str) -> bool {
87    let Some(resolved) = resolve_for_detection(model, rid, path) else {
88        return false;
89    };
90    any_concrete_string(&resolved)
91}
92
93/// Resolves `path` (which may contain a `{}` wildcard) for the advisory detector.
94fn resolve_for_detection(model: &SemanticModel, rid: &str, path: &str) -> Option<ResolvedValue> {
95    if path.contains("{}") {
96        let resource = model.resources.get(rid)?;
97        let prop_path = path.strip_prefix("Properties.").unwrap_or(path);
98        let mut segments = prop_path.splitn(2, '.');
99        let top_key = segments.next()?;
100        let resolved = resource.properties.get(top_key)?;
101        return match segments.next() {
102            Some(rest) if !rest.is_empty() => resolved_value_at_path(resolved, rest),
103            _ => Some(resolved.clone()),
104        };
105    }
106    model.resolve_deep(rid, path).or_else(|| model.resolve(rid, path).cloned())
107}
108
109/// Whether a resolved value yields any concrete string, descending through the
110/// composite variants the enum rules also descend (lists, `Fn::If` true branches,
111/// enum candidates).
112fn any_concrete_string(value: &ResolvedValue) -> bool {
113    match value {
114        ResolvedValue::Concrete { value } => value.0.is_string(),
115        ResolvedValue::List { items } | ResolvedValue::Enum { variants: items } => {
116            items.iter().any(any_concrete_string)
117        }
118        ResolvedValue::Conditional { if_true, if_false, .. } => {
119            any_concrete_string(if_true) || any_concrete_string(if_false)
120        }
121        _ => false,
122    }
123}
124
125/// Fragment used in place of a region name in a diagnostic message when no region
126/// is configured and the value was validated against the union of all regions.
127pub const ANY_REGION_LABEL: &str = "any region";
128
129/// The label naming what a region-scoped value was validated against: the
130/// configured region, or [`ANY_REGION_LABEL`] when none was configured.
131pub fn region_label(region: Option<&str>) -> &str {
132    region.unwrap_or(ANY_REGION_LABEL)
133}
134
135/// Diagnostic message for a flat instance-type / node-type enum value that is not
136/// valid for the effective scope. With a region configured this is today's
137/// message verbatim; with none configured it states the value is valid in no
138/// region. Built here so both engines emit byte-identical text.
139pub fn flat_invalid_message(value: &str, region: Option<&str>) -> String {
140    match region {
141        Some(region) => format!("'{value}' is not valid for region '{region}'"),
142        None => format!("'{value}' is not valid in any region"),
143    }
144}
145
146/// Diagnostic message for a conditional RDS instance-class value that is not one
147/// of the allowed classes for the effective scope, rendering the candidate enum.
148/// This compatibility helper preserves the original context-free wording for
149/// external callers; engine diagnostics use [`conditional_mismatch_message`].
150pub fn conditional_invalid_message(value: &str, allowed_sorted: &[String], region: Option<&str>) -> String {
151    let rendered = render_str_list(allowed_sorted);
152    match region {
153        Some(region) => format!("'{value}' is not one of {rendered} in '{region}'"),
154        None => format!("'{value}' is not one of {rendered} in any region"),
155    }
156}
157
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct ConditionalEnumMismatch {
160    allowed_values: Vec<String>,
161    property_constraints: Vec<(String, String)>,
162}
163
164#[derive(Clone, Debug)]
165struct ConditionalBranchEnum {
166    allowed_values: HashSet<String>,
167    property_constraints: Vec<(String, String)>,
168}
169
170/// Diagnostic message for a conditional instance-class mismatch. It names the
171/// resource properties whose values selected the failing enum branch so a class
172/// that is valid for another engine or license model is not described as globally invalid.
173pub fn conditional_mismatch_message(value: &str, mismatch: &ConditionalEnumMismatch, region: Option<&str>) -> String {
174    if mismatch.property_constraints.is_empty() {
175        return conditional_invalid_message(value, &mismatch.allowed_values, region);
176    }
177
178    let rendered_allowed = render_str_list(&mismatch.allowed_values);
179    let rendered_constraints = mismatch
180        .property_constraints
181        .iter()
182        .map(|(property, expected)| format!("{property} is {}", quote(expected)))
183        .collect::<Vec<_>>()
184        .join(" and ");
185    match region {
186        Some(region) => format!("'{value}' is not one of {rendered_allowed} when {rendered_constraints} in '{region}'"),
187        None => format!("'{value}' is not one of {rendered_allowed} when {rendered_constraints} in any region"),
188    }
189}
190
191/// The allowed values from a flat enum document for the effective scope: the
192/// single `region` when configured, or the union across all AWS regions when not.
193/// Returns `None` when the document has no entry for the effective scope, so the
194/// caller skips validation - a dynamic or unknown configured region is not
195/// validated, matching today's per-region lookup that returns `None` for a
196/// missing region.
197pub fn flat_allowed_value_set<'a>(
198    doc: &'a serde_json::Map<String, serde_json::Value>,
199    region: Option<&str>,
200) -> Option<HashSet<&'a str>> {
201    match region {
202        Some(region) => {
203            let values = doc.get(region)?.get("enum")?.as_array()?;
204            Some(values.iter().filter_map(|v| v.as_str()).collect())
205        }
206        None => {
207            let mut union = HashSet::new();
208            for region in AWS_REGIONS {
209                if let Some(values) = doc.get(*region).and_then(|r| r.get("enum")).and_then(|e| e.as_array()) {
210                    union.extend(values.iter().filter_map(|v| v.as_str()));
211                }
212            }
213            // An empty union means no region contributed any allowed value, so
214            // there is nothing to validate against - skip (return `None`) rather
215            // than flag every value as invalid, mirroring the per-region arm which
216            // returns `None` when the region has no enum.
217            (!union.is_empty()).then_some(union)
218        }
219    }
220}
221
222/// Returns the same allowed values in deterministic order for callers that iterate them.
223pub fn flat_allowed_values<'a>(
224    doc: &'a serde_json::Map<String, serde_json::Value>,
225    region: Option<&str>,
226) -> Option<BTreeSet<&'a str>> {
227    flat_allowed_value_set(doc, region).map(|allowed| allowed.into_iter().collect())
228}
229
230/// For a conditional RDS document (`{ "<region>": { "allOf": [...] } }`), returns
231/// the sorted enum to render when `value` is invalid for the effective scope, or
232/// `None` when the value is valid or the document does not apply. `resolve_prop`
233/// supplies the resource's scalar property values (Engine, LicenseModel) that the
234/// branch `if.required` consts key on.
235///
236/// With a region configured, this validates against that one region's
237/// matching-branch intersection. With no region configured, a value is valid
238/// when it is valid in *any* region, so it is flagged only when it fails in every
239/// region; the rendered enum is then the largest failing branch across all regions.
240pub fn conditional_invalid_enum<F>(
241    doc: &serde_json::Map<String, serde_json::Value>,
242    region: Option<&str>,
243    target_prop: &str,
244    normalize_engine_case: bool,
245    value: &str,
246    resolve_prop: F,
247) -> Option<Vec<String>>
248where
249    F: Fn(&str) -> Option<String>,
250{
251    conditional_enum_mismatch(doc, region, target_prop, normalize_engine_case, value, resolve_prop)
252        .map(|mismatch| mismatch.allowed_values)
253}
254
255/// Returns the failing enum branch together with the property constraints that
256/// selected it. A value that is valid under any region is accepted when no
257/// region is configured; otherwise the largest failing branch is returned.
258pub fn conditional_enum_mismatch<F>(
259    doc: &serde_json::Map<String, serde_json::Value>,
260    region: Option<&str>,
261    target_prop: &str,
262    normalize_engine_case: bool,
263    value: &str,
264    resolve_prop: F,
265) -> Option<ConditionalEnumMismatch>
266where
267    F: Fn(&str) -> Option<String>,
268{
269    match region {
270        Some(region) => {
271            let region_doc = doc.get(region)?;
272            let branch_enums = conditional_branch_enums(region_doc, target_prop, normalize_engine_case, &resolve_prop);
273            invalid_branch_mismatch(&branch_enums, value)
274        }
275        None => {
276            let mut valid_somewhere = false;
277            let mut had_matching_branch = false;
278            let mut failing_largest: Option<ConditionalEnumMismatch> = None;
279            for region in AWS_REGIONS {
280                let Some(region_doc) = doc.get(*region) else {
281                    continue;
282                };
283                let branch_enums =
284                    conditional_branch_enums(region_doc, target_prop, normalize_engine_case, &resolve_prop);
285                if branch_enums.is_empty() {
286                    continue;
287                }
288                had_matching_branch = true;
289                if branch_enums.iter().all(|branch| branch.allowed_values.contains(value)) {
290                    valid_somewhere = true;
291                    break;
292                }
293                if let Some(mismatch) = invalid_branch_mismatch(&branch_enums, value)
294                    && failing_largest
295                        .as_ref()
296                        .is_none_or(|current| mismatch.allowed_values.len() > current.allowed_values.len())
297                {
298                    failing_largest = Some(mismatch);
299                }
300            }
301            if !had_matching_branch || valid_somewhere {
302                return None;
303            }
304            failing_largest
305        }
306    }
307}
308
309/// Collects every `then.<target_prop>.enum` from a conditional region document
310/// whose `allOf` branch `if.required` consts all match the resolved properties.
311/// Each enum retains the const constraints that selected it because those values
312/// explain why an otherwise valid instance class is incompatible.
313fn conditional_branch_enums<F>(
314    region_doc: &serde_json::Value,
315    target_prop: &str,
316    normalize_engine_case: bool,
317    resolve_prop: &F,
318) -> Vec<ConditionalBranchEnum>
319where
320    F: Fn(&str) -> Option<String>,
321{
322    let mut matching_branches = Vec::new();
323    let Some(branches) = region_doc.get("allOf").and_then(|value| value.as_array()) else {
324        return matching_branches;
325    };
326    for branch in branches {
327        let (Some(required), Some(if_properties)) = (
328            branch.get("if").and_then(|condition| condition.get("required")).and_then(|value| value.as_array()),
329            branch.get("if").and_then(|condition| condition.get("properties")).and_then(|value| value.as_object()),
330        ) else {
331            continue;
332        };
333
334        let mut property_constraints = Vec::new();
335        let mut all_required_match = true;
336        for property in required.iter().filter_map(|value| value.as_str()).filter(|property| *property != target_prop) {
337            let Some(expected) =
338                if_properties.get(property).and_then(|schema| schema.get("const")).and_then(|value| value.as_str())
339            else {
340                all_required_match = false;
341                break;
342            };
343            let Some(actual) = resolve_prop(property) else {
344                all_required_match = false;
345                break;
346            };
347            let matches = if normalize_engine_case && property == "Engine" {
348                actual.eq_ignore_ascii_case(expected)
349            } else {
350                actual == expected
351            };
352            if !matches {
353                all_required_match = false;
354                break;
355            }
356            property_constraints.push((property.to_string(), expected.to_string()));
357        }
358        if !all_required_match {
359            continue;
360        }
361        property_constraints.sort_unstable();
362
363        let Some(enum_values) = branch
364            .get("then")
365            .and_then(|then_schema| then_schema.get("properties"))
366            .and_then(|properties| properties.get(target_prop))
367            .and_then(|property_schema| property_schema.get("enum"))
368            .and_then(|value| value.as_array())
369        else {
370            continue;
371        };
372        matching_branches.push(ConditionalBranchEnum {
373            allowed_values: enum_values.iter().filter_map(|value| value.as_str().map(String::from)).collect(),
374            property_constraints,
375        });
376    }
377    matching_branches
378}
379
380/// Returns the largest matching branch that excludes `value`, preserving the
381/// constraints that selected it. `None` means every matching branch accepts the
382/// value or no branch matched.
383fn invalid_branch_mismatch(branch_enums: &[ConditionalBranchEnum], value: &str) -> Option<ConditionalEnumMismatch> {
384    let failing_largest = branch_enums
385        .iter()
386        .filter(|branch| !branch.allowed_values.contains(value))
387        .max_by_key(|branch| branch.allowed_values.len())?;
388    let mut allowed_values: Vec<String> = failing_largest.allowed_values.iter().cloned().collect();
389    allowed_values.sort();
390    Some(ConditionalEnumMismatch { allowed_values, property_constraints: failing_largest.property_constraints.clone() })
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    fn flat_doc() -> serde_json::Map<String, serde_json::Value> {
399        // us-east-1 lacks "special.type"; ap-east-2 has it. "all"/"description"
400        // are synthetic non-region keys that must never contribute.
401        json!({
402            "us-east-1": { "enum": ["common.type", "east.type"] },
403            "ap-east-2": { "enum": ["common.type", "special.type"] },
404            "all": { "enum": ["should.be.ignored"] },
405            "description": "generated"
406        })
407        .as_object()
408        .unwrap()
409        .clone()
410    }
411
412    #[test]
413    fn region_label_uses_region_or_any_region() {
414        assert_eq!(region_label(Some("us-west-2")), "us-west-2");
415        assert_eq!(region_label(None), "any region");
416    }
417
418    #[test]
419    fn flat_with_region_validates_that_region_only() {
420        let doc = flat_doc();
421        let allowed = flat_allowed_values(&doc, Some("us-east-1")).unwrap();
422        assert!(allowed.contains("east.type"));
423        assert!(!allowed.contains("special.type"), "special.type is not valid in us-east-1");
424    }
425
426    #[test]
427    fn flat_without_region_unions_all_regions() {
428        let doc = flat_doc();
429        let allowed = flat_allowed_values(&doc, None).unwrap();
430        assert!(allowed.contains("east.type"));
431        assert!(allowed.contains("special.type"), "special.type is valid in ap-east-2, so present in the union");
432        assert!(!allowed.contains("should.be.ignored"), "the synthetic 'all' key must not contribute");
433    }
434
435    #[test]
436    fn flat_unknown_configured_region_returns_none() {
437        let doc = flat_doc();
438        assert!(flat_allowed_values(&doc, Some("mars-north-1")).is_none());
439    }
440
441    #[test]
442    fn flat_without_region_returns_none_when_no_region_keys() {
443        let doc = json!({ "description": "x", "all": { "enum": ["a"] } }).as_object().unwrap().clone();
444        assert!(flat_allowed_values(&doc, None).is_none(), "no real region keys → nothing to validate");
445    }
446
447    fn conditional_doc() -> serde_json::Map<String, serde_json::Value> {
448        // mysql in us-east-1 allows only db.small; ap-east-2 also allows db.big.
449        let branch = |class: &str| {
450            json!({
451                "if": { "required": ["Engine", "DBInstanceClass"], "properties": { "Engine": { "const": "mysql" } } },
452                "then": { "properties": { "DBInstanceClass": { "enum": [class] } } }
453            })
454        };
455        json!({
456            "us-east-1": { "allOf": [branch("db.small")] },
457            "ap-east-2": { "allOf": [ { "if": { "required": ["Engine", "DBInstanceClass"], "properties": { "Engine": { "const": "mysql" } } }, "then": { "properties": { "DBInstanceClass": { "enum": ["db.small", "db.big"] } } } } ] }
458        })
459        .as_object()
460        .unwrap()
461        .clone()
462    }
463
464    #[test]
465    fn conditional_with_region_flags_value_absent_from_that_region() {
466        let doc = conditional_doc();
467        let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
468        // db.big is not valid for mysql in us-east-1.
469        let invalid = conditional_invalid_enum(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.big", engine);
470        assert_eq!(invalid, Some(vec!["db.small".to_string()]));
471    }
472
473    #[test]
474    fn conditional_without_region_accepts_value_valid_in_any_region() {
475        let doc = conditional_doc();
476        let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
477        // db.big is valid for mysql in ap-east-2, so with no region it is accepted.
478        let invalid = conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.big", engine);
479        assert!(invalid.is_none(), "db.big is valid in ap-east-2 → not flagged when region is unset");
480    }
481
482    #[test]
483    fn conditional_without_region_flags_value_valid_nowhere() {
484        let doc = conditional_doc();
485        let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
486        let invalid = conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.bogus", engine);
487        assert!(invalid.is_some(), "db.bogus is valid in no region → flagged");
488        let rendered = invalid.unwrap();
489        assert!(rendered.contains(&"db.big".to_string()), "renders the largest failing branch across regions");
490    }
491
492    #[test]
493    fn conditional_message_names_the_engine_that_selected_the_enum() {
494        let doc = conditional_doc();
495        let engine = |property: &str| (property == "Engine").then(|| "MySQL".to_string());
496        let mismatch = conditional_enum_mismatch(&doc, None, "DBInstanceClass", true, "db.bogus", engine).unwrap();
497
498        assert_eq!(
499            conditional_mismatch_message("db.bogus", &mismatch, None),
500            "'db.bogus' is not one of ['db.big', 'db.small'] when Engine is 'mysql' in any region"
501        );
502    }
503
504    #[test]
505    fn conditional_message_includes_every_constraint_on_the_failing_branch() {
506        let doc = json!({
507            "us-east-1": {
508                "allOf": [{
509                    "if": {
510                        "required": ["Engine", "LicenseModel", "DBInstanceClass"],
511                        "properties": {
512                            "Engine": { "const": "oracle-se2" },
513                            "LicenseModel": { "const": "license-included" }
514                        }
515                    },
516                    "then": { "properties": { "DBInstanceClass": { "enum": ["db.allowed"] } } }
517                }]
518            }
519        })
520        .as_object()
521        .unwrap()
522        .clone();
523        let properties = |property: &str| match property {
524            "Engine" => Some("oracle-se2".to_string()),
525            "LicenseModel" => Some("license-included".to_string()),
526            _ => None,
527        };
528        let mismatch =
529            conditional_enum_mismatch(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.bogus", properties)
530                .unwrap();
531
532        assert_eq!(
533            conditional_mismatch_message("db.bogus", &mismatch, Some("us-east-1")),
534            "'db.bogus' is not one of ['db.allowed'] when Engine is 'oracle-se2' and LicenseModel is 'license-included' in 'us-east-1'"
535        );
536    }
537
538    #[test]
539    fn conditional_unmatched_engine_is_not_validated() {
540        let doc = conditional_doc();
541        // Engine resolves to something no branch matches → no matching branch in
542        // any region → not validated (mirrors the per-region contract).
543        let engine = |p: &str| (p == "Engine").then(|| "oracle".to_string());
544        assert!(conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.bogus", engine).is_none());
545        assert!(
546            conditional_invalid_enum(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.bogus", engine).is_none()
547        );
548    }
549}