Skip to content

Commit ede3bdf

Browse files
robstolarzclaude
andcommitted
feat: add depot ci ssh command and interactive terminal for CI jobs
- Add `depot ci ssh <run-id>` command to connect to a running CI job's sandbox via interactive terminal, with polling for sandbox provisioning - Add `--info` flag to print SSH connection details (for agent/automation) - Add `--ssh` flag to `depot ci run` for run-then-connect flow - Update `depot ci status` to show sandbox IDs and ssh hints - Extract reusable `pkg/pty` package from `sandbox pty` command - Add `sandbox_id` and `session_id` to `AttemptStatus` proto Closes DEP-3852 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 388acf9 commit ede3bdf

File tree

11 files changed

+556
-219
lines changed

11 files changed

+556
-219
lines changed

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
golang 1.24.4
2+
buf 1.65.0

pkg/cmd/ci/ci.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func NewCmdCI() *cobra.Command {
1616
cmd.AddCommand(NewCmdMigrate())
1717
cmd.AddCommand(NewCmdRun())
1818
cmd.AddCommand(NewCmdSecrets())
19+
cmd.AddCommand(NewCmdSSH())
1920
cmd.AddCommand(NewCmdStatus())
2021
cmd.AddCommand(NewCmdVars())
2122

pkg/cmd/ci/run.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/depot/cli/pkg/api"
1414
"github.com/depot/cli/pkg/config"
1515
"github.com/depot/cli/pkg/helpers"
16+
"github.com/depot/cli/pkg/pty"
1617
civ1 "github.com/depot/cli/pkg/proto/depot/ci/v1"
1718
"github.com/spf13/cobra"
1819
"gopkg.in/yaml.v3"
@@ -27,6 +28,7 @@ func NewCmdRun() *cobra.Command {
2728
workflowPath string
2829
jobNames []string
2930
sshAfterStep int
31+
ssh bool
3032
)
3133

3234
cmd := &cobra.Command{
@@ -44,7 +46,10 @@ This command is in beta and subject to change.`,
4446
# Run specific jobs
4547
depot ci run --workflow .depot/workflows/ci.yml --job build --job test
4648
47-
# Debug with SSH after a specific step
49+
# Run a job and connect to its terminal via SSH
50+
depot ci run --workflow .depot/workflows/ci.yml --job build --ssh
51+
52+
# Debug with tmate after a specific step
4853
depot ci run --workflow .depot/workflows/ci.yml --job build --ssh-after-step 3`,
4954
RunE: func(cmd *cobra.Command, args []string) error {
5055
if workflowPath == "" {
@@ -57,6 +62,14 @@ This command is in beta and subject to change.`,
5762
return fmt.Errorf("--ssh-after-step requires exactly one --job")
5863
}
5964

65+
if ssh && len(jobNames) != 1 {
66+
return fmt.Errorf("--ssh requires exactly one --job")
67+
}
68+
69+
if ssh && sshAfterStep > 0 {
70+
return fmt.Errorf("--ssh and --ssh-after-step are mutually exclusive")
71+
}
72+
6073
if orgID == "" {
6174
orgID = config.GetCurrentOrganization()
6275
}
@@ -199,6 +212,20 @@ This command is in beta and subject to change.`,
199212
fmt.Printf("Org: %s\n", resp.OrgId)
200213
fmt.Printf("Run: %s\n", resp.RunId)
201214
fmt.Println()
215+
216+
if ssh {
217+
fmt.Printf("Waiting for job to start and connecting via SSH...\n")
218+
sandboxID, sessionID, err := waitForSandbox(ctx, tokenVal, orgID, resp.RunId, jobNames[0])
219+
if err != nil {
220+
return err
221+
}
222+
return pty.Run(ctx, pty.SessionOptions{
223+
Token: tokenVal,
224+
SandboxID: sandboxID,
225+
SessionID: sessionID,
226+
})
227+
}
228+
202229
fmt.Printf("Check status: depot ci status %s\n", resp.RunId)
203230
fmt.Printf("View in Depot: https://depot.dev/orgs/%s/workflows/%s\n", resp.OrgId, resp.RunId)
204231

@@ -211,6 +238,7 @@ This command is in beta and subject to change.`,
211238
cmd.Flags().StringVar(&workflowPath, "workflow", "", "Path to workflow YAML file")
212239
cmd.Flags().StringSliceVar(&jobNames, "job", nil, "Job name(s) to run (repeatable; omit to run all)")
213240
cmd.Flags().IntVar(&sshAfterStep, "ssh-after-step", 0, "1-based step index to insert a tmate debug step after (requires single --job)")
241+
cmd.Flags().BoolVar(&ssh, "ssh", false, "Start the run and connect to the job's sandbox via interactive terminal (requires single --job)")
214242

215243
cmd.AddCommand(NewCmdRunList())
216244

pkg/cmd/ci/ssh.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package ci
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"time"
10+
11+
"github.com/depot/cli/pkg/api"
12+
"github.com/depot/cli/pkg/config"
13+
"github.com/depot/cli/pkg/helpers"
14+
"github.com/depot/cli/pkg/pty"
15+
civ1 "github.com/depot/cli/pkg/proto/depot/ci/v1"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
func NewCmdSSH() *cobra.Command {
20+
var (
21+
orgID string
22+
token string
23+
job string
24+
info bool
25+
output string
26+
)
27+
28+
cmd := &cobra.Command{
29+
Use: "ssh <run-id>",
30+
Short: "Connect to a running CI job via interactive terminal [beta]",
31+
Long: `Open an interactive terminal session to the sandbox running a CI job.
32+
33+
If the job hasn't started yet, the command will wait for the sandbox to be provisioned.
34+
Use --info to print SSH connection details instead of connecting interactively.
35+
36+
This command is in beta and subject to change.`,
37+
Example: ` # Connect to a running job
38+
depot ci ssh <run-id> --job build
39+
40+
# Auto-select job when there's only one
41+
depot ci ssh <run-id>
42+
43+
# Print SSH connection details (for agents/automation)
44+
depot ci ssh <run-id> --job build --info
45+
46+
# Print SSH details as JSON
47+
depot ci ssh <run-id> --job build --info --output json`,
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
if len(args) == 0 {
50+
return cmd.Help()
51+
}
52+
53+
ctx := cmd.Context()
54+
runID := args[0]
55+
56+
if orgID == "" {
57+
orgID = config.GetCurrentOrganization()
58+
}
59+
60+
tokenVal, err := helpers.ResolveOrgAuth(ctx, token)
61+
if err != nil {
62+
return err
63+
}
64+
if tokenVal == "" {
65+
return fmt.Errorf("missing API token, please run `depot login`")
66+
}
67+
68+
sandboxID, sessionID, err := waitForSandbox(ctx, tokenVal, orgID, runID, job)
69+
if err != nil {
70+
return err
71+
}
72+
73+
if info {
74+
return printSSHInfo(sandboxID, sessionID, output)
75+
}
76+
77+
return pty.Run(ctx, pty.SessionOptions{
78+
Token: tokenVal,
79+
SandboxID: sandboxID,
80+
SessionID: sessionID,
81+
})
82+
},
83+
}
84+
85+
cmd.Flags().StringVar(&orgID, "org", "", "Organization ID (required when user is a member of multiple organizations)")
86+
cmd.Flags().StringVar(&token, "token", "", "Depot API token")
87+
cmd.Flags().StringVar(&job, "job", "", "Job key to connect to (required when run has multiple jobs)")
88+
cmd.Flags().BoolVar(&info, "info", false, "Print SSH connection details instead of connecting")
89+
cmd.Flags().StringVarP(&output, "output", "o", "", "Output format for --info (json)")
90+
91+
return cmd
92+
}
93+
94+
// waitForSandbox polls the CI run status until a sandbox_id is available for the
95+
// target job, or returns an error if the job has finished or doesn't exist.
96+
func waitForSandbox(ctx context.Context, token, orgID, runID, jobKey string) (sandboxID, sessionID string, err error) {
97+
const pollInterval = 2 * time.Second
98+
const timeout = 5 * time.Minute
99+
100+
deadline := time.Now().Add(timeout)
101+
first := true
102+
103+
for {
104+
if time.Now().After(deadline) {
105+
return "", "", fmt.Errorf("timed out waiting for sandbox to be provisioned (waited %s)", timeout)
106+
}
107+
108+
resp, err := api.CIGetRunStatus(ctx, token, orgID, runID)
109+
if err != nil {
110+
return "", "", fmt.Errorf("failed to get run status: %w", err)
111+
}
112+
113+
targetJob, err := findJob(resp, jobKey)
114+
if err != nil {
115+
// If no jobs exist yet or the target job hasn't appeared, keep polling.
116+
if isRetryableJobError(err) {
117+
if first {
118+
fmt.Fprintf(os.Stderr, "Waiting for job to be created...\n")
119+
first = false
120+
}
121+
select {
122+
case <-ctx.Done():
123+
return "", "", ctx.Err()
124+
case <-time.After(pollInterval):
125+
}
126+
continue
127+
}
128+
return "", "", err
129+
}
130+
131+
attempt := latestAttempt(targetJob)
132+
if attempt == nil {
133+
if first {
134+
fmt.Fprintf(os.Stderr, "Waiting for job %q to start...\n", targetJob.JobKey)
135+
first = false
136+
}
137+
} else {
138+
switch attempt.Status {
139+
case "finished", "failed", "cancelled":
140+
return "", "", fmt.Errorf("job %q has already completed (status: %s)", targetJob.JobKey, attempt.Status)
141+
default:
142+
sid := attempt.GetSandboxId()
143+
ssid := attempt.GetSessionId()
144+
if sid != "" && ssid != "" {
145+
fmt.Fprintf(os.Stderr, "Connecting to sandbox %s...\n", sid)
146+
return sid, ssid, nil
147+
}
148+
if first {
149+
fmt.Fprintf(os.Stderr, "Waiting for sandbox to be provisioned...\n")
150+
first = false
151+
}
152+
}
153+
}
154+
155+
select {
156+
case <-ctx.Done():
157+
return "", "", ctx.Err()
158+
case <-time.After(pollInterval):
159+
}
160+
}
161+
}
162+
163+
// retryableJobError is returned when jobs haven't been created yet.
164+
type retryableJobError struct{ msg string }
165+
166+
func (e *retryableJobError) Error() string { return e.msg }
167+
168+
func isRetryableJobError(err error) bool {
169+
var re *retryableJobError
170+
return errors.As(err, &re)
171+
}
172+
173+
// findJob locates the target job in the run status response.
174+
// If jobKey is empty and there's exactly one job, it returns that job.
175+
// If there are multiple jobs and no jobKey, it returns an error listing them.
176+
func findJob(resp *civ1.GetRunStatusResponse, jobKey string) (*civ1.JobStatus, error) {
177+
var allJobs []*civ1.JobStatus
178+
for _, wf := range resp.Workflows {
179+
allJobs = append(allJobs, wf.Jobs...)
180+
}
181+
182+
if len(allJobs) == 0 {
183+
return nil, &retryableJobError{msg: fmt.Sprintf("run %s has no jobs yet", resp.RunId)}
184+
}
185+
186+
if jobKey != "" {
187+
for _, j := range allJobs {
188+
if j.JobKey == jobKey {
189+
return j, nil
190+
}
191+
}
192+
// Job might not exist yet if workflows are still being expanded.
193+
return nil, &retryableJobError{msg: fmt.Sprintf("job %q not found yet", jobKey)}
194+
}
195+
196+
if len(allJobs) == 1 {
197+
return allJobs[0], nil
198+
}
199+
200+
keys := make([]string, len(allJobs))
201+
for i, j := range allJobs {
202+
keys[i] = fmt.Sprintf(" %s (%s)", j.JobKey, j.Status)
203+
}
204+
return nil, fmt.Errorf("run has multiple jobs, specify one with --job:\n%s", joinLines(keys))
205+
}
206+
207+
func latestAttempt(job *civ1.JobStatus) *civ1.AttemptStatus {
208+
if len(job.Attempts) == 0 {
209+
return nil
210+
}
211+
latest := job.Attempts[0]
212+
for _, a := range job.Attempts[1:] {
213+
if a.Attempt > latest.Attempt {
214+
latest = a
215+
}
216+
}
217+
return latest
218+
}
219+
220+
func joinLines(lines []string) string {
221+
result := ""
222+
for i, l := range lines {
223+
if i > 0 {
224+
result += "\n"
225+
}
226+
result += l
227+
}
228+
return result
229+
}
230+
231+
func printSSHInfo(sandboxID, sessionID, output string) error {
232+
if output == "json" {
233+
enc := json.NewEncoder(os.Stdout)
234+
enc.SetIndent("", " ")
235+
return enc.Encode(map[string]string{
236+
"host": "api.depot.dev",
237+
"sandbox_id": sandboxID,
238+
"session_id": sessionID,
239+
"ssh_command": fmt.Sprintf("ssh %s@api.depot.dev", sandboxID),
240+
})
241+
}
242+
243+
fmt.Printf("Host: api.depot.dev\n")
244+
fmt.Printf("User: %s\n", sandboxID)
245+
fmt.Printf("Password: Use your Depot API token ($DEPOT_TOKEN)\n")
246+
fmt.Println()
247+
fmt.Printf("Connect: ssh %s@api.depot.dev\n", sandboxID)
248+
return nil
249+
}

pkg/cmd/ci/status.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ func NewCmdStatus() *cobra.Command {
5858
fmt.Printf(" Job: %s [%s] (%s)\n", job.JobId, job.JobKey, job.Status)
5959

6060
for _, attempt := range job.Attempts {
61-
fmt.Printf(" Attempt: %s #%d (%s) → depot ci logs %s | https://depot.dev/orgs/%s/workflows/%s\n", attempt.AttemptId, attempt.Attempt, attempt.Status, attempt.AttemptId, resp.OrgId, attempt.AttemptId)
61+
line := fmt.Sprintf(" Attempt: %s #%d (%s)", attempt.AttemptId, attempt.Attempt, attempt.Status)
62+
if sid := attempt.GetSandboxId(); sid != "" {
63+
line += fmt.Sprintf(" sandbox: %s", sid)
64+
}
65+
line += fmt.Sprintf(" → depot ci logs %s | https://depot.dev/orgs/%s/workflows/%s", attempt.AttemptId, resp.OrgId, attempt.AttemptId)
66+
if attempt.GetSandboxId() != "" {
67+
line += fmt.Sprintf(" | depot ci ssh %s --job %s", resp.RunId, job.JobKey)
68+
}
69+
fmt.Println(line)
6270
}
6371
}
6472
}

0 commit comments

Comments
 (0)