Repository: SFDO-Tooling/CumulusCI
Pull request #3034 (2022-01-15 02:05:01)

Make cross-project step preflights work
# Critical Changes

# Changes

# Issues Closed

- We fixed an issue preventing step-level preflight checks from working correctly in MetaDeploy when run as a cross-project flow.
- The `metadeploy_publish` task now displays a clear error message if you supply the wrong API URL.



Patch:
diff --git a/cumulusci/core/flowrunner.py b/cumulusci/core/flowrunner.py
index d8f039ea5..a5f497af7 100644
--- a/cumulusci/core/flowrunner.py
+++ b/cumulusci/core/flowrunner.py
@@ -730,11 +730,12 @@ class PreflightFlowCoordinator(FlowCoordinator):
         self._rule(new_line=True)
 
         self.preflight_results = defaultdict(list)
-        self._task_cache = TaskCache(self)
+        # Expose for test access
+        self._task_caches = {self.project_config: TaskCache(self, self.project_config)}
         try:
             # flow-level checks
             jinja2_context = {
-                "tasks": self._task_cache,
+                "tasks": self._task_caches[self.project_config],
                 "project_config": self.project_config,
                 "org_config": self.org_config,
             }
@@ -746,6 +747,14 @@ class PreflightFlowCoordinator(FlowCoordinator):
             # Step-level checks
             for step in self.steps:
                 jinja2_context["project_config"] = step.project_config
+                # Create a cache for this project config, if not present
+                # and not equal to the root project config.
+                # This accommodates cross-project preflight checks.
+                if step.project_config not in self._task_caches:
+                    self._task_caches[step.project_config] = TaskCache(
+                        self, step.project_config
+                    )
+                jinja2_context["tasks"] = self._task_caches[step.project_config]
                 for check in step.task_config.get("checks", []):
                     result = self.evaluate_check(check, jinja2_context)
                     if result:
