From 18672c908659dadc77ca78250b05d925c8f828be Mon Sep 17 00:00:00 2001 From: Ivan Chvets Date: Fri, 30 Jun 2023 15:44:51 -0400 Subject: [PATCH] feat: mlserver-mlflow rock https://github.com/canonical/seldon-core-operator/issues/133 Summary of changes: - Created rockcraft file for mlserver-mlflow rock according to best practices spec. - Added unit test. - Added tox.ini --- mlserver-mlflow/rockcraft.yaml | 115 +++++++++++++++++++++++++++++ mlserver-mlflow/tests/test_rock.py | 47 ++++++++++++ mlserver-mlflow/tox.ini | 76 +++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 mlserver-mlflow/rockcraft.yaml create mode 100644 mlserver-mlflow/tests/test_rock.py create mode 100644 mlserver-mlflow/tox.ini diff --git a/mlserver-mlflow/rockcraft.yaml b/mlserver-mlflow/rockcraft.yaml new file mode 100644 index 0000000..d9962b9 --- /dev/null +++ b/mlserver-mlflow/rockcraft.yaml @@ -0,0 +1,115 @@ +# Based on https://github.com/SeldonIO/MLServer/blob/1.2.0/Dockerfile +name: mlserver-mlflow +summary: An image for Seldon MLServer MLflow +description: | + This image is used as part of the Charmed Kubeflow product. +version: 1.2.0_22.04_1 # __ +license: Apache-2.0 +base: ubuntu:22.04 +run-user: _daemon_ +services: + mlserver-mlflow: + override: replace + summary: "mlserver-mlflow service" + startup: enabled + # We need to build and activate the "hot-loaded" environment before MLServer starts + command: bash -c 'export PATH=/opt/conda/bin/:/opt/mlserver/.local/bin:${PATH} && \ + export PYTHONPATH=/opt/mlserver/.local/lib/python3.8/site-packages/:${PYTHONPATH} && \ + source /opt/conda/etc/profile.d/conda.sh && \ + source /hack/activate-env.sh $MLSERVER_ENV_TARBALL && \ + mlserver start $MLSERVER_MODELS_DIR' + environment: + MLSERVER_ENV_TARBALL: "/mnt/models" + MLSERVER_MODELS_DIR: "/mnt/models/environment.tar.gz" +platforms: + amd64: + +parts: + mlserver-mlflow: + plugin: nil + source: https://github.com/SeldonIO/MLServer + source-type: git + source-tag: 1.2.0 + build-packages: + - bash + - tar + - gzip + # TO-DO: Verify need for the packages below + # - libgomp + # - mesa-libGL + # - glib2-devel + # - shadow-utils + stage-packages: + - bash + override-build: | + + mkdir -p ${CRAFT_PART_INSTALL}/opt/mlserver/dist + cp ${CRAFT_PART_SRC}/setup.py . + cp ${CRAFT_PART_SRC}/README.md . + + ./hack/build-wheels.sh ${CRAFT_PART_INSTALL}/opt/mlserver/dist + + override-stage: | + export ROCK_RUNTIME="mlflow" + export PYTHON_VERSION="3.8.13" + export CONDA_VERSION="4.13.0" + export RUNTIMES="mlserver_${ROCK_RUNTIME}" + export MINIFORGE_VERSION="${CONDA_VERSION}-1" + export MLSERVER_PATH=opt/mlserver + export CONDA_PATH=opt/conda + export PATH=/opt/mlserver/.local/bin:/opt/conda/bin:$PATH + + # Install Conda, Python 3.8 and FFmpeg + curl -L -o ~/miniforge3.sh https://github.com/conda-forge/miniforge/releases/download/${MINIFORGE_VERSION}/Miniforge3-${MINIFORGE_VERSION}-Linux-x86_64.sh + bash ~/miniforge3.sh -b -u -p ${CONDA_PATH} + rm ~/miniforge3.sh + ${CONDA_PATH}/bin/conda install --yes conda=${CONDA_VERSION} python=${PYTHON_VERSION} ffmpeg + ${CONDA_PATH}/bin/conda clean -tipy + mkdir -p etc/profile.d + ln -sf ${CONDA_PATH}/etc/profile.d/conda.sh etc/profile.d/conda.sh + echo ". ${CONDA_PATH}/etc/profile.d/conda.sh" >> ~/.bashrc + echo "PATH=${PATH}" >> ~/.bashrc + bash -c "${CONDA_PATH}/bin/conda init bash" + echo "conda activate base" >> ~/.bashrc + chgrp -R root opt/conda && chmod -R g+rw opt/conda + + # conda writes shebangs with its path everywhere, and in crafting, that will be, for example: + # #!/root/stage/opt/conda/... + # + # Snip off the /root/stage part + bash -c "grep -R -E '/root/stage' opt/ 2>/dev/null | grep -v Bin | awk '{split(\$0,out,\":\"); print out[1]}' | uniq | xargs -I{} sed -i -e 's/\/root\/stage//' {}" + + # install required wheels + mkdir -p $MLSERVER_PATH + mkdir -p ./wheels + cp -p ${CRAFT_PART_INSTALL}/opt/mlserver/dist/mlserver-*.whl ./wheels + cp -p ${CRAFT_PART_INSTALL}/opt/mlserver/dist/mlserver_${ROCK_RUNTIME}-*.whl ./wheels + pip install --prefix ${MLSERVER_PATH}/.local $(ls "./wheels/mlserver-"*.whl) + pip install --prefix ${MLSERVER_PATH}/.local $(ls "./wheels/mlserver_${ROCK_RUNTIME}-"*.whl) + + # replace first line of mlserver script with reference to installed Conda python + export CONDA_PYTHON="#\!\/opt\/conda\/bin\/python" + sed -i "1s/.*/${CONDA_PYTHON}/" ${MLSERVER_PATH}/.local/bin/mlserver + + # clean wheels + rm -rf ./wheels + + override-prime: | + # copy all artifacts + cp -rp ${CRAFT_STAGE}/opt . + + # copy required files + mkdir -p licenses + cp ${CRAFT_PART_SRC}/licenses/license.txt licenses/ + mkdir -p hack + cp ${CRAFT_PART_SRC}/hack/build-env.sh hack/ + cp ${CRAFT_PART_SRC}/hack/generate_dotenv.py hack/ + cp ${CRAFT_PART_SRC}/hack/activate-env.sh hack/ + + security-team-requirement: + plugin: nil + override-build: | + mkdir -p ${CRAFT_PART_INSTALL}/usr/share/rocks + (echo "# os-release" && cat /etc/os-release && echo "# dpkg-query" && \ + dpkg-query --root=${CRAFT_PROJECT_DIR}/../bundles/ubuntu-22.04/rootfs/ -f '${db:Status-Abbrev},${binary:Package},${Version},${source:Package},${Source:Version}\n' -W) \ + > ${CRAFT_PART_INSTALL}/usr/share/rocks/dpkg.query diff --git a/mlserver-mlflow/tests/test_rock.py b/mlserver-mlflow/tests/test_rock.py new file mode 100644 index 0000000..01336c1 --- /dev/null +++ b/mlserver-mlflow/tests/test_rock.py @@ -0,0 +1,47 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +# +# + +from pathlib import Path + +import os +import logging +import random +import pytest +import string +import subprocess +import yaml + +from pytest_operator.plugin import OpsTest +from charmed_kubeflow_chisme.rock import CheckRock + +@pytest.fixture() +def rock_test_env(tmpdir): + """Yields a temporary directory and random docker container name, then cleans them up after.""" + container_name = "".join([str(i) for i in random.choices(string.ascii_lowercase, k=8)]) + yield tmpdir, container_name + + try: + subprocess.run(["docker", "rm", container_name]) + except Exception: + pass + # tmpdir fixture we use here should clean up the other files for us + +@pytest.mark.abort_on_fail +def test_rock(ops_test: OpsTest, rock_test_env): + """Test rock.""" + temp_dir, container_name = rock_test_env + check_rock = CheckRock("rockcraft.yaml") + rock_image = check_rock.get_image_name() + rock_version = check_rock.get_version() + LOCAL_ROCK_IMAGE = f"{check_rock.get_image_name()}:{check_rock.get_version()}" + + # TO-DO uncomment when updated chisme is published + #rock_services = check_rock.get_services() + #assert rock_services["mlserver-mlflow"] + #assert rock_services["mlserver-mlflow"]["startup"] == "enabled" + + # create ROCK filesystem + subprocess.run(["docker", "run", LOCAL_ROCK_IMAGE, "exec", "ls", "-la", "/opt/mlserver/.local/lib/python3.8/site-packages/mlserver"], check=True) + subprocess.run(["docker", "run", LOCAL_ROCK_IMAGE, "exec", "ls", "-la", "/opt/mlserver/.local/bin/mlserver"], check=True) diff --git a/mlserver-mlflow/tox.ini b/mlserver-mlflow/tox.ini new file mode 100644 index 0000000..ad26a02 --- /dev/null +++ b/mlserver-mlflow/tox.ini @@ -0,0 +1,76 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +[tox] +skipsdist = True +skip_missing_interpreters = True + +[testenv] +setenv = + PYTHONPATH={toxinidir} + PYTHONBREAKPOINT=ipdb.set_trace + CHARM_REPO=https://github.com/canonical/seldon-core-operator.git + CHARM_BRANCH=main + LOCAL_CHARM_DIR=charm_repo + +[testenv:unit] +passenv = * +allowlist_externals = + bash + tox + rockcraft +deps = + juju~=2.9.0 + pytest + pytest-operator + ops + charmed_kubeflow_chisme +commands = + # build and pack rock + rockcraft pack + bash -c 'NAME=$(yq eval .name rockcraft.yaml) && \ + VERSION=$(yq eval .version rockcraft.yaml) && \ + ARCH=$(yq eval ".platforms | keys" rockcraft.yaml | awk -F " " '\''{ print $2 }'\'') && \ + ROCK="$\{NAME\}_$\{VERSION\}_$\{ARCH\}" && \ + sudo skopeo --insecure-policy copy oci-archive:$ROCK.rock docker-daemon:$ROCK:$VERSION' + + # run rock tests + pytest -vvv --tb native --show-capture=all --log-cli-level=INFO {posargs} {toxinidir}/tests + +[testenv:integration] +passenv = * +allowlist_externals = + bash + git + rm + tox + rockcraft + sed +deps = + juju~=2.9.0 + pytest + pytest-operator + ops +commands = + # build and pack rock + rockcraft pack + # clone related charm + rm -rf {env:LOCAL_CHARM_DIR} + git clone --branch {env:CHARM_BRANCH} {env:CHARM_REPO} {env:LOCAL_CHARM_DIR} + # replace jinja2 templated value with yq safe placeholder + sed -i "s/namespace: {{ namespace }}/namespace: YQ_SAFE/" {env:LOCAL_CHARM_DIR}/src/templates/configmap.yaml.j2 + # upload rock to docker and microk8s cache, replace charm's container with local rock reference + bash -c 'NAME=$(yq eval .name rockcraft.yaml) && \ + VERSION=$(yq eval .version rockcraft.yaml) && \ + ARCH=$(yq eval ".platforms | keys" rockcraft.yaml | awk -F " " '\''{ print $2 }'\'') && \ + ROCK="$\{NAME\}_$\{VERSION\}_$\{ARCH\}" && \ + sudo skopeo --insecure-policy copy oci-archive:$ROCK.rock docker-daemon:$ROCK:$VERSION && \ + docker save $ROCK > $ROCK.tar && \ + microk8s ctr image import $ROCK.tar --digests=true && \ + predictor_servers=$(yq e ".data.predictor_servers" {env:LOCAL_CHARM_DIR}/src/templates/configmap.yaml.j2) && \ + predictor_servers=$(jq --arg jq_rock $ROCK -r '\''.MLFLOW_SERVER.protocols.v2.image=$jq_rock'\'' <<< $predictor_servers) && \ + predictor_servers=$(jq --arg jq_version $VERSION -r '\''.MLFLOW_SERVER.protocols.v2.defaultImageVersion=$jq_version'\'' <<< $predictor_servers) yq e -i ".data.predictor_servers=strenv(predictor_servers)" {env:LOCAL_CHARM_DIR}/src/templates/configmap.yaml.j2' + # replace yq safe placeholder with original value + sed -i "s/namespace: YQ_SAFE/namespace: {{ namespace }}/" {env:LOCAL_CHARM_DIR}/src/templates/configmap.yaml.j2 + # run charm integration test with rock + tox -c {env:LOCAL_CHARM_DIR} -e integration +