From 08fe3b633b301841569a3d08f8839cc4eb5edc5e Mon Sep 17 00:00:00 2001 From: mike0sv Date: Thu, 1 Dec 2022 21:03:31 +0200 Subject: [PATCH 1/4] Add fly.io deployments --- mlem/contrib/flyio/__init__.py | 0 mlem/contrib/flyio/meta.py | 131 +++++++++++++++++++++++++++++++++ mlem/contrib/flyio/utils.py | 60 +++++++++++++++ setup.py | 3 + 4 files changed, 194 insertions(+) create mode 100644 mlem/contrib/flyio/__init__.py create mode 100644 mlem/contrib/flyio/meta.py create mode 100644 mlem/contrib/flyio/utils.py diff --git a/mlem/contrib/flyio/__init__.py b/mlem/contrib/flyio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mlem/contrib/flyio/meta.py b/mlem/contrib/flyio/meta.py new file mode 100644 index 00000000..90a3594a --- /dev/null +++ b/mlem/contrib/flyio/meta.py @@ -0,0 +1,131 @@ +import tempfile +from typing import ClassVar, Optional + +from pydantic import BaseModel + +from mlem.api import build +from mlem.config import MlemConfigBase, project_config +from mlem.contrib.docker import DockerDirBuilder +from mlem.contrib.flyio.utils import ( + check_flyctl_exec, + get_status, + read_fly_toml, + run_flyctl, +) +from mlem.core.objects import ( + DeployState, + DeployStatus, + MlemDeployment, + MlemEnv, + MlemModel, +) +from mlem.runtime.client import Client, HTTPClient + +# TODO: did not find refenrence for possible values +FLYIO_STATE_MAPPING = { + "running": DeployStatus.RUNNING, + "deployed": DeployStatus.RUNNING, + "pending": DeployStatus.STARTING, + "suspended": DeployStatus.STOPPED, + "dead": DeployStatus.CRASHED, +} + + +class FlyioConfig(MlemConfigBase): + class Config: + section = "flyio" + + region: str = "lax" + + +class FlyioSettings(BaseModel): + org: Optional[str] = None + region: Optional[str] = None + + +class FlyioEnv(MlemEnv, FlyioSettings): + type: ClassVar = "flyio" + + access_token: Optional[str] = None + + +class FlyioAppState(DeployState): + type: ClassVar = "flyio" + + fly_toml: Optional[str] + app_name: Optional[str] + hostname: Optional[str] + + +class FlyioApp(MlemDeployment, FlyioSettings): + type: ClassVar = "flyio" + + state_type: ClassVar = FlyioAppState + env_type: ClassVar = FlyioEnv + + image: Optional[str] + app_name: Optional[str] + + # server: Server = None # TODO + def _get_client(self, state: FlyioAppState) -> Client: + return HTTPClient(host=f"https://{state.hostname}", port=443) + + def _build_in_dir(self, model: MlemModel, state: FlyioAppState): + with tempfile.TemporaryDirectory( + prefix="mlem_flyio_build_" + ) as tempdir: + build(DockerDirBuilder(target=tempdir), model) + + args = { + "auto-confirm": True, + "region": self.region + or self.get_env().region + or project_config("", section=FlyioConfig).region, + "now": True, + } + if self.app_name: + args["name"] = self.app_name + else: + args["generate-name"] = True + if self.get_env().access_token: + args["access-token"] = self.get_env().access_token + run_flyctl("launch", workdir=tempdir, kwargs=args) + state.fly_toml = read_fly_toml(tempdir) + state.update_model(model) + + status = get_status(workdir=tempdir) + state.app_name = status.Name + state.hostname = status.Hostname + self.update_state(state) + + def deploy(self, model: MlemModel): + check_flyctl_exec() + with self.lock_state(): + state: FlyioAppState = self.get_state() + + if state.fly_toml is None: + self._build_in_dir(model, state) + else: + raise NotImplementedError("No flyio redeploy yet") + + def remove(self): + check_flyctl_exec() + with self.lock_state(): + state: FlyioAppState = self.get_state() + if state.app_name is not None: + run_flyctl( + f"apps destroy {state.app_name}", kwargs={"yes": True} + ) # lol + self.purge_state() + + def get_status(self, raise_on_error=True) -> "DeployStatus": + check_flyctl_exec() + with self.lock_state(): + state: FlyioAppState = self.get_state() + + if state.fly_toml is None or state.app_name is None: + return DeployStatus.NOT_DEPLOYED + + status = get_status(app_name=state.app_name) + + return FLYIO_STATE_MAPPING[status.Status] diff --git a/mlem/contrib/flyio/utils.py b/mlem/contrib/flyio/utils.py new file mode 100644 index 00000000..fdc81e07 --- /dev/null +++ b/mlem/contrib/flyio/utils.py @@ -0,0 +1,60 @@ +import json +import os.path +import subprocess +from typing import Any, Dict + +from pydantic import BaseModel, parse_obj_as + +from mlem.core.errors import DeploymentError + +FLY_TOML = "fly.toml" + + +def check_flyctl_exec(): + try: + run_flyctl("version") + except subprocess.SubprocessError as e: + raise DeploymentError( + "flyctl executable is not available. Please install it using " + ) from e + + +def run_flyctl( + command: str, workdir: str = None, kwargs: Dict[str, Any] = None +): + kwargs = kwargs or {} + cmd = ( + ["flyctl"] + + command.split(" ") + + " ".join( + [ + f"--{k} {v}" if v is not True else f"--{k}" + for k, v in kwargs.items() + ] + ).split() + ) + return subprocess.check_output(cmd, cwd=workdir) + + +def read_fly_toml(workdir: str): + with open(os.path.join(workdir, FLY_TOML), encoding="utf8") as f: + return f.read() + + +def place_fly_toml(workdir: str, fly_toml: str): + with open(os.path.join(workdir, FLY_TOML), "w", encoding="utf8") as f: + f.write(fly_toml) + + +class FlyioStatusModel(BaseModel): + Name: str + Status: str + Hostname: str + + +def get_status(workdir: str = None, app_name: str = None) -> FlyioStatusModel: + args: Dict[str, Any] = {"json": True} + if app_name is not None: + args["app"] = app_name + status = run_flyctl("status", kwargs=args, workdir=workdir) + return parse_obj_as(FlyioStatusModel, json.loads(status)) diff --git a/setup.py b/setup.py index 05422321..27930b38 100644 --- a/setup.py +++ b/setup.py @@ -161,6 +161,9 @@ "artifact.dvc = mlem.contrib.dvc:DVCArtifact", "storage.dvc = mlem.contrib.dvc:DVCStorage", "server.fastapi = mlem.contrib.fastapi:FastAPIServer", + "deployment.flyio = mlem.contrib.flyio.meta:FlyioApp", + "deploy_state.flyio = mlem.contrib.flyio.meta:FlyioAppState", + "env.flyio = mlem.contrib.flyio.meta:FlyioEnv", "resolver.local_git = mlem.contrib.git:LocalGitResolver", "resolver.github = mlem.contrib.github:GithubResolver", "resolver.gitlab = mlem.contrib.gitlabfs:GitlabResolver", From 2e5ad53026b030c19c28078112364670b00eb1dc Mon Sep 17 00:00:00 2001 From: mike0sv Date: Thu, 1 Dec 2022 21:13:34 +0200 Subject: [PATCH 2/4] fix docs --- mlem/contrib/flyio/__init__.py | 5 +++++ mlem/contrib/flyio/meta.py | 15 ++++++++++++++- mlem/ext.py | 3 +++ setup.py | 2 ++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/mlem/contrib/flyio/__init__.py b/mlem/contrib/flyio/__init__.py index e69de29b..a5f61726 100644 --- a/mlem/contrib/flyio/__init__.py +++ b/mlem/contrib/flyio/__init__.py @@ -0,0 +1,5 @@ +"""fly.io Deployments support +Extension type: deployment + +Implements MlemEnv, MlemDeployment and DeployState to work with fly.io +""" diff --git a/mlem/contrib/flyio/meta.py b/mlem/contrib/flyio/meta.py index 90a3594a..09a02cdb 100644 --- a/mlem/contrib/flyio/meta.py +++ b/mlem/contrib/flyio/meta.py @@ -21,7 +21,6 @@ ) from mlem.runtime.client import Client, HTTPClient -# TODO: did not find refenrence for possible values FLYIO_STATE_MAPPING = { "running": DeployStatus.RUNNING, "deployed": DeployStatus.RUNNING, @@ -40,31 +39,45 @@ class Config: class FlyioSettings(BaseModel): org: Optional[str] = None + """Organization name""" region: Optional[str] = None + """Region name""" class FlyioEnv(MlemEnv, FlyioSettings): + """fly.io organization/account""" + type: ClassVar = "flyio" access_token: Optional[str] = None + """Access token for fly.io. Alternatively use `flyctl auth login`""" class FlyioAppState(DeployState): + """fly.io app state""" + type: ClassVar = "flyio" fly_toml: Optional[str] + """Contents of fly.toml file for app""" app_name: Optional[str] + """Application name""" hostname: Optional[str] + """Application hostname""" class FlyioApp(MlemDeployment, FlyioSettings): + """fly.io deployment""" + type: ClassVar = "flyio" state_type: ClassVar = FlyioAppState env_type: ClassVar = FlyioEnv image: Optional[str] + """Image name for docker image""" app_name: Optional[str] + """Application name. Leave empty for auto-generated one""" # server: Server = None # TODO def _get_client(self, state: FlyioAppState) -> Client: diff --git a/mlem/ext.py b/mlem/ext.py index 5637484f..60a1b2a5 100644 --- a/mlem/ext.py +++ b/mlem/ext.py @@ -119,6 +119,9 @@ class ExtensionLoader: Extension("mlem.contrib.requirements", [], False), Extension("mlem.contrib.venv", [], False), Extension("mlem.contrib.git", ["pygit2"], True), + Extension( + "mlem.contrib.flyio", ["docker", "fastapi", "uvicorn"], False + ), ) _loaded_extensions: Dict[Extension, ModuleType] = {} diff --git a/setup.py b/setup.py index 27930b38..921c3fa7 100644 --- a/setup.py +++ b/setup.py @@ -87,6 +87,7 @@ "kubernetes": ["docker", "kubernetes"], "dvc": ["dvc~=2.0"], "git": ["pygit2"], + "flyio": ["docker", "fastapi", "uvicorn"], } # add DVC extras @@ -241,6 +242,7 @@ "core = mlem.config:MlemConfig", "bitbucket = mlem.contrib.bitbucketfs:BitbucketConfig", "docker = mlem.contrib.docker.context:DockerConfig", + "flyio = mlem.contrib.flyio.meta:FlyioConfig", "heroku = mlem.contrib.heroku.config:HerokuConfig", "pandas = mlem.contrib.pandas:PandasConfig", "aws = mlem.contrib.sagemaker.config:AWSConfig", From ab2e39416f0ae6cfa9b071244c5384cd5751eb1c Mon Sep 17 00:00:00 2001 From: mike0sv Date: Mon, 26 Dec 2022 18:05:44 +0300 Subject: [PATCH 3/4] update flyio stuff --- mlem/contrib/flyio/meta.py | 56 ++++++++++++++++++++++++++++++------- mlem/contrib/flyio/utils.py | 34 ++++++++++++++++++++-- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/mlem/contrib/flyio/meta.py b/mlem/contrib/flyio/meta.py index 09a02cdb..d612a126 100644 --- a/mlem/contrib/flyio/meta.py +++ b/mlem/contrib/flyio/meta.py @@ -3,12 +3,15 @@ from pydantic import BaseModel +from mlem import ui from mlem.api import build from mlem.config import MlemConfigBase, project_config from mlem.contrib.docker import DockerDirBuilder from mlem.contrib.flyio.utils import ( check_flyctl_exec, + get_scale, get_status, + place_fly_toml, read_fly_toml, run_flyctl, ) @@ -20,6 +23,7 @@ MlemModel, ) from mlem.runtime.client import Client, HTTPClient +from mlem.runtime.server import Server FLYIO_STATE_MAPPING = { "running": DeployStatus.RUNNING, @@ -74,27 +78,28 @@ class FlyioApp(MlemDeployment, FlyioSettings): state_type: ClassVar = FlyioAppState env_type: ClassVar = FlyioEnv - image: Optional[str] + image: Optional[str] = None """Image name for docker image""" - app_name: Optional[str] + app_name: Optional[str] = None """Application name. Leave empty for auto-generated one""" + scale_memory: Optional[int] = None + """Set VM memory to a number of megabytes (256/512/1024 etc)""" + # TODO other scale params + + server: Optional[Server] = None - # server: Server = None # TODO def _get_client(self, state: FlyioAppState) -> Client: return HTTPClient(host=f"https://{state.hostname}", port=443) - def _build_in_dir(self, model: MlemModel, state: FlyioAppState): + def _create_app(self, state: FlyioAppState): with tempfile.TemporaryDirectory( prefix="mlem_flyio_build_" ) as tempdir: - build(DockerDirBuilder(target=tempdir), model) - args = { "auto-confirm": True, "region": self.region or self.get_env().region or project_config("", section=FlyioConfig).region, - "now": True, } if self.app_name: args["name"] = self.app_name @@ -104,22 +109,53 @@ def _build_in_dir(self, model: MlemModel, state: FlyioAppState): args["access-token"] = self.get_env().access_token run_flyctl("launch", workdir=tempdir, kwargs=args) state.fly_toml = read_fly_toml(tempdir) - state.update_model(model) status = get_status(workdir=tempdir) state.app_name = status.Name state.hostname = status.Hostname self.update_state(state) + def _scale_app(self, state: FlyioAppState): + if self.scale_memory is None: + return + current_scale = get_scale(app_name=state.app_name) + + if current_scale.MemoryMB != self.scale_memory: + run_flyctl( + f"scale memory {self.scale_memory}", + kwargs={"app": state.app_name}, + ) + ui.echo(f"Scaled {state.app_name} memory to {self.scale_memory}MB") + + def _build_in_dir(self, model: MlemModel, state: FlyioAppState): + with tempfile.TemporaryDirectory( + prefix="mlem_flyio_build_" + ) as tempdir: + assert state.fly_toml is not None + place_fly_toml(tempdir, state.fly_toml) + build(DockerDirBuilder(target=tempdir, server=self.server), model) + + args = {} + if self.get_env().access_token: + args["access-token"] = self.get_env().access_token + + run_flyctl("deploy", workdir=tempdir, kwargs=args) + state.fly_toml = read_fly_toml(tempdir) + state.update_model(model) + self.update_state(state) + def deploy(self, model: MlemModel): check_flyctl_exec() with self.lock_state(): state: FlyioAppState = self.get_state() if state.fly_toml is None: + self._create_app(state) + + self._scale_app(state) + + if self.model_changed(model, state): self._build_in_dir(model, state) - else: - raise NotImplementedError("No flyio redeploy yet") def remove(self): check_flyctl_exec() diff --git a/mlem/contrib/flyio/utils.py b/mlem/contrib/flyio/utils.py index fdc81e07..4200cf60 100644 --- a/mlem/contrib/flyio/utils.py +++ b/mlem/contrib/flyio/utils.py @@ -12,7 +12,7 @@ def check_flyctl_exec(): try: - run_flyctl("version") + run_flyctl("version", wrap_error=False) except subprocess.SubprocessError as e: raise DeploymentError( "flyctl executable is not available. Please install it using " @@ -20,7 +20,10 @@ def check_flyctl_exec(): def run_flyctl( - command: str, workdir: str = None, kwargs: Dict[str, Any] = None + command: str, + workdir: str = None, + kwargs: Dict[str, Any] = None, + wrap_error=True, ): kwargs = kwargs or {} cmd = ( @@ -33,7 +36,12 @@ def run_flyctl( ] ).split() ) - return subprocess.check_output(cmd, cwd=workdir) + try: + return subprocess.check_output(cmd, cwd=workdir) + except subprocess.SubprocessError as e: + if wrap_error: + raise DeploymentError(e) from e + raise def read_fly_toml(workdir: str): @@ -58,3 +66,23 @@ def get_status(workdir: str = None, app_name: str = None) -> FlyioStatusModel: args["app"] = app_name status = run_flyctl("status", kwargs=args, workdir=workdir) return parse_obj_as(FlyioStatusModel, json.loads(status)) + + +class FlyioScaleModel(BaseModel): + Name: str + CPUCores: int + CPUClass: str + MemoryGB: float + MemoryMB: int + PriceMonth: float + PriceSecond: float + Count: str + MaxPerRegion: str + + +def get_scale(workdir: str = None, app_name: str = None) -> FlyioScaleModel: + args: Dict[str, Any] = {"json": True} + if app_name is not None: + args["app"] = app_name + status = run_flyctl("scale show", kwargs=args, workdir=workdir) + return parse_obj_as(FlyioScaleModel, json.loads(status)) From 2c2e1ded3edb44e130b08b97b5329c0fcb0b4df8 Mon Sep 17 00:00:00 2001 From: mike0sv Date: Tue, 17 Jan 2023 13:56:38 +0300 Subject: [PATCH 4/4] fix tests --- mlem/contrib/flyio/meta.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mlem/contrib/flyio/meta.py b/mlem/contrib/flyio/meta.py index d612a126..0dcb7b3d 100644 --- a/mlem/contrib/flyio/meta.py +++ b/mlem/contrib/flyio/meta.py @@ -87,6 +87,7 @@ class FlyioApp(MlemDeployment, FlyioSettings): # TODO other scale params server: Optional[Server] = None + """Server to use""" def _get_client(self, state: FlyioAppState) -> Client: return HTTPClient(host=f"https://{state.hostname}", port=443)