<Workflow_WorkflowDefinition outputs="<outputs>
 <exitCode class="java.lang.String" _list_="false">xpath:{/workflow/variables/exitCode}</exitCode>
 <message class="java.lang.String" _list_="false">xpath:{/workflow/variables/message}</message>
</outputs>" webHidden="0" isHtmlDescription="0" inputs="<inputs>
 <operationType class="java.lang.String" _list_="false">Backup</operationType>
 <path class="java.lang.String" _list_="false"/>
</inputs>" interactive="0" description="<html><p>The workflow is designed for the backup of DocuSign documents to a Commvault S3 Vault. It tracks the last backup time to ensure only new or updated documents are retrieved, reducing redundancy. Backups are stored in S3, and the process automatically updates the timestamp for seamless incremental backups in future runs.</p><p><br></p><p>It can also list backup content and can restore a backup path from the backup to a local folder on workflow engine machine.</p></html>" manualPercentageComplete="0" apiMode="0" executeOnWeb="0" variables="<variables>
 <exitCode class="java.lang.Integer" _list_="false"/>
 <message class="java.lang.String" _list_="false"/>
 <isWindows class="java.lang.Integer" _list_="false">0</isWindows>
 <isValidInputs class="java.lang.Boolean" _list_="false">true</isValidInputs>
</variables>" revision="$Revision: $" tags="" modTime="1756360384" uniqueGuid="5beacc90-e05c-40b1-af71-d58c5901da15" name="Backup Docusign Utility" config="<configuration>
 <privateKey class="java.lang.String" _list_="false"/>
 <configJson class="java.lang.String" _list_="false"/>
