Skip to content

Commit 101504d

Browse files
authored
DEVOPS-652 feat: extend create scratch org to accept snapshot as extra param that allow to create an org from a template (#2)
* DEVOPS-652 feat: extend create scratch org to accept snapshot as extra param that allow to create an org from a template * DEVOPS-652 test: add unit test for creating org with snapshot configuration * DEVOPS-652 refactor: remove snapshot assignment from org config during creation and clean up the file * DEVOPS-652 fix: improve error handling for temporary config file creation in ScratchOrgConfig * DEVOPS-652 fix: remove snapshot from config when using snapshot in ScratchOrgConfig
1 parent f179b0c commit 101504d

4 files changed

Lines changed: 156 additions & 60 deletions

File tree

cumulusci/core/config/scratch_org_config.py

Lines changed: 102 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from cumulusci.core.sfdx import sfdx
1616

17+
import tempfile
1718

1819
class ScratchOrgConfig(SfdxOrgConfig):
1920
"""Salesforce DX Scratch org configuration"""
@@ -24,6 +25,7 @@ class ScratchOrgConfig(SfdxOrgConfig):
2425
password_failed: bool
2526
devhub: str
2627
release: str
28+
snapshot: str
2729

2830
createable: bool = True
2931

@@ -62,81 +64,119 @@ def days_alive(self) -> Optional[int]:
6264

6365
def create_org(self) -> None:
6466
"""Uses sf org create scratch to create the org"""
65-
if not self.config_file:
66-
raise ScratchOrgException(
67-
f"Scratch org config {self.name} is missing a config_file"
67+
try:
68+
if not self.config_file:
69+
raise ScratchOrgException(
70+
f"Scratch org config {self.name} is missing a config_file"
71+
)
72+
if not self.scratch_org_type:
73+
self.config["scratch_org_type"] = "workspace"
74+
75+
args: List[str] = self._build_org_create_args()
76+
extra_args = os.environ.get("SFDX_ORG_CREATE_ARGS", "")
77+
p: sarge.Command = sfdx(
78+
f"org create scratch --json {extra_args}",
79+
args=args,
80+
username=None,
81+
log_note="Creating scratch org",
6882
)
69-
if not self.scratch_org_type:
70-
self.config["scratch_org_type"] = "workspace"
83+
stdout = p.stdout_text.read()
84+
stderr = p.stderr_text.read()
85+
86+
def raise_error() -> NoReturn:
87+
message = f"{FAILED_TO_CREATE_SCRATCH_ORG}: \n{stdout}\n{stderr}"
88+
try:
89+
output = json.loads(stdout)
90+
if (
91+
output.get("message") == "The requested resource does not exist"
92+
and output.get("name") == "NOT_FOUND"
93+
):
94+
raise ScratchOrgException(
95+
"The Salesforce CLI was unable to create a scratch org. Ensure you are connected using a valid API version on an active Dev Hub."
96+
)
97+
except json.decoder.JSONDecodeError:
98+
raise ScratchOrgException(message)
7199

72-
args: List[str] = self._build_org_create_args()
73-
extra_args = os.environ.get("SFDX_ORG_CREATE_ARGS", "")
74-
p: sarge.Command = sfdx(
75-
f"org create scratch --json {extra_args}",
76-
args=args,
77-
username=None,
78-
log_note="Creating scratch org",
79-
)
80-
stdout = p.stdout_text.read()
81-
stderr = p.stderr_text.read()
100+
raise ScratchOrgException(message)
82101

83-
def raise_error() -> NoReturn:
84-
message = f"{FAILED_TO_CREATE_SCRATCH_ORG}: \n{stdout}\n{stderr}"
102+
result = {} # for type checker.
103+
if p.returncode:
104+
raise_error()
85105
try:
86-
output = json.loads(stdout)
87-
if (
88-
output.get("message") == "The requested resource does not exist"
89-
and output.get("name") == "NOT_FOUND"
90-
):
91-
raise ScratchOrgException(
92-
"The Salesforce CLI was unable to create a scratch org. Ensure you are connected using a valid API version on an active Dev Hub."
93-
)
106+
result = json.loads(stdout)
107+
94108
except json.decoder.JSONDecodeError:
95-
raise ScratchOrgException(message)
109+
raise_error()
96110

97-
raise ScratchOrgException(message)
111+
if (
112+
not (res := result.get("result"))
113+
or ("username" not in res)
114+
or ("orgId" not in res)
115+
):
116+
raise_error()
98117

99-
result = {} # for type checker.
100-
if p.returncode:
101-
raise_error()
102-
try:
103-
result = json.loads(stdout)
104-
105-
except json.decoder.JSONDecodeError:
106-
raise_error()
107-
108-
if (
109-
not (res := result.get("result"))
110-
or ("username" not in res)
111-
or ("orgId" not in res)
112-
):
113-
raise_error()
114-
115-
if res["username"] is None:
116-
raise ScratchOrgException(
117-
"SFDX claimed to be successful but there was no username "
118-
"in the output...maybe there was a gack?"
119-
)
118+
if res["username"] is None:
119+
raise ScratchOrgException(
120+
"SFDX claimed to be successful but there was no username "
121+
"in the output...maybe there was a gack?"
122+
)
120123

121-
self.config["org_id"] = res["orgId"]
122-
self.config["username"] = res["username"]
124+
self.config["org_id"] = res["orgId"]
125+
self.config["username"] = res["username"]
123126

124-
self.config["date_created"] = datetime.datetime.utcnow()
127+
self.config["date_created"] = datetime.datetime.utcnow()
125128

126-
self.logger.error(stderr)
129+
self.logger.error(stderr)
127130

128-
self.logger.info(
129-
f"Created: OrgId: {self.config['org_id']}, Username:{self.config['username']}"
130-
)
131+
self.logger.info(
132+
f"Created: OrgId: {self.config['org_id']}, Username:{self.config['username']}"
133+
)
131134

132-
if self.config.get("set_password"):
133-
self.generate_password()
135+
if self.config.get("set_password"):
136+
self.generate_password()
134137

135-
# Flag that this org has been created
136-
self.config["created"] = True
138+
# Flag that this org has been created
139+
self.config["created"] = True
140+
finally:
141+
# Clean up temporary config file if it exists
142+
if hasattr(self, '_tmp_config') and self._tmp_config and os.path.exists(self._tmp_config):
143+
try:
144+
os.unlink(self._tmp_config)
145+
except Exception as e:
146+
self.logger.warning(f"Failed to clean up temporary config file: {e}")
137147

138148
def _build_org_create_args(self) -> List[str]:
139-
args = ["-f", self.config_file, "-w", "120"]
149+
config_file = self.config_file
150+
self._tmp_config = None
151+
if self.snapshot and self.config_file:
152+
# When using snapshot, remove features, edition and snapshot from config
153+
with open(self.config_file, "r") as f:
154+
org_config = json.load(f)
155+
org_config.pop("features", None)
156+
org_config.pop("edition", None)
157+
org_config.pop("snapshot", None)
158+
159+
# Create temporary config file
160+
tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
161+
self._tmp_config = tmp.name
162+
163+
# Try catch error here to avoid leaving temp file around
164+
try:
165+
json.dump(org_config, tmp, indent=4)
166+
tmp.close()
167+
config_file = tmp.name
168+
self._tmp_config = config_file
169+
except Exception:
170+
tmp_name = tmp.name
171+
try:
172+
tmp.close()
173+
except Exception:
174+
pass
175+
if os.path.exists(tmp_name):
176+
os.remove(tmp_name)
177+
raise
178+
179+
args = ["-f", config_file, "-w", "120"]
140180
devhub_username: Optional[str] = self._choose_devhub_username()
141181
if devhub_username:
142182
args += ["--target-dev-hub", devhub_username]
@@ -157,6 +197,8 @@ def _build_org_create_args(self) -> List[str]:
157197
args += [f"--admin-email={self.email_address}"]
158198
if self.default:
159199
args += ["--set-default"]
200+
if self.snapshot:
201+
args += [f"--snapshot={self.snapshot}"]
160202

161203
return args
162204

cumulusci/core/config/tests/test_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
BaseProjectConfig,
1818
BaseTaskFlowConfig,
1919
OrgConfig,
20+
ScratchOrgConfig,
2021
ServiceConfig,
2122
UniversalConfig,
2223
)
@@ -1767,3 +1768,51 @@ def test_resolve_04t_dependencies__not_installed(self):
17671768
config.resolve_04t_dependencies(
17681769
[PackageNamespaceVersionDependency(namespace="dep", version="1.0")]
17691770
)
1771+
1772+
def test_create_org_with_snapshot(self):
1773+
"""Test that creating an org with a snapshot:
1774+
1. Stores the snapshot value in config
1775+
2. Removes features and edition from config when using snapshot
1776+
3. Adds the --snapshot argument to the command
1777+
4. Creates and cleans up the temporary config file
1778+
"""
1779+
with temporary_dir() as d:
1780+
config_path = os.path.join(d, "scratch_def.json")
1781+
with open(config_path, "w") as f:
1782+
json.dump(
1783+
{
1784+
"edition": "Developer",
1785+
"features": ["EnableSetPasswordInApi"],
1786+
"settings": {"securitySettings": {"passwordPolicies": {"enableSetPasswordInApi": True}}},
1787+
},
1788+
f,
1789+
)
1790+
1791+
config = ScratchOrgConfig(
1792+
{
1793+
"config_file": config_path,
1794+
"snapshot": "test_snapshot",
1795+
"days": 1,
1796+
"email": "test@example.com",
1797+
},
1798+
"test",
1799+
)
1800+
1801+
with mock.patch("cumulusci.core.config.scratch_org_config.sfdx") as sfdx:
1802+
sfdx.return_value.returncode = 0
1803+
sfdx.return_value.stdout_text.read.return_value = json.dumps(
1804+
{"result": {"username": "test", "orgId": "test"}}
1805+
)
1806+
config.create_org()
1807+
1808+
# Verify snapshot was stored in config
1809+
assert config.config["snapshot"] == "test_snapshot"
1810+
1811+
# Verify the command included the snapshot argument
1812+
sfdx.assert_called_once()
1813+
args = sfdx.call_args[1]["args"]
1814+
assert "--snapshot=test_snapshot" in args
1815+
1816+
# Verify temporary config file was created and cleaned up
1817+
tmp_config = getattr(config, "_tmp_config", None)
1818+
assert not tmp_config or not os.path.exists(tmp_config)

cumulusci/schema/cumulusci.jsonschema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,10 @@
435435
"title": "Release",
436436
"enum": ["preview", "previous"],
437437
"type": "string"
438+
},
439+
"snapshot": {
440+
"title": "Snapshot",
441+
"type": "string"
438442
}
439443
},
440444
"additionalProperties": false

cumulusci/utils/yaml/cumulusci_yml.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ class ScratchOrg(CCIDictModel):
151151
setup_flow: str = None
152152
noancestors: bool = None
153153
release: Literal["preview", "previous"] = None
154+
snapshot: str = None
154155

155156

156157
class Orgs(CCIDictModel):

0 commit comments

Comments
 (0)