From 2a76d56da079c3b62aa0220b54561e79d96efcd0 Mon Sep 17 00:00:00 2001 From: Mateusz Russak Date: Tue, 16 Jul 2024 17:09:15 +0200 Subject: [PATCH] test: add tests for deploy and cli commands --- poetry.lock | 4 +- src/writer/command_line.py | 2 +- src/writer/deploy.py | 55 ++++--- tests/backend/test_cli.py | 42 +++++ tests/backend/test_deploy.py | 297 +++++++++++++++++++++++++++++++++++ 5 files changed, 374 insertions(+), 26 deletions(-) create mode 100644 tests/backend/test_cli.py create mode 100644 tests/backend/test_deploy.py diff --git a/poetry.lock b/poetry.lock index c487023af..400e6a417 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alfred-cli" @@ -2081,4 +2081,4 @@ typing-extensions = ">=4.7,<5" [metadata] lock-version = "2.0" python-versions = ">=3.9.2, <4.0" -content-hash = "087a35e24f0286c5063c952de4bf460b036579e4956f1e598b9ac2172fa82cdd" +content-hash = "a9575d1bd14a7337965fc1a7d9da4ce77580f409b9f24408abbc9f1bba3cb85f" diff --git a/src/writer/command_line.py b/src/writer/command_line.py index c911acd69..bc644d1b0 100644 --- a/src/writer/command_line.py +++ b/src/writer/command_line.py @@ -50,7 +50,7 @@ def edit(path, port, host, enable_remote_edit, enable_server_setup): enable_remote_edit=enable_remote_edit, enable_server_setup=enable_server_setup) @main.command() -@click.argument('path', type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True)) +@click.argument('path', type=click.Path(exists=False, file_okay=False, dir_okay=True, resolve_path=True)) @click.option('--template', help="The template to use when creating a new app.") def create(path, template): """Create a new app in PATH folder.""" diff --git a/src/writer/deploy.py b/src/writer/deploy.py index fbac6b91a..7d5ec3c0c 100644 --- a/src/writer/deploy.py +++ b/src/writer/deploy.py @@ -15,7 +15,6 @@ import requests from gitignore_parser import parse_gitignore -WRITER_DEPLOY_URL = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") @click.group() def cloud(): @@ -32,12 +31,18 @@ def cloud(): hide_input=True, help="Writer API key" ) @click.option('--env', '-e', multiple=True, default=[], help="Environment to deploy the app to") +@click.option('--force', '-f', default=False, is_flag=True, help="Ignores warnings and overwrites the app") @click.option('--verbose', '-v', default=False, is_flag=True, help="Enable verbose mode") @click.argument('path') -def deploy(path, api_key, env, verbose): - check_app(api_key) +def deploy(path, api_key, env, verbose, force): """Deploy the app from PATH folder.""" + deploy_url = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") + sleep_interval = int(os.getenv("WRITER_DEPLOY_SLEEP_INTERVAL", '5')) + + if not force: + check_app(deploy_url, api_key) + abs_path, is_folder = _get_absolute_app_path(path) if not is_folder: raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer cloud deploy my_app") @@ -45,7 +50,7 @@ def deploy(path, api_key, env, verbose): env = _validate_env_vars(env) tar = pack_project(abs_path) try: - upload_package(tar, api_key, env, verbose=verbose) + upload_package(deploy_url, tar, api_key, env, verbose=verbose, sleep_interval=sleep_interval) except requests.exceptions.HTTPError as e: if e.response.status_code == 401: unauthorized_error() @@ -83,7 +88,8 @@ def undeploy(api_key, verbose): """Stop the app, app would not be available anymore.""" try: print("Undeploying app") - with requests.delete(WRITER_DEPLOY_URL, headers={"Authorization": f"Bearer {api_key}"}) as resp: + deploy_url = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") + with requests.delete(deploy_url, headers={"Authorization": f"Bearer {api_key}"}) as resp: on_error_print_and_raise(resp, verbose=verbose) print("App undeployed") sys.exit(0) @@ -105,13 +111,16 @@ def undeploy(api_key, verbose): def logs(api_key, verbose): """Fetch logs from the deployed app.""" + deploy_url = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") + sleep_interval = int(os.getenv("WRITER_DEPLOY_SLEEP_INTERVAL", '5')) + try: build_time = datetime.now(pytz.timezone('UTC')) - timedelta(days=4) start_time = build_time while True: prev_start = start_time end_time = datetime.now(pytz.timezone('UTC')) - data = get_logs(api_key, { + data = get_logs(deploy_url, api_key, { "buildTime": build_time, "startTime": start_time, "endTime": end_time, @@ -122,12 +131,12 @@ def logs(api_key, verbose): start_time = start_time if start_time > log[0] else log[0] if start_time == prev_start: start_time = datetime.now(pytz.timezone('UTC')) - time.sleep(5) + time.sleep(sleep_interval) continue for log in logs: print(log[0], log[1]) print(start_time) - time.sleep(1) + time.sleep(sleep_interval) except Exception as e: print(e) sys.exit(1) @@ -164,8 +173,8 @@ def match(file_path) -> bool: return False return f -def check_app(token): - url = get_app_url(token) +def check_app(deploy_url, token): + url = get_app_url(deploy_url, token) if url: print("[WARNING] This token was already used to deploy a different app") print(f"[WARNING] URL: {url}") @@ -173,8 +182,8 @@ def check_app(token): if input("[WARNING] Are you sure you want to overwrite? (y/N)").lower() != "y": sys.exit(1) -def get_app_url(token): - with requests.get(WRITER_DEPLOY_URL, params={"lineLimit": 1}, headers={"Authorization": f"Bearer {token}"}) as resp: +def get_app_url(deploy_url, token): + with requests.get(deploy_url, params={"lineLimit": 1}, headers={"Authorization": f"Bearer {token}"}) as resp: try: resp.raise_for_status() except Exception as e: @@ -184,8 +193,8 @@ def get_app_url(token): data = resp.json() return data['status']['url'] -def get_logs(token, params, verbose=False): - with requests.get(WRITER_DEPLOY_URL, params = params, headers={"Authorization": f"Bearer {token}"}) as resp: +def get_logs(deploy_url, token, params, verbose=False): + with requests.get(deploy_url, params = params, headers={"Authorization": f"Bearer {token}"}) as resp: on_error_print_and_raise(resp, verbose=verbose) data = resp.json() @@ -198,8 +207,8 @@ def get_logs(token, params, verbose=False): logs.sort(key=lambda x: x[0]) return {"status": data["status"], "logs": logs} -def check_service_status(token, build_id, build_time, start_time, end_time, last_status): - data = get_logs(token, { +def check_service_status(deploy_url, token, build_id, build_time, start_time, end_time, last_status): + data = get_logs(deploy_url, token, { "buildId": build_id, "buildTime": build_time, "startTime": start_time, @@ -225,14 +234,15 @@ def dictFromEnv(env: List[str]) -> dict: return env_dict -def upload_package(tar, token, env, verbose=False): +def upload_package(deploy_url, tar, token, env, verbose=False, sleep_interval=5): print("Uploading package to deployment server") tar.seek(0) files = {'file': tar} start_time = datetime.now(pytz.timezone('UTC')) build_time = start_time + with requests.post( - url = WRITER_DEPLOY_URL, + url = deploy_url, headers = { "Authorization": f"Bearer {token}", }, @@ -248,8 +258,8 @@ def upload_package(tar, token, env, verbose=False): url = "" while status not in ["COMPLETED", "FAILED"] and datetime.now(pytz.timezone('UTC')) < build_time + timedelta(minutes=5): end_time = datetime.now(pytz.timezone('UTC')) - status, url = check_service_status(token, build_id, build_time, start_time, end_time, status) - time.sleep(5) + status, url = check_service_status(deploy_url, token, build_id, build_time, start_time, end_time, status) + time.sleep(sleep_interval) start_time = end_time if status == "COMPLETED": @@ -257,8 +267,8 @@ def upload_package(tar, token, env, verbose=False): print(f"URL: {url}") sys.exit(0) else: - time.sleep(5) - check_service_status(token, build_id, build_time, start_time, datetime.now(pytz.timezone('UTC')), status) + time.sleep(sleep_interval) + check_service_status(deploy_url, token, build_id, build_time, start_time, datetime.now(pytz.timezone('UTC')), status) print("Deployment failed") sys.exit(1) @@ -271,7 +281,6 @@ def on_error_print_and_raise(resp, verbose=False): raise e def unauthorized_error(): - print(f"\n{WRITER_DEPLOY_URL}") print("Unauthorized. Please check your API key.") sys.exit(1) diff --git a/tests/backend/test_cli.py b/tests/backend/test_cli.py new file mode 100644 index 000000000..84ef1b3fb --- /dev/null +++ b/tests/backend/test_cli.py @@ -0,0 +1,42 @@ +import os + +from click.testing import CliRunner +from writer.command_line import main + + +def test_version(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, ['-v']) + assert result.exit_code == 0 + assert 'version' in result.output + +def test_create_default(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, ['create', './my_app']) + print(result.output) + assert result.exit_code == 0 + #check if filder exists and if has the right files + assert os.path.exists('./my_app') + assert os.path.exists('./my_app/ui.json') + assert os.path.exists('./my_app/main.py') + #load toml and check name and version + with open('./my_app/pyproject.toml') as f: + content = f.read() + assert content.find('name = "writer-framework-default"') != -1 + +def test_create_specific_template(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, ['create', './my_app', '--template', 'hello']) + print(result.output) + assert result.exit_code == 0 + #check if filder exists and if has the right files + assert os.path.exists('./my_app') + assert os.path.exists('./my_app/ui.json') + assert os.path.exists('./my_app/main.py') + #load toml and check name and version + with open('./my_app/pyproject.toml') as f: + content = f.read() + assert content.find('name = "writer-framework-hello"') != -1 diff --git a/tests/backend/test_deploy.py b/tests/backend/test_deploy.py new file mode 100644 index 000000000..178730d05 --- /dev/null +++ b/tests/backend/test_deploy.py @@ -0,0 +1,297 @@ +import contextlib +import json +import re +import threading +import time +from datetime import datetime, timedelta +from typing import Annotated + +import pytest +import pytz +import uvicorn +from click.testing import CliRunner +from fastapi import Body, Depends, FastAPI, File, Header, UploadFile +from writer.command_line import main + + +def create_app(): + class State: + log_counter = 0 + envs: str| None = None + + state = State() + app = FastAPI() + + + @app.post("/deploy") + def deploy( + state: Annotated[State, Depends(lambda: state)], + authorization: Annotated[str, Header(description="The API key")], + file: UploadFile = File(...), + envs: Annotated[str, Body(description = 'JSON object of environment variables')] = "{}", + ): + print (envs) + state.envs = envs + return {"status": "ok", "buildId": "123"} + + + @app.get("/deploy") + def get_status( + state: Annotated[State, Depends(lambda: state)], + authorization: Annotated[str, Header(description="The API key")], + ): + + def get_time(n): + return (datetime.now(pytz.timezone('UTC')) + timedelta(seconds=n)).isoformat() + + state.log_counter += 1 + if (authorization == "Bearer full"): + if state.log_counter == 1: # first call is to checking if app exist + return { + "logs": [], + "status": { + "url": None, + "status": "PENDING", + } + } + if state.log_counter == 2: + return { + "logs": [ + {"log": f"{get_time(-7)} stdout F {state.envs}"}, + {"log": f"{get_time(-6)} stdout F "}, + {"log": f"{get_time(-5)} stdout F "}, + ], + "status": { + "url": None, + "status": "BUILDING", + } + } + if state.log_counter == 3: + return { + "logs": [ + {"log": f"{get_time(-2)} stdout F "}, + {"log": f"{get_time(-4)} stdout F "}, + ], + "status": { + "url": "https://full.my-app.com", + "status": "COMPLETED", + } + } + if (authorization == "Bearer test"): + return { + "logs": [ + {"log": f"20210813163223 stdout F {state.envs}"}, + ], + "status": { + "url": "https://my-app.com", + "status": "COMPLETED", + } + } + return { + "logs": [], + "status": { + "url": None, + "status": "FAILED", + } + } + + @app.delete("/deploy") + def undeploy( + authorization: Annotated[str, Header(description="The API key")], + ): + return {"status": "ok"} + return app + + +class Server(uvicorn.Server): + def __init__(self): + config = uvicorn.Config(create_app(), host="127.0.0.1", port=8888, log_level="info") + super().__init__(config) + self.keep_running = True + + def install_signal_handlers(self): + pass + + @contextlib.contextmanager + def run_in_thread(self): + thread = threading.Thread(target=self.run) + thread.start() + try: + while not self.started: + time.sleep(1e-3) + yield + finally: + self.should_exit = True + thread.join() + + + +@pytest.fixture(autouse=True) +def run_with_server(): + server = Server() + with server.run_in_thread(): + yield + print('end') + +def assert_warning(result, url = "https://my-app.com"): + found = re.search(f".WARNING. URL: {url}", result.output) + + assert found is not None + + +def assert_url(result, expectedUrl): + url = re.search("URL: (.*)$", result.output) + assert url and url.group(1) == expectedUrl + +def extract_envs(result): + content = re.search("(.*)", result.output) + assert content is not None + return json.loads(content.group(1)) + + +def test_deploy(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + }, input='y\n') + print(result.output) + assert result.exit_code == 0 + assert_warning(result) + assert_url(result, 'https://my-app.com') + +def test_deploy_force_flag(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app', '--force'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + }) + print(result.output) + assert result.exit_code == 0 + found = re.search(".WARNING. URL: https://my-app.com", result.output) + assert found is None + assert_url(result, 'https://my-app.com') + +def test_deploy_api_key_option(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app', '--api-key', 'test'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'fail', + }, input='y\n') + print(result.output) + assert result.exit_code == 0 + assert_warning(result) + assert_url(result, 'https://my-app.com') + +def test_deploy_api_key_prompt(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + }, input='test\ny\n') + print(result.output) + assert result.exit_code == 0 + assert_warning(result) + assert_url(result, 'https://my-app.com') + +def test_deploy_warning(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + }) + print(result.output) + assert result.exit_code == 1 + +def test_deploy_env(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, + args = [ + 'cloud', 'deploy', './my_app', + '-e', 'ENV1=test', '-e', 'ENV2=other' + ], + env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + 'WRITER_DEPLOY_SLEEP_INTERVAL': '0' + }, + input='y\n' + ) + print(result.output) + assert result.exit_code == 0 + envs = extract_envs(result) + assert envs['ENV1'] == 'test' + assert envs['ENV2'] == 'other' + assert_url(result, 'https://my-app.com') + +def test_deploy_full_flow(): + runner = CliRunner() + with runner.isolated_filesystem(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, + args = [ + 'cloud', 'deploy', './my_app', + '-e', 'ENV1=test', '-e', 'ENV2=other' + ], + env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'full', + 'WRITER_DEPLOY_SLEEP_INTERVAL': '0' + }, + ) + print(result.output) + assert result.exit_code == 0 + envs = extract_envs(result) + assert envs['ENV1'] == 'test' + assert envs['ENV2'] == 'other' + assert_url(result, 'https://full.my-app.com') + + logs = re.findall("", result.output) + assert logs[0] == "" + assert logs[1] == "" + assert logs[2] == "" + assert logs[3] == "" + + +def test_undeploy(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, + args = [ + 'cloud', 'undeploy' + ], + env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'full', + 'WRITER_DEPLOY_SLEEP_INTERVAL': '0' + }, + ) + print(result.output) + assert re.search("App undeployed", result.output) + assert result.exit_code == 0 +