mirror of
https://github.com/coder/coder.git
synced 2026-06-05 14:08:20 +00:00
119030d795
Processes started via the agent process API inherited the agent's own working directory (/tmp/coder.xxx) when no WorkDir was specified. SSH sessions already use a fallback chain: configured agent directory > $HOME. This wires the same manifest directory closure into the process manager so the priority is now: explicit req.WorkDir > agent configured dir > $HOME The resolved directory is recorded on the process struct so ProcessInfo.WorkDir and pathStore notifications reflect where the process actually ran.
1034 lines
28 KiB
Go
1034 lines
28 KiB
Go
package agentproc_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/agent/agentexec"
|
|
"github.com/coder/coder/v2/agent/agentgit"
|
|
"github.com/coder/coder/v2/agent/agentproc"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// postStart sends a POST /start request and returns the recorder.
|
|
func postStart(t *testing.T, handler http.Handler, req workspacesdk.StartProcessRequest, headers ...http.Header) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
body, err := json.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/start", bytes.NewReader(body))
|
|
for _, h := range headers {
|
|
for k, vals := range h {
|
|
for _, v := range vals {
|
|
r.Header.Add(k, v)
|
|
}
|
|
}
|
|
}
|
|
handler.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// getList sends a GET /list request and returns the recorder.
|
|
func getList(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, "/list", nil)
|
|
handler.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// getOutput sends a GET /{id}/output request and returns the
|
|
// recorder.
|
|
func getOutput(t *testing.T, handler http.Handler, id string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/%s/output", id), nil)
|
|
handler.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// postSignal sends a POST /{id}/signal request and returns
|
|
// the recorder.
|
|
func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.SignalProcessRequest) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
body, err := json.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("/%s/signal", id), bytes.NewReader(body))
|
|
handler.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// newTestAPI creates a new API with a test logger and default
|
|
// execer, returning the handler and API.
|
|
func newTestAPI(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
return newTestAPIWithOptions(t, nil, nil)
|
|
}
|
|
|
|
// newTestAPIWithUpdateEnv creates a new API with an optional
|
|
// updateEnv hook for testing environment injection.
|
|
func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, error)) http.Handler {
|
|
t.Helper()
|
|
return newTestAPIWithOptions(t, updateEnv, nil)
|
|
}
|
|
|
|
// newTestAPIWithOptions creates a new API with optional
|
|
// updateEnv and workingDir hooks.
|
|
func newTestAPIWithOptions(t *testing.T, updateEnv func([]string) ([]string, error), workingDir func() string) http.Handler {
|
|
t.Helper()
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{
|
|
IgnoreErrors: true,
|
|
}).Leveled(slog.LevelDebug)
|
|
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil, workingDir)
|
|
t.Cleanup(func() {
|
|
_ = api.Close()
|
|
})
|
|
return api.Routes()
|
|
}
|
|
|
|
// waitForExit polls the output endpoint until the process is
|
|
// no longer running or the context expires.
|
|
func waitForExit(t *testing.T, handler http.Handler, id string) workspacesdk.ProcessOutputResponse {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(50 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for process to exit")
|
|
case <-ticker.C:
|
|
w := getOutput(t, handler, id)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ProcessOutputResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
|
|
if !resp.Running {
|
|
return resp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// startAndGetID is a helper that starts a process and returns
|
|
// the process ID.
|
|
func startAndGetID(t *testing.T, handler http.Handler, req workspacesdk.StartProcessRequest, headers ...http.Header) string {
|
|
t.Helper()
|
|
|
|
w := postStart(t, handler, req, headers...)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.StartProcessResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Started)
|
|
require.NotEmpty(t, resp.ID)
|
|
return resp.ID
|
|
}
|
|
|
|
func TestStartProcess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ForegroundCommand", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
w := postStart(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo hello",
|
|
})
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.StartProcessResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Started)
|
|
require.NotEmpty(t, resp.ID)
|
|
})
|
|
|
|
t.Run("BackgroundCommand", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
w := postStart(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo background",
|
|
Background: true,
|
|
})
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.StartProcessResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Started)
|
|
require.NotEmpty(t, resp.ID)
|
|
})
|
|
|
|
t.Run("EmptyCommand", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
w := postStart(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "",
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
var resp codersdk.Response
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Contains(t, resp.Message, "Command is required")
|
|
})
|
|
|
|
t.Run("MalformedJSON", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/start", strings.NewReader("{invalid json"))
|
|
handler.ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
var resp codersdk.Response
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Contains(t, resp.Message, "valid JSON")
|
|
})
|
|
|
|
t.Run("CustomWorkDir", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
tmpDir := t.TempDir()
|
|
|
|
// Write a marker file to verify the command ran in
|
|
// the correct directory. Comparing pwd output is
|
|
// unreliable on Windows where Git Bash returns POSIX
|
|
// paths.
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "touch marker.txt && ls marker.txt",
|
|
WorkDir: tmpDir,
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
require.Contains(t, resp.Output, "marker.txt")
|
|
})
|
|
|
|
t.Run("DefaultWorkDirIsHome", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// No working directory closure, so the process
|
|
// should fall back to $HOME. We verify through
|
|
// the process list API which reports the resolved
|
|
// working directory using native OS paths,
|
|
// avoiding shell path format mismatches on
|
|
// Windows (Git Bash returns POSIX paths).
|
|
handler := newTestAPI(t)
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo ok",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
|
|
w := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var listResp workspacesdk.ListProcessesResponse
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
|
var proc *workspacesdk.ProcessInfo
|
|
for i := range listResp.Processes {
|
|
if listResp.Processes[i].ID == id {
|
|
proc = &listResp.Processes[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, proc, "process not found in list")
|
|
require.Equal(t, homeDir, proc.WorkDir)
|
|
})
|
|
|
|
t.Run("DefaultWorkDirFromClosure", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The closure provides a valid directory, so the
|
|
// process should start there. Use the marker file
|
|
// pattern to avoid path format mismatches on
|
|
// Windows.
|
|
tmpDir := t.TempDir()
|
|
handler := newTestAPIWithOptions(t, nil, func() string {
|
|
return tmpDir
|
|
})
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "touch marker.txt && ls marker.txt",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
require.Contains(t, resp.Output, "marker.txt")
|
|
})
|
|
|
|
t.Run("DefaultWorkDirClosureNonExistentFallsBackToHome", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The closure returns a path that doesn't exist,
|
|
// so the process should fall back to $HOME.
|
|
handler := newTestAPIWithOptions(t, nil, func() string {
|
|
return "/tmp/nonexistent-dir-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
|
})
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo ok",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
|
|
w := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var listResp workspacesdk.ListProcessesResponse
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
|
var proc *workspacesdk.ProcessInfo
|
|
for i := range listResp.Processes {
|
|
if listResp.Processes[i].ID == id {
|
|
proc = &listResp.Processes[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, proc, "process not found in list")
|
|
require.Equal(t, homeDir, proc.WorkDir)
|
|
})
|
|
|
|
t.Run("CustomEnv", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
// Use a unique env var name to avoid collisions in
|
|
// parallel tests.
|
|
envKey := fmt.Sprintf("TEST_PROC_ENV_%d", time.Now().UnixNano())
|
|
envVal := "custom_value_12345"
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: fmt.Sprintf("printenv %s", envKey),
|
|
Env: map[string]string{envKey: envVal},
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
require.Contains(t, strings.TrimSpace(resp.Output), envVal)
|
|
})
|
|
|
|
t.Run("UpdateEnvHook", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
envKey := fmt.Sprintf("TEST_UPDATE_ENV_%d", time.Now().UnixNano())
|
|
envVal := "injected_by_hook"
|
|
|
|
handler := newTestAPIWithUpdateEnv(t, func(current []string) ([]string, error) {
|
|
return append(current, fmt.Sprintf("%s=%s", envKey, envVal)), nil
|
|
})
|
|
|
|
// The process should see the variable even though it
|
|
// was not passed in req.Env.
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: fmt.Sprintf("printenv %s", envKey),
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
require.Contains(t, strings.TrimSpace(resp.Output), envVal)
|
|
})
|
|
|
|
t.Run("UpdateEnvHookOverriddenByReqEnv", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
envKey := fmt.Sprintf("TEST_OVERRIDE_%d", time.Now().UnixNano())
|
|
hookVal := "from_hook"
|
|
reqVal := "from_request"
|
|
|
|
handler := newTestAPIWithUpdateEnv(t, func(current []string) ([]string, error) {
|
|
return append(current, fmt.Sprintf("%s=%s", envKey, hookVal)), nil
|
|
})
|
|
|
|
// req.Env should take precedence over the hook.
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: fmt.Sprintf("printenv %s", envKey),
|
|
Env: map[string]string{envKey: reqVal},
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
// When duplicate env vars exist, shells use the last
|
|
// value. Since req.Env is appended after the hook,
|
|
// the request value wins.
|
|
require.Contains(t, strings.TrimSpace(resp.Output), reqVal)
|
|
})
|
|
}
|
|
|
|
func TestListProcesses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("NoProcesses", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
w := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ListProcessesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.Processes)
|
|
require.Empty(t, resp.Processes)
|
|
})
|
|
|
|
t.Run("FilterByChatID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
chatA := uuid.New().String()
|
|
chatB := uuid.New().String()
|
|
headersA := http.Header{workspacesdk.CoderChatIDHeader: {chatA}}
|
|
headersB := http.Header{workspacesdk.CoderChatIDHeader: {chatB}}
|
|
|
|
// Start processes with different chat IDs.
|
|
id1 := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo chat-a",
|
|
}, headersA)
|
|
waitForExit(t, handler, id1)
|
|
|
|
id2 := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo chat-b",
|
|
}, headersB)
|
|
waitForExit(t, handler, id2)
|
|
|
|
id3 := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo chat-a-2",
|
|
}, headersA)
|
|
waitForExit(t, handler, id3)
|
|
|
|
// List with chat A header should return 2 processes.
|
|
w := getListWithChatHeader(t, handler, chatA)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ListProcessesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Processes, 2)
|
|
|
|
ids := make(map[string]bool)
|
|
for _, p := range resp.Processes {
|
|
ids[p.ID] = true
|
|
}
|
|
require.True(t, ids[id1])
|
|
require.True(t, ids[id3])
|
|
|
|
// List with chat B header should return 1 process.
|
|
w2 := getListWithChatHeader(t, handler, chatB)
|
|
require.Equal(t, http.StatusOK, w2.Code)
|
|
|
|
var resp2 workspacesdk.ListProcessesResponse
|
|
err = json.NewDecoder(w2.Body).Decode(&resp2)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp2.Processes, 1)
|
|
require.Equal(t, id2, resp2.Processes[0].ID)
|
|
|
|
// List without chat header should return all 3.
|
|
w3 := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w3.Code)
|
|
|
|
var resp3 workspacesdk.ListProcessesResponse
|
|
err = json.NewDecoder(w3.Body).Decode(&resp3)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp3.Processes, 3)
|
|
})
|
|
|
|
t.Run("ChatIDFiltering", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
chatID := uuid.New().String()
|
|
headers := http.Header{workspacesdk.CoderChatIDHeader: {chatID}}
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo with-chat",
|
|
}, headers)
|
|
waitForExit(t, handler, id)
|
|
|
|
// Listing with the same chat header should return
|
|
// the process.
|
|
w := getListWithChatHeader(t, handler, chatID)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ListProcessesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Processes, 1)
|
|
require.Equal(t, id, resp.Processes[0].ID)
|
|
|
|
// Listing with a different chat header should not
|
|
// return the process.
|
|
w2 := getListWithChatHeader(t, handler, uuid.New().String())
|
|
require.Equal(t, http.StatusOK, w2.Code)
|
|
|
|
var resp2 workspacesdk.ListProcessesResponse
|
|
err = json.NewDecoder(w2.Body).Decode(&resp2)
|
|
require.NoError(t, err)
|
|
require.Empty(t, resp2.Processes)
|
|
|
|
// Listing without a chat header should return the
|
|
// process (no filtering).
|
|
w3 := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w3.Code)
|
|
|
|
var resp3 workspacesdk.ListProcessesResponse
|
|
err = json.NewDecoder(w3.Body).Decode(&resp3)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp3.Processes, 1)
|
|
})
|
|
|
|
t.Run("SortAndLimit", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
// Start 12 short-lived processes so we exceed the
|
|
// limit of 10.
|
|
for i := 0; i < 12; i++ {
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: fmt.Sprintf("echo proc-%d", i),
|
|
})
|
|
waitForExit(t, handler, id)
|
|
}
|
|
|
|
w := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ListProcessesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Processes, 10, "should be capped at 10")
|
|
|
|
// All returned processes are exited, so they should
|
|
// be sorted by StartedAt descending (newest first).
|
|
for i := 1; i < len(resp.Processes); i++ {
|
|
require.GreaterOrEqual(t, resp.Processes[i-1].StartedAt, resp.Processes[i].StartedAt,
|
|
"processes should be sorted by started_at descending")
|
|
}
|
|
})
|
|
|
|
t.Run("RunningProcessesSortedFirst", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
// Start an exited process first.
|
|
exitedID := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo done",
|
|
})
|
|
waitForExit(t, handler, exitedID)
|
|
|
|
// Start a running process after.
|
|
runningID := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
w := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ListProcessesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Processes, 2)
|
|
|
|
// Running process should come first regardless of
|
|
// start order.
|
|
require.Equal(t, runningID, resp.Processes[0].ID)
|
|
require.True(t, resp.Processes[0].Running)
|
|
require.Equal(t, exitedID, resp.Processes[1].ID)
|
|
require.False(t, resp.Processes[1].Running)
|
|
|
|
// Clean up.
|
|
postSignal(t, handler, runningID, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
})
|
|
|
|
t.Run("MixedRunningAndExited", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
// Start a process that exits quickly.
|
|
exitedID := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo done",
|
|
})
|
|
waitForExit(t, handler, exitedID)
|
|
|
|
// Start a long-running process.
|
|
runningID := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
// List should contain both.
|
|
w := getList(t, handler)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ListProcessesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Processes, 2)
|
|
|
|
procMap := make(map[string]workspacesdk.ProcessInfo)
|
|
for _, p := range resp.Processes {
|
|
procMap[p.ID] = p
|
|
}
|
|
|
|
exited, ok := procMap[exitedID]
|
|
require.True(t, ok, "exited process should be in list")
|
|
require.False(t, exited.Running)
|
|
require.NotNil(t, exited.ExitCode)
|
|
|
|
running, ok := procMap[runningID]
|
|
require.True(t, ok, "running process should be in list")
|
|
require.True(t, running.Running)
|
|
|
|
// Clean up the long-running process.
|
|
sw := postSignal(t, handler, runningID, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
require.Equal(t, http.StatusOK, sw.Code)
|
|
})
|
|
}
|
|
|
|
// getListWithChatHeader sends a GET /list request with the
|
|
// Coder-Chat-Id header set and returns the recorder.
|
|
func getListWithChatHeader(t *testing.T, handler http.Handler, chatID string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, "/list", nil)
|
|
if chatID != "" {
|
|
r.Header.Set(workspacesdk.CoderChatIDHeader, chatID)
|
|
}
|
|
handler.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
func TestProcessOutput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ExitedProcess", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo hello-output",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
require.Contains(t, resp.Output, "hello-output")
|
|
})
|
|
|
|
t.Run("RunningProcess", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
w := getOutput(t, handler, id)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp workspacesdk.ProcessOutputResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Running)
|
|
|
|
// Kill and wait for the process so cleanup does
|
|
// not hang.
|
|
postSignal(
|
|
t, handler, id,
|
|
workspacesdk.SignalProcessRequest{Signal: "kill"},
|
|
)
|
|
waitForExit(t, handler, id)
|
|
})
|
|
|
|
t.Run("NonexistentProcess", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
w := getOutput(t, handler, "nonexistent-id-12345")
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
var resp codersdk.Response
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Contains(t, resp.Message, "not found")
|
|
})
|
|
}
|
|
|
|
func TestSignalProcess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("KillRunning", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
w := postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify the process exits.
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
})
|
|
|
|
t.Run("TerminateRunning", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("SIGTERM is not supported on Windows")
|
|
}
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
w := postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "terminate",
|
|
})
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify the process exits.
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
})
|
|
|
|
t.Run("NonexistentProcess", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
w := postSignal(t, handler, "nonexistent-id-12345", workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
})
|
|
|
|
t.Run("AlreadyExitedProcess", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo done",
|
|
})
|
|
|
|
// Wait for exit first.
|
|
waitForExit(t, handler, id)
|
|
|
|
// Signaling an exited process should return 409
|
|
// Conflict via the errProcessNotRunning sentinel.
|
|
w := postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
assert.Equal(t, http.StatusConflict, w.Code,
|
|
"expected 409 for signaling exited process, got %d", w.Code)
|
|
})
|
|
|
|
t.Run("EmptySignal", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
w := postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "",
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
var resp codersdk.Response
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Contains(t, resp.Message, "Signal is required")
|
|
|
|
// Clean up.
|
|
postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
})
|
|
|
|
t.Run("InvalidSignal", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
w := postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "SIGFOO",
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
var resp codersdk.Response
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
require.Contains(t, resp.Message, "Unsupported signal")
|
|
|
|
// Clean up.
|
|
postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestHandleStartProcess_ChatHeaders_EmptyWorkDir_StillNotifies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
chatID := uuid.New()
|
|
ch, unsub := pathStore.Subscribe(chatID)
|
|
defer unsub()
|
|
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, func(current []string) ([]string, error) {
|
|
return current, nil
|
|
}, pathStore, nil)
|
|
defer api.Close()
|
|
|
|
routes := api.Routes()
|
|
|
|
body, err := json.Marshal(workspacesdk.StartProcessRequest{
|
|
Command: "echo hello",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/start", bytes.NewReader(body))
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
rw := httptest.NewRecorder()
|
|
routes.ServeHTTP(rw, req)
|
|
|
|
require.Equal(t, http.StatusOK, rw.Code)
|
|
|
|
// The subscriber should be notified even though no paths
|
|
// were added.
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(testutil.WaitShort):
|
|
t.Fatal("timed out waiting for path store notification")
|
|
}
|
|
|
|
// No paths should have been stored for this chat.
|
|
require.Nil(t, pathStore.GetPaths(chatID))
|
|
}
|
|
|
|
func TestProcessLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("StartWaitCheckOutput", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo lifecycle-test && echo second-line",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
require.Contains(t, resp.Output, "lifecycle-test")
|
|
require.Contains(t, resp.Output, "second-line")
|
|
})
|
|
|
|
t.Run("NonZeroExitCode", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "exit 42",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 42, *resp.ExitCode)
|
|
})
|
|
|
|
t.Run("StartSignalVerifyExit", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
// Start a long-running background process.
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "sleep 300",
|
|
Background: true,
|
|
})
|
|
|
|
// Verify it's running.
|
|
w := getOutput(t, handler, id)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
var running workspacesdk.ProcessOutputResponse
|
|
err := json.NewDecoder(w.Body).Decode(&running)
|
|
require.NoError(t, err)
|
|
require.True(t, running.Running)
|
|
|
|
// Signal it.
|
|
sw := postSignal(t, handler, id, workspacesdk.SignalProcessRequest{
|
|
Signal: "kill",
|
|
})
|
|
require.Equal(t, http.StatusOK, sw.Code)
|
|
|
|
// Verify it exits.
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
require.NotNil(t, resp.ExitCode)
|
|
})
|
|
|
|
t.Run("OutputExceedsBuffer", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
// Generate output that exceeds MaxHeadBytes +
|
|
// MaxTailBytes. Each line is ~100 chars, and we
|
|
// need more than 32KB total (16KB head + 16KB
|
|
// tail).
|
|
lineCount := (agentproc.MaxHeadBytes+agentproc.MaxTailBytes)/50 + 500
|
|
cmd := fmt.Sprintf(
|
|
"for i in $(seq 1 %d); do echo \"line-$i-padding-to-make-this-longer-than-fifty-characters-total\"; done",
|
|
lineCount,
|
|
)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: cmd,
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
|
|
// The output should be truncated with head/tail
|
|
// strategy metadata.
|
|
require.NotNil(t, resp.Truncated, "large output should be truncated")
|
|
require.Equal(t, "head_tail", resp.Truncated.Strategy)
|
|
require.Greater(t, resp.Truncated.OmittedBytes, 0)
|
|
require.Greater(t, resp.Truncated.OriginalBytes, resp.Truncated.RetainedBytes)
|
|
|
|
// Verify the output contains the omission marker.
|
|
require.Contains(t, resp.Output, "... [omitted")
|
|
})
|
|
|
|
t.Run("StderrCaptured", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newTestAPI(t)
|
|
|
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
|
Command: "echo stdout-msg && echo stderr-msg >&2",
|
|
})
|
|
|
|
resp := waitForExit(t, handler, id)
|
|
require.False(t, resp.Running)
|
|
require.NotNil(t, resp.ExitCode)
|
|
require.Equal(t, 0, *resp.ExitCode)
|
|
// Both stdout and stderr should be captured.
|
|
require.Contains(t, resp.Output, "stdout-msg")
|
|
require.Contains(t, resp.Output, "stderr-msg")
|
|
})
|
|
}
|