Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: prevent writer framework version mismatch between local & deploy - WF-67 #626

Draft
wants to merge 2 commits into
base: dev
Choose a base branch
from
Draft
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
291 changes: 145 additions & 146 deletions poetry.lock

Large diffs are not rendered by default.

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.5.0, < 2"
python-multipart = ">=0.0.7, < 1"
toml = "^0.10.2"
packaging = "^24.2"

[tool.poetry.group.build]
optional = true
Expand Down Expand Up @@ -75,6 +77,7 @@ redis = "^5.2.1"

[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
109 changes: 80 additions & 29 deletions src/writer/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
import pytz
import requests
from gitignore_parser import parse_gitignore
from packaging.version import Version

from writer import wf_project

DEPLOY_TIMEOUT = int(os.getenv("WRITER_DEPLOY_TIMEOUT", 20))

Expand All @@ -22,15 +25,16 @@ 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 @@ -48,6 +52,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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very small one but now that I spotted it it bothers me 😅 Can you use either click.ClickException or ClickException?


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 @@ -58,12 +72,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 @@ -74,15 +87,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 @@ -98,23 +112,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 @@ -143,11 +158,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 @@ -179,6 +198,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 @@ -188,6 +208,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 @@ -199,8 +220,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 @@ -213,6 +235,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 @@ -228,6 +251,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 @@ -248,8 +272,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 @@ -269,6 +293,7 @@ def upload_package(deploy_url, tar, token, env, verbose=False, sleep_interval=5)
start_time = end_time

if status == "COMPLETED":
_check_version_integrity(url, token)
print("Deployment successful")
print(f"URL: {url}")
sys.exit(0)
Expand All @@ -283,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 @@ -291,7 +317,32 @@ 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, token: str):
"""
Check if the writer version in the deployment package is newer than the writer version running on the server.

>>> _check_version_integrity("https://xxxxxxxxxxxxxxxx.ai.writer.build", "xxxxxxxxxxxxxxxxxxxxxxxxxx")
"""
try:
with requests.get(
f"{app_url}/_meta",
headers={
"Authorization": f"Bearer {token}",
},
) 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
except requests.exceptions.RequestException as e:
print("Ignore local / cloud version integrity check")
logging.debug(f"Error checking version integrity {e}", exc_info=e)
33 changes: 32 additions & 1 deletion src/writer/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
from fastapi.responses import FileResponse, JSONResponse
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, crypto
from writer import VERSION, abstract, crypto, wf_project
from writer.app_runner import AppRunner
from writer.ss_types import (
AppProcessServerResponse,
Expand Down Expand Up @@ -181,6 +182,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 @@ -965,6 +967,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
2 changes: 1 addition & 1 deletion src/writer/ss_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,6 @@ class ComponentDefinition(TypedDict):
class WorkflowExecutionLog(BaseModel):
summary: List[Dict]


class WriterConfigurationError(ValueError):
pass

Loading
Loading