</configuration>" workflowId="0"><schema><outputs className="" type="" name="outputs"><children hidden="0" defaultValue="" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" inputType="java.lang.String" attribute="0" documentation="" readOnly="0" controlType="0" name="exitCode" /><children hidden="0" defaultValue="" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" inputType="java.lang.String" attribute="0" documentation="" readOnly="0" controlType="0" name="message" /></outputs><variables className="" type="" name="variables"><children hidden="0" className="java.lang.Integer" type="{http://www.w3.org/2001/XMLSchema}integer" listType="0" inputType="java.lang.Integer" attribute="0" name="exitCode" /><children hidden="0" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" inputType="java.lang.String" attribute="0" name="message" /><children hidden="0" className="java.lang.Integer" type="{http://www.w3.org/2001/XMLSchema}integer" listType="0" inputType="java.lang.Integer" attribute="0" name="isWindows" /><children className="java.lang.Boolean" type="{http://www.w3.org/2001/XMLSchema}boolean" listType="0" inputType="java.lang.Boolean" name="isValidInputs" /></variables><inputs><children hidden="0" defaultValue="" displayName="Operation Type" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" required="0" minimumValue="" inputType="java.lang.String" attribute="0" documentation="" controlHidden="0" readOnly="0" searchable="0" controlType="2" name="operationType" maximumValue=""><labelsForOptions val="Backup" /><labelsForOptions val="List Backup" /><labelsForOptions val="Restore Locally" /><options val="Backup" /><options val="List" /><options val="Restore" /></children><children hidden="0" defaultValue="" displayName="Path" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" required="0" minimumValue="" inputType="java.lang.String" attribute="0" documentation="" controlHidden="0" readOnly="0" searchable="0" controlType="0" name="path" maximumValue="" /></inputs><config className="" type="" name="configuration"><children hidden="0" displayName="" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" required="0" minimumValue="" inputType="java.lang.String" attribute="0" documentation="" controlHidden="0" readOnly="0" searchable="0" controlType="0" name="privateKey" alignment="0" maximumValue="" /><children hidden="0" displayName="" className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" listType="0" required="0" inputType="java.lang.String" attribute="0" documentation="" controlHidden="0" readOnly="0" searchable="0" controlType="0" name="configJson" alignment="0" /></config></schema><Start displayName="Start" interactive="0" originalStyle="" jobMode="0" description="" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="36" created="0" uniqueName="Start_1" skipAttempt="0" name="Start" width="55" x="45" y="41" style="image;image=/images/jgraphx/house.png"><inputs val="<inputs/>" /><transition sourceX="72" sourceY="59" activity="Script_6" targetY="56" targetX="178" originalStyle="" description="" points="78,89" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0" /></Start><Activity maxRestarts="0" displayName="Python3 Available?" interactive="0" originalStyle="" jobMode="0" description="Execute a script on a remote machine" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="25" created="1755510637092" uniqueName="ExecuteScript_1" skipAttempt="0" name="ExecuteScript" width="137" x="272" y="42" style="label;image=commvault.cte.workflow.activities.ExecuteScript"><inputs val="<inputs>
 <script>Write-Host "Checking for Python 3 and pip3..."

# Function to check command existence
function Test-Command($cmd) {
 $null -ne (Get-Command $cmd -ErrorAction SilentlyContinue)
}

$pythonOK = $false
$pipOK = $false

# ---- Check Python 3 ----
if (Test-Command "python3") {
 $ver = python3 --version 2&gt;&amp;1
 Write-Host "python3 found: $ver"

 if ($ver -match "Python 3\.\d+") {
 $pythonOK = $true
 } else {
 Write-Host "Found python3 but version is not 3.x"
 }
} elseif (Test-Command "python") {
 $ver = python --version 2&gt;&amp;1
 Write-Host "python found: $ver"

 if ($ver -match "Python 3\.\d+") {
 $pythonOK = $true
 } else {
 Write-Host "python exists but it’s not Python 3"
 }
} else {
 Write-Host "Python 3 is not installed"
}

# ---- Check pip3 ----
if (Test-Command "pip3") {
 $ver = pip3 --version 2&gt;&amp;1
 Write-Host "pip3 found: $ver"
 $pipOK = $true
} elseif (Test-Command "pip") {
 $ver = pip --version 2&gt;&amp;1
 Write-Host "pip found: $ver"
 if ($ver -match "python 3\.\d+") {
 $pipOK = $true
 } else {
 Write-Host "pip exists but tied to non-Python3"
 }
} else {
 Write-Host "pip3 is not installed"
}

# ---- Exit code ----
if ($pythonOK -and $pipOK) {
 Write-Host "Python 3 and pip3 are installed"
 exit 0
} else {
 Write-Host "Python 3 or pip3 missing"
 exit 1
}
</script>
 <scriptType>PowerShell</scriptType>
</inputs>" /><onExit language="3" script="var exitcode = xpath:{/workflow/ExecuteScript_1/exitCode};
if(exitcode == 0){
	workflow.setVariable("exitCode", 0);
}else{
 	workflow.setVariable("exitCode", 1);
	workflow.setVariable("message", xpath:{/workflow/ExecuteScript_1/commandOutput});

}

" /><transition sourceX="258" sourceY="69" activity="ReleaseLock_1" displayName="No" targetY="163" targetX="258" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition language="3" script="/*
The expression should return a boolean. Use the variable name "activity" to refer to the previous activity object. Example:
activity.exitCode==0;
*/
xpath:{/workflow/ExecuteScript_1/exitCode} == 1;" /></transition><transition sourceX="305" sourceY="54" activity="ExecuteProcessBlock_1" displayName="Yes" targetY="119" targetX="463" originalStyle="" description="" points="468,53" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/ExecuteScript_1/exitCode} == 0;" /></transition></Activity><Activity displayName="WorkflowEnd" interactive="0" originalStyle="" jobMode="0" description="Ends the workflow" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="27" created="1755512827384" uniqueName="WorkflowEnd_1" skipAttempt="0" name="WorkflowEnd" width="108" x="279" y="245" style="label;image=commvault.cte.workflow.activities.EndActivity"><inputs val="<inputs>
 <completionStatus class="workflow.types.WorkflowCompletionStatus" _list_="false"/>
 <failureMessage class="java.lang.String" _list_="false"/>
</inputs>" /><outputs outputs="<outputs>
 <exitCode>xpath:{/workflow/variables/exitCode}</exitCode>
 <message>xpath:{/workflow/variables/message}</message>
</outputs>" /></Activity><Activity displayName="AcquireLock" interactive="0" originalStyle="" jobMode="0" description="synchronizes a workflow per named parameter" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="23" created="1755597745277" uniqueName="AcquireLock_1" skipAttempt="0" name="AcquireLock" width="102" x="135" y="45" style="label;image=commvault.cte.workflow.activities.LockAcquireActivity"><inputs val="<inputs>
 <name class="java.lang.String" _list_="false">xpath:{/workflow/system/workflow/workflowName}</name>
 <releaseLockOnCompletion class="java.lang.Boolean" _list_="false">true</releaseLockOnCompletion>
 <timeout class="java.lang.Integer" _list_="false"/>
</inputs>" /><transition sourceX="178" sourceY="56" activity="Script_4" targetY="106" targetX="177" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0" /></Activity><Activity displayName="ReleaseLock" interactive="0" originalStyle="" jobMode="0" description="releases the lock for the named parameter" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="22" created="1755597814236" uniqueName="ReleaseLock_1" skipAttempt="0" name="ReleaseLock" width="104" x="276" y="126" style="label;image=commvault.cte.workflow.activities.LockReleaseActivity"><inputs val="<inputs>
 <name class="java.lang.String" _list_="false">xpath:{/workflow/system/workflow/workflowName}</name>
</inputs>" /><transition sourceX="308" sourceY="121" activity="WorkflowEnd_1" targetY="175" targetX="325" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0" /></Activity><Activity maxRestarts="0" displayName="Is Windows" interactive="0" originalStyle="" jobMode="0" description="activity to execute code snippets in the selected language" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="21" created="1756102342592" uniqueName="Script_4" skipAttempt="0" name="Script" width="96" x="138" y="106" style="label;image=commvault.cte.workflow.activities.ScriptActivity"><inputs val="<inputs>
 <script language="4" script="String os = System.getProperty(&quot;os.name&quot;).toLowerCase();&#xA;int isWindows = 0;&#xA;if (os.contains(&quot;win&quot;)) {&#xA; logger.info(&quot;Running on Windows&quot;);&#xA; isWindows = 1;&#xA;} else if (os.contains(&quot;nix&quot;) || os.contains(&quot;nux&quot;) || os.contains(&quot;aix&quot;)) {&#xA; logger.info(&quot;Running on Linux&quot;);&#xA;} else if (os.contains(&quot;mac&quot;)) {&#xA; logger.info(&quot;Running on macOS&quot;);&#xA;} else {&#xA; logger.info(&quot;Running on &quot; + os);&#xA;}&#xA;workflow.setVariable(&quot;isWindows&quot;, isWindows);&#xA;return isWindows;"/>
</inputs>" /><transition sourceX="177" sourceY="106" activity="ExecuteScript_1" displayName="Windows" targetY="54" targetX="305" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/Script_4/output} == 1;" /></transition><transition sourceX="177" sourceY="106" activity="ExecuteScript_4" displayName="Linux" targetY="174" targetX="187" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/Script_4/output} == 0;" /></transition></Activity><Activity displayName="Python3 Available?" interactive="0" originalStyle="" jobMode="0" description="Execute a script on a remote machine" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="25" created="1756102643525" uniqueName="ExecuteScript_4" skipAttempt="0" name="ExecuteScript" width="137" x="115" y="186" style="label;image=commvault.cte.workflow.activities.ExecuteScript"><inputs val="<inputs>
 <script>#!/bin/bash

