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:
committed by
GitHub
parent
c4db03f11a
commit
3a3537a642
@@ -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
@@ -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 "$@"
|
||||
|
||||
@@ -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") != ""
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user