Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 30 additions & 16 deletions cli/src/cmd/codingbooth/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,16 +413,21 @@ func showHelpShell() {
USAGE: %s shell [options] [name]

OPTIONS:
--name <n> Container name
--shell <shell> Shell to launch (default: bash)
--dir <path> Starting directory (default: /home/coder/code)
--run Run the booth first if it is not already running
--keep-alive With --run, leave the booth running afterwards
-e <VAR=value> Set environment variable (repeatable)
--envfile <path> Load environment variables from a file
--name <n> Container name
--shell <shell> Shell to launch (default: bash)
--dir <path> Starting directory (default: /home/coder/code)
--run Run the booth first if it is not already running
--keep-alive With --run, leave the booth running afterwards
--port <n|NEXT|RANDOM>
With --run, host port when creating a missing booth
--accept-existing Connect even if create flags (e.g. --port) do not match
-e <VAR=value> Set environment variable (repeatable)
--envfile <path> Load environment variables from a file

A booth brought up by --run is stopped again when you disconnect, unless
--keep-alive is given. A booth that was already running is never stopped.
Create-intent flags like --port apply only when a booth is created; against an
existing booth a mismatch fails unless --accept-existing is set.

EXAMPLES:
%s shell myproject
Expand All @@ -431,7 +436,9 @@ EXAMPLES:
%s shell myproject -e DEBUG=1
%s shell myproject --run
%s shell myproject --run --keep-alive
`, s, s, s, s, s, s, s, s)
%s shell myproject --run --port 9000
%s shell myproject --port 9000 --accept-existing
`, s, s, s, s, s, s, s, s, s, s)
}

func showHelpExec() {
Expand All @@ -441,25 +448,32 @@ func showHelpExec() {
USAGE: %s exec [options] [name] -- <command>

OPTIONS:
--name <n> Container name
--dir <path> Working directory (default: /home/coder/code)
-it Force interactive mode with TTY
--run Run the booth first if it is not already running
--keep-alive With --run, leave the booth running afterwards
-e <VAR=value> Set environment variable (repeatable)
--envfile <path> Load environment variables from a file
--name <n> Container name
--dir <path> Working directory (default: /home/coder/code)
-it Force interactive mode with TTY
--run Run the booth first if it is not already running
--keep-alive With --run, leave the booth running afterwards
--port <n|NEXT|RANDOM>
With --run, host port when creating a missing booth
--accept-existing Connect even if create flags (e.g. --port) do not match
-e <VAR=value> Set environment variable (repeatable)
--envfile <path> Load environment variables from a file

The exit code of the executed command is forwarded to the caller. A booth
brought up by --run is stopped again when the command finishes, unless
--keep-alive is given. A booth that was already running is never stopped.
Create-intent flags like --port apply only when a booth is created; against an
existing booth a mismatch fails unless --accept-existing is set.

EXAMPLES:
%s exec myproject -- make test
%s exec myproject -e FOO=bar -- env
%s exec myproject --dir /tmp -- ls
%s exec myproject --run -- make test
%s exec myproject --run --keep-alive -- make test
`, s, s, s, s, s, s, s)
%s exec myproject --run --port 9000 -- make test
%s exec myproject --port 9000 --accept-existing -- make test
`, s, s, s, s, s, s, s, s, s)
}

