Skip to content
Merged
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
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
48 changes: 35 additions & 13 deletions appwrite_lab/_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [],
):
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion appwrite_lab/_state.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import os
from pathlib import Path

from .utils import get_state_path

Expand Down
2 changes: 1 addition & 1 deletion appwrite_lab/labs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions appwrite_lab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 2 additions & 2 deletions appwrite_lab/templates/environment/dotenv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions appwrite_lab/templates/extras/twilio-shim/docker_compose.yml
Original file line number Diff line number Diff line change
@@ -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]
17 changes: 17 additions & 0 deletions appwrite_lab/tools/sms.py
Original file line number Diff line number Diff line change
@@ -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")
16 changes: 11 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"
13 changes: 12 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"



@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()
11 changes: 7 additions & 4 deletions tests/test_labs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
@@ -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


24 changes: 24 additions & 0 deletions twilio-shim/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading