From c2a495e74a0f61af085dfc75217b332feb09dc07 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:08:25 +0000 Subject: [PATCH 1/3] fix(mux): persist install under $HOME and reuse it when the version matches --- registry/coder/modules/mux/README.md | 27 ++--- registry/coder/modules/mux/main.test.ts | 125 +++++++++++++++------- registry/coder/modules/mux/main.tf | 6 +- registry/coder/modules/mux/mux.tftest.hcl | 39 +++++++ registry/coder/modules/mux/run.sh | 44 +++++++- 5 files changed, 182 insertions(+), 59 deletions(-) diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 2f4155e29..01036ffac 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -8,13 +8,13 @@ tags: [ai, agents, development, multiplexer] # Mux -Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. +Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The install lives under `~/.coder-modules/coder/mux` so it survives workspace restarts; each start compares the installed version with `install_version` and only reinstalls when it differs or is missing. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. ```tf module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id } ``` @@ -37,7 +37,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id } ``` @@ -48,7 +48,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id # Default is "latest"; set to a specific version to pin install_version = "0.4.0" @@ -63,7 +63,7 @@ Start Mux with `mux server --add-project /path/to/project`: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id add_project = "/path/to/project" } @@ -78,7 +78,7 @@ The module parses quoted values, so grouped arguments remain intact. module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'" } @@ -92,7 +92,7 @@ Enable automatic restarts after Mux exits, including clean exits and intentional module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id restart_on_kill = true restart_delay_seconds = 3 @@ -106,7 +106,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id port = 8080 } @@ -120,7 +120,7 @@ Force a specific package manager instead of auto-detection: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id package_manager = "pnpm" # or "npm", "bun" } @@ -134,7 +134,7 @@ Use a private or mirrored npm registry: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id registry_url = "https://npm.pkg.github.com" } @@ -142,13 +142,13 @@ module "mux" { ### Use Cached Installation -Run an existing copy of Mux if found, otherwise install from npm: +By default the module reuses the installed copy when its version already matches `install_version` and reinstalls only when the version differs or the install is missing. Set `use_cached` to run whatever copy is present without checking the registry: ```tf module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id use_cached = true } @@ -162,7 +162,7 @@ Run without installing from the network (requires Mux to be pre-installed): module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.5.0" + version = "1.6.0" agent_id = coder_agent.main.id install = false } @@ -179,6 +179,7 @@ module "mux" { - Auto-detects `npm`, `pnpm`, or `bun` by default; set `package_manager` to force a specific one - Requires a Node.js runtime; if `node` is not on the workspace `PATH`, the module bootstraps a pinned Node.js runtime into `~/.local/share/coder-mux` (override the version with the `MUX_NODE_VERSION` environment variable) - Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry +- Installs into `install_prefix` (default `$HOME/.coder-modules/coder/mux`), which persists across restarts; the installed version is compared with `install_version` on every start and only refetched when it differs or is missing - Falls back to a direct tarball download when no package manager is found - Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup - Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits diff --git a/registry/coder/modules/mux/main.test.ts b/registry/coder/modules/mux/main.test.ts index a8944deec..886bd1e62 100644 --- a/registry/coder/modules/mux/main.test.ts +++ b/registry/coder/modules/mux/main.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; import { - executeScriptInContainer, execContainer, findResourceInstance, readFileContainer, @@ -18,37 +17,58 @@ describe("mux", async () => { agent_id: "foo", }); - it("runs with default", async () => { + it("runs with default and reuses the tarball install on the next start", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", }); - const output = await executeScriptInContainer( - state, - "alpine/curl", - "sh", - "apk add --no-cache bash tar gzip ca-certificates findutils nodejs && update-ca-certificates", - ); - if (output.exitCode !== 0) { - console.log("STDOUT:\n" + output.stdout.join("\n")); - console.log("STDERR:\n" + output.stderr.join("\n")); - } - expect(output.exitCode).toBe(0); - const expectedLines = [ - "📥 No package manager found; downloading tarball from registry...", - "🥳 mux has been installed in /tmp/mux", - "🚀 Starting mux server on port 4000...", - "Check logs at /tmp/mux.log!", - ]; - for (const line of expectedLines) { - expect(output.stdout).toContain(line); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine/curl"); + + try { + const setup = await execContainer(id, [ + "sh", + "-c", + "apk add --no-cache bash tar gzip ca-certificates findutils nodejs && update-ca-certificates", + ]); + expect(setup.exitCode).toBe(0); + + const first = await execContainer(id, ["sh", "-c", instance.script]); + if (first.exitCode !== 0) { + console.log("STDOUT:\n" + first.stdout); + console.log("STDERR:\n" + first.stderr); + } + expect(first.exitCode).toBe(0); + const expectedLines = [ + "📥 No package manager found; downloading tarball from registry...", + "🥳 mux has been installed in /root/.coder-modules/coder/mux", + "🚀 Starting mux server on port 4000...", + "Check logs at /tmp/mux.log!", + ]; + for (const line of expectedLines) { + expect(first.stdout).toContain(line); + } + + const second = await execContainer(id, ["sh", "-c", instance.script]); + if (second.exitCode !== 0) { + console.log("STDOUT:\n" + second.stdout); + console.log("STDERR:\n" + second.stderr); + } + expect(second.exitCode).toBe(0); + expect(second.stdout).toMatch( + /🥳 mux@\S+ is already installed in \/root\/.coder-modules\/coder\/mux; skipping install/, + ); + expect(second.stdout).not.toContain("📥 No package manager found"); + } finally { + await removeContainer(id); } - }, 60000); + }, 120000); it("parses custom additional_arguments", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, + install_prefix: "/tmp/mux", log_path: "/tmp/mux.log", additional_arguments: "--open-mode pinned --add-project '/workspaces/my repo'", @@ -100,6 +120,7 @@ chmod +x /tmp/mux/mux`, const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, + install_prefix: "/tmp/mux", log_path: "/tmp/mux.log", }); @@ -149,6 +170,7 @@ chmod +x /tmp/mux/mux`, const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, + install_prefix: "/tmp/mux", log_path: "/tmp/mux.log", restart_on_kill: true, restart_delay_seconds: 1, @@ -223,6 +245,7 @@ chmod +x /tmp/mux/mux`, const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, + install_prefix: "/tmp/mux", log_path: "/tmp/mux.log", restart_on_kill: true, restart_delay_seconds: 1, @@ -282,28 +305,48 @@ chmod +x /tmp/mux/mux`, } }, 60000); - it("runs with npm present", async () => { + it("runs with npm present and reuses the install on the next start", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", }); - const output = await executeScriptInContainer( - state, - "node:20-alpine", - "sh", - "apk add bash", - ); - - expect(output.exitCode).toBe(0); - const expectedLines = [ - "📦 Installing mux via npm into /tmp/mux...", - "⏭️ Skipping lifecycle scripts with --ignore-scripts", - "🥳 mux has been installed in /tmp/mux", - "🚀 Starting mux server on port 4000...", - "Check logs at /tmp/mux.log!", - ]; - for (const line of expectedLines) { - expect(output.stdout).toContain(line); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("node:20-alpine"); + + try { + const setup = await execContainer(id, ["sh", "-c", "apk add bash"]); + expect(setup.exitCode).toBe(0); + + const first = await execContainer(id, ["sh", "-c", instance.script]); + if (first.exitCode !== 0) { + console.log("STDOUT:\n" + first.stdout); + console.log("STDERR:\n" + first.stderr); + } + expect(first.exitCode).toBe(0); + const expectedLines = [ + "📦 Installing mux via npm into /root/.coder-modules/coder/mux...", + "⏭️ Skipping lifecycle scripts with --ignore-scripts", + "🥳 mux has been installed in /root/.coder-modules/coder/mux", + "🚀 Starting mux server on port 4000...", + "Check logs at /tmp/mux.log!", + ]; + for (const line of expectedLines) { + expect(first.stdout).toContain(line); + } + + const second = await execContainer(id, ["sh", "-c", instance.script]); + if (second.exitCode !== 0) { + console.log("STDOUT:\n" + second.stdout); + console.log("STDERR:\n" + second.stderr); + } + expect(second.exitCode).toBe(0); + expect(second.stdout).toMatch( + /🥳 mux@\S+ is already installed in \/root\/.coder-modules\/coder\/mux; skipping install/, + ); + expect(second.stdout).not.toContain("📦 Installing mux via npm"); + expect(second.stdout).toContain("🚀 Starting mux server on port 4000..."); + } finally { + await removeContainer(id); } - }, 180000); + }, 240000); }); diff --git a/registry/coder/modules/mux/main.tf b/registry/coder/modules/mux/main.tf index f80b8b3ff..24c6b9bb1 100644 --- a/registry/coder/modules/mux/main.tf +++ b/registry/coder/modules/mux/main.tf @@ -39,8 +39,8 @@ variable "slug" { variable "install_prefix" { type = string - description = "The prefix to install Mux to." - default = "/tmp/mux" + description = "The directory to install Mux into. Defaults to a persistent path under the home directory so restarts reuse the installed copy." + default = "$HOME/.coder-modules/coder/mux" } variable "log_path" { @@ -141,7 +141,7 @@ variable "install" { variable "use_cached" { type = bool - description = "Use cached copy of Mux if present; otherwise install from npm" + description = "Reuse any installed copy of Mux without checking its version. By default the installed copy is reused only when its version matches install_version and reinstalled otherwise." default = false } diff --git a/registry/coder/modules/mux/mux.tftest.hcl b/registry/coder/modules/mux/mux.tftest.hcl index af4cbfe23..3706e8a1f 100644 --- a/registry/coder/modules/mux/mux.tftest.hcl +++ b/registry/coder/modules/mux/mux.tftest.hcl @@ -337,3 +337,42 @@ run "registry_url_trailing_slash" { } } +# Default install prefix persists under the home directory and the script +# resolves the wanted version before deciding whether to reinstall. +run "default_install_prefix_is_persistent" { + command = plan + + variables { + agent_id = "foo" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "MUX_BINARY=\"$HOME/.coder-modules/coder/mux/mux\"") + error_message = "mux must install under $HOME/.coder-modules/coder/mux by default" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "https://registry.npmjs.org/mux/next") + error_message = "mux script must resolve install_version against the registry to detect a stale install" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "is already installed in $HOME/.coder-modules/coder/mux; skipping install") + error_message = "mux script must skip the install when the installed version matches" + } +} + +run "custom_install_prefix" { + command = plan + + variables { + agent_id = "foo" + install_prefix = "/opt/mux" + } + + assert { + condition = strcontains(resource.coder_script.mux.script, "MUX_BINARY=\"/opt/mux/mux\"") + error_message = "mux script must honor a custom install_prefix" + } +} + diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/run.sh index 04431e02f..d716086b8 100644 --- a/registry/coder/modules/mux/run.sh +++ b/registry/coder/modules/mux/run.sh @@ -244,8 +244,48 @@ if [ "${OFFLINE}" = true ]; then exit 1 fi -# If there is no cached install OR we don't want to use a cached install -if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then +# Version of the mux package installed under the install prefix (package +# manager or extracted tarball layout); empty when nothing is installed. +installed_mux_version() { + local pkg_json + for pkg_json in "${INSTALL_PREFIX}/npm/node_modules/mux/package.json" "${INSTALL_PREFIX}/.mux-package/package.json"; do + if [ -f "$pkg_json" ]; then + node -e 'try{const fs=require("fs");const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(p&&p.version){console.log(p.version);}}catch(e){}' "$pkg_json" + return 0 + fi + done +} + +# Concrete version the registry currently maps the requested version or +# dist-tag to; empty when it cannot be resolved, which falls back to a reinstall. +# curl is preferred because it honors proxy environment variables; node's fetch +# covers images that ship a package manager without curl. +wanted_mux_version() { + local meta_url="${REGISTRY_URL}/mux/${VERSION}" + if command -v curl > /dev/null 2>&1; then + curl -fsSL --max-time 15 "$meta_url" 2> /dev/null | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}' + else + node -e 'fetch(process.argv[1],{signal:AbortSignal.timeout(15000)}).then((r)=>(r.ok?r.json():null)).then((data)=>{if(data&&data.version){console.log(data.version);}}).catch(()=>{})' "$meta_url" 2> /dev/null + fi +} + +NEEDS_INSTALL=true +if [ -f "$MUX_BINARY" ]; then + if [ "${USE_CACHED}" = true ]; then + NEEDS_INSTALL=false + else + INSTALLED_VERSION="$(installed_mux_version)" + WANTED_VERSION="$(wanted_mux_version)" + if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" = "$WANTED_VERSION" ]; then + echo "🥳 mux@$INSTALLED_VERSION is already installed in ${INSTALL_PREFIX}; skipping install" + NEEDS_INSTALL=false + else + echo "♻️ Installed mux@$${INSTALLED_VERSION:-unknown} does not match mux@${VERSION} ($${WANTED_VERSION:-unresolved}); reinstalling" + fi + fi +fi + +if [ "$NEEDS_INSTALL" = true ]; then printf "$${BOLD}Installing mux...\n" # Clean up from other install (in case install prefix changed). From 935ff33c360ebfcabf426c7a56190d7d7d52d450 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:26:33 +0000 Subject: [PATCH 2/3] fix(mux): orchestrate install and start with coder-utils and resolve versions through the package manager Round 2 of review on #1095: - resolve install_version through npm/pnpm/bun view so registry auth applies - default log_path into the module root logs/ directory - split run.sh into scripts/install.sh.tftpl and scripts/start.sh.tftpl run by coder-utils - keep a pre-1.6.0 copy at /tmp/mux working for install=false and use_cached - keep a single install layout so the installed version is read from the active one - normalize an empty install_version to latest before resolving it - apply before asserting on the token-bearing start script --- registry/coder/modules/mux/README.md | 6 +- registry/coder/modules/mux/main.test.ts | 333 ++++++++++-------- registry/coder/modules/mux/main.tf | 67 ++-- registry/coder/modules/mux/mux.tftest.hcl | 129 +++++-- .../mux/{run.sh => scripts/install.sh.tftpl} | 275 +++------------ .../coder/modules/mux/scripts/start.sh.tftpl | 218 ++++++++++++ 6 files changed, 594 insertions(+), 434 deletions(-) rename registry/coder/modules/mux/{run.sh => scripts/install.sh.tftpl} (57%) create mode 100644 registry/coder/modules/mux/scripts/start.sh.tftpl diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 01036ffac..3e6a3a970 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -8,7 +8,7 @@ tags: [ai, agents, development, multiplexer] # Mux -Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The install lives under `~/.coder-modules/coder/mux` so it survives workspace restarts; each start compares the installed version with `install_version` and only reinstalls when it differs or is missing. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. +Automatically install and run [Mux](https://github.com/coder/mux) in a Coder workspace. By default, the module auto-detects an available package manager (`npm`, `pnpm`, or `bun`) to install `mux@next` (with a fallback to downloading the npm tarball if none is found). You can also force a specific package manager via `package_manager` and point to a custom registry with `registry_url`. The install lives under `~/.coder-modules/coder/mux` so it survives workspace restarts; each start resolves `install_version` through the package manager (so private registry credentials apply) and only reinstalls when the installed version differs or is missing. Scripts and logs are orchestrated by [`coder-utils`](../coder-utils), which runs the install script before the start script and keeps both under the same module directory. The launcher keeps watching the mux process after startup, appends signal/exit-code diagnostics to the mux log when the server is killed outside the Node runtime, and can optionally wait a few seconds, remove the stale server lock, and restart Mux after any exit until an optional restart-attempt cap is reached. Mux is a desktop application for parallel agentic development that enables developers to run multiple AI agents simultaneously across isolated workspaces. ```tf module "mux" { @@ -156,7 +156,7 @@ module "mux" { ### Skip Install -Run without installing from the network (requires Mux to be pre-installed): +Run without installing from the network (requires Mux to be pre-installed at `install_prefix`; a copy left at the pre-1.6.0 default `/tmp/mux` is still picked up when `install_prefix` is not set): ```tf module "mux" { @@ -180,6 +180,8 @@ module "mux" { - Requires a Node.js runtime; if `node` is not on the workspace `PATH`, the module bootstraps a pinned Node.js runtime into `~/.local/share/coder-mux` (override the version with the `MUX_NODE_VERSION` environment variable) - Installs `mux@next` from the npm registry by default; set `registry_url` to use a private or mirrored registry - Installs into `install_prefix` (default `$HOME/.coder-modules/coder/mux`), which persists across restarts; the installed version is compared with `install_version` on every start and only refetched when it differs or is missing +- Writes the Mux server log to `log_path` (default `$HOME/.coder-modules/coder/mux/logs/mux.log`); the install and start script output lands next to it in `logs/install.log` and `logs/start.log` +- Exposes a `scripts` output so other modules can serialize their own scripts behind the Mux install with `coder exp sync` - Falls back to a direct tarball download when no package manager is found - Appends best-effort signal and external-kill diagnostics to `log_path` if the mux process dies after startup - Set `restart_on_kill = true` to wait `restart_delay_seconds`, remove `~/.mux/server.lock`, and restart Mux after it exits diff --git a/registry/coder/modules/mux/main.test.ts b/registry/coder/modules/mux/main.test.ts index 886bd1e62..cc609bec2 100644 --- a/registry/coder/modules/mux/main.test.ts +++ b/registry/coder/modules/mux/main.test.ts @@ -1,15 +1,80 @@ import { describe, expect, it } from "bun:test"; import { execContainer, - findResourceInstance, readFileContainer, removeContainer, runContainer, runTerraformApply, runTerraformInit, + type TerraformState, testRequiredVariables, } from "~test"; +const MODULE_ROOT = "/root/.coder-modules/coder/mux"; +const DEFAULT_MUX_BINARY = `${MODULE_ROOT}/mux`; +const DEFAULT_LOG_PATH = `${MODULE_ROOT}/logs/mux.log`; + +// coder-utils renders one coder_script per lifecycle stage; pick them by display name. +const collectScripts = (state: TerraformState) => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + byDisplayName[attrs.display_name as string] = attrs.script as string; + } + } + const install = byDisplayName["Mux: Install Script"]; + const start = byDisplayName["Mux: Start Script"]; + if (!install || !start) { + throw new Error( + `missing mux scripts, found: ${Object.keys(byDisplayName).join(", ")}`, + ); + } + return { install, start }; +}; + +// The coder-utils wrappers call `coder exp sync`; stub the CLI outside a real workspace. +const setupContainer = async (id: string, packages: string) => { + const setup = await execContainer(id, [ + "sh", + "-c", + `${packages} +printf '#!/bin/sh\nexit 0\n' > /usr/local/bin/coder +chmod +x /usr/local/bin/coder`, + ]); + expect(setup.exitCode).toBe(0); +}; + +const writeFakeMux = async (id: string, path: string, body: string) => { + const result = await execContainer(id, [ + "sh", + "-c", + `mkdir -p "$(dirname '${path}')" +cat <<'EOF' > '${path}' +${body} +EOF +chmod +x '${path}'`, + ]); + expect(result.exitCode).toBe(0); +}; + +const runScript = async (id: string, script: string) => { + const output = await execContainer(id, ["bash", "-c", script]); + if (output.exitCode !== 0) { + console.log("STDOUT:\n" + output.stdout); + console.log("STDERR:\n" + output.stderr); + } + return output; +}; + +const ECHO_ARGS_MUX = `#!/usr/bin/env sh +i=1 +for arg in "$@"; do + echo "arg$i=$arg" + i=$((i + 1)) +done`; + describe("mux", async () => { await runTerraformInit(import.meta.dir); @@ -22,41 +87,35 @@ describe("mux", async () => { agent_id: "foo", }); - const instance = findResourceInstance(state, "coder_script"); + const scripts = collectScripts(state); const id = await runContainer("alpine/curl"); try { - const setup = await execContainer(id, [ - "sh", - "-c", - "apk add --no-cache bash tar gzip ca-certificates findutils nodejs && update-ca-certificates", - ]); - expect(setup.exitCode).toBe(0); - - const first = await execContainer(id, ["sh", "-c", instance.script]); - if (first.exitCode !== 0) { - console.log("STDOUT:\n" + first.stdout); - console.log("STDERR:\n" + first.stderr); - } - expect(first.exitCode).toBe(0); - const expectedLines = [ + await setupContainer( + id, + "apk add --no-cache bash tar gzip ca-certificates findutils nodejs >/dev/null && update-ca-certificates", + ); + + const install = await runScript(id, scripts.install); + expect(install.exitCode).toBe(0); + expect(install.stdout).toContain( "📥 No package manager found; downloading tarball from registry...", - "🥳 mux has been installed in /root/.coder-modules/coder/mux", - "🚀 Starting mux server on port 4000...", - "Check logs at /tmp/mux.log!", - ]; - for (const line of expectedLines) { - expect(first.stdout).toContain(line); - } + ); + expect(install.stdout).toContain( + `🥳 mux has been installed in ${MODULE_ROOT}`, + ); - const second = await execContainer(id, ["sh", "-c", instance.script]); - if (second.exitCode !== 0) { - console.log("STDOUT:\n" + second.stdout); - console.log("STDERR:\n" + second.stderr); - } + const start = await runScript(id, scripts.start); + expect(start.exitCode).toBe(0); + expect(start.stdout).toContain("🚀 Starting mux server on port 4000..."); + expect(start.stdout).toContain(`Check logs at ${DEFAULT_LOG_PATH}!`); + + const second = await runScript(id, scripts.install); expect(second.exitCode).toBe(0); expect(second.stdout).toMatch( - /🥳 mux@\S+ is already installed in \/root\/.coder-modules\/coder\/mux; skipping install/, + new RegExp( + `🥳 mux@\\S+ is already installed in ${MODULE_ROOT}; skipping install`, + ), ); expect(second.stdout).not.toContain("📥 No package manager found"); } finally { @@ -68,42 +127,25 @@ describe("mux", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, - install_prefix: "/tmp/mux", - log_path: "/tmp/mux.log", additional_arguments: "--open-mode pinned --add-project '/workspaces/my repo'", }); - const instance = findResourceInstance(state, "coder_script"); + const scripts = collectScripts(state); const id = await runContainer("alpine/curl"); try { - const setup = await execContainer(id, [ - "sh", - "-c", - `apk add --no-cache bash >/dev/null -mkdir -p /tmp/mux -cat <<'EOF' > /tmp/mux/mux -#!/usr/bin/env sh -i=1 -for arg in "$@"; do - echo "arg$i=$arg" - i=$((i + 1)) -done -EOF -chmod +x /tmp/mux/mux`, - ]); - expect(setup.exitCode).toBe(0); - - const output = await execContainer(id, ["sh", "-c", instance.script]); - if (output.exitCode !== 0) { - console.log("STDOUT:\n" + output.stdout); - console.log("STDERR:\n" + output.stderr); - } - expect(output.exitCode).toBe(0); + await setupContainer(id, "apk add --no-cache bash >/dev/null"); + await writeFakeMux(id, DEFAULT_MUX_BINARY, ECHO_ARGS_MUX); + + const install = await runScript(id, scripts.install); + expect(install.exitCode).toBe(0); + expect(install.stdout).toContain("🥳 Found a copy of mux"); + const start = await runScript(id, scripts.start); + expect(start.exitCode).toBe(0); await execContainer(id, ["sh", "-c", "sleep 1"]); - const log = await readFileContainer(id, "/tmp/mux.log"); + const log = await readFileContainer(id, DEFAULT_LOG_PATH); expect(log).toContain("arg1=server"); expect(log).toContain("arg2=--port"); expect(log).toContain("arg3=4000"); @@ -116,25 +158,51 @@ chmod +x /tmp/mux/mux`, } }, 60000); + it("runs a copy pre-installed at the pre-1.6.0 path when install is false", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install: false, + }); + + const scripts = collectScripts(state); + const id = await runContainer("alpine/curl"); + + try { + await setupContainer(id, "apk add --no-cache bash >/dev/null"); + await writeFakeMux(id, "/tmp/mux/mux", ECHO_ARGS_MUX); + + const install = await runScript(id, scripts.install); + expect(install.exitCode).toBe(0); + expect(install.stdout).toContain( + "ℹ️ Using mux pre-installed at /tmp/mux", + ); + expect(install.stdout).toContain("🥳 Found a copy of mux"); + const start = await runScript(id, scripts.start); + expect(start.exitCode).toBe(0); + + await execContainer(id, ["sh", "-c", "sleep 1"]); + const log = await readFileContainer(id, DEFAULT_LOG_PATH); + expect(log).toContain("arg1=server"); + } finally { + await removeContainer(id); + } + }, 60000); + it("logs signal-based exits after startup", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, - install_prefix: "/tmp/mux", - log_path: "/tmp/mux.log", }); - const instance = findResourceInstance(state, "coder_script"); + const scripts = collectScripts(state); const id = await runContainer("alpine/curl"); try { - const setup = await execContainer(id, [ - "sh", - "-c", - `apk add --no-cache bash >/dev/null -mkdir -p /tmp/mux -cat <<'EOF' > /tmp/mux/mux -#!/usr/bin/env sh + await setupContainer(id, "apk add --no-cache bash >/dev/null"); + await writeFakeMux( + id, + DEFAULT_MUX_BINARY, + `#!/usr/bin/env sh target_pid="$$" ( sleep 1 @@ -142,21 +210,14 @@ target_pid="$$" ) & while true; do sleep 1 -done -EOF -chmod +x /tmp/mux/mux`, - ]); - expect(setup.exitCode).toBe(0); - - const output = await execContainer(id, ["sh", "-c", instance.script]); - if (output.exitCode !== 0) { - console.log("STDOUT:\n" + output.stdout); - console.log("STDERR:\n" + output.stderr); - } - expect(output.exitCode).toBe(0); +done`, + ); + + expect((await runScript(id, scripts.install)).exitCode).toBe(0); + expect((await runScript(id, scripts.start)).exitCode).toBe(0); await execContainer(id, ["sh", "-c", "sleep 2"]); - const log = await readFileContainer(id, "/tmp/mux.log"); + const log = await readFileContainer(id, DEFAULT_LOG_PATH); expect(log).toContain("shell exit code 137"); expect(log).toContain( "SIGKILL usually means the process was killed externally or by the OOM killer.", @@ -170,24 +231,20 @@ chmod +x /tmp/mux/mux`, const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, - install_prefix: "/tmp/mux", - log_path: "/tmp/mux.log", restart_on_kill: true, restart_delay_seconds: 1, max_restart_attempts: 1, }); - const instance = findResourceInstance(state, "coder_script"); + const scripts = collectScripts(state); const id = await runContainer("alpine/curl"); try { - const setup = await execContainer(id, [ - "sh", - "-c", - `apk add --no-cache bash >/dev/null -mkdir -p /tmp/mux -cat <<'EOF' > /tmp/mux/mux -#!/usr/bin/env sh + await setupContainer(id, "apk add --no-cache bash >/dev/null"); + await writeFakeMux( + id, + DEFAULT_MUX_BINARY, + `#!/usr/bin/env sh run_count_file="/tmp/mux-run-count" run_count=0 if [ -f "$run_count_file" ]; then @@ -206,21 +263,14 @@ if [ -f "$HOME/.mux/server.lock" ]; then else echo "lock=cleaned" fi -exit 0 -EOF -chmod +x /tmp/mux/mux`, - ]); - expect(setup.exitCode).toBe(0); - - const output = await execContainer(id, ["sh", "-c", instance.script]); - if (output.exitCode !== 0) { - console.log("STDOUT:\n" + output.stdout); - console.log("STDERR:\n" + output.stderr); - } - expect(output.exitCode).toBe(0); +exit 0`, + ); + + expect((await runScript(id, scripts.install)).exitCode).toBe(0); + expect((await runScript(id, scripts.start)).exitCode).toBe(0); await execContainer(id, ["sh", "-c", "sleep 4"]); - const log = await readFileContainer(id, "/tmp/mux.log"); + const log = await readFileContainer(id, DEFAULT_LOG_PATH); const runCount = await readFileContainer(id, "/tmp/mux-run-count"); expect(log).toContain("run=1"); expect(log).toContain("mux server exited cleanly."); @@ -245,24 +295,20 @@ chmod +x /tmp/mux/mux`, const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", install: false, - install_prefix: "/tmp/mux", - log_path: "/tmp/mux.log", restart_on_kill: true, restart_delay_seconds: 1, max_restart_attempts: 1, }); - const instance = findResourceInstance(state, "coder_script"); + const scripts = collectScripts(state); const id = await runContainer("alpine/curl"); try { - const setup = await execContainer(id, [ - "sh", - "-c", - `apk add --no-cache bash >/dev/null -mkdir -p /tmp/mux -cat <<'EOF' > /tmp/mux/mux -#!/usr/bin/env sh + await setupContainer(id, "apk add --no-cache bash >/dev/null"); + await writeFakeMux( + id, + DEFAULT_MUX_BINARY, + `#!/usr/bin/env sh run_count_file="/tmp/mux-run-count" run_count=0 if [ -f "$run_count_file" ]; then @@ -274,21 +320,14 @@ echo "run=$run_count" if [ "$run_count" -eq 1 ]; then kill -TERM $$ fi -exit 0 -EOF -chmod +x /tmp/mux/mux`, - ]); - expect(setup.exitCode).toBe(0); - - const output = await execContainer(id, ["sh", "-c", instance.script]); - if (output.exitCode !== 0) { - console.log("STDOUT:\n" + output.stdout); - console.log("STDERR:\n" + output.stderr); - } - expect(output.exitCode).toBe(0); +exit 0`, + ); + + expect((await runScript(id, scripts.install)).exitCode).toBe(0); + expect((await runScript(id, scripts.start)).exitCode).toBe(0); await execContainer(id, ["sh", "-c", "sleep 4"]); - const log = await readFileContainer(id, "/tmp/mux.log"); + const log = await readFileContainer(id, DEFAULT_LOG_PATH); const runCount = await readFileContainer(id, "/tmp/mux-run-count"); expect(log).toContain("run=1"); expect(log).toContain("signal TERM (15); shell exit code 143."); @@ -310,41 +349,43 @@ chmod +x /tmp/mux/mux`, agent_id: "foo", }); - const instance = findResourceInstance(state, "coder_script"); + const scripts = collectScripts(state); const id = await runContainer("node:20-alpine"); try { - const setup = await execContainer(id, ["sh", "-c", "apk add bash"]); - expect(setup.exitCode).toBe(0); + await setupContainer(id, "apk add bash >/dev/null"); - const first = await execContainer(id, ["sh", "-c", instance.script]); - if (first.exitCode !== 0) { - console.log("STDOUT:\n" + first.stdout); - console.log("STDERR:\n" + first.stderr); - } - expect(first.exitCode).toBe(0); + const install = await runScript(id, scripts.install); + expect(install.exitCode).toBe(0); const expectedLines = [ - "📦 Installing mux via npm into /root/.coder-modules/coder/mux...", + `📦 Installing mux via npm into ${MODULE_ROOT}...`, "⏭️ Skipping lifecycle scripts with --ignore-scripts", - "🥳 mux has been installed in /root/.coder-modules/coder/mux", - "🚀 Starting mux server on port 4000...", - "Check logs at /tmp/mux.log!", + `🥳 mux has been installed in ${MODULE_ROOT}`, ]; for (const line of expectedLines) { - expect(first.stdout).toContain(line); + expect(install.stdout).toContain(line); } + const installLog = await readFileContainer( + id, + `${MODULE_ROOT}/logs/install.log`, + ); + expect(installLog).toContain( + `🥳 mux has been installed in ${MODULE_ROOT}`, + ); - const second = await execContainer(id, ["sh", "-c", instance.script]); - if (second.exitCode !== 0) { - console.log("STDOUT:\n" + second.stdout); - console.log("STDERR:\n" + second.stderr); - } + const start = await runScript(id, scripts.start); + expect(start.exitCode).toBe(0); + expect(start.stdout).toContain("🚀 Starting mux server on port 4000..."); + expect(start.stdout).toContain(`Check logs at ${DEFAULT_LOG_PATH}!`); + + const second = await runScript(id, scripts.install); expect(second.exitCode).toBe(0); expect(second.stdout).toMatch( - /🥳 mux@\S+ is already installed in \/root\/.coder-modules\/coder\/mux; skipping install/, + new RegExp( + `🥳 mux@\\S+ is already installed in ${MODULE_ROOT}; skipping install`, + ), ); expect(second.stdout).not.toContain("📦 Installing mux via npm"); - expect(second.stdout).toContain("🚀 Starting mux server on port 4000..."); } finally { await removeContainer(id); } diff --git a/registry/coder/modules/mux/main.tf b/registry/coder/modules/mux/main.tf index 24c6b9bb1..c8ce4db51 100644 --- a/registry/coder/modules/mux/main.tf +++ b/registry/coder/modules/mux/main.tf @@ -5,7 +5,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.13" } random = { source = "hashicorp/random" @@ -45,8 +45,8 @@ variable "install_prefix" { variable "log_path" { type = string - description = "The path for Mux logs." - default = "/tmp/mux.log" + description = "The path for the Mux server log." + default = "$HOME/.coder-modules/coder/mux/logs/mux.log" } variable "restart_on_kill" { @@ -143,6 +143,11 @@ variable "use_cached" { type = bool description = "Reuse any installed copy of Mux without checking its version. By default the installed copy is reused only when its version matches install_version and reinstalled otherwise." default = false + + validation { + condition = var.install || !var.use_cached + error_message = "Cannot use 'use_cached' when 'install' is false" + } } variable "subdomain" { @@ -183,36 +188,56 @@ resource "random_password" "mux_auth_token" { locals { mux_auth_token = random_password.mux_auth_token.result registry_url = trimsuffix(var.registry_url, "/") -} + # An empty install_version means the registry's latest release. + install_version = var.install_version == "" ? "latest" : var.install_version -resource "coder_script" "mux" { - agent_id = var.agent_id - display_name = var.display_name - icon = "/icon/mux.svg" - script = templatefile("${path.module}/run.sh", { - VERSION : var.install_version, + default_install_prefix = "$HOME/.coder-modules/coder/mux" + # Releases before 1.6.0 installed into /tmp/mux. With the default prefix, a + # copy pre-installed there keeps working (install = false, use_cached = true). + legacy_install_prefix = var.install_prefix == local.default_install_prefix ? "/tmp/mux" : "" + + # Keyed by slug so several instances on one agent do not share scripts or logs. + module_directory = "$HOME/.coder-modules/coder/${var.slug}" + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + VERSION : local.install_version, + INSTALL_PREFIX : var.install_prefix, + LEGACY_INSTALL_PREFIX : local.legacy_install_prefix, + OFFLINE : !var.install, + USE_CACHED : var.use_cached, + PACKAGE_MANAGER : var.package_manager, + REGISTRY_URL : local.registry_url, + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { PORT : var.port, LOG_PATH : var.log_path, ADD_PROJECT : var.add_project == null ? "" : var.add_project, ADDITIONAL_ARGUMENTS : var.additional_arguments, INSTALL_PREFIX : var.install_prefix, - OFFLINE : !var.install, - USE_CACHED : var.use_cached, AUTH_TOKEN : local.mux_auth_token, RESTART_ON_KILL : var.restart_on_kill, RESTART_DELAY_SECONDS : var.restart_delay_seconds, MAX_RESTART_ATTEMPTS : var.max_restart_attempts, - PACKAGE_MANAGER : var.package_manager, - REGISTRY_URL : local.registry_url, }) - run_on_start = true +} - lifecycle { - precondition { - condition = var.install || !var.use_cached - error_message = "Cannot use 'use_cached' when 'install' is false" - } - } +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = local.module_directory + display_name_prefix = var.display_name + icon = "/icon/mux.svg" + install_script = local.install_script + start_script = local.start_script +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (install, start)." + # The start script embeds the sensitive auth token, which taints the derived names. + value = nonsensitive(module.coder_utils.scripts) } resource "coder_app" "mux" { diff --git a/registry/coder/modules/mux/mux.tftest.hcl b/registry/coder/modules/mux/mux.tftest.hcl index 3706e8a1f..ae02107fd 100644 --- a/registry/coder/modules/mux/mux.tftest.hcl +++ b/registry/coder/modules/mux/mux.tftest.hcl @@ -16,7 +16,7 @@ run "install_false_and_use_cached_conflict" { } expect_failures = [ - resource.coder_script.mux + var.use_cached ] } @@ -50,12 +50,12 @@ run "auth_token_in_server_script" { } assert { - condition = strcontains(resource.coder_script.mux.script, "MUX_SERVER_AUTH_TOKEN=") + condition = strcontains(local.start_script, "MUX_SERVER_AUTH_TOKEN=") error_message = "mux launch script must set MUX_SERVER_AUTH_TOKEN" } assert { - condition = strcontains(resource.coder_script.mux.script, random_password.mux_auth_token.result) + condition = strcontains(local.start_script, random_password.mux_auth_token.result) error_message = "mux launch script must use the generated auth token" } } @@ -79,8 +79,9 @@ run "auth_token_in_url" { } } +# The start script embeds random_password.result, so it is unknown during plan. run "custom_additional_arguments" { - command = plan + command = apply variables { agent_id = "foo" @@ -88,31 +89,33 @@ run "custom_additional_arguments" { } assert { - condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'") + condition = strcontains(local.start_script, "--open-mode pinned --add-project '/workspaces/my repo'") error_message = "mux launch script must include the configured additional arguments" } } +# The start script embeds random_password.result, so it is unknown during plan. run "launcher_logs_external_kills" { - command = plan + command = apply variables { agent_id = "foo" } assert { - condition = strcontains(resource.coder_script.mux.script, "shell exit code $exit_code") + condition = strcontains(local.start_script, "shell exit code $exit_code") error_message = "mux launcher must log the shell exit code when the server dies unexpectedly" } assert { - condition = strcontains(resource.coder_script.mux.script, "SIGKILL usually means the process was killed externally or by the OOM killer.") + condition = strcontains(local.start_script, "SIGKILL usually means the process was killed externally or by the OOM killer.") error_message = "mux launcher must explain SIGKILL exits in the log" } } +# The start script embeds random_password.result, so it is unknown during plan. run "restart_on_kill_enabled" { - command = plan + command = apply variables { agent_id = "foo" @@ -121,38 +124,39 @@ run "restart_on_kill_enabled" { } assert { - condition = strcontains(resource.coder_script.mux.script, "restart_on_kill_value=\"true\"") + condition = strcontains(local.start_script, "restart_on_kill_value=\"true\"") error_message = "mux launcher must receive the restart_on_kill setting" } assert { - condition = strcontains(resource.coder_script.mux.script, "restart_delay_seconds_value=\"7\"") + condition = strcontains(local.start_script, "restart_delay_seconds_value=\"7\"") error_message = "mux launcher must receive the configured restart delay" } assert { - condition = strcontains(resource.coder_script.mux.script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.") + condition = strcontains(local.start_script, "Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited.") error_message = "mux launcher must log the restart delay before relaunching" } assert { - condition = strcontains(resource.coder_script.mux.script, "Removing $HOME/.mux/server.lock before restarting mux.") + condition = strcontains(local.start_script, "Removing $HOME/.mux/server.lock before restarting mux.") error_message = "mux launcher must clean up the server lock before relaunching" } assert { - condition = !strcontains(resource.coder_script.mux.script, "\"$exit_code\" -le 128") + condition = !strcontains(local.start_script, "\"$exit_code\" -le 128") error_message = "mux launcher must no longer exclude non-signal exits from restart handling" } assert { - condition = !strcontains(resource.coder_script.mux.script, "1|2|15)") + condition = !strcontains(local.start_script, "1|2|15)") error_message = "mux launcher must no longer exclude intentional signals from restart handling" } } +# The start script embeds random_password.result, so it is unknown during plan. run "restart_on_kill_with_restart_cap" { - command = plan + command = apply variables { agent_id = "foo" @@ -162,17 +166,17 @@ run "restart_on_kill_with_restart_cap" { } assert { - condition = strcontains(resource.coder_script.mux.script, "max_restart_attempts_value=\"2\"") + condition = strcontains(local.start_script, "max_restart_attempts_value=\"2\"") error_message = "mux launcher must receive the configured restart cap" } assert { - condition = strcontains(resource.coder_script.mux.script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.") + condition = strcontains(local.start_script, "Mux will stop restarting after $${max_restart_attempts_value} restart attempts.") error_message = "mux launcher must describe the configured restart cap" } assert { - condition = strcontains(resource.coder_script.mux.script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.") + condition = strcontains(local.start_script, "Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again.") error_message = "mux launcher must log when it hits the restart cap" } } @@ -255,7 +259,7 @@ run "custom_package_manager_npm" { } assert { - condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"npm\"") + condition = strcontains(local.install_script, "PM_CMD=\"npm\"") error_message = "mux script must set PM_CMD to the configured package manager" } } @@ -269,7 +273,7 @@ run "custom_package_manager_pnpm" { } assert { - condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"pnpm\"") + condition = strcontains(local.install_script, "PM_CMD=\"pnpm\"") error_message = "mux script must set PM_CMD to the configured package manager" } } @@ -283,7 +287,7 @@ run "custom_package_manager_bun" { } assert { - condition = strcontains(resource.coder_script.mux.script, "PM_CMD=\"bun\"") + condition = strcontains(local.install_script, "PM_CMD=\"bun\"") error_message = "mux script must set PM_CMD to the configured package manager" } } @@ -312,12 +316,12 @@ run "custom_registry_url" { } assert { - condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com") + condition = strcontains(local.install_script, "https://npm.example.com") error_message = "mux script must use the configured registry URL" } assert { - condition = !strcontains(resource.coder_script.mux.script, "registry.npmjs.org") + condition = !strcontains(local.install_script, "registry.npmjs.org") error_message = "mux script must not contain hardcoded registry.npmjs.org when custom registry is set" } } @@ -332,38 +336,48 @@ run "registry_url_trailing_slash" { } assert { - condition = strcontains(resource.coder_script.mux.script, "https://npm.example.com/mux/") + condition = strcontains(local.install_script, "https://npm.example.com/mux/") error_message = "registry URL trailing slash must be stripped to avoid double slashes" } } -# Default install prefix persists under the home directory and the script +# Default install prefix persists under the module root and the install script # resolves the wanted version before deciding whether to reinstall. run "default_install_prefix_is_persistent" { - command = plan + command = apply variables { agent_id = "foo" } assert { - condition = strcontains(resource.coder_script.mux.script, "MUX_BINARY=\"$HOME/.coder-modules/coder/mux/mux\"") + condition = strcontains(local.install_script, "MUX_BINARY=\"$HOME/.coder-modules/coder/mux/mux\"") error_message = "mux must install under $HOME/.coder-modules/coder/mux by default" } assert { - condition = strcontains(resource.coder_script.mux.script, "https://registry.npmjs.org/mux/next") - error_message = "mux script must resolve install_version against the registry to detect a stale install" + condition = strcontains(local.start_script, "MUX_BINARY=\"$HOME/.coder-modules/coder/mux/mux\"") + error_message = "start script must launch the binary from the same prefix" + } + + assert { + condition = strcontains(local.install_script, "PKG_SPEC=\"mux@next\"") + error_message = "install script must resolve and install the requested dist-tag" } assert { - condition = strcontains(resource.coder_script.mux.script, "is already installed in $HOME/.coder-modules/coder/mux; skipping install") - error_message = "mux script must skip the install when the installed version matches" + condition = strcontains(local.install_script, "is already installed in $HOME/.coder-modules/coder/mux; skipping install") + error_message = "install script must skip the install when the installed version matches" + } + + assert { + condition = strcontains(local.install_script, "ln -sf \"/tmp/mux/mux\" \"$MUX_BINARY\"") + error_message = "default prefix must keep a pre-1.6.0 copy in /tmp/mux working" } } run "custom_install_prefix" { - command = plan + command = apply variables { agent_id = "foo" @@ -371,8 +385,53 @@ run "custom_install_prefix" { } assert { - condition = strcontains(resource.coder_script.mux.script, "MUX_BINARY=\"/opt/mux/mux\"") - error_message = "mux script must honor a custom install_prefix" + condition = strcontains(local.install_script, "MUX_BINARY=\"/opt/mux/mux\"") && strcontains(local.start_script, "MUX_BINARY=\"/opt/mux/mux\"") + error_message = "both scripts must honor a custom install_prefix" + } + + assert { + condition = strcontains(local.install_script, "[ -n \"\" ]") + error_message = "a custom install_prefix must not fall back to the legacy /tmp/mux path" + } +} + +run "empty_install_version_means_latest" { + command = apply + + variables { + agent_id = "foo" + install_version = "" + } + + assert { + condition = strcontains(local.install_script, "PKG_SPEC=\"mux@latest\"") && strcontains(local.install_script, "https://registry.npmjs.org/mux/latest") + error_message = "an empty install_version must resolve and install latest" + } +} + +run "default_log_path_in_module_root" { + command = apply + + variables { + agent_id = "foo" + } + + assert { + condition = strcontains(local.start_script, "LOG_PATH=\"$HOME/.coder-modules/coder/mux/logs/mux.log\"") + error_message = "the Mux server log must default to the module root's logs directory" + } +} + +run "coder_utils_orders_install_before_start" { + command = apply + + variables { + agent_id = "foo" + } + + assert { + condition = length(output.scripts) == 2 && output.scripts[0] == "coder-mux-install_script" && output.scripts[1] == "coder-mux-start_script" + error_message = "coder-utils must expose the install and start scripts in run order" } } diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/scripts/install.sh.tftpl similarity index 57% rename from registry/coder/modules/mux/run.sh rename to registry/coder/modules/mux/scripts/install.sh.tftpl index d716086b8..1b4ef0259 100644 --- a/registry/coder/modules/mux/run.sh +++ b/registry/coder/modules/mux/scripts/install.sh.tftpl @@ -1,199 +1,10 @@ #!/usr/bin/env bash BOLD='\033[0;1m' -RESET='\033[0m' MUX_BINARY="${INSTALL_PREFIX}/mux" +NPM_WORKDIR="${INSTALL_PREFIX}/npm" +PKG_SPEC="mux@${VERSION}" -function run_mux() { - local port_value - local auth_token_value - local restart_on_kill_value - local restart_delay_seconds_value - local max_restart_attempts_value - - port_value="${PORT}" - auth_token_value="${AUTH_TOKEN}" - restart_on_kill_value="${RESTART_ON_KILL}" - restart_delay_seconds_value="${RESTART_DELAY_SECONDS}" - max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}" - - if [ -z "$port_value" ]; then - port_value="4000" - fi - - if [ -z "$restart_delay_seconds_value" ]; then - restart_delay_seconds_value="5" - fi - - if [ -z "$max_restart_attempts_value" ]; then - max_restart_attempts_value="0" - fi - - mkdir -p "$(dirname "${LOG_PATH}")" - - # Build args for mux (POSIX-compatible, avoid bash arrays) - set -- server --port "$port_value" - if [ -n "${ADD_PROJECT}" ]; then - set -- "$@" --add-project "${ADD_PROJECT}" - fi - - # Parse additional user-supplied server arguments while preserving quoted groups. - if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then - local parsed_additional_arguments - if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then - echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced." - exit 1 - fi - while IFS= read -r parsed_arg; do - [ -n "$parsed_arg" ] || continue - set -- "$@" "$parsed_arg" - done << EOF_ARGS -$${parsed_additional_arguments} -EOF_ARGS - fi - - echo "🚀 Starting mux server on port $port_value..." - echo "Check logs at ${LOG_PATH}!" - echo "ℹ️ Mux exit details will be appended to ${LOG_PATH} by the launcher." - if [ "$restart_on_kill_value" = true ]; then - echo "ℹ️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay." - if [ "$max_restart_attempts_value" = "0" ]; then - echo "ℹ️ Automatic restarts are unlimited for every mux exit." - else - echo "ℹ️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts." - fi - fi - - nohup env \ - LOG_PATH="${LOG_PATH}" \ - MUX_BINARY="$MUX_BINARY" \ - AUTH_TOKEN="$auth_token_value" \ - PORT_VALUE="$port_value" \ - RESTART_ON_KILL_VALUE="$restart_on_kill_value" \ - RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \ - MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \ - bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' & -signal_name() { - local signal_number="$1" - local resolved_signal - - resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)" - if [ -n "$resolved_signal" ]; then - printf '%s' "$resolved_signal" - return 0 - fi - - printf 'SIG%s' "$signal_number" -} - -append_kernel_kill_context() { - local mux_pid="$1" - local kernel_context="" - - if command -v dmesg > /dev/null 2>&1; then - kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)" - fi - - if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then - kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)" - fi - - if [ -n "$kernel_context" ]; then - echo "Recent kernel kill context:" - echo "$kernel_context" - else - echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)." - fi -} - -cleanup_mux_lock() { - rm -f "$HOME/.mux/server.lock" -} - -should_restart_mux() { - [ "$RESTART_ON_KILL_VALUE" = "true" ] -} - -log_mux_exit() { - local mux_pid="$1" - local exit_code="$2" - local timestamp - - timestamp="$(date -Iseconds 2> /dev/null || date)" - - if [ "$exit_code" -eq 0 ]; then - echo "[$timestamp] mux server exited cleanly." - return 0 - fi - - if [ "$exit_code" -gt 128 ]; then - local signal_number=$((exit_code - 128)) - local signal_label - - signal_label="$(signal_name "$signal_number")" - echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code." - - if [ "$signal_number" -eq 9 ]; then - echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer." - append_kernel_kill_context "$mux_pid" - fi - - echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." - return 0 - fi - - echo "[$timestamp] mux server exited with code $exit_code." - echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." -} - -log_mux_restart_wait() { - local timestamp - - timestamp="$(date -Iseconds 2> /dev/null || date)" - echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited." -} - -log_mux_restart_cleanup() { - local timestamp - - timestamp="$(date -Iseconds 2> /dev/null || date)" - echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux." -} - -log_mux_restart_cap_reached() { - local timestamp - - timestamp="$(date -Iseconds 2> /dev/null || date)" - echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again." -} - -restart_attempt_count=0 -while true; do - cleanup_mux_lock - MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 & - mux_pid=$! - wait "$mux_pid" - exit_code=$? - log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1 - - if should_restart_mux; then - if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then - log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1 - break - fi - - restart_attempt_count=$((restart_attempt_count + 1)) - log_mux_restart_wait >> "$LOG_PATH" 2>&1 - sleep "$RESTART_DELAY_SECONDS_VALUE" - cleanup_mux_lock - log_mux_restart_cleanup >> "$LOG_PATH" 2>&1 - continue - fi - - break -done -EOF_LAUNCHER -} # Ensure a Node.js runtime is available (mux is a Node application launched # via "#!/usr/bin/env node"). When the workspace image does not provide node, # bootstrap a pinned runtime into $HOME so it persists across restarts. @@ -233,17 +44,43 @@ ensure_node() { ensure_node +# Releases before 1.6.0 installed into /tmp/mux. Link a copy pre-installed there +# so install=false and use_cached keep working while the default prefix is cold. +if [ ! -f "$MUX_BINARY" ] && [ -n "${LEGACY_INSTALL_PREFIX}" ] && [ -f "${LEGACY_INSTALL_PREFIX}/mux" ]; then + if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then + echo "ℹ️ Using mux pre-installed at ${LEGACY_INSTALL_PREFIX}" + mkdir -p "$(dirname "$MUX_BINARY")" + ln -sf "${LEGACY_INSTALL_PREFIX}/mux" "$MUX_BINARY" + fi +fi + # Check if mux is already installed for offline mode if [ "${OFFLINE}" = true ]; then if [ -f "$MUX_BINARY" ]; then echo "🥳 Found a copy of mux" - run_mux exit 0 fi echo "❌ Failed to find a copy of mux" exit 1 fi +# Determine which package manager to use +PM_CMD="" +if [ "${PACKAGE_MANAGER}" = "auto" ]; then + for pm in npm pnpm bun; do + if command -v "$pm" > /dev/null 2>&1; then + PM_CMD="$pm" + break + fi + done +else + PM_CMD="${PACKAGE_MANAGER}" + if ! command -v "$PM_CMD" > /dev/null 2>&1; then + echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH" + exit 1 + fi +fi + # Version of the mux package installed under the install prefix (package # manager or extracted tarball layout); empty when nothing is installed. installed_mux_version() { @@ -258,15 +95,20 @@ installed_mux_version() { # Concrete version the registry currently maps the requested version or # dist-tag to; empty when it cannot be resolved, which falls back to a reinstall. -# curl is preferred because it honors proxy environment variables; node's fetch -# covers images that ship a package manager without curl. +# Resolved through the selected package manager so its registry auth applies; +# the tarball path has only curl, matching how it downloads the tarball. wanted_mux_version() { - local meta_url="${REGISTRY_URL}/mux/${VERSION}" - if command -v curl > /dev/null 2>&1; then - curl -fsSL --max-time 15 "$meta_url" 2> /dev/null | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}' - else - node -e 'fetch(process.argv[1],{signal:AbortSignal.timeout(15000)}).then((r)=>(r.ok?r.json():null)).then((data)=>{if(data&&data.version){console.log(data.version);}}).catch(()=>{})' "$meta_url" 2> /dev/null - fi + case "$PM_CMD" in + npm | pnpm) + "$PM_CMD" view "$PKG_SPEC" version --registry "${REGISTRY_URL}" 2> /dev/null | tail -n 1 | sed "s/^.* '\(.*\)'$/\1/" + ;; + bun) + (cd "$NPM_WORKDIR" 2> /dev/null && bun pm view "$PKG_SPEC" version --registry "${REGISTRY_URL}" 2> /dev/null | tail -n 1) + ;; + *) + curl -fsSL --max-time 15 "${REGISTRY_URL}/mux/${VERSION}" 2> /dev/null | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}' + ;; + esac } NEEDS_INSTALL=true @@ -295,38 +137,16 @@ if [ "$NEEDS_INSTALL" = true ]; then mkdir -p "$(dirname "$MUX_BINARY")" - # Determine which package manager to use - PM_CMD="" - if [ "${PACKAGE_MANAGER}" = "auto" ]; then - for pm in npm pnpm bun; do - if command -v "$pm" > /dev/null 2>&1; then - PM_CMD="$pm" - break - fi - done - else - PM_CMD="${PACKAGE_MANAGER}" - if ! command -v "$PM_CMD" > /dev/null 2>&1; then - echo "❌ Configured package manager '${PACKAGE_MANAGER}' not found on PATH" - exit 1 - fi - fi - if [ -n "$PM_CMD" ]; then echo "📦 Installing mux via $PM_CMD into ${INSTALL_PREFIX}..." - NPM_WORKDIR="${INSTALL_PREFIX}/npm" + # Only one layout is kept so the installed version is read from the active one. + rm -rf "${INSTALL_PREFIX}/.mux-package" mkdir -p "$NPM_WORKDIR" cd "$NPM_WORKDIR" || exit 1 if [ ! -f package.json ]; then echo '{}' > package.json fi echo "⏭️ Skipping lifecycle scripts with --ignore-scripts" - PKG="mux" - if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then - PKG_SPEC="$PKG@latest" - else - PKG_SPEC="$PKG@${VERSION}" - fi INSTALL_OK=true case "$PM_CMD" in npm) @@ -360,10 +180,8 @@ if [ "$NEEDS_INSTALL" = true ]; then ln -sf "$CANDIDATE" "$MUX_BINARY" else echo "📥 No package manager found; downloading tarball from registry..." + rm -rf "$NPM_WORKDIR" VERSION_TO_USE="${VERSION}" - if [ -z "$VERSION_TO_USE" ]; then - VERSION_TO_USE="next" - fi META_URL="${REGISTRY_URL}/mux/$VERSION_TO_USE" META_JSON="$(curl -fsSL "$META_URL" || true)" if [ -z "$META_JSON" ]; then @@ -479,6 +297,3 @@ if [ -n "$CODER_SCRIPT_BIN_DIR" ]; then ln -s "$MUX_BINARY" "$CODER_SCRIPT_BIN_DIR/mux" fi fi - -# Start mux -run_mux diff --git a/registry/coder/modules/mux/scripts/start.sh.tftpl b/registry/coder/modules/mux/scripts/start.sh.tftpl new file mode 100644 index 000000000..78d0011b5 --- /dev/null +++ b/registry/coder/modules/mux/scripts/start.sh.tftpl @@ -0,0 +1,218 @@ +#!/usr/bin/env bash + +MUX_BINARY="${INSTALL_PREFIX}/mux" + +# The install script bootstraps a pinned Node.js into $HOME when the image has none. +use_bootstrapped_node() { + if command -v node > /dev/null 2>&1; then + return 0 + fi + + local node_version node_arch + node_version="$${MUX_NODE_VERSION:-22.14.0}" + case "$(uname -m)" in + x86_64 | amd64) node_arch="x64" ;; + aarch64 | arm64) node_arch="arm64" ;; + *) return 0 ;; + esac + export PATH="$HOME/.local/share/coder-mux/node-v$node_version-linux-$node_arch/bin:$PATH" +} + +function run_mux() { + local port_value + local auth_token_value + local restart_on_kill_value + local restart_delay_seconds_value + local max_restart_attempts_value + + port_value="${PORT}" + auth_token_value="${AUTH_TOKEN}" + restart_on_kill_value="${RESTART_ON_KILL}" + restart_delay_seconds_value="${RESTART_DELAY_SECONDS}" + max_restart_attempts_value="${MAX_RESTART_ATTEMPTS}" + + if [ -z "$port_value" ]; then + port_value="4000" + fi + + if [ -z "$restart_delay_seconds_value" ]; then + restart_delay_seconds_value="5" + fi + + if [ -z "$max_restart_attempts_value" ]; then + max_restart_attempts_value="0" + fi + + mkdir -p "$(dirname "${LOG_PATH}")" + + # Build args for mux (POSIX-compatible, avoid bash arrays) + set -- server --port "$port_value" + if [ -n "${ADD_PROJECT}" ]; then + set -- "$@" --add-project "${ADD_PROJECT}" + fi + + # Parse additional user-supplied server arguments while preserving quoted groups. + if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then + local parsed_additional_arguments + if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then + echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced." + exit 1 + fi + while IFS= read -r parsed_arg; do + [ -n "$parsed_arg" ] || continue + set -- "$@" "$parsed_arg" + done << EOF_ARGS +$${parsed_additional_arguments} +EOF_ARGS + fi + + echo "🚀 Starting mux server on port $port_value..." + echo "Check logs at ${LOG_PATH}!" + echo "ℹ️ Mux exit details will be appended to ${LOG_PATH} by the launcher." + if [ "$restart_on_kill_value" = true ]; then + echo "ℹ️ Auto-restart after mux exits is enabled with a $${restart_delay_seconds_value}-second delay." + if [ "$max_restart_attempts_value" = "0" ]; then + echo "ℹ️ Automatic restarts are unlimited for every mux exit." + else + echo "ℹ️ Mux will stop restarting after $${max_restart_attempts_value} restart attempts." + fi + fi + + nohup env \ + LOG_PATH="${LOG_PATH}" \ + MUX_BINARY="$MUX_BINARY" \ + AUTH_TOKEN="$auth_token_value" \ + PORT_VALUE="$port_value" \ + RESTART_ON_KILL_VALUE="$restart_on_kill_value" \ + RESTART_DELAY_SECONDS_VALUE="$restart_delay_seconds_value" \ + MAX_RESTART_ATTEMPTS_VALUE="$max_restart_attempts_value" \ + bash -s -- "$@" > /dev/null 2>&1 << 'EOF_LAUNCHER' & +signal_name() { + local signal_number="$1" + local resolved_signal + + resolved_signal="$(kill -l "$signal_number" 2> /dev/null || true)" + if [ -n "$resolved_signal" ]; then + printf '%s' "$resolved_signal" + return 0 + fi + + printf 'SIG%s' "$signal_number" +} + +append_kernel_kill_context() { + local mux_pid="$1" + local kernel_context="" + + if command -v dmesg > /dev/null 2>&1; then + kernel_context="$(dmesg -T 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)" + fi + + if [ -z "$kernel_context" ] && command -v journalctl > /dev/null 2>&1; then + kernel_context="$(journalctl -k -n 200 --no-pager 2> /dev/null | grep -Ei "Killed process $mux_pid|out of memory|oom-killer|oom reaper" | tail -n 10 || true)" + fi + + if [ -n "$kernel_context" ]; then + echo "Recent kernel kill context:" + echo "$kernel_context" + else + echo "No kernel OOM/kill context was available (dmesg/journalctl unavailable or permission denied)." + fi +} + +cleanup_mux_lock() { + rm -f "$HOME/.mux/server.lock" +} + +should_restart_mux() { + [ "$RESTART_ON_KILL_VALUE" = "true" ] +} + +log_mux_exit() { + local mux_pid="$1" + local exit_code="$2" + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + + if [ "$exit_code" -eq 0 ]; then + echo "[$timestamp] mux server exited cleanly." + return 0 + fi + + if [ "$exit_code" -gt 128 ]; then + local signal_number=$((exit_code - 128)) + local signal_label + + signal_label="$(signal_name "$signal_number")" + echo "[$timestamp] mux server exited due to signal $signal_label ($signal_number); shell exit code $exit_code." + + if [ "$signal_number" -eq 9 ]; then + echo "[$timestamp] SIGKILL usually means the process was killed externally or by the OOM killer." + append_kernel_kill_context "$mux_pid" + fi + + echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." + return 0 + fi + + echo "[$timestamp] mux server exited with code $exit_code." + echo "[$timestamp] Check the earlier mux log lines for any in-process crash breadcrumbs from mux itself." +} + +log_mux_restart_wait() { + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + echo "[$timestamp] Waiting $${RESTART_DELAY_SECONDS_VALUE} seconds before restarting mux after it exited." +} + +log_mux_restart_cleanup() { + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + echo "[$timestamp] Removing $HOME/.mux/server.lock before restarting mux." +} + +log_mux_restart_cap_reached() { + local timestamp + + timestamp="$(date -Iseconds 2> /dev/null || date)" + echo "[$timestamp] Reached the max restart attempts limit ($MAX_RESTART_ATTEMPTS_VALUE); not restarting mux again." +} + +restart_attempt_count=0 +while true; do + cleanup_mux_lock + MUX_SERVER_AUTH_TOKEN="$AUTH_TOKEN" PORT="$PORT_VALUE" "$MUX_BINARY" "$@" >> "$LOG_PATH" 2>&1 & + mux_pid=$! + wait "$mux_pid" + exit_code=$? + log_mux_exit "$mux_pid" "$exit_code" >> "$LOG_PATH" 2>&1 + + if should_restart_mux; then + if [ "$MAX_RESTART_ATTEMPTS_VALUE" -gt 0 ] && [ "$restart_attempt_count" -ge "$MAX_RESTART_ATTEMPTS_VALUE" ]; then + log_mux_restart_cap_reached >> "$LOG_PATH" 2>&1 + break + fi + + restart_attempt_count=$((restart_attempt_count + 1)) + log_mux_restart_wait >> "$LOG_PATH" 2>&1 + sleep "$RESTART_DELAY_SECONDS_VALUE" + cleanup_mux_lock + log_mux_restart_cleanup >> "$LOG_PATH" 2>&1 + continue + fi + + break +done +EOF_LAUNCHER +} + +if [ ! -f "$MUX_BINARY" ]; then + echo "❌ mux is not installed at $MUX_BINARY; check the install script log" + exit 1 +fi + +use_bootstrapped_node +run_mux From 0e85698ac1fb7b5b93fc358f212abee1a485b03b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:22:39 +0000 Subject: [PATCH 3/3] fix: resolve mux version on bun releases without pm view bun pm view only exists since Bun 1.2.15. Older releases print their usage to stdout and exit 1, so the warm-start check compared the installed version against 'Learn more about these at https://bun.sh/docs/cli/pm' and reinstalled on every start. Check the exit status and fall back to npm view (shares .npmrc credentials) and then to the plain registry lookup. Adds a container test on oven/bun:1.2.14-alpine covering the reuse with and without npm present. --- registry/coder/modules/mux/main.test.ts | 49 +++++++++++++++++++ .../modules/mux/scripts/install.sh.tftpl | 28 +++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/registry/coder/modules/mux/main.test.ts b/registry/coder/modules/mux/main.test.ts index cc609bec2..369cb1d61 100644 --- a/registry/coder/modules/mux/main.test.ts +++ b/registry/coder/modules/mux/main.test.ts @@ -390,4 +390,53 @@ exit 0`, await removeContainer(id); } }, 240000); + + // Bun before 1.2.15 has no `bun pm view`; the version lookup must still + // resolve so the cached install is reused, with and without npm present. + it("reuses the install with a bun that lacks pm view", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + package_manager: "bun", + }); + + const scripts = collectScripts(state); + const id = await runContainer("oven/bun:1.2.14-alpine"); + + try { + await setupContainer( + id, + "apk add --no-cache bash curl nodejs >/dev/null", + ); + + const install = await runScript(id, scripts.install); + expect(install.exitCode).toBe(0); + expect(install.stdout).toContain( + `📦 Installing mux via bun into ${MODULE_ROOT}...`, + ); + expect(install.stdout).toContain( + `🥳 mux has been installed in ${MODULE_ROOT}`, + ); + + const skipped = new RegExp( + `🥳 mux@\\S+ is already installed in ${MODULE_ROOT}; skipping install`, + ); + const withoutNpm = await runScript(id, scripts.install); + expect(withoutNpm.exitCode).toBe(0); + expect(withoutNpm.stdout).toMatch(skipped); + expect(withoutNpm.stdout).not.toContain("📦 Installing mux via bun"); + + const addNpm = await execContainer(id, [ + "sh", + "-c", + "apk add --no-cache npm >/dev/null", + ]); + expect(addNpm.exitCode).toBe(0); + const withNpm = await runScript(id, scripts.install); + expect(withNpm.exitCode).toBe(0); + expect(withNpm.stdout).toMatch(skipped); + expect(withNpm.stdout).not.toContain("📦 Installing mux via bun"); + } finally { + await removeContainer(id); + } + }, 240000); }); diff --git a/registry/coder/modules/mux/scripts/install.sh.tftpl b/registry/coder/modules/mux/scripts/install.sh.tftpl index 1b4ef0259..f4199285b 100644 --- a/registry/coder/modules/mux/scripts/install.sh.tftpl +++ b/registry/coder/modules/mux/scripts/install.sh.tftpl @@ -97,16 +97,38 @@ installed_mux_version() { # dist-tag to; empty when it cannot be resolved, which falls back to a reinstall. # Resolved through the selected package manager so its registry auth applies; # the tarball path has only curl, matching how it downloads the tarball. +pm_view_mux_version() { + "$1" view "$PKG_SPEC" version --registry "${REGISTRY_URL}" 2> /dev/null | tail -n 1 | sed "s/^.* '\(.*\)'$/\1/" +} + +registry_mux_version() { + curl -fsSL --max-time 15 "${REGISTRY_URL}/mux/${VERSION}" 2> /dev/null | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}' +} + wanted_mux_version() { + local resolved case "$PM_CMD" in npm | pnpm) - "$PM_CMD" view "$PKG_SPEC" version --registry "${REGISTRY_URL}" 2> /dev/null | tail -n 1 | sed "s/^.* '\(.*\)'$/\1/" + pm_view_mux_version "$PM_CMD" ;; bun) - (cd "$NPM_WORKDIR" 2> /dev/null && bun pm view "$PKG_SPEC" version --registry "${REGISTRY_URL}" 2> /dev/null | tail -n 1) + # `bun pm view` exists since Bun 1.2.15; older releases print their usage + # to stdout and exit 1. Fall back to npm, which shares .npmrc credentials, + # and then to the plain registry lookup. + if ! resolved="$(cd "$NPM_WORKDIR" 2> /dev/null && bun pm view "$PKG_SPEC" version --registry "${REGISTRY_URL}" 2> /dev/null)"; then + resolved="" + fi + resolved="$(printf '%s' "$resolved" | tail -n 1)" + if [ -z "$resolved" ] && command -v npm > /dev/null 2>&1; then + resolved="$(pm_view_mux_version npm)" + fi + if [ -z "$resolved" ]; then + resolved="$(registry_mux_version)" + fi + printf '%s\n' "$resolved" ;; *) - curl -fsSL --max-time 15 "${REGISTRY_URL}/mux/${VERSION}" 2> /dev/null | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}' + registry_mux_version ;; esac }