# Check if python3 exists
if command -v python3 &amp;&gt;/dev/null; then
 echo "Python 3 is installed: $(python3 --version)"
 exit 0
else
 echo "Python 3 is not installed."
 exit 1
fi
</script>
 <scriptType>UnixShell</scriptType>
</inputs>" /><onExit language="3" script="var exitcode = xpath:{/workflow/ExecuteScript_4/exitCode};
if(exitcode == 0){
	workflow.setVariable("exitCode", 0);
}else{
 	workflow.setVariable("exitCode", 1);
	workflow.setVariable("message", xpath:{/workflow/ExecuteScript_1/commandOutput});

}

" /><transition sourceX="187" sourceY="174" activity="ReleaseLock_1" displayName="No" targetY="121" targetX="308" originalStyle="" description="" points="236,177" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/ExecuteScript_4/exitCode} == 1;" /></transition><transition sourceX="187" sourceY="174" activity="ExecuteProcessBlock_1" displayName="Yes" targetY="119" targetX="463" originalStyle="" description="" points="452,200" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/ExecuteScript_4/exitCode} == 0;" /></transition></Activity><Activity maxRestarts="0" displayName="Install required lib and process" interactive="0" originalStyle="" jobMode="0" description="creates a super process group" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="185" created="1756103171107" uniqueName="ProcessBlock_1" skipAttempt="0" name="ProcessBlock" width="430" x="566" y="62" style="swimlane"><inputs val="<inputs>
 <inputs>
 <inputs/>
 <option/>
 </inputs>
</inputs>" /><superProcess><Start displayName="Start" interactive="0" originalStyle="" jobMode="0" description="" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="40" created="1756103171111" uniqueName="Start_2" skipAttempt="0" name="Start" width="60" x="10" y="37" style="label;fillColor=#FFFF33;gradientColor=#FFFF00"><inputs val="<inputs/>" /><transition sourceX="40" sourceY="57" activity="ExecuteScript_2" targetY="57" targetX="100" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0" /></Start><Activity maxRestarts="0" displayName="Install Required Lib" interactive="0" originalStyle="" jobMode="0" description="Execute a script on a remote machine" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="25" created="1755510797959" uniqueName="ExecuteScript_2" skipAttempt="0" name="ExecuteScript" width="138" x="83" y="45" style="label;image=commvault.cte.workflow.activities.ExecuteScript"><inputs val="<inputs>
 <script>import importlib
import subprocess
import sys

# List of required packages
REQUIRED_LIBS = [
 "boto3", "requests", "PyJWT", "PyYAML", "cryptography"
]

