Skip to content
This repository was archived by the owner on Aug 30, 2024. It is now read-only.

Commit 288197a

Browse files
authored
feat: Prompt the user to rebuild workspace on coder sh (#223)
If the workspace is OFF or a rebuild is required, prompt the user to rebuild right away. This prompt only occurs in an interactive shell
1 parent daa3f7a commit 288197a

File tree

3 files changed

+167
-27
lines changed

3 files changed

+167
-27
lines changed

coder-sdk/env.go

+9
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,12 @@ func (c Client) WaitForEnvironmentReady(ctx context.Context, envID string) error
274274
}
275275
}
276276
}
277+
278+
// EnvironmentByID get the details of an environment by its id.
279+
func (c Client) EnvironmentByID(ctx context.Context, id string) (*Environment, error) {
280+
var env Environment
281+
if err := c.requestBody(ctx, http.MethodGet, "/api/v0/environments/"+id, nil, &env); err != nil {
282+
return nil, err
283+
}
284+
return &env, nil
285+
}

docs/coder_sh.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ Open a shell and execute commands in a Coder environment
44

55
### Synopsis
66

7-
Execute a remote command on the environment\nIf no command is specified, the default shell is opened.
7+
Execute a remote command on the environment
8+
If no command is specified, the default shell is opened.
9+
If the command is run in an interactive shell, a user prompt will occur if the environment needs to be rebuilt.
810

