diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md index ed7400dca..6c401dede 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 4ef7fa026..06caff3aa 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 1237d980d..f2b308608 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 + } }