Repository: SFDO-Tooling/CumulusCI
Pull request #3066 (2022-02-09 17:16:23)

Switch mc package deploy task to new endpoints
# Changes
* The `deploy_marketing_cloud_package` task now utilizes the new API endpoint for deployments.

## Dev Notes
Per a discussion I had with Sam Varga, all stacks should now be swapped over to using these new endpoints:

> All stacks are live now from our side. The MC R2 release actually got pushed back to the 18th, but with your server-side integration that doesn't matter. That's only for the UI oauth flow endpoints.

I have tested and confirmed the deploy task works with the new endpoints for our learning account which resides on S4.



Patch:
diff --git a/cumulusci/tasks/marketing_cloud/deploy.py b/cumulusci/tasks/marketing_cloud/deploy.py
index 5c187be44..cc8171312 100644
--- a/cumulusci/tasks/marketing_cloud/deploy.py
+++ b/cumulusci/tasks/marketing_cloud/deploy.py
@@ -12,8 +12,9 @@ from cumulusci.utils import temporary_dir
 from cumulusci.utils.http.requests_utils import safe_json_from_response
 
 from .base import BaseMarketingCloudTask
+from .util import get_mc_stack_key
 
-MCPM_ENDPOINT = "https://mc-package-manager.herokuapp.com/api"
+MCPM_ENDPOINT = "https://spf.{}.marketingcloudapps.com/api"
 
 PAYLOAD_CONFIG_VALUES = {"preserveCategories": True}
 
@@ -57,8 +58,6 @@ class MarketingCloudDeployTask(BaseMarketingCloudTask):
         )
 
     def _run_task(self):
-        self.endpoint = self.options.get("endpoint") or MCPM_ENDPOINT
-
         pkg_zip_file = Path(self.options["package_zip_file"])
         if not pkg_zip_file.is_file():
             self.logger.error(f"Package zip file not valid: {pkg_zip_file.name}")
@@ -73,12 +72,23 @@ class MarketingCloudDeployTask(BaseMarketingCloudTask):
             "Authorization": f"Bearer {self.mc_config.access_token}",
             "SFMC-TSSD": self.mc_config.tssd,
         }
+        custom_endpoint = self.options.get("endpoint")
+        self.endpoint = (
+            custom_endpoint
+            if custom_endpoint
+            else MCPM_ENDPOINT.format(
+                get_mc_stack_key(self.mc_config.tssd, self.mc_config.access_token)
+            )
+        )
+
+        self.logger.info(f"Deploying package to: {self.endpoint}/deployments")
         response = requests.post(
             f"{self.endpoint}/deployments",
             json=payload,
             headers=self.headers,
         )
         result = safe_json_from_response(response)
+
         self.job_id = result["id"]
         self.logger.info(f"Started job {self.job_id}")
         self._poll()
diff --git a/cumulusci/tasks/marketing_cloud/get_user_info.py b/cumulusci/tasks/marketing_cloud/get_user_info.py
index 53d58321b..1a7a73caf 100644
--- a/cumulusci/tasks/marketing_cloud/get_user_info.py
+++ b/cumulusci/tasks/marketing_cloud/get_user_info.py
@@ -2,10 +2,8 @@ import json
 
 import requests
 
-from cumulusci.utils.http.requests_utils import safe_json_from_response
-
 from .base import BaseMarketingCloudTask
-from .mc_constants import MC_API_VERSION
+from .util import get_mc_user_info
 
 
 class GetUserInfoTask(BaseMarketingCloudTask):
