diff --git a/Makefile b/Makefile index d5c6651..1689252 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,20 @@ patch_templates: rm -rf $$VENV tests: - uv run pytest -rs -m e2e + @set -e; \ + uv run pytest -rs -v -s -m e2e || exit 0; \ + $(MAKE) clean-tests + clean-tests: - @source .venv/bin/activate && \ + @. .venv/bin/activate && \ appwrite-lab stop test-lab -.PHONY: patch_templates tests clean-tests build_appwrite_cli build_appwrite_playwright \ No newline at end of file +build_twilio_shim: + cd twilio-shim && docker buildx build -t docker.io/syntaxsdev/twilio-shim:latest -f Dockerfile . --load + +push_twilio_shim: + cd twilio-shim && docker buildx build -t docker.io/syntaxsdev/twilio-shim:latest -f Dockerfile . --load --push + + +.PHONY: patch_templates tests clean-tests build_appwrite_cli build_appwrite_playwright build_twilio_shim push_twilio_shim \ No newline at end of file diff --git a/appwrite_lab/_orchestrator.py b/appwrite_lab/_orchestrator.py index 30eacaa..6ca1bdb 100644 --- a/appwrite_lab/_orchestrator.py +++ b/appwrite_lab/_orchestrator.py @@ -132,10 +132,11 @@ def get_running_pods_by_project(self, name: str): } return pods - def _deploy_service( + def _deploy_compose_service( self, project: str, - template_path: Path, + template_paths: list[Path] | Path, + env_file: Path | None = None, env_vars: dict[str, str] = {}, extra_args: list[str] = [], ): @@ -144,22 +145,29 @@ def _deploy_service( Args: project: The name of the project to deploy the service to. - template_path: The path to the template to use for the service. + template_paths: The path to the template to use for the service. env_vars: The environment variables to set. + env_file: The path to the environment file to use for the service. extra_args: Extra arguments to pass to the compose command. """ - new_env = {**os.environ, **env_vars} + if isinstance(template_paths, Path): + template_paths = [template_paths] + + file_overlays = [] + for path in template_paths: + file_overlays.extend(["-f", str(path)]) + cmd = [ *self.compose, - "-f", - template_path, + *file_overlays, + *(["--env-file", str(env_file)] if env_file else []), "-p", project, *extra_args, "up", "-d", ] - return self._run_cmd_safely(cmd, envs=new_env) + return self._run_cmd_safely(cmd, envs=env_vars) def deploy_appwrite_lab( self, @@ -188,24 +196,38 @@ def deploy_appwrite_lab( error=True, message=f"Lab '{name}' already deployed.", data=None ) converted_version = version.replace(".", "_") + + # Gather paths template_path = ( Path(__file__).parent / "templates" / f"docker_compose_{converted_version}.yml" ) + + twilio_shim_path = ( + Path(__file__).parent + / "templates" + / "extras" + / "twilio-shim" + / "docker_compose.yml" + ) + + env_file = Path(__file__).parent / "templates" / "environment" / "dotenv" if not template_path.exists(): return Response( error=True, message=f"Template {version} not found.", data=None ) - # Override default env vars + # Override default env vars for port env_vars = get_env_vars(self.default_env_vars) if port != 80: env_vars["_APP_PORT"] = str(port) # What actually deploys the initial appwrite service - cmd_res = self._deploy_service( - project=name, template_path=template_path, env_vars=env_vars + cmd_res = self._deploy_compose_service( + project=name, + template_paths=[template_path, twilio_shim_path], + env_file=env_file, ) # Deploy mail server (mailpit) @@ -216,9 +238,7 @@ def deploy_appwrite_lab( / "mailpit" / "docker_compose.yml" ) - self._deploy_service( - project=name, template_path=mailpit_template_path, env_vars={} - ) + self._deploy_compose_service(project=name, template_paths=mailpit_template_path) # if CLI, will throw error in actual Response object if type(cmd_res) is Response and cmd_res.error: return cmd_res @@ -246,6 +266,8 @@ def deploy_appwrite_lab( version=version, url=url, **_kwargs, + sms_shim_url="https://localhost:4443", + mailpit_url="http://localhost:8025", ) lab.generate_missing_config() diff --git a/appwrite_lab/_state.py b/appwrite_lab/_state.py index ebd00f9..7ff20bc 100644 --- a/appwrite_lab/_state.py +++ b/appwrite_lab/_state.py @@ -1,6 +1,5 @@ import json import os -from pathlib import Path from .utils import get_state_path diff --git a/appwrite_lab/labs.py b/appwrite_lab/labs.py index 418ac54..fc60ca1 100644 --- a/appwrite_lab/labs.py +++ b/appwrite_lab/labs.py @@ -152,7 +152,7 @@ def create_api_key( return Response( message=f"API key created for {project_name}", data=api_key.data, - _print_data=True, + _print_data=False, ) def stop(self, name: str): diff --git a/appwrite_lab/models.py b/appwrite_lab/models.py index 7c6431a..6a35f6a 100644 --- a/appwrite_lab/models.py +++ b/appwrite_lab/models.py @@ -51,6 +51,8 @@ class Lab(_BaseClass): default_factory=lambda: {"default": Project(None, None, None)} ) _file: str = field(default="") + sms_shim_url: str = field(default="") + mailpit_url: str = field(default="") def generate_missing_config(self): """Generate missing data config with random values.""" diff --git a/appwrite_lab/templates/environment/dotenv b/appwrite_lab/templates/environment/dotenv index f7e79a7..313f147 100644 --- a/appwrite_lab/templates/environment/dotenv +++ b/appwrite_lab/templates/environment/dotenv @@ -55,8 +55,8 @@ _APP_SMTP_PORT=1025 _APP_SMTP_SECURE= _APP_SMTP_USERNAME= _APP_SMTP_PASSWORD= -_APP_SMS_PROVIDER= -_APP_SMS_FROM= +_APP_SMS_PROVIDER=sms://twilio:shim@twilio +_APP_SMS_FROM=+15555555555 _APP_STORAGE_LIMIT=30000000 _APP_STORAGE_PREVIEW_LIMIT=20000000 _APP_STORAGE_ANTIVIRUS=disabled diff --git a/appwrite_lab/templates/extras/twilio-shim/docker_compose.yml b/appwrite_lab/templates/extras/twilio-shim/docker_compose.yml new file mode 100644 index 0000000..1ea6387 --- /dev/null +++ b/appwrite_lab/templates/extras/twilio-shim/docker_compose.yml @@ -0,0 +1,61 @@ +x-sms-env: &sms_env + _APP_SMS_PROVIDER: ${_APP_SMS_PROVIDER:-sms://twilio:shim@twilio} + _APP_SMS_FROM: ${_APP_SMS_FROM:-+15555555555} + +networks: + appwrite: + name: appwrite + +volumes: + fake_twilio_certs: {} + php_extra_conf: {} + +services: + # one-shot: write php ini into the named volume + php-ini-init: + image: busybox:1.36 + volumes: + - php_extra_conf:/out + command: > + sh -lc 'printf "%s\n%s\n" + "curl.cainfo=/usr/local/share/ca-certificates/dev-rootCA.pem" + "openssl.cafile=/usr/local/share/ca-certificates/dev-rootCA.pem" + > /out/zz-dev-ca.ini' + + twilio-shim: + image: syntaxsdev/twilio-shim:latest + volumes: + - fake_twilio_certs:/certs + ports: ["4443:443"] # dev-only host access + networks: + appwrite: + aliases: [api.twilio.com] + restart: unless-stopped + healthcheck: + test: ["CMD","curl","-k","https://localhost:443/"] + interval: 5s + timeout: 3s + retries: 5 + + appwrite: + depends_on: + php-ini-init: { condition: service_completed_successfully } + environment: + <<: *sms_env + PHP_INI_SCAN_DIR: /usr/local/etc/php/conf.d:/opt/php-extra-conf.d + volumes: + - fake_twilio_certs:/usr/local/share/ca-certificates:ro + - php_extra_conf:/opt/php-extra-conf.d:ro + networks: [appwrite] + + appwrite-worker-messaging: + depends_on: + php-ini-init: { condition: service_completed_successfully } + twilio-shim: { condition: service_healthy } + environment: + <<: *sms_env + PHP_INI_SCAN_DIR: /usr/local/etc/php/conf.d:/opt/php-extra-conf.d + volumes: + - fake_twilio_certs:/usr/local/share/ca-certificates:ro + - php_extra_conf:/opt/php-extra-conf.d:ro + networks: [appwrite] diff --git a/appwrite_lab/tools/sms.py b/appwrite_lab/tools/sms.py new file mode 100644 index 0000000..6f21844 --- /dev/null +++ b/appwrite_lab/tools/sms.py @@ -0,0 +1,17 @@ +from appwrite_lab.models import Lab +from httpx import AsyncClient + + +class SMS: + def __init__(self, lab: Lab): + self.url = lab.sms_shim_url + + async def get_messages(self): + async with AsyncClient(verify=False) as client: + response = await client.get(f"{self.url}/inbox") + return response.json().get("messages") + + async def clear_messages(self): + async with AsyncClient(verify=False) as client: + response = await client.post(f"{self.url}/clear") + return response.json().get("ok") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3f0e143..4e7c4e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "appwrite-lab" -version = "0.1.2" +version = "0.1.3" description = "Zero-click Appwrite test environments." readme = "README.md" requires-python = ">=3.11" -dependencies = ["playwright", "typer>=0.16.0", "python-dotenv>=1.1.0", "pytest>=8.4.1"] +dependencies = [ + "playwright", + "typer>=0.16.0", + "python-dotenv>=1.1.0", + "pytest>=8.4.1", + "httpx>=0.28.1", + "pytest-asyncio>=1.1.0", +] license = "MIT" [tool.setuptools] @@ -23,6 +30,5 @@ appwrite-lab = "appwrite_lab.cli.entry:app" awlab = "appwrite_lab.cli.entry:app" [tool.pytest.ini_options] -markers = [ - "e2e: mark test as e2e", -] +markers = ["e2e: mark test as e2e"] +asyncio_mode = "auto" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 8ec0814..8197d8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,20 @@ from pathlib import Path from appwrite_lab.models import Lab from appwrite_lab.test_suite import lab_svc, appwrite_file, lab_config, lab +from appwrite_lab.tools.sms import SMS @pytest.fixture(scope="session") def appwrite_file_path(): return Path(__file__).parent / "data" / "appwrite.json" - \ No newline at end of file + + +@pytest.fixture(scope="session") +def sms(lab: Lab): + return SMS(lab) + + +@pytest.fixture(autouse=True) +async def clear_sms(sms: SMS): + yield + await sms.clear_messages() diff --git a/tests/test_labs.py b/tests/test_labs.py index adec80c..776b044 100644 --- a/tests/test_labs.py +++ b/tests/test_labs.py @@ -2,19 +2,21 @@ from appwrite_lab.labs import Labs from appwrite_lab.automations.models import Expiration import pytest - +import uuid @pytest.mark.e2e def test_labs_new(lab: Lab): assert lab.name == "test-lab" assert lab.version == "1.7.4" - assert lab.url.endswith("8080") + assert lab.url.endswith("80") assert lab.projects.get("default") is not None @pytest.mark.e2e def test_labs_create_api_key(lab: Lab, lab_svc: Labs): default = lab.projects.get("default") + if default.api_key: + pytest.skip("API key already exists") res = lab_svc.create_api_key( project_name=default.project_name, key_name="default-api-key", @@ -37,8 +39,9 @@ def test_labs_synced_project(lab: Lab, lab_svc: Labs): @pytest.mark.e2e def test_labs_create_project(lab: Lab, lab_svc: Labs): - project_name = "test-project" - project_id = "test-project-id" + nonce = str(uuid.uuid4())[:8] + project_name = f"test-project-{nonce}" + project_id = f"test-project-id-{nonce}" res = lab_svc.create_project( project_name=project_name, project_id=project_id, diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..33623c8 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,27 @@ +import pytest +from appwrite_lab.tools.sms import SMS +from appwrite_lab.models import Lab +from httpx import AsyncClient +import asyncio + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_sms_get_messages(sms: SMS, lab: Lab): + await sms.clear_messages() + messages = await sms.get_messages() + assert len(messages) == 0 + + async with AsyncClient(verify=False) as client: + response = await client.post( + f"{lab.url}/v1/account/sessions/phone", + json={"userId": "unique()", "phone": "+15555550123"}, + headers={"X-Appwrite-Project": lab.projects.get("default").project_id}, + ) + assert response.status_code == 201 + await asyncio.sleep(1) + messages = await sms.get_messages() + assert len(messages) == 1 + assert messages[0].get("frm") == "+15555555555" + assert len(messages[0].get("body")) == 6 + + diff --git a/twilio-shim/Dockerfile b/twilio-shim/Dockerfile new file mode 100644 index 0000000..6500325 --- /dev/null +++ b/twilio-shim/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +WORKDIR /app + +# deps +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# mkcert (static binary) +RUN curl -L -o /usr/local/bin/mkcert \ + https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64 \ + && chmod +x /usr/local/bin/mkcert + +# app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py ./main.py + +# entrypoint that generates certs then runs HTTPS +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 443 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/twilio-shim/entrypoint.sh b/twilio-shim/entrypoint.sh new file mode 100644 index 0000000..ec7b2ee --- /dev/null +++ b/twilio-shim/entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR=${CERT_DIR:-/certs} +CAROOT="${CERT_DIR}/ca" +mkdir -p "${CAROOT}" + +# Generate CA + leaf if missing +if [ ! -f "${CERT_DIR}/api.twilio.com.crt" ] || [ ! -f "${CERT_DIR}/api.twilio.com.key" ]; then + echo ">> Generating dev CA and leaf cert..." + export CAROOT + mkcert -install >/dev/null 2>&1 || true + mkcert -key-file "${CERT_DIR}/api.twilio.com.key" \ + -cert-file "${CERT_DIR}/api.twilio.com.crt" \ + "api.twilio.com" + cp "${CAROOT}/rootCA.pem" "${CERT_DIR}/dev-rootCA.pem" +fi + +echo ">> Starting Twilio shim (HTTPS)…" +exec uvicorn main:app \ + --host 0.0.0.0 --port 443 \ + --ssl-keyfile "${CERT_DIR}/api.twilio.com.key" \ + --ssl-certfile "${CERT_DIR}/api.twilio.com.crt" diff --git a/twilio-shim/main.py b/twilio-shim/main.py new file mode 100644 index 0000000..34bf0e2 --- /dev/null +++ b/twilio-shim/main.py @@ -0,0 +1,87 @@ +from fastapi import FastAPI, Body, Form, Request +from fastapi.responses import HTMLResponse, JSONResponse +from typing import List, Dict +from datetime import datetime + +app = FastAPI() +MESSAGES: List[Dict] = [] + + +@app.post("/inbox") +async def inbox(payload: Dict = Body(...)): + payload["ts"] = datetime.utcnow().isoformat() + "Z" + MESSAGES.append(payload) + if len(MESSAGES) > 100: + del MESSAGES[:-100] + return {"ok": True} + + +@app.get("/inbox") +def list_inbox(): + return {"messages": MESSAGES} + + +@app.post("/clear") +def clear_inbox(): + MESSAGES.clear() + return {"ok": True} + + +# Twilio-compatible endpoint +@app.post("/2010-04-01/Accounts/{sid}/Messages.json") +async def send_sms(sid: str, request: Request): + form = await request.form() + data = dict(form) # includes any extra fields + to = data.get("To") + body = data.get("Body") + from_ = data.get("From") # optional + mssid = data.get("MessagingServiceSid") # optional + + # Log everything we got to see what Appwrite is actually posting + print(f"[FAKE-TWILIO] sid={sid} payload={data}") + MESSAGES.append( + { + "to": to, + "frm": from_, + "body": body, + "ts": datetime.utcnow().isoformat() + "Z", + } + ) + + # Minimal Twilio-style validation: need To + Body + (From or MessagingServiceSid) + if not to or not body or not (from_ or mssid): + return JSONResponse( + status_code=400, + content={ + "code": 21601, + "message": "Missing required parameter: 'To'/'Body' or sender", + }, + ) + + # Return Twilio-like success + return JSONResponse( + status_code=201, + content={ + "sid": "SM_fake123", + "status": "queued", + "to": to, + "from": from_, + "messaging_service_sid": mssid, + "body": body, + }, + ) + + +@app.get("/", response_class=HTMLResponse) +def home(): + rows = "".join( + f"
To | From | Body | Time (UTC) |
---|