def install_package(package):
 """Install package using pip"""
 try:
 subprocess.check_call([sys.executable, "-m", "pip", "install", package])
 except subprocess.CalledProcessError as e:
 print(f"Failed to install {package}: {e}")
 sys.exit(1)

def ensure_packages():
 """Ensure all required packages are installed"""
 for lib in REQUIRED_LIBS:
 try:
 importlib.import_module(lib)
 print(f"{lib} already installed")
 except ImportError:
 print(f"Installing {lib} ...")
 install_package(lib)


ensure_packages()
print("All required packages are installed.")
sys.exit(0)</script>
 <scriptType>Python</scriptType>
</inputs>" /><onExit language="3" script="var exitcode = xpath:{/workflow/ExecuteScript_2/exitCode};
if(exitcode == 0){
	workflow.setVariable("exitCode", 0);
}else{
 	workflow.setVariable("exitCode", 2);
	workflow.setVariable("message", xpath:{/workflow/ExecuteScript_2/commandOutput});

}

" /><transition sourceX="100" sourceY="57" activity="Break_1" displayName="Falied" targetY="55" targetX="331" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/ExecuteScript_2/exitCode} == 1;" /></transition><transition sourceX="100" sourceY="57" activity="ExecuteScript_3" displayName="All Installed" targetY="123" targetX="158" originalStyle="" description="" points="" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0"><condition script="xpath:{/workflow/ExecuteScript_2/exitCode} == 0;" /></transition></Activity><Activity maxRestarts="0" displayName="Run Operation" interactive="0" originalStyle="" jobMode="0" description="Execute a script on a remote machine" timeout="0" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="25" created="1755527270431" uniqueName="ExecuteScript_3" skipAttempt="0" name="ExecuteScript" width="124" x="109" y="125" style="label;image=commvault.cte.workflow.activities.ExecuteScript"><inputs val="<inputs>
 <scriptType>Python</scriptType>
 <script>import importlib
import boto3
import logging
import requests
import yaml
import mimetypes
import os
from pathlib import Path
import argparse
from datetime import datetime, timedelta, UTC
from botocore.exceptions import ClientError
from urllib.parse import urlencode
import jwt
import tempfile
import sys
import json

# Ensure logs directory exists
isWindows = xpath:{/workflow/variables/isWindows}
script_directory = os.path.dirname(os.path.abspath(__file__))
if(isWindows == 1):
	log_directory = os.path.join(script_directory, "../../Log Files")
else:
	log_directory = os.path.join("/var/log/commvault/", "Log_Files")
print(f"Logs will be saved in {log_directory} folder, docusign_backup.log file.")
os.makedirs(log_directory, exist_ok=True)

# Setup logger to file and console
logging.basicConfig(
 level=logging.INFO,
 format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
 handlers=[
 logging.FileHandler(os.path.join(log_directory, "docusign_backup.log"), encoding="utf-8"),
 #logging.StreamHandler() # also log to console
 ]
)

logger = logging.getLogger("docusign_backup")


required_packages = [
 "boto3",
 "requests",
 "jwt", # PyJWT
 "yaml" # PyYAML
]

missing = []

for package in required_packages:
 try:
 importlib.import_module(package)
 except ImportError:
 missing.append(package)

if missing:
 logger.exception(f"Missing required libraries: {', '.join(missing)}")
 logger.info("Run: pip install -r requirements.txt")
 sys.exit(1)

LAST_RUN_KEY = 'docusign-backup/last-run.txt'


def load_config(path):
 with open(path) as f:
 return yaml.safe_load(f)

def load_config_from_json(json_str):
 try:
 return json.loads(json_str)
 except json.JSONDecodeError as e:
 logger.exception(f"Invalid JSON input: {e}")


def get_s3_client(config):
 try:
 return boto3.client(
 's3',
 endpoint_url=config['aws']['endpoint'],
 region_name=config['aws']['region'],
 aws_access_key_id=config['aws']['accessKeyId'],
 aws_secret_access_key=config['aws']['secretAccessKey'],
 config=boto3.session.Config(s3={'addressing_style': 'path'})
 )
 except KeyError as e:
 logger.exception(f"Missing AWS config key: {e}. Please check your configuration file.")
 raise

def get_docusign_auth_server(docu_config):
 try:
 return docu_config['authServer']
 except KeyError:
 logger.exception("Missing 'authServer' in DocuSign config. Please check your configuration file.")
 raise

