diff --git a/binderhub/app.py b/binderhub/app.py
index 24b7b2833..db552790e 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 6c87f2a15..9d5780eab 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 = ""
c.BinderHub.banner_message = 'This is headline news.'