Skip to content
This repository was archived by the owner on Sep 13, 2023. It is now read-only.

Commit 3bc673e

Browse files
mike0svaguschin
andauthored
Add server field to heroku deploy (#631)
closes #568 Heroku requires you to use `PORT` env to expose a port. I changed `HerokuServer` to wrap arbitrary server (instead of subclassing `FastAPIServer`) and swap port for value from env. For that it needs to know which value is to swap, so I added `port_field` classvar to servers. --------- Co-authored-by: Alexander Guschin <[email protected]>
1 parent 6609613 commit 3bc673e

File tree

7 files changed

+78
-17
lines changed

7 files changed

+78
-17
lines changed

mlem/contrib/fastapi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class FastAPIServer(Server, LibRequirementsMixin):
5353

5454
libraries: ClassVar[List[ModuleType]] = [uvicorn, fastapi]
5555
type: ClassVar[str] = "fastapi"
56+
port_field: ClassVar = "port"
5657

5758
host: str = "0.0.0.0"
5859
"""Network interface to use"""

mlem/contrib/heroku/build.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33

44
from mlem.core.objects import MlemModel
55

6+
from ...runtime.server import Server
67
from ...ui import EMOJI_BUILD, echo, set_offset
7-
from ..docker.base import DockerEnv, DockerImage, RemoteRegistry
8+
from ..docker.base import DockerImage, RemoteRegistry
89
from ..docker.helpers import build_model_image
910
from .server import HerokuServer
1011

@@ -41,23 +42,21 @@ def login(self, client):
4142

4243
def build_heroku_docker(
4344
meta: MlemModel,
45+
server: Server,
4446
app_name: str,
4547
process_type: str = "web",
4648
api_key: str = None,
4749
push: bool = True,
4850
) -> DockerImage:
49-
docker_env = DockerEnv(
50-
registry=HerokuRemoteRegistry(
51-
host="registry.heroku.com", api_key=api_key
52-
)
53-
)
5451
echo(EMOJI_BUILD + "Creating docker image for heroku")
5552
with set_offset(2):
5653
return build_model_image(
5754
meta,
5855
process_type,
59-
server=HerokuServer(),
60-
env=docker_env,
56+
server=HerokuServer(server=server),
57+
registry=HerokuRemoteRegistry(
58+
host="registry.heroku.com", api_key=api_key
59+
),
6160
repository=app_name,
6261
force_overwrite=True,
6362
# heroku does not support arm64 images built on Mac M1 devices

mlem/contrib/heroku/meta.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
)
1313
from mlem.runtime.client import Client, HTTPClient
1414

15+
from ...config import project_config
1516
from ...core.errors import DeploymentError
17+
from ...runtime.server import Server
1618
from ...ui import EMOJI_OK, echo
1719
from ..docker.base import DockerImage
1820
from .build import build_heroku_docker
@@ -77,6 +79,8 @@ class HerokuDeployment(MlemDeployment[HerokuState, HerokuEnv]):
7779
"""Stack to use"""
7880
team: Optional[str] = None
7981
"""Heroku team"""
82+
server: Optional[Server] = None
83+
"""Server to use"""
8084

8185
def _get_client(self, state: HerokuState) -> Client:
8286
return HTTPClient(
@@ -95,7 +99,13 @@ def deploy(self, model: MlemModel):
9599
redeploy = False
96100
if state.image is None or self.model_changed(model):
97101
state.image = build_heroku_docker(
98-
model, state.app.name, api_key=self.get_env().api_key
102+
model,
103+
self.server
104+
or project_config(
105+
self.loc.project if self.is_saved else None
106+
).server,
107+
state.app.name,
108+
api_key=self.get_env().api_key,
99109
)
100110
state.update_model(model)
101111
self.update_state(state)

mlem/contrib/heroku/server.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,47 @@
11
import logging
22
import os
3-
from typing import ClassVar
3+
from typing import ClassVar, Dict
44

5-
from mlem.contrib.fastapi import FastAPIServer
5+
from pydantic import validator
6+
7+
from mlem.core.requirements import Requirements
68
from mlem.runtime import Interface
9+
from mlem.runtime.server import Server
710

811
logger = logging.getLogger(__name__)
912

1013

11-
class HerokuServer(FastAPIServer):
14+
class HerokuServer(Server):
1215
"""Special FastAPI server to pickup port from env PORT"""
1316

1417
type: ClassVar = "_heroku"
18+
server: Server
19+
20+
@validator("server")
21+
@classmethod
22+
def server_validator(cls, value: Server):
23+
if value.port_field is None:
24+
raise ValueError(
25+
f"{value} does not have port field and can not be exposed on heroku"
26+
)
27+
return value
1528

1629
def serve(self, interface: Interface):
17-
self.port = int(os.environ["PORT"])
18-
logger.info("Switching port to %s", self.port)
19-
return super().serve(interface)
30+
assert self.server.port_field is not None # ensured by validator
31+
setattr(self.server, self.server.port_field, int(os.environ["PORT"]))
32+
logger.info(
33+
"Switching port to %s",
34+
getattr(self.server, self.server.port_field),
35+
)
36+
return self.server.serve(interface)
37+
38+
def get_requirements(self) -> Requirements:
39+
return self.server.get_requirements()
40+
41+
def get_env_vars(self) -> Dict[str, str]:
42+
env_vars = super().get_env_vars()
43+
env_vars.update(self.server.get_env_vars())
44+
return env_vars
45+
46+
def get_sources(self) -> Dict[str, bytes]:
47+
return self.server.get_sources()

mlem/contrib/streamlit/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import tempfile
66
from threading import Thread
77
from time import sleep
8-
from typing import ClassVar, Dict, Optional
8+
from typing import ClassVar, Dict, List, Optional
99

1010
import streamlit
1111
import streamlit_pydantic
@@ -52,6 +52,7 @@ class StreamlitServer(Server, StreamlitScript, LibRequirementsMixin):
5252

5353
type: ClassVar = "streamlit"
5454
libraries: ClassVar = (streamlit, streamlit_pydantic)
55+
port_field: ClassVar = "ui_port"
5556

5657
run_server: bool = True
5758
"""Whether to run backend server or use existing one"""
@@ -159,3 +160,6 @@ def get_sources(self) -> Dict[str, bytes]:
159160
sources[template] = f.read()
160161
self.template = template
161162
return sources
163+
164+
def get_ports(self) -> List[int]:
165+
return [self.ui_port, self.server_port]

mlem/runtime/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ class Config:
118118
abs_name: ClassVar[str] = "server"
119119
env_vars: ClassVar[Optional[Dict[str, str]]] = None
120120
additional_source_files: ClassVar[Optional[List[str]]] = None
121+
port_field: ClassVar[Optional[str]] = None
121122

122123
# @validator("interface")
123124
# @classmethod
@@ -158,6 +159,11 @@ def get_requirements(self) -> Requirements:
158159
[self.request_serializer, self.response_serializer, self.methods]
159160
)
160161

162+
def get_ports(self) -> List[int]:
163+
if self.port_field is not None:
164+
return [getattr(self, self.port_field)]
165+
return []
166+
161167

162168
class ServerInterface(Interface):
163169
type: ClassVar = "_server"

tests/contrib/test_heroku.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
import pytest
66
import requests
7+
import testcontainers.core.container
78
from docker import DockerClient
89
from sklearn.datasets import load_iris
910
from sklearn.ensemble import RandomForestClassifier
11+
from testcontainers.core.waiting_utils import wait_for_logs
1012

1113
from mlem.api.commands import deploy
14+
from mlem.contrib.fastapi import FastAPIServer
1215
from mlem.contrib.heroku.build import (
1316
DEFAULT_HEROKU_REGISTRY,
1417
build_heroku_docker,
@@ -28,6 +31,7 @@
2831
)
2932
from mlem.core.errors import DeploymentError
3033
from mlem.core.objects import DeployStatus, MlemModel
34+
from mlem.runtime.client import HTTPClient
3135
from tests.conftest import flaky, long
3236

3337
heroku = pytest.mark.skipif(
@@ -97,12 +101,21 @@ def test_create_app(heroku_app_name, heroku_env, model):
97101

98102
@long
99103
def test_build_heroku_docker(model: MlemModel, uses_docker_build):
100-
image_meta = build_heroku_docker(model, "test_build", push=False)
104+
image_meta = build_heroku_docker(
105+
model, FastAPIServer(), "test_build", push=False
106+
)
101107
client = DockerClient.from_env()
102108
image = client.images.get(image_meta.image_id)
103109
assert image is not None
104110
assert f"{DEFAULT_HEROKU_REGISTRY}/test_build/web:latest" in image.tags
105111

112+
with testcontainers.core.container.DockerContainer(
113+
image_meta.uri
114+
).with_env("PORT", "4567").with_bind_ports(4567, 80) as c:
115+
wait_for_logs(c, ".*Uvicorn running on.*")
116+
client = HTTPClient(port=80)
117+
assert client.interface is not None
118+
106119

107120
def test_state_ensured_app():
108121
state = HerokuState(declaration=HerokuDeployment(app_name=""))

0 commit comments

Comments
 (0)