def get_access_token(config):
 try:
 docu_config = config['docusign']
 #with open(docu_config['privateKeyPath'], 'r') as f:
 # private_key = f.read()
 private_key = r"""xpath:{/workflow/configuration/privateKey}"""
 except Exception as e:
 logger.exception(f"Failed to read private key from workflow configuration privateKey, exception: {e}")
 raise
 now = int(datetime.now(UTC).timestamp())
 try:
 authserver = get_docusign_auth_server(docu_config)
 payload = {
 'iss': docu_config['integrationKey'],
 'sub': docu_config['userId'],
 'aud': authserver,
 'iat': now,
 'exp': now + 120,
 'scope': docu_config['scopes']
 }
 assertion = jwt.encode(payload, private_key, algorithm='RS256')
 headers = {'Content-Type': 'application/x-www-form-urlencoded'}
 body = urlencode({
 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
 'assertion': assertion
 })
 
 except KeyError as e:
 logger.exception(f"Missing DocuSign config key: {e}. Please check your configuration file.")
 raise
 url = f"https://{authserver}/oauth/token"
 res = requests.post(url, headers=headers, data=body)
 res.raise_for_status()
 return res.json()['access_token']


def get_user_info(config, token):
 headers = {'Authorization': f'Bearer {token}'}
 res = requests.get(f"https://{get_docusign_auth_server(config['docusign'])}/oauth/userinfo", headers=headers)
 res.raise_for_status()
 return res.json()['accounts'][0]


def list_completed_envelopes(config, account_id, token, from_date):
 url = f"{get_base_path(config)}/v2.1/accounts/{account_id}/envelopes?from_date={from_date}&amp;status=completed"
 headers = {'Authorization': f'Bearer {token}'}
 res = requests.get(url, headers=headers)
 res.raise_for_status()
 return res.json().get('envelopes', [])


def download_large_document(config, account_id, envelope_id, document_id, token):
 doc_url = f"{get_base_path(config)}/v2.1/accounts/{account_id}/envelopes/{envelope_id}/documents/{document_id}"
 headers = {'Authorization': f'Bearer {token}'}
 temp_stream = None
 try:
 temp_stream = download_to_tempfile(doc_url, headers)
 return temp_stream
 except Exception as e:
 if temp_stream:
 temp_stream.close()
 logger.exception(f"Failed to download document {document_id} from envelope {envelope_id}")
 raise
def get_envelope_metadata(config, account_id, envelope_id, token):
 url = f"{get_base_path(config)}/v2.1/accounts/{account_id}/envelopes/{envelope_id}"
 headers = {'Authorization': f'Bearer {token}'}
 res = requests.get(url, headers=headers)
 res.raise_for_status()
 return res.json()

def get_base_path(config):
 try:
 return config['docusign']['basePath']
 except KeyError:
 logger.exception("Missing 'basePath' in DocuSign config. Please check your configuration file.")
 raise

def get_envelope_documents(config, account_id, envelope_id, token):
 url = f"{get_base_path(config)}/v2.1/accounts/{account_id}/envelopes/{envelope_id}/documents"
 headers = {'Authorization': f'Bearer {token}'}
 res = requests.get(url, headers=headers)
 res.raise_for_status()
 return res.json().get('envelopeDocuments', [])


def upload_to_s3(s3, bucket, key, body, content_type, is_stream=False):
 try:
 if is_stream:
 s3.upload_fileobj(body, bucket, key, ExtraArgs={'ContentType': content_type})
 else:
 s3.put_object(Bucket=bucket, Key=key, Body=body, ContentType=content_type)
 logger.info(f"Uploaded: {key}")
 except Exception as e:
 logger.exception(f"Failed to upload {key} to S3")
 raise


def get_last_run_timestamp(s3, bucket, config):
 try:
 obj = s3.get_object(Bucket=bucket, Key=LAST_RUN_KEY)
 return obj['Body'].read().decode().strip()
 except ClientError:
 if config.get('fromDate'):
 logger.info(f"Using configured fromDate: {config['fromDate']}")
 return config['fromDate']
 else:
 default_date = datetime.now(UTC) - timedelta(days=7)
 default_date = default_date.replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%S")
 logger.info(f"No previous timestamp found. Using default: 7 days ago {default_date}")
 return default_date


def save_backup_timestamp(s3, bucket, ts):
 logger.info(f"Saving last run time to {LAST_RUN_KEY} on S3")
 s3.put_object(Bucket=bucket, Key=LAST_RUN_KEY, Body=ts.encode(), ContentType='text/plain')


def determine_content_type(filename):
 content_type, _ = mimetypes.guess_type(filename)
 return content_type or 'application/octet-stream'

def download_to_tempfile(url, headers):
 response = requests.get(url, headers=headers, stream=True)
 response.raise_for_status()
 temp_file = None
 try:
 temp_file = tempfile.NamedTemporaryFile(delete=True)
 for chunk in response.iter_content(chunk_size=8192):
 if chunk: # filter out keep-alive chunks
 temp_file.write(chunk)
 temp_file.seek(0)
 return temp_file
 except Exception:
 if temp_file:
 temp_file.close()
 raise

