Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Files for Rest API Deploy #3644

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions cumulusci/salesforce_api/rest_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import base64
import io
import json
import os
import time
import uuid
import zipfile

import requests


class RestDeploy:
def __init__(
self, task, package_zip, purge_on_delete, check_only, test_level, run_tests
):
# Initialize instance variables and configuration options
self.api_version = task.project_config.project__package__api_version
self.task = task
assert package_zip, "Package zip should not be None"
if purge_on_delete is None:
purge_on_delete = True
self._set_purge_on_delete(purge_on_delete)
self.check_only = "true" if check_only else "false"
self.test_level = test_level
self.package_zip = self._reformat_zip(package_zip)
self.run_tests = run_tests or []

def __call__(self):
self._boundary = str(uuid.uuid4())
url = f"{self.task.org_config.instance_url}/services/data/v{self.api_version}/metadata/deployRequest"
headers = {
"Authorization": f"Bearer {self.task.org_config.access_token}",
"Content-Type": f"multipart/form-data; boundary={self._boundary}",
}

# Prepare deployment options as JSON payload
deploy_options = {
"deployOptions": {
"allowMissingFiles": False,
"autoUpdatePackage": False,
"checkOnly": self.check_only,
"ignoreWarnings": False,
"performRetrieve": False,
"purgeOnDelete": self.purge_on_delete,
"rollbackOnError": False,
"runTests": self.run_tests,
"singlePackage": False,
"testLevel": self.test_level,
}
}
json_payload = json.dumps(deploy_options)

# Construct the multipart/form-data request body
body = (
f"--{self._boundary}\r\n"
f'Content-Disposition: form-data; name="json"\r\n'
f"Content-Type: application/json\r\n\r\n"
f"{json_payload}\r\n"
f"--{self._boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="metadata.zip"\r\n'
f"Content-Type: application/zip\r\n\r\n"
).encode("utf-8")
body += self.package_zip
body += f"\r\n--{self._boundary}--\r\n".encode("utf-8")

response = requests.post(url, headers=headers, data=body)
response_json = response.json()

if response.status_code == 201:
self.task.logger.info("Deployment request successful")
deploy_request_id = response_json["id"]
self._monitor_deploy_status(deploy_request_id)
else:
self.task.logger.error(
f"Deployment request failed with status code {response.status_code}"
)

# Set the purge_on_delete attribute based on org type
def _set_purge_on_delete(self, purge_on_delete):
if not purge_on_delete or purge_on_delete == "false":
self.purge_on_delete = "false"
else:
self.purge_on_delete = "true"
# Disable purge on delete entirely for non sandbox or DE orgs as it is
# not allowed
org_type = self.task.org_config.org_type
is_sandbox = self.task.org_config.is_sandbox
if org_type != "Developer Edition" and not is_sandbox:
self.purge_on_delete = "false"

# Monitor the deployment status and log progress
def _monitor_deploy_status(self, deploy_request_id):
url = f"{self.task.org_config.instance_url}/services/data/v{self.api_version}/metadata/deployRequest/{deploy_request_id}?includeDetails=true"
headers = {"Authorization": f"Bearer {self.task.org_config.access_token}"}

while True:
response = requests.get(url, headers=headers)
response_json = response.json()

if response_json["deployResult"]["status"] != "InProgress":

if response_json["deployResult"]["status"] in [
"Succeeded",
"Failed",
"Cancelled",
]:
self.task.logger.info(
f"Deployment completed with status: {response_json['deployResult']['status']}"
)

if response_json["deployResult"]["status"] == "Failed":
for failure in response_json["deployResult"]["details"][
"componentFailures"
]:
self.task.logger.error(
self._construct_error_message(failure)
)
break

self.task.logger.debug(
f"Deployment status: {response_json['deployResult']['status']}"
)

time.sleep(5)

# Reformat the package zip file to include parent directory
def _reformat_zip(self, package_zip):
zip_bytes = base64.b64decode(package_zip)
zip_stream = io.BytesIO(zip_bytes)
new_zip_stream = io.BytesIO()

