Skip to content

Commit

Permalink
Merge pull request #202 from whole-tale/k8s
Browse files Browse the repository at this point in the history
Add support for K8s deployments
  • Loading branch information
Xarthisius committed May 22, 2024
2 parents e086030 + 288e5c8 commit f683734
Show file tree
Hide file tree
Showing 29 changed files with 2,353 additions and 971 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
gwvolman.egg-info/
*.pyc
*venv*
.coverage
Testing
build/
dist/
pip-wheel-metadata/
.vscode/
gwvolman.code-workspace
22 changes: 14 additions & 8 deletions gwvolman/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
DEFAULT_USER = 1000
DEFAULT_GROUP = 100
ENABLE_WORKSPACES = True
MOUNTPOINTS = ["data", "home"]
MOUNTPOINTS = ["data"]
if ENABLE_WORKSPACES:
MOUNTPOINTS.append("workspace")
# MOUNTPOINTS.append("workspace")
MOUNTPOINTS.append("versions")
MOUNTPOINTS.append("runs")

Expand All @@ -23,15 +23,13 @@
LICENSE_PATH = os.environ.get("WT_LICENSE_PATH", "/licenses/")

REPO2DOCKER_VERSION = os.environ.get(
"REPO2DOCKER_VERSION",
"wholetale/repo2docker_wholetale:latest"
)
GIRDERFS_IMAGE = os.environ.get(
"GIRDERFS_IMAGE",
"wholetale/girderfs:latest"
"REPO2DOCKER_VERSION", "wholetale/repo2docker_wholetale:latest"
)
GIRDERFS_IMAGE = os.environ.get("GIRDERFS_IMAGE", "wholetale/girderfs:latest")
CPR_VERSION = os.environ.get("CPR_VERSION", "wholetale/wt-cpr:latest")
VOLUMES_ROOT = os.environ.get("WT_VOLUMES_PATH", "/mnt/homes")
NFS_PATH = os.environ.get("NFS_PATH", "/srv/vc_crypt/IMQCAM/girder")
NFS_SERVER = os.environ.get("NFS_SERVER")

