Repository: SFDO-Tooling/CumulusCI
Pull request #3298 (2022-07-20 21:37:21)

Validate MetaDeploy plans in cumulusci.yml
Validate that MetaDeploy plans only specify a single primary and secondary plan.

Because `plan.tier` defaults to `primary` in the API, we set the same here, with the expectation that users that do not explicitly set `tier` will see these warnings before plan validation is enforced by `cci plan publish`.

Other callers can validate project_configs with:

```python
with pytest.raises(ValueError, match='Only one plan'):
    CumulusCIRoot.validate_plan_tiers(plans_with_multiple_primary)
```


Patch:
diff --git a/cumulusci/schema/cumulusci.jsonschema.json b/cumulusci/schema/cumulusci.jsonschema.json
index 14de6ea72..99894760e 100644
--- a/cumulusci/schema/cumulusci.jsonschema.json
+++ b/cumulusci/schema/cumulusci.jsonschema.json
@@ -523,6 +523,7 @@
                 },
                 "tier": {
                     "title": "Tier",
+                    "default": "primary",
                     "enum": ["primary", "secondary", "additional"],
                     "type": "string"
                 },
diff --git a/cumulusci/utils/yaml/cumulusci_yml.py b/cumulusci/utils/yaml/cumulusci_yml.py
index 1bcbd5996..d3ce937f3 100644
--- a/cumulusci/utils/yaml/cumulusci_yml.py
+++ b/cumulusci/utils/yaml/cumulusci_yml.py
@@ -10,7 +10,7 @@ from logging import getLogger
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Sequence, Union
 
-from pydantic import Field, root_validator
+from pydantic import Field, root_validator, validator
 from pydantic.types import DirectoryPath
 from typing_extensions import Literal, TypedDict
 
@@ -115,7 +115,7 @@ class Git(CCIDictModel):
 class Plan(CCIDictModel):  # MetaDeploy plans
     title: str = None
     description: str = None
-    tier: Literal["primary", "secondary", "additional"] = None
+    tier: Literal["primary", "secondary", "additional"] = "primary"
     slug: str = None
     is_listed: bool = True
     steps: Dict[str, Step] = None
@@ -232,6 +232,16 @@ class CumulusCIRoot(CCIDictModel):
     sources: Dict[str, Union[LocalFolderSourceModel, GitHubSourceModel]] = {}
     cli: CumulusCLIConfig = None
 
+    @validator("plans")
+    def validate_plan_tiers(cls, plans):
+        existing_tiers = [plan.tier for plan in plans.values()]
+        has_duplicate_tiers = any(
+            existing_tiers.count(tier) > 1 for tier in ("primary", "secondary")
+        )
+        if has_duplicate_tiers:
+            raise ValueError("Only one plan can be defined as 'primary' or 'secondary'")
+        return plans
+
 
 class CumulusCIFile(CCIDictModel):
     __root__: Union[CumulusCIRoot, None]


Test patch:
diff --git a/cumulusci/utils/yaml/tests/test_cumulusci_yml.py b/cumulusci/utils/yaml/tests/test_cumulusci_yml.py
index 81a7a8ad5..e8dda297f 100644
--- a/cumulusci/utils/yaml/tests/test_cumulusci_yml.py
+++ b/cumulusci/utils/yaml/tests/test_cumulusci_yml.py
@@ -225,3 +225,62 @@ def test_github_source():
         GitHubSourceModel(github="https://github.com/Test/TestRepo").resolution_strategy
         == "production"
     )
+
+
+def test_single_primary_plan(caplog):
+    yaml = """
+plans:
+  first:
+    tier: primary
+  second:
+    tier: primary
+    """
+    cci_safe_load(StringIO(yaml))
+    assert "Only one plan can be defined as 'primary' or 'secondary'" in caplog.text
+
+
+def test_single_primary_plan_implicit(caplog):
+    yaml = """
+plans:
+  explicit:
+    tier: primary
+  implicit:
+    slug: implicit_primary
+    """
+    cci_safe_load(StringIO(yaml))
+    assert "Only one plan can be defined as 'primary' or 'secondary'" in caplog.text
+
+
+def test_single_secondary_plan(caplog):
+    yaml = """
+plans:
+  first:
+    tier: secondary
+  second:
+    tier: secondary
+    """
+    cci_safe_load(StringIO(yaml))
+    assert "Only one plan can be defined as 'primary' or 'secondary'" in caplog.text
+
+
+def test_multiple_additional_plan(caplog):
+    yaml = """
+plans:
+  first:
+    slug: implicit_primary
+  second:
+    tier: secondary
+  third:
+    tier: additional
+  fourth:
+    tier: additional
+    """
+    parsed_yaml: dict = cci_safe_load(StringIO(yaml))
+    expected: dict = {
+        "first": {"slug": "implicit_primary"},
+        "second": {"tier": "secondary"},
+        "third": {"tier": "additional"},
+        "fourth": {"tier": "additional"},
+    }
+    assert "" == caplog.text
+    assert expected == parsed_yaml["plans"]