Skip to content

Commit

Permalink
Adding initial support for inspecting images on singularity platforms. (
Browse files Browse the repository at this point in the history
#317)

Current implementation is quite naive. It supports several cases:
  - Primary distribution format is SIF file (singularity binary image). In this case, the MLCube hash is sha256 of this file.
  - Primary distribution format is a source tree with singularity definition file. In this case, the MLCube hash is sha256 of the image file built from this definition file.
  - Primary distribution format is docker image hosted on docker hub. In this case, the MLCube hash is the image ID of the original docker image (extracted from image manifest). It is assumed all images are hosted on docker hub. It makes two API calls - one to retrieve authorization token for pulling from the image repository, and another one to pull image manifest. This implementation is not robust in current implementation. The subsequent call to `configure` may pull a new version of the image should these two calls (identifying hash and configuring MLCube) happen simoultaneously with MLCube owner uploading a newer version.

To avoid getting logging messages, set log level to ERROR:
```shell
mlcube --log-level=error inspect --mlcube=. --platform=singularity
```
  • Loading branch information
sergey-serebryakov authored Jul 24, 2023
1 parent 157e6f7 commit 3b3fffc
Show file tree
Hide file tree
Showing 2 changed files with 250 additions and 8 deletions.
151 changes: 150 additions & 1 deletion runners/mlcube_singularity/mlcube_singularity/singularity_client.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import json
import logging
import typing as t
from enum import Enum
from pathlib import Path

import requests
import semver

from mlcube.errors import ExecutionError
from mlcube.shell import Shell
from mlcube.system_settings import SystemSettings

__all__ = ["Runtime", "Version", "Client"]
__all__ = ["Runtime", "Version", "ImageSpec", "Client", "DockerHubClient"]

logger = logging.getLogger(__name__)

Expand All @@ -31,6 +33,25 @@ def __str__(self) -> str:
return f"Version(runtime={self.runtime.name}, version={self.version})"


class ImageSpec(Enum):
"""Build specification format for building singularity images.
Primary purpose of this enum is to help MLCube guess how to compute the hash for MLCube-based project.
"""

OTHER = 0
"""Other type pretty much means everything that's not covered by types defined below."""

DOCKER = 1
"""Docker image ('docker://')."""

DOCKER_ARCHIVE = 2
"""Local tar files ('docker-archive:')."""

SINGULARITY = 3
"""Singularity Image File."""


class Client:
"""Singularity container platform client.
Expand Down Expand Up @@ -222,3 +243,131 @@ def run(
f"Error occurred while running MLCube task. See context for more details.",
**err.context,
)

def image_spec(self, uri: str) -> ImageSpec:
if uri.startswith("docker://"):
return ImageSpec.DOCKER

if uri.startswith("docker-archive:"):
return ImageSpec.DOCKER_ARCHIVE

if not Path(uri).is_file():
logger.warning(
"Client.image_spec URI (%s) not a file. Can't identify image spec.", uri
)
return ImageSpec.OTHER

exit_code, _ = Shell.run_and_capture_output(
self.singularity + ["sif", "header", uri]
)
if exit_code == 0:
return ImageSpec.SINGULARITY


class DockerHubClient:
"""Ad-hoc implementation for interacting with remote docker registries."""

def __init__(self, singularity_: Client) -> None:
"""
self.token: t.Optional[str] = None
if singularity.version.runtime == Runtime.APPTAINER:
config_paths = [
Path("~/.apptainer/docker-config.json").expanduser(),
Path("~/.singularity/docker-config.json").expanduser(),
]
else:
config_paths = [
Path("~/.singularity/docker-config.json").expanduser(),
Path("~/.apptainer/docker-config.json").expanduser(),
]
config_paths.append(Path("~/.docker/config.json").expanduser())
for config_path in config_paths:
if not config_path.is_file():
logger.debug("DockerHubClient.__init__ no such file: %s", config_path.as_posix())
continue
with open(config_path, 'rt') as file:
config = json.load(file)
if not isinstance(config, dict) or \
"auths" not in config or \
"https://index.docker.io/v1/" not in config["auths"] or \
"auth" not in config["auths"]["https://index.docker.io/v1/"]:
logger.debug("DockerHubClient.__init__: no docker.io credentials in %s", config_path.as_posix())
continue
self.token = config["auths"]["https://index.docker.io/v1/"]["auth"]
logger.debug("DockerHubClient.__init__: found auth credentials in %s.", config_path.as_posix())
break
if not self.token:
logger.warning(
"DockerHubClient.__init__: could not credentials to authenticate in docker registry in %s",
[name.as_posix() for name in config_paths]
)
"""
pass

def get_image_manifest(self, image_name: str) -> t.Dict:
"""Return image manifest pulled from a remote docker registry.
Args:
image_name: Docker image name, e.g., docker://mlcommons/mnist:0.0.1
Returns:
Dictionary containing image manifest pulled from docker registry.
"""
user, repository, tag = self.parse_image_name(image_name)
token = self.get_token(user, repository)

url = f"https://registry-1.docker.io/v2/{user}/{repository}/manifests/{tag}"
headers = {
"Accept": "application/vnd.docker.distribution.manifest.v2+json",
"Authorization": f"Bearer {token}",
}
response = requests.get(url, headers=headers)
if response.status_code != 200:
raise ValueError(
f"Failed to get image manifest (status={response.status_code}, url={url}, response={response.text}"
)
return response.json()

@staticmethod
def get_token(user: str, repository: str) -> str:
"""Return authentication token for pulling from {user}/{repository} repository.
Args:
user: Username in a remote docker registry.
repository: Repository name in a remote docker registry.
Returns:
Access token for pulling from {user}/{repository} repository. It should be used in the Authorization header:
`"Authorization": f"Bearer {token}"`.
"""
url = f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{user}/{repository}:pull"
response = requests.get(url)
if response.status_code != 200:
raise ValueError(
f"Failed to get token (status={response.status_code}, url={url}, response={response.text}"
)
return response.json()["token"]

@staticmethod
def parse_image_name(name: str) -> t.Tuple[str, str, str]:
"""Parse image name and return username, repository name and image tag.
Args:
name: Image name.
Returns:
Tuple containing username, repository name and image tag.
"""
if name.startswith("docker:"):
name = name[7:]
while True:
if len(name) > 0 and name[0] == "/":
name = name[1:]
else:
break
name_tag = name.split(":")
if len(name_tag) != 2:
raise ValueError(f"Unsupported image name: {name}")
user_repository = name_tag[0].split("/")
if len(user_repository) != 2:
raise ValueError(f"Unsupported image name: {name}")

return (user_repository[0], user_repository[1], name_tag[1])
107 changes: 100 additions & 7 deletions runners/mlcube_singularity/mlcube_singularity/singularity_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import typing as t
from pathlib import Path

from mlcube_singularity.singularity_client import Client
from mlcube_singularity.singularity_client import Client, DockerHubClient, ImageSpec
from omegaconf import DictConfig, OmegaConf

from mlcube.errors import ConfigurationError, ExecutionError
from mlcube.errors import ConfigurationError, ExecutionError, MLCubeError
from mlcube.runner import Runner, RunnerConfig
from mlcube.shell import Shell
from mlcube.validate import Validate
Expand Down Expand Up @@ -154,11 +154,11 @@ def configure(self) -> None:
"""Build Singularity Image on a current host."""
s_cfg: DictConfig = self.mlcube.runner
self.client.build(
self.mlcube.runtime.root,
s_cfg.build_file,
s_cfg.image_dir,
s_cfg.image,
s_cfg.build_args,
build_dir=self.mlcube.runtime.root,
recipe=s_cfg.build_file,
image_dir=s_cfg.image_dir,
image_name=s_cfg.image,
build_args=s_cfg.build_args,
)

def run(self) -> None:
Expand Down Expand Up @@ -216,3 +216,96 @@ def run(self) -> None:
# By contract, custom entry points do not accept task name as the first argument.
task_args = task_args[1:]
self.client.run(run_args, volumes, str(image_file), task_args, entrypoint)

def inspect(self, force: bool = False) -> t.Dict:
s_cfg: DictConfig = self.mlcube.runner
image_file = Path(s_cfg.image_dir, s_cfg.image)

def _local_file_sha256sum(file_path: Path) -> str:
"""Compute sha256 hash sum of the local file."""
_exit_code, _output = Shell.run_and_capture_output(
["sha256sum", file_path.as_posix()]
)
if _exit_code != 0:
_output = _output.replace("\n", " ")
raise MLCubeError(
f"SingularityRun.inspect failed to compute sha256 sum of the local file. File={file_path}, "
f"sha256sum_exitcode={_exit_code}, sha256sum_output={_output}"
)
return _output.split(" ")[0].strip()

if not s_cfg.build_file:
# The build specs do not exist. This probably means that the SIF file must exist.
if not image_file.is_file():
raise MLCubeError(
"The build file (build_file) that specifies how a SIF image is to be built is not specified or "
"empty. This means the SIF image must exist (image_dir=%s, image_name=%s) but it does not. "
"Inspection failed.",
s_cfg.image_dir,
s_cfg.image,
)
logger.debug(
"SingularityRun.inspect: build file (%s) is not specified, but SIF image exists (%s) - will use it to "
"compute hash.",
s_cfg.build_file,
image_file.as_posix(),
)
return {"hash": _local_file_sha256sum(image_file)}

if s_cfg.build_file.startswith("docker-archive:"):
# MLCube is distributed as docker save image (tar archive): I (sergey) guess we need to recover ID of the
# original docker image from the tar archive.
raise MLCubeError(
"SingularityRun.inspect: docker archives not supported yet."
)

if s_cfg.build_file.startswith("docker:"):
# MLCube is distributed as docker image: need to identify image ID of this image by querying the docker
# registry (docker hub).
# TODO: Current implementation makes an API call to docker registry. It's quite possible that the next call
# (e.g., configure) will pull a newer version of this image. Need to address this in subsequent
# patches.
docker_hub = DockerHubClient(self.client)
manifest = docker_hub.get_image_manifest(s_cfg.build_file)
logger.debug(
"SingularityRun.inspect build file is a docker image (%s) - I will consider it as a distribution "
"format for this MLCube, and MLCube hash will be docker image ID. Image manifest: %s",
s_cfg.build_file,
manifest,
)
return {"hash": manifest["config"]["digest"][7:]}

# Here, the recipe file (s_cfg.build_file) must point to a singularity image file. Is there an easy way to
# validate it here?
recipe_file = Path(self.mlcube.runtime.root, s_cfg.build_file)
if not recipe_file.is_file():
raise MLCubeError(
f"SingularityRun.inspect: the build file ({s_cfg.build_file}) is specified, and it is assumed it is a "
f"singularity definition file, but it does not exist ({recipe_file.as_posix()}). Can't identify how "
"this MLCube is distributed."
)

if not image_file.is_file():
if not force:
raise MLCubeError(
f"SingularityRun.inspect: SIF image file does not exist ({image_file}), but build recipe file "
f"exist ({recipe_file}). It is assumed that this MLCube is distributed as a singularity image, and "
"I need this image to identify its hash, but `force` parameter is set to false. Configure this "
"MLCube or set this parameter to true (e.g., rerun inspect command with `--force` CLi switch)."
)
logger.debug(
"SingularityRun.inspect build recipe file exists (%s), SIF image file does not exist (%s), and `force` "
"parameter is set to true - will build SIF image and will use it to compute hash.",
recipe_file.as_posix(),
image_file.as_posix(),
)
self.configure()
else:
logger.debug(
"SingularityRun.inspect: build file (%s) is specified, build recipe exists (%s), SIF image exists (%s) "
"- will use it to compute hash.",
s_cfg.build_file,
recipe_file.as_posix(),
image_file.as_posix(),
)
return {"hash": _local_file_sha256sum(image_file)}

0 comments on commit 3b3fffc

Please sign in to comment.