RUN_WT_BUTTON_IMG = (
"https://img.shields.io/badge/WholeTale-Run!-579ACA.svg?"
Expand Down Expand Up @@ -98,3 +96,11 @@ class RunStatus(object):
COMPLETED = 3
FAILED = 4
CANCELED = 5


CREATE_VOLUME_STEP_TOTAL = 2
LAUNCH_CONTAINER_STEP_TOTAL = 2
UPDATE_CONTAINER_STEP_TOTAL = 2
IMPORT_TALE_STEP_TOTAL = 2
RECORDED_RUN_STEP_TOTAL = 4
BUILD_TALE_IMAGE_STEP_TOTAL = 2
8 changes: 8 additions & 0 deletions gwvolman/r2d/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os
from .docker import DockerImageBuilder # noqa
from .kaniko import KanikoImageBuilder # noqa

if os.environ.get("DEPLOYMENT", "docker") == "k8s":
ImageBuilder = KanikoImageBuilder
else:
ImageBuilder = DockerImageBuilder
164 changes: 71 additions & 93 deletions gwvolman/build_utils.py → gwvolman/r2d/builder.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,24 @@
import base64
import docker
import hashlib
import json
import logging
import os
from packaging import version
import shutil
import tempfile
from urllib.parse import urlparse

from .constants import R2D_FILENAMES
from .utils import (
_get_container_config,
import requests
from packaging import version

from ..constants import R2D_FILENAMES
from ..utils import (
DEPLOYMENT,
_get_container_config,
_get_stata_license_path,
DummyTask,
stop_container
)


class DockerHelper:
def __init__(self, auth=True):
username = os.environ.get("REGISTRY_USER", "fido")
password = os.environ.get("REGISTRY_PASS")
self.cli = docker.from_env(version="1.28")
self.apicli = docker.APIClient(base_url="unix://var/run/docker.sock")
if auth:
self.cli.login(
username=username, password=password, registry=DEPLOYMENT.registry_url
)
self.apicli.login(
username=username, password=password, registry=DEPLOYMENT.registry_url
)


class ImageBuilder:
class ImageBuilderBase:
_build_context = None

@property
Expand All @@ -61,7 +45,6 @@ def __init__(self, gc, imageId=None, tale=None, auth=True):
raise ValueError("Only one of 'imageId' and 'tale' can be set")

self.gc = gc
self.dh = DockerHelper(auth=auth)
if tale is None:
tale = {
"_id": None,
Expand All @@ -73,12 +56,7 @@ def __init__(self, gc, imageId=None, tale=None, auth=True):
self.container_config = _get_container_config(gc, self.tale)

def pull_r2d(self):
try:
self.dh.cli.images.pull(self.container_config.repo2docker_version)
except docker.errors.NotFound:
raise ValueError(
f"Requested r2d image '{self.container_config.repo2docker_version}' not found."
)
raise NotImplementedError()

def _create_build_context(self):
temp_dir = tempfile.mkdtemp()
Expand Down Expand Up @@ -160,18 +138,16 @@ def get_tag(self, force=False):
return f"{registry_netloc}/tale/{env_hash.hexdigest()}:{output_digest}"

def run_r2d(self, tag, dry_run=False, task=None):
"""
Run repo2docker on the workspace using a shared temp directory. Note that
this uses the "local" provider. Use the same default user-id and
user-name as BinderHub
"""
raise NotImplementedError()

task = task or DummyTask
def push_image(self, image):
raise NotImplementedError()

def extra_args(self, dry_run=False):
# Extra arguments for r2d
extra_args = ""
extra_args = "--debug"
if self.container_config.buildpack == "MatlabBuildPack":
extra_args = " --build-arg FILE_INSTALLATION_KEY={} ".format(
extra_args += " --build-arg FILE_INSTALLATION_KEY={}".format(
os.environ.get("MATLAB_FILE_INSTALLATION_KEY")
)
elif self.container_config.buildpack == "StataBuildPack" and not dry_run:
Expand All @@ -182,70 +158,72 @@ def run_r2d(self, tag, dry_run=False, task=None):
encoded = base64.b64encode(stata_license.encode("ascii")).decode(
"ascii"
)
extra_args = " --build-arg STATA_LICENSE_ENCODED='{}' ".format(encoded)
extra_args += " --build-arg STATA_LICENSE_ENCODED='{}'".format(encoded)
return extra_args

def r2d_command(self, tag, dry_run=False):
extra_args = self.extra_args(dry_run=dry_run)
op = "--no-build" if dry_run else "--no-run"
target_repo_dir = os.path.join(self.container_config.target_mount, "workspace")
r2d_cmd = (
return (
f"jupyter-repo2docker {self.engine} "
"--config='/wholetale/repo2docker_config.py' "
f"--target-repo-dir='{target_repo_dir}' "
"--config=/wholetale/repo2docker_config.py "
f"--target-repo-dir={target_repo_dir} "
f"--user-id=1000 --user-name={self.container_config.container_user} "
f"--no-clean {op} --debug {extra_args} "
f"--image-name {tag} {self.build_context}"
)

r2d_context_dir = os.path.relpath(self.build_context, tempfile.gettempdir())
host_r2d_context_dir = os.path.join(DEPLOYMENT.tmpdir_mount, r2d_context_dir)

logging.info("Calling %s", r2d_cmd)

volumes = {
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"},
host_r2d_context_dir: {"bind": self.build_context, "mode": "ro"},
}

print(f"Using repo2docker {self.container_config.repo2docker_version}")
container = self.dh.cli.containers.run(
image=self.container_config.repo2docker_version,
command=r2d_cmd,
environment=["DOCKER_HOST=unix:///var/run/docker.sock"],
privileged=True,
detach=True,
remove=True,
volumes=volumes,
f"--no-clean {op} {extra_args} "
f"--image-name={tag} {self.build_context}"
)

# Job output must come from stdout/stderr
h = hashlib.md5("R2D output".encode())
for line in container.logs(stream=True):
if task.canceled:
task.request.chain = None
stop_container(container)
break
output = line.decode("utf-8").strip()
if not output.startswith("Using local repo"): # contains variable path
h.update(output.encode("utf-8"))
if not dry_run: # We don't want to see it.
print(output)

try:
ret = container.wait()
except docker.errors.NotFound:
ret = {"StatusCode": -123}

if ret["StatusCode"] != 0:
logging.error("Error building image")
# Since detach=True, then we need to explicitly check for the
# container exit code
return ret, h.hexdigest()

def __del__(self):
if self._build_context is not None:
shutil.rmtree(self._build_context, ignore_errors=True)

def cached_image(self, tag):
def cached_image(self, image):
"""Check if image exists in the registry"""
_, full_name = image.split("/", 1)
name, tag = full_name.split(":", 1)
try:
return self.dh.apicli.inspect_distribution(tag)
except docker.errors.NotFound:
pass
with requests.Session() as session:
session.auth = (
os.environ.get("REGISTRY_USER", "fido"),
os.environ.get("REGISTRY_PASS"),
)
base_url = (
urlparse(DEPLOYMENT.registry_url)._replace(path="/v2/").geturl()
)

req = session.get(base_url)
req.raise_for_status()

req = session.get(
f"{base_url}{name}/manifests/{tag}",
headers={
"Accept": "application/vnd.docker.distribution.manifest.v2+json"
},
)
req.raise_for_status()
manifest = req.json()
content_digest = req.headers["Docker-Content-Digest"]

config_digest = manifest["config"]["digest"]

req = session.get(
f"{base_url}{name}/blobs/{config_digest}",
headers={"Accept": manifest["config"]["mediaType"]},
)
req.raise_for_status()
config = req.json()

return {
"name": f"{urlparse(base_url).netloc}/{name}",
"tag": tag,
"digest": content_digest,
"created": config["created"],
"labels": config["config"]["Labels"],
"architecture": config["architecture"],
"os": config["os"],
}
except requests.exceptions.HTTPError as err:
if err.response.status_code == 404:
return
raise
105 changes: 105 additions & 0 deletions gwvolman/r2d/docker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import hashlib
import logging
import os
import tempfile

import docker

from ..utils import (
DEPLOYMENT,
DummyTask,
stop_container,
)
from .builder import ImageBuilderBase


class DockerHelper:
def __init__(self, auth=True):
username = os.environ.get("REGISTRY_USER", "fido")
password = os.environ.get("REGISTRY_PASS")
self.cli = docker.from_env(version="1.28")
self.apicli = docker.APIClient(base_url="unix://var/run/docker.sock")
if auth:
self.cli.login(
username=username, password=password, registry=DEPLOYMENT.registry_url
)
self.apicli.login(
username=username, password=password, registry=DEPLOYMENT.registry_url
)


class DockerImageBuilder(ImageBuilderBase):
def __init__(self, gc, imageId=None, tale=None, auth=True):
super().__init__(gc, imageId=imageId, tale=tale, auth=auth)
self.dh = DockerHelper(auth)

def pull_r2d(self):
try:
self.dh.cli.images.pull(self.container_config.repo2docker_version)
except docker.errors.NotFound:
raise ValueError(
f"Requested r2d image '{self.container_config.repo2docker_version}' not found."
)

def push_image(self, image):
"""Push image to the registry"""
repository, tag = image.split(":", 1)
for line in self.dh.apicli.push(repository, tag=tag, stream=True, decode=True):
print(line)

def run_r2d(self, tag, dry_run=False, task=None):
"""
Run repo2docker on the workspace using a shared temp directory. Note that
this uses the "local" provider. Use the same default user-id and
user-name as BinderHub
"""

task = task or DummyTask

# Extra arguments for r2d
r2d_cmd = self.r2d_command(tag, dry_run=dry_run)
r2d_context_dir = os.path.relpath(self.build_context, tempfile.gettempdir())
host_r2d_context_dir = os.path.join(DEPLOYMENT.tmpdir_mount, r2d_context_dir)

logging.info("Calling %s", r2d_cmd)

volumes = {
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"},
host_r2d_context_dir: {"bind": self.build_context, "mode": "ro"},
}

print(f"Using repo2docker {self.container_config.repo2docker_version}")
container = self.dh.cli.containers.run(
image=self.container_config.repo2docker_version,
command=r2d_cmd,
environment=["DOCKER_HOST=unix:///var/run/docker.sock"],
privileged=True,
detach=True,
remove=False,
volumes=volumes,
)

# Job output must come from stdout/stderr
h = hashlib.md5("R2D output".encode())
for line in container.logs(stream=True):
if task.canceled:
task.request.chain = None
stop_container(container)
break
output = line.decode("utf-8").strip()
if not output.startswith("Using local repo"): # contains variable path
h.update(output.encode("utf-8"))
if not dry_run: # We don't want to see it.
print(output)

try:
ret = container.wait()
except docker.errors.NotFound:
ret = {"StatusCode": -123}
container.remove()

if ret["StatusCode"] != 0:
logging.error("Error building image")
# Since detach=True, then we need to explicitly check for the
# container exit code
return ret, h.hexdigest()
Loading

0 comments on commit f683734

Please sign in to comment.