func showHelpEmitDockerfile() {
Expand Down
124 changes: 110 additions & 14 deletions cli/src/pkg/lifecycle/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ func (f *stringSliceFlag) Set(value string) error {
return nil
}

// connectCreateOpts holds create-time run overrides accepted by shell/exec.
// These apply only when --run creates a missing booth; against an existing booth
// they are asserted (fail by default) unless --accept-existing is set.
type connectCreateOpts struct {
port string // --port (empty if unset)
acceptExisting bool // --accept-existing: connect despite create-intent mismatch
}

// Shell opens a new interactive shell inside a running booth container.
func Shell(args []string, stderr io.Writer) error {
positional, flags := extractPositionalAndFlags(args)
Expand All @@ -45,6 +53,8 @@ func Shell(args []string, stderr io.Writer) error {
envfile := flagSet.String("envfile", "", "Load environment variables from a file")
run := flagSet.Bool("run", false, "Run the booth first if it is not already running")
keepAlive := flagSet.Bool("keep-alive", false, "Keep a booth started by --run running afterwards")
port := flagSet.String("port", "", "With --run, host port for a newly created booth (number, NEXT, or RANDOM)")
acceptExisting := flagSet.Bool("accept-existing", false, "Connect to an existing booth even if create flags (e.g. --port) do not match")
var envVars stringSliceFlag
flagSet.Var(&envVars, "e", "Set environment variable (repeatable)")
flagSet.SetOutput(stderr)
Expand All @@ -53,7 +63,8 @@ func Shell(args []string, stderr io.Writer) error {
return commandExit(2, "")
}

target, cleanup, err := resolveConnectTarget(*name, positional, *run, *keepAlive, stderr)
create := connectCreateOpts{port: *port, acceptExisting: *acceptExisting}
target, cleanup, err := resolveConnectTarget(*name, positional, *run, *keepAlive, create, stderr)
if err != nil {
return err
}
Expand Down Expand Up @@ -94,6 +105,8 @@ func Exec(args []string, stderr io.Writer) error {
envfile := flagSet.String("envfile", "", "Load environment variables from a file")
run := flagSet.Bool("run", false, "Run the booth first if it is not already running")
keepAlive := flagSet.Bool("keep-alive", false, "Keep a booth started by --run running afterwards")
port := flagSet.String("port", "", "With --run, host port for a newly created booth (number, NEXT, or RANDOM)")
acceptExisting := flagSet.Bool("accept-existing", false, "Connect to an existing booth even if create flags (e.g. --port) do not match")
var envVars stringSliceFlag
flagSet.Var(&envVars, "e", "Set environment variable (repeatable)")
flagSet.SetOutput(stderr)
Expand All @@ -106,7 +119,8 @@ func Exec(args []string, stderr io.Writer) error {
return commandExit(1, "Error: no command specified. Usage: booth exec <name> -- <command>")
}

target, cleanup, err := resolveConnectTarget(*name, positional, *run, *keepAlive, stderr)
create := connectCreateOpts{port: *port, acceptExisting: *acceptExisting}
target, cleanup, err := resolveConnectTarget(*name, positional, *run, *keepAlive, create, stderr)
if err != nil {
return err
}
Expand Down Expand Up @@ -147,13 +161,17 @@ const (
// run=true a stopped booth is started and a missing booth is created with
// `booth run` (in daemon mode) before connecting.
//
// Create-intent flags (e.g. --port) are applied only when a new booth is created.
// Against an existing booth they are asserted: a mismatch fails by default, or
// connects with a warning when --accept-existing is set.
//
// When --run had to change the booth's state to connect (started a stopped one
// or created a new one) and keepAlive is false, the cleanup stops the booth
// again so the session leaves no trace: a freshly-created `--rm` booth is
// removed, and a pre-existing keep-alive booth returns to stopped. A booth that
// was already running, or keepAlive=true, yields a no-op cleanup. The returned
// error is already a commandError.
func resolveConnectTarget(name string, positional []string, run, keepAlive bool, stderr io.Writer) (managedContainer, func(), error) {
func resolveConnectTarget(name string, positional []string, run, keepAlive bool, create connectCreateOpts, stderr io.Writer) (managedContainer, func(), error) {
noCleanup := func() {}

containers, err := managedContainers(false)
Expand All @@ -166,6 +184,14 @@ func resolveConnectTarget(name string, positional []string, run, keepAlive bool,
return managedContainer{}, noCleanup, commandExit(1, err.Error())
}

// Create-intent flags only reconfigure a missing booth. Against an existing
// one they are a contract: refuse on mismatch unless the user opts out.
if action == connectUse || action == connectStart {
if err := checkCreateIntentAgainstExisting(target, create, stderr); err != nil {
return managedContainer{}, noCleanup, err
}
}

switch action {
case connectUse:
// Already running. We did not bring it up, but it may be an ephemeral
Expand All @@ -190,7 +216,7 @@ func resolveConnectTarget(name string, positional []string, run, keepAlive bool,
}

case connectRun:
created, err := runConnectTarget(name, positional, keepAlive, stderr)
created, err := runConnectTarget(name, positional, keepAlive, create, stderr)
if err != nil {
return managedContainer{}, noCleanup, err
}
Expand All @@ -204,6 +230,62 @@ func resolveConnectTarget(name string, positional []string, run, keepAlive bool,
return target, cleanup, nil
}

// checkCreateIntentAgainstExisting compares explicit create-intent flags against
// an already-existing booth. Returns a commandError on mismatch unless
// accept-existing is set (then warns and returns nil). Non-comparable values
// such as --port NEXT/RANDOM are not asserted.
func checkCreateIntentAgainstExisting(target managedContainer, create connectCreateOpts, stderr io.Writer) error {
mismatches := createIntentMismatches(target, create)
if len(mismatches) == 0 {
return nil
}

detail := strings.Join(mismatches, "; ")
if create.acceptExisting {
fmt.Fprintf(stderr, "Warning: booth %q does not match create flags (%s); connecting anyway (--accept-existing).\n", target.Name, detail)
return nil
}

return commandExit(1, fmt.Sprintf(
"Error: booth %q does not match create flags (%s).\n"+
" Refusing to connect so the command does not run against the wrong environment.\n"+
" Use --accept-existing to connect anyway, or remove the booth and re-run with the desired flags.",
target.Name, detail,
))
}

// createIntentMismatches returns human-readable mismatch descriptions for
// explicit create-intent flags that disagree with the existing booth. Pure for
// unit tests.
func createIntentMismatches(target managedContainer, create connectCreateOpts) []string {
var mismatches []string

if create.port != "" && isComparablePort(create.port) {
actual := strings.TrimSpace(target.Port)
if actual == "" {
mismatches = append(mismatches, fmt.Sprintf("--port %s requested but booth has no published host port", create.port))
} else if actual != create.port {
mismatches = append(mismatches, fmt.Sprintf("--port %s requested but booth is on port %s", create.port, actual))
}
}

return mismatches
}

// isComparablePort reports whether a --port value can be asserted against a
// live booth. Symbolic NEXT/RANDOM only apply on create.
func isComparablePort(port string) bool {
if port == "" {
return false
}
upper := strings.ToUpper(port)
if upper == "NEXT" || upper == "RANDOM" {
return false
}
// Numeric (and any other explicit token users might pass) is comparable.
return true
}

// registerConnectSession wires this shell/exec session into the booth's
// connection count and returns the cleanup to defer.
//
Expand Down Expand Up @@ -291,7 +373,9 @@ func connectPlan(containers []managedContainer, name string, positional []string
// container. It shells out to the same executable so the full run pipeline
// (config, image build, ports, …) is reused verbatim. When keepAlive is set the
// new booth is created with --keep-alive so it persists across a later stop.
func runConnectTarget(name string, positional []string, keepAlive bool, stderr io.Writer) (managedContainer, error) {
// Create-intent flags (e.g. --port) are forwarded so a shell/exec --run create
// matches an equivalent booth run.
func runConnectTarget(name string, positional []string, keepAlive bool, create connectCreateOpts, stderr io.Writer) (managedContainer, error) {
self, err := os.Executable()
if err != nil {
return managedContainer{}, commandExit(1, fmt.Sprintf("Error: failed to locate booth executable: %v", err))
Expand All @@ -302,13 +386,7 @@ func runConnectTarget(name string, positional []string, keepAlive bool, stderr i
explicitName = positional[0]
}

runArgs := []string{"run", "--daemon"}
if keepAlive {
runArgs = append(runArgs, "--keep-alive")
}
if explicitName != "" {
runArgs = append(runArgs, "--name", explicitName)
}
runArgs := buildConnectRunArgs(explicitName, keepAlive, create)

fmt.Fprintf(stderr, "No running booth found; starting one with 'booth run'...\n")

Expand Down Expand Up @@ -541,6 +619,23 @@ func buildExecFlags(interactive bool, dir string, envVars stringSliceFlag, envfi
return execArgs
}

// buildConnectRunArgs builds the argv for `booth run` when shell/exec creates a
// missing booth. Always daemon mode so the process returns and shell/exec can
// attach. Pure for unit tests.
func buildConnectRunArgs(explicitName string, keepAlive bool, create connectCreateOpts) []string {
runArgs := []string{"run", "--daemon"}
if keepAlive {
runArgs = append(runArgs, "--keep-alive")
}
if explicitName != "" {
runArgs = append(runArgs, "--name", explicitName)
}
if create.port != "" {
runArgs = append(runArgs, "--port", create.port)
}
return runArgs
}

// extractPositionalAndFlags separates positional arguments from flags.
// Go's flag.Parse stops at the first positional arg, so flags after a positional
// arg are never parsed. This function pulls out non-flag args (positional) and
Expand All @@ -549,15 +644,16 @@ func buildExecFlags(interactive bool, dir string, envVars stringSliceFlag, envfi
func extractPositionalAndFlags(args []string) (positional []string, flags []string) {
knownValueFlags := map[string]bool{
"-e": true, "--name": true, "--shell": true,
"--dir": true, "--envfile": true,
"--dir": true, "--envfile": true, "--port": true,
}

for i := 0; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "-") {
flags = append(flags, arg)
// If this flag takes a value, consume the next arg too
if knownValueFlags[arg] && i+1 < len(args) {
// (including --flag=value forms, which are a single token).
if !strings.Contains(arg, "=") && knownValueFlags[arg] && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
Expand Down
33 changes: 29 additions & 4 deletions cli/src/pkg/lifecycle/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ type inspectData struct {
HostPort string `json:"HostPort"`
} `json:"Ports"`
} `json:"NetworkSettings"`
// HostConfig.PortBindings retains the configured host ports even when the
// container is stopped (NetworkSettings.Ports is often empty then).
HostConfig struct {
PortBindings map[string][]struct {
HostPort string `json:"HostPort"`
} `json:"PortBindings"`
} `json:"HostConfig"`
}

type commandError struct {
Expand Down Expand Up @@ -436,10 +443,7 @@ func inspectManagedContainer(name string, flags docker.DockerFlags) (managedCont
labels = map[string]string{}
}

port := ""
if bindings, found := data.NetworkSettings.Ports["10000/tcp"]; found && len(bindings) > 0 {
port = bindings[0].HostPort
}
port := hostPortFromInspect(data)

containerName := strings.TrimPrefix(data.Name, "/")
if containerName == "" {
Expand All @@ -463,6 +467,27 @@ func inspectManagedContainer(name string, flags docker.DockerFlags) (managedCont
}, nil
}

// hostPortFromInspect returns the host port mapped to the booth UI container
// port. Prefers live NetworkSettings, then HostConfig bindings (works for
// stopped containers). Checks both 10000/tcp (default) and 10443/tcp (public TLS).
func hostPortFromInspect(data inspectData) string {
for _, containerPort := range []string{"10000/tcp", "10443/tcp"} {
if bindings, found := data.NetworkSettings.Ports[containerPort]; found && len(bindings) > 0 {
if p := strings.TrimSpace(bindings[0].HostPort); p != "" {
return p
}
}
}
for _, containerPort := range []string{"10000/tcp", "10443/tcp"} {
if bindings, found := data.HostConfig.PortBindings[containerPort]; found && len(bindings) > 0 {
if p := strings.TrimSpace(bindings[0].HostPort); p != "" {
return p
}
}
}
return ""
}

func filterByState(containers []managedContainer, runningOnly bool, stoppedOnly bool) []managedContainer {
if !runningOnly && !stoppedOnly {
return containers
Expand Down
Loading
Loading