Repository: SFDO-Tooling/CumulusCI
Pull request #3303 (2022-07-22 16:09:06)

MetaData ETL Task - `add_remote_site_settings`
* CumulusCI has a new task: `add_remote_site_settings`. This task deploys an arbitrary number of `RemoteSiteSetting` records to an org.
* CumulusCI has a new task: `deploy_omni_studio_site_settings`. This task deploys the remote site setting records that are needed for OmniStudio to an org.


Patch:
diff --git a/cumulusci/cumulusci.yml b/cumulusci/cumulusci.yml
index b047d54e6..dc8bcfb76 100644
--- a/cumulusci/cumulusci.yml
+++ b/cumulusci/cumulusci.yml
@@ -45,6 +45,10 @@ tasks:
         group: Metadata Transformations
         description: "Adds the specified 'Record' context Lightning button/action to the provided page layout."
         class_path: cumulusci.tasks.metadata_etl.layouts.AddRecordPlatformActionListItem
+    add_remote_site_settings:
+        group: Metadata Transformations
+        description: "Adds the specified RemoteSiteSettings records to an org."
+        class_path: cumulusci.tasks.metadata_etl.remote_site_settings.AddRemoteSiteSettings
     assign_compact_layout:
         group: Metadata Transformations
         description: "Assigns the Compact Layout specified in the 'value' option to the Custom Objects in 'api_names' option."
@@ -221,6 +225,10 @@ tasks:
         class_path: cumulusci.tasks.marketing_cloud.api.UpdateUserRole
         description: Assigns a Role to an existing User via the Marketing Cloud SOAP API.
         group: Marketing Cloud
+    deploy_omni_studio_site_settings:
+        class_path: cumulusci.tasks.vlocity.vlocity.OmniStudioDeployRemoteSiteSettings
+        description: Deploys remote site settings needed for OmniStudio.
+        group: OmniStudio
     deploy_pre:
         description: Deploys all metadata bundles under unpackaged/pre/
         class_path: cumulusci.tasks.salesforce.DeployBundles
@@ -679,11 +687,11 @@ tasks:
     vlocity_pack_export:
         class_path: cumulusci.tasks.vlocity.vlocity.VlocityRetrieveTask
         description: "Executes the `vlocity packExport` command against an org"
-        group: Vlocity
+        group: OmniStudio
     vlocity_pack_deploy:
         class_path: cumulusci.tasks.vlocity.vlocity.VlocityDeployTask
         description: "Executes the `vlocity packDeploy` command against an org"
-        group: Vlocity
+        group: OmniStudio
 flows:
     ci_beta:
         group: Continuous Integration
diff --git a/cumulusci/tasks/metadata_etl/base.py b/cumulusci/tasks/metadata_etl/base.py
index e9adc5049..876e79561 100644
--- a/cumulusci/tasks/metadata_etl/base.py
+++ b/cumulusci/tasks/metadata_etl/base.py
@@ -185,6 +185,9 @@ class BaseMetadataSynthesisTask(BaseMetadataETLTask, metaclass=ABCMeta):
         generator = PackageXmlGenerator(str(self.deploy_dir), self.api_version)
         return generator()
 
+    def _get_package_xml_content(self, operation) -> str:
+        return ""
+
     def _transform(self):
         self._synthesize()
 
