diff --git a/cmd/config/apply.go b/cmd/config/apply.go index 3cef7c5bf..b59316881 100644 --- a/cmd/config/apply.go +++ b/cmd/config/apply.go @@ -18,6 +18,7 @@ import ( "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/types" + pkgworkspace "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -143,7 +144,7 @@ func (cmd *ApplyCmd) resolveDockerPath() string { if cmd.DockerPath != "" { return cmd.DockerPath } - return workspace.DefaultDockerCommand + return pkgworkspace.DefaultDockerCommand } func (cmd *ApplyCmd) inspectRunningContainer( @@ -159,7 +160,7 @@ func (cmd *ApplyCmd) inspectRunningContainer( } containerDetails := &details[0] - if !strings.EqualFold(containerDetails.State.Status, workspace.ContainerStatusRunning) { + if !strings.EqualFold(containerDetails.State.Status, pkgworkspace.ContainerStatusRunning) { return nil, fmt.Errorf( "container %s is not running (status: %s)", cmd.Container, diff --git a/cmd/config/apply_test.go b/cmd/config/apply_test.go index 015dd9e12..7969e6994 100644 --- a/cmd/config/apply_test.go +++ b/cmd/config/apply_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" - "github.com/devsy-org/devsy/cmd/workspace" + "github.com/devsy-org/devsy/pkg/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/cmd/config/read.go b/cmd/config/read.go index 579889be6..60d8ab343 100644 --- a/cmd/config/read.go +++ b/cmd/config/read.go @@ -8,10 +8,10 @@ import ( "path/filepath" "github.com/devsy-org/devsy/cmd/flags" - "github.com/devsy-org/devsy/cmd/workspace" devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" "github.com/devsy-org/devsy/pkg/docker" + pkgworkspace "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -216,7 +216,7 @@ func (cmd *ReadCmd) resolveConfigFromContainer(ctx context.Context) ( string, error, ) { - dockerCommand := workspace.DefaultDockerCommand + dockerCommand := pkgworkspace.DefaultDockerCommand if cmd.DockerPath != "" { dockerCommand = cmd.DockerPath } @@ -262,9 +262,8 @@ func (cmd *ReadCmd) resolveConfigFromIDLabels(ctx context.Context) ( string, error, ) { - containerDetails, err := workspace.FindRunningContainer( - ctx, workspace.DefaultDockerCommand, "", cmd.IDLabels, - ) + runtime := pkgworkspace.NewDockerRuntime(nil, cmd.DockerPath) + containerDetails, err := runtime.FindRunning(ctx, "", cmd.IDLabels) if err != nil { return nil, "", err } diff --git a/cmd/internal/runusercommands.go b/cmd/internal/runusercommands.go index c3981b1af..efbdfe464 100644 --- a/cmd/internal/runusercommands.go +++ b/cmd/internal/runusercommands.go @@ -233,7 +233,7 @@ func (cmd *RunUserCommandsCmd) resolveDockerPath() string { if cmd.DockerPath != "" { return cmd.DockerPath } - return workspace.DefaultDockerCommand + return workspace2.DefaultDockerCommand } func (cmd *RunUserCommandsCmd) inspectRunningContainer( @@ -252,7 +252,7 @@ func (cmd *RunUserCommandsCmd) inspectRunningContainer( } containerDetails := &details[0] - if !strings.EqualFold(containerDetails.State.Status, workspace.ContainerStatusRunning) { + if !strings.EqualFold(containerDetails.State.Status, workspace2.ContainerStatusRunning) { errMsg := fmt.Sprintf( "container %s is not running (status: %s)", cmd.ContainerID, @@ -332,20 +332,17 @@ func (cmd *RunUserCommandsCmd) resolveContainer( } workspaceConfig := client.WorkspaceConfig() - dockerCommand := workspace.ResolveDockerCommand(workspaceConfig) - if cmd.DockerPath != "" { - dockerCommand = cmd.DockerPath - } + runtime := workspace2.NewDockerRuntime(workspaceConfig, cmd.DockerPath) - containerDetails, err := workspace.FindRunningContainer( - ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, + containerDetails, err := runtime.FindRunning( + ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, ) if err != nil { _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) return nil, nil, err } - result := workspace.LoadExecResult(workspaceConfig, containerDetails) + result := workspace2.LoadExecResult(workspaceConfig, containerDetails) if result == nil || result.MergedConfig == nil { _ = devcconfig.WriteErrorJSON( os.Stderr, @@ -369,10 +366,10 @@ func (cmd *RunUserCommandsCmd) resolveContainer( params := &workspace.LifecycleExecParams{ Ctx: ctx, - Helper: &docker.DockerHelper{DockerCommand: dockerCommand}, + Helper: &docker.DockerHelper{DockerCommand: runtime.DockerCommand()}, ContainerID: containerDetails.ID, EnvArgs: envArgs, - Workdir: workspace.ResolveExecWorkdir(result, client.Workspace()), + Workdir: workspace2.ResolveExecWorkdir(result, client.Workspace()), User: devcconfig.GetRemoteUser(result), } return params, result, nil diff --git a/cmd/mcp/buffer.go b/cmd/mcp/buffer.go new file mode 100644 index 000000000..6557f2cf8 --- /dev/null +++ b/cmd/mcp/buffer.go @@ -0,0 +1,51 @@ +package mcp + +import "fmt" + +// BoundedBuffer is an io.Writer that keeps only the last cap bytes written. +// Tail retention beats mid-truncation for command output because endings carry +// exit status, errors, and final state. +type BoundedBuffer struct { + cap int + buf []byte + written int64 +} + +// NewBoundedBuffer returns a BoundedBuffer with the given capacity (minimum 64). +func NewBoundedBuffer(cap int) *BoundedBuffer { + if cap < 64 { + cap = 64 + } + return &BoundedBuffer{cap: cap, buf: make([]byte, 0, cap)} +} + +func (b *BoundedBuffer) Write(p []byte) (int, error) { + n := len(p) + b.written += int64(n) + if len(p) >= b.cap { + // Incoming chunk fills or exceeds cap — keep only the last cap bytes. + b.buf = append(b.buf[:0], p[len(p)-b.cap:]...) + return n, nil + } + if len(b.buf)+len(p) > b.cap { + drop := len(b.buf) + len(p) - b.cap + b.buf = b.buf[drop:] + } + b.buf = append(b.buf, p...) + return n, nil +} + +// Truncated reports whether more bytes were written than the buffer can hold. +func (b *BoundedBuffer) Truncated() bool { return b.written > int64(b.cap) } + +// BytesWritten returns the total number of bytes written, including dropped ones. +func (b *BoundedBuffer) BytesWritten() int64 { return b.written } + +// String returns the buffered content. When truncated, a marker showing how +// many bytes were dropped is prepended so callers know output is incomplete. +func (b *BoundedBuffer) String() string { + if !b.Truncated() { + return string(b.buf) + } + return fmt.Sprintf("... [%d bytes dropped] ...\n%s", b.written-int64(len(b.buf)), b.buf) +} diff --git a/cmd/mcp/buffer_test.go b/cmd/mcp/buffer_test.go new file mode 100644 index 000000000..94fd838e8 --- /dev/null +++ b/cmd/mcp/buffer_test.go @@ -0,0 +1,87 @@ +package mcp + +import ( + "strings" + "testing" +) + +func TestBoundedBuffer_NoTruncation(t *testing.T) { + b := NewBoundedBuffer(100) + _, _ = b.Write([]byte("hello")) + if got := b.String(); got != "hello" { + t.Fatalf("got %q", got) + } + if b.Truncated() { + t.Fatal("expected not truncated") + } +} + +func TestBoundedBuffer_TruncatesHead(t *testing.T) { + // Write 130 bytes into a cap-64 buffer: only the last 64 bytes (tail) should survive. + b := NewBoundedBuffer(64) + _, _ = b.Write([]byte(strings.Repeat("a", 66) + strings.Repeat("b", 64))) + s := b.String() + if !b.Truncated() { + t.Fatal("expected truncated") + } + if !strings.Contains(s, "bytes dropped") { + t.Fatalf("missing drop marker: %q", s) + } + // The tail (last 64 bytes, all 'b') should be preserved. + if !strings.HasSuffix(s, strings.Repeat("b", 64)) { + t.Fatalf("tail not preserved: %q", s) + } + // The head ('a' bytes) should be dropped. + if strings.Contains(s, "aaaa") { + t.Fatalf("head should be dropped but was retained: %q", s) + } +} + +func TestBoundedBuffer_MultipleWritesAccumulate(t *testing.T) { + b := NewBoundedBuffer(64) // min cap is 64 + for range 5 { + _, _ = b.Write([]byte(strings.Repeat("x", 20))) + } + // 100 bytes written into cap 64: should be truncated. + if !b.Truncated() { + t.Fatal("expected truncated after 100 bytes into cap 64") + } +} + +func TestBoundedBuffer_TailPreservedAcrossSmallWrites(t *testing.T) { + // Write many small chunks; the buffer should hold the most recent cap bytes. + b := NewBoundedBuffer(64) + for i := range 200 { + _, _ = b.Write([]byte{byte('a' + (i % 26))}) + } + if !b.Truncated() { + t.Fatal("expected truncated") + } + s := b.String() + // The last 64 chars of the 200-char sequence. + var want strings.Builder + for i := 136; i < 200; i++ { + want.WriteString(string([]byte{byte('a' + (i % 26))})) + } + if !strings.HasSuffix(s, want.String()) { + t.Fatalf( + "tail not preserved: got suffix %q, want %q", + s[len(s)-len(want.String()):], + want.String(), + ) + } +} + +func TestBoundedBuffer_LargeChunkOverwrite(t *testing.T) { + // A single write larger than cap should keep only the last cap bytes. + b := NewBoundedBuffer(64) + data := strings.Repeat("a", 30) + strings.Repeat("b", 64) + _, _ = b.Write([]byte(data)) + s := b.String() + if !b.Truncated() { + t.Fatal("expected truncated") + } + if !strings.HasSuffix(s, strings.Repeat("b", 64)) { + t.Fatalf("last cap bytes not preserved: %q", s) + } +} diff --git a/cmd/mcp/errors.go b/cmd/mcp/errors.go new file mode 100644 index 000000000..a06ee802b --- /dev/null +++ b/cmd/mcp/errors.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "errors" + + cliErrors "github.com/devsy-org/devsy/pkg/errors" + "github.com/devsy-org/devsy/pkg/workspace" +) + +// ErrorPayload is the JSON shape attached to MCP tool errors so the agent gets +// the same structured information the Devsy CLI shows humans (code, hint, doc URL). +type ErrorPayload struct { + Code string `json:"code"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + DocURL string `json:"doc_url,omitempty"` +} + +// ClassifyError converts any error returned by an MCP handler into a structured +// payload using the same classifier the CLI uses. +func ClassifyError(err error) ErrorPayload { + if err == nil { + return ErrorPayload{} + } + classified := cliErrors.Classify(err, cliErrors.ClassifyContext{}) + code := "internal_error" + if classified.Code != "" { + code = string(classified.Code) + } + if errors.Is(err, workspace.ErrWorkspaceNotFound) { + code = "workspace_not_found" + } + return ErrorPayload{ + Code: code, + Message: err.Error(), + Hint: classified.Hint, + DocURL: classified.DocURL, + } +} diff --git a/cmd/mcp/helpers.go b/cmd/mcp/helpers.go new file mode 100644 index 000000000..fffea4d2a --- /dev/null +++ b/cmd/mcp/helpers.go @@ -0,0 +1,12 @@ +package mcp + +import sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + +// opResultHandler is the standard (errorResult | opOK) wrapper for void operations. +// It calls fn and returns either an error result or opOK{OK: true}. +func opResultHandler(fn func() error) (*sdkmcp.CallToolResult, opOK, error) { + if err := fn(); err != nil { + return errorResult(err), opOK{}, nil + } + return nil, opOK{OK: true}, nil +} diff --git a/cmd/mcp/helpers_test.go b/cmd/mcp/helpers_test.go new file mode 100644 index 000000000..1874bb7a8 --- /dev/null +++ b/cmd/mcp/helpers_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "errors" + "testing" +) + +func TestOpResultHandler_Success(t *testing.T) { + result, ok, err := opResultHandler(func() error { return nil }) + if err != nil { + t.Fatalf("unexpected go error: %v", err) + } + if result != nil { + t.Fatalf("success path must return nil *CallToolResult, got %+v", result) + } + if !ok.OK { + t.Fatalf("success path must report ok.OK=true") + } +} + +func TestOpResultHandler_Error(t *testing.T) { + sentinel := errors.New("boom") + result, ok, err := opResultHandler(func() error { return sentinel }) + if err != nil { + t.Fatalf( + "opResultHandler must always return nil go-error so the SDK uses our *CallToolResult, got %v", + err, + ) + } + if result == nil { + t.Fatalf( + "error path must return a non-nil *CallToolResult so the SDK reports IsError to the client", + ) + } + if !result.IsError { + t.Fatalf("error path must mark the result IsError=true") + } + if ok.OK { + t.Fatalf("error path must leave ok zero") + } +} diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go new file mode 100644 index 000000000..d1629410e --- /dev/null +++ b/cmd/mcp/mcp.go @@ -0,0 +1,16 @@ +package mcp + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +// NewMCPCmd builds the 'devsy mcp' parent command. +func NewMCPCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run Devsy as a Model Context Protocol server", + } + cmd.AddCommand(NewServeCmd(globalFlags)) + return cmd +} diff --git a/cmd/mcp/recover.go b/cmd/mcp/recover.go new file mode 100644 index 000000000..fdcf5a19a --- /dev/null +++ b/cmd/mcp/recover.go @@ -0,0 +1,33 @@ +package mcp + +import ( + "context" + "fmt" + "runtime/debug" + + "github.com/devsy-org/devsy/pkg/log" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// safeHandler wraps a typed MCP handler so a panic in one tool doesn't kill +// the server. Recovered panics are logged with a stack and returned as a tool error. +func safeHandler[In any, Out any]( + inner func(context.Context, *sdkmcp.CallToolRequest, In) (*sdkmcp.CallToolResult, Out, error), +) func(context.Context, *sdkmcp.CallToolRequest, In) (*sdkmcp.CallToolResult, Out, error) { + return func( + ctx context.Context, req *sdkmcp.CallToolRequest, in In, + ) (result *sdkmcp.CallToolResult, out Out, err error) { + defer func() { + if r := recover(); r != nil { + log.Errorf("mcp handler panic: %v\n%s", r, debug.Stack()) + // Return a non-nil Go error so the SDK takes over with SetError. + // Returning a custom result here would lose its StructuredContent + // when the SDK marshals the (zero) typed Out over it. + result = nil + out = *new(Out) + err = fmt.Errorf("handler panicked: %v", r) + } + }() + return inner(ctx, req, in) + } +} diff --git a/cmd/mcp/serve.go b/cmd/mcp/serve.go new file mode 100644 index 000000000..7f3ddcedb --- /dev/null +++ b/cmd/mcp/serve.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "context" + "os" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/version" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +// ServeCmd holds configuration for `devsy mcp serve`. +type ServeCmd struct { + *flags.GlobalFlags + + ExecTimeoutDefault time.Duration + ExecTimeoutMax time.Duration + ExecOutputCap int +} + +// NewServeCmd builds the `serve` subcommand. +func NewServeCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &ServeCmd{GlobalFlags: globalFlags} + cobraCmd := &cobra.Command{ + Use: "serve", + Short: "Run an MCP server over stdio", + Args: cobra.NoArgs, + RunE: func(cobraCmd *cobra.Command, _ []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + cobraCmd.Flags().DurationVar(&cmd.ExecTimeoutDefault, "exec-timeout-default", 5*time.Minute, + "Default timeout for workspace_exec calls") + cobraCmd.Flags().DurationVar(&cmd.ExecTimeoutMax, "exec-timeout-max", 30*time.Minute, + "Maximum timeout for workspace_exec calls (caller values are clamped)") + cobraCmd.Flags().IntVar(&cmd.ExecOutputCap, "exec-output-cap", 100*1024, + "Per-stream byte cap for workspace_exec output; excess is replaced with a truncation marker") + return cobraCmd +} + +// Run wires up the MCP server and serves over stdio until ctx is cancelled. +func (cmd *ServeCmd) Run(ctx context.Context) error { + log.Debugf("starting MCP server (timeout default=%s max=%s cap=%dB)", + cmd.ExecTimeoutDefault, cmd.ExecTimeoutMax, cmd.ExecOutputCap) + + // Reserve real stdout for the JSON-RPC frame; redirect os.Stdout to stderr + // so any stray write elsewhere in the process can't corrupt the transport. + realStdout := os.Stdout + os.Stdout = os.Stderr + defer func() { os.Stdout = realStdout }() + + transport := &sdkmcp.IOTransport{ + Reader: os.Stdin, + Writer: realStdout, + } + + server := sdkmcp.NewServer(&sdkmcp.Implementation{ + Name: "devsy", + Version: version.GetVersion(), + }, nil) + + cmd.registerTools(server) + + return server.Run(ctx, transport) +} + +func (cmd *ServeCmd) registerTools(s *sdkmcp.Server) { + registerWorkspaceTools(s, cmd.GlobalFlags) + registerExecTool(s, cmd) + registerProviderTools(s, cmd.GlobalFlags) +} diff --git a/cmd/mcp/serve_test.go b/cmd/mcp/serve_test.go new file mode 100644 index 000000000..d7ce8388c --- /dev/null +++ b/cmd/mcp/serve_test.go @@ -0,0 +1,59 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestServer_ListsAllTools(t *testing.T) { + home := t.TempDir() + t.Setenv("DEVSY_HOME", home) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + server := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "devsy-test", Version: "test"}, nil) + g := &flags.GlobalFlags{} + serveCmd := &ServeCmd{GlobalFlags: g, ExecOutputCap: 1024} + serveCmd.registerTools(server) + + clientTransport, serverTransport := sdkmcp.NewInMemoryTransports() + + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run(ctx, serverTransport) + }() + + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "test-client", Version: "0"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = session.Close() }) + + tools, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + wantNames := []string{ + "workspace_list", "workspace_status", "workspace_start", "workspace_stop", + "workspace_delete", "workspace_create", "workspace_exec", + "provider_list", "provider_add", "provider_delete", "provider_use", + } + have := map[string]bool{} + for _, tool := range tools.Tools { + have[tool.Name] = true + } + for _, name := range wantNames { + if !have[name] { + t.Errorf("missing tool: %s", name) + } + } + if len(tools.Tools) != len(wantNames) { + t.Errorf("expected %d tools, got %d: %+v", len(wantNames), len(tools.Tools), have) + } +} diff --git a/cmd/mcp/tools_exec.go b/cmd/mcp/tools_exec.go new file mode 100644 index 000000000..c68ff5fb3 --- /dev/null +++ b/cmd/mcp/tools_exec.go @@ -0,0 +1,98 @@ +package mcp + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/devsy-org/devsy/pkg/workspace" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// durationToSeconds rounds up so any non-zero sub-second value becomes at +// least 1s instead of truncating to 0 and falling through to defaults. +func durationToSeconds(d time.Duration) int { + if d <= 0 { + return 0 + } + return int(math.Ceil(d.Seconds())) +} + +type execInput struct { + Name string `json:"name" jsonschema:"required"` + Command []string `json:"command" jsonschema:"required"` + Workdir string `json:"workdir,omitempty"` + Env map[string]string `json:"env,omitempty"` + IDLabels []string `json:"id_labels,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +type execOutput struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + DurationMS int64 `json:"duration_ms"` + Truncated bool `json:"truncated"` + TimedOut bool `json:"timed_out,omitempty"` + Clamped bool `json:"clamped,omitempty"` + // Error rides inside execOutput because the SDK overwrites + // CallToolResult.StructuredContent with the marshalled typed output, which + // would otherwise drop the classified payload on partial-output failures. + Error *ErrorPayload `json:"error,omitempty"` +} + +func registerExecTool(s *sdkmcp.Server, cmd *ServeCmd) { + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_exec", + Description: "Run a one-shot command in a workspace container. Output is capped " + + "per stream; excess is truncated in the middle. The command is argv, not a shell string.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in execInput, + ) (*sdkmcp.CallToolResult, execOutput, error) { + if in.Name == "" { + return errorResult(fmt.Errorf("name is required")), execOutput{}, nil + } + if len(in.Command) == 0 { + return errorResult(fmt.Errorf("command is required")), execOutput{}, nil + } + stdout := NewBoundedBuffer(cmd.ExecOutputCap) + stderr := NewBoundedBuffer(cmd.ExecOutputCap) + + res, err := workspace.ExecOneShot(ctx, workspace.ExecOneShotOptions{ + WorkspaceName: in.Name, + Command: in.Command, + Workdir: in.Workdir, + Env: in.Env, + IDLabels: in.IDLabels, + TimeoutSeconds: in.TimeoutSeconds, + TimeoutSecondsDefault: durationToSeconds(cmd.ExecTimeoutDefault), + TimeoutSecondsMax: durationToSeconds(cmd.ExecTimeoutMax), + Owner: cmd.Owner, + Context: cmd.Context, + Provider: cmd.Provider, + Stdout: stdout, + Stderr: stderr, + }) + // Populate output from whatever was captured. A cancelled or timed-out + // exec may still have written partial stdout/stderr that's useful to + // the caller, so read the buffers unconditionally. + out := execOutput{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Truncated: stdout.Truncated() || stderr.Truncated(), + } + if res != nil { + out.ExitCode = res.ExitCode + out.DurationMS = res.DurationMS + out.TimedOut = res.TimedOut + out.Clamped = res.Clamped + } + if err != nil { + payload := ClassifyError(err) + out.Error = &payload + return errorResult(err), out, nil + } + return nil, out, nil + })) +} diff --git a/cmd/mcp/tools_provider.go b/cmd/mcp/tools_provider.go new file mode 100644 index 000000000..ccc1667ef --- /dev/null +++ b/cmd/mcp/tools_provider.go @@ -0,0 +1,150 @@ +package mcp + +import ( + "context" + "fmt" + "sort" + + "github.com/devsy-org/devsy/cmd/flags" + cmdprovider "github.com/devsy-org/devsy/cmd/provider" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/workspace" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type providerSummary struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Default bool `json:"default,omitempty"` +} + +type providerListOutput struct { + Providers []providerSummary `json:"providers"` +} + +type providerAddInput struct { + Source string `json:"source" jsonschema:"required"` + Name string `json:"name,omitempty"` + Options map[string]string `json:"options,omitempty"` + Use bool `json:"use,omitempty"` +} + +type providerNameInput struct { + Name string `json:"name" jsonschema:"required"` +} + +func registerProviderTools(s *sdkmcp.Server, g *flags.GlobalFlags) { + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "provider_list", + Description: "List configured Devsy providers.", + }, safeHandler(func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ struct{}, + ) (*sdkmcp.CallToolResult, providerListOutput, error) { + out, err := handleProviderList(ctx, g) + if err != nil { + return errorResult(err), providerListOutput{}, nil + } + return nil, out, nil + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "provider_add", + Description: "Add a provider from a source (registry name, URL, or local path).", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in providerAddInput, + ) (*sdkmcp.CallToolResult, opOK, error) { + if in.Source == "" { + return errorResult(fmt.Errorf("source is required")), opOK{}, nil + } + return opResultHandler(func() error { return runProviderAdd(ctx, g, in) }) + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "provider_delete", + Description: "Delete a configured provider.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in providerNameInput, + ) (*sdkmcp.CallToolResult, opOK, error) { + if in.Name == "" { + return errorResult(fmt.Errorf("name is required")), opOK{}, nil + } + return opResultHandler(func() error { return runProviderDelete(ctx, g, in.Name) }) + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "provider_use", + Description: "Set a provider as the default for new workspaces.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in providerNameInput, + ) (*sdkmcp.CallToolResult, opOK, error) { + if in.Name == "" { + return errorResult(fmt.Errorf("name is required")), opOK{}, nil + } + return opResultHandler(func() error { return runProviderUse(ctx, g, in.Name) }) + })) +} + +func handleProviderList(_ context.Context, g *flags.GlobalFlags) (providerListOutput, error) { + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return providerListOutput{}, err + } + + providers, err := workspace.LoadAllProviders(devsyConfig) + if err != nil { + return providerListOutput{}, err + } + + defaultProvider := devsyConfig.Current().DefaultProvider + + summaries := make([]providerSummary, 0, len(providers)) + for _, entry := range providers { + summaries = append(summaries, providerSummary{ + Name: entry.Config.Name, + Version: entry.Config.Version, + Default: entry.Config.Name == defaultProvider, + }) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].Name < summaries[j].Name + }) + + return providerListOutput{Providers: summaries}, nil +} + +func runProviderAdd(ctx context.Context, g *flags.GlobalFlags, in providerAddInput) error { + args := []string{} + if in.Name != "" { + args = append(args, fmt.Sprintf("--name=%s", in.Name)) + } + if in.Use { + args = append(args, "--use") + } + for k, v := range in.Options { + // Use --flag=value single-token form so a key starting with "-" cannot + // be reparsed by pflag as a separate flag. + args = append(args, fmt.Sprintf("--option=%s=%s", k, v)) + } + args = append(args, "--") + args = append(args, in.Source) + + cobraCmd := cmdprovider.NewAddCmd(g) + cobraCmd.SetArgs(args) + cobraCmd.SetContext(ctx) + return cobraCmd.Execute() +} + +func runProviderDelete(ctx context.Context, g *flags.GlobalFlags, name string) error { + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return err + } + return cmdprovider.DeleteProvider(ctx, devsyConfig, name, false, false) +} + +func runProviderUse(_ context.Context, g *flags.GlobalFlags, name string) error { + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return err + } + return cmdprovider.UseProvider(devsyConfig, name) +} diff --git a/cmd/mcp/tools_workspace.go b/cmd/mcp/tools_workspace.go new file mode 100644 index 000000000..c6d5af842 --- /dev/null +++ b/cmd/mcp/tools_workspace.go @@ -0,0 +1,269 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + up "github.com/devsy-org/devsy/cmd/workspace/up" + client2 "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/workspace" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type workspaceSummary struct { + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + IDE string `json:"ide,omitempty"` + Source string `json:"source,omitempty"` + LastUsed string `json:"last_used,omitempty"` +} + +type ( + workspaceListInput struct{} + workspaceListOutput struct { + Workspaces []workspaceSummary `json:"workspaces"` + } +) + +type workspaceStatusInput struct { + Name string `json:"name" jsonschema:"required"` +} + +func registerWorkspaceTools(s *sdkmcp.Server, g *flags.GlobalFlags) { + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_list", + Description: "List all Devsy workspaces.", + }, safeHandler(func(ctx context.Context, _ *sdkmcp.CallToolRequest, _ workspaceListInput, + ) (*sdkmcp.CallToolResult, workspaceListOutput, error) { + out, err := handleWorkspaceList(ctx, g) + if err != nil { + return errorResult(err), workspaceListOutput{}, nil + } + return nil, out, nil + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_status", + Description: "Get detailed status for a workspace by name.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in workspaceStatusInput, + ) (*sdkmcp.CallToolResult, any, error) { + out, err := handleWorkspaceStatus(ctx, g, in.Name) + if err != nil { + return errorResult(err), nil, nil + } + return nil, out, nil + })) + + registerWorkspaceLifecycleTools(s, g) +} + +func handleWorkspaceList(ctx context.Context, g *flags.GlobalFlags) (workspaceListOutput, error) { + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return workspaceListOutput{}, err + } + entries, err := workspace.List(ctx, devsyConfig, false, g.Owner) + if err != nil { + return workspaceListOutput{}, err + } + summaries := make([]workspaceSummary, 0, len(entries)) + for _, e := range entries { + summaries = append(summaries, workspaceSummary{ + Name: e.ID, + Provider: e.Provider.Name, + IDE: e.IDE.Name, + Source: e.Source.String(), + LastUsed: e.LastUsedTimestamp.Format(time.RFC3339), + }) + } + return workspaceListOutput{Workspaces: summaries}, nil +} + +func handleWorkspaceStatus(ctx context.Context, g *flags.GlobalFlags, name string) (any, error) { + if name == "" { + return nil, fmt.Errorf("name is required") + } + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return nil, err + } + client, err := workspace.Get(ctx, workspace.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{name}, + Owner: g.Owner, + }) + if err != nil { + return nil, err + } + return client.WorkspaceConfig(), nil +} + +// errorResult builds an isError CallToolResult carrying the classified payload. +// The raw error is logged so operators can see the unclassified failure detail. +func errorResult(err error) *sdkmcp.CallToolResult { + log.Errorf("mcp tool error: %v", err) + payload := ClassifyError(err) + return &sdkmcp.CallToolResult{ + IsError: true, + Content: []sdkmcp.Content{&sdkmcp.TextContent{Text: payload.Message}}, + StructuredContent: payload, + } +} + +type nameInput struct { + Name string `json:"name" jsonschema:"required"` + Force bool `json:"force,omitempty"` +} + +type opOK struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} + +type createInput struct { + Source string `json:"source" jsonschema:"required"` + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` + IDE string `json:"ide,omitempty"` + DevcontainerPath string `json:"devcontainer_path,omitempty"` +} + +func registerWorkspaceLifecycleTools(s *sdkmcp.Server, g *flags.GlobalFlags) { + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_start", + Description: "Start (or resume) an existing workspace by name.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in nameInput, + ) (*sdkmcp.CallToolResult, opOK, error) { + if in.Name == "" { + return errorResult(fmt.Errorf("name is required")), opOK{}, nil + } + return opResultHandler(func() error { return startWorkspace(ctx, g, in.Name) }) + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_stop", + Description: "Stop a running workspace by name.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in nameInput, + ) (*sdkmcp.CallToolResult, opOK, error) { + if in.Name == "" { + return errorResult(fmt.Errorf("name is required")), opOK{}, nil + } + return opResultHandler(func() error { return stopWorkspace(ctx, g, in.Name) }) + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_delete", + Description: "Delete a workspace by name. Pass force=true to force-delete even if not found remotely.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in nameInput, + ) (*sdkmcp.CallToolResult, opOK, error) { + if in.Name == "" { + return errorResult(fmt.Errorf("name is required")), opOK{}, nil + } + return opResultHandler(func() error { return deleteWorkspace(ctx, g, in.Name, in.Force) }) + })) + + sdkmcp.AddTool(s, &sdkmcp.Tool{ + Name: "workspace_create", + Description: "Create and start a new workspace from a git URL, local path, or container image.", + }, safeHandler(func( + ctx context.Context, _ *sdkmcp.CallToolRequest, in createInput, + ) (*sdkmcp.CallToolResult, any, error) { + out, err := createWorkspace(ctx, g, in) + if err != nil { + return errorResult(err), nil, nil + } + return nil, out, nil + })) +} + +func startWorkspace(ctx context.Context, g *flags.GlobalFlags, name string) error { + // RunFromOptions creates the workspace if the name doesn't resolve; + // pre-check existence to keep workspace_start lookup-only. A TOCTOU race + // with a concurrent delete surfaces as an actionable error. + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return err + } + if _, err := workspace.Get(ctx, workspace.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{name}, + Owner: g.Owner, + }); err != nil { + return err + } + return runUp(ctx, g, createInput{Source: name}) +} + +func stopWorkspace(ctx context.Context, g *flags.GlobalFlags, name string) error { + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return err + } + client, err := workspace.Get(ctx, workspace.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{name}, + Owner: g.Owner, + }) + if err != nil { + return err + } + return client.Stop(ctx, client2.StopOptions{}) +} + +func deleteWorkspace(ctx context.Context, g *flags.GlobalFlags, name string, force bool) error { + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return err + } + _, err = workspace.Delete(ctx, workspace.DeleteOptions{ + DevsyConfig: devsyConfig, + Args: []string{name}, + Force: force, + Owner: g.Owner, + }) + return err +} + +func createWorkspace(ctx context.Context, g *flags.GlobalFlags, in createInput) (any, error) { + if in.Source == "" { + return nil, fmt.Errorf("source is required") + } + if err := runUp(ctx, g, in); err != nil { + return nil, err + } + devsyConfig, err := config.LoadConfig(g.Context, g.Provider) + if err != nil { + return nil, err + } + lookup := in.Name + if lookup == "" { + lookup = in.Source + } + client, err := workspace.Get(ctx, workspace.GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{lookup}, + Owner: g.Owner, + }) + if err != nil { + return nil, err + } + return client.WorkspaceConfig(), nil +} + +func runUp(ctx context.Context, g *flags.GlobalFlags, in createInput) error { + return up.RunFromOptions(ctx, g, up.Options{ + Source: in.Source, + Name: in.Name, + Provider: in.Provider, + IDE: in.IDE, + DevcontainerPath: in.DevcontainerPath, + }) +} diff --git a/cmd/provider/use.go b/cmd/provider/use.go index d698b20d0..67903547b 100644 --- a/cmd/provider/use.go +++ b/cmd/provider/use.go @@ -11,6 +11,20 @@ import ( "github.com/spf13/cobra" ) +// UseProvider sets the named provider as the default for the active config context. +func UseProvider(devsyConfig *config.Config, name string) error { + p, err := workspace.FindProvider(devsyConfig, name) + if err != nil { + return err + } + devsyConfig.Current().DefaultProvider = p.Config.Name + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Infof("default provider: %s", p.Config.Name) + return nil +} + // UseCmd holds the cmd flags. type UseCmd struct { *flags.GlobalFlags diff --git a/cmd/root.go b/cmd/root.go index 4d0ab9a5a..2b4c2ee05 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/devsy-org/devsy/cmd/ide" cmdinternal "github.com/devsy-org/devsy/cmd/internal" "github.com/devsy-org/devsy/cmd/machine" + "github.com/devsy-org/devsy/cmd/mcp" "github.com/devsy-org/devsy/cmd/pro" "github.com/devsy-org/devsy/cmd/provider" "github.com/devsy-org/devsy/cmd/self" @@ -213,6 +214,9 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) selfCmd := self.NewSelfCmd(globalFlags) selfCmd.GroupID = groupMeta rootCmd.AddCommand(selfCmd) + mcpCmd := mcp.NewMCPCmd(globalFlags) + mcpCmd.GroupID = groupMeta + rootCmd.AddCommand(mcpCmd) configCmd := cliconfig.NewConfigCmd(globalFlags) configCmd.GroupID = groupDevcontainer rootCmd.AddCommand(configCmd) diff --git a/cmd/workspace/exec.go b/cmd/workspace/exec.go index 363e6aea5..a62fe36f2 100644 --- a/cmd/workspace/exec.go +++ b/cmd/workspace/exec.go @@ -1,13 +1,9 @@ package workspace import ( - "bytes" "context" "fmt" - "io" - "maps" "os" - "path" "strings" "github.com/devsy-org/devsy/cmd/flags" @@ -17,17 +13,11 @@ import ( "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/output" - provider2 "github.com/devsy-org/devsy/pkg/provider" workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" "golang.org/x/term" ) -const ( - DefaultDockerCommand = "docker" - ContainerStatusRunning = "running" -) - type ExecCmd struct { *flags.GlobalFlags @@ -155,27 +145,26 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { } workspaceConfig := client.WorkspaceConfig() - dockerCommand := ResolveDockerCommand(workspaceConfig) + runtime := workspace2.NewDockerRuntime(workspaceConfig, cmd.DockerPath) - containerDetails, err := FindRunningContainer( - ctx, dockerCommand, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, + containerDetails, err := runtime.FindRunning( + ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels, ) if err != nil { return err } - result := LoadExecResult(workspaceConfig, containerDetails) - workdir := ResolveExecWorkdir(result, client.Workspace()) + result := workspace2.LoadExecResult(workspaceConfig, containerDetails) + workdir := workspace2.ResolveExecWorkdir(result, client.Workspace()) user := devcconfig.GetRemoteUser(result) userEnvProbe := resolveUserEnvProbe(result, cmd.DefaultUserEnvProbe) - target := containerTarget{ - helper: &docker.DockerHelper{DockerCommand: dockerCommand}, - containerID: containerDetails.ID, - user: user, + target := workspace2.ContainerTarget{ + ContainerID: containerDetails.ID, + User: user, } - probedEnv := probeContainerEnv(ctx, target, userEnvProbe) - envMap := buildExecEnv(result, cmd.RemoteEnv, probedEnv) + probedEnv := runtime.ProbeEnv(ctx, target, userEnvProbe) + envMap := workspace2.BuildExecEnv(result, cmd.RemoteEnv, probedEnv) mode, err := output.ResolveMode(cmd.ResultFormat) if err != nil { @@ -184,9 +173,10 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { emitJSON := mode == output.ModeJSON err = cmd.execInContainer(ctx, execOpts{ - target: target, - workdir: workdir, - envMap: envMap, + dockerCmd: runtime.DockerCommand(), + target: target, + workdir: workdir, + envMap: envMap, }, args) if err != nil { if emitJSON { @@ -206,11 +196,8 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { } func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error { - dockerCommand := DefaultDockerCommand - if cmd.DockerPath != "" { - dockerCommand = cmd.DockerPath - } - helper := &docker.DockerHelper{DockerCommand: dockerCommand} + runtime := workspace2.NewDockerRuntime(nil, cmd.DockerPath) + helper := &docker.DockerHelper{DockerCommand: runtime.DockerCommand()} details, err := helper.InspectContainers(ctx, []string{cmd.ContainerID}) if err != nil { @@ -221,7 +208,7 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error } containerDetails := &details[0] - if !strings.EqualFold(containerDetails.State.Status, ContainerStatusRunning) { + if !strings.EqualFold(containerDetails.State.Status, workspace2.ContainerStatusRunning) { return fmt.Errorf( "container %s is not running (status: %s)", cmd.ContainerID, @@ -230,13 +217,12 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error } userEnvProbe := cmd.DefaultUserEnvProbe - target := containerTarget{ - helper: helper, - containerID: containerDetails.ID, - user: "", + target := workspace2.ContainerTarget{ + ContainerID: containerDetails.ID, + User: "", } - probedEnv := probeContainerEnv(ctx, target, userEnvProbe) - envMap := buildExecEnv(nil, cmd.RemoteEnv, probedEnv) + probedEnv := runtime.ProbeEnv(ctx, target, userEnvProbe) + envMap := workspace2.BuildExecEnv(nil, cmd.RemoteEnv, probedEnv) workdir := containerDetails.Config.WorkingDir @@ -247,9 +233,10 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error emitJSON := mode == output.ModeJSON err = cmd.execInContainer(ctx, execOpts{ - target: target, - workdir: workdir, - envMap: envMap, + dockerCmd: runtime.DockerCommand(), + target: target, + workdir: workdir, + envMap: envMap, }, args) if err != nil { if emitJSON { @@ -277,83 +264,6 @@ func (cmd *ExecCmd) validateRemoteEnv() error { return nil } -func ResolveDockerCommand( - workspace *provider2.Workspace, -) string { - if workspace == nil || workspace.Context == "" { - return DefaultDockerCommand - } - - providerConfig, err := provider2.LoadProviderConfig( - workspace.Context, - workspace.Provider.Name, - ) - if err != nil { - log.Debugf("Failed to load provider config, defaulting to 'docker': %v", err) - return DefaultDockerCommand - } - - if providerConfig.Agent.Docker.Path != "" { - if expanded := os.ExpandEnv(providerConfig.Agent.Docker.Path); expanded != "" { - return expanded - } - } - - return DefaultDockerCommand -} - -func FindRunningContainer( - ctx context.Context, - dockerCommand string, - workspaceID string, - idLabels []string, -) (*devcconfig.ContainerDetails, error) { - dockerHelper := &docker.DockerHelper{ - DockerCommand: dockerCommand, - } - - labels := devcconfig.GetIDLabels(workspaceID, idLabels) - container, err := dockerHelper.FindDevContainer(ctx, labels) - if err != nil { - return nil, fmt.Errorf("find container: %w", err) - } - if container == nil { - return nil, fmt.Errorf( - "no running container found for workspace %q", - workspaceID, - ) - } - - if !strings.EqualFold(container.State.Status, ContainerStatusRunning) { - return nil, fmt.Errorf( - "container %s is not running (status: %s)", - container.ID, - container.State.Status, - ) - } - - return container, nil -} - -func LoadExecResult( - workspaceConfig *provider2.Workspace, - containerDetails *devcconfig.ContainerDetails, -) *devcconfig.Result { - if workspaceConfig == nil || workspaceConfig.Context == "" || workspaceConfig.ID == "" { - return nil - } - - result, err := provider2.LoadWorkspaceResult(workspaceConfig.Context, workspaceConfig.ID) - if err != nil { - log.Warnf("Error loading workspace result: %v", err) - return nil - } - if result != nil { - result.ContainerDetails = containerDetails - } - return result -} - func resolveUserEnvProbe(result *devcconfig.Result, cliOverride string) string { if cliOverride != "" { return cliOverride @@ -364,59 +274,11 @@ func resolveUserEnvProbe(result *devcconfig.Result, cliOverride string) string { return "" } -func ResolveExecWorkdir(result *devcconfig.Result, workspaceName string) string { - if result != nil && result.MergedConfig != nil && result.MergedConfig.WorkspaceFolder != "" { - return result.MergedConfig.WorkspaceFolder - } - return path.Join("/workspaces", workspaceName) -} - -func buildExecEnv( - result *devcconfig.Result, - cliEnv []string, - probedEnv map[string]string, -) map[string]string { - env := make(map[string]string, len(probedEnv)) - maps.Copy(env, probedEnv) - - if result != nil { - applyRemoteEnv(env, mergedRemoteEnv(result)) - } - - for _, e := range cliEnv { - if k, v, ok := strings.Cut(e, "="); ok { - env[k] = v - } - } - - return env -} - -func mergedRemoteEnv(result *devcconfig.Result) map[string]*string { - merged := map[string]*string{} - if result.MergedConfig != nil { - maps.Copy(merged, result.MergedConfig.RemoteEnv) - } - if result.DevContainerConfigWithPath != nil && result.DevContainerConfigWithPath.Config != nil { - maps.Copy(merged, result.DevContainerConfigWithPath.Config.RemoteEnv) - } - return merged -} - -func applyRemoteEnv(env map[string]string, remoteEnv map[string]*string) { - for k, v := range remoteEnv { - if v == nil { - delete(env, k) - } else { - env[k] = *v - } - } -} - type execOpts struct { - target containerTarget - workdir string - envMap map[string]string + dockerCmd string + target workspace2.ContainerTarget + workdir string + envMap map[string]string } func (cmd *ExecCmd) execInContainer(ctx context.Context, opts execOpts, args []string) error { @@ -430,106 +292,17 @@ func (cmd *ExecCmd) execInContainer(ctx context.Context, opts execOpts, args []s if opts.workdir != "" { execArgs = append(execArgs, "--workdir", opts.workdir) } - if opts.target.user != "" { - execArgs = append(execArgs, "--user", opts.target.user) + if opts.target.User != "" { + execArgs = append(execArgs, "--user", opts.target.User) } - execArgs = append(execArgs, opts.target.containerID) + execArgs = append(execArgs, opts.target.ContainerID) execArgs = append(execArgs, args...) redacted := strings.Join(redactExecArgs(execArgs), " ") - log.Debugf("Executing in container: %s %s", opts.target.helper.DockerCommand, redacted) - return opts.target.helper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr) -} - -func parseEnvOutput(out []byte, sep byte) map[string]string { - entries := bytes.Split(out, []byte{sep}) - env := make(map[string]string, len(entries)) - for _, e := range entries { - if len(e) == 0 { - continue - } - name, value, ok := bytes.Cut(e, []byte{'='}) - if !ok || len(name) == 0 { - continue - } - env[string(name)] = string(value) - } - delete(env, "PWD") - return env -} - -type containerTarget struct { - helper *docker.DockerHelper - containerID string - user string -} - -func probeContainerEnv( - ctx context.Context, - target containerTarget, - probe string, -) map[string]string { - userEnvProbe, err := devcconfig.NewUserEnvProbe(probe) - if err != nil { - log.Warnf("Invalid userEnvProbe %q, using default: %v", probe, err) - userEnvProbe = devcconfig.DefaultUserEnvProbe - } - if userEnvProbe == devcconfig.NoneProbe { - return map[string]string{} - } - - shellFlag := probeShellFlag(userEnvProbe) - - out, sep, err := runProbeCommand(ctx, target, shellFlag) - if err != nil { - log.Warnf("Failed to probe user env: %v", err) - return map[string]string{} - } - return parseEnvOutput(out, sep) -} + log.Debugf("Executing in container: %s %s", opts.dockerCmd, redacted) -func probeShellFlag(probe devcconfig.UserEnvProbe) string { - switch probe { - case devcconfig.LoginInteractiveShellProbe: - return "-lic" - case devcconfig.LoginShellProbe: - return "-lc" - case devcconfig.InteractiveShellProbe: - return "-ic" - default: - return "-c" - } -} - -func runProbeCommand( - ctx context.Context, - target containerTarget, - shellFlag string, -) ([]byte, byte, error) { - args := buildProbeArgs(target, shellFlag, "cat /proc/self/environ") - var stdout bytes.Buffer - err := target.helper.Run(ctx, args, nil, &stdout, io.Discard) - if err == nil { - return stdout.Bytes(), 0, nil - } - - log.Debugf("Env probe with /proc/self/environ failed: %v, trying printenv", err) - args = buildProbeArgs(target, shellFlag, "printenv") - stdout.Reset() - err = target.helper.Run(ctx, args, nil, &stdout, io.Discard) - if err != nil { - return nil, 0, fmt.Errorf("probe user env: %w", err) - } - return stdout.Bytes(), '\n', nil -} - -func buildProbeArgs(target containerTarget, shellFlag string, cmd string) []string { - args := []string{"exec"} - if target.user != "" { - args = append(args, "--user", target.user) - } - args = append(args, target.containerID, "sh", shellFlag, cmd) - return args + helper := &docker.DockerHelper{DockerCommand: opts.dockerCmd} + return helper.Run(ctx, execArgs, os.Stdin, os.Stdout, os.Stderr) } func redactExecArgs(args []string) []string { diff --git a/cmd/workspace/exec_test.go b/cmd/workspace/exec_test.go index 3cfc96092..c601ccbd5 100644 --- a/cmd/workspace/exec_test.go +++ b/cmd/workspace/exec_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -69,10 +70,15 @@ func TestNewExecCmd_RequiresArgs(t *testing.T) { } func TestResolveDockerCommand_NilWorkspace(t *testing.T) { - result := ResolveDockerCommand(nil) + result := workspace2.ResolveDockerCommand(nil, "") assert.Equal(t, "docker", result) } +func TestResolveDockerCommand_OverrideBeatsDefault(t *testing.T) { + result := workspace2.ResolveDockerCommand(nil, "/usr/local/bin/podman") + assert.Equal(t, "/usr/local/bin/podman", result, "explicit override must win over the default") +} + func TestExecCmd_DockerPathFlag(t *testing.T) { execCmd := NewExecCmd(&flags.GlobalFlags{}) flag := execCmd.Flags().Lookup("docker-path") diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index a430acedb..631fe31d5 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -3,6 +3,7 @@ package up import ( "context" "fmt" + "io" "os" "os/signal" "path/filepath" @@ -47,6 +48,77 @@ type UpCmd struct { DotfilesTargetPath string DotfilesScriptEnv []string // Key=Value to pass to install script DotfilesScriptEnvFile []string // Paths to files containing Key=Value pairs to pass to install script + + // Out receives result/error JSON envelopes; nil falls back to os.Stdout. + Out io.Writer +} + +// Options is the structured input form of the up command. +type Options struct { + Source string // git URL, local path, image, or workspace name + Name string // explicit workspace ID override + Provider string // provider name override + IDE string // ide name; "none" to skip launching + DevcontainerPath string // path to devcontainer.json, relative to project +} + +// RunFromOptions runs the up logic without cobra. Callers own ctx cancellation; +// WithSignals is intentionally skipped. +func RunFromOptions(ctx context.Context, g *flags.GlobalFlags, opts Options) error { + cmd := buildUpCmd(g, opts) + if err := cmd.validate(); err != nil { + return err + } + // Read from the copy in cmd.GlobalFlags so opts.Provider overrides take effect. + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return fmt.Errorf("load devsy config: %w", err) + } + cmd.applyConfig(devsyConfig) + + if cmd.Provider == "" && devsyConfig.Current().DefaultProvider == "" { + return fmt.Errorf("no provider specified and no default provider configured for context %q", + cmd.Context) + } + + args := []string{opts.Source} + client, err := cmd.prepareClient(ctx, devsyConfig, args) + if err != nil { + return fmt.Errorf("prepare workspace client: %w", err) + } + if cmd.ExtraDevContainerPath != "" && client.Provider() != "docker" { + return fmt.Errorf("extra devcontainer file is only supported with local provider") + } + telemetry.FromContext(ctx).SetClient(client) + return cmd.Run(ctx, devsyConfig, client, args) +} + +func buildUpCmd(g *flags.GlobalFlags, opts Options) *UpCmd { + ide := opts.IDE + if ide == "" { + ide = "none" + } + // Shallow-copy so per-call overrides don't mutate the caller's flags. + gCopy := *g + if gCopy.ResultFormat == "" { + gCopy.ResultFormat = "plain" + } + if opts.Provider != "" { + gCopy.Provider = opts.Provider + } + cmd := &UpCmd{ + GlobalFlags: &gCopy, + Out: io.Discard, // any callers wanting envelopes can set this themselves. + } + cmd.IDE = ide + cmd.DevContainerPath = opts.DevcontainerPath + if opts.Name != "" { + cmd.ID = opts.Name + } + // *bool flags lose their CLI default when the cobra registration is skipped. + mountGitRootDefault := true + cmd.MountWorkspaceGitRoot = &mountGitRootDefault + return cmd } // NewUpCmd creates a new up command. @@ -76,9 +148,10 @@ func (cmd *UpCmd) Run( } emitJSON := mode == output.ModeJSON + out := cmd.stdout() wctx, err := cmd.executeDevsyUp(ctx, devsyConfig, client) if err != nil { - return reportErr(err, emitJSON) + return reportErr(err, emitJSON, out) } if wctx == nil || cmd.Prebuild { return nil // Platform mode or prebuild-only run. @@ -88,21 +161,30 @@ func (cmd *UpCmd) Run( client: client, wctx: wctx, emitJSON: emitJSON, + out: out, }) } +func (cmd *UpCmd) applyConfig(devsyConfig *config.Config) { + if devsyConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == config.BoolTrue { + cmd.StrictHostKeyChecking = true + } + cmd.resolveDotfilesOptions(devsyConfig) +} + type finalizeUpArgs struct { devsyConfig *config.Config client client2.BaseWorkspaceClient wctx *workspaceContext emitJSON bool + out io.Writer } // finalizeUp performs the post-up steps: workspace configuration, optional SSH // tunnel, IDE launch, and JSON envelope emission. Split out to keep Run small. func (cmd *UpCmd) finalizeUp(ctx context.Context, args *finalizeUpArgs) error { if err := cmd.configureWorkspace(args.devsyConfig, args.client, args.wctx); err != nil { - return reportErr(err, args.emitJSON) + return reportErr(err, args.emitJSON, args.out) } if cleanup := cmd.maybeStartTunnel( @@ -119,10 +201,10 @@ func (cmd *UpCmd) finalizeUp(ctx context.Context, args *finalizeUpArgs) error { ideURL, err := cmd.openIDE(ctx, args.devsyConfig, args.client, args.wctx) if err != nil { - return reportErr(err, args.emitJSON) + return reportErr(err, args.emitJSON, args.out) } if args.emitJSON { - emitUpResult(args.wctx, ideURL) + emitUpResult(args.wctx, ideURL, args.out) } if args.wctx.tunnelPort > 0 { log.Infof( @@ -156,16 +238,23 @@ func (cmd *UpCmd) maybeStartTunnel( return tunnelCleanup } +func (cmd *UpCmd) stdout() io.Writer { + if cmd.Out != nil { + return cmd.Out + } + return os.Stdout +} + // reportErr writes the error to JSON output when requested and returns it for the caller. -func reportErr(err error, emitJSON bool) error { +func reportErr(err error, emitJSON bool, out io.Writer) error { if emitJSON { - _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + _ = config2.WriteErrorJSON(out, err.Error()) } return err } // emitUpResult writes the JSON result envelope for a completed `up` invocation. -func emitUpResult(wctx *workspaceContext, ideURL string) { +func emitUpResult(wctx *workspaceContext, ideURL string, out io.Writer) { containerID := "" var warnings []string if wctx.result != nil { @@ -174,7 +263,7 @@ func emitUpResult(wctx *workspaceContext, ideURL string) { } warnings = wctx.result.HostWarnings } - _ = config2.WriteResultJSON(os.Stdout, config2.ResultEnvelope{ + _ = config2.WriteResultJSON(out, config2.ResultEnvelope{ ContainerID: containerID, RemoteUser: wctx.user, RemoteWorkspaceFolder: wctx.workdir, @@ -191,11 +280,7 @@ func (cmd *UpCmd) execute(cobraCmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("load devsy config: %w", err) } - if devsyConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == config.BoolTrue { - cmd.StrictHostKeyChecking = true - } - - cmd.resolveDotfilesOptions(devsyConfig) + cmd.applyConfig(devsyConfig) ctx, cancel := WithSignals(cobraCmd.Context()) defer cancel() @@ -328,23 +413,42 @@ func WithSignals(ctx context.Context) (context.Context, func()) { ctx, cancel := context.WithCancel(ctx) signals := make(chan os.Signal, 1) signal.Notify(signals, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGQUIT) + + // done lets cleanup unblock goroutines parked on <-signals; signal.Stop + // alone wouldn't wake them. + done := make(chan struct{}) + go func() { select { case <-signals: cancel() case <-ctx.Done(): + case <-done: } }() go func() { - <-ctx.Done() - <-signals - // force shutdown if context is done and another signal arrives - os.Exit(1) + select { + case <-ctx.Done(): + case <-done: + return + } + // Skip the second-signal wait if cleanup already closed done. + select { + case <-done: + return + default: + } + select { + case <-signals: + os.Exit(1) // second signal — force shutdown + case <-done: + } }() return ctx, func() { cancel() signal.Stop(signals) + close(done) } } diff --git a/cmd/workspace/up/up_test.go b/cmd/workspace/up/up_test.go index d03533837..7c756d289 100644 --- a/cmd/workspace/up/up_test.go +++ b/cmd/workspace/up/up_test.go @@ -277,3 +277,54 @@ func TestUpCmd_ValidateMounts(t *testing.T) { }) } } + +func TestBuildUpCmd_AppliesOptions(t *testing.T) { + g := &flags.GlobalFlags{Provider: "default-provider", ResultFormat: ""} + opts := Options{ + Source: "github.com/example/repo", + Name: "my-ws", + Provider: "k8s", + IDE: "vscode", + DevcontainerPath: ".devcontainer/devcontainer.json", + } + cmd := buildUpCmd(g, opts) + + assert.Equal(t, "vscode", cmd.IDE) + assert.Equal(t, ".devcontainer/devcontainer.json", cmd.DevContainerPath) + assert.Equal(t, "my-ws", cmd.ID) + assert.Equal(t, "k8s", cmd.Provider, "Provider override must reach LoadConfig via gCopy") + assert.Equal(t, "plain", cmd.ResultFormat, "default ResultFormat ensures human-readable output") + require.NotNil(t, cmd.Out, "Out must be set to suppress JSON envelope writes to stdout") + assert.Equal(t, "default-provider", g.Provider, "caller's GlobalFlags must not be mutated") +} + +func TestBuildUpCmd_DefaultsIDEToNone(t *testing.T) { + g := &flags.GlobalFlags{} + cmd := buildUpCmd(g, Options{Source: "src"}) + assert.Equal( + t, + "none", + cmd.IDE, + "MCP path must default IDE to none — there's no human to attach an IDE to", + ) +} + +func TestBuildUpCmd_DoesNotMutateCallerGlobalFlags(t *testing.T) { + g := &flags.GlobalFlags{Provider: "default-provider", ResultFormat: ""} + + // Two calls with different overrides must each see a clean copy. If the + // shallow-copy guard regressed, the second call would inherit the first + // call's override. + first := buildUpCmd(g, Options{Source: "src1", Provider: "alpha"}) + second := buildUpCmd(g, Options{Source: "src2", Provider: "beta"}) + + assert.Equal(t, "alpha", first.Provider, "first call applies its own override") + assert.Equal(t, "beta", second.Provider, "second call applies its own override") + assert.Equal(t, "default-provider", g.Provider, "caller's Provider must remain untouched") + assert.Equal( + t, + "", + g.ResultFormat, + "caller's ResultFormat must remain untouched even after copy defaulted it", + ) +} diff --git a/go.mod b/go.mod index 2204e857d..3e858a6b2 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/moby/buildkit v0.29.0 github.com/moby/patternmatcher v0.6.1 github.com/moby/term v0.5.2 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pkg/sftp v1.13.10 @@ -185,6 +186,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-github/v74 v74.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect @@ -248,6 +250,8 @@ require ( github.com/safchain/ethtool v0.3.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect @@ -269,6 +273,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect go.etcd.io/etcd/api/v3 v3.6.5 // indirect diff --git a/go.sum b/go.sum index a3f9b073b..6d47dd858 100644 --- a/go.sum +++ b/go.sum @@ -386,6 +386,8 @@ github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= @@ -535,6 +537,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -607,6 +611,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= @@ -709,6 +717,8 @@ github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chq github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/pkg/workspace/exec.go b/pkg/workspace/exec.go new file mode 100644 index 000000000..198beefcd --- /dev/null +++ b/pkg/workspace/exec.go @@ -0,0 +1,514 @@ +package workspace + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path" + "strings" + "time" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/devcontainer" + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/docker" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/platform" + provider2 "github.com/devsy-org/devsy/pkg/provider" +) + +const ( + DefaultDockerCommand = "docker" + ContainerStatusRunning = "running" +) + +// defaultExecTimeoutSeconds bounds an exec when no caller or configured default +// applies. Long enough for typical build/test, short enough to surface hangs. +const defaultExecTimeoutSeconds = 300 + +// ResolveDockerCommand returns the docker binary to invoke. Precedence: +// override → provider config (agent.docker.path) → default. The override is +// honored even when workspace is nil. +func ResolveDockerCommand( + workspace *provider2.Workspace, + override string, +) string { + if override != "" { + return override + } + if workspace == nil || workspace.Context == "" { + return DefaultDockerCommand + } + + providerConfig, err := provider2.LoadProviderConfig( + workspace.Context, + workspace.Provider.Name, + ) + if err != nil { + log.Debugf("Failed to load provider config, defaulting to 'docker': %v", err) + return DefaultDockerCommand + } + + if providerConfig.Agent.Docker.Path != "" { + if expanded := os.ExpandEnv(providerConfig.Agent.Docker.Path); expanded != "" { + return expanded + } + } + + return DefaultDockerCommand +} + +func LoadExecResult( + workspaceConfig *provider2.Workspace, + containerDetails *devcconfig.ContainerDetails, +) *devcconfig.Result { + if workspaceConfig == nil || workspaceConfig.Context == "" || workspaceConfig.ID == "" { + return nil + } + + result, err := provider2.LoadWorkspaceResult(workspaceConfig.Context, workspaceConfig.ID) + if err != nil { + log.Warnf("Error loading workspace result: %v", err) + return nil + } + if result != nil { + result.ContainerDetails = containerDetails + } + return result +} + +func ResolveExecWorkdir(result *devcconfig.Result, workspaceName string) string { + if result != nil && result.MergedConfig != nil && result.MergedConfig.WorkspaceFolder != "" { + return result.MergedConfig.WorkspaceFolder + } + return path.Join("/workspaces", workspaceName) +} + +// BuildExecEnv merges probed env, result remote env, and caller-supplied env slices. +func BuildExecEnv( + result *devcconfig.Result, + cliEnv []string, + probedEnv map[string]string, +) map[string]string { + env := make(map[string]string, len(probedEnv)) + maps.Copy(env, probedEnv) + + if result != nil { + applyRemoteEnv(env, mergedRemoteEnv(result)) + } + + for _, e := range cliEnv { + if k, v, ok := strings.Cut(e, "="); ok { + env[k] = v + } + } + + return env +} + +func mergedRemoteEnv(result *devcconfig.Result) map[string]*string { + merged := map[string]*string{} + if result.MergedConfig != nil { + maps.Copy(merged, result.MergedConfig.RemoteEnv) + } + if result.DevContainerConfigWithPath != nil && result.DevContainerConfigWithPath.Config != nil { + maps.Copy(merged, result.DevContainerConfigWithPath.Config.RemoteEnv) + } + return merged +} + +func applyRemoteEnv(env map[string]string, remoteEnv map[string]*string) { + for k, v := range remoteEnv { + if v == nil { + delete(env, k) + } else { + env[k] = *v + } + } +} + +func probeShellFlag(probe devcconfig.UserEnvProbe) string { + switch probe { + case devcconfig.LoginInteractiveShellProbe: + return "-lic" + case devcconfig.LoginShellProbe: + return "-lc" + case devcconfig.InteractiveShellProbe: + return "-ic" + default: + return "-c" + } +} + +func buildProbeArgs(target ContainerTarget, shellFlag string, cmd string) []string { + args := []string{"exec"} + if target.User != "" { + args = append(args, "--user", target.User) + } + args = append(args, target.ContainerID, "sh", shellFlag, cmd) + return args +} + +func parseEnvOutput(out []byte, sep byte) map[string]string { + entries := bytes.Split(out, []byte{sep}) + env := make(map[string]string, len(entries)) + for _, e := range entries { + if len(e) == 0 { + continue + } + name, value, ok := bytes.Cut(e, []byte{'='}) + if !ok || len(name) == 0 { + continue + } + env[string(name)] = string(value) + } + delete(env, "PWD") + return env +} + +// ContainerRuntime abstracts find/exec/probe over a container runtime so +// callers and tests don't depend on a particular CLI (docker, podman, ...). +type ContainerRuntime interface { + // FindRunning resolves a running container by workspace ID and/or labels. + // A non-nil error includes the not-found and not-running cases. + FindRunning( + ctx context.Context, + workspaceID string, + idLabels []string, + ) (*devcconfig.ContainerDetails, error) + + // Exec runs req inside a container and returns the process exit code. + // A non-nil error means the exec machinery itself failed (e.g. binary + // missing), not a non-zero exit. + Exec(ctx context.Context, req ExecRequest) (exitCode int, err error) + + // ProbeEnv reads the container's environment via shell. Returns an empty + // map on any failure; probeMode comes from devcconfig.UserEnvProbe. + ProbeEnv(ctx context.Context, target ContainerTarget, probeMode string) map[string]string +} + +// ExecRequest is the per-call input to ContainerRuntime.Exec. +type ExecRequest struct { + Target ContainerTarget + Workdir string + Env map[string]string + Argv []string + Stdout io.Writer + Stderr io.Writer +} + +// ContainerTarget identifies a container and the user to exec as. +type ContainerTarget struct { + ContainerID string + User string +} + +// DockerRuntime is the production ContainerRuntime, shelling out to a +// docker-compatible binary. +type DockerRuntime struct { + helper *docker.DockerHelper +} + +// NewDockerRuntime builds a DockerRuntime using ResolveDockerCommand to pick +// the binary; override wins if set. +func NewDockerRuntime(workspace *provider2.Workspace, override string) *DockerRuntime { + return &DockerRuntime{ + helper: &docker.DockerHelper{ + DockerCommand: ResolveDockerCommand(workspace, override), + }, + } +} + +func (r *DockerRuntime) DockerCommand() string { return r.helper.DockerCommand } + +func (r *DockerRuntime) FindRunning( + ctx context.Context, + workspaceID string, + idLabels []string, +) (*devcconfig.ContainerDetails, error) { + labels := devcconfig.GetIDLabels(workspaceID, idLabels) + container, err := r.helper.FindDevContainer(ctx, labels) + if err != nil { + return nil, fmt.Errorf("find container: %w", err) + } + if container == nil { + return nil, fmt.Errorf( + "no running container found for workspace %q", + workspaceID, + ) + } + + if !strings.EqualFold(container.State.Status, ContainerStatusRunning) { + return nil, fmt.Errorf( + "container %s is not running (status: %s)", + container.ID, + container.State.Status, + ) + } + + return container, nil +} + +func (r *DockerRuntime) Exec(ctx context.Context, req ExecRequest) (int, error) { + execArgs := []string{"exec", "-i"} + for k, v := range req.Env { + execArgs = append(execArgs, "-e", k+"="+v) + } + if req.Workdir != "" { + execArgs = append(execArgs, "--workdir", req.Workdir) + } + if req.Target.User != "" { + execArgs = append(execArgs, "--user", req.Target.User) + } + execArgs = append(execArgs, req.Target.ContainerID) + execArgs = append(execArgs, req.Argv...) + + stdout := req.Stdout + if stdout == nil { + stdout = io.Discard + } + stderr := req.Stderr + if stderr == nil { + stderr = io.Discard + } + + err := r.helper.Run(ctx, execArgs, nil, stdout, stderr) + if err == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return -1, fmt.Errorf("exec in container %s: %w", req.Target.ContainerID, err) +} + +func (r *DockerRuntime) ProbeEnv( + ctx context.Context, + target ContainerTarget, + probe string, +) map[string]string { + userEnvProbe, err := devcconfig.NewUserEnvProbe(probe) + if err != nil { + log.Warnf("Invalid userEnvProbe %q, using default: %v", probe, err) + userEnvProbe = devcconfig.DefaultUserEnvProbe + } + if userEnvProbe == devcconfig.NoneProbe { + return map[string]string{} + } + + shellFlag := probeShellFlag(userEnvProbe) + + out, sep, probeErr := r.runProbeCommand(ctx, target, shellFlag) + if probeErr != nil { + log.Warnf("Failed to probe user env: %v", probeErr) + return map[string]string{} + } + return parseEnvOutput(out, sep) +} + +func (r *DockerRuntime) runProbeCommand( + ctx context.Context, + target ContainerTarget, + shellFlag string, +) ([]byte, byte, error) { + args := buildProbeArgs(target, shellFlag, "cat /proc/self/environ") + var stdout bytes.Buffer + err := r.helper.Run(ctx, args, nil, &stdout, io.Discard) + if err == nil { + return stdout.Bytes(), 0, nil + } + + log.Debugf("Env probe with /proc/self/environ failed: %v, trying printenv", err) + args = buildProbeArgs(target, shellFlag, "printenv") + stdout.Reset() + err = r.helper.Run(ctx, args, nil, &stdout, io.Discard) + if err != nil { + return nil, 0, fmt.Errorf("probe user env: %w", err) + } + return stdout.Bytes(), '\n', nil +} + +// ExecOneShotOptions configures a single non-interactive exec inside a +// workspace's running container. +type ExecOneShotOptions struct { + WorkspaceName string + Command []string + Workdir string + Env map[string]string + IDLabels []string // additional id-labels for container lookup; nil uses defaults + TimeoutSeconds int + TimeoutSecondsDefault int + TimeoutSecondsMax int + Owner platform.OwnerFilter + Context string + Provider string + Stdout io.Writer + Stderr io.Writer +} + +// ExecOneShotResult is the outcome of an ExecOneShot call. +type ExecOneShotResult struct { + ExitCode int + DurationMS int64 + TimedOut bool + TimeoutSeconds int + Clamped bool +} + +// ResolveTimeout picks the first positive of TimeoutSeconds, +// TimeoutSecondsDefault, fallbackDefault, then clamps by TimeoutSecondsMax. +// The bool is true when clamping applied. +func (o ExecOneShotOptions) ResolveTimeout(fallbackDefault int) (time.Duration, bool) { + want := o.TimeoutSeconds + if want <= 0 { + want = o.TimeoutSecondsDefault + } + if want <= 0 { + want = fallbackDefault + } + if o.TimeoutSecondsMax > 0 && want > o.TimeoutSecondsMax { + return time.Duration(o.TimeoutSecondsMax) * time.Second, true + } + return time.Duration(want) * time.Second, false +} + +// ExecOneShot runs opts.Command in the workspace's container, capturing +// stdout/stderr via the provided writers. Never reads stdin, never allocates a TTY. +func ExecOneShot(ctx context.Context, opts ExecOneShotOptions) (*ExecOneShotResult, error) { + if opts.WorkspaceName == "" { + return nil, fmt.Errorf("workspace name is required") + } + if len(opts.Command) == 0 { + return nil, fmt.Errorf("command is required") + } + + timeout, clamped := opts.ResolveTimeout(defaultExecTimeoutSeconds) + + // Resolve under the parent context so docker lookup doesn't eat the exec budget. + resolved, err := resolveExecTarget(ctx, opts) + if err != nil { + return nil, err + } + + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + start := time.Now() + exitCode, runErr := execOneShotWithRuntime(execCtx, resolved.runtime, ExecRequest{ + Target: resolved.target, + Workdir: resolved.workdir, + Env: resolved.envMap, + Argv: opts.Command, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + }) + duration := time.Since(start) + + res := &ExecOneShotResult{ + ExitCode: exitCode, + DurationMS: duration.Milliseconds(), + Clamped: clamped, + TimeoutSeconds: int(timeout.Seconds()), + } + if parentErr := ctx.Err(); parentErr != nil { + res.ExitCode = -1 + return res, parentErr + } + if errors.Is(execCtx.Err(), context.DeadlineExceeded) { + res.TimedOut = true + res.ExitCode = -1 + return res, nil + } + if runErr != nil { + return res, runErr + } + return res, nil +} + +// execOneShotWithRuntime is the testable seam: takes an already-resolved +// runtime so fakes can be injected without touching workspace lookup. +func execOneShotWithRuntime( + ctx context.Context, + runtime ContainerRuntime, + req ExecRequest, +) (int, error) { + return runtime.Exec(ctx, req) +} + +type resolvedExecTarget struct { + runtime ContainerRuntime + target ContainerTarget + workdir string + envMap map[string]string +} + +func resolveExecTarget(ctx context.Context, opts ExecOneShotOptions) (resolvedExecTarget, error) { + devsyConfig, err := config.LoadConfig(opts.Context, opts.Provider) + if err != nil { + return resolvedExecTarget{}, fmt.Errorf("load config: %w", err) + } + + client, err := Get(ctx, GetOptions{ + DevsyConfig: devsyConfig, + Args: []string{opts.WorkspaceName}, + Owner: opts.Owner, + }) + if err != nil { + return resolvedExecTarget{}, fmt.Errorf("resolve workspace: %w", err) + } + + workspaceConfig := client.WorkspaceConfig() + runtime := NewDockerRuntime(workspaceConfig, "") + + containerDetails, err := runtime.FindRunning( + ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), opts.IDLabels, + ) + if err != nil { + return resolvedExecTarget{}, err + } + + execResult := LoadExecResult(workspaceConfig, containerDetails) + workdir := opts.Workdir + if workdir == "" { + workdir = ResolveExecWorkdir(execResult, client.Workspace()) + } + + user := "" + if execResult != nil { + user = devcconfig.GetRemoteUser(execResult) + } + + target := ContainerTarget{ + ContainerID: containerDetails.ID, + User: user, + } + + userEnvProbe := "" + if execResult != nil && execResult.MergedConfig != nil { + userEnvProbe = execResult.MergedConfig.UserEnvProbe + } + probedEnv := runtime.ProbeEnv(ctx, target, userEnvProbe) + envSlice := envMapToSlice(opts.Env) + envMap := BuildExecEnv(execResult, envSlice, probedEnv) + + return resolvedExecTarget{ + runtime: runtime, + target: target, + workdir: workdir, + envMap: envMap, + }, nil +} + +func envMapToSlice(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k, v := range m { + out = append(out, k+"="+v) + } + return out +} diff --git a/pkg/workspace/exec_test.go b/pkg/workspace/exec_test.go new file mode 100644 index 000000000..68b2d4138 --- /dev/null +++ b/pkg/workspace/exec_test.go @@ -0,0 +1,150 @@ +package workspace + +import ( + "bytes" + "context" + "io" + "testing" + + devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" +) + +func TestExecOneShotOptions_ResolveTimeout_Clamp(t *testing.T) { + opts := ExecOneShotOptions{ + TimeoutSeconds: 10000, + TimeoutSecondsMax: 60, + } + clamped, wasClamped := opts.ResolveTimeout(5) + if !wasClamped { + t.Fatal("expected clamp=true") + } + if clamped.Seconds() != 60 { + t.Fatalf("expected 60s, got %s", clamped) + } +} + +func TestExecOneShotOptions_ResolveTimeout_Default(t *testing.T) { + opts := ExecOneShotOptions{TimeoutSecondsMax: 600} + clamped, wasClamped := opts.ResolveTimeout(300) + if wasClamped { + t.Fatal("expected clamp=false") + } + if clamped.Seconds() != 300 { + t.Fatalf("expected 300s, got %s", clamped) + } +} + +func TestExecOneShotOptions_ResolveTimeout_CallerExplicit(t *testing.T) { + opts := ExecOneShotOptions{ + TimeoutSeconds: 120, + TimeoutSecondsMax: 600, + } + clamped, wasClamped := opts.ResolveTimeout(300) + if wasClamped { + t.Fatal("expected clamp=false") + } + if clamped.Seconds() != 120 { + t.Fatalf("expected 120s, got %s", clamped) + } +} + +// fakeRuntime is a test double for ContainerRuntime. +type fakeRuntime struct { + findResult *devcconfig.ContainerDetails + findErr error + execExit int + execErr error + execStdout string + execStderr string + probeEnv map[string]string +} + +func (f *fakeRuntime) FindRunning( + _ context.Context, + _ string, + _ []string, +) (*devcconfig.ContainerDetails, error) { + return f.findResult, f.findErr +} + +func (f *fakeRuntime) Exec(_ context.Context, req ExecRequest) (int, error) { + stdout := req.Stdout + if stdout == nil { + stdout = io.Discard + } + stderr := req.Stderr + if stderr == nil { + stderr = io.Discard + } + if f.execStdout != "" { + _, _ = stdout.Write([]byte(f.execStdout)) + } + if f.execStderr != "" { + _, _ = stderr.Write([]byte(f.execStderr)) + } + return f.execExit, f.execErr +} + +func (f *fakeRuntime) ProbeEnv( + _ context.Context, + _ ContainerTarget, + _ string, +) map[string]string { + return f.probeEnv +} + +func TestExecOneShot_ExitCodeAndOutput(t *testing.T) { + var stdout, stderr bytes.Buffer + runtime := &fakeRuntime{ + execExit: 42, + execStdout: "hi", + } + + exitCode, err := execOneShotWithRuntime( + context.Background(), + runtime, + ExecRequest{ + Target: ContainerTarget{ContainerID: "ctr1"}, + Workdir: "/workdir", + Argv: []string{"echo", "hi"}, + Stdout: &stdout, + Stderr: &stderr, + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exitCode != 42 { + t.Fatalf("expected exit code 42, got %d", exitCode) + } + if stdout.String() != "hi" { + t.Fatalf("expected stdout %q, got %q", "hi", stdout.String()) + } +} + +func TestExecOneShot_PartialOutputOnError(t *testing.T) { + var stdout, stderr bytes.Buffer + runtime := &fakeRuntime{ + execExit: -1, + execErr: context.Canceled, + execStdout: "partial", + } + + _, err := execOneShotWithRuntime( + context.Background(), + runtime, + ExecRequest{ + Target: ContainerTarget{ContainerID: "ctr2"}, + Workdir: "/workdir", + Argv: []string{"long-running-cmd"}, + Stdout: &stdout, + Stderr: &stderr, + }, + ) + if err == nil { + t.Fatal("expected error, got nil") + } + if stdout.String() != "partial" { + t.Fatalf("expected partial stdout %q to be preserved, got %q", "partial", stdout.String()) + } +}