911
```
1012
coder sh [environment_name] [<command [args...]>] [flags]

internal/cmd/shell.go

+155-26
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/manifoldco/promptui"
1112
"github.com/spf13/cobra"
1213
"golang.org/x/crypto/ssh/terminal"
1314
"golang.org/x/time/rate"
@@ -65,9 +66,11 @@ func shValidArgs(cmd *cobra.Command, args []string) error {
6566

6667
func shCmd() *cobra.Command {
6768
return &cobra.Command{
68-
Use: "sh [environment_name] [<command [args...]>]",
69-
Short: "Open a shell and execute commands in a Coder environment",
70-
Long: "Execute a remote command on the environment\\nIf no command is specified, the default shell is opened.",
69+
Use: "sh [environment_name] [<command [args...]>]",
70+
Short: "Open a shell and execute commands in a Coder environment",
71+
Long: `Execute a remote command on the environment
72+
If no command is specified, the default shell is opened.
73+
If the command is run in an interactive shell, a user prompt will occur if the environment needs to be rebuilt.`,
7174
Args: shValidArgs,
7275
DisableFlagParsing: true,
7376
ValidArgsFunction: getEnvsForCompletion(coder.Me),
@@ -92,7 +95,28 @@ func shell(cmd *cobra.Command, cmdArgs []string) error {
9295

9396
envName := cmdArgs[0]
9497

95-
if err := runCommand(ctx, envName, command, args); err != nil {
98+
// Before the command is run, ensure the workspace is on and ready to accept
99+
// an ssh connection.
100+
client, err := newClient(ctx)
101+
if err != nil {
102+
return err
103+
}
104+
105+
env, err := findEnv(ctx, client, envName, coder.Me)
106+
if err != nil {
107+
return err
108+
}
109+
110+
// TODO: Verify this is the correct behavior
111+
isInteractive := terminal.IsTerminal(int(os.Stdout.Fd()))
112+
if isInteractive { // checkAndRebuildEnvironment requires an interactive shell
113+
// Checks & Rebuilds the environment if needed.
114+
if err := checkAndRebuildEnvironment(ctx, client, env); err != nil {
115+
return err
116+
}
117+
}
118+
119+
if err := runCommand(ctx, client, env, command, args); err != nil {
96120
if exitErr, ok := err.(wsep.ExitError); ok {
97121
os.Exit(exitErr.Code)
98122
}
@@ -101,6 +125,132 @@ func shell(cmd *cobra.Command, cmdArgs []string) error {
101125
return nil
102126
}
103127

128+
// rebuildPrompt returns function that prompts the user if they wish to
129+
// rebuild the selected environment if a rebuild is needed. The returned prompt function will
130+
// return an error if the user selects "no".
131+
// This functions returns `nil` if there is no reason to prompt the user to rebuild
132+
// the environment.
133+
func rebuildPrompt(env *coder.Environment) (prompt func() error) {
134+
// Option 1: If the environment is off, the rebuild is needed
135+
if env.LatestStat.ContainerStatus == coder.EnvironmentOff {
136+
confirm := promptui.Prompt{
137+
Label: fmt.Sprintf("Environment %q is \"OFF\". Rebuild it now? (this can take several minutes", env.Name),
138+
IsConfirm: true,
139+
}
140+
return func() (err error) {
141+
_, err = confirm.Run()
142+
return
143+
}
144+
}
145+
146+
// Option 2: If there are required rebuild messages, the rebuild is needed
147+
var lines []string
148+
for _, r := range env.RebuildMessages {
149+
if r.Required {
150+
lines = append(lines, clog.Causef(r.Text))
151+
}
152+
}
153+
154+
if len(lines) > 0 {
155+
confirm := promptui.Prompt{
156+
Label: fmt.Sprintf("Environment %q requires a rebuild to work correctly. Do you wish to rebuild it now? (this will take a moment)", env.Name),
157+
IsConfirm: true,
158+
}
159+
// This function also prints the reasons in a log statement.
160+
// The confirm prompt does not handle new lines well in the label.
161+
return func() (err error) {
162+
clog.LogWarn("rebuild required", lines...)
163+
_, err = confirm.Run()
164+
return
165+
}
166+
}
167+
168+
// Environment looks good, no need to prompt the user.
169+
return nil
170+
}
171+
172+
// checkAndRebuildEnvironment will:
173+
// 1. Check if an environment needs to be rebuilt to be used
174+
// 2. Prompt the user if they want to rebuild the environment (returns an error if they do not)
175+
// 3. Rebuilds the environment and waits for it to be 'ON'
176+
// Conditions for rebuilding are:
177+
// - Environment is offline
178+
// - Environment has rebuild messages requiring a rebuild
179+
func checkAndRebuildEnvironment(ctx context.Context, client *coder.Client, env *coder.Environment) error {
180+
var err error
181+
rebuildPrompt := rebuildPrompt(env) // Fetch the prompt for rebuilding envs w/ reason
182+
183+
switch {
184+
// If this conditonal is true, a rebuild is **required** to make the sh command work.
185+
case rebuildPrompt != nil:
186+
// TODO: (@emyrk) I'd like to add a --force and --verbose flags to this command,
187+
// but currently DisableFlagParsing is set to true.
188+
// To enable force/verbose, we'd have to parse the flags ourselves,
189+
// or make the user `coder sh <env> -- [args]`
190+
//
191+
if err := rebuildPrompt(); err != nil {
192+
// User selected not to rebuild :(
193+
return clog.Fatal(
194+
"environment is not ready for use",
195+
"environment requires a rebuild",
196+
fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus),
197+
clog.BlankLine,
198+
clog.Tipf("run \"coder envs rebuild %s --follow\" to start the environment", env.Name),
199+
)
200+
}
201+
202+
// Start the rebuild
203+
if err := client.RebuildEnvironment(ctx, env.ID); err != nil {
204+
return err
205+
}
206+
207+
fallthrough // Fallthrough to watching the logs
208+
case env.LatestStat.ContainerStatus == coder.EnvironmentCreating:
209+
// Environment is in the process of being created, just trail the logs
210+
// and wait until it is done
211+
clog.LogInfo(fmt.Sprintf("Rebuilding %q", env.Name))
212+
213+
// Watch the rebuild.
214+
if err := trailBuildLogs(ctx, client, env.ID); err != nil {
215+
return err
216+
}
217+
218+
// newline after trailBuildLogs to place user on a fresh line for their shell
219+
fmt.Println()
220+
221+
// At this point the buildlog is complete, and the status of the env should be 'ON'
222+
env, err = client.EnvironmentByID(ctx, env.ID)
223+
if err != nil {
224+
// If this api call failed, it will likely fail again, no point to retry and make the user wait
225+
return err
226+
}
227+
228+
if env.LatestStat.ContainerStatus != coder.EnvironmentOn {
229+
// This means we had a timeout
230+
return clog.Fatal("the environment rebuild ran into an issue",
231+
fmt.Sprintf("environment %q rebuild has failed and will not come online", env.Name),
232+
fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus),
233+
clog.BlankLine,
234+
// TODO: (@emyrk) can they check these logs from the cli? Isn't this the logs that
235+
// I just showed them? I'm trying to decide what exactly to tell a user.
236+
clog.Tipf("take a look at the build logs to determine what went wrong"),
237+
)
238+
}
239+
240+
case env.LatestStat.ContainerStatus == coder.EnvironmentFailed:
241+
// A failed container might just keep re-failing. I think it should be investigated by the user
242+
return clog.Fatal("the environment has failed to come online",
243+
fmt.Sprintf("environment %q is not running", env.Name),
244+
fmt.Sprintf("its current status is %q", env.LatestStat.ContainerStatus),
245+
246+
clog.BlankLine,
247+
clog.Tipf("take a look at the build logs to determine what went wrong"),
248+
clog.Tipf("run \"coder envs rebuild %s --follow\" to attempt to rebuild the environment", env.Name),
249+
)
250+
}
251+
return nil
252+
}
253+
104254
// sendResizeEvents starts watching for the client's terminal resize signals
105255
// and sends the event to the server so the remote tty can match the client.
106256
func sendResizeEvents(ctx context.Context, termFD uintptr, process wsep.Process) {
@@ -121,28 +271,7 @@ func sendResizeEvents(ctx context.Context, termFD uintptr, process wsep.Process)
121271
}
122272
}
123273

124-
func runCommand(ctx context.Context, envName, command string, args []string) error {
125-
client, err := newClient(ctx)
126-
if err != nil {
127-
return err
128-
}
129-
env, err := findEnv(ctx, client, envName, coder.Me)
130-
if err != nil {
131-
return xerrors.Errorf("find environment: %w", err)
132-
}
133-
134-
// check if a rebuild is required before attempting to open a shell
135-
for _, r := range env.RebuildMessages {
136-
// use the first rebuild message that is required
137-
if r.Required {
138-
return clog.Error(
139-
fmt.Sprintf(`environment "%s" requires a rebuild`, env.Name),
140-
clog.Causef(r.Text), clog.BlankLine,
141-
clog.Tipf(`run "coder envs rebuild %s" to rebuild`, env.Name),
142-
)
143-
}
144-
}
145-
274+
func runCommand(ctx context.Context, client *coder.Client, env *coder.Environment, command string, args []string) error {
146275
termFD := os.Stdout.Fd()
147276

148277
isInteractive := terminal.IsTerminal(int(termFD))

0 commit comments

Comments
 (0)