mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
3a3537a642
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
901 lines
26 KiB
Go
901 lines
26 KiB
Go
// 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") != ""
|
|
}
|