@@ -770,8 +779,11 @@ class TaskCache(object):
     can avoid running a task more than once with the same options.
     """
 
-    def __init__(self, flow):
+    def __init__(self, flow, project_config):
         self.flow = flow
+        # Cross-project flows may include preflight checks
+        # that depend on their local context.
+        self.project_config = project_config
         self.results = {}
 
     def __getattr__(self, task_name):
@@ -790,14 +802,14 @@ class CachedTaskRunner(object):
         if cache_key in self.cache.results:
             return self.cache.results[cache_key].return_values
 
-        task_config = self.cache.flow.project_config.tasks[self.task_name]
+        task_config = self.cache.project_config.tasks[self.task_name]
         task_class = import_global(task_config["class_path"])
         step = StepSpec(
             step_num=StepVersion("1"),
             task_name=self.task_name,
             task_config=task_config,
             task_class=task_class,
-            project_config=self.cache.flow.project_config,
+            project_config=self.cache.project_config,
         )
         self.cache.flow.callbacks.pre_task(step)
         result = TaskRunner(step, self.cache.flow.org_config, self.cache.flow).run_step(
diff --git a/cumulusci/tasks/metadeploy.py b/cumulusci/tasks/metadeploy.py
index 7c53ebd9f..f4bf29ba4 100644
--- a/cumulusci/tasks/metadeploy.py
+++ b/cumulusci/tasks/metadeploy.py
@@ -6,7 +6,7 @@ from pathlib import Path
 import requests
 
 from cumulusci.core.config import BaseProjectConfig, FlowConfig, TaskConfig
-from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError
 from cumulusci.core.flowrunner import FlowCoordinator
 from cumulusci.core.github import get_tag_by_name
 from cumulusci.core.tasks import BaseTask
@@ -238,10 +238,17 @@ class Publish(BaseMetaDeployTask):
 
     def _find_product(self):
         repo_url = self.project_config.project__git__repo_url
-        result = self._call_api("GET", "/products", params={"repo_url": repo_url})
-        if len(result["data"]) != 1:
-            raise Exception(
-                "No product found in MetaDeploy with repo URL {}".format(repo_url)
+        try:
+            result = self._call_api("GET", "/products", params={"repo_url": repo_url})
+            if len(result["data"]) != 1:
+                raise CumulusCIException(
+                    "No product found in MetaDeploy with repo URL {}".format(repo_url)
+                )
+        except KeyError:
+            raise CumulusCIException(
+                "CumulusCI received an unexpected response from MetaDeploy. "
+                "Ensure that your MetaDeploy service is configured with the Admin API URL, which "
+                "ends in /rest, and that your authentication token is valid."
             )
         product = result["data"][0]
         self._add_labels(


Test patch:
diff --git a/cumulusci/core/tests/test_flowrunner.py b/cumulusci/core/tests/test_flowrunner.py
index 545ccd9e0..5de7a1f21 100644
--- a/cumulusci/core/tests/test_flowrunner.py
+++ b/cumulusci/core/tests/test_flowrunner.py
@@ -659,7 +659,46 @@ class PreflightFlowCoordinatorTest(AbstractFlowCoordinatorTest, unittest.TestCas
         )
         # Make sure task result got cached
         key = ("log", (("level", "info"), ("line", "plan")))
-        assert key in flow._task_cache.results
+        assert key in flow._task_caches[flow.project_config].results
+
+    def test_run__cross_project_preflights(self):
+        other_project_config = mock.MagicMock()
+        other_project_config.source.__str__.return_value = "other source"
+        other_project_config.tasks = {
+            "foo": {
+                "class_path": "cumulusci.tasks.util.LogLine",
+                "options": {"level": "info", "line": "test"},
+            }
+        }
+        flow = PreflightFlowCoordinator.from_steps(
+            self.project_config,
+            [
+                StepSpec(
+                    "1/1",
+                    "other:test1",
+                    {
+                        "checks": [{"when": "not tasks.foo()", "action": "error"}],
+                    },
+                    None,
+                    other_project_config,
+                    from_flow="test",
+                ),
+                StepSpec(
+                    "1/2", "test2", {}, None, self.project_config, from_flow="test"
+                ),
+            ],
+        )
+        flow.run(self.org_config)
+        key = ("foo", ())
+        assert key in flow._task_caches[other_project_config].results
+        # log doesn't return anything => None is falsy => preflight is True
+        # which results in the action taking place (error)
+        self.assertDictEqual(
+            {
+                "1/1": [{"status": "error", "message": None}],
+            },
+            flow.preflight_results,
+        )
 
 
 @pytest.fixture
diff --git a/cumulusci/tasks/tests/test_metadeploy.py b/cumulusci/tasks/tests/test_metadeploy.py
index f820f6512..919d7de37 100644
--- a/cumulusci/tasks/tests/test_metadeploy.py
+++ b/cumulusci/tasks/tests/test_metadeploy.py
@@ -12,7 +12,7 @@ import responses
 import yaml
 
 from cumulusci.core.config import ServiceConfig, TaskConfig
-from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError
 from cumulusci.tasks.github.tests.util_github_api import GithubApiTestMixin
 from cumulusci.tasks.metadeploy import BaseMetaDeployTask, Publish
 from cumulusci.tests.util import create_project_config
@@ -66,6 +66,51 @@ class TestBaseMetaDeployTask(unittest.TestCase):
 class TestPublish(unittest.TestCase, GithubApiTestMixin):
     maxDiff = None
 
+    @responses.activate
+    def test_error__base_instead_of_admin_api(self):
+        responses.add(
+            "GET",
+            "https://metadeploy/api/products?repo_url=EXISTING_REPO",
+            status=200,
+            body=b'{"results":[]}',
+        )
+
+        project_config = create_project_config()
+        project_config.keychain.set_service(
+            "metadeploy",
+            "test_alias",
+            ServiceConfig({"url": "https://metadeploy/api", "token": "TOKEN"}),
+        )
+        project_config.config["project"]["git"]["repo_url"] = "EXISTING_REPO"
+        project_config.config["plans"] = {
+            "install": {
+                "title": "Test Install",
+                "slug": "install",
+                "tier": "primary",
+                "steps": {
+                    1: {"flow": "install_prod"},
+                    2: {
+                        "task": "util_sleep",
+                        "checks": [{"when": "False", "action": "error"}],
+                    },
+                },
+                "checks": [{"when": "False", "action": "error"}],
+            }
+        }
+
+        task_config = TaskConfig(
+            {
+                "options": {
+                    "tag": "release/1.0",
+                }
+            }
+        )
+        with self.assertRaises(CumulusCIException) as e:
+            task = Publish(project_config, task_config)
+            task()
+
+        assert "Admin API" in str(e.exception)
+
     @responses.activate
     def test_run_task(self):
         project_config = create_project_config()