def run_backup(config, bucket):
 try:
 logger.info("----- Starting Backup -----")
 print("Backup Summary:")
 s3 = get_s3_client(config)
 token = get_access_token(config)
 account = get_user_info(config, token)
 from_date = get_last_run_timestamp(s3, bucket, config)
 logger.info(f"From S3, we get last backup run time: {from_date}, will get completed envelops from docusign.")
 print(f"From S3, we get last backup run time: {from_date}, will get completed envelops from docusign.")
 envelopes = list_completed_envelopes(config, account['account_id'], token, from_date)
 now_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
 today = now_str.split("T")[0]
 if not envelopes:
 logger.info("There are no completed envelopes found for backup.")
 print("There are no completed envelopes found for backup.")
 else:
 for env in envelopes:
 eid = env['envelopeId']
 logger.info(f"Processing envelop: [{eid}].")
 try:
 meta = get_envelope_metadata(config, account['account_id'], eid, token)
 logger.info(f"Uploading metadata.json to s3 with key {today}/{eid}/metadata.json.")
 print(f"Uploading metadata.json to s3 with key {today}/{eid}/metadata.json.")
 upload_to_s3(s3, bucket, f"{today}/{eid}/metadata.json", yaml.dump(meta).encode(), "application/json")
 logger.info(f"Getting docusign documents for envelop: [{eid}].")
 docs = get_envelope_documents(config, account['account_id'], eid, token)
 for doc in docs:
 name = doc['name']
 logger.info(f"Processing document: [{name}].")
 stream = None
 try:
 logger.info(f"Downloading document from docusign with id: [{doc['documentId']}].")
 stream = download_large_document(config, account['account_id'], eid, doc['documentId'], token)
 content_type = determine_content_type(name)
 logger.info(f"Uploading document to s3 with key {today}/{eid}/{name}.")
 upload_to_s3(s3, bucket, f"{today}/{eid}/{name}", stream, content_type, is_stream=True)
 print(f"Uploaded document to s3 with key {today}/{eid}/{name}.")
 if stream:
 stream.close()
 except Exception as e:
 if stream:
 stream.close()
 logger.error(f"Unable to backup document {name} in envelope {eid} due to error: {e}. Throwing error.")
 print(f"Error: Unable to backup document {name} in envelope {eid} due to error: {e}. Throwing error.")
 raise
 except Exception as e:
 logger.error(f"Failed to process envelope {eid}: {e}")
 print(f"Error: Failed to process envelope {eid} due to error: {e}. Throwing error.")
 raise
 logger.info(f"Saving backup run time: [{now_str}] to S3.")
 save_backup_timestamp(s3, bucket, now_str)
 logger.info("Backup completed.")
 print("Backup completed.")

 except Exception as e:
 logger.exception("Backup process failed.")
 print("Backup process failed.")
 raise

def list_backups(config, bucket, prefix=""):
 """
 Lists all backup folders and files on or after a given date (YYYY-MM-DD).
 """
 logger.info("----- Starting List Backup -----")
 
 s3 = get_s3_client(config)

 if prefix and not prefix.endswith("/"):
 prefix += "/"

 paginator = s3.get_paginator("list_objects_v2")
 operation_params = {
 "Bucket": bucket,
 "Prefix": prefix,
 "Delimiter": "/"
 }

 logger.info(f"Listing backup folders from: [{prefix}], bucket: [{bucket}]")

 found = False
 valid_folders = []

 for page in paginator.paginate(**operation_params):
 if "CommonPrefixes" in page:
 #print(f"Common Prefixes {str(page['CommonPrefixes'])}")
 for cp in page["CommonPrefixes"]:
 folder = cp["Prefix"].rstrip("/")
 valid_folders.append(folder)
 
 if not valid_folders:
 #print("No matching backups found.")
 logger.info("No matching backups found.")
 return

 for folder in sorted(valid_folders):
 #print(f"{folder}")
 logger.info(f"{folder}")

 # List contents inside the folder
 inner_params = {
 "Bucket": bucket,
 "Prefix": folder + "/"
 }
 for subpage in paginator.paginate(**inner_params):
 if "Contents" in subpage:
 for obj in subpage["Contents"]:
 if not obj["Key"].endswith("/"):
 #print(f" |___{obj['Key'].split(folder + '/')[1]}")
 logger.info(f" |___ {obj['Key']}")

 logger.info("----- END List Backup -----")

