diff --git a/registry/coder/modules/personalize/README.md b/registry/coder/modules/personalize/README.md index 70426593d..d64667117 100644 --- a/registry/coder/modules/personalize/README.md +++ b/registry/coder/modules/personalize/README.md @@ -8,13 +8,48 @@ tags: [helper, personalize] # Personalize -Run a script on workspace start that allows developers to run custom commands to personalize their workspace. +Personalize runs a developer-managed script when a Coder workspace starts. It lets each developer install personal tools or configure their environment without changing the shared Coder template. + +The default configuration runs `~/personalize` on workspace start and writes its output to `~/personalize.log`. ```tf module "personalize" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/personalize/coder" - version = "1.0.32" + version = "1.0.33" agent_id = coder_agent.main.id } ``` + +## Create the personalize script + +Create the script inside the workspace and make it executable. Keep startup commands idempotent because Coder runs the script on every workspace start. + +```sh +cat > ~/personalize << 'EOF' +#!/usr/bin/env sh + +command -v jq >/dev/null 2>&1 || echo "Install jq for this workspace" +EOF +chmod +x ~/personalize +``` + +If the script is missing or is not executable, Personalize prints instructions and exits without blocking the workspace with an error. + +## Use custom paths + +Set `path` when the script is stored elsewhere and `log_path` when its output should be written to a different location. + +```tf +module "personalize" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/personalize/coder" + version = "1.0.33" + agent_id = coder_agent.main.id + + path = "/home/coder/scripts/personalize workspace.sh" + log_path = "/home/coder/logs/personalize.log" +} +``` + +Coder creates a startup `coder_script` from this module. The script resolves the configured path, verifies that the developer's file exists and is executable, and then runs it as the workspace user. The module does not download software or require `sudo`; any network access or elevated privileges come from commands the developer places in their own script. diff --git a/registry/coder/modules/personalize/main.test.ts b/registry/coder/modules/personalize/main.test.ts index 98ed11db1..2488d8f1b 100644 --- a/registry/coder/modules/personalize/main.test.ts +++ b/registry/coder/modules/personalize/main.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, setDefaultTimeout } from "bun:test"; import { executeScriptInContainer, runTerraformApply, @@ -6,6 +6,26 @@ import { testRequiredVariables, } from "~test"; +const createScript = ( + path: string, + content: string, + executable = true, +): string => { + const encodedPath = Buffer.from(path).toString("base64"); + const encodedContent = Buffer.from(content).toString("base64"); + + return [ + `script_path="$(printf '%s' '${encodedPath}' | base64 -d)"`, + 'mkdir -p "$(dirname "$script_path")"', + `printf '%s' '${encodedContent}' | base64 -d > "$script_path"`, + executable ? 'chmod +x "$script_path"' : "", + ] + .filter(Boolean) + .join("\n"); +}; + +setDefaultTimeout(30 * 1000); + describe("personalize", async () => { await runTerraformInit(import.meta.dir); @@ -22,8 +42,79 @@ describe("personalize", async () => { expect(output.stdout).toEqual([ "✨ \u001b[0;1mYou don't have a personalize script!", "", - "Run \u001b[36;40;1mtouch ~/personalize && chmod +x ~/personalize\u001b[0m to create one.", + "Create a script at \u001b[36;40;1m~/personalize\u001b[0m and make it executable.", "It will run every time your workspace starts. Use it to install personal packages!", ]); }); + + it("warns when the personalize script is not executable", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer( + state, + "alpine", + "sh", + createScript( + "/root/personalize", + "#!/bin/sh\necho should-not-run\n", + false, + ), + ); + + expect(output.exitCode).toBe(0); + expect(output.stdout).toContain( + "🔐 Your personalize script isn't executable!", + ); + expect(output.stdout).not.toContain("should-not-run"); + }); + + it("runs an executable personalize script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer( + state, + "alpine", + "sh", + createScript( + "/root/personalize", + "#!/bin/sh\nprintf 'personalize executed\\n'\n", + ), + ); + + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual(["personalize executed"]); + }); + + it("preserves spaces and shell syntax in a custom path", async () => { + const path = "/tmp/personalize scripts/$(printf injected) [daily]*"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + path, + }); + const output = await executeScriptInContainer( + state, + "alpine", + "sh", + createScript(path, "#!/bin/sh\nprintf 'custom path executed\\n'\n"), + ); + + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual(["custom path executed"]); + }); + + it("preserves the personalize script exit code", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer( + state, + "alpine", + "sh", + createScript("/root/personalize", "#!/bin/sh\nexit 23\n"), + ); + + expect(output.exitCode).toBe(23); + }); }); diff --git a/registry/coder/modules/personalize/main.tf b/registry/coder/modules/personalize/main.tf index 9de4b7899..581051a3b 100644 --- a/registry/coder/modules/personalize/main.tf +++ b/registry/coder/modules/personalize/main.tf @@ -29,7 +29,7 @@ variable "log_path" { resource "coder_script" "personalize" { agent_id = var.agent_id script = templatefile("${path.module}/run.sh", { - PERSONALIZE_PATH : var.path, + PERSONALIZE_PATH : base64encode(var.path), }) display_name = "Personalize" icon = "/icon/personalize.svg" diff --git a/registry/coder/modules/personalize/main.tftest.hcl b/registry/coder/modules/personalize/main.tftest.hcl new file mode 100644 index 000000000..5c247c01a --- /dev/null +++ b/registry/coder/modules/personalize/main.tftest.hcl @@ -0,0 +1,53 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent-id" +} + +run "defaults" { + command = plan + + assert { + condition = resource.coder_script.personalize.agent_id == "test-agent-id" + error_message = "Personalize must use the configured agent ID." + } + + assert { + condition = resource.coder_script.personalize.log_path == "~/personalize.log" + error_message = "Personalize must preserve the default log path." + } + + assert { + condition = resource.coder_script.personalize.run_on_start + error_message = "Personalize must run when the workspace starts." + } + + assert { + condition = resource.coder_script.personalize.start_blocks_login + error_message = "Personalize must continue to block login until it finishes." + } +} + +run "custom_paths" { + command = plan + + variables { + path = "/tmp/personalize scripts/$(printf injected) [daily]*" + log_path = "/tmp/personalize logs/start.log" + } + + assert { + condition = resource.coder_script.personalize.log_path == var.log_path + error_message = "Personalize must use the configured log path." + } + + assert { + condition = strcontains(resource.coder_script.personalize.script, base64encode(var.path)) + error_message = "The rendered script must contain the encoded custom path." + } + + assert { + condition = !strcontains(resource.coder_script.personalize.script, var.path) + error_message = "The rendered script must not interpolate the raw custom path." + } +} diff --git a/registry/coder/modules/personalize/run.sh b/registry/coder/modules/personalize/run.sh index dacaf4862..d8990cfee 100644 --- a/registry/coder/modules/personalize/run.sh +++ b/registry/coder/modules/personalize/run.sh @@ -3,25 +3,34 @@ BOLD='\033[0;1m' CODE='\033[36;40;1m' RESET='\033[0m' -SCRIPT="${PERSONALIZE_PATH}" -SCRIPT="$${SCRIPT/#\~/$${HOME}}" +# shellcheck disable=SC2016 # Terraform replaces this placeholder. +SCRIPT=$(printf '%s' '${PERSONALIZE_PATH}' | base64 -d) || { + echo "Failed to decode the personalize script path." >&2 + exit 1 +} +DISPLAY_PATH="$SCRIPT" +case "$SCRIPT" in + \~) SCRIPT="$${HOME}" ;; + \~/*) SCRIPT="$${HOME}/$${SCRIPT#??}" ;; + *) ;; +esac # If the personalize script doesn't exist, educate # the user how they can customize their environment! -if [ ! -f $SCRIPT ]; then - printf "✨ $${BOLD}You don't have a personalize script!\n\n" - printf "Run $${CODE}touch $${SCRIPT} && chmod +x $${SCRIPT}$${RESET} to create one.\n" - printf "It will run every time your workspace starts. Use it to install personal packages!\n\n" +if [ ! -f "$SCRIPT" ]; then + printf "✨ %bYou don't have a personalize script!\n\n" "$BOLD" + printf 'Create a script at %b%s%b and make it executable.\n' "$CODE" "$DISPLAY_PATH" "$RESET" + printf 'It will run every time your workspace starts. Use it to install personal packages!\n\n' exit 0 fi # Check if the personalize script is executable, if not, # try to make it executable and educate the user if it fails. -if [ ! -x $SCRIPT ]; then +if [ ! -x "$SCRIPT" ]; then echo "🔐 Your personalize script isn't executable!" - printf "Run $CODE\`chmod +x $SCRIPT\`$RESET to make it executable.\n" + printf 'Make %b%s%b executable before restarting the workspace.\n' "$CODE" "$DISPLAY_PATH" "$RESET" exit 0 fi # Run the personalize script! -$SCRIPT +"$SCRIPT"