mirror of https://github.com/coder/coder.git
feat(cli): Add support for `delay_login_until_ready` (#5851)
This commit is contained in:
parent
cf93fbd39a
commit
a753703e47
|
@ -10,16 +10,21 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/briandowns/spinner"
|
"github.com/briandowns/spinner"
|
||||||
|
"github.com/muesli/reflow/indent"
|
||||||
|
"github.com/muesli/reflow/wordwrap"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var AgentStartError = xerrors.New("agent startup exited with non-zero exit status")
|
||||||
|
|
||||||
type AgentOptions struct {
|
type AgentOptions struct {
|
||||||
WorkspaceName string
|
WorkspaceName string
|
||||||
Fetch func(context.Context) (codersdk.WorkspaceAgent, error)
|
Fetch func(context.Context) (codersdk.WorkspaceAgent, error)
|
||||||
FetchInterval time.Duration
|
FetchInterval time.Duration
|
||||||
WarnInterval time.Duration
|
WarnInterval time.Duration
|
||||||
|
NoWait bool // If true, don't wait for the agent to be ready.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent displays a spinning indicator that waits for a workspace agent to connect.
|
// Agent displays a spinning indicator that waits for a workspace agent to connect.
|
||||||
|
@ -36,48 +41,33 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||||
return xerrors.Errorf("fetch: %w", err)
|
return xerrors.Errorf("fetch: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent.Status == codersdk.WorkspaceAgentConnected {
|
// Fast path if the agent is ready (avoid showing connecting prompt).
|
||||||
|
// We don't take the fast path for opts.NoWait yet because we want to
|
||||||
|
// show the message.
|
||||||
|
if agent.Status == codersdk.WorkspaceAgentConnected &&
|
||||||
|
(!agent.DelayLoginUntilReady || agent.LifecycleState == codersdk.WorkspaceAgentLifecycleReady) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
|
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
|
||||||
spin.Writer = writer
|
spin.Writer = writer
|
||||||
spin.ForceOutput = true
|
spin.ForceOutput = true
|
||||||
spin.Suffix = " Waiting for connection from " + Styles.Field.Render(agent.Name) + "..."
|
spin.Suffix = waitingMessage(agent, opts).Spin
|
||||||
spin.Start()
|
|
||||||
defer spin.Stop()
|
|
||||||
|
|
||||||
ctx, cancelFunc := context.WithCancel(ctx)
|
waitMessage := &message{}
|
||||||
defer cancelFunc()
|
|
||||||
stopSpin := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stopSpin, os.Interrupt)
|
|
||||||
defer signal.Stop(stopSpin)
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-stopSpin:
|
|
||||||
}
|
|
||||||
cancelFunc()
|
|
||||||
signal.Stop(stopSpin)
|
|
||||||
spin.Stop()
|
|
||||||
// nolint:revive
|
|
||||||
os.Exit(1)
|
|
||||||
}()
|
|
||||||
|
|
||||||
var waitMessage string
|
|
||||||
messageAfter := time.NewTimer(opts.WarnInterval)
|
|
||||||
defer messageAfter.Stop()
|
|
||||||
showMessage := func() {
|
showMessage := func() {
|
||||||
resourceMutex.Lock()
|
resourceMutex.Lock()
|
||||||
defer resourceMutex.Unlock()
|
defer resourceMutex.Unlock()
|
||||||
|
|
||||||
m := waitingMessage(agent)
|
m := waitingMessage(agent, opts)
|
||||||
if m == waitMessage {
|
if m.Prompt == waitMessage.Prompt {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
moveUp := ""
|
moveUp := ""
|
||||||
if waitMessage != "" {
|
if waitMessage.Prompt != "" {
|
||||||
// If this is an update, move a line up
|
// If this is an update, move a line up
|
||||||
// to keep it tidy and aligned.
|
// to keep it tidy and aligned.
|
||||||
moveUp = "\033[1A"
|
moveUp = "\033[1A"
|
||||||
|
@ -86,20 +76,43 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||||
|
|
||||||
// Stop the spinner while we write our message.
|
// Stop the spinner while we write our message.
|
||||||
spin.Stop()
|
spin.Stop()
|
||||||
|
spin.Suffix = waitMessage.Spin
|
||||||
// Clear the line and (if necessary) move up a line to write our message.
|
// Clear the line and (if necessary) move up a line to write our message.
|
||||||
_, _ = fmt.Fprintf(writer, "\033[2K%s%s\n\n", moveUp, Styles.Paragraph.Render(Styles.Prompt.String()+waitMessage))
|
_, _ = fmt.Fprintf(writer, "\033[2K%s\n%s\n", moveUp, waitMessage.Prompt)
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
default:
|
default:
|
||||||
// Safe to resume operation.
|
// Safe to resume operation.
|
||||||
spin.Start()
|
if spin.Suffix != "" {
|
||||||
|
spin.Start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast path for showing the error message even when using no wait,
|
||||||
|
// we do this just before starting the spinner to avoid needless
|
||||||
|
// spinning.
|
||||||
|
if agent.Status == codersdk.WorkspaceAgentConnected &&
|
||||||
|
agent.DelayLoginUntilReady && opts.NoWait {
|
||||||
|
showMessage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start spinning after fast paths are handled.
|
||||||
|
if spin.Suffix != "" {
|
||||||
|
spin.Start()
|
||||||
|
}
|
||||||
|
defer spin.Stop()
|
||||||
|
|
||||||
|
warnAfter := time.NewTimer(opts.WarnInterval)
|
||||||
|
defer warnAfter.Stop()
|
||||||
|
warningShown := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
case <-messageAfter.C:
|
close(warningShown)
|
||||||
messageAfter.Stop()
|
case <-warnAfter.C:
|
||||||
|
close(warningShown)
|
||||||
showMessage()
|
showMessage()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -121,6 +134,29 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||||
resourceMutex.Unlock()
|
resourceMutex.Unlock()
|
||||||
switch agent.Status {
|
switch agent.Status {
|
||||||
case codersdk.WorkspaceAgentConnected:
|
case codersdk.WorkspaceAgentConnected:
|
||||||
|
// NOTE(mafredri): Once we have access to the workspace agent's
|
||||||
|
// startup script logs, we can show them here.
|
||||||
|
// https://github.com/coder/coder/issues/2957
|
||||||
|
if agent.DelayLoginUntilReady && !opts.NoWait {
|
||||||
|
switch agent.LifecycleState {
|
||||||
|
case codersdk.WorkspaceAgentLifecycleReady:
|
||||||
|
return nil
|
||||||
|
case codersdk.WorkspaceAgentLifecycleStartTimeout:
|
||||||
|
showMessage()
|
||||||
|
case codersdk.WorkspaceAgentLifecycleStartError:
|
||||||
|
showMessage()
|
||||||
|
return AgentStartError
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-warningShown:
|
||||||
|
showMessage()
|
||||||
|
default:
|
||||||
|
// This state is normal, we don't want
|
||||||
|
// to show a message prematurely.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
case codersdk.WorkspaceAgentTimeout, codersdk.WorkspaceAgentDisconnected:
|
case codersdk.WorkspaceAgentTimeout, codersdk.WorkspaceAgentDisconnected:
|
||||||
showMessage()
|
showMessage()
|
||||||
|
@ -128,19 +164,78 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitingMessage(agent codersdk.WorkspaceAgent) string {
|
type message struct {
|
||||||
var m string
|
Spin string
|
||||||
|
Prompt string
|
||||||
|
Troubleshoot bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitingMessage(agent codersdk.WorkspaceAgent, opts AgentOptions) (m *message) {
|
||||||
|
m = &message{
|
||||||
|
Spin: fmt.Sprintf("Waiting for connection from %s...", Styles.Field.Render(agent.Name)),
|
||||||
|
Prompt: "Don't panic, your workspace is booting up!",
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if opts.NoWait {
|
||||||
|
m.Spin = ""
|
||||||
|
}
|
||||||
|
if m.Spin != "" {
|
||||||
|
m.Spin = " " + m.Spin
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't want to wrap the troubleshooting URL, so we'll handle word
|
||||||
|
// wrapping ourselves (vs using lipgloss).
|
||||||
|
w := wordwrap.NewWriter(Styles.Paragraph.GetWidth() - Styles.Paragraph.GetMarginLeft()*2)
|
||||||
|
w.Breakpoints = []rune{' ', '\n'}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprint(w, m.Prompt)
|
||||||
|
if m.Troubleshoot {
|
||||||
|
if agent.TroubleshootingURL != "" {
|
||||||
|
_, _ = fmt.Fprintf(w, " See troubleshooting instructions at:\n%s", agent.TroubleshootingURL)
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprint(w, " Wait for it to (re)connect or restart your workspace.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, "\n")
|
||||||
|
|
||||||
|
// We want to prefix the prompt with a caret, but we want text on the
|
||||||
|
// following lines to align with the text on the first line (i.e. added
|
||||||
|
// spacing).
|
||||||
|
ind := " " + Styles.Prompt.String()
|
||||||
|
iw := indent.NewWriter(1, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte(ind))
|
||||||
|
ind = " " // Set indentation to space after initial prompt.
|
||||||
|
})
|
||||||
|
_, _ = fmt.Fprint(iw, w.String())
|
||||||
|
m.Prompt = iw.String()
|
||||||
|
}()
|
||||||
|
|
||||||
switch agent.Status {
|
switch agent.Status {
|
||||||
case codersdk.WorkspaceAgentTimeout:
|
case codersdk.WorkspaceAgentTimeout:
|
||||||
m = "The workspace agent is having trouble connecting."
|
m.Prompt = "The workspace agent is having trouble connecting."
|
||||||
case codersdk.WorkspaceAgentDisconnected:
|
case codersdk.WorkspaceAgentDisconnected:
|
||||||
m = "The workspace agent lost connection!"
|
m.Prompt = "The workspace agent lost connection!"
|
||||||
|
case codersdk.WorkspaceAgentConnected:
|
||||||
|
m.Spin = fmt.Sprintf("Waiting for %s to become ready...", Styles.Field.Render(agent.Name))
|
||||||
|
m.Prompt = "Don't panic, your workspace agent has connected and the workspace is getting ready!"
|
||||||
|
if opts.NoWait {
|
||||||
|
m.Prompt = "Your workspace is still getting ready, it may be in an incomplete state."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch agent.LifecycleState {
|
||||||
|
case codersdk.WorkspaceAgentLifecycleStartTimeout:
|
||||||
|
m.Prompt = "The workspace is taking longer than expected to get ready, the agent startup script is still executing."
|
||||||
|
case codersdk.WorkspaceAgentLifecycleStartError:
|
||||||
|
m.Spin = ""
|
||||||
|
m.Prompt = "The workspace ran into a problem while getting ready, the agent startup script exited with non-zero status."
|
||||||
|
default:
|
||||||
|
// Not a failure state, no troubleshooting necessary.
|
||||||
|
return m
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// Not a failure state, no troubleshooting necessary.
|
// Not a failure state, no troubleshooting necessary.
|
||||||
return "Don't panic, your workspace is booting up!"
|
return m
|
||||||
}
|
}
|
||||||
if agent.TroubleshootingURL != "" {
|
m.Troubleshoot = true
|
||||||
return fmt.Sprintf("%s See troubleshooting instructions at: %s", m, agent.TroubleshootingURL)
|
return m
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s Wait for it to (re)connect or restart your workspace.", m)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/atomic"
|
"go.uber.org/atomic"
|
||||||
|
|
||||||
"github.com/coder/coder/cli/cliui"
|
"github.com/coder/coder/cli/cliui"
|
||||||
|
@ -17,13 +18,17 @@ import (
|
||||||
|
|
||||||
func TestAgent(t *testing.T) {
|
func TestAgent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
var disconnected atomic.Bool
|
var disconnected atomic.Bool
|
||||||
ptty := ptytest.New(t)
|
ptty := ptytest.New(t)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||||
WorkspaceName: "example",
|
WorkspaceName: "example",
|
||||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
agent := codersdk.WorkspaceAgent{
|
agent := codersdk.WorkspaceAgent{
|
||||||
Status: codersdk.WorkspaceAgentDisconnected,
|
Status: codersdk.WorkspaceAgentDisconnected,
|
||||||
}
|
}
|
||||||
|
@ -46,33 +51,34 @@ func TestAgent(t *testing.T) {
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}()
|
}()
|
||||||
ptty.ExpectMatch("lost connection")
|
ptty.ExpectMatchContext(ctx, "lost connection")
|
||||||
disconnected.Store(true)
|
disconnected.Store(true)
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
|
func TestAgent_TimeoutWithTroubleshootingURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx, _ := testutil.Context(t)
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
wantURL := "https://coder.com/troubleshoot"
|
wantURL := "https://coder.com/troubleshoot"
|
||||||
|
|
||||||
var connected, timeout atomic.Bool
|
var connected, timeout atomic.Bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||||
WorkspaceName: "example",
|
WorkspaceName: "example",
|
||||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
agent := codersdk.WorkspaceAgent{
|
agent := codersdk.WorkspaceAgent{
|
||||||
Status: codersdk.WorkspaceAgentConnecting,
|
Status: codersdk.WorkspaceAgentConnecting,
|
||||||
TroubleshootingURL: "https://coder.com/troubleshoot",
|
TroubleshootingURL: wantURL,
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case !connected.Load() && timeout.Load():
|
||||||
|
agent.Status = codersdk.WorkspaceAgentTimeout
|
||||||
case connected.Load():
|
case connected.Load():
|
||||||
agent.Status = codersdk.WorkspaceAgentConnected
|
agent.Status = codersdk.WorkspaceAgentConnected
|
||||||
case timeout.Load():
|
|
||||||
agent.Status = codersdk.WorkspaceAgentTimeout
|
|
||||||
}
|
}
|
||||||
return agent, nil
|
return agent, nil
|
||||||
},
|
},
|
||||||
|
@ -85,15 +91,264 @@ func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
|
||||||
ptty := ptytest.New(t)
|
ptty := ptytest.New(t)
|
||||||
cmd.SetOutput(ptty.Output())
|
cmd.SetOutput(ptty.Output())
|
||||||
cmd.SetIn(ptty.Input())
|
cmd.SetIn(ptty.Input())
|
||||||
done := make(chan struct{})
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
done <- cmd.ExecuteContext(ctx)
|
||||||
err := cmd.ExecuteContext(ctx)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}()
|
}()
|
||||||
ptty.ExpectMatch("Don't panic")
|
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||||
timeout.Store(true)
|
timeout.Store(true)
|
||||||
ptty.ExpectMatch(wantURL)
|
ptty.ExpectMatchContext(ctx, wantURL)
|
||||||
connected.Store(true)
|
connected.Store(true)
|
||||||
<-done
|
require.NoError(t, <-done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgent_StartupTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||||
|
|
||||||
|
var status, state atomic.String
|
||||||
|
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||||
|
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||||
|
WorkspaceName: "example",
|
||||||
|
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
|
agent := codersdk.WorkspaceAgent{
|
||||||
|
Status: codersdk.WorkspaceAgentConnecting,
|
||||||
|
DelayLoginUntilReady: true,
|
||||||
|
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||||
|
TroubleshootingURL: wantURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := status.Load(); s != "" {
|
||||||
|
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||||
|
}
|
||||||
|
if s := state.Load(); s != "" {
|
||||||
|
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||||
|
}
|
||||||
|
return agent, nil
|
||||||
|
},
|
||||||
|
FetchInterval: time.Millisecond,
|
||||||
|
WarnInterval: time.Millisecond,
|
||||||
|
NoWait: false,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ptty := ptytest.New(t)
|
||||||
|
cmd.SetOutput(ptty.Output())
|
||||||
|
cmd.SetIn(ptty.Input())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- cmd.ExecuteContext(ctx)
|
||||||
|
}()
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnecting)
|
||||||
|
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnected)
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||||
|
ptty.ExpectMatchContext(ctx, "workspace is getting ready")
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||||
|
ptty.ExpectMatchContext(ctx, "is taking longer")
|
||||||
|
ptty.ExpectMatchContext(ctx, wantURL)
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||||
|
require.NoError(t, <-done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgent_StartErrorExit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||||
|
|
||||||
|
var status, state atomic.String
|
||||||
|
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||||
|
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||||
|
WorkspaceName: "example",
|
||||||
|
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
|
agent := codersdk.WorkspaceAgent{
|
||||||
|
Status: codersdk.WorkspaceAgentConnecting,
|
||||||
|
DelayLoginUntilReady: true,
|
||||||
|
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||||
|
TroubleshootingURL: wantURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := status.Load(); s != "" {
|
||||||
|
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||||
|
}
|
||||||
|
if s := state.Load(); s != "" {
|
||||||
|
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||||
|
}
|
||||||
|
return agent, nil
|
||||||
|
},
|
||||||
|
FetchInterval: time.Millisecond,
|
||||||
|
WarnInterval: 60 * time.Second,
|
||||||
|
NoWait: false,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ptty := ptytest.New(t)
|
||||||
|
cmd.SetOutput(ptty.Output())
|
||||||
|
cmd.SetIn(ptty.Input())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- cmd.ExecuteContext(ctx)
|
||||||
|
}()
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnected)
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||||
|
ptty.ExpectMatchContext(ctx, "to become ready...")
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||||
|
ptty.ExpectMatchContext(ctx, "ran into a problem")
|
||||||
|
err := <-done
|
||||||
|
require.ErrorIs(t, err, cliui.AgentStartError, "lifecycle start_error should exit with error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgent_NoWait(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||||
|
|
||||||
|
var status, state atomic.String
|
||||||
|
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||||
|
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||||
|
WorkspaceName: "example",
|
||||||
|
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
|
agent := codersdk.WorkspaceAgent{
|
||||||
|
Status: codersdk.WorkspaceAgentConnecting,
|
||||||
|
DelayLoginUntilReady: true,
|
||||||
|
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||||
|
TroubleshootingURL: wantURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := status.Load(); s != "" {
|
||||||
|
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||||
|
}
|
||||||
|
if s := state.Load(); s != "" {
|
||||||
|
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||||
|
}
|
||||||
|
return agent, nil
|
||||||
|
},
|
||||||
|
FetchInterval: time.Millisecond,
|
||||||
|
WarnInterval: time.Second,
|
||||||
|
NoWait: true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ptty := ptytest.New(t)
|
||||||
|
cmd.SetOutput(ptty.Output())
|
||||||
|
cmd.SetIn(ptty.Input())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- cmd.ExecuteContext(ctx)
|
||||||
|
}()
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnecting)
|
||||||
|
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||||
|
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnected)
|
||||||
|
require.NoError(t, <-done, "created - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "starting - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "start timeout - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "start error - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "ready - should exit early")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgent_DelayLoginUntilReadyDisabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wantURL := "https://coder.com/this-is-a-really-long-troubleshooting-url-that-should-not-wrap"
|
||||||
|
|
||||||
|
var status, state atomic.String
|
||||||
|
setStatus := func(s codersdk.WorkspaceAgentStatus) { status.Store(string(s)) }
|
||||||
|
setState := func(s codersdk.WorkspaceAgentLifecycle) { state.Store(string(s)) }
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||||
|
WorkspaceName: "example",
|
||||||
|
Fetch: func(_ context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
|
agent := codersdk.WorkspaceAgent{
|
||||||
|
Status: codersdk.WorkspaceAgentConnecting,
|
||||||
|
DelayLoginUntilReady: false,
|
||||||
|
LifecycleState: codersdk.WorkspaceAgentLifecycleCreated,
|
||||||
|
TroubleshootingURL: wantURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := status.Load(); s != "" {
|
||||||
|
agent.Status = codersdk.WorkspaceAgentStatus(s)
|
||||||
|
}
|
||||||
|
if s := state.Load(); s != "" {
|
||||||
|
agent.LifecycleState = codersdk.WorkspaceAgentLifecycle(s)
|
||||||
|
}
|
||||||
|
return agent, nil
|
||||||
|
},
|
||||||
|
FetchInterval: time.Millisecond,
|
||||||
|
WarnInterval: time.Second,
|
||||||
|
NoWait: false,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ptty := ptytest.New(t)
|
||||||
|
cmd.SetOutput(ptty.Output())
|
||||||
|
cmd.SetIn(ptty.Input())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- cmd.ExecuteContext(ctx)
|
||||||
|
}()
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnecting)
|
||||||
|
ptty.ExpectMatchContext(ctx, "Don't panic, your workspace is booting")
|
||||||
|
|
||||||
|
setStatus(codersdk.WorkspaceAgentConnected)
|
||||||
|
require.NoError(t, <-done, "created - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStarting)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "starting - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStartTimeout)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "start timeout - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleStartError)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "start error - should exit early")
|
||||||
|
|
||||||
|
setState(codersdk.WorkspaceAgentLifecycleReady)
|
||||||
|
go func() { done <- cmd.ExecuteContext(ctx) }()
|
||||||
|
require.NoError(t, <-done, "ready - should exit early")
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,7 @@ func ssh() *cobra.Command {
|
||||||
forwardGPG bool
|
forwardGPG bool
|
||||||
identityAgent string
|
identityAgent string
|
||||||
wsPollInterval time.Duration
|
wsPollInterval time.Duration
|
||||||
|
noWait bool
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Annotations: workspaceCommand,
|
Annotations: workspaceCommand,
|
||||||
|
@ -90,8 +91,15 @@ func ssh() *cobra.Command {
|
||||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||||
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
|
return client.WorkspaceAgent(ctx, workspaceAgent.ID)
|
||||||
},
|
},
|
||||||
|
NoWait: noWait,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if xerrors.Is(err, context.Canceled) {
|
||||||
|
return cliui.Canceled
|
||||||
|
}
|
||||||
|
if xerrors.Is(err, cliui.AgentStartError) {
|
||||||
|
return xerrors.New("Agent startup script exited with non-zero status, use --no-wait to login anyway.")
|
||||||
|
}
|
||||||
return xerrors.Errorf("await agent: %w", err)
|
return xerrors.Errorf("await agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,6 +250,7 @@ func ssh() *cobra.Command {
|
||||||
cliflag.BoolVarP(cmd.Flags(), &forwardGPG, "forward-gpg", "G", "CODER_SSH_FORWARD_GPG", false, "Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, but supports all clients. Requires gnupg (gpg, gpgconf) on both the client and workspace. The GPG agent must already be running locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed.")
|
cliflag.BoolVarP(cmd.Flags(), &forwardGPG, "forward-gpg", "G", "CODER_SSH_FORWARD_GPG", false, "Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, but supports all clients. Requires gnupg (gpg, gpgconf) on both the client and workspace. The GPG agent must already be running locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed.")
|
||||||
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
|
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
|
||||||
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
|
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
|
||||||
|
cliflag.BoolVarP(cmd.Flags(), &noWait, "no-wait", "", "CODER_SSH_NO_WAIT", false, "Specifies whether to wait for a workspace to become ready before logging in (only applicable when the delay login until ready option is enabled). Note that the workspace agent may still be in the process of executing the startup script and the workspace may be in an incomplete state.")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/coder/coder/agent"
|
"github.com/coder/coder/agent"
|
||||||
"github.com/coder/coder/cli/clitest"
|
"github.com/coder/coder/cli/clitest"
|
||||||
|
"github.com/coder/coder/cli/cliui"
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
"github.com/coder/coder/provisioner/echo"
|
"github.com/coder/coder/provisioner/echo"
|
||||||
|
@ -136,7 +137,7 @@ func TestSSH(t *testing.T) {
|
||||||
|
|
||||||
cmdDone := tGo(t, func() {
|
cmdDone := tGo(t, func() {
|
||||||
err := cmd.ExecuteContext(ctx)
|
err := cmd.ExecuteContext(ctx)
|
||||||
assert.ErrorIs(t, err, context.Canceled)
|
assert.ErrorIs(t, err, cliui.Canceled)
|
||||||
})
|
})
|
||||||
pty.ExpectMatch(wantURL)
|
pty.ExpectMatch(wantURL)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
|
@ -20,6 +20,13 @@ Flags:
|
||||||
$SSH_AUTH_SOCK), forward agent must also be
|
$SSH_AUTH_SOCK), forward agent must also be
|
||||||
enabled.
|
enabled.
|
||||||
Consumes $CODER_SSH_IDENTITY_AGENT
|
Consumes $CODER_SSH_IDENTITY_AGENT
|
||||||
|
--no-wait Specifies whether to wait for a workspace to become
|
||||||
|
ready before logging in (only applicable when the
|
||||||
|
delay login until ready option is enabled). Note
|
||||||
|
that the workspace agent may still be in the
|
||||||
|
process of executing the startup script and the
|
||||||
|
workspace may be in an incomplete state.
|
||||||
|
Consumes $CODER_SSH_NO_WAIT
|
||||||
--stdio Specifies whether to emit SSH output over
|
--stdio Specifies whether to emit SSH output over
|
||||||
stdin/stdout.
|
stdin/stdout.
|
||||||
Consumes $CODER_SSH_STDIO
|
Consumes $CODER_SSH_STDIO
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -274,7 +274,7 @@ require (
|
||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
|
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
|
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0
|
||||||
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
|
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
|
||||||
github.com/niklasfasching/go-org v1.6.5 // indirect
|
github.com/niklasfasching/go-org v1.6.5 // indirect
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
|
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
|
||||||
|
|
Loading…
Reference in New Issue