def restore_backup(config, bucket, prefix):
 logger.info("----- Starting Restore From Backup -----")
 download_root = "docusign-restores"
 logger.info(f"Files will be restored to {script_directory}/{download_root}/ directory.")
 print(f"Files will be restored to {script_directory}/{download_root}/ directory.")
 try:
 s3 = get_s3_client(config)
 paginator = s3.get_paginator("list_objects_v2")
 logger.info(f"Restoring backup from: {prefix or 'beginning'}")
 print(f"Restoring backup from: {prefix or 'beginning'}")

 for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
 for obj in page.get("Contents", []):
 key = obj["Key"]

 # Build relative path under 'docusign-restores/' based on full key
 local_path = os.path.join(script_directory, download_root, *key.split('/'))
 absPath = os.path.realpath(local_path)

 # Ensure folders exist, not including the filename
 os.makedirs(os.path.dirname(local_path), exist_ok=True)
 # Download file
 with open(local_path, "wb") as f:
 body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
 f.write(body)

 logger.info(f"Restored: {key} --&gt; {absPath}")
 print(f"Restored: {key} --&gt; {absPath}")
 except Exception as e:
 logger.exception(f"Exception while restore from {prefix}: {e}")
 print(f"Exception while restore from {prefix}: {e}")
 logger.info("----- End Restore From Backup -----")
 print("----- End Restore From Backup -----")



exitCode = 0
try:
 logger.info("----- Starting DocuSign Backup Utility-----")
 parser = argparse.ArgumentParser(
 description="DocuSign S3 Backup Utility",
 formatter_class=argparse.RawTextHelpFormatter
 )
 parser.add_argument("--option", help="Option can have values from these three values:Backup, List, Restore. Backup is for Running DocuSign backup and upload to S3. List is for listing all paths from backup. Restore is for restoring a path from backup to a local folder. For this restore option, you need to pass path agrument with path.", default="Backup")
 parser.add_argument("--path", help="S3 prefix path for restore e.g., 2025-07-01/", default="")
 #print(f"Logs will be saved in logs/docusign_backup.log file.")
 args = parser.parse_args()

 config = load_config_from_json(r"""xpath:{/workflow/configuration/configJson}""")
 logger.info("Config loaded from JSON string.")
 #print(f"Using config: {config}")
 if not config:
 #print(f"Failed to load config from JSON string. Please save configJson in the workflow configuration.")
 logger.error(f"Failed to load config from JSON string. Please save configJson in the workflow configuration.")
 exitCode = 1
 else:
 bucket = config["aws"]["bucket"]
 option = args.option
 logger.info(f"Using option: {option}")
 if not option or option.lower() == "backup":
 print("---Running Backup---")
 run_backup(config, bucket)
 elif option and option.lower() == "list":
 print("---List Backup---")
 list_backups(config, bucket, args.path or "")
 elif option and option.lower() == "restore":
 if not args.path:
 logger.error("--path is required with Restore option")
 exitCode = 1
 else:
 print("---Restore From Backup---")
 restore_backup(config, bucket, args.path)
 else:
 logger.error("Invalid option provided, available options are Backup, List, Restore")
 exitCode = 1
except Exception as e:
 logger.exception(f"An error occurred during the DocuSign backup utility process: {e}")
 exitCode = 1
finally:
 logger.info("----- END DocuSign Backup Utility-----")
 sys.exit(exitCode)
</script>
 <arguments>--option xpath:{/workflow/inputs/operationType} --path "xpath:{/workflow/inputs/path}"</arguments>
</inputs>" /><onExit language="3" script="var exitcode = xpath:{/workflow/ExecuteScript_3/exitCode};
if(exitcode == 0){
	workflow.setVariable("exitCode", 0);
	workflow.setVariable("message", xpath:{/workflow/ExecuteScript_3/commandOutput});

}else{
	workflow.setVariable("exitCode", 3);
	workflow.setVariable("message", "There are some error while running docusing backup, please check docusign_backup.log for more details. Log file will be available under Commvault install dir/Log Files/ folder on the workflow engine machine.");

}" /></Activity><Activity displayName="Break" interactive="0" originalStyle="" jobMode="0" description="interrupts a process block execution" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="35" created="1756103402678" uniqueName="Break_1" skipAttempt="0" name="Break" width="80" x="291" y="38" style="label;image=commvault.cte.workflow.activities.InterruptActivity"><inputs val="<inputs/>" /></Activity></superProcess><activitySchema><outputs name="outputs" /><inputs name="inputs" /></activitySchema></Activity><Activity maxRestarts="0" displayName="Process Request" interactive="0" originalStyle="" jobMode="0" description="executes a defined process block within the workflow" waitSetting="0" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="26" created="1756103634578" uniqueName="ExecuteProcessBlock_1" skipAttempt="0" name="ExecuteProcessBlock" width="111" x="422" y="121" style="label;image=commvault.cte.workflow.activities.ExecuteSuperProcess"><inputs val="<inputs>
 <inputs/>
 <processBlock class="commvault.cte.workflow.dom.WorkflowElement" _list_="false">ProcessBlock_1</processBlock>
 <outputs class="commvault.cte.workflow.dom.WorkflowElement" _list_="false"/>
