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
This commit is contained in:
Mathias Fredriksson
2026-03-16 16:13:57 +02:00
committed by GitHub
parent c4db03f11a
commit 3a3537a642
4 changed files with 1356 additions and 362 deletions
+3
View File
@@ -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)
+6 -362
View File
@@ -1,368 +1,12 @@
#!/usr/bin/env bash
# Usage: ./develop.sh [--agpl] [--port <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 "$@"
+900
View File
@@ -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") != ""
}
+447
View File
@@ -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)
})
}