-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into feature/#255-Rename_data_science_sandbox_to_…
…exasol-ai-lab
- Loading branch information
Showing
18 changed files
with
401 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# Notebook Files | ||
|
||
DSS repository includes some Jupyter notebooks and scripts to add these notebooks to DSS images, e.g. AMI or Docker Images. | ||
|
||
Please add or update the notebook files in folder [exasol/ds/sandbox/runtime/ansible/roles/jupyter/files/notebook](../../exasol/ds/sandbox/runtime/ansible/roles/jupyter/files/notebook). | ||
|
||
## Notebook Testing | ||
|
||
We are running tests for the notebooks in the Docker Edition of the AI Lab. For this we are creating a Docker test setup in | ||
[test_notebooks_in_dss_docker_image.py](test/integration/test_notebooks_in_dss_docker_image.py) which installs test libraries into the AI Lab Docker Image. | ||
It further creates a new test and Docker Container for each notebook test in [test/notebooks](test/notebooks). | ||
Notebook test names need to fit the pattern `nbtest_*.py`, to prevent pytest running them outside of Docker setup. | ||
|
||
Environment variables with the prefix `NBTEST_` with which you call | ||
[test_notebooks_in_dss_docker_image.py](test/integration/test_notebooks_in_dss_docker_image.py) are forwarded | ||
into the Docker container and to the notebook test. You can use this to forward secrets to the notebook tests. | ||
|
||
By default all created containers and images are removed after running the tests regardless of success or failure. | ||
However, with the following pytest commandline parameters you can keep them or reuse them to speed up local testing: | ||
|
||
``` | ||
--dss-docker-image=DSS_DOCKER_IMAGE | ||
Name and version of existing Docker image to use for tests | ||
--keep-dss-docker-image | ||
Keep the created dss docker image for inspection or reuse. | ||
--docker-image-notebook-test=DOCKER_IMAGE_NOTEBOOK_TEST | ||
Name and version of existing Docker image for Notebook testing to use for tests | ||
--keep-docker-image-notebook-test | ||
Keep the created notebook-test docker image for inspection or reuse. | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import re | ||
from typing import Union | ||
|
||
import docker | ||
from docker.models.containers import Container | ||
from docker.models.images import Image | ||
|
||
|
||
def sanitize_test_name(test_name: str): | ||
test_name = re.sub('[^0-9a-zA-Z]+', '_', test_name) | ||
test_name = re.sub('_+', '_', test_name) | ||
return test_name | ||
|
||
|
||
def container(request, base_name: str, image: Union[Image, str], start: bool = True, **kwargs) -> Container: | ||
""" | ||
Create a Docker container based on the specified Docker image. | ||
""" | ||
client = docker.from_env() | ||
base_container_name = base_name.replace("-", "_") | ||
test_name = sanitize_test_name(str(request.node.name)) | ||
container_name = f"{base_container_name}_{test_name}" | ||
try: | ||
image_name = image.id if hasattr(image, "id") else image | ||
container = client.containers.create( | ||
image=image_name, | ||
name=container_name, | ||
detach=True, | ||
**kwargs | ||
) | ||
if start: | ||
container.start() | ||
yield container | ||
finally: | ||
client.containers.get(container_name).remove(force=True) | ||
client.close() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
from typing import Optional, Tuple, Callable, Union, Iterator, cast, Mapping | ||
|
||
from docker.models.containers import Container | ||
|
||
|
||
def decode_bytes(bytes): | ||
return bytes.decode("utf-8").strip() | ||
|
||
|
||
def exec_command( | ||
command: str, | ||
container: Container, | ||
print_output: bool = False, | ||
workdir: Optional[str] = None, | ||
environment: Optional[Mapping[str, str]] = None, | ||
user: str = '' | ||
) -> Optional[str]: | ||
exit_code, output = exec_run(container, command, stream=print_output, | ||
workdir=workdir, environment=environment, user=user) | ||
output_string = handle_output(output, print_output) | ||
handle_error_during_exec(command, exit_code, output_string) | ||
return output_string | ||
|
||
|
||
def exec_run(container: Container, cmd, stream=False, environment=None, workdir=None, user='') \ | ||
-> Tuple[Callable[[], Optional[int]], Union[bytes, Iterator[bytes]]]: | ||
""" | ||
Run a command in the provided Docker container and return | ||
a function to inquire the exit code and the stdout as stream or byte array. | ||
""" | ||
resp = container.client.api.exec_create( | ||
container.id, cmd, user=user, environment=environment, | ||
workdir=workdir, | ||
) | ||
exec_output = container.client.api.exec_start( | ||
resp['Id'], stream=stream | ||
) | ||
|
||
def exit_code() -> Optional[int]: | ||
return cast(Optional[int], container.client.api.exec_inspect(resp['Id'])['ExitCode']) | ||
|
||
return ( | ||
exit_code, | ||
cast(Union[bytes, Iterator[bytes]], exec_output) | ||
) | ||
|
||
|
||
def handle_output(output: Union[bytes, Iterator[bytes]], print_output: bool): | ||
output_string = None | ||
if print_output and isinstance(output, Iterator): | ||
for chunk in output: | ||
print(decode_bytes(chunk)) | ||
else: | ||
output_string = decode_bytes(output) | ||
return output_string | ||
|
||
|
||
def handle_error_during_exec(command: str, exit_code: Callable[[], Optional[int]], output_string: str): | ||
exit_code = exit_code() | ||
if exit_code != 0: | ||
if output_string: | ||
raise RuntimeError( | ||
f"Command {command} failed with exit_code {exit_code} and output_string:\n {output_string}") | ||
|
||
raise RuntimeError( | ||
f"Command {command} failed with exit_code {exit_code},") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
import json | ||
import re | ||
from datetime import datetime | ||
from typing import List, Dict, Any, Tuple, Optional | ||
|
||
import docker | ||
from docker.errors import BuildError | ||
from docker.models.images import Image | ||
|
||
|
||
def format_build_log(build_log: List[Dict[str, Any]]): | ||
def format_entry(entry: Dict[str, Any]): | ||
if "stream" in entry: | ||
return entry["stream"] | ||
if "error" in entry: | ||
return entry["error"] | ||
return "" | ||
|
||
return "\n".join(format_entry(entry) for entry in build_log) | ||
|
||
|
||
class BuildErrorWithLog(BuildError): | ||
def __init__(self, reason, build_log: List[Dict[str, Any]]): | ||
super().__init__(f"{reason}\n\n{format_build_log(build_log)}", build_log) | ||
|
||
|
||
def image(request, name: str, print_log=False, **kwargs) -> Image: | ||
""" | ||
Create a Docker image. | ||
The function supports a pair of pytest cli options with a suffix derived from parameter ``name``: | ||
Option `--docker-image-(suffix)` specifies the name of an existing image to be used | ||
instead of creating a new one. | ||
Option `--keep-docker-image-(suffix)` skips removing the image after test execution. | ||
""" | ||
base_command_line = name.replace("_", "-") | ||
image_tag = request.config.getoption(f"--docker-image-{base_command_line}") | ||
keep_image = request.config.getoption(f"--keep-docker-image-{base_command_line}") | ||
client = docker.from_env() | ||
if image_tag: | ||
return client.images.get(image_tag) | ||
timestamp = f'{datetime.now().timestamp():.0f}' | ||
image_name = name.replace("-", "_") | ||
image_tag = f"{image_name}:{timestamp}" | ||
try: | ||
log_generator = client.api.build(tag=image_tag, **kwargs) | ||
image_id, log, error = analyze_build_log(log_generator) | ||
if image_id is None: | ||
raise BuildErrorWithLog(error, log) | ||
if print_log: | ||
print(format_build_log(log)) | ||
yield client.images.get(image_id) | ||
finally: | ||
if not keep_image: | ||
client.images.remove(image_tag, force=True) | ||
client.close() | ||
|
||
|
||
def analyze_build_log(log_generator) -> Tuple[Optional[str], List[Dict[str, Any]], Optional[str]]: | ||
log = [json.loads(chunk) for chunk in log_generator] # | ||
last_event = "Unknown" | ||
for entry in log: | ||
if 'error' in entry: | ||
return None, log, entry["error"] | ||
if 'stream' in entry: | ||
match = re.search( | ||
r'(^Successfully built |sha256:)([0-9a-f]+)$', | ||
entry['stream'] | ||
) | ||
if match: | ||
image_id = match.group(2) | ||
return image_id, log, None | ||
last_event = entry | ||
return None, log, last_event |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import io | ||
import tarfile | ||
import time | ||
|
||
|
||
class InMemoryBuildContext: | ||
|
||
def __init__(self): | ||
super().__init__() | ||
self.fileobj = io.BytesIO() | ||
self._tar = tarfile.open(fileobj=self.fileobj, mode="x") | ||
|
||
def __enter__(self): | ||
return self | ||
|
||
def __exit__(self, exc_type, exc_val, exc_tb): | ||
self.close() | ||
|
||
def close(self): | ||
self._tar.close() | ||
self.fileobj.seek(0) | ||
|
||
def __del__(self): | ||
self._tar.close() | ||
|
||
def add_string_to_file(self, name: str, string: str): | ||
self.add_bytes_to_file(name, string.encode("UTF-8")) | ||
|
||
def add_bytes_to_file(self, name: str, bytes: bytes): | ||
file_obj = io.BytesIO(bytes) | ||
self.add_fileobj_to_file(bytes, file_obj, name) | ||
|
||
def add_fileobj_to_file(self, bytes, file_obj, name): | ||
tar_info = tarfile.TarInfo(name=name) | ||
tar_info.mtime = time.time() | ||
tar_info.size = len(bytes) | ||
self._tar.addfile(tarinfo=tar_info, fileobj=file_obj) | ||
|
||
def add_host_path(self, host_path: str, path_in_tar: str, recursive: bool): | ||
self._tar.add(host_path, path_in_tar, recursive) | ||
|
||
def add_directory(self, name: str): | ||
tar_info = tarfile.TarInfo(name=name) | ||
tar_info.type = tarfile.DIRTYPE | ||
self._tar.addfile(tarinfo=tar_info) |
Oops, something went wrong.