Skip to content

Commit

Permalink
feat: prevent Writer Framework version mismatch local/cloud
Browse files Browse the repository at this point in the history
* fix: comment from review
* feat: warn user about consistency version when running the app
* refact: improve wf_project module readability
* feat: implement poetry verification
  • Loading branch information
FabienArcellier committed Nov 24, 2024
1 parent ec53f21 commit 44ed26a
Show file tree
Hide file tree
Showing 17 changed files with 3,109 additions and 44 deletions.
19 changes: 15 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ watchdog = ">= 3.0.0, < 4"
websockets = ">= 12, < 13"
writer-sdk = ">= 1.2.0, < 2"
python-multipart = ">=0.0.7, < 1"
toml = "^0.10.2"
packaging = "^24.2"


[tool.poetry.group.build]
Expand All @@ -71,6 +73,7 @@ types-requests = "^2.31.0.20240406"

[tool.poetry.group.dev.dependencies]
types-python-dateutil = "^2.9.0.20240316"
types-toml = "^0.10.8.20240310"

[tool.poetry.scripts]
writer = 'writer.command_line:main'
Expand Down
100 changes: 71 additions & 29 deletions src/writer/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,26 @@
import pytz
import requests
from gitignore_parser import parse_gitignore
from packaging.version import Version

from writer import wf_project


@click.group()
def cloud():
"""A group of commands to deploy the app on writer cloud"""
pass


@cloud.command()
@click.option('--api-key',
default=lambda: os.environ.get("WRITER_API_KEY", None),
allow_from_autoenv=True,
show_envvar=True,
envvar='WRITER_API_KEY',
prompt="Enter your API key",
hide_input=True, help="Writer API key"
)
default=lambda: os.environ.get("WRITER_API_KEY", None),
allow_from_autoenv=True,
show_envvar=True,
envvar='WRITER_API_KEY',
prompt="Enter your API key",
hide_input=True, help="Writer API key"
)
@click.option('--env', '-e', multiple=True, default=[], help="Set environment variables for the app (e.g., --env KEY=VALUE)")
@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")
Expand All @@ -47,6 +51,16 @@ def deploy(path, api_key, env, verbose, force):
if not os.path.isdir(abs_path):
raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer cloud deploy my_app")

ignore_poetry_version_check = int(os.getenv("WF_WRITER_IGNORE_POETRY_VERSION_CHECK", '0')) == 1
if wf_project.use_poetry(abs_path) and ignore_poetry_version_check is False:
poetry_locked_version = wf_project.poetry_locked_version(abs_path)
if poetry_locked_version is None:
raise click.ClickException("The application does not have a poetry.lock file. Please run `poetry install` to generate it.")

writer_version = wf_project.writer_version(abs_path)
if poetry_locked_version < writer_version:
raise click.ClickException(f"locked version in poetry.lock does not match the project version use in .wf, pin a new version `poetry add writer@^{writer_version}")

env = _validate_env_vars(env)
tar = pack_project(abs_path)
try:
Expand All @@ -57,12 +71,11 @@ def deploy(path, api_key, env, verbose, force):
else:
on_error_print_and_raise(e.response, verbose=verbose)
except Exception as e:
print(e)
print("Error deploying app")
sys.exit(1)
raise click.ClickException(f"Error deploying app - {e}")
finally:
tar.close()


def _validate_env_vars(env: Union[List[str], None]) -> Union[List[str], None]:
if env is None:
return None
Expand All @@ -73,15 +86,16 @@ def _validate_env_vars(env: Union[List[str], None]) -> Union[List[str], None]:
sys.exit(1)
return env


@cloud.command()
@click.option('--api-key',
default=lambda: os.environ.get("WRITER_API_KEY", None),
allow_from_autoenv=True,
show_envvar=True,
envvar='WRITER_API_KEY',
prompt="Enter your API key",
hide_input=True, help="Writer API key"
)
default=lambda: os.environ.get("WRITER_API_KEY", None),
allow_from_autoenv=True,
show_envvar=True,
envvar='WRITER_API_KEY',
prompt="Enter your API key",
hide_input=True, help="Writer API key"
)
@click.option('--verbose', '-v', default=False, is_flag=True, help="Enable verbose mode")
def undeploy(api_key, verbose):
"""Stop the app, app would not be available anymore."""
Expand All @@ -97,23 +111,24 @@ def undeploy(api_key, verbose):
print(e)
sys.exit(1)


@cloud.command()
@click.option('--api-key',
default=lambda: os.environ.get("WRITER_API_KEY", None),
allow_from_autoenv=True,
show_envvar=True,
envvar='WRITER_API_KEY',
prompt="Enter your API key",
hide_input=True, help="Writer API key"
)
default=lambda: os.environ.get("WRITER_API_KEY", None),
allow_from_autoenv=True,
show_envvar=True,
envvar='WRITER_API_KEY',
prompt="Enter your API key",
hide_input=True, help="Writer API key"
)
@click.option('--verbose', '-v', default=False, is_flag=True, help="Enable verbose mode")
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:
try:
build_time = datetime.now(pytz.timezone('UTC')) - timedelta(days=4)
start_time = build_time
while True:
Expand Down Expand Up @@ -142,11 +157,15 @@ def logs(api_key, verbose):
except KeyboardInterrupt:
sys.exit(0)


def pack_project(path):
print(f"Creating deployment package from path: {path}")

files = []
def match(file_path) -> bool: return False

def match(file_path) -> bool:
return False

if os.path.exists(os.path.join(path, ".gitignore")):
match = parse_gitignore(os.path.join(path, ".gitignore"))
for root, dirs, filenames in os.walk(path, followlinks=False):
Expand Down Expand Up @@ -178,6 +197,7 @@ def match(file_path) -> bool: return False

return f


def check_app(deploy_url, token):
url = _get_app_url(deploy_url, token)
if url:
Expand All @@ -187,6 +207,7 @@ def check_app(deploy_url, token):
if input("[WARNING] Are you sure you want to overwrite? (y/N)").lower() != "y":
sys.exit(1)


def _get_app_url(deploy_url: str, token: str) -> Union[str, None]:
with requests.get(deploy_url, params={"lineLimit": 1}, headers={"Authorization": f"Bearer {token}"}) as resp:
try:
Expand All @@ -198,8 +219,9 @@ def _get_app_url(deploy_url: str, token: str) -> Union[str, None]:
data = resp.json()
return data['status']['url']


def get_logs(deploy_url, token, params, verbose=False):
with requests.get(deploy_url, params = params, headers={"Authorization": f"Bearer {token}"}) as resp:
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()

Expand All @@ -212,6 +234,7 @@ def get_logs(deploy_url, token, params, verbose=False):
logs.sort(key=lambda x: x[0])
return {"status": data["status"], "logs": logs}


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,
Expand All @@ -227,6 +250,7 @@ def check_service_status(deploy_url, token, build_id, build_time, start_time, en
print(log[0], log[1])
return status, url


def dictFromEnv(env: List[str]) -> dict:
env_dict = {}
if env is None:
Expand All @@ -247,8 +271,8 @@ def upload_package(deploy_url, tar, token, env, verbose=False, sleep_interval=5)
build_time = start_time

with requests.post(
url = deploy_url,
headers = {
url=deploy_url,
headers={
"Authorization": f"Bearer {token}",
},
files=files,
Expand All @@ -267,6 +291,8 @@ def upload_package(deploy_url, tar, token, env, verbose=False, sleep_interval=5)
time.sleep(sleep_interval)
start_time = end_time

_check_version_integrity(deploy_url)

if status == "COMPLETED":
print("Deployment successful")
print(f"URL: {url}")
Expand All @@ -282,6 +308,7 @@ def upload_package(deploy_url, tar, token, env, verbose=False, sleep_interval=5)
print(f"URL: {url}")
sys.exit(2)


def on_error_print_and_raise(resp, verbose=False):
try:
resp.raise_for_status()
Expand All @@ -290,7 +317,22 @@ def on_error_print_and_raise(resp, verbose=False):
print(resp.json())
raise e


def unauthorized_error():
print("Unauthorized. Please check your API key.")
sys.exit(1)


def _check_version_integrity(app_url: str):
"""
Check if the writer version in the deployment package is newer than the writer version running on the server.
"""
with requests.get(f"{app_url}/_meta") as resp:
data = resp.json()
if "writer_version_dev" not in data or "writer_version_run" not in data:
return

if Version(data["writer_version_dev"]) > Version(data["writer_version_run"]):
print(f"[WARNING] The writer version in the deployment package ({data['writer_version_dev']}) is newer than the writer version running on the server ({data['writer_version_run']}).")
return
33 changes: 32 additions & 1 deletion src/writer/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
from fastapi.responses import FileResponse
from fastapi.routing import Mount
from fastapi.staticfiles import StaticFiles
from packaging.version import Version
from pydantic import ValidationError
from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState

from writer import VERSION, abstract
from writer import VERSION, abstract, wf_project
from writer.app_runner import AppRunner
from writer.ss_types import (
AppProcessServerResponse,
Expand Down Expand Up @@ -86,6 +87,7 @@ def get_asgi_app(
if serve_mode not in ["run", "edit"]:
raise ValueError("""Invalid mode. Must be either "run" or "edit".""")

_warn_about_version_consistency(serve_mode, user_app_path)
_fix_mimetype()
app_runner = AppRunner(user_app_path, serve_mode)

Expand Down Expand Up @@ -632,6 +634,35 @@ def wf_root_static_assets() -> List[pathlib.Path]:
return all_static_assets


def _warn_about_version_consistency(mode: ServeMode, app_path: str) -> None:
"""
Checks that the version of the writer package used to run the app is equivalent
to or newer than the version used during development.
If it is not the case, a warning message is displayed.
"""
logger = logging.getLogger("writer")
dev_version = wf_project.writer_version(app_path)
if dev_version is None:
return

run_version = Version(VERSION)
if run_version < dev_version:
if mode == "run":
logger.warning(f"The version {run_version}, used to run the app, is older than the version {dev_version} used during development.")

if wf_project.use_pyproject(app_path):
logger.warning("You should update the version of writer in the pyproject.toml.")
elif wf_project.use_requirement(app_path):
logger.warning("You should update the version of writer in the requirements.txt.")
else:
logger.warning("You should update the version of writer.")

if mode == "edit":
logger.warning(f"The version {VERSION}, used to edit the app, is older than the version {dev_version} used in previous development.")

logger.warning("")

def _execute_server_setup_hook(user_app_path: str) -> None:
"""
Runs the server_setup.py module if present in the application directory.
Expand Down
5 changes: 3 additions & 2 deletions src/writer/ss_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ class ComponentDefinition(TypedDict):
class WorkflowExecutionLog(BaseModel):
summary: List[Dict]


class WriterConfigurationError(ValueError):
pass
pass


Loading

0 comments on commit 44ed26a

Please sign in to comment.