From 3a3537a642f11f0a600ac15d0b48167a03a4ad27 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 16 Mar 2026 16:13:57 +0200 Subject: [PATCH] refactor: rewrite develop.sh orchestrator in Go (#23054) Replace the ~370-line bash develop.sh with a Go program using serpent for CLI flags, errgroup for process lifecycle, and codersdk for setup. develop.sh becomes a thin make + exec wrapper. - Process groups for clean shutdown of child trees - Docker template auto-creation via SDK ExampleID - Idempotent setup (users, orgs, templates) - Configurable --port, --web-port, --proxy-port - Preflight runs lib.sh dependency checks - TCP dial for port-busy checks - Make target (build/.bin/develop) for build caching --- Makefile | 3 + scripts/develop.sh | 368 +------------- scripts/develop/main.go | 900 +++++++++++++++++++++++++++++++++++ scripts/develop/main_test.go | 447 +++++++++++++++++ 4 files changed, 1356 insertions(+), 362 deletions(-) create mode 100644 scripts/develop/main.go create mode 100644 scripts/develop/main_test.go diff --git a/Makefile b/Makefile index 8ea62233ec..bc578ed90b 100644 --- a/Makefile +++ b/Makefile @@ -514,6 +514,9 @@ install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT) cp "$<" "$$output_file" .PHONY: install +build/.bin/develop: go.mod go.sum $(GO_SRC_FILES) + CGO_ENABLED=0 go build -o $@ ./scripts/develop + BOLD := $(shell tput bold 2>/dev/null) GREEN := $(shell tput setaf 2 2>/dev/null) RED := $(shell tput setaf 1 2>/dev/null) diff --git a/scripts/develop.sh b/scripts/develop.sh index f9db8f00af..8f4e4d45a4 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -1,368 +1,12 @@ #!/usr/bin/env bash -# Usage: ./develop.sh [--agpl] [--port ] +# Usage: ./develop.sh [flags...] [-- extra server args...] # -# If the --agpl parameter is specified, builds only the AGPL-licensed code (no -# Coder enterprise features). The --port parameter changes the API port. The -# frontend dev server still listens on port 8080. +# This is a thin wrapper that delegates to the Go development orchestrator +# at scripts/develop. See that package for the full implementation. -SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") -# shellcheck source=scripts/lib.sh -source "${SCRIPT_DIR}/lib.sh" - -# Allow toggling verbose output -[[ -n ${VERBOSE:-} ]] && set -x set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." -api_port=3000 -web_port=8080 -proxy_port=3010 -CODER_DEV_ACCESS_URL="${CODER_DEV_ACCESS_URL:-}" -access_url_set=0 -if [ -n "${CODER_DEV_ACCESS_URL}" ]; then - access_url_set=1 -fi -DEVELOP_IN_CODER="${DEVELOP_IN_CODER:-0}" -debug=0 -DEFAULT_PASSWORD="SomeSecurePassword!" -password="${CODER_DEV_ADMIN_PASSWORD:-${DEFAULT_PASSWORD}}" -use_proxy=0 -multi_org=0 - -# Ensure that extant environment variables do not override -# the config dir we use to override auth for dev.coder.com. -unset CODER_SESSION_TOKEN -unset CODER_URL - -args="$(getopt -o "" -l access-url:,use-proxy,agpl,debug,password:,multi-organization,port: -- "$@")" -eval set -- "$args" -while true; do - case "$1" in - --access-url) - CODER_DEV_ACCESS_URL="$2" - access_url_set=1 - shift 2 - ;; - --port) - api_port="$2" - shift 2 - ;; - --agpl) - export CODER_BUILD_AGPL=1 - shift - ;; - --password) - password="$2" - shift 2 - ;; - --use-proxy) - use_proxy=1 - shift - ;; - --multi-organization) - multi_org=1 - shift - ;; - --debug) - debug=1 - shift - ;; - --) - shift - break - ;; - *) - error "Unrecognized option: $1" - ;; - esac -done - -if [ "${CODER_BUILD_AGPL:-0}" -gt "0" ] && [ "${use_proxy}" -gt "0" ]; then - echo '== ERROR: cannot use both external proxies and APGL build.' && exit 1 -fi - -if [ "${CODER_BUILD_AGPL:-0}" -gt "0" ] && [ "${multi_org}" -gt "0" ]; then - echo '== ERROR: cannot use both multi-organizations and APGL build.' && exit 1 -fi - -validate_port() { - local port=$1 - local flag=$2 - - if ! [[ "${port}" =~ ^[0-9]+$ ]]; then - error "${flag} must be an integer between 1 and 65535" - fi - if [ "${#port}" -gt 5 ]; then - error "${flag} must be an integer between 1 and 65535" - fi - if ((10#${port} < 1 || 10#${port} > 65535)); then - error "${flag} must be an integer between 1 and 65535" - fi -} - -validate_port "${api_port}" "--port" -if [ "${api_port}" -eq "${web_port}" ]; then - error "--port cannot use ${web_port} because the frontend dev server uses that port" -fi -if [ "${use_proxy}" -gt "0" ] && [ "${api_port}" -eq "${proxy_port}" ]; then - error "--port cannot use ${proxy_port} when --use-proxy is enabled because the workspace proxy uses that port" -fi -if [ "${access_url_set}" -eq 0 ]; then - CODER_DEV_ACCESS_URL="http://127.0.0.1:${api_port}" -fi - -api_url="http://127.0.0.1:${api_port}" -api_local_url="http://localhost:${api_port}" -web_url="http://127.0.0.1:${web_port}" - -if [ -n "${CODER_AGENT_URL:-}" ]; then - DEVELOP_IN_CODER=1 -fi - -# Preflight checks: ensure we have our required dependencies, and make sure -# nothing is listening on the configured API or frontend ports. -dependencies curl git go jq make pnpm - -if curl --silent --fail "${api_url}" >/dev/null 2>&1; then - # Check if this is the Coder development server. - if curl --silent --fail "${api_url}/api/v2/buildinfo" 2>&1 | jq -r '.version' >/dev/null 2>&1; then - echo "== INFO: Coder development server is already running on port ${api_port}!" && exit 0 - else - echo "== ERROR: something is listening on port ${api_port}. Kill it and re-run this script." && exit 1 - fi -fi - -if curl --fail "${web_url}" >/dev/null 2>&1; then - # Check if this is the Coder development frontend. - if curl --silent --fail "${web_url}/api/v2/buildinfo" 2>&1 | jq -r '.version' >/dev/null 2>&1; then - echo "== ERROR: Coder development frontend is already running on port ${web_port}, but the requested API on port ${api_port} is not already running. Stop the frontend and re-run this script." && exit 1 - else - echo "== ERROR: something is listening on port ${web_port}. Kill it and re-run this script." && exit 1 - fi -fi - -# Compile the CLI binary. This should also compile the frontend and refresh -# node_modules if necessary. -GOOS="$(go env GOOS)" -GOARCH="$(go env GOARCH)" -DEVELOP_IN_CODER="${DEVELOP_IN_CODER}" make -j "build/coder_${GOOS}_${GOARCH}" - -# Use the coder dev shim so we don't overwrite the user's existing Coder config. -CODER_DEV_SHIM="${PROJECT_ROOT}/scripts/coder-dev.sh" - -# Stores the pid of the subshell that runs our main routine. -ppid=0 -# Tracks pids of commands we've started. -pids=() -exit_cleanup() { - set +e - # Set empty interrupt handler so cleanup isn't interrupted. - # HUP is included in case SSH drops while cleanup is already - # in progress from another signal. - trap '' INT TERM HUP - # Remove exit trap to avoid infinite loop. - trap - EXIT - - # Send INT for graceful shutdown. On SIGHUP, the Go server - # re-registers via signal.Notify and handles it, but other - # commands may not. INT covers all cases uniformly. - kill -INT "${pids[@]}" >/dev/null 2>&1 - # Use the hammer if things take too long. Stdout/stderr are - # closed so the background job can't hold the shell open. - { sleep 15 && kill -TERM "${pids[@]}"; } >/dev/null 2>&1 & - - # Wait for all children to exit (this can be aborted by hammer). - wait_cmds - - # Add a sleep to allow any final output to be flushed to keep the - # terminal clean. - sleep 0.5 - - exit 1 -} -start_cmd() { - name=$1 - prefix=$2 - shift 2 - - echo "== CMD: $*" >&2 - - # Shield the command from direct SIGHUP via SIG_IGN, which - # is inherited across exec. Go's signal.Notify resets it to - # caught once registered, enabling graceful shutdown. - ( - trap '' HUP - FORCE_COLOR=1 exec "$@" - ) > >( - # Keep draining output until the command's stdout closes. - # Errexit is off so EIO from a dead terminal doesn't kill - # this reader and break the pipe (causing SIGPIPE). - set +e - trap '' INT HUP - - while read -r line; do - if [[ $prefix == date ]]; then - echo "[$name] $(date '+%Y-%m-%d %H:%M:%S') $line" - else - echo "[$name] $line" - fi - done - echo "== CMD EXIT: $*" >&2 - # Let parent know the command exited. - kill -INT $ppid >/dev/null 2>&1 - ) 2>&1 & - pids+=("$!") -} -wait_cmds() { - wait "${pids[@]}" >/dev/null 2>&1 -} -fatal() { - echo "== FAIL: $*" >&2 - exit_cleanup - exit 1 -} - -# This is a way to run multiple processes in parallel, and have Ctrl-C work correctly -# to kill both at the same time. For more details, see: -# https://stackoverflow.com/questions/3004811/how-do-you-run-multiple-programs-in-parallel-from-a-bash-script -( - ppid=$BASHPID - # If something goes wrong, just bail and tear everything down - # rather than leaving things in an inconsistent state. - trap 'exit_cleanup' INT TERM HUP EXIT - trap 'fatal "Script encountered an error"' ERR - - cdroot - DEBUG_DELVE="${debug}" DEVELOP_IN_CODER="${DEVELOP_IN_CODER}" start_cmd API "" "${CODER_DEV_SHIM}" server --http-address "0.0.0.0:${api_port}" --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true --enable-terraform-debug-mode "$@" - - echo '== Waiting for Coder to become ready' - # Start the timeout in the background so interrupting this script - # doesn't hang for 60s. - timeout 60s bash -c "until curl -s --fail ${api_local_url}/healthz > /dev/null 2>&1; do sleep 0.5; done" || - fatal 'Coder did not become ready in time' & - wait $! - - # Check if credentials are already set up to avoid setting up again. - "${CODER_DEV_SHIM}" list >/dev/null 2>&1 && touch "${PROJECT_ROOT}/.coderv2/developsh-did-first-setup" - - if ! "${CODER_DEV_SHIM}" whoami >/dev/null 2>&1; then - # Try to create the initial admin user. - echo "Login required; use admin@coder.com and password '${password}'" >&2 - - if "${CODER_DEV_SHIM}" login "${api_url}" --first-user-username=admin --first-user-email=admin@coder.com --first-user-password="${password}" --first-user-full-name="Admin User" --first-user-trial=false; then - # Only create this file if an admin user was successfully - # created, otherwise we won't retry on a later attempt. - touch "${PROJECT_ROOT}/.coderv2/developsh-did-first-setup" - else - echo 'Failed to create admin user. To troubleshoot, try running this command manually.' - fi - - # Try to create a regular user. - "${CODER_DEV_SHIM}" users create --email=member@coder.com --username=member --full-name "Regular User" --password="${password}" || - echo 'Failed to create regular user. To troubleshoot, try running this command manually.' - fi - - # Create a new organization and add the member user to it. - if [ "${multi_org}" -gt "0" ]; then - another_org="second-organization" - if ! "${CODER_DEV_SHIM}" organizations show selected --org "${another_org}" >/dev/null 2>&1; then - echo "Creating organization '${another_org}'..." - ( - "${CODER_DEV_SHIM}" organizations create -y "${another_org}" - ) || echo "Failed to create organization '${another_org}'" - fi - - if ! "${CODER_DEV_SHIM}" org members list --org ${another_org} | grep "^member" >/dev/null 2>&1; then - echo "Adding member user to organization '${another_org}'..." - ( - "${CODER_DEV_SHIM}" organizations members add member --org "${another_org}" - ) || echo "Failed to add member user to organization '${another_org}'" - fi - - echo "Starting external provisioner for '${another_org}'..." - ( - start_cmd EXT_PROVISIONER "" "${CODER_DEV_SHIM}" provisionerd start --tag "scope=organization" --name second-org-daemon --org "${another_org}" - ) || echo "Failed to start external provisioner. No external provisioner started." - fi - - # If we have docker available and the "docker" template doesn't already - # exist, then let's try to create a template! - template_name="docker" - # Determine the name of the default org with some jq hacks! - first_org_name=$("${CODER_DEV_SHIM}" organizations show me -o json | jq -r '.[] | select(.is_default) | .name') - if docker info >/dev/null 2>&1 && ! "${CODER_DEV_SHIM}" templates versions list "${template_name}" >/dev/null 2>&1; then - # sometimes terraform isn't installed yet when we go to create the - # template - echo "Waiting for terraform to be installed..." - sleep 5 - - echo "Initializing docker template..." - temp_template_dir="$(mktemp -d)" - "${CODER_DEV_SHIM}" templates init --id "${template_name}" "${temp_template_dir}" - # Run terraform init so we get a terraform.lock.hcl - pushd "${temp_template_dir}" && terraform init && popd - - DOCKER_HOST="$(docker context inspect --format '{{ .Endpoints.docker.Host }}')" - printf 'docker_arch: "%s"\ndocker_host: "%s"\n' "${GOARCH}" "${DOCKER_HOST}" >"${temp_template_dir}/params.yaml" - ( - echo "Pushing docker template to '${first_org_name}'..." - "${CODER_DEV_SHIM}" templates push "${template_name}" --directory "${temp_template_dir}" --variables-file "${temp_template_dir}/params.yaml" --yes --org "${first_org_name}" - if [ "${multi_org}" -gt "0" ]; then - echo "Pushing docker template to '${another_org}'..." - "${CODER_DEV_SHIM}" templates push "${template_name}" --directory "${temp_template_dir}" --variables-file "${temp_template_dir}/params.yaml" --yes --org "${another_org}" - fi - rm -rfv "${temp_template_dir}" # Only delete template dir if template creation succeeds - ) || echo "Failed to create a template. The template files are in ${temp_template_dir}" - fi - - if [ "${use_proxy}" -gt "0" ]; then - log "Using external workspace proxy" - ( - # Attempt to delete the proxy first, in case it already exists. - "${CODER_DEV_SHIM}" wsproxy delete local-proxy --yes || true - # Create the proxy - proxy_session_token=$("${CODER_DEV_SHIM}" wsproxy create --name=local-proxy --display-name="Local Proxy" --icon="/emojis/1f4bb.png" --only-token) - # Start the proxy - start_cmd PROXY "" "${CODER_DEV_SHIM}" wsproxy server --dangerous-allow-cors-requests=true --http-address="localhost:${proxy_port}" --proxy-session-token="${proxy_session_token}" --primary-access-url="${api_local_url}" - ) || echo "Failed to create workspace proxy. No workspace proxy created." - fi - - # Start the frontend once we have a template up and running. We pin the - # port because some environments export PORT for unrelated services. - PORT="${web_port}" CODER_HOST="${api_url}" start_cmd SITE date pnpm --dir ./site dev --host - - interfaces=(localhost) - if command -v ip >/dev/null; then - # shellcheck disable=SC2207 - interfaces+=($(ip a | awk '/inet / {print $2}' | cut -d/ -f1)) - elif command -v ifconfig >/dev/null; then - # shellcheck disable=SC2207 - interfaces+=($(ifconfig | awk '/inet / {print $2}')) - fi - - # Space padding used after the URLs to align "==". - space_padding=26 - log - log "====================================================================" - log "== ==" - log "== Coder is now running in development mode. ==" - for iface in "${interfaces[@]}"; do - log "$(printf "== API: http://%s:${api_port}%$((space_padding - ${#iface}))s==" "$iface" "")" - done - for iface in "${interfaces[@]}"; do - log "$(printf "== Web UI: http://%s:${web_port}%$((space_padding - ${#iface}))s==" "$iface" "")" - done - if [ "${use_proxy}" -gt "0" ]; then - for iface in "${interfaces[@]}"; do - log "$(printf "== Proxy: http://%s:${proxy_port}%$((space_padding - ${#iface}))s==" "$iface" "")" - done - fi - log "== ==" - log "== Use ./scripts/coder-dev.sh to talk to this instance! ==" - log "$(printf "== alias cdr=%s/scripts/coder-dev.sh%$((space_padding - ${#PWD}))s==" "$PWD" "")" - log "====================================================================" - log - - # Wait for both frontend and backend to exit. - wait_cmds -) +make -j MAKE_TIMED=1 build/.bin/develop +exec build/.bin/develop "$@" diff --git a/scripts/develop/main.go b/scripts/develop/main.go new file mode 100644 index 0000000000..0ca72f5b6f --- /dev/null +++ b/scripts/develop/main.go @@ -0,0 +1,900 @@ +// Command develop orchestrates the Coder development environment. It +// builds the binary, starts the API server and frontend dev server, +// sets up a first user, and handles graceful shutdown on signals. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/cli/config" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +const ( + defaultAPIPort = "3000" + defaultWebPort = "8080" + defaultProxyPort = "3010" + defaultPassword = "SomeSecurePassword!" + healthTimeout = 60 * time.Second + shutdownTimeout = 15 * time.Second +) + +func main() { + var cfg devConfig + + cmd := &serpent.Command{ + Use: "develop", + Short: "Orchestrate the Coder development environment.", + Options: serpent.OptionSet{ + { + Flag: "port", + Env: "CODER_DEV_PORT", + Default: defaultAPIPort, + Description: "API server port.", + Value: serpent.Int64Of(&cfg.apiPort), + }, + { + Flag: "web-port", + Env: "CODER_DEV_WEB_PORT", + Default: defaultWebPort, + Description: "Frontend dev server port.", + Value: serpent.Int64Of(&cfg.webPort), + }, + { + Flag: "proxy-port", + Env: "CODER_DEV_PROXY_PORT", + Default: defaultProxyPort, + Description: "Workspace proxy port.", + Value: serpent.Int64Of(&cfg.proxyPort), + }, + { + Flag: "agpl", + Env: "CODER_BUILD_AGPL", + Description: "Build AGPL-licensed code only.", + Value: serpent.BoolOf(&cfg.agpl), + }, + { + Flag: "access-url", + Env: "CODER_DEV_ACCESS_URL", + Description: "Override access URL.", + Value: serpent.StringOf(&cfg.accessURL), + }, + { + Flag: "password", + Env: "CODER_DEV_ADMIN_PASSWORD", + Default: defaultPassword, + Description: "Admin user password.", + Value: serpent.StringOf(&cfg.password), + }, + { + Flag: "use-proxy", + Description: "Start a workspace proxy.", + Value: serpent.BoolOf(&cfg.useProxy), + }, + { + Flag: "multi-organization", + Description: "Create a second organization.", + Value: serpent.BoolOf(&cfg.multiOrg), + }, + { + Flag: "debug", + Description: "Run under Delve debugger.", + Value: serpent.BoolOf(&cfg.debug), + }, + }, + Handler: func(inv *serpent.Invocation) error { + cfg.serverExtraArgs = inv.Args + + logger := slog.Make(sloghuman.Sink(inv.Stderr)) + if err := cfg.validate(); err != nil { + return err + } + if err := cfg.resolveEnv(); err != nil { + return err + } + return develop(inv.Context(), logger, &cfg) + }, + } + + err := cmd.Invoke(os.Args[1:]...).WithOS().Run() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +type devConfig struct { + apiPort int64 + webPort int64 + proxyPort int64 + agpl bool + accessURL string + password string + useProxy bool + multiOrg bool + debug bool + projectRoot string + binaryPath string + configDir string + childEnv []string + // Extra args after flags forwarded to "coder server". + serverExtraArgs []string +} + +func (c *devConfig) validate() error { + if c.agpl && c.useProxy { + return xerrors.New("cannot use both --agpl and --use-proxy") + } + if c.agpl && c.multiOrg { + return xerrors.New("cannot use both --agpl and --multi-organization") + } + for _, p := range []struct { + name string + val int64 + }{ + {"--port", c.apiPort}, + {"--web-port", c.webPort}, + {"--proxy-port", c.proxyPort}, + } { + if p.val < 1 || p.val > 65535 { + return xerrors.Errorf("%s must be between 1 and 65535", p.name) + } + } + if c.apiPort == c.webPort { + return xerrors.Errorf("--port %d conflicts with frontend dev server", c.webPort) + } + if c.useProxy && c.apiPort == c.proxyPort { + return xerrors.Errorf("--port %d conflicts with workspace proxy", c.proxyPort) + } + if c.useProxy && c.webPort == c.proxyPort { + return xerrors.Errorf("--web-port %d conflicts with --proxy-port", c.webPort) + } + return nil +} + +// resolveEnv sets defaults, unsets leaked credentials, resolves +// filesystem paths, and computes the child process environment. +func (c *devConfig) resolveEnv() error { + if c.accessURL == "" { + c.accessURL = fmt.Sprintf("http://127.0.0.1:%d", c.apiPort) + } + + // Prevent inherited credentials from leaking into child + // processes or being picked up by config reads. + _ = os.Unsetenv("CODER_SESSION_TOKEN") + _ = os.Unsetenv("CODER_URL") + + var err error + c.projectRoot, err = os.Getwd() + if err != nil { + return xerrors.Errorf("getting working directory: %w", err) + } + c.binaryPath = filepath.Join(c.projectRoot, "build", + fmt.Sprintf("coder_%s_%s", runtime.GOOS, runtime.GOARCH)) + c.configDir = filepath.Join(c.projectRoot, ".coderv2") + + // Compute once, reused by cmd(). + c.childEnv = filterEnv(os.Environ(), "CODER_SESSION_TOKEN", "CODER_URL") + + return nil +} + +// cmd builds an exec.Cmd rooted in the project directory with a +// clean child environment. The context controls process lifetime. +func (c *devConfig) cmd(ctx context.Context, bin string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = c.projectRoot + cmd.Env = slices.Clone(c.childEnv) + return cmd +} + +// filterEnv returns env with any variables whose key matches +// exclude removed. +func filterEnv(env []string, exclude ...string) []string { + out := make([]string, 0, len(env)) + for _, e := range env { + k, _, _ := strings.Cut(e, "=") + if !slices.Contains(exclude, k) { + out = append(out, e) + } + } + return out +} + +// procGroup tracks child processes using an errgroup. When any +// child exits, the errgroup cancels its derived context, aborting +// all downstream operations. Graceful shutdown is handled by +// cmd.Cancel/WaitDelay on each command. +type procGroup struct { + eg *errgroup.Group + ctx context.Context + logger slog.Logger +} + +func newProcGroup(ctx context.Context, logger slog.Logger) *procGroup { + eg, ctx := errgroup.WithContext(ctx) + return &procGroup{eg: eg, ctx: ctx, logger: logger} +} + +// Start registers a long-running command with the group. It sets up +// graceful shutdown (SIGINT on context cancel, SIGKILL after +// timeout), wires stdout/stderr to structured logging, starts the +// process, and registers a goroutine that waits for it to exit. +func (g *procGroup) Start(name string, cmd *exec.Cmd) error { + // Guard against nil env: appending to nil creates a non-nil + // slice that exec.Cmd treats as an explicit (empty) env. + if cmd.Env == nil { + cmd.Env = os.Environ() + } + cmd.Env = append(cmd.Env, "FORCE_COLOR=1") + + // Run in a new process group so signals reach the entire + // child tree (e.g. pnpm → vite), not just the direct child. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + // Graceful shutdown: SIGINT the process group on context + // cancel, escalate to SIGKILL after WaitDelay. + cmd.Cancel = func() error { + return syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) + } + cmd.WaitDelay = shutdownTimeout + + named := g.logger.Named(name) + w := &logWriter{logger: named} + cmd.Stdout = w + cmd.Stderr = w + + named.Info(g.ctx, "starting", slog.F("cmd", strings.Join(cmd.Args, " "))) + if err := cmd.Start(); err != nil { + return xerrors.Errorf("starting %s: %w", name, err) + } + + g.eg.Go(func() error { + err := cmd.Wait() + if err != nil { + return xerrors.Errorf("process %q exited: %w", name, err) + } + // Clean exit is still unexpected for a long-running dev + // process. Report it so the orchestrator shuts down. + return xerrors.Errorf("process %q exited unexpectedly", name) + }) + return nil +} + +// Wait blocks until all started processes have exited. +func (g *procGroup) Wait() error { return g.eg.Wait() } + +// Ctx returns the errgroup's derived context. It cancels when the +// parent context fires (signal) or any child process exits. +func (g *procGroup) Ctx() context.Context { return g.ctx } + +// poll calls cond every interval until it returns a value and true, +// or the context is canceled. If cond returns a non-nil error, +// polling stops immediately. +func poll[T any](ctx context.Context, interval time.Duration, cond func(ctx context.Context) (T, bool, error)) (T, error) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + var zero T + return zero, ctx.Err() + case <-ticker.C: + v, done, err := cond(ctx) + if err != nil { + return v, err + } + if done { + return v, nil + } + } + } +} + +func develop(ctx context.Context, logger slog.Logger, cfg *devConfig) error { + sigCtx, stop := signal.NotifyContext(ctx, cli.StopSignals...) + defer stop() + + if err := preflight(sigCtx, logger, cfg); err != nil { + return err + } + if err := buildBinary(sigCtx, logger, cfg); err != nil { + return xerrors.Errorf("build: %w", err) + } + + // Wrap in a cancelable context so deferred cleanup can + // trigger graceful shutdown on early return. + cancelCtx, cancelAll := context.WithCancel(sigCtx) + + group := newProcGroup(cancelCtx, logger) + defer func() { + cancelAll() + _ = group.Wait() + }() + + ctx = group.Ctx() + + if err := startServer(cfg, group); err != nil { + return err + } + + // The vite dev server proxies to the API and handles the + // case where the API isn't ready yet, so start it in parallel. + if err := group.Start("site", pnpmCmd(ctx, cfg)); err != nil { + return xerrors.Errorf("starting frontend: %w", err) + } + + apiURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.apiPort) + if err := waitForHealthy(ctx, logger, apiURL); err != nil { + return err + } + + client, err := setupFirstUser(ctx, logger, cfg, apiURL) + if err != nil { + return xerrors.Errorf("setup: %w", err) + } + + if cfg.multiOrg { + if err := setupMultiOrg(ctx, logger, cfg, client, group); err != nil { + logger.Warn(ctx, "multi-org setup failed, continuing", + slog.Error(err)) + } + } + + if err := setupDockerTemplate(ctx, logger, cfg, client); err != nil { + logger.Warn(ctx, "docker template setup failed, continuing", slog.Error(err)) + } + + if cfg.useProxy { + if err := setupWorkspaceProxy(ctx, cfg, client, group); err != nil { + logger.Warn(ctx, "proxy setup failed, continuing", + slog.Error(err)) + } + } + + printBanner(ctx, logger, cfg) + + // Block until a signal fires or a child process exits. + <-ctx.Done() + + waitErr := group.Wait() + + // If a signal triggered shutdown, process exit errors are + // expected (SIGINT deaths). Report clean shutdown. + if sigCtx.Err() != nil { + logger.Info(ctx, "signal received, shutting down") + return nil + } + return waitErr +} + +func preflight(ctx context.Context, logger slog.Logger, cfg *devConfig) error { + // Source lib.sh to run its dependency checks (bash 4+, GNU + // getopt, make 4+) and then check command dependencies, + // matching the original develop.sh. Prints helpful install + // instructions on failure and exits non-zero. + libSh := filepath.Join(cfg.projectRoot, "scripts", "lib.sh") + libCheck := exec.CommandContext(ctx, "bash", "-c", //nolint:gosec // libSh is a project-relative path, not user input + "source "+libSh+" && dependencies curl git go jq make pnpm") + libCheck.Stdout = os.Stderr + libCheck.Stderr = os.Stderr + if err := libCheck.Run(); err != nil { + return xerrors.New("dependency check failed, see above") + } + apiAddr := fmt.Sprintf("http://127.0.0.1:%d", cfg.apiPort) + if isCoderRunning(ctx, apiAddr) { + logger.Info(ctx, "coder is already running on this port", + slog.F("port", cfg.apiPort)) + return nil + } + if isPortBusy(ctx, cfg.apiPort) { + return xerrors.Errorf("port %d is already in use", cfg.apiPort) + } + if isPortBusy(ctx, cfg.webPort) { + return xerrors.Errorf("port %d is already in use (frontend)", cfg.webPort) + } + if cfg.useProxy && isPortBusy(ctx, cfg.proxyPort) { + return xerrors.Errorf("port %d is already in use (proxy)", cfg.proxyPort) + } + return nil +} + +// buildBinary uses os.Environ() directly (not cfg.cmd()) because +// the build needs the full unfiltered parent environment. +func buildBinary(ctx context.Context, logger slog.Logger, cfg *devConfig) error { + target := fmt.Sprintf("build/coder_%s_%s", runtime.GOOS, runtime.GOARCH) + cmd := exec.CommandContext(ctx, "make", "-j", target) + cmd.Dir = cfg.projectRoot + w := &logWriter{logger: logger.Named("build")} + cmd.Stdout = w + cmd.Stderr = w + cmd.Env = append(os.Environ(), + "DEVELOP_IN_CODER="+shellBool(developInCoder()), + "MAKE_TIMED=1", + ) + if cfg.agpl { + cmd.Env = append(cmd.Env, "CODER_BUILD_AGPL=1") + } + return cmd.Run() +} + +func startServer(cfg *devConfig, group *procGroup) error { + serverArgs := []string{ + "--global-config", cfg.configDir, + "server", + "--http-address", fmt.Sprintf("0.0.0.0:%d", cfg.apiPort), + "--swagger-enable", + "--access-url", cfg.accessURL, + "--dangerous-allow-cors-requests=true", + "--enable-terraform-debug-mode", + } + serverArgs = append(serverArgs, cfg.serverExtraArgs...) + + if cfg.debug { + return startServerDebug(cfg, serverArgs, group) + } + cmd := cfg.cmd(group.Ctx(), cfg.binaryPath, serverArgs...) + return group.Start("api", cmd) +} + +func startServerDebug(cfg *devConfig, serverArgs []string, group *procGroup) error { + ctx := group.Ctx() + logger := group.logger + + debugBin := filepath.Join(cfg.projectRoot, "build", + fmt.Sprintf("coder_debug_%s_%s", runtime.GOOS, runtime.GOARCH)) + dlvBinDir := filepath.Join(cfg.projectRoot, "build", ".bin") + dlvBin := filepath.Join(dlvBinDir, "dlv") + + // Build debug binary and install dlv in parallel. + eg, egCtx := errgroup.WithContext(ctx) + eg.Go(func() error { + buildArgs := []string{ + "--os", runtime.GOOS, "--arch", runtime.GOARCH, + "--output", debugBin, "--debug", + } + if cfg.agpl { + buildArgs = append(buildArgs, "--agpl") + } + cmd := cfg.cmd(egCtx, + filepath.Join(cfg.projectRoot, "scripts", "build_go.sh"), + buildArgs...) + w := &logWriter{logger: logger.Named("build-debug")} + cmd.Stdout = w + cmd.Stderr = w + return cmd.Run() + }) + eg.Go(func() error { + goVer := strings.TrimPrefix(runtime.Version(), "go") + cmd := cfg.cmd(egCtx, "go", "install", + "github.com/go-delve/delve/cmd/dlv@latest") + cmd.Env = append(cmd.Env, + "GOBIN="+dlvBinDir, "GOTOOLCHAIN=go"+goVer) + w := &logWriter{logger: logger.Named("dlv-install")} + cmd.Stdout = w + cmd.Stderr = w + return cmd.Run() + }) + if err := eg.Wait(); err != nil { + return xerrors.Errorf("debug build: %w", err) + } + + srvCmd := cfg.cmd(ctx, debugBin, serverArgs...) + if err := group.Start("api", srvCmd); err != nil { + return err + } + + dlvCmd := cfg.cmd(ctx, dlvBin, "attach", + strconv.Itoa(srvCmd.Process.Pid), + "--headless", "--continue", + "--listen", "127.0.0.1:12345", + "--accept-multiclient") + if err := group.Start("dlv", dlvCmd); err != nil { + return xerrors.Errorf("attaching dlv: %w", err) + } + logger.Info(ctx, "delve debugger listening", slog.F("addr", "127.0.0.1:12345")) + return nil +} + +func waitForHealthy(ctx context.Context, logger slog.Logger, apiURL string) error { + logger.Info(ctx, "waiting for server to become ready") + ctx, cancel := context.WithTimeout(ctx, healthTimeout) + defer cancel() + + _, err := poll(ctx, 500*time.Millisecond, + func(ctx context.Context) (struct{}, bool, error) { + req, err := http.NewRequestWithContext( + ctx, "GET", apiURL+"/healthz", nil) + if err != nil { + return struct{}{}, false, nil + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return struct{}{}, false, nil + } + _ = resp.Body.Close() + return struct{}{}, resp.StatusCode == http.StatusOK, nil + }) + if err != nil { + return xerrors.Errorf("server did not become ready in %s: %w", healthTimeout, err) + } + logger.Info(ctx, "server is ready to accept connections") + return nil +} + +func setupFirstUser(ctx context.Context, logger slog.Logger, cfg *devConfig, apiURL string) (*codersdk.Client, error) { + serverURL, _ := url.Parse(apiURL) + client := codersdk.New(serverURL) + cfgRoot := config.Root(cfg.configDir) + + // Try reusing an existing session. + loggedIn := false + if token, err := cfgRoot.Session().Read(); err == nil && token != "" { + client.SetSessionToken(token) + if _, err := client.User(ctx, codersdk.Me); err == nil { + loggedIn = true + } else { + client.SetSessionToken("") + } + } + + if !loggedIn { + hasUser, err := client.HasFirstUser(ctx) + if err != nil { + return nil, xerrors.Errorf("checking first user: %w", err) + } + if !hasUser { + logger.Info(ctx, "creating first user", + slog.F("email", "admin@coder.com"), + slog.F("password", cfg.password)) + _, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ + Email: "admin@coder.com", + Username: "admin", + Name: "Admin User", + Password: cfg.password, + }) + if err != nil { + return nil, xerrors.Errorf("creating first user: %w", err) + } + } + + loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: "admin@coder.com", + Password: cfg.password, + }) + if err != nil { + return nil, xerrors.Errorf("login: %w", err) + } + client.SetSessionToken(loginResp.SessionToken) + + if err := cfgRoot.Session().Write(loginResp.SessionToken); err != nil { + return nil, xerrors.Errorf("writing session: %w", err) + } + if err := cfgRoot.URL().Write(apiURL); err != nil { + return nil, xerrors.Errorf("writing url: %w", err) + } + } + logger.Info(ctx, "authenticated as admin user", slog.F("email", "admin@coder.com")) + + // Look up the default org for member creation. + defaultOrg, err := client.OrganizationByName(ctx, codersdk.DefaultOrganization) + if err != nil { + return nil, xerrors.Errorf("looking up default org: %w", err) + } + + // Member user is best-effort. + if _, err := client.User(ctx, "member"); err != nil { + _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "member@coder.com", + Username: "member", + Name: "Regular User", + Password: cfg.password, + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{defaultOrg.ID}, + }) + if err != nil { + logger.Warn(ctx, "failed to create member user", slog.Error(err)) + } else { + logger.Info(ctx, "created member user", slog.F("email", "member@coder.com")) + } + } + + return client, nil +} + +func setupMultiOrg(ctx context.Context, logger slog.Logger, cfg *devConfig, client *codersdk.Client, group *procGroup) error { + const orgName = "second-organization" + + org, err := client.OrganizationByName(ctx, orgName) + if err != nil { + logger.Info(ctx, "creating organization", + slog.F("name", orgName)) + org, err = client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{Name: orgName}) + if err != nil { + return xerrors.Errorf("creating org: %w", err) + } + } + + members, err := client.OrganizationMembers(ctx, org.ID) + if err == nil { + found := false + for _, m := range members { + if m.Username == "member" { + found = true + break + } + } + if !found { + if _, err := client.PostOrganizationMember(ctx, org.ID, "member"); err != nil { + logger.Warn(ctx, "failed to add member to org", slog.Error(err)) + } + } + } + + cmd := cfg.cmd(ctx, cfg.binaryPath, + "--global-config", cfg.configDir, + "provisionerd", "start", + "--tag", "scope=organization", + "--name", "second-org-daemon", + "--org", orgName) + return group.Start("ext-provisioner", cmd) +} + +func setupWorkspaceProxy(ctx context.Context, cfg *devConfig, client *codersdk.Client, group *procGroup) error { + _ = client.DeleteWorkspaceProxyByName(ctx, "local-proxy") + + resp, err := client.CreateWorkspaceProxy(ctx, + codersdk.CreateWorkspaceProxyRequest{ + Name: "local-proxy", + DisplayName: "Local Proxy", + Icon: "/emojis/1f4bb.png", + }) + if err != nil { + return xerrors.Errorf("creating proxy: %w", err) + } + + cmd := cfg.cmd(ctx, cfg.binaryPath, + "--global-config", cfg.configDir, + "wsproxy", "server", + "--dangerous-allow-cors-requests=true", + "--http-address", fmt.Sprintf("localhost:%d", cfg.proxyPort), + "--proxy-session-token", resp.ProxyToken, + "--primary-access-url", fmt.Sprintf("http://localhost:%d", cfg.apiPort)) + return group.Start("proxy", cmd) +} + +// setupDockerTemplate creates a Docker template from the embedded +// starter example when Docker is available and the template does +// not already exist. Uses the SDK's ExampleID field so the server +// resolves the template archive internally, no CLI shelling needed. +func setupDockerTemplate(ctx context.Context, logger slog.Logger, cfg *devConfig, client *codersdk.Client) error { + // Check if Docker is available. + if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil { + logger.Debug(ctx, "docker not available, skipping template setup") + return nil + } + + // Resolve Docker host for template variables. + dockerHost := "" + if out, err := exec.CommandContext(ctx, "docker", "context", "inspect", + "--format", "{{ .Endpoints.docker.Host }}").Output(); err == nil { + dockerHost = strings.TrimSpace(string(out)) + } + + if err := createTemplateInOrg(ctx, logger, client, codersdk.DefaultOrganization, dockerHost); err != nil { + return err + } + + if cfg.multiOrg { + if err := createTemplateInOrg(ctx, logger, client, "second-organization", dockerHost); err != nil { + logger.Warn(ctx, "failed to create docker template in second org", + slog.Error(err)) + } + } + + return nil +} + +// waitForVersion polls until a template version's provisioner job +// reaches a terminal state. +func waitForVersion(ctx context.Context, client *codersdk.Client, id uuid.UUID) (codersdk.TemplateVersion, error) { + return poll(ctx, 500*time.Millisecond, + func(ctx context.Context) (codersdk.TemplateVersion, bool, error) { + v, err := client.TemplateVersion(ctx, id) + if err != nil { + return v, false, err + } + switch v.Job.Status { + case codersdk.ProvisionerJobSucceeded: + return v, true, nil + case codersdk.ProvisionerJobFailed: + return v, false, xerrors.Errorf("job failed: %s", v.Job.Error) + case codersdk.ProvisionerJobCanceled: + return v, false, xerrors.New("job was canceled") + default: + return v, false, nil // Still pending/running. + } + }) +} + +// createTemplateInOrg ensures the "docker" template exists in the +// given org, creating it from the embedded example if needed. +func createTemplateInOrg(ctx context.Context, logger slog.Logger, client *codersdk.Client, orgName string, dockerHost string) error { + org, err := client.OrganizationByName(ctx, orgName) + if err != nil { + return xerrors.Errorf("looking up org %q: %w", orgName, err) + } + if _, err := client.TemplateByName(ctx, org.ID, "docker"); err == nil { + logger.Debug(ctx, "docker template already exists", + slog.F("org", orgName)) + return nil + } + version, err := client.CreateTemplateVersion(ctx, org.ID, + codersdk.CreateTemplateVersionRequest{ + StorageMethod: codersdk.ProvisionerStorageMethodFile, + ExampleID: "docker", + Provisioner: codersdk.ProvisionerTypeTerraform, + UserVariableValues: []codersdk.VariableValue{ + {Name: "docker_arch", Value: runtime.GOARCH}, + {Name: "docker_host", Value: dockerHost}, + }, + }) + if err != nil { + return xerrors.Errorf("creating version: %w", err) + } + version, err = waitForVersion(ctx, client, version.ID) + if err != nil { + return err + } + _, err = client.CreateTemplate(ctx, org.ID, + codersdk.CreateTemplateRequest{ + Name: "docker", + VersionID: version.ID, + }) + if err != nil { + return xerrors.Errorf("creating template: %w", err) + } + logger.Info(ctx, "docker template created in org", + slog.F("org", orgName)) + return nil +} + +func pnpmCmd(ctx context.Context, cfg *devConfig) *exec.Cmd { + cmd := cfg.cmd(ctx, "pnpm", "--dir", "./site", "dev", "--host") + cmd.Env = append(cmd.Env, + fmt.Sprintf("PORT=%d", cfg.webPort), + fmt.Sprintf("CODER_HOST=http://127.0.0.1:%d", cfg.apiPort), + ) + return cmd +} + +func printBanner(ctx context.Context, logger slog.Logger, cfg *devConfig) { + ifaces := []string{"localhost"} + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + ifaces = append(ifaces, ipnet.IP.String()) + } + } + } + var b strings.Builder + w := 64 + line := func(content string) { + _, _ = fmt.Fprintf(&b, "║ %-*s ║\n", w, content) + } + divider := "╔" + strings.Repeat("═", w+2) + "╗" + bottom := "╚" + strings.Repeat("═", w+2) + "╝" + + _, _ = fmt.Fprintln(&b) + _, _ = fmt.Fprintln(&b, divider) + line("") + line(" Coder is now running in development mode.") + line("") + for _, h := range ifaces { + line(fmt.Sprintf("API: http://%s:%d", h, cfg.apiPort)) + line(fmt.Sprintf("Web UI: http://%s:%d", h, cfg.webPort)) + if cfg.useProxy { + line(fmt.Sprintf("Proxy: http://%s:%d", h, cfg.proxyPort)) + } + } + line("") + line("Use ./scripts/coder-dev.sh to talk to this instance!") + line(fmt.Sprintf(" alias cdr=%s/scripts/coder-dev.sh", cfg.projectRoot)) + line("") + _, _ = fmt.Fprintln(&b, bottom) + logger.Info(ctx, b.String()) +} + +// logWriter adapts an slog.Logger into an io.Writer. Each complete +// line of text written is logged at Info level. Partial lines are +// buffered until a newline arrives. Safe for concurrent use. +type logWriter struct { + logger slog.Logger + mu sync.Mutex + buf []byte +} + +func (w *logWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + w.buf = append(w.buf, p...) + for { + idx := bytes.IndexByte(w.buf, '\n') + if idx < 0 { + break + } + line := string(w.buf[:idx]) + w.buf = w.buf[idx+1:] + if line != "" { + w.logger.Info(context.Background(), line) + } + } + return len(p), nil +} + +func isPortBusy(ctx context.Context, port int64) bool { + d := net.Dialer{Timeout: 2 * time.Second} + conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +func isCoderRunning(ctx context.Context, baseURL string) bool { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/api/v2/buildinfo", nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + var info struct{ Version string } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return false + } + return info.Version != "" +} + +// shellBool returns "1" for true and "0" for false (shell convention). +func shellBool(b bool) string { //nolint:revive // trivial bool-to-string helper + if b { + return "1" + } + return "0" +} + +func developInCoder() bool { + return os.Getenv("DEVELOP_IN_CODER") == "1" || os.Getenv("CODER_AGENT_URL") != "" +} diff --git a/scripts/develop/main_test.go b/scripts/develop/main_test.go new file mode 100644 index 0000000000..e165c14b39 --- /dev/null +++ b/scripts/develop/main_test.go @@ -0,0 +1,447 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" +) + +func TestLogWriter(t *testing.T) { + t.Parallel() + + t.Run("SingleLine", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.Make(sloghuman.Sink(&buf)).Named("test") + w := &logWriter{logger: logger} + _, err := w.Write([]byte("hello\n")) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "test:") + assert.Contains(t, out, "hello") + }) + + t.Run("MultiLine", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.Make(sloghuman.Sink(&buf)).Named("x") + w := &logWriter{logger: logger} + _, err := w.Write([]byte("a\nb\nc\n")) + require.NoError(t, err) + out := buf.String() + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 3) + for _, line := range lines { + assert.Contains(t, line, "x:") + } + }) + + t.Run("PartialLine", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.Make(sloghuman.Sink(&buf)).Named("p") + w := &logWriter{logger: logger} + _, err := w.Write([]byte("no newline")) + require.NoError(t, err) + // Partial line should be buffered, not logged yet. + assert.Empty(t, buf.String()) + }) + + t.Run("PartialThenNewline", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.Make(sloghuman.Sink(&buf)).Named("p") + w := &logWriter{logger: logger} + + _, err := w.Write([]byte("hello")) + require.NoError(t, err) + assert.Empty(t, buf.String()) + + _, err = w.Write([]byte(" world\n")) + require.NoError(t, err) + assert.Contains(t, buf.String(), "hello world") + }) + + t.Run("EmptyLinesSkipped", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.Make(sloghuman.Sink(&buf)).Named("e") + w := &logWriter{logger: logger} + _, err := w.Write([]byte("\n\nfoo\n\n")) + require.NoError(t, err) + out := buf.String() + // Only "foo" should produce a log line. + lines := strings.Split(strings.TrimSpace(out), "\n") + assert.Len(t, lines, 1) + assert.Contains(t, lines[0], "foo") + }) + + t.Run("ConcurrentWrites", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + logger := slog.Make(sloghuman.Sink(&buf)).Named("c") + w := &logWriter{logger: logger} + + var wg sync.WaitGroup + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + for range 50 { + _, _ = w.Write([]byte("x\n")) + } + }() + } + wg.Wait() + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + assert.Len(t, lines, 500) + for _, line := range lines { + assert.Contains(t, line, "c:") + assert.Contains(t, line, "x") + } + }) +} + +func TestFilterEnv(t *testing.T) { + t.Parallel() + + env := []string{ + "CODER_SESSION_TOKEN=secret", + "CODER_URL=https://example.com", + "KEEP_ME=yes", + "PATH=/usr/bin", + } + result := filterEnv(env, "CODER_SESSION_TOKEN", "CODER_URL") + + for _, e := range result { + k, _, _ := strings.Cut(e, "=") + assert.NotEqual(t, "CODER_SESSION_TOKEN", k) + assert.NotEqual(t, "CODER_URL", k) + } + assert.Contains(t, result, "KEEP_ME=yes") + assert.Contains(t, result, "PATH=/usr/bin") +} + +func TestShellBool(t *testing.T) { + t.Parallel() + assert.Equal(t, "1", shellBool(true)) + assert.Equal(t, "0", shellBool(false)) +} + +func TestDevelopInCoder(t *testing.T) { + t.Run("DEVELOP_IN_CODER", func(t *testing.T) { + t.Setenv("DEVELOP_IN_CODER", "1") + t.Setenv("CODER_AGENT_URL", "") + assert.True(t, developInCoder()) + }) + + t.Run("CODER_AGENT_URL", func(t *testing.T) { + t.Setenv("DEVELOP_IN_CODER", "") + t.Setenv("CODER_AGENT_URL", "http://something") + assert.True(t, developInCoder()) + }) + + t.Run("Neither", func(t *testing.T) { + t.Setenv("DEVELOP_IN_CODER", "") + t.Setenv("CODER_AGENT_URL", "") + assert.False(t, developInCoder()) + }) +} + +func TestDevConfigValidate(t *testing.T) { + t.Parallel() + + base := func() *devConfig { + return &devConfig{ + apiPort: 3000, + webPort: 8080, + proxyPort: 3010, + password: defaultPassword, + } + } + + t.Run("Valid", func(t *testing.T) { + t.Parallel() + assert.NoError(t, base().validate()) + }) + + t.Run("AgplAndProxy", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.agpl = true + cfg.useProxy = true + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--agpl and --use-proxy") + }) + + t.Run("AgplAndMultiOrg", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.agpl = true + cfg.multiOrg = true + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--agpl and --multi-organization") + }) + + t.Run("PortTooLow", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.apiPort = 0 + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--port must be between 1 and 65535") + }) + + t.Run("PortTooHigh", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.apiPort = 70000 + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--port must be between 1 and 65535") + }) + + t.Run("PortConflictWithWeb", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.apiPort = 8080 + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with frontend dev server") + }) + + t.Run("PortConflictWithProxy", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.apiPort = 3010 + cfg.useProxy = true + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with workspace proxy") + }) + + t.Run("ProxyPortOKWithoutFlag", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.apiPort = 3010 + assert.NoError(t, cfg.validate()) + }) + + t.Run("WebPortTooLow", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.webPort = 0 + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--web-port must be between 1 and 65535") + }) + + t.Run("ProxyPortTooHigh", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.proxyPort = 70000 + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--proxy-port must be between 1 and 65535") + }) + + t.Run("WebProxyPortConflict", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.webPort = 9000 + cfg.proxyPort = 9000 + cfg.useProxy = true + err := cfg.validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "--web-port 9000 conflicts with --proxy-port") + }) + + t.Run("WebProxyPortConflictOKWithoutProxy", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.webPort = 9000 + cfg.proxyPort = 9000 + assert.NoError(t, cfg.validate()) + }) +} + +func TestDevConfigResolveEnv(t *testing.T) { + t.Setenv("CODER_SESSION_TOKEN", "leaked") + t.Setenv("CODER_URL", "https://leaked.example.com") + + cfg := &devConfig{apiPort: 3000} + require.NoError(t, cfg.resolveEnv()) + + wd, _ := os.Getwd() + assert.Equal(t, wd, cfg.projectRoot) + assert.Equal(t, filepath.Join(wd, "build", + fmt.Sprintf("coder_%s_%s", runtime.GOOS, runtime.GOARCH)), cfg.binaryPath) + assert.Equal(t, filepath.Join(wd, ".coderv2"), cfg.configDir) + assert.Equal(t, "http://127.0.0.1:3000", cfg.accessURL) + + // Should have unset leaked env vars. + assert.Empty(t, os.Getenv("CODER_SESSION_TOKEN")) + assert.Empty(t, os.Getenv("CODER_URL")) + + // childEnv should be populated and exclude leaked vars. + require.NotEmpty(t, cfg.childEnv) + for _, e := range cfg.childEnv { + k, _, _ := strings.Cut(e, "=") + assert.NotEqual(t, "CODER_SESSION_TOKEN", k) + assert.NotEqual(t, "CODER_URL", k) + } +} + +func TestDevConfigResolveEnvExplicitAccessURL(t *testing.T) { + t.Setenv("CODER_SESSION_TOKEN", "") + t.Setenv("CODER_URL", "") + + cfg := &devConfig{apiPort: 5000, accessURL: "http://myhost:5000"} + require.NoError(t, cfg.resolveEnv()) + assert.Equal(t, "http://myhost:5000", cfg.accessURL) +} + +func TestDevConfigCmd(t *testing.T) { + t.Parallel() + + cfg := &devConfig{ + projectRoot: "/fake/root", + childEnv: []string{"A=1", "B=2"}, + } + + cmd := cfg.cmd(context.Background(), "echo", "hello") + assert.Equal(t, "/fake/root", cmd.Dir) + assert.Equal(t, []string{"A=1", "B=2"}, cmd.Env) + + // Verify childEnv is cloned, not shared. + cmd.Env = append(cmd.Env, "C=3") + assert.Len(t, cfg.childEnv, 2, "original childEnv must not be mutated") +} + +func TestProcGroupProcessExit(t *testing.T) { + t.Parallel() + + logger := slog.Make(sloghuman.Sink(&bytes.Buffer{})) + group := newProcGroup(t.Context(), logger) + + cmd := exec.CommandContext(t.Context(), "false") + cmd.Env = os.Environ() + require.NoError(t, group.Start("dies-fast", cmd)) + + // Process exit should cancel the group context. + select { + case <-group.Ctx().Done(): + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for context cancellation") + } + + // Wait should return an error naming the exited process. + err := group.Wait() + require.Error(t, err) + assert.Contains(t, err.Error(), "dies-fast") +} + +func TestProcGroupGracefulShutdown(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + logger := slog.Make(sloghuman.Sink(&bytes.Buffer{})) + group := newProcGroup(ctx, logger) + + // Start a process that runs until signaled. + cmd := exec.CommandContext(ctx, "sleep", "60") + cmd.Env = os.Environ() + err := group.Start("sleeper", cmd) + require.NoError(t, err) + + // Cancel the parent context. cmd.Cancel sends SIGINT, and + // cmd.WaitDelay escalates to SIGKILL if needed. + cancel() + + done := make(chan error, 1) + go func() { done <- group.Wait() }() + + select { + case err := <-done: + // The process was killed, so we expect an error. + require.Error(t, err) + case <-time.After(shutdownTimeout + 5*time.Second): + t.Fatal("timed out waiting for graceful shutdown") + } +} + +func TestPoll(t *testing.T) { + t.Parallel() + + t.Run("ImmediateSuccess", func(t *testing.T) { + t.Parallel() + val, err := poll(t.Context(), 10*time.Millisecond, + func(_ context.Context) (string, bool, error) { + return "done", true, nil + }) + require.NoError(t, err) + assert.Equal(t, "done", val) + }) + + t.Run("EventualSuccess", func(t *testing.T) { + t.Parallel() + calls := 0 + val, err := poll(t.Context(), 10*time.Millisecond, + func(_ context.Context) (int, bool, error) { + calls++ + if calls >= 3 { + return calls, true, nil + } + return 0, false, nil + }) + require.NoError(t, err) + assert.Equal(t, 3, val) + }) + + t.Run("ContextCanceled", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := poll(ctx, 10*time.Millisecond, + func(_ context.Context) (struct{}, bool, error) { + t.Fatal("cond should not be called") + return struct{}{}, false, nil + }) + require.ErrorIs(t, err, context.Canceled) + }) + + t.Run("ErrorStopsPolling", func(t *testing.T) { + t.Parallel() + calls := 0 + _, err := poll(t.Context(), 10*time.Millisecond, + func(_ context.Context) (string, bool, error) { + calls++ + if calls == 2 { + return "", false, xerrors.New("boom") + } + return "", false, nil + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.Equal(t, 2, calls) + }) +}