-
Notifications
You must be signed in to change notification settings - Fork 238
experimental/ssh: show a spinner per agent-shim bootstrap step #6823
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,6 @@ import ( | |
| "time" | ||
|
|
||
| "github.com/databricks/cli/libs/auth" | ||
| "github.com/databricks/cli/libs/cmdio" | ||
| "github.com/databricks/cli/libs/env" | ||
| "github.com/databricks/cli/libs/log" | ||
| "github.com/databricks/databricks-sdk-go" | ||
|
|
@@ -349,26 +348,38 @@ func ensureToolchain(ctx context.Context, home string) error { | |
| return nil | ||
| } | ||
|
|
||
| ui := newProgressUI(ctx) | ||
|
|
||
| // 1. uv (installs into ~/.local/bin). | ||
| if _, err := exec.LookPath("uv"); err != nil { | ||
| cmdio.LogString(ctx, "Installing uv...") | ||
| if err := runShell(ctx, "curl -LsSf https://astral.sh/uv/install.sh | sh"); err != nil { | ||
| return fmt.Errorf("failed to install uv: %w", err) | ||
| if err := ui.runStep(ctx, "Installing dependencies", func(out io.Writer) error { | ||
| if err := runShell(ctx, out, "curl -LsSf https://astral.sh/uv/install.sh | sh"); err != nil { | ||
| return fmt.Errorf("failed to install uv: %w", err) | ||
| } | ||
| return nil | ||
| }); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| // 2. Unity Gateway CLI (pinned stock upstream release). | ||
| if _, err := exec.LookPath("ucode"); err != nil { | ||
| cmdio.LogString(ctx, "Installing Unity Gateway CLI...") | ||
| if err := runCommand(ctx, "uv", "tool", "install", "git+https://github.com/"+ugRepo+"@"+ugVersion); err != nil { | ||
| return fmt.Errorf("failed to install Unity Gateway CLI: %w", err) | ||
| if err := ui.runStep(ctx, "Installing Unity Gateway CLI", func(out io.Writer) error { | ||
| if err := runCommand(ctx, out, "uv", "tool", "install", "git+https://github.com/"+ugRepo+"@"+ugVersion); err != nil { | ||
| return fmt.Errorf("failed to install Unity Gateway CLI: %w", err) | ||
| } | ||
| return nil | ||
| }); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
||
| // 3. Node/npm (installs into depsDir/node/bin) | ||
| if _, err := exec.LookPath("npm"); err != nil { | ||
| cmdio.LogString(ctx, "Installing npm...") | ||
| if _, err := ensureNode(ctx, home); err != nil { | ||
| if err := ui.runStep(ctx, "Installing Node.js", func(out io.Writer) error { | ||
| _, err := ensureNode(ctx, home, out) | ||
| return err | ||
| }); err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
@@ -469,9 +480,10 @@ func injectAgentContext(ctx context.Context, home string, agent agentSpec) ([]st | |
| } | ||
|
|
||
| // download the latest Krypton LTS Node into deps/node once, returning its bin. | ||
| // Linux-only by design: the shim runs on the serverless driver, so the tarball | ||
| // name is hardcoded to linux while nodeDownloadArch guards the arch. | ||
| func ensureNode(ctx context.Context, home string) (string, error) { | ||
| // Extraction output is written to out. Linux-only by design: the shim runs on | ||
| // the serverless driver, so the tarball name is hardcoded to linux while | ||
| // nodeDownloadArch guards the arch. | ||
| func ensureNode(ctx context.Context, home string, out io.Writer) (string, error) { | ||
| depsRoot := filepath.Join(home, depsDir) | ||
| nodeDir := filepath.Join(depsRoot, "node") | ||
| nodeBin := filepath.Join(nodeDir, "bin") | ||
|
|
@@ -506,7 +518,7 @@ func ensureNode(ctx context.Context, home string) (string, error) { | |
| } | ||
| defer os.RemoveAll(tmpDir) // no-op once renamed; cleans up a failed extraction | ||
| // Node's .tar.xz is the smallest download; extract it with the system tar. | ||
| if err := runCommand(ctx, "tar", "-xJf", tarball, "--strip-components=1", "-C", tmpDir); err != nil { | ||
| if err := runCommand(ctx, out, "tar", "-xJf", tarball, "--strip-components=1", "-C", tmpDir); err != nil { | ||
| return "", fmt.Errorf("failed to extract Node.js: %w", err) | ||
| } | ||
| // Clear any partial leftover from a previously-interrupted run, then publish | ||
|
|
@@ -608,19 +620,22 @@ func npmGlobalPrefix(ctx context.Context) string { | |
| return strings.TrimSpace(string(out)) | ||
| } | ||
|
|
||
| func runCommand(ctx context.Context, name string, args ...string) error { | ||
| // runCommand runs name with the given args, writing combined stdout+stderr to out | ||
| // (captured so it surfaces only on failure) and inheriting the process env. Stdin | ||
| // is left closed: the shim's install steps are non-interactive. | ||
| func runCommand(ctx context.Context, out io.Writer, name string, args ...string) error { | ||
| cmd := exec.CommandContext(ctx, name, args...) | ||
| cmd.Stdin = os.Stdin | ||
| cmd.Stdout = os.Stdout | ||
| cmd.Stderr = os.Stderr | ||
| cmd.Stdout = out | ||
| cmd.Stderr = out | ||
| return cmd.Run() | ||
| } | ||
|
|
||
| func runShell(ctx context.Context, script string) error { | ||
| // runShell runs a shell snippet — for the curl|sh / curl|tar pipelines — writing | ||
| // combined stdout+stderr to out. | ||
|
Comment on lines
+633
to
+634
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Nit] The new comment says runShell is for the curl|sh / curl|tar pipelines. The only caller is the uv Suggestion: Drop the curl|tar mention; say it runs the uv |
||
| func runShell(ctx context.Context, out io.Writer, script string) error { | ||
| cmd := exec.CommandContext(ctx, "sh", "-c", script) | ||
| cmd.Stdin = os.Stdin | ||
| cmd.Stdout = os.Stdout | ||
| cmd.Stderr = os.Stderr | ||
| cmd.Stdout = out | ||
| cmd.Stderr = out | ||
| return cmd.Run() | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package client | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "strings" | ||
|
|
||
| "github.com/charmbracelet/lipgloss" | ||
| "github.com/databricks/cli/libs/cmdio" | ||
| ) | ||
|
|
||
| // progressUI renders the bootstrap steps: each step spins while it runs and | ||
| // leaves a checkmark line when it finishes. Subprocess output is captured per | ||
| // step and printed only when that step fails. | ||
| type progressUI struct { | ||
| w io.Writer | ||
| check string // styled "✓" prefix for a finished step | ||
| cross string // styled "✗" prefix for a failed step | ||
| } | ||
|
|
||
| // newProgressUI builds a progress renderer writing its checkmark lines to stderr. | ||
| func newProgressUI(ctx context.Context) *progressUI { | ||
| // Mint the checkmark styles from a renderer targeting stderr so color handling | ||
| // stays centralized in cmdio.NewRenderer, matching cmdio's own spinner. | ||
| r, _ := cmdio.NewRenderer(ctx, os.Stderr) | ||
| return &progressUI{ | ||
| w: os.Stderr, | ||
| check: r.NewStyle().Foreground(lipgloss.Color("10")).Render("✓"), // green | ||
| cross: r.NewStyle().Foreground(lipgloss.Color("9")).Render("✗"), // red | ||
| } | ||
|
Comment on lines
+25
to
+33
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Outcome lines write to
Suggestion: Print the ✓/✗ line (and the failure dump) through cmdio, e.g. |
||
| } | ||
|
|
||
| // runStep shows a cmdio spinner labelled desc while fn runs, giving fn a writer | ||
| // that captures the step's subprocess output. On success it leaves a checkmark | ||
| // line; on failure it prints the captured output (stdout+stderr, in order) before | ||
| // returning fn's error. The shared spinner shows elapsed time and degrades to no | ||
| // output in a non-interactive terminal, so the checkmark line is what the reader | ||
| // sees either way. | ||
| func (ui *progressUI) runStep(ctx context.Context, desc string, fn func(out io.Writer) error) error { | ||
| sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime()) | ||
| // Close is idempotent; defer it so a panic in fn can't leave the spinner (and | ||
| // its tea-program slot) running, while the explicit Close below still controls | ||
| // output ordering on the normal path. | ||
| defer sp.Close() | ||
| sp.Update(desc) | ||
|
|
||
| var buf bytes.Buffer | ||
| err := fn(&buf) | ||
| // Stop the spinner (clearing its line) before printing the step's outcome. | ||
| sp.Close() | ||
|
|
||
| if err != nil { | ||
| fmt.Fprintln(ui.w, ui.cross+" "+desc) | ||
| if out := buf.String(); out != "" { | ||
| fmt.Fprint(ui.w, out) | ||
| if !strings.HasSuffix(out, "\n") { | ||
| fmt.Fprintln(ui.w) | ||
| } | ||
| } | ||
| return err | ||
| } | ||
|
|
||
| fmt.Fprintln(ui.w, ui.check+" "+desc) | ||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package client | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "errors" | ||
| "io" | ||
| "testing" | ||
|
|
||
| "github.com/databricks/cli/libs/cmdio" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // newTestProgressUI returns a progressUI writing to w with sentinel check/cross | ||
| // markers so the emitted outcome line is assertable. runStep is driven with a | ||
| // non-interactive cmdio context (the mode tests run in), where the spinner | ||
| // degrades to no output, exercising the capture-and-dump logic directly. | ||
| func newTestProgressUI(w io.Writer) *progressUI { | ||
| return &progressUI{w: w, check: "OK", cross: "FAIL"} | ||
| } | ||
|
|
||
| func TestRunStepHidesOutputOnSuccess(t *testing.T) { | ||
| var w bytes.Buffer | ||
| ui := newTestProgressUI(&w) | ||
|
|
||
| err := ui.runStep(cmdio.MockDiscard(t.Context()), "Installing dependencies", func(out io.Writer) error { | ||
| _, _ = io.WriteString(out, "verbose installer chatter\n") | ||
| return nil | ||
| }) | ||
| require.NoError(t, err) | ||
|
|
||
| // The step's subprocess output must not surface on success, but the checkmark | ||
| // line for the step is still emitted. | ||
| assert.NotContains(t, w.String(), "verbose installer chatter") | ||
| assert.Contains(t, w.String(), "OK Installing dependencies") | ||
| } | ||
|
|
||
| func TestRunStepShowsOutputOnFailure(t *testing.T) { | ||
| var w bytes.Buffer | ||
| ui := newTestProgressUI(&w) | ||
|
|
||
| sentinel := errors.New("install failed") | ||
| err := ui.runStep(cmdio.MockDiscard(t.Context()), "Installing ucode", func(out io.Writer) error { | ||
| _, _ = io.WriteString(out, "line to stdout\nline to stderr") | ||
| return sentinel | ||
| }) | ||
| require.ErrorIs(t, err, sentinel) | ||
|
|
||
| // On failure the cross line and the full captured output are printed, with a | ||
| // trailing newline added. | ||
| assert.Contains(t, w.String(), "FAIL Installing ucode") | ||
| assert.Contains(t, w.String(), "line to stdout\nline to stderr\n") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Nit] First step label is vaguer than the siblings and than the error it wraps
The uv step is shown as
"Installing dependencies"while the next two steps are"Installing Unity Gateway CLI"and"Installing Node.js". The old message was"Installing uv...", and the error returned from the same closure is stillfailed to install uv. On failure the user sees✗ Installing dependenciesfollowed byError: failed to install uv: ..., which names two different things. The other labels name the tool; this one does not.Suggestion: Use
"Installing uv"(or"Installing uv..."if you want to keep the old wording).