From 5764ff2fdcb50736edb721a1114a0af6dae6e097 Mon Sep 17 00:00:00 2001 From: Ben Potter Date: Sat, 23 Aug 2025 11:43:24 -0700 Subject: [PATCH] feat: add healthcheck and config options to JupyterLab Module (#363) ## Description Simplified JupyterLab module configuration and added automatic CSP headers for iFrame embedding for Coder Tasks. The module now works out of the box without requiring users to manually configure Content-Security-Policy headers. **Changes:** - Removed redundant configuration examples from README that duplicated existing module variables - Added fallback CSP configuration when user doesn't provide custom config - Cleaned up locals logic with better naming and clearer conditionals - Updated README to show minimal usage with CSP example for custom configurations ## Type of Change - [ ] New module - [ ] Bug fix - [x] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/jupyterlab` **New version:** `v1.2.0` **Breaking change:** [x] Yes [ ] No *Breaking change: Config behavior changed - now automatically includes CSP when no user config provided* ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun run fmt`) - [x] Changes tested locally ## Related Issues Closes #345 --- registry/coder/modules/jupyterlab/README.md | 27 ++++++++- .../coder/modules/jupyterlab/main.test.ts | 55 +++++++++++++++++++ registry/coder/modules/jupyterlab/main.tf | 42 ++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md index ed7400dc..6c401ded 100644 --- a/registry/coder/modules/jupyterlab/README.md +++ b/registry/coder/modules/jupyterlab/README.md @@ -16,7 +16,32 @@ A module that adds JupyterLab in your Coder template. module "jupyterlab" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jupyterlab/coder" - version = "1.1.1" + version = "1.2.0" agent_id = coder_agent.example.id } ``` + +## Configuration + +JupyterLab is automatically configured to work with Coder's iframe embedding. For advanced configuration, you can use the `config` parameter to provide additional JupyterLab server settings according to the [JupyterLab configuration documentation](https://jupyter-server.readthedocs.io/en/latest/users/configuration.html). + +```tf +module "jupyterlab" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jupyterlab/coder" + version = "1.2.0" + agent_id = coder_agent.example.id + config = { + ServerApp = { + # Required for Coder Tasks iFrame embedding - do not remove + tornado_settings = { + headers = { + "Content-Security-Policy" = "frame-ancestors 'self' ${data.coder_workspace.me.access_url}" + } + } + # Your additional configuration here + root_dir = "/workspace/notebooks" + } + } +} +``` diff --git a/registry/coder/modules/jupyterlab/main.test.ts b/registry/coder/modules/jupyterlab/main.test.ts index 4ef7fa02..06caff3a 100644 --- a/registry/coder/modules/jupyterlab/main.test.ts +++ b/registry/coder/modules/jupyterlab/main.test.ts @@ -3,6 +3,8 @@ import { execContainer, executeScriptInContainer, findResourceInstance, + readFileContainer, + removeContainer, runContainer, runTerraformApply, runTerraformInit, @@ -104,4 +106,57 @@ describe("jupyterlab", async () => { // const output = await executeScriptInContainerWithPip(state, "alpine"); // ... // }); + + it("writes ~/.jupyter/jupyter_server_config.json when config provided", async () => { + const id = await runContainer("alpine"); + try { + const config = { + ServerApp: { + port: 8888, + token: "test-token", + password: "", + allow_origin: "*" + } + }; + const configJson = JSON.stringify(config); + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + config: configJson, + }); + const script = findResourceInstance(state, "coder_script", "jupyterlab_config").script; + const resp = await execContainer(id, ["sh", "-c", script]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); + const content = await readFileContainer(id, "/root/.jupyter/jupyter_server_config.json"); + // Parse both JSON strings and compare objects to avoid key ordering issues + const actualConfig = JSON.parse(content); + expect(actualConfig).toEqual(config); + } finally { + await removeContainer(id); + } + }); + + it("creates config script with CSP fallback when config is empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + config: "{}", + }); + const configScripts = state.resources.filter( + (res) => res.type === "coder_script" && res.name === "jupyterlab_config" + ); + expect(configScripts.length).toBe(1); + }); + + it("creates config script with CSP fallback when config is not provided", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const configScripts = state.resources.filter( + (res) => res.type === "coder_script" && res.name === "jupyterlab_config" + ); + expect(configScripts.length).toBe(1); + }); }); diff --git a/registry/coder/modules/jupyterlab/main.tf b/registry/coder/modules/jupyterlab/main.tf index 1237d980..f2b30860 100644 --- a/registry/coder/modules/jupyterlab/main.tf +++ b/registry/coder/modules/jupyterlab/main.tf @@ -12,6 +12,23 @@ terraform { data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} +locals { + # Fallback config with CSP for Coder iframe embedding when user config is empty + csp_fallback_config = { + ServerApp = { + tornado_settings = { + headers = { + "Content-Security-Policy" = "frame-ancestors 'self' ${data.coder_workspace.me.access_url}" + } + } + } + } + + # Use user config if provided, otherwise fallback to CSP config + config_json = var.config == "{}" ? jsonencode(local.csp_fallback_config) : var.config + config_b64 = base64encode(local.config_json) +} + # Add required variables for your modules and remove any unneeded variables variable "agent_id" { type = string @@ -57,6 +74,26 @@ variable "group" { default = null } +variable "config" { + type = string + description = "A JSON string of JupyterLab server configuration settings. When set, writes ~/.jupyter/jupyter_server_config.json." + default = "{}" +} + +resource "coder_script" "jupyterlab_config" { + agent_id = var.agent_id + display_name = "JupyterLab Config" + icon = "/icon/jupyter.svg" + run_on_start = true + start_blocks_login = false + script = <<-EOT + #!/bin/sh + set -eu + mkdir -p "$HOME/.jupyter" + echo -n "${local.config_b64}" | base64 -d > "$HOME/.jupyter/jupyter_server_config.json" + EOT +} + resource "coder_script" "jupyterlab" { agent_id = var.agent_id display_name = "jupyterlab" @@ -79,4 +116,9 @@ resource "coder_app" "jupyterlab" { share = var.share order = var.order group = var.group + healthcheck { + url = "http://localhost:${var.port}/api" + interval = 5 + threshold = 6 + } }