@@ -13,21 +11,15 @@ class GetUserInfoTask(BaseMarketingCloudTask):
     Sanitizes and returns the payload in self.return_values."""
 
     def _run_task(self):
-        endpoint = f"https://{self.mc_config.tssd}.auth.marketingcloudapis.com/{MC_API_VERSION}/userinfo"
-        headers = {
-            "Authorization": f"Bearer {self.mc_config.access_token}",
-            "Content-Type": "application/json",
-        }
         try:
-            response = requests.get(endpoint, headers=headers)
-            response.raise_for_status()
-        except requests.exceptions.HTTPError:
-            self.logger.error(f"Exception occurred fetching user info: {response.text}")
+            payload = get_mc_user_info(self.mc_config.tssd, self.mc_config.access_token)
+        except requests.exceptions.HTTPError as e:
+            self.logger.error(
+                f"Exception occurred fetching user info: {e.response.text}"
+            )
             raise
 
-        payload = safe_json_from_response(response)
         payload = self._sanitize_payload(payload)
-
         self.logger.info("Successfully fetched user info.")
         self.logger.info(json.dumps(payload, indent=4))
 
diff --git a/cumulusci/tasks/marketing_cloud/util.py b/cumulusci/tasks/marketing_cloud/util.py
new file mode 100644
index 000000000..e67fd7c79
--- /dev/null
+++ b/cumulusci/tasks/marketing_cloud/util.py
@@ -0,0 +1,26 @@
+import requests
+
+from cumulusci.utils.http.requests_utils import safe_json_from_response
+
+from .mc_constants import MC_API_VERSION
+
+
+def get_mc_user_info(tssd: str, access_token: str) -> dict:
+    """Make a call to the Marketing Cloud REST API UserInfo endpoint.
+    Raises HTTPError for bad response status, otherwise returns the payload
+    in full."""
+    endpoint = f"https://{tssd}.auth.marketingcloudapis.com/{MC_API_VERSION}/userinfo"
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Content-Type": "application/json",
+    }
+    response = requests.get(endpoint, headers=headers)
+    response.raise_for_status()
+
+    return safe_json_from_response(response)
+
+
+def get_mc_stack_key(tssd: str, access_token: str) -> str:
+    """Return the stack_key associated with the given access token."""
+    user_info_payload = get_mc_user_info(tssd, access_token)
+    return user_info_payload["organization"]["stack_key"]


Test patch:
diff --git a/cumulusci/tasks/marketing_cloud/tests/test_deploy.py b/cumulusci/tasks/marketing_cloud/tests/test_deploy.py
index 20c5744cb..03543b8b7 100644
--- a/cumulusci/tasks/marketing_cloud/tests/test_deploy.py
+++ b/cumulusci/tasks/marketing_cloud/tests/test_deploy.py
@@ -13,8 +13,12 @@ from cumulusci.tasks.marketing_cloud.deploy import (
     MCPM_ENDPOINT,
     MarketingCloudDeployTask,
 )
+from cumulusci.tasks.marketing_cloud.mc_constants import MC_API_VERSION
 from cumulusci.utils import temporary_dir
 
+TEST_TSSD = "asdf-qwerty"
+STACK_KEY = "S4"
+
 
 @pytest.fixture
 def task(project_config):
@@ -32,7 +36,7 @@ def task(project_config):
     )
     task.mc_config = mock.Mock()
     task.mc_config.access_token = "foo"
-    task.mc_config.tssd = "bar"
+    task.mc_config.tssd = TEST_TSSD
     return task
 
 
@@ -52,21 +56,31 @@ def task_without_custom_inputs(project_config):
     )
     task.mc_config = mock.Mock()
     task.mc_config.access_token = "foo"
-    task.mc_config.tssd = "bar"
+    task.mc_config.tssd = TEST_TSSD
     return task
 
 
+@pytest.fixture
+def mocked_responses():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            "GET",
+            f"https://{TEST_TSSD}.auth.marketingcloudapis.com/{MC_API_VERSION}/userinfo",
+            json={"organization": {"stack_key": STACK_KEY}},
+        )
+        yield rsps
+
+
 class TestMarketingCloudDeployTask:
-    @responses.activate
-    def test_run_task__deploy_succeeds_with_custom_inputs(self, task):
-        responses.add(
+    def test_run_task__deploy_succeeds_with_custom_inputs(self, task, mocked_responses):
+        mocked_responses.add(
             "POST",
-            f"{MCPM_ENDPOINT}/deployments",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments",
             json={"id": "JOBID", "status": "IN_PROGRESS"},
         )
-        responses.add(
+        mocked_responses.add(
             "GET",
-            f"{MCPM_ENDPOINT}/deployments/JOBID",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments/JOBID",
             json={"status": "DONE", "entities": {}},
         )
 
@@ -76,18 +90,17 @@ class TestMarketingCloudDeployTask:
         assert task.logger.error.call_count == 0
         assert task.logger.warn.call_count == 0
 
-    @responses.activate
     def test_run_task__deploy_succeeds_without_custom_inputs(
-        self, task_without_custom_inputs
+        self, task_without_custom_inputs, mocked_responses
     ):
-        responses.add(
+        mocked_responses.add(
             "POST",
-            f"{MCPM_ENDPOINT}/deployments",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments",
             json={"id": "JOBID", "status": "IN_PROGRESS"},
         )
-        responses.add(
+        mocked_responses.add(
             "GET",
-            f"{MCPM_ENDPOINT}/deployments/JOBID",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments/JOBID",
             json={"status": "DONE", "entities": {}},
         )
         task = task_without_custom_inputs
@@ -97,16 +110,15 @@ class TestMarketingCloudDeployTask:
         assert task.logger.error.call_count == 0
         assert task.logger.warn.call_count == 0
 
-    @responses.activate
-    def test_run_task__deploy_fails(self, task):
-        responses.add(
+    def test_run_task__deploy_fails(self, task, mocked_responses):
+        mocked_responses.add(
             "POST",
-            f"{MCPM_ENDPOINT}/deployments",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments",
             json={"id": "JOBID", "status": "IN_PROGRESS"},
         )
-        responses.add(
+        mocked_responses.add(
             "GET",
-            f"{MCPM_ENDPOINT}/deployments/JOBID",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments/JOBID",
             json={
                 "status": "DONE",
                 "entities": {
@@ -132,16 +144,15 @@ class TestMarketingCloudDeployTask:
             == "Failed to deploy assets/1. Status: SKIPPED. Issues: ['A problem occurred']"
         )
 
-    @responses.activate
-    def test_run_task__FATAL_ERROR_result(self, task):
-        responses.add(
+    def test_run_task__FATAL_ERROR_result(self, task, mocked_responses):
+        mocked_responses.add(
             "POST",
-            f"{MCPM_ENDPOINT}/deployments",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments",
             json={"id": "JOBID", "status": "FATAL_ERROR"},
         )
-        responses.add(
+        mocked_responses.add(
             "GET",
-            f"{MCPM_ENDPOINT}/deployments/JOBID",
+            f"{MCPM_ENDPOINT.format(STACK_KEY)}/deployments/JOBID",
             json={
                 "status": "FATAL_ERROR",
                 "entities": {},
diff --git a/cumulusci/tasks/marketing_cloud/tests/test_util.py b/cumulusci/tasks/marketing_cloud/tests/test_util.py
new file mode 100644
index 000000000..520343983
--- /dev/null
+++ b/cumulusci/tasks/marketing_cloud/tests/test_util.py
@@ -0,0 +1,17 @@
+import responses
+
+from cumulusci.tasks.marketing_cloud.mc_constants import MC_API_VERSION
+from cumulusci.tasks.marketing_cloud.util import get_mc_stack_key
+
+STACK_KEY = "S4"
+TSSD = "asdf-qwerty"
+
+
+@responses.activate
+def test_marketing_cloud_get_stack_key():
+    responses.add(
+        "GET",
+        f"https://{TSSD}.auth.marketingcloudapis.com/{MC_API_VERSION}/userinfo",
+        json={"organization": {"stack_key": STACK_KEY}},
+    )
+    assert STACK_KEY == get_mc_stack_key(TSSD, "fake-access-token")