diff --git a/conda-store-server/conda_store_server/__main__.py b/conda-store-server/conda_store_server/__main__.py deleted file mode 100644 index 389e3d383..000000000 --- a/conda-store-server/conda_store_server/__main__.py +++ /dev/null @@ -1,10 +0,0 @@ -from conda_store_server import cli, logging - - -def main(): - logging.initialize_logging() - cli.cli_conda_store() - - -if __name__ == "__main__": - main() diff --git a/conda-store-server/conda_store_server/app.py b/conda-store-server/conda_store_server/app.py index c4a0309e2..28663f475 100644 --- a/conda-store-server/conda_store_server/app.py +++ b/conda-store-server/conda_store_server/app.py @@ -1,44 +1,85 @@ import os import pathlib import datetime -import logging import shutil import yaml +from traitlets import Type, Unicode, Integer +from traitlets.config import LoggingConfigurable from conda_store_server import orm, utils, storage, schema -logger = logging.getLogger(__name__) +class CondaStore(LoggingConfigurable): + storage_backend_class = Type( + default_value=storage.S3Storage, + klass=storage.Storage, + allow_none=False, + ) + + store_directory = Unicode( + 'conda-store-state', + help="directory for conda-store to build environments and store state", + ) + + environment_directory = Unicode( + 'conda-store-state/envs', + help="directory for symlinking conda environment builds", + ) + + database_url = Unicode( + os.environ.get( + 'CONDA_STORE_DATABASE_URL', + 'sqlite:///conda-store.sqlite'), + help="url for the database. e.g. 'sqlite:///conda-store.sqlite'" + ) + + default_uid = Integer( + os.getuid(), + help="default uid to assign to built environments", + config=True, + ) + + default_gid = Integer( + os.getgid(), + help="default gid to assign to built environments", + config=True, + ) + + default_permissions = Unicode( + '775', + help="default file permissions to assign to built environments", + config=True + ) -class CondaStore: - def __init__(self, store_directory, database_url=None, storage_backend="s3"): - self.store_directory = pathlib.Path(store_directory).resolve() - if not self.store_directory.is_dir(): - logger.info(f"creating directory store_directory={store_directory}") - self.store_directory.mkdir(parents=True) + @property + def session_factory(self): + if hasattr(self, '_session_factory'): + return self._session_factory - self.database_url = database_url or os.environ.get( - "CONDA_STORE_DB_URL", - f'sqlite:///{self.store_directory / "conda_store.sqlite"}', + self._session_factory = orm.new_session_factory( + url=self.database_url ) + return self._session_factory - Session = orm.new_session_factory(url=self.database_url) - self.db = Session() - - if storage_backend == schema.StorageBackend.FILESYSTEM: - storage_directory = self.store_directory / "storage" - self.storage = storage.LocalStorage(storage_directory) - elif storage_backend == schema.StorageBackend.S3: - self.storage = storage.S3Storage() + @property + def db(self): + if hasattr(self, '_db'): + return self._db - self.configuration.store_directory = str(self.store_directory) + self._db = self.session_factory() + return self._db @property def configuration(self): return orm.CondaStoreConfiguration.configuration(self.db) + def ensure_directories(self): + os.makedirs(self.store_directory, exist_ok=True) + os.makedirs(self.environment_directory, exist_ok=True) + def update_storage_metrics(self): + self.log.info('updating storage metrics') configuration = self.configuration disk_usage = shutil.disk_usage(str(self.store_directory)) configuration.disk_usage = disk_usage.used @@ -59,7 +100,7 @@ def update_conda_channels(self, channels=None, update_interval=60 * 60): + datetime.timedelta(seconds=update_interval) < datetime.datetime.now() ): - logger.info(f"packages were updated in the last seconds={update_interval}") + self.log.info(f"packages were updated in the last seconds={update_interval}") return for channel in channels: @@ -88,18 +129,18 @@ def register_environment(self, specification, namespace="library"): orm.Specification.sha256 == specification_sha256 ) if query.count() != 0: - logger.debug( + self.log.debug( f"already registered specification name={specification.name} sha256={specification_sha256}" ) return - logger.info( + self.log.info( f"registering specification name={specification.name} sha256={specification_sha256}" ) specification = orm.Specification(specification.dict()) self.db.add(specification) self.db.commit() - logger.info( + self.log.info( f"scheduling specification for build name={specification.name} sha256={specification.sha256}" ) build = orm.Build(specification_id=specification.id) diff --git a/conda-store-server/conda_store_server/build.py b/conda-store-server/conda_store_server/build.py index 34bc891fc..d4eb1e8cb 100644 --- a/conda-store-server/conda_store_server/build.py +++ b/conda-store-server/conda_store_server/build.py @@ -109,7 +109,7 @@ def package_query(package): def start_conda_build(conda_store, paths, storage_threshold, poll_interval): - logger.info(f"polling interval set to {poll_interval} seconds") + conda_store.log.info(f"polling interval set to {poll_interval} seconds") while True: environments = discover_environments(paths) for environment in environments: @@ -136,9 +136,9 @@ def start_conda_build(conda_store, paths, storage_threshold, poll_interval): def conda_build(conda_store): build = claim_build(conda_store) - store_directory = pathlib.Path(conda_store.configuration.store_directory) + store_directory = pathlib.Path(conda_store.store_directory) environment_directory = pathlib.Path( - conda_store.configuration.environment_directory + conda_store.environment_directory ) build_path = build.build_path(store_directory) environment_path = build.environment_path(environment_directory) @@ -180,9 +180,9 @@ def conda_build(conda_store): # modify permissions, uid, gid if they do not match stat_info = os.stat(build_path) - permissions = conda_store.configuration.default_permissions - uid = conda_store.configuration.default_uid - gid = conda_store.configuration.default_gid + permissions = conda_store.default_permissions + uid = conda_store.default_uid + gid = conda_store.default_gid if permissions is not None and oct(stat.S_IMODE(stat_info.st_mode))[-3:] != str( permissions diff --git a/conda-store-server/conda_store_server/cli.py b/conda-store-server/conda_store_server/cli.py deleted file mode 100644 index 38f509a19..000000000 --- a/conda-store-server/conda_store_server/cli.py +++ /dev/null @@ -1,168 +0,0 @@ -import pathlib -import logging -from typing import List - -import typer -import yaml - -from conda_store_server import schema, server, build, client -from conda_store_server.app import CondaStore - - -logger = logging.getLogger(__name__) - - -# conda-store [build, server, env] -cli_conda_store = typer.Typer() - -# conda-store env [list, create] -cli_conda_store_env = typer.Typer() -cli_conda_store.add_typer(cli_conda_store_env, name="env") - -# conda-store env package [list] -cli_conda_store_env_package = typer.Typer() -cli_conda_store_env.add_typer(cli_conda_store_env_package, name="package") - - -@cli_conda_store.command("server") -def cli_conda_store_server( - address: str = typer.Option( - "0.0.0.0", "--address", help="address to bind run conda-store ui" - ), - port: int = typer.Option(5000, help="port to run conda-store ui"), - store: str = typer.Option( - ".conda-store", "--store", "-s", help="directory for conda-store state" - ), - storage_backend: str = typer.Option( - schema.StorageBackend.S3, - "--storage-backend", - help="backend for storing build artifacts. Production should use s3", - ), - disable_ui: bool = typer.Option( - False, "--disable-ui", help="disable ui for conda store" - ), - disable_api: bool = typer.Option( - False, "--disable-api", help="disable api for conda store" - ), - disable_registry: bool = typer.Option( - False, "--disable-registry", help="disable docker registry for conda store" - ), - verbose: bool = typer.Option(False, "--verbose", help="enable debugging logging"), -): - server.start_app( - store, - storage_backend, - disable_ui=disable_ui, - disable_api=disable_api, - disable_registry=disable_registry, - address=address, - port=port, - ) - - -@cli_conda_store.command("build") -def cli_conda_store_build_command( - environment: str = typer.Option( - ..., - "--environment", - "-e", - help="environment directory for symlinking conda environment builds", - ), - store: str = typer.Option( - ".conda-store", "--store", "-s", help="directory for conda-store state" - ), - paths: List[pathlib.Path] = typer.Option( - [], - "--paths", - "-p", - help="input paths for environments directories(non-recursive) and filenames", - ), - uid: int = typer.Option(None, "--uid", help="uid to assign to built environments"), - gid: int = typer.Option(None, "--gid", help="gid to assign to built environments"), - permissions: str = typer.Option( - "", help="permissions to assign to built environments" - ), - storage_threshold: int = typer.Option( - (5 * (2 ** 30)), - "--storage-threshold", - help="emit warning when free disk space drops below threshold bytes", - ), - storage_backend: schema.StorageBackend = typer.Option( - schema.StorageBackend.S3, - "--storage-backend", - help="backend for storing build artifacts. Production should use s3", - ), - poll_interval: int = typer.Option( - 10, - "--poll-interval", - help="poll interval to check environment directory for new environments", - ), -): - conda_store = CondaStore( - store_directory=store, database_url=None, storage_backend=storage_backend - ) - - environment_directory = pathlib.Path( - environment - or conda_store.configuration.environment_directory - or (conda_store.configuration.store_directory / "envs") - ).resolve() - if not environment_directory.is_dir(): - logger.info(f"creating directory environment_directory={environment_directory}") - environment_directory.mkdir(parents=True) - conda_store.configuration.environment_directory = str(environment_directory) - - if permissions: - conda_store.configuration.default_permissions = permissions - if uid: - conda_store.configuration.default_uid = uid - if gid: - conda_store.configuration.default_gid = gid - - conda_store.update_storage_metrics() - conda_store.update_conda_channels() - - build.start_conda_build(conda_store, paths, storage_threshold, poll_interval) - - -@cli_conda_store_env.command("create") -def cli_conda_store_env_create_command( - filename: pathlib.Path = typer.Option( - ..., - "--filename", - "-f", - help="A conda file supplied with the environment details", - ), -): - with filename.open() as f: - data = yaml.safe_load(f) - client.post_specification(data) - - -@cli_conda_store_env.command("list") -def cli_conda_store_env_list_command(): - data = client.get_environments() - print(data) - print("{:10}{:32}{:32}".format("BUILD", "NAME", "SHA256")) - print("=" * 74) - for row in data: - name = row["name"] - build_id = row["build_id"] - sha256 = row.get("specification", {}).get("sha256") - print(f"{build_id:<10}{name:32}{sha256[:32]:32}") - - -@cli_conda_store_env_package.command("list") -def cli_conda_store_env_package_list_command( - name: str = typer.Option(..., "--name", "-n", help="A conda store environment name") -): - data = client.get_environment_packages(name=name) - print("{:32}{:16}{:48}{:32}".format("NAME", "VERSION", "LICENSE", "SHA256")) - print("=" * 128) - pkgs = data["specification"]["builds"][0]["packages"] - for pkg in pkgs: - name = pkg["name"] - version = pkg["version"] - license = pkg["license"] - sha = pkg["sha256"] - print(f"{name:32}{version:16}{license:48}{sha:32}") diff --git a/conda-store-server/conda_store_server/orm.py b/conda-store-server/conda_store_server/orm.py index 2a6c4d5ae..c15b5f666 100644 --- a/conda-store-server/conda_store_server/orm.py +++ b/conda-store-server/conda_store_server/orm.py @@ -193,12 +193,6 @@ class CondaStoreConfiguration(Base): __tablename__ = "conda_store_configuration" id = Column(Integer, primary_key=True) - store_directory = Column(String) - environment_directory = Column(String) - - default_permissions = Column(String, default=None) - default_uid = Column(String, default=None) - default_gid = Column(String, default=None) last_package_update = Column(DateTime) diff --git a/conda-store-server/conda_store_server/server/__init__.py b/conda-store-server/conda_store_server/server/__init__.py index f7652a683..e69de29bb 100644 --- a/conda-store-server/conda_store_server/server/__init__.py +++ b/conda-store-server/conda_store_server/server/__init__.py @@ -1,43 +0,0 @@ -from flask import Flask, g -from flask_cors import CORS - -from conda_store_server.server import views -from conda_store_server.app import CondaStore - - -def start_app( - store_directory, - storage_backend, - disable_ui=False, - disable_api=False, - disable_registry=False, - disable_metrics=False, - address="0.0.0.0", - port=5000, -): - app = Flask(__name__) - CORS(app, resources={r"/api/v1/*": {"origins": "*"}}) - - if not disable_api: - app.register_blueprint(views.app_api) - - if not disable_registry: - app.register_blueprint(views.app_registry) - - if not disable_ui: - app.register_blueprint(views.app_ui) - - if not disable_metrics: - app.register_blueprint(views.app_metrics) - - @app.before_request - def ensure_conda_store(): - conda_store = getattr(g, "_conda_store", None) - if conda_store is None: - g._conda_store = CondaStore( - store_directory=store_directory, - database_url=None, - storage_backend=storage_backend, - ) - - app.run(debug=True, host=address, port=port) diff --git a/conda-store-server/conda_store_server/server/__main__.py b/conda-store-server/conda_store_server/server/__main__.py new file mode 100644 index 000000000..3ffe529ee --- /dev/null +++ b/conda-store-server/conda_store_server/server/__main__.py @@ -0,0 +1,6 @@ +from conda_store_server.server.app import CondaStoreServer + +main = CondaStoreServer.launch_instance + +if __name__ == "__main__": + main() diff --git a/conda-store-server/conda_store_server/server/app.py b/conda-store-server/conda_store_server/server/app.py new file mode 100644 index 000000000..fae786ccf --- /dev/null +++ b/conda-store-server/conda_store_server/server/app.py @@ -0,0 +1,82 @@ +import logging + +from flask import Flask, g +from flask_cors import CORS +from traitlets import Bool, Unicode, Integer +from traitlets.config import Application + +from conda_store_server.server import views +from conda_store_server.app import CondaStore + + +class CondaStoreServer(Application): + log_level = Integer( + logging.INFO, + help="log level to use" + ) + + enable_ui = Bool( + True, + help="serve the web ui for conda-store", + config=True + ) + + enable_api = Bool( + True, + help="enable the rest api for conda-store", + config=True, + ) + + enable_registry = Bool( + True, + help="enable the docker registry for conda-store", + config=True + ) + + enable_metrics = Bool( + True, + help="enable the prometheus metrics for conda-store", + config=True, + ) + + config_file = Unicode( + 'conda_store_config.py', + help="config file to load for conda-store", + config=True + ) + + address = Unicode( + '0.0.0.0', + help="ip address or hostname for conda-store server", + config=True + ) + + port = Integer( + 5000, + help="port for conda-store server", + config=True + ) + + def start(self): + app = Flask(__name__) + CORS(app, resources={r"/api/v1/*": {"origins": "*"}}) + + if self.enable_api: + app.register_blueprint(views.app_api) + + if self.enable_registry: + app.register_blueprint(views.app_registry) + + if self.enable_ui: + app.register_blueprint(views.app_ui) + + if self.enable_metrics: + app.register_blueprint(views.app_metrics) + + @app.before_request + def ensure_conda_store(): + if not hasattr(g, "_conda_store"): + g._conda_store = CondaStore() + + + app.run(debug=True, host=self.address, port=self.port) diff --git a/conda-store-server/conda_store_server/storage.py b/conda-store-server/conda_store_server/storage.py index 42c2e2c12..5c584143c 100644 --- a/conda-store-server/conda_store_server/storage.py +++ b/conda-store-server/conda_store_server/storage.py @@ -4,9 +4,11 @@ import shutil import minio +from traitlets.config import LoggingConfigurable +from traitlets import Unicode, Bool -class Storage: +class Storage(LoggingConfigurable): def fset(self, key, filename): raise NotImplementedError() @@ -18,28 +20,73 @@ def get_url(self, key): class S3Storage(Storage): - def __init__(self): - self.internal_endpoint = os.environ["CONDA_STORE_S3_INTERNAL_ENDPOINT"] - self.external_endpoint = os.environ["CONDA_STORE_S3_EXTERNAL_ENDPOINT"] - self.bucket_name = os.environ.get("CONDA_STORE_S3_BUCKET_NAME", "conda-store") - self.internal_client = minio.Minio( + internal_endpoint = Unicode( + os.environ.get("CONDA_STORE_S3_INTERNAL_ENDPOINT"), + help="internal endpoint to reach s3 bucket e.g. 'minio:9000'" + ) + + external_endpoint = Unicode( + os.environ.get("CONDA_STORE_S3_EXTERNAL_ENDPOINT"), + help="internal endpoint to reach s3 bucket e.g. 'localhost:9000'", + config=True, + ) + + access_key = Unicode( + os.environ.get('CONDA_STORE_S3_ACCESS_KEY'), + help="access key for S3 bucket" + ) + + secret_key = Unicode( + os.environ.get('CONDA_STORE_S3_SECRET_KEY'), + help="secret key for S3 bucket" + ) + + region = Unicode( + os.environ.get("CONDA_STORE_S3_REGION", "us-east-1"), + help="region for s3 bucket" + ) + + bucket_name = Unicode( + os.environ.get("CONDA_STORE_S3_BUCKET_NAME", "conda-store"), + help="bucket name for s3 bucket" + ) + + secure = Bool( + False, + help="use secure connection to collect to s3 bucket" + ) + + @property + def internal_client(self): + if hasattr(self, '_internal_client'): + return self._internal_client + + self._internal_client = minio.Minio( self.internal_endpoint, - os.environ["CONDA_STORE_S3_ACCESS_KEY"], - os.environ["CONDA_STORE_S3_SECRET_KEY"], - region="us-east-1", - secure=False, - ) - self.external_client = minio.Minio( - self.external_endpoint, - os.environ["CONDA_STORE_S3_ACCESS_KEY"], - os.environ["CONDA_STORE_S3_SECRET_KEY"], - region="us-east-1", - secure=False, + self.access_key, + self.secret_key, + region=self.region, + secure=self.secure, ) self._check_bucket_exists() + return self._internal_client + + @property + def external_client(self): + if hasattr(self, '_external_client'): + return self._external_client + + self._external_client = minio.Minio( + self.internal_endpoint, + self.access_key, + self.secret_key, + region=self.region, + secure=self.secure, + ) + return self._external_client def _check_bucket_exists(self): - if not self.internal_client.bucket_exists(self.bucket_name): + if not self._internal_client.bucket_exists(self.bucket_name): raise ValueError(f"S3 bucket={self.bucket_name} does not exist") def fset(self, key, filename, content_type="application/octet-stream"): @@ -61,18 +108,29 @@ def get_url(self, key): class LocalStorage(Storage): - def __init__(self, storage_path, base_url=None): - self.storage_path = pathlib.Path(storage_path) - if not self.storage_path.is_dir(): - self.storage_path.mkdir(parents=True) - self.base_url = base_url + storage_path = Unicode( + 'conda-store-state/storage', + help="directory to store binary blobs of conda-store artifacts", + config=True + ) + + storage_url = Unicode( + help="unauthenticated url where artifacts in storage path are being served from", + config=True + ) def fset(self, key, filename, content_type=None): - shutil.copyfile(filename, self.storage_path / key) + filename = os.path.join(self.storage_path, key) + os.makedirs(os.path.dirname(filename), exist_ok=True) + + shutil.copyfile(filename, os.path.join(self.storage_path, key)) def set(self, key, value, content_type=None): - with (self.storage_path / key).open("wb") as f: + filename = os.path.join(self.storage_path, key) + os.makedirs(os.path.dirname(filename), exist_ok=True) + + with open(filename, "wb") as f: f.write(value) def get_url(self, key): - return os.path.join(self.base_url, key) + return os.path.join(self.storage_url, key) diff --git a/conda-store-server/conda_store_server/worker/__init__.py b/conda-store-server/conda_store_server/worker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conda-store-server/conda_store_server/worker/__main__.py b/conda-store-server/conda_store_server/worker/__main__.py new file mode 100644 index 000000000..88c22474c --- /dev/null +++ b/conda-store-server/conda_store_server/worker/__main__.py @@ -0,0 +1,6 @@ +from conda_store_server.worker.app import CondaStoreWorker + +main = CondaStoreWorker.launch_instance + +if __name__ == "__main__": + main() diff --git a/conda-store-server/conda_store_server/worker/app.py b/conda-store-server/conda_store_server/worker/app.py new file mode 100644 index 000000000..f0f982a47 --- /dev/null +++ b/conda-store-server/conda_store_server/worker/app.py @@ -0,0 +1,38 @@ +import logging + +from traitlets import Unicode, Integer, List +from traitlets.config import Application + +from conda_store_server import build +from conda_store_server.app import CondaStore + + +class CondaStoreWorker(Application): + log_level = Integer( + logging.INFO, + help="log level to use" + ) + + storage_threshold = Integer( + 5 * (2 ** 30), + help="emit warning when free disk space drops below storage threshold bytes", + ) + + poll_interval = Integer( + 10, + help="poll interval to check environment directory for new environments", + ) + + watch_paths = List( + [], + help="set of directories or filenames to watch for conda environment changes" + ) + + def start(self): + conda_store = CondaStore() + conda_store.ensure_directories() + + conda_store.update_storage_metrics() + conda_store.update_conda_channels() + + build.start_conda_build(conda_store, self.watch_paths, self.storage_threshold, self.poll_interval) diff --git a/conda-store-server/environment-dev.yaml b/conda-store-server/environment-dev.yaml index 9e918d56f..58e8e012c 100644 --- a/conda-store-server/environment-dev.yaml +++ b/conda-store-server/environment-dev.yaml @@ -13,6 +13,7 @@ dependencies: - flask-cors - pyyaml - pydantic + - traitlets # artifact storage - minio # CLI diff --git a/conda-store-server/environment.yaml b/conda-store-server/environment.yaml index 74e76303f..b5352a162 100644 --- a/conda-store-server/environment.yaml +++ b/conda-store-server/environment.yaml @@ -13,6 +13,7 @@ dependencies: - flask-cors - pyyaml - pydantic + - traitlets # artifact storage - minio # CLI diff --git a/conda-store-server/setup.py b/conda-store-server/setup.py index 293c49f68..bc56d0953 100644 --- a/conda-store-server/setup.py +++ b/conda-store-server/setup.py @@ -39,6 +39,7 @@ "pyyaml", "pydantic", "minio", + "traitlets", ], extras_require={ "dev": [ @@ -53,7 +54,8 @@ }, entry_points={ "console_scripts": [ - "conda-store-server=conda_store_server.__main__:main", + "conda-store-server=conda_store_server.server.__main__:main", + "conda-store-worker=conda_store_server.worker.__main__:main", ], }, project_urls={ diff --git a/docker-compose.yaml b/docker-compose.yaml index b0c4f7f80..c548977fe 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ version: "2" services: - conda-store-build: + conda-store-worker: build: conda-store-server depends_on: - "postgres" @@ -9,9 +9,9 @@ services: volumes: - ./tests/assets/environments:/opt/environments:ro platform: linux/amd64 - command: ["wait-for-it", "postgres:5432", '--', 'conda-store-server', 'build', '-p', '/opt/environments', '-e', '/data/envs', '-s', '/data/store', '--uid', '1000', '--gid', '100', '--permissions', '775', '--storage-backend', 's3'] + command: ["wait-for-it", "postgres:5432", '--', 'conda-store-worker'] environment: - CONDA_STORE_DB_URL: "postgresql+psycopg2://admin:password@postgres/conda-store" + CONDA_STORE_DATABASE_URL: "postgresql+psycopg2://admin:password@postgres/conda-store" CONDA_STORE_S3_INTERNAL_ENDPOINT: minio:9000 CONDA_STORE_S3_EXTERNAL_ENDPOINT: localhost:9000 CONDA_STORE_S3_ACCESS_KEY: admin @@ -23,21 +23,21 @@ services: - "postgres" - "minio" platform: linux/amd64 - command: ["wait-for-it", "postgres:5432", '--', 'conda-store-server', 'server', '-s', '/data/store', '--port', '5000'] + command: ["wait-for-it", "postgres:5432", '--', 'conda-store-server'] ports: - "5000:5000" environment: - CONDA_STORE_DB_URL: "postgresql+psycopg2://admin:password@postgres/conda-store" + CONDA_STORE_DATABASE_URL: "postgresql+psycopg2://admin:password@postgres/conda-store" CONDA_STORE_S3_INTERNAL_ENDPOINT: minio:9000 CONDA_STORE_S3_EXTERNAL_ENDPOINT: localhost:9000 CONDA_STORE_S3_ACCESS_KEY: admin CONDA_STORE_S3_SECRET_KEY: password - jupyterlab: - build: conda-store - command: /opt/conda/envs/conda-store/bin/jupyter lab --allow-root --ip=0.0.0.0 --NotebookApp.token='' - ports: - - "8888:8888" + # jupyterlab: + # build: conda-store + # command: /opt/conda/envs/conda-store/bin/jupyter lab --allow-root --ip=0.0.0.0 --NotebookApp.token='' + # ports: + # - "8888:8888" minio: image: minio/minio:RELEASE.2020-11-10T21-02-24Z