with zipfile.ZipFile(zip_stream, "r") as zip_ref:
with zipfile.ZipFile(new_zip_stream, "w") as new_zip_ref:
for item in zip_ref.infolist():
# Choice of name for parent directory is irrelevant to functioning
new_item_name = os.path.join("metadata", item.filename)
file_content = zip_ref.read(item.filename)
new_zip_ref.writestr(new_item_name, file_content)

new_zip_bytes = new_zip_stream.getvalue()
return new_zip_bytes

# Construct an error message from deployment failure details
def _construct_error_message(self, failure):
error_message = f"{str.upper(failure['problemType'])} in file {failure['fileName'][9:]}: {failure['problem']}"

if failure["lineNumber"] and failure["columnNumber"]:
error_message += (
f" at line {failure['lineNumber']}:{failure['columnNumber']}"
)

return error_message
182 changes: 182 additions & 0 deletions cumulusci/salesforce_api/tests/test_rest_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import unittest
from unittest.mock import MagicMock, Mock, call, patch

from cumulusci.salesforce_api.rest_deploy import RestDeploy


class TestRestDeploy(unittest.TestCase):
# Setup method executed before each test method
def setUp(self):
self.mock_logger = Mock()
self.mock_task = MagicMock()
self.mock_task.logger = self.mock_logger
self.mock_task.org_config.instance_url = "https://example.com"
self.mock_task.org_config.access_token = "dummy_token"
# Header for empty zip file
self.mock_zip = "UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="

# Test case for a successful deployment and deploy status
@patch("requests.post")
@patch("requests.get")
def test_deployment_success(self, mock_get, mock_post):

response_post = Mock(status_code=201)
response_post.json.return_value = {"id": "dummy_id"}
mock_post.return_value = response_post

response_get = Mock(status_code=200)
response_get.json.side_effect = [
{"deployResult": {"status": "InProgress"}},
{"deployResult": {"status": "Succeeded"}},
]
mock_get.return_value = response_get

deployer = RestDeploy(
self.mock_task, self.mock_zip, False, False, "NoTestRun", []
)
deployer()

# Assertions to verify log messages and method calls
mock_post.assert_called_once()
self.assertEqual(
self.mock_logger.info.call_args_list[0],
call("Deployment request successful"),
)
self.assertEqual(
self.mock_logger.info.call_args_list[1],
call("Deployment completed with status: Succeeded"),
)
self.assertEqual(self.mock_logger.info.call_count, 2)
self.assertEqual(self.mock_logger.error.call_count, 0)
self.assertEqual(self.mock_logger.debug.call_count, 0)

# Test case for a deployment failure
@patch("requests.post")
def test_deployment_failure(self, mock_post):

response_post = Mock(status_code=500)
response_post.json.return_value = {"id": "dummy_id"}
mock_post.return_value = response_post

deployer = RestDeploy(
self.mock_task, self.mock_zip, False, False, "NoTestRun", []
)
deployer()

# Assertions to verify log messages and method calls
mock_post.assert_called_once()
self.assertEqual(
self.mock_logger.error.call_args_list[0],
call("Deployment request failed with status code 500"),
)
self.assertEqual(self.mock_logger.info.call_count, 0)
self.assertEqual(self.mock_logger.error.call_count, 1)
self.assertEqual(self.mock_logger.debug.call_count, 0)

# Test for deployment success but deploy status failure
@patch("requests.post")
@patch("requests.get")
def test_deployStatus_failure(self, mock_get, mock_post):

response_post = Mock(status_code=201)
response_post.json.return_value = {"id": "dummy_id"}
mock_post.return_value = response_post

response_get = Mock(status_code=200)
response_get.json.side_effect = [
{"deployResult": {"status": "InProgress"}},
{
"deployResult": {
"status": "Failed",
"details": {
"componentFailures": [
{
"problemType": "Error",
"fileName": "metadata/classes/mockfile1.cls",
"problem": "someproblem1",
"lineNumber": 1,
"columnNumber": 1,
},
{
"problemType": "Error",
"fileName": "metadata/objects/mockfile2.obj",
"problem": "someproblem2",
"lineNumber": 2,
"columnNumber": 2,
},
]
},
}
},
]
mock_get.return_value = response_get

