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

Implementation of the STEMMUS_SCOPE BMI, +docs +gprc4bmi #89

Merged
merged 44 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1e3011d
First implementation of the STEMMUS_SCOPE BMI
BSchilperoort Nov 17, 2023
facb2b3
Use STEMMUS_SCOPE's interactive mode, h5py for IO
BSchilperoort Nov 22, 2023
5f7f418
Fix type errors, comply with linters & formatter
BSchilperoort Nov 22, 2023
bdc3e3f
Implement all required BMI methods
BSchilperoort Nov 28, 2023
4de0a59
Add a notebook demonstrating the BMI
BSchilperoort Nov 28, 2023
fc2438d
Fix update_until bug in BMI
BSchilperoort Nov 28, 2023
8576160
Make type hints compatible with py 3.8
BSchilperoort Nov 29, 2023
87a2b0d
Add set/get value at indices methods
BSchilperoort Nov 29, 2023
0226af0
Apply code formatting
BSchilperoort Nov 29, 2023
aa6dd0d
Add support for dockerized model.
BSchilperoort Jan 4, 2024
902b1f8
Add docker as optional dependency
BSchilperoort Jan 10, 2024
e806224
Add support for exe file from env. variable.
BSchilperoort Jan 10, 2024
4d5eb7c
Fix volume binds & root file issue. Check image tags.
BSchilperoort Jan 10, 2024
98fbcff
WIP Dockerfile for grpc4bmi
BSchilperoort Jan 10, 2024
93066b7
Add start on BMI documentation
BSchilperoort Jan 10, 2024
88ec40b
Remove Docker container upon clean finalize
BSchilperoort Jan 11, 2024
71c06aa
Move BMI code to bmi module
BSchilperoort Jan 11, 2024
cb95e3c
Deprecate Py3.8. Add support for Py3.11
BSchilperoort Jan 11, 2024
d7f39bd
Fix typing and formatting issues
BSchilperoort Jan 11, 2024
7fa1aaa
Add h5py to dependencies
BSchilperoort Jan 11, 2024
4e1098d
Update netcdf4 version pin
BSchilperoort Jan 11, 2024
c439178
Split of docker utils. Automagically pull docker image
BSchilperoort Jan 11, 2024
c2a101a
Remove unneeded docker.errors import
BSchilperoort Jan 11, 2024
71f8cb8
Make ruff happy
BSchilperoort Jan 11, 2024
1bbd352
Set GID correctly when starting container.
BSchilperoort Jan 23, 2024
310e03c
Ensure state is writable
BSchilperoort Jan 23, 2024
828b69b
Add matplotlib to dependencies
BSchilperoort Jan 23, 2024
eb4d385
Make Docker and local process communication more robust
BSchilperoort Jan 25, 2024
daf0910
Add BMI tests w/ Docker (linux only on GH Actions)
BSchilperoort Jan 25, 2024
3057ea5
Exclude untestable parts from coverage
BSchilperoort Jan 25, 2024
f8e38b4
Rename test file to test_bmi_docker. Add first batch of tests.
BSchilperoort Jan 25, 2024
a803209
Expand BMI tests, fix BMI bugs.
BSchilperoort Jan 26, 2024
93ce52a
Pin black to previous stable version
BSchilperoort Jan 26, 2024
b0ca6fd
Additional tests for BMI error messages
BSchilperoort Jan 26, 2024
09a52c1
Remove unused import
BSchilperoort Jan 26, 2024
1667729
Add tests for tag validation
BSchilperoort Jan 26, 2024
b705c6d
Update BMI documentation, add to mkdocs
BSchilperoort Jan 26, 2024
0e98061
Fix links to BMI notebook
BSchilperoort Jan 26, 2024
787103d
Add gprc4bmi dockerfile, instructions/docs.
BSchilperoort Jan 26, 2024
fbc7818
Correct grpc typo
BSchilperoort Jan 26, 2024
b0a7ff8
Also correct notebook name
BSchilperoort Jan 26, 2024
05300bd
Improve reading of stdout
BSchilperoort Jan 30, 2024
84a7f21
Add more explanation to the grpc demo
BSchilperoort Jan 30, 2024
95d4188
Improve the documentation on the docker images/files.
BSchilperoort Jan 30, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ['3.8', '3.9', '3.10']
python-version: ['3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down
33 changes: 33 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
FROM ghcr.io/ecoextreml/stemmus_scope:1.5.0

LABEL maintainer="Bart Schilperoort <[email protected]>"
LABEL org.opencontainers.image.source = "https://github.com/EcoExtreML/STEMMUS_SCOPE_Processing"

# Requirements for building Python 3.10
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y build-essential zlib1g-dev libncurses5-dev libgdbm-dev \
libnss3-dev libssl-dev libreadline-dev libffi-dev libsqlite3-dev wget libbz2-dev
RUN apt-get install -y libhdf5-serial-dev

# Get Python source and compile
WORKDIR /python
RUN wget https://www.python.org/ftp/python/3.10.12/Python-3.10.12.tgz --no-check-certificate
RUN tar -xf Python-3.10.*.tgz
WORKDIR /python/Python-3.10.12
RUN ./configure --prefix=/usr/local --enable-optimizations --enable-shared LDFLAGS="-Wl,-rpath /usr/local/lib"
RUN make -j $(nproc)
RUN make altinstall
WORKDIR /

# Pip install PyStemmusScope and dependencies
COPY . /opt/PyStemmusScope
RUN pip3.10 install /opt/PyStemmusScope/[docker]
RUN pip3.10 install grpc4bmi==0.5.0

# # Set the STEMMUS_SCOPE environmental variable, so the BMI can find the executable
WORKDIR /
ENV STEMMUS_SCOPE /STEMMUS_SCOPE

EXPOSE 55555
# Start grpc4bmi server
CMD run-bmi-server --name "PyStemmusScope.bmi.implementation.StemmusScopeBmi" --port 55555 --debug
163 changes: 163 additions & 0 deletions PyStemmusScope/bmi/docker_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""The Docker STEMMUS_SCOPE model process wrapper."""
import os
import socket as pysocket
import warnings
from time import sleep
from typing import Any
from PyStemmusScope.bmi.docker_utils import check_tags
from PyStemmusScope.bmi.docker_utils import find_image
from PyStemmusScope.bmi.docker_utils import make_docker_vols_binds
from PyStemmusScope.bmi.utils import MATLAB_ERROR
from PyStemmusScope.bmi.utils import PROCESS_FINALIZED
from PyStemmusScope.bmi.utils import PROCESS_READY
from PyStemmusScope.bmi.utils import MatlabError
from PyStemmusScope.config_io import read_config


try:
import docker
except ImportError:
docker = None


def _model_is_ready(socket: Any, client: Any, container_id: Any) -> None:
return _wait_for_model(PROCESS_READY, socket, client, container_id)


def _model_is_finalized(socket: Any, client: Any, container_id: Any) -> None:
return _wait_for_model(PROCESS_FINALIZED, socket, client, container_id)


def _wait_for_model(phrase: bytes, socket: Any, client: Any, container_id: Any) -> None:
"""Wait for the model to be ready to receive (more) commands, or is finalized."""
output = b""

while phrase not in output:
try:
data = socket.read(1)
except TimeoutError as err:
client.stop(container_id)
logs = client.logs(container_id).decode("utf-8")
msg = (
f"Container connection timed out '{container_id['Id']}'."
f"\nPlease inspect logs:\n{logs}"
)
raise TimeoutError(msg) from err

if data is None:
msg = "Could not read data from socket. Docker container might be dead."
raise ConnectionError(msg)
else:
output += bytes(data)

if MATLAB_ERROR in output:
client.stop(container_id)
logs = client.logs(container_id).decode("utf-8")
msg = (
f"Error in container '{container_id['Id']}'.\n"
f"Please inspect logs:\n{logs}"
)
raise MatlabError(msg)


def _attach_socket(client, container_id) -> Any:
"""Attach a socket to a container and add a timeout to it."""
connection_timeout = 30 # seconds

socket = client.attach_socket(container_id, {"stdin": 1, "stdout": 1, "stream": 1})
if isinstance(socket, pysocket.SocketIO):
socket._sock.settimeout(connection_timeout) # type: ignore
else:
warnings.warn(
message=(
"Unknown socket type found. This might cause issues with the Docker"
" connection. \nPlease report this to the developers in an issue "
"on: https://github.com/EcoExtreML/STEMMUS_SCOPE_Processing/issues"
),
stacklevel=1,
)
return socket


class StemmusScopeDocker:
"""Communicate with a STEMMUS_SCOPE Docker container."""

# Default image, can be overridden with config:
compatible_tags = ("1.5.0",)

_process_ready_phrase = b"Select BMI mode:"
_process_finalized_phrase = b"Finished clean up."

def __init__(self, cfg_file: str):
"""Create the Docker container.."""
self.cfg_file = cfg_file
config = read_config(cfg_file)

self.image = config["DockerImage"]
find_image(self.image)
check_tags(self.image, self.compatible_tags)

self.client = docker.APIClient()

vols, binds = make_docker_vols_binds(cfg_file)
self.container_id = self.client.create_container(
self.image,
stdin_open=True,
tty=True,
detach=True,
user=f"{os.getuid()}:{os.getgid()}", # ensure correct user for writing files.
volumes=vols,
host_config=self.client.create_host_config(binds=binds),
)

self.running = False

def _wait_for_model(self) -> None:
"""Wait for the model to be ready to receive (more) commands."""
_model_is_ready(self.socket, self.client, self.container_id)

def is_alive(self) -> bool:
"""Return if the process is alive."""
return self.running

def initialize(self) -> None:
"""Initialize the model and wait for it to be ready."""
if self.is_alive():
self.client.stop(self.container_id)

self.client.start(self.container_id)
self.socket = _attach_socket(self.client, self.container_id)

self._wait_for_model()
os.write(
self.socket.fileno(),
bytes(f'initialize "{self.cfg_file}"\n', encoding="utf-8"),
)
self._wait_for_model()

self.running = True

def update(self) -> None:
"""Update the model and wait for it to be ready."""
if self.is_alive():
os.write(self.socket.fileno(), b"update\n")
self._wait_for_model()
else:
msg = "Docker container is not alive. Please restart the model."
raise ConnectionError(msg)

def finalize(self) -> None:
"""Finalize the model."""
if self.is_alive():
os.write(self.socket.fileno(), b"finalize\n")
_model_is_finalized(
self.socket,
self.client,
self.container_id,
)
sleep(0.5) # Ensure the container can stop cleanly.
self.client.stop(self.container_id)
self.running = False
self.client.remove_container(self.container_id, v=True)
else:
pass
87 changes: 87 additions & 0 deletions PyStemmusScope/bmi/docker_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Utility functions for making the docker process work."""
import warnings
from pathlib import Path
from PyStemmusScope.config_io import read_config


try:
import docker
except ImportError:
docker = None


def make_docker_vols_binds(cfg_file: str) -> tuple[list[str], list[str]]:
"""Make docker volume mounting configs.

Args:
cfg_file: Location of the config file

Returns:
volumes, binds
"""
cfg = read_config(cfg_file)
cfg_dir = Path(cfg_file).parent
volumes = []
binds = []

# Make sure no subpaths are mounted:
if not cfg_dir.is_relative_to(cfg["InputPath"]):
volumes.append(str(cfg_dir))
binds.append(f"{str(cfg_dir)}:{str(cfg_dir)}")
if (not Path(cfg["InputPath"]).is_relative_to(cfg_dir)) or (
Path(cfg["InputPath"]) == cfg_dir
):
volumes.append(cfg["InputPath"])
binds.append(f"{cfg['InputPath']}:{cfg['InputPath']}")
if not Path(cfg["OutputPath"]).is_relative_to(cfg_dir):
volumes.append(cfg["OutputPath"])
binds.append(f"{cfg['OutputPath']}:{cfg['OutputPath']}")

return volumes, binds


def check_tags(image: str, compatible_tags: tuple[str, ...]):
"""Check if the tag is compatible with this version of the BMI.

Args:
image: The full image name (including tag)
compatible_tags: Tags which are known to be compatible with this version of the
BMI.
"""
if ":" not in image:
msg = (
"Could not validate the Docker image tag, as no tag was provided.\n"
"Please set the Docker image tag in the configuration file."
)
warnings.warn(UserWarning(msg), stacklevel=1)

tag = image.split(":")[-1]
if tag not in compatible_tags:
msg = (
f"Docker image tag '{tag}' not found in compatible tags "
f"({compatible_tags}).\n"
"You might experience issues or unexpected results."
)
warnings.warn(UserWarning(msg), stacklevel=1)


def find_image(image: str) -> None:
"""See if the desired image is available, and if not, try to pull it."""
client = docker.APIClient()
images = client.images()
tags = []
for img in images:
for tag in img["RepoTags"]:
tags.append(tag)
if image not in set(tags):
pull_image(image)


def pull_image(image: str) -> None:
"""Pull the image from ghcr/dockerhub."""
if ":" in image:
image, tag = image.split(":")
else:
tag = None
client = docker.from_env()
image = client.images.pull(image, tag)
Loading
Loading