-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun_command.go
More file actions
395 lines (381 loc) · 12.8 KB
/
Copy pathrun_command.go
File metadata and controls
395 lines (381 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/SecondStack-AI/SecondBox/internal/cliui"
"github.com/SecondStack-AI/SecondBox/pkg/contracts"
secondboxclient "github.com/SecondStack-AI/SecondBox/sdk/go/secondboxclient"
)
const defaultRunReadyTimeout = 5 * time.Minute
func runRunCommand(
ctx context.Context,
session cliSession,
args []string,
environment execCommandEnvironment,
terminal sandboxShellEnvironment,
) (resultErr error) {
profile, rest, err := splitLeadingOperand("run", "Profile", args)
if err != nil {
return err
}
flags := flag.NewFlagSet("run", flag.ContinueOnError)
flags.SetOutput(io.Discard)
name := flags.String("name", "", "reserved Sandbox name for later reference")
keep := flags.Bool("keep", false, "retain the Sandbox instead of deleting it")
shell := flags.Bool("shell", false, "treat the single operand as one shell command")
cwd := flags.String("cwd", "", "workspace-relative working directory")
var environmentValues repeatedValues
flags.Var(&environmentValues, "env", "environment name=value; repeatable")
var metadataValues repeatedValues
flags.Var(&metadataValues, "metadata", "Sandbox metadata name=value; repeatable")
deadline := flags.Duration("deadline", defaultExecDeadline, "command deadline")
maximumOutputBytes := flags.Int64(
"max-output-bytes", defaultExecOutputBytes, "maximum buffered output bytes",
)
readyTimeout := flags.Duration(
"ready-timeout", defaultRunReadyTimeout, "time allowed for the Sandbox to become ready",
)
tty := flags.Bool("tty", false, "attach an interactive Terminal instead of running one command")
forwardStdin := flags.Bool("stdin", false, "send standard input to the command")
emitJSON := flags.Bool("json", false, "write the raw ExecOutcome JSON instead of the output")
if err := flags.Parse(rest); err != nil {
return fmt.Errorf("SecondBox CLI parse run options: %w", err)
}
if err := requireSessionCredentials("run", session); err != nil {
return err
}
if *deadline < time.Millisecond {
return errors.New("SecondBox CLI run --deadline must be at least one millisecond")
}
if *maximumOutputBytes < 1 {
return errors.New("SecondBox CLI run --max-output-bytes must be positive")
}
if *readyTimeout < time.Second {
return errors.New("SecondBox CLI run --ready-timeout must be at least one second")
}
if *tty {
if *forwardStdin || *emitJSON || *shell {
return errors.New(
"SecondBox CLI run --tty cannot be combined with --stdin, --json, or --shell",
)
}
if len(flags.Args()) > 1 {
return errors.New(
"SecondBox CLI run --tty accepts at most one command operand",
)
}
}
var command secondboxclient.Command
if !*tty {
command, err = buildExecCommand(*shell, flags.Args())
if err != nil {
return err
}
}
values, err := parsePairs(environmentValues)
if err != nil {
return fmt.Errorf("SecondBox CLI run environment: %w", err)
}
metadata, err := parsePairs(metadataValues)
if err != nil {
return fmt.Errorf("SecondBox CLI run metadata: %w", err)
}
if *name != "" {
if _, reserved := metadata[contracts.SandboxNameMetadataKey]; reserved {
return fmt.Errorf(
"SecondBox CLI run cannot combine --name with metadata %s",
contracts.SandboxNameMetadataKey,
)
}
metadata[contracts.SandboxNameMetadataKey] = *name
}
if environment.httpClient == nil {
environment.httpClient = http.DefaultClient
}
client, err := secondboxclient.NewSecondBoxSubjectClient(
session.url, session.token, session.tenantRef, session.subjectRef, environment.httpClient,
)
if err != nil {
return err
}
if *tty {
return runInteractiveSandbox(
ctx, session, client, interactiveRequest{
profile: profile,
metadata: metadata,
operands: flags.Args(),
cwd: *cwd,
keep: *keep,
readyTimeout: *readyTimeout,
}, terminal, environment.stderr,
)
}
request := secondboxclient.RunRequest{
Profile: profile,
Metadata: metadata,
Command: command,
Environment: secondboxclient.StringMap(values),
DeadlineMilliseconds: deadline.Milliseconds(),
MaximumOutputBytes: *maximumOutputBytes,
}
if *cwd != "" {
workspacePath := secondboxclient.WorkspacePath(*cwd)
request.Cwd = &workspacePath
}
// Read standard input before creating anything, so an oversized input fails
// without leaving a Sandbox behind.
if *forwardStdin {
stdin, err := readExecStdin("run", environment.stdin)
if err != nil {
return err
}
request.StdinBase64 = stdin
}
// The SDK requires a deadline covering both becoming ready and running.
runContext, cancel := context.WithTimeout(ctx, *readyTimeout+*deadline)
defer cancel()
activity, err := startGuestStreamActivity(runContext, presentationFromContext(ctx, environment.stdout).renderer, "Create, schedule, and execute Sandbox")
if err != nil {
return err
}
handle, result, runErr := client.Run(runContext, request)
if handle != nil && !*keep {
defer func() {
cleanupErr := deleteRunSandbox(ctx, handle)
if resultErr == nil && cleanupErr == nil {
cleanupErr = writeRunCompletion(ctx, "Sandbox cleanup", "deleted")
}
resultErr = errors.Join(resultErr, cleanupErr)
}()
}
activityStatus, activityDetail := cliui.StatusComplete, "guest stream ready"
var executionFailure *secondboxclient.ExecFailure
if runErr != nil && !errors.As(runErr, &executionFailure) {
activityStatus, activityDetail = cliui.StatusFailed, "lifecycle failed"
}
if err := completeGuestStreamActivity(activity, activityStatus, activityDetail); err != nil {
return errors.Join(runErr, err)
}
if handle != nil && *keep {
if err := writeRetainedSandbox(ctx, environment.stderr, handle.Snapshot().ID); err != nil {
return err
}
}
// A transport or lifecycle failure has no outcome to render.
var failure *secondboxclient.ExecFailure
if runErr != nil && !errors.As(runErr, &failure) {
return runErr
}
if *emitJSON {
return writeExecOutcomeJSON(environment.stdout, result.Outcome)
}
return writeExecOutcome(environment, result.Outcome)
}
func writeRetainedSandbox(ctx context.Context, fallback io.Writer, sandboxID string) error {
if value, ok := ctx.Value(presentationContextKey{}).(presentation); ok {
if value.renderer.Capabilities.Diagnostic.TTY {
renderer := value.renderer
renderer.Output = fallback
renderer.Capabilities.Output = renderer.Capabilities.Diagnostic
return renderer.WritePhases([]cliui.Phase{{Name: "Retained Sandbox", Detail: sandboxID, Status: cliui.StatusComplete}})
}
}
_, err := fmt.Fprintf(fallback, "SecondBox retained Sandbox %s\n", sandboxID)
return err
}
func writeRunCompletion(ctx context.Context, name, detail string) error {
if value, ok := ctx.Value(presentationContextKey{}).(presentation); ok {
if !value.renderer.Capabilities.Diagnostic.TTY {
return nil
}
return value.renderer.WritePhases([]cliui.Phase{{Name: name, Detail: detail, Status: cliui.StatusComplete}})
}
return nil
}
// interactiveRequest is one ephemeral interactive Sandbox request.
type interactiveRequest struct {
profile string
metadata map[string]string
operands []string
cwd string
keep bool
readyTimeout time.Duration
}
// runInteractiveSandbox creates a Sandbox, attaches a Terminal to it, and
// disposes of it when the Terminal ends.
//
// Disposal runs on every exit, including a dropped connection, because the
// Sandbox exists only to serve this session. --keep opts out and reports the
// identifier so the session can be resumed with secondbox shell.
func runInteractiveSandbox(
ctx context.Context,
session cliSession,
client *secondboxclient.Client,
request interactiveRequest,
terminal sandboxShellEnvironment,
report io.Writer,
) (resultErr error) {
handle, _, err := client.CreateSandbox(ctx, secondboxclient.CreateSandboxRequest{
Profile: request.profile, Metadata: request.metadata,
}, "")
if err != nil {
return err
}
if !request.keep {
defer func() {
resultErr = errors.Join(resultErr, deleteRunSandbox(ctx, handle))
}()
}
readyContext, cancel := context.WithTimeout(ctx, request.readyTimeout)
defer cancel()
activity, err := startGuestStreamActivity(readyContext, presentationFromContext(ctx, terminal.output).renderer, "Create and attach Sandbox terminal")
if err != nil {
return err
}
if _, err := handle.WaitFor(readyContext, secondboxclient.SandboxStateReady); err != nil {
return errors.Join(err, completeGuestStreamActivity(activity, cliui.StatusFailed, "readiness failed"))
}
if err := completeGuestStreamActivity(activity, cliui.StatusComplete, "terminal ready"); err != nil {
return err
}
if request.keep {
if err := writeRetainedSandbox(ctx, report, handle.Snapshot().ID); err != nil {
return err
}
}
var rest []string
if len(request.operands) == 1 {
rest = append(rest, "--command", request.operands[0])
}
if request.cwd != "" {
rest = append(rest, "--cwd", request.cwd)
}
return attachSandboxTerminal(ctx, session, handle, rest, terminal, terminal.httpClient)
}
// deleteRunSandbox disposes of the Sandbox this command created. The SDK never
// deletes implicitly, so disposal is requested here and only here.
// runDisposeAttempts bounds the optimistic-concurrency retry below.
const runDisposeAttempts = 5
func deleteRunSandbox(
ctx context.Context,
handle *secondboxclient.SandboxHandle,
) error {
disposeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
var lastErr error
for range runDisposeAttempts {
sandbox, err := handle.Refresh(disposeContext)
if err != nil {
return fmt.Errorf("SecondBox CLI run refresh before delete: %w", err)
}
if !liveSandbox(sandbox) {
return nil
}
_, err = handle.Delete(disposeContext, secondboxclient.LifecycleOptions{
IfMatch: secondboxclient.RevisionETag(sandbox.Revision),
})
if err == nil {
return nil
}
// Reconciliation advances the revision while a Sandbox is managed, so a
// validator read a moment earlier can already be stale. That is the one
// failure worth re-reading for; everything else is reported as it is.
if secondboxclient.ProblemCodeOf(err) != secondboxclient.ProblemCodePreconditionFailed {
return fmt.Errorf("SecondBox CLI run delete Sandbox %s: %w", sandbox.ID, err)
}
lastErr = err
}
return fmt.Errorf(
"SecondBox CLI run delete Sandbox %s after %d attempts: %w",
handle.Snapshot().ID, runDisposeAttempts, lastErr,
)
}
func runShellCommand(
ctx context.Context,
session cliSession,
args []string,
environment sandboxShellEnvironment,
httpClient *http.Client,
) error {
reference, rest, err := splitLeadingOperand("shell", "Sandbox", args)
if err != nil {
return err
}
if err := requireSessionCredentials("shell", session); err != nil {
return err
}
if httpClient == nil {
httpClient = http.DefaultClient
}
client, err := secondboxclient.NewSecondBoxSubjectClient(
session.url, session.token, session.tenantRef, session.subjectRef, httpClient,
)
if err != nil {
return err
}
handle, err := resolveSandboxReference(ctx, client, reference)
if err != nil {
return err
}
return attachSandboxTerminal(ctx, session, handle, rest, environment, httpClient)
}
// attachSandboxTerminal supplies the values the terminal command would otherwise
// demand by hand, then delegates to it unchanged. Injected values precede the
// caller's own arguments, and the flag package keeps the last occurrence, so
// every injected value stays overridable.
func attachSandboxTerminal(
ctx context.Context,
session cliSession,
handle *secondboxclient.SandboxHandle,
rest []string,
environment sandboxShellEnvironment,
httpClient *http.Client,
) (resultErr error) {
sandbox := handle.Snapshot()
injected := []string{
"--sandbox", sandbox.ID,
"--generation", fmt.Sprintf("%d", sandbox.Generation),
}
if !suppliedFlag(rest, "lease") && !suppliedFlag(rest, "session") {
keeper, err := handle.KeepLease(ctx, shellLeaseDuration)
if err != nil {
return err
}
defer func() {
resultErr = errors.Join(resultErr, keeper.Close())
}()
injected = append(injected, "--lease", keeper.ID())
}
if !suppliedFlag(rest, "idempotency-key") && !suppliedFlag(rest, "session") {
key, err := secondboxclient.NewIdempotencyKey()
if err != nil {
return err
}
injected = append(injected, "--idempotency-key", key)
}
if environment.httpClient == nil {
environment.httpClient = httpClient
}
return runSandboxShellCommand(
ctx, session.url, session.token, session.tenantRef, session.subjectRef,
append(injected, rest...), environment,
)
}
const shellLeaseDuration = 5 * time.Minute
// suppliedFlag reports whether the caller already gave a flag, so an injected
// default is not acquired needlessly.
func suppliedFlag(args []string, name string) bool {
for _, arg := range args {
if arg == "-"+name || arg == "--"+name ||
strings.HasPrefix(arg, "-"+name+"=") || strings.HasPrefix(arg, "--"+name+"=") {
return true
}
}
return false
}