diff --git a/binderhub/app.py b/binderhub/app.py index da1e377e5..2d6f5ca54 100644 --- a/binderhub/app.py +++ b/binderhub/app.py @@ -786,6 +786,21 @@ def _template_path_default(self): help="Origin to use when emitting events. Defaults to hostname of request when empty", ) + enable_api_only_mode = Bool( + False, + help=""" + When enabled, BinderHub will operate in an API only mode, + without a UI, and with the only registered endpoints being: + - /metrics + - /versions + - /build/([^/]+)/(.+) + - /health + - /_config + - /* -> shows a 404 page + """, + config=True, + ) + _build_config_deprecated_map = { "appendix": ("BuildExecutor", "appendix"), "push_secret": ("BuildExecutor", "push_secret"), @@ -943,6 +958,7 @@ def initialize(self, *args, **kwargs): "auth_enabled": self.auth_enabled, "event_log": self.event_log, "normalized_origin": self.normalized_origin, + "enable_api_only_mode": self.enable_api_only_mode, } ) if self.auth_enabled: @@ -956,55 +972,85 @@ def initialize(self, *args, **kwargs): (r"/metrics", MetricsHandler), (r"/versions", VersionHandler), (r"/build/([^/]+)/(.+)", BuildHandler), - (r"/v2/([^/]+)/(.+)", ParameterizedMainHandler), - (r"/repo/([^/]+)/([^/]+)(/.*)?", LegacyRedirectHandler), - # for backward-compatible mybinder.org badge URLs - # /assets/images/badge.svg - ( - r"/assets/(images/badge\.svg)", - tornado.web.StaticFileHandler, - {"path": self.tornado_settings["static_path"]}, - ), - # /badge.svg - ( - r"/(badge\.svg)", - tornado.web.StaticFileHandler, - {"path": os.path.join(self.tornado_settings["static_path"], "images")}, - ), - # /badge_logo.svg - ( - r"/(badge\_logo\.svg)", - tornado.web.StaticFileHandler, - {"path": os.path.join(self.tornado_settings["static_path"], "images")}, - ), - # /logo_social.png - ( - r"/(logo\_social\.png)", - tornado.web.StaticFileHandler, - {"path": os.path.join(self.tornado_settings["static_path"], "images")}, - ), - # /favicon_XXX.ico - ( - r"/(favicon\_fail\.ico)", - tornado.web.StaticFileHandler, - {"path": os.path.join(self.tornado_settings["static_path"], "images")}, - ), - ( - r"/(favicon\_success\.ico)", - tornado.web.StaticFileHandler, - {"path": os.path.join(self.tornado_settings["static_path"], "images")}, - ), - ( - r"/(favicon\_building\.ico)", - tornado.web.StaticFileHandler, - {"path": os.path.join(self.tornado_settings["static_path"], "images")}, - ), - (r"/about", AboutHandler), (r"/health", self.health_handler_class, {"hub_url": self.hub_url_local}), (r"/_config", ConfigHandler), - (r"/", MainHandler), - (r".*", Custom404), ] + if not self.enable_api_only_mode: + # In API only mode the endpoints in the list below + # are unregistered as they don't make sense in a API only scenario + handlers += [ + (r"/about", AboutHandler), + (r"/v2/([^/]+)/(.+)", ParameterizedMainHandler), + (r"/", MainHandler), + (r"/repo/([^/]+)/([^/]+)(/.*)?", LegacyRedirectHandler), + # for backward-compatible mybinder.org badge URLs + # /assets/images/badge.svg + ( + r"/assets/(images/badge\.svg)", + tornado.web.StaticFileHandler, + {"path": self.tornado_settings["static_path"]}, + ), + # /badge.svg + ( + r"/(badge\.svg)", + tornado.web.StaticFileHandler, + { + "path": os.path.join( + self.tornado_settings["static_path"], "images" + ) + }, + ), + # /badge_logo.svg + ( + r"/(badge\_logo\.svg)", + tornado.web.StaticFileHandler, + { + "path": os.path.join( + self.tornado_settings["static_path"], "images" + ) + }, + ), + # /logo_social.png + ( + r"/(logo\_social\.png)", + tornado.web.StaticFileHandler, + { + "path": os.path.join( + self.tornado_settings["static_path"], "images" + ) + }, + ), + # /favicon_XXX.ico + ( + r"/(favicon\_fail\.ico)", + tornado.web.StaticFileHandler, + { + "path": os.path.join( + self.tornado_settings["static_path"], "images" + ) + }, + ), + ( + r"/(favicon\_success\.ico)", + tornado.web.StaticFileHandler, + { + "path": os.path.join( + self.tornado_settings["static_path"], "images" + ) + }, + ), + ( + r"/(favicon\_building\.ico)", + tornado.web.StaticFileHandler, + { + "path": os.path.join( + self.tornado_settings["static_path"], "images" + ) + }, + ), + ] + # This needs to be the last handler in the list, because it needs to match "everything else" + handlers.append((r".*", Custom404)) handlers = self.add_url_prefix(self.base_url, handlers) if self.extra_static_path: handlers.insert( diff --git a/binderhub/builder.py b/binderhub/builder.py index 4fd34579f..9cc41550f 100644 --- a/binderhub/builder.py +++ b/binderhub/builder.py @@ -19,7 +19,7 @@ from tornado.iostream import StreamClosedError from tornado.log import app_log from tornado.queues import Queue -from tornado.web import Finish, authenticated +from tornado.web import Finish, HTTPError, authenticated from .base import BaseHandler from .build import ProgressEvent @@ -228,6 +228,25 @@ def set_default_headers(self): self.set_header("content-type", "text/event-stream") self.set_header("cache-control", "no-cache") + def _get_build_only(self): + # Get the value of the `enable_api_only_mode` traitlet + enable_api_only_mode = self.settings.get("enable_api_only_mode", False) + # Get the value of the `build_only` query parameter if present + build_only_query_parameter = str( + self.get_query_argument(name="build_only", default="") + ) + build_only = False + if build_only_query_parameter.lower() == "true": + if not enable_api_only_mode: + raise HTTPError( + status_code=400, + log_message="Building but not launching is not permitted when" + " the API only mode was not enabled by setting `enable_api_only_mode` to True. ", + ) + build_only = True + + return build_only + @authenticated async def get(self, provider_prefix, _unescaped_spec): """Get a built image for a given spec and repo provider. @@ -408,33 +427,52 @@ async def get(self, provider_prefix, _unescaped_spec): else: image_found = True - if image_found: + build_only = self._get_build_only() + if build_only: await self.emit( { - "phase": "built", + "phase": "info", "imageName": image_name, - "message": "Found built image, launching...\n", + "message": "The built image will not be launched " + "because the API only mode was enabled and the query parameter `build_only` was set to true\n", } ) - with LAUNCHES_INPROGRESS.track_inprogress(): - try: - await self.launch(provider) - except LaunchQuotaExceeded: - return - self.event_log.emit( - "binderhub.jupyter.org/launch", - 5, - { - "provider": provider.name, - "spec": spec, - "ref": ref, - "status": "success", - "build_token": self._have_build_token, - "origin": self.settings["normalized_origin"] - if self.settings["normalized_origin"] - else self.request.host, - }, - ) + if image_found: + if build_only: + await self.emit( + { + "phase": "ready", + "imageName": image_name, + "message": "Done! Found built image\n", + } + ) + else: + await self.emit( + { + "phase": "built", + "imageName": image_name, + "message": "Found built image, launching...\n", + } + ) + with LAUNCHES_INPROGRESS.track_inprogress(): + try: + await self.launch(provider) + except LaunchQuotaExceeded: + return + self.event_log.emit( + "binderhub.jupyter.org/launch", + 5, + { + "provider": provider.name, + "spec": spec, + "ref": ref, + "status": "success", + "build_token": self._have_build_token, + "origin": self.settings["normalized_origin"] + if self.settings["normalized_origin"] + else self.request.host, + }, + ) return # Don't allow builds when quota is exceeded @@ -504,7 +542,6 @@ def _check_result(future): while not done: progress = await q.get() - # FIXME: If pod goes into an unrecoverable stage, such as ImagePullBackoff or # whatever, we should fail properly. if progress.kind == ProgressEvent.Kind.BUILD_STATUS_CHANGE: @@ -513,11 +550,22 @@ def _check_result(future): # nothing to do, just waiting continue elif progress.payload == ProgressEvent.BuildStatus.BUILT: + if build_only: + message = "Done! Image built\n" + phase = "ready" + else: + message = "Built image, launching...\n" event = { "phase": phase, - "message": "Built image, launching...\n", + "message": message, "imageName": image_name, } + BUILD_TIME.labels(status="success").observe( + time.perf_counter() - build_starttime + ) + BUILD_COUNT.labels( + status="success", **self.repo_metric_labels + ).inc() done = True elif progress.payload == ProgressEvent.BuildStatus.RUNNING: # start capturing build logs once the pod is running @@ -549,15 +597,13 @@ def _check_result(future): BUILD_COUNT.labels( status="failure", **self.repo_metric_labels ).inc() - await self.emit(event) - # Launch after building an image + if build_only: + return + if not failed: - BUILD_TIME.labels(status="success").observe( - time.perf_counter() - build_starttime - ) - BUILD_COUNT.labels(status="success", **self.repo_metric_labels).inc() + # Launch after building an image with LAUNCHES_INPROGRESS.track_inprogress(): await self.launch(provider) self.event_log.emit( diff --git a/binderhub/tests/conftest.py b/binderhub/tests/conftest.py index 63cd6677f..2252246ab 100644 --- a/binderhub/tests/conftest.py +++ b/binderhub/tests/conftest.py @@ -16,6 +16,7 @@ import requests from tornado.httpclient import AsyncHTTPClient from tornado.platform.asyncio import AsyncIOMainLoop +from traitlets.config import Config from traitlets.config.loader import PyFileConfigLoader from ..app import BinderHub @@ -252,10 +253,25 @@ def app(request, io_loop, _binderhub_config): app._configured_bhub = BinderHub(config=_binderhub_config) return app - if hasattr(request, "param") and request.param is True: - # load conf for auth test - cfg = PyFileConfigLoader(binderhub_config_auth_additions_path).load_config() + api_only_app = False + if hasattr(request, "param"): + if request.param == "app_with_auth_config": + # load conf for auth test + cfg = PyFileConfigLoader(binderhub_config_auth_additions_path).load_config() + _binderhub_config.merge(cfg) + elif request.param == "api_only_app": + # load conf that sets BinderHub.enable_api_only_mode = True + cfg = Config({"BinderHub": {"enable_api_only_mode": True}}) + _binderhub_config.merge(cfg) + api_only_app = True + + if not api_only_app: + # load conf that sets BinderHub.require_build_only = False + # otherwise because _binderhub_config has a session scope, + # any previous set of require_build_only to True will stick around + cfg = Config({"BinderHub": {"enable_api_only_mode": False}}) _binderhub_config.merge(cfg) + bhub = BinderHub.instance(config=_binderhub_config) bhub.initialize([]) bhub.start(run_loop=False) diff --git a/binderhub/tests/test_auth.py b/binderhub/tests/test_auth.py index 7ef9e9a2b..2780df0bb 100644 --- a/binderhub/tests/test_auth.py +++ b/binderhub/tests/test_auth.py @@ -23,17 +23,17 @@ def use_session(): @pytest.mark.parametrize( "app,path,authenticated", [ - (True, "/", True), # main page + ("app_with_auth_config", "/", True), # main page ( True, "/v2/gh/binderhub-ci-repos/requirements/d687a7f9e6946ab01ef2baa7bd6d5b73c6e904fd", True, ), - (True, "/metrics", False), + ("app_with_auth_config", "/metrics", False), ], indirect=[ "app" - ], # send param True to app fixture, so that it loads authentication configuration + ], # send param "app_with_auth_config" to app fixture, so that it loads authentication configuration ) @pytest.mark.auth async def test_auth(app, path, authenticated, use_session): diff --git a/binderhub/tests/test_build.py b/binderhub/tests/test_build.py index 7f7a795ea..21adc22e4 100644 --- a/binderhub/tests/test_build.py +++ b/binderhub/tests/test_build.py @@ -96,9 +96,59 @@ async def test_build(app, needs_build, needs_launch, always_build, slug, pytestc assert r.url.startswith(final["url"]) +@pytest.mark.asyncio(timeout=900) +@pytest.mark.parametrize( + "app,build_only_query_param", + [ + ("api_only_app", "True"), + ], + indirect=[ + "app" + ], # send param "api_only_app" to app fixture, so that it loads `enable_api_only_mode` configuration +) +async def test_build_only(app, build_only_query_param, needs_build): + """ + Test build a repo that is very quick and easy to build. + """ + slug = "gh/binderhub-ci-repos/cached-minimal-dockerfile/HEAD" + build_url = f"{app.url}/build/{slug}" + r = await async_requests.get( + build_url, stream=True, params={"build_only": build_only_query_param} + ) + r.raise_for_status() + events = [] + launch_events = 0 + async for line in async_requests.iter_lines(r): + line = line.decode("utf8", "replace") + if line.startswith("data:"): + event = json.loads(line.split(":", 1)[1]) + events.append(event) + assert "message" in event + sys.stdout.write(f"{event.get('phase', '')}: {event['message']}") + if event.get("phase") == "ready": + r.close() + break + if event.get("phase") == "info": + assert ( + "The built image will not be launched because the API only mode was enabled and the query parameter `build_only` was set to true" + in event["message"] + ) + if event.get("phase") == "launching" and not event["message"].startswith( + ("Launching server...", "Launch attempt ") + ): + # skip standard launching events of builder + # we are interested in launching events from spawner + launch_events += 1 + + assert launch_events == 0 + final = events[-1] + assert "phase" in final + assert final["phase"] == "ready" + + @pytest.mark.asyncio(timeout=120) @pytest.mark.remote -async def test_build_fail(app, needs_build, needs_launch, always_build, pytestconfig): +async def test_build_fail(app, needs_build, needs_launch, always_build): """ Test build a repo that should fail immediately. """ @@ -120,6 +170,60 @@ async def test_build_fail(app, needs_build, needs_launch, always_build, pytestco assert failed_events > 0, "Should have seen phase 'failed'" +@pytest.mark.asyncio(timeout=120) +@pytest.mark.parametrize( + "app,build_only_query_param,expected_error_msg", + [ + ( + "app_without_require_build_only", + True, + "Building but not launching is not permitted", + ), + ], + indirect=[ + "app" + ], # send param "require_build_only_app" to app fixture, so that it loads `require_build_only` configuration +) +async def test_build_only_fail( + app, build_only_query_param, expected_error_msg, needs_build +): + """ + Test the scenarios that are expected to fail when setting configs for building but no launching. + + Table for evaluating whether or not the image will be launched after build based on the values of + the `enable_api_only_mode` traitlet and the `build_only` query parameter. + + | `enable_api_only_mode` trait | `build_only` query param | Outcome + ------------------------------------------------------------------------------------------------ + | false | missing | OK, image will be launched after build + | false | false | OK, image will be launched after build + | false | true | ERROR, building but not launching is not permitted when UI is still enabled + | true | missing | OK, image will be launched after build + | true | false | OK, image will be launched after build + | true | true | OK, image won't be launched after build + """ + + slug = "gh/binderhub-ci-repos/cached-minimal-dockerfile/HEAD" + build_url = f"{app.url}/build/{slug}" + r = await async_requests.get( + build_url, stream=True, params={"build_only": build_only_query_param} + ) + r.raise_for_status() + failed_events = 0 + async for line in async_requests.iter_lines(r): + line = line.decode("utf8", "replace") + if line.startswith("data:"): + event = json.loads(line.split(":", 1)[1]) + assert event.get("phase") not in ("launching", "ready") + if event.get("phase") == "failed": + failed_events += 1 + assert expected_error_msg in event["message"] + break + r.close() + + assert failed_events > 0, "Should have seen phase 'failed'" + + def _list_image_builder_pods_mock(): """Mock list of DIND pods""" mock_response = mock.MagicMock() diff --git a/examples/binder-api.py b/examples/binder-api.py index a066c81ed..d64510dbb 100644 --- a/examples/binder-api.py +++ b/examples/binder-api.py @@ -15,14 +15,18 @@ import requests -def build_binder(repo, ref, *, binder_url="https://mybinder.org"): +def build_binder(repo, ref, *, binder_url="https://mybinder.org", build_only): """Launch a binder Yields Binder's event-stream events (dicts) """ print(f"Building binder for {repo}@{ref}") url = binder_url + f"/build/gh/{repo}/{ref}" - r = requests.get(url, stream=True) + params = {} + if build_only: + params = {"build_only": "true"} + + r = requests.get(url, stream=True, params=params) r.raise_for_status() for line in r.iter_lines(): line = line.decode("utf8", "replace") @@ -34,6 +38,11 @@ def build_binder(repo, ref, *, binder_url="https://mybinder.org"): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("repo", type=str, help="The GitHub repo to build") parser.add_argument("--ref", default="HEAD", help="The ref of the repo to build") + parser.add_argument( + "--build-only", + action="store_true", + help="When passed, the image will not be launched after build", + ) file_or_url = parser.add_mutually_exclusive_group() file_or_url.add_argument("--filepath", type=str, help="The file to open, if any.") file_or_url.add_argument("--urlpath", type=str, help="The url to open, if any.") @@ -47,7 +56,9 @@ def build_binder(repo, ref, *, binder_url="https://mybinder.org"): ) opts = parser.parse_args() - for evt in build_binder(opts.repo, ref=opts.ref, binder_url=opts.binder): + for evt in build_binder( + opts.repo, ref=opts.ref, binder_url=opts.binder, build_only=opts.build_only + ): if "message" in evt: print( "[{phase}] {message}".format( @@ -56,7 +67,9 @@ def build_binder(repo, ref, *, binder_url="https://mybinder.org"): ) ) if evt.get("phase") == "ready": - if opts.filepath: + if opts.build_only: + break + elif opts.filepath: url = "{url}notebooks/{filepath}?token={token}".format( **evt, filepath=opts.filepath ) diff --git a/testing/local-binder-mocked-hub/binderhub_config.py b/testing/local-binder-mocked-hub/binderhub_config.py index a9a5c0584..7136d43a1 100644 --- a/testing/local-binder-mocked-hub/binderhub_config.py +++ b/testing/local-binder-mocked-hub/binderhub_config.py @@ -17,5 +17,11 @@ c.BinderHub.repo_providers = {"gh": FakeProvider} c.BinderHub.build_class = FakeBuild +# Uncomment the following line to enable BinderHub's API only mode +# With this, we can then use the `build_only` query parameter in the request +# to not launch the image after build + +c.BinderHub.enable_api_only_mode = True + c.BinderHub.about_message = "Hello world." c.BinderHub.banner_message = 'This is headline news.'