Skip to content

Commit 8b6a533

Browse files
authored
Feat/addn automations (#3)
* wip: addn context * wip: modularize the automations * refactor: update automations * wip: support for modular playwright automations, expanded utils * fix: project creation fixed after user register * feat: support for new project nested changes * feat: support for multiple projects * fix: sync bug and other small enhancements * fix: sync bug * feat: support for test suite + fixed sync and creating new projects * fix: api key automation
1 parent 789b694 commit 8b6a533

22 files changed

+681
-268
lines changed

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ build_appwrite_playwright:
1010

1111
# push_appwrite_cli:
1212
# docker tag appwrite-cli:latest appwrite-cli:$(APPWRITE_CLI_TAG)
13-
# docker push appwrite-cli:$(APPWRITE_CLI_TAG)
13+
# docker push appwrite-cli:$(APPWRITE_CLI_TAG)
14+
clean-tests:
15+
appwrite-lab stop test-lab
16+
17+
tests:
18+
source .venv/bin/activate && pytest -m e2e

appwrite_lab/_orchestrator.py

Lines changed: 116 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
import tempfile
77
from pathlib import Path
88

9-
from appwrite_lab.automations.models import BaseVarModel
9+
from appwrite_lab.automations.models import BaseVarModel, AppwriteAPIKeyCreation
1010
from ._state import State
1111
from dataclasses import dataclass
12-
from .models import LabService, Automation, SyncType
12+
from .models import Lab, Automation, SyncType, Project
1313
from dotenv import dotenv_values
1414
from appwrite_lab.utils import console
1515
from .utils import is_cli
@@ -22,13 +22,16 @@ class Response:
2222
message: str
2323
data: any = None
2424
error: bool = False
25+
_print_data: bool = False
2526

2627
def __post_init__(self):
2728
if is_cli:
2829
if self.error:
2930
console.print(self.message, style="red")
3031
else:
31-
console.print(self.message, style="green")
32+
console.print(
33+
self.message if not self._print_data else self.data, style="green"
34+
)
3235

3336

3437
@dataclass
@@ -51,16 +54,18 @@ def get_labs(self):
5154
Get all labs.
5255
"""
5356
labs: dict = self.state.get("labs", {})
54-
return [LabService(**lab) for lab in labs.values()]
57+
return [Lab(**lab) for lab in labs.values()]
5558

56-
def get_lab(self, name: str) -> LabService | None:
59+
def get_lab(self, name: str) -> Lab | None:
5760
"""
5861
Get a lab by name.
5962
"""
6063
labs: dict = self.state.get("labs", {})
6164
if not (lab := labs.get(name, None)):
6265
return None
63-
return LabService(**lab)
66+
projects = lab.get("projects", {})
67+
_projects = {key: Project(**project) for key, project in projects.items()}
68+
return Lab(**{**lab, "projects": _projects})
6469

6570
def get_formatted_labs(self, collapsed: bool = False):
6671
"""
@@ -69,17 +74,20 @@ def get_formatted_labs(self, collapsed: bool = False):
6974
labs: dict = self.state.get("labs", {})
7075
if collapsed:
7176
headers = ["Name", "Version", "URL", "Admin Email", "Project ID", "API Key"]
72-
return headers, [
73-
[
74-
val["name"],
75-
val["version"],
76-
val["url"],
77-
val["admin_email"],
78-
val["project_id"],
79-
val["api_key"],
80-
]
81-
for val in labs.values()
82-
]
77+
data = []
78+
for val in labs.values():
79+
project = Project(**val.get("projects", {}).get("default"))
80+
data.append(
81+
[
82+
val["name"],
83+
val["version"],
84+
val["url"],
85+
val["admin_email"],
86+
project.project_id,
87+
project.api_key,
88+
]
89+
)
90+
return headers, data
8391
return labs
8492

8593
def get_running_pods(self):
@@ -146,19 +154,6 @@ def _deploy_service(
146154
]
147155
return self._run_cmd_safely(cmd, envs=new_env)
148156

149-
def _run_cmd_safely(self, cmd: list[str], envs: dict[str, str] = {}):
150-
"""
151-
Private function to run a command and return the output.
152-
153-
Args:
154-
cmd: The command to run.
155-
envs: The environment variables to set.
156-
"""
157-
try:
158-
return run_cmd(cmd, envs)
159-
except OrchestratorError as e:
160-
return Response(error=True, message=f"Failed to run command: {e}", data=e)
161-
162157
def deploy_appwrite_lab(
163158
self, name: str, version: str, port: int, meta: dict[str, str]
164159
):
@@ -195,7 +190,7 @@ def deploy_appwrite_lab(
195190
if port != 80:
196191
env_vars["_APP_PORT"] = str(port)
197192

198-
# What actually deploys the service
193+
# What actually deploys the initial appwrite service
199194
cmd_res = self._deploy_service(
200195
project=name, template_path=template_path, env_vars=env_vars
201196
)
@@ -213,26 +208,39 @@ def deploy_appwrite_lab(
213208
)
214209
port = extract_port_from_pod_info(traefik_pod)
215210
url = f"http://localhost:{port}"
216-
print("url", url)
217211
else:
218212
url = ""
219-
lab = LabService(
213+
proj_id = appwrite_config.pop("project_id", None)
214+
proj_name = appwrite_config.pop("project_name", None)
215+
kwargs = {
216+
**appwrite_config,
217+
"projects": {"default": Project(proj_id, proj_name, None)},
218+
}
219+
lab = Lab(
220220
name=name,
221221
version=version,
222222
url=url,
223-
**appwrite_config,
223+
**kwargs,
224224
)
225225

226226
lab.generate_missing_config()
227+
# ensure project_id and project_name are set
228+
proj_id = proj_id or lab.projects.get("default").project_id
229+
proj_name = proj_name or lab.projects.get("default").project_name
230+
227231
# Deploy playwright automations for creating user and API key
228232
api_key_res = self.deploy_playwright_automation(
229-
lab, Automation.CREATE_USER_AND_API_KEY
233+
lab=lab,
234+
automation=Automation.CREATE_USER_AND_API_KEY,
235+
model=AppwriteAPIKeyCreation(
236+
key_name="default_key", project_name=proj_name, key_expiry="Never"
237+
),
230238
)
231239
if type(api_key_res) is Response and api_key_res.error:
232240
api_key_res.message = f"Lab '{name}' deployed, but failed to create API key. Spinning down lab."
233241
self.teardown_service(name)
234242
return api_key_res
235-
lab.api_key = api_key_res.data
243+
lab.projects["default"].api_key = api_key_res.data
236244

237245
stored_labs: dict = self.state.get("labs", {}).copy()
238246
stored_labs[name] = asdict(lab)
@@ -246,10 +254,13 @@ def deploy_appwrite_lab(
246254

247255
def deploy_playwright_automation(
248256
self,
249-
lab: LabService,
257+
lab: Lab,
250258
automation: Automation,
259+
project: Project | None = None,
251260
model: BaseVarModel = None,
252261
args: list[str] = [],
262+
*,
263+
print_data: bool = False,
253264
) -> str | Response:
254265
"""
255266
Deploy playwright automations on a lab (very few automations supported).
@@ -262,11 +273,16 @@ def deploy_playwright_automation(
262273
Args:
263274
lab: The lab to deploy the automations for.
264275
automation: The automation to deploy.
265-
model: The model to use for the automation.
276+
model: The model args to use for the automation.
277+
args: Extra arguments to the container.
278+
project: The project to use for the automation, if not provided, the default project is used.
279+
280+
Keyword Args:
281+
print_data: Whether to print the data of the response instead of the message.
266282
"""
267283
automation = automation.value
268284
function = (
269-
Path(__file__).parent / "automations" / "functions" / f"{automation}.py"
285+
Path(__file__).parent / "automations" / "scripts" / f"{automation}.py"
270286
)
271287
if not function.exists():
272288
return Response(
@@ -276,28 +292,34 @@ def deploy_playwright_automation(
276292
)
277293
automation_dir = Path(__file__).parent / "automations"
278294
container_work_dir = "/work/automations"
295+
project = project or lab.projects["default"]
296+
project = Project(**project) if isinstance(project, dict) else project
297+
proj_id = project.project_id
298+
api_key = project.api_key
299+
279300
env_vars = {
280301
"APPWRITE_URL": lab.url,
281-
"APPWRITE_PROJECT_ID": lab.project_id,
302+
"APPWRITE_PROJECT_ID": proj_id,
282303
"APPWRITE_ADMIN_EMAIL": lab.admin_email,
283304
"APPWRITE_ADMIN_PASSWORD": lab.admin_password,
305+
"APPWRITE_API_KEY": api_key,
306+
"APPWRITE_PROJECT_NAME": project.project_name,
284307
"HOME": container_work_dir,
285308
**(model.as_dict_with_prefix("APPWRITE") if model else {}),
286309
}
287-
envs = " ".join([f"{key}={value}" for key, value in env_vars.items()])
288310
docker_env_args = []
289311
for key, value in env_vars.items():
290312
docker_env_args.extend(["-e", f"{key}={value}"])
291313
with tempfile.TemporaryDirectory() as temp_dir:
292314
shutil.copytree(automation_dir, temp_dir, dirs_exist_ok=True)
293-
function = Path(temp_dir) / "automations" / "functions" / f"{automation}.py"
315+
function = Path(temp_dir) / "automations" / "scripts" / f"{automation}.py"
294316

295317
cmd = [
296318
self.util,
297319
"run",
298320
"--network",
299321
"host",
300-
"--rm",
322+
# "--rm",
301323
"-u",
302324
f"{os.getuid()}:{os.getgid()}",
303325
"-v",
@@ -307,7 +329,7 @@ def deploy_playwright_automation(
307329
APPWRITE_PLAYWRIGHT_IMAGE,
308330
"python",
309331
"-m",
310-
f"automations.functions.{automation}",
332+
f"automations.scripts.{automation}",
311333
]
312334
cmd_res = self._run_cmd_safely(cmd)
313335
if type(cmd_res) is Response and cmd_res.error:
@@ -317,14 +339,16 @@ def deploy_playwright_automation(
317339
return cmd_res
318340
# If successful, any data should be mounted as result.txt
319341
result_file = Path(temp_dir) / "result.txt"
342+
_data = None
320343
if result_file.exists():
321344
with open(result_file, "r") as f:
322-
data = f.read()
323-
return Response(
324-
error=False,
325-
message=f"Playwright automation{automation} deployed successfully.",
326-
data=data,
327-
)
345+
_data = f.read()
346+
return Response(
347+
error=False,
348+
message=f"Playwright automation {automation} deployed successfully.",
349+
data=_data,
350+
_print_data=print_data,
351+
)
328352

329353
def teardown_service(self, name: str):
330354
"""
@@ -340,7 +364,16 @@ def teardown_service(self, name: str):
340364
message=f"Nothing to stop by name of '{name}'.",
341365
data=None,
342366
)
343-
cmd = [self.compose, "-p", name, "down", "-v"]
367+
cmd = [
368+
self.compose,
369+
"-p",
370+
name,
371+
"down",
372+
"-v",
373+
"--timeout",
374+
"0",
375+
"--remove-orphans",
376+
]
344377
cmd_res = self._run_cmd_safely(cmd)
345378
if type(cmd_res) is Response and cmd_res.error:
346379
cmd_res.message = f"Failed to teardown lab {name}. \
@@ -383,6 +416,19 @@ def get_pods_by_project(self, project_name: str):
383416
)
384417
return _stdout_to_json(result.stdout)
385418

419+
def _run_cmd_safely(self, cmd: list[str], envs: dict[str, str] = {}):
420+
"""
421+
Private function to run a command and return the output.
422+
423+
Args:
424+
cmd: The command to run.
425+
envs: The environment variables to set.
426+
"""
427+
try:
428+
return run_cmd(cmd, envs)
429+
except OrchestratorError as e:
430+
return Response(error=True, message=f"{str(e)}", data=str(e))
431+
386432
@property
387433
def util(self):
388434
return shutil.which(self.backend)
@@ -408,20 +454,25 @@ def run_cmd(cmd: list[str], envs: dict[str, str] | None = None):
408454
cmd: The command to run.
409455
envs: The environment variables to set.
410456
"""
411-
try:
412-
result = subprocess.run(
413-
cmd,
414-
capture_output=True,
415-
text=True,
416-
env={**os.environ, **envs} if envs else None,
417-
)
418-
if result.returncode != 0:
419-
raise OrchestratorError(
420-
f"An error occured running a command: {result.stderr}"
421-
)
422-
return result
423-
except Exception as e:
424-
raise OrchestratorError(f"An error occured running a command: {e}")
457+
result = subprocess.run(
458+
cmd,
459+
capture_output=True,
460+
text=True,
461+
env={**os.environ, **envs} if envs else None,
462+
)
463+
if result.returncode != 0:
464+
error_msg = result.stderr.strip()
465+
if error_msg:
466+
# Look for the actual error message in the traceback
467+
lines = error_msg.split("\n")
468+
for line in reversed(lines):
469+
if "PlaywrightAutomationError:" in line or "OrchestratorError:" in line:
470+
# Extract just the error message part
471+
if ":" in line:
472+
error_msg = line.split(":", 1)[1].strip()
473+
break
474+
raise OrchestratorError(f"An error occured running a command: {error_msg}")
475+
return result
425476

426477

427478
def get_template_versions():

0 commit comments

Comments
 (0)