diff --git a/cumulusci/tasks/metadata_etl/remote_site_settings.py b/cumulusci/tasks/metadata_etl/remote_site_settings.py
new file mode 100644
index 000000000..7add3b6e3
--- /dev/null
+++ b/cumulusci/tasks/metadata_etl/remote_site_settings.py
@@ -0,0 +1,99 @@
+from typing import List, Optional
+
+import pydantic
+from pydantic import BaseModel
+
+from cumulusci.core.exceptions import TaskOptionsError
+from cumulusci.tasks.metadata_etl.base import BaseMetadataSynthesisTask
+
+REQUIRED_FIELD_ERR = "You must specify a value for RemoteSiteSetting.{} on all entries."
+RSS_DIR_NAME = "remoteSiteSettings"
+RSS_FILE_EXTENSION = "remoteSite"
+RSS_XML = """<?xml version="1.0" encoding="UTF-8"?>
+<RemoteSiteSetting xmlns="http://soap.sforce.com/2006/04/metadata">
+    <description>{description}</description>
+    <disableProtocolSecurity>{disable_protocol_security}</disableProtocolSecurity>
+    <isActive>{is_active}</isActive>
+    <url>{url}</url>
+</RemoteSiteSetting>"""
+
+
+class RemoteSiteSetting(BaseModel):
+    """Schema for the RemoteSiteSetting object"""
+
+    full_name: str
+    url: str
+    is_active: bool
+    description: Optional[str]
+    disable_protocol_security: Optional[bool]
+
+
+class RSSOptions(BaseModel):
+    """Defines the options for this task"""
+
+    records: List[RemoteSiteSetting]
+
+
+class AddRemoteSiteSettings(BaseMetadataSynthesisTask):
+    """Task for adding Remote Site Setting records to an org"""
+
+    entity = "RemoteSiteSetting"
+    task_docs = """
+
+        Example Usage
+        -----------------------
+
+        .. code-block::  yaml
+
+            task: add_remote_site_settings
+            options:
+                records:
+                    - full_name: ExampleRemoteSiteSetting
+                      description: Descriptions can be included optionally
+                      url: https://test.salesforce.com
+                      is_active: True
+
+    """
+    task_options = {
+        "records": {
+            "description": "Array of RemoteSiteSetting records to insert. "
+            "Each RemoteSiteSetting requires the keys: 'full_name', 'is_active', and 'url'. "
+            "'description' is optional, and defaults to an empty string. "
+            "'disable_security_protocol' is optional, and defaults to False.",
+            "required": True,
+        },
+    }
+
+    def _synthesize(self):
+        options: RSSOptions = self._get_options()
+        for rss in options.records:
+            content: str = self._get_rss_xml_content(rss)
+            filename: str = f"{rss.full_name}.{RSS_FILE_EXTENSION}"
+            self._create_rss_file(filename, content)
+        return
+
+    def _get_options(self) -> RSSOptions:
+        """Parse and return the options defined in RSSOptions"""
+        try:
+            return RSSOptions.parse_obj(self.options)
+        except pydantic.ValidationError as exc:
+            raise TaskOptionsError(f"Invalid options: {exc}")
+
+    def _get_rss_xml_content(self, rss: RemoteSiteSetting) -> str:
+        disable_protocol_security = rss.disable_protocol_security or False
+        is_active = rss.is_active or False
+        return RSS_XML.format(
+            url=rss.url,
+            is_active=is_active,
+            description=rss.description or "",
+            disable_protocol_security=disable_protocol_security,
+        )
+
+    def _create_rss_file(self, filename: str, content: str):
+        """Creates an xml file with given name and contents in self.deploy_dir/RemoteSiteSettings/"""
+        rss_dir = self.deploy_dir / RSS_DIR_NAME
+        rss_dir.mkdir(exist_ok=True)
+
+        filepath = rss_dir / filename
+        filepath.touch(exist_ok=False)
+        filepath.write_text(content, encoding="UTF-8")
diff --git a/cumulusci/tasks/vlocity/vlocity.py b/cumulusci/tasks/vlocity/vlocity.py
index 67d821472..196a639d3 100644
--- a/cumulusci/tasks/vlocity/vlocity.py
+++ b/cumulusci/tasks/vlocity/vlocity.py
@@ -6,6 +6,10 @@ import sarge
 from cumulusci.core.config.scratch_org_config import ScratchOrgConfig
 from cumulusci.core.tasks import BaseSalesforceTask
 from cumulusci.tasks.command import Command
+from cumulusci.tasks.metadata_etl.remote_site_settings import (
+    AddRemoteSiteSettings,
+    RSSOptions,
+)
 from cumulusci.tasks.vlocity.exceptions import BuildToolMissingError
 
 CLI_KEYWORD = "vlocity"
@@ -13,6 +17,10 @@ BUILD_TOOL_MISSING_ERROR = (
     "This task requires the Vlocity Build CLI tool which is not currently installed on this system. "
     "For information on installing this tool visit: https://github.com/vlocityinc/vlocity_build#vlocity-build"
 )
+VF_RSS_NAME = "OmniStudioVisualforce"
+VF_LEGACY_RSS_NAME = "OmniStudioLegacyVisualforce"
+LWC_RSS_NAME = "OmniStudioLightning"
+OMNI_NAMESPACE = "omnistudio"
 
 
 class VlocityBaseTask(Command, BaseSalesforceTask):
@@ -84,3 +92,52 @@ class VlocityDeployTask(VlocitySimpleJobTask):
     """Runs a `vlocity packDeploy` command with a given user and job file"""
 
     command_keyword: Final[str] = "packDeploy"
+
+
+class OmniStudioDeployRemoteSiteSettings(AddRemoteSiteSettings):
+    """Deploys remote site settings needed for OmniStudio.
+    This cannot be configured in cumulusci/cumulusci.yml because
+    the values for the 'url' field are dynamic."""
+
+    task_options: dict = {
+        "namespace": {
+            "description": "The namespace to inject into RemoteSiteSettings.url values. Defaults to '{OMNI_NAMESPACE}'."
+        }
+    }
+
+    def _get_options(self) -> RSSOptions:
+        namespace = self.options.get("namespace") or OMNI_NAMESPACE
+
+        visualforce_url: str = self.org_config.instance_url.replace(
+            ".my.salesforce.com",
+            f"--{namespace}.{self.org_config.instance_name}.visual.force.com",
+        )
+        legacy_visualforce_url: str = self.org_config.instance_url.replace(
+            ".my.salesforce.com",
+            f"--{namespace}.vf.force.com",
+        )
+        lightning_url: str = self.org_config.instance_url.replace(
+            ".my.salesforce.com", ".lightning.force.com"
+        )
+
+        self.options = {
+            **self.options,
+            "records": [
+                {
+                    "full_name": VF_RSS_NAME,
+                    "url": visualforce_url,
+                    "is_active": True,
+                },
+                {
+                    "full_name": VF_LEGACY_RSS_NAME,
+                    "url": legacy_visualforce_url,
+                    "is_active": True,
+                },
+                {
+                    "full_name": LWC_RSS_NAME,
+                    "url": lightning_url,
+                    "is_active": True,
+                },
+            ],
+        }
+        return super()._get_options()


Test patch:
diff --git a/cumulusci/tasks/metadata_etl/tests/test_remote_site_settings.py b/cumulusci/tasks/metadata_etl/tests/test_remote_site_settings.py
new file mode 100644
index 000000000..d634e68e3
--- /dev/null
+++ b/cumulusci/tasks/metadata_etl/tests/test_remote_site_settings.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from xml.dom.minidom import parseString
+
+from cumulusci.tasks.metadata_etl.remote_site_settings import (
+    RSS_DIR_NAME,
+    RSS_FILE_EXTENSION,
+    AddRemoteSiteSettings,
+)
+from cumulusci.tasks.salesforce.tests.util import create_task
+
+
+class TestAddRemoteSiteSettings:
+    def test_synthesis(self):
+        name = "ExampleSite"
+        url = "https://foo.bar"
+        is_active = True
+        records = [
+            {
+                "full_name": name,
+                "url": url,
+                "is_active": is_active,
+            },
+        ]
+
+        task = create_task(AddRemoteSiteSettings, {"records": records})
+        with TemporaryDirectory() as tempdir:
+            task.deploy_dir = Path(tempdir)
+            task._synthesize()
+
+            rss_xml_file = Path(f"{tempdir}/{RSS_DIR_NAME}/{name}.{RSS_FILE_EXTENSION}")
+            assert rss_xml_file.is_file()
+
+            file_content = rss_xml_file.read_text()
+            dom = parseString(file_content)
+
+            assert url == get_node_value("url", dom)
+            assert "" == get_node_value("description", dom)
+            assert is_active == bool(get_node_value("isActive", dom))
+
+
+def get_node_value(tag_name: str, dom) -> str:
+    element = dom.getElementsByTagName(tag_name)[0]
+    if element.firstChild:
+        return element.firstChild.nodeValue
+    return ""
diff --git a/cumulusci/tasks/vlocity/tests/test_vlocity.py b/cumulusci/tasks/vlocity/tests/test_vlocity.py
index 657c51f7d..4a8e0a9fa 100644
--- a/cumulusci/tasks/vlocity/tests/test_vlocity.py
+++ b/cumulusci/tasks/vlocity/tests/test_vlocity.py
@@ -8,6 +8,11 @@ from cumulusci.core.config.scratch_org_config import ScratchOrgConfig
 from cumulusci.tasks.vlocity.exceptions import BuildToolMissingError
 from cumulusci.tasks.vlocity.vlocity import (
     BUILD_TOOL_MISSING_ERROR,
+    LWC_RSS_NAME,
+    OMNI_NAMESPACE,
+    VF_LEGACY_RSS_NAME,
+    VF_RSS_NAME,
+    OmniStudioDeployRemoteSiteSettings,
     VlocityDeployTask,
     VlocityRetrieveTask,
 )
@@ -101,3 +106,40 @@ def test_vlocity_build_tool_missing(project_config):
     ):
         with pytest.raises(BuildToolMissingError, match=BUILD_TOOL_MISSING_ERROR):
             task._init_task()
+
+
+namespace = "cci"
+test_cases = [
+    (TaskConfig(config={}), OMNI_NAMESPACE),
+    (TaskConfig(config={"options": {"namespace": namespace}}), namespace),
+]
+
+
+@pytest.mark.parametrize("task_config,expected_namespace", test_cases)
+def test_deploy_omni_studio_site_settings(
+    project_config, task_config, expected_namespace
+):
+    org_config = mock.Mock(
+        installed_packages=[],
+        instance_url="https://inspiration-velocity-34802-dev-ed.my.salesforce.com/",
+        instance_name="CS28",
+    )
+
+    task = OmniStudioDeployRemoteSiteSettings(project_config, task_config, org_config)
+    rss_options = task._get_options()
+    records = rss_options.records
+
+    expected_site_names = set([VF_RSS_NAME, VF_LEGACY_RSS_NAME, LWC_RSS_NAME])
+    actual_site_names = set([r.full_name for r in records])
+    assert expected_site_names == actual_site_names
+
+    # when no 'namespace' option is specified, we default to the omni studio namespace
+    expected_urls = set(
+        [
+            "https://inspiration-velocity-34802-dev-ed.lightning.force.com/",
+            f"https://inspiration-velocity-34802-dev-ed--{expected_namespace}.vf.force.com/",
+            f"https://inspiration-velocity-34802-dev-ed--{expected_namespace}.CS28.visual.force.com/",
+        ]
+    )
+    actual_urls = set([r.url for r in records])
+    assert expected_urls == actual_urls