deployer = RestDeploy(
self.mock_task, self.mock_zip, False, False, "NoTestRun", []
)
deployer()

# Assertions to verify log messages and method calls
mock_post.assert_called_once()
self.assertEqual(
self.mock_logger.info.call_args_list[0],
call("Deployment request successful"),
)
self.assertEqual(
self.mock_logger.info.call_args_list[1],
call("Deployment completed with status: Failed"),
)
self.assertEqual(self.mock_logger.info.call_count, 2)
self.assertEqual(
self.mock_logger.error.call_args_list[0],
call("ERROR in file classes/mockfile1.cls: someproblem1 at line 1:1"),
)
self.assertEqual(
self.mock_logger.error.call_args_list[1],
call("ERROR in file objects/mockfile2.obj: someproblem2 at line 2:2"),
)
self.assertEqual(self.mock_logger.error.call_count, 2)
self.assertEqual(self.mock_logger.debug.call_count, 0)

# Test case for a deployment with a pending status
@patch("requests.post")
@patch("requests.get")
def test_pending_call(self, mock_get, mock_post):

response_post = Mock(status_code=201)
response_post.json.return_value = {"id": "dummy_id"}
mock_post.return_value = response_post

response_get = Mock(status_code=200)
response_get.json.side_effect = [
{"deployResult": {"status": "InProgress"}},
{"deployResult": {"status": "Pending"}},
{"deployResult": {"status": "Succeeded"}},
]
mock_get.return_value = response_get

deployer = RestDeploy(
self.mock_task, self.mock_zip, False, False, "NoTestRun", []
)
deployer()

# Assertions to verify log messages and method calls
mock_post.assert_called_once()
self.assertEqual(
self.mock_logger.info.call_args_list[0],
call("Deployment request successful"),
)
self.assertEqual(
self.mock_logger.info.call_args_list[1],
call("Deployment completed with status: Succeeded"),
)
self.assertEqual(self.mock_logger.info.call_count, 2)
self.assertEqual(self.mock_logger.error.call_count, 0)
self.assertEqual(
self.mock_logger.debug.call_args_list[0], call("Deployment status: Pending")
)
self.assertEqual(self.mock_logger.debug.call_count, 1)


if __name__ == "__main__":
unittest.main()
11 changes: 11 additions & 0 deletions cumulusci/tasks/salesforce/Deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from cumulusci.core.utils import process_bool_arg, process_list_arg
from cumulusci.salesforce_api.metadata import ApiDeploy
from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder
from cumulusci.salesforce_api.rest_deploy import RestDeploy
from cumulusci.tasks.salesforce.BaseSalesforceMetadataApiTask import (
BaseSalesforceMetadataApiTask,
)
Expand Down Expand Up @@ -55,6 +56,9 @@ class Deploy(BaseSalesforceMetadataApiTask):
"transforms": {
"description": "Apply source transforms before deploying. See the CumulusCI documentation for details on how to specify transforms."
},
"rest_deploy": {
"description": "If True, deploy metadata with Apex Testing using REST API"
},
}

namespaces = {"sf": "http://soap.sforce.com/2006/04/metadata"}
Expand Down Expand Up @@ -99,6 +103,9 @@ def _init_options(self, kwargs):
f"The validation error was {str(e)}"
)

# Set class variable to true if rest_deploy is set to True
self.rest_deploy = process_bool_arg(self.options.get("rest_deploy", False))

def _get_api(self, path=None):
if not path:
path = self.options.get("path")
Expand All @@ -110,6 +117,10 @@ def _get_api(self, path=None):
self.logger.warning("Deployment package is empty; skipping deployment.")
return

# If rest_deploy param is set, update api_class to be RestDeploy
if self.rest_deploy:
self.api_class = RestDeploy

return self.api_class(
self,
package_zip,
Expand Down