</inputs>" /><activitySchema><outputs name="ProcessBlock_1"><children name="Start_2" /><children name="ExecuteScript_2"><children className="java.lang.Integer" type="{http://www.w3.org/2001/XMLSchema}integer" inputType="java.lang.Integer" documentation="the exitCode recieved from executing the command" name="exitCode" /><children className="java.lang.Integer" type="{http://www.w3.org/2001/XMLSchema}integer" inputType="java.lang.Integer" documentation="the return code recieved from completion of the command" name="errorCode" /><children className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" inputType="java.lang.String" documentation="the output from the command or error message if it failed" name="commandOutput" /></children><children name="ExecuteScript_3"><children className="java.lang.Integer" type="{http://www.w3.org/2001/XMLSchema}integer" inputType="java.lang.Integer" documentation="the exitCode recieved from executing the command" name="exitCode" /><children className="java.lang.Integer" type="{http://www.w3.org/2001/XMLSchema}integer" inputType="java.lang.Integer" documentation="the return code recieved from completion of the command" name="errorCode" /><children className="java.lang.String" type="{http://www.w3.org/2001/XMLSchema}string" inputType="java.lang.String" documentation="the output from the command or error message if it failed" name="commandOutput" /></children><children name="Break_1" /></outputs><inputs name="inputs" /></activitySchema><transition sourceX="463" sourceY="119" activity="ReleaseLock_1" targetY="121" targetX="308" originalStyle="" description="" points="386,135" x="0" y="0" transitionIndex="0" style="defaultEdge" value="ANY" commented="0" status="0" /></Activity><Activity maxRestarts="0" displayName="Valid Inputs?" description="activity to execute code snippets in the selected language" continueOnFailure="0" namespaceUri="commvault.cte.workflow.activities" commented="0" height="15" created="1756362561606" breakpoint="0" uniqueName="Script_6" name="Script" width="92" x="27" y="119"><inputs val="<inputs>
 <script language="3" script="let optype = xpath:{/workflow/inputs/operationType};&#xA;const pathPrefix = xpath:{/workflow/inputs/path};&#xA;logger.info(&quot;Opreation Type Provided: &quot; + optype);&#xA;logger.info(&quot;Path Provided: &quot; + pathPrefix);&#xA;let error = false;&#xA;if(!optype){&#xA;&#x9;optype = &quot;backup&quot;;&#xA;}&#xA;optype = optype.toLowerCase();&#xA;if(!(optype == &quot;backup&quot; ||&#xA; optype == &quot;list&quot; ||&#xA; optype == &quot;restore&quot;)){&#xA; error = true;&#xA; logger.error(&quot;Invalid Opreation Type Provided: &quot; + optype + &quot;. Valid options are Backup, List, Restore.&quot;);&#xA; workflow.setVariable(&quot;exitCode&quot;, 1);&#xA; workflow.setVariable(&quot;message&quot;, &quot;Invalid opreation type provided.&quot;);&#xA;}&#xA;&#xA;if(optype == &quot;restore&quot;){&#xA; if(!pathPrefix || pathPrefix.trim() == &quot;&quot;){&#xA; logger.error(&quot;path is required with Restore option&quot;);&#xA; error = true;&#xA; workflow.setVariable(&quot;exitCode&quot;, 2);&#xA; workflow.setVariable(&quot;message&quot;, &quot;Provide path for resstore.&quot;);&#xA; }&#xA;}&#xA;workflow.setVariable(&quot;isValidInputs&quot;, (error == false));"/>
</inputs>" /><activitySchema><outputs><children className="java.lang.Object" type="{http://www.w3.org/2001/XMLSchema}anyType" inputType="java.lang.Object" name="output" /></outputs><inputs /></activitySchema><transition activity="AcquireLock_1" displayName="Yes" points="110,93" value="ANY"><condition script="xpath:{/workflow/variables/isValidInputs} == true;" /></transition><transition activity="WorkflowEnd_1" displayName="Invalid Inputs" points="64,244" value="ANY"><condition script="xpath:{/workflow/variables/isValidInputs} == false;" /></transition></Activity><formProperties css="" javaScript="" pageMode="0" formVersion="0"><rules /></formProperties><minCommCellVersion servicePack="0" releaseID="16" /></Workflow_WorkflowDefinition>