mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
7d95153bf4
Add a new subcommand to list all registered sync units and their current
statuses. This provides a quick overview of the dependency coordination
state in a workspace without needing to query each unit individually.
The command supports both table (default) and JSON output formats.
```
$ coder exp sync list
UNIT STATUS READY
unit-a started true
unit-b completed true
unit-c pending false
$ coder exp sync list --output json
[
{
"unit_name": "my-unit",
"status": "started",
"is_ready": true
}
]
```
When no units are registered, the command prints `No units registered`.
<details><summary>Changes across layers</summary>
- `agent/unit`: add `Manager.ListUnits()` method
- `agent/agentsocket/proto`: add `SyncList` RPC, bump API to v1.2
- `agent/agentsocket`: add service and client implementations
- `cli`: add `sync_list.go` command, register in `sync.go`
- Tests: three golden-file test cases (empty list, multiple units, JSON)
</details>
> Generated by Coder Agents on behalf of @SasSwart
---------
Co-authored-by: Cian Johnston <cian@coder.com>
490 lines
14 KiB
Go
490 lines
14 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/agent/agentsocket"
|
|
"github.com/coder/coder/v2/cli/clitest"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// setupSocketServer creates an agentsocket server at a temporary path for testing.
|
|
// Returns the socket path and a cleanup function. The path should be passed to
|
|
// sync commands via the --socket-path flag.
|
|
func setupSocketServer(t *testing.T) (path string, cleanup func()) {
|
|
t.Helper()
|
|
|
|
// Use a temporary socket path for each test
|
|
socketPath := testutil.AgentSocketPath(t)
|
|
|
|
// Create parent directory if needed. Not necessary on Windows because named pipes live in an abstract namespace
|
|
// not tied to any real files.
|
|
if runtime.GOOS != "windows" {
|
|
parentDir := filepath.Dir(socketPath)
|
|
err := os.MkdirAll(parentDir, 0o700)
|
|
require.NoError(t, err, "create socket directory")
|
|
}
|
|
|
|
server, err := agentsocket.NewServer(
|
|
slog.Make().Leveled(slog.LevelDebug),
|
|
agentsocket.WithPath(socketPath),
|
|
)
|
|
require.NoError(t, err, "create socket server")
|
|
|
|
// Return cleanup function
|
|
return socketPath, func() {
|
|
err := server.Close()
|
|
require.NoError(t, err, "close socket server")
|
|
_ = os.Remove(socketPath)
|
|
}
|
|
}
|
|
|
|
func TestSyncCommands_Golden(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ping", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "ping", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/ping_success", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("start_no_dependencies", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_no_dependencies", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("start_with_dependencies", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Set up dependencies: test-unit depends on dep-unit and dep-unit-2.
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
|
|
err = client.SyncWant(ctx, "test-unit", "dep-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncWant(ctx, "test-unit", "dep-unit-2")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
outBuf := testutil.NewWaitBuffer()
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
if err := outBuf.WaitFor(ctx, "is waiting for dependencies"); err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
|
|
compCtx := context.Background()
|
|
compClient, err := agentsocket.NewClient(compCtx, agentsocket.WithPath(path))
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
defer compClient.Close()
|
|
|
|
// Start and complete both dependency units.
|
|
err = compClient.SyncStart(compCtx, "dep-unit")
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
err = compClient.SyncComplete(compCtx, "dep-unit")
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
err = compClient.SyncStart(compCtx, "dep-unit-2")
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
err = compClient.SyncComplete(compCtx, "dep-unit-2")
|
|
done <- err
|
|
}()
|
|
|
|
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
|
|
inv.Stdout = outBuf
|
|
inv.Stderr = outBuf
|
|
|
|
// Run the start command. It should wait for the dependencies.
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
// Ensure the completion goroutine finished.
|
|
select {
|
|
case err := <-done:
|
|
require.NoError(t, err, "complete dependency")
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for dependency completion goroutine")
|
|
}
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_dependencies", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("start_with_satisfied_dependencies", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Set up dependencies: test-unit depends on dep-unit and dep-unit-2.
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
|
|
err = client.SyncWant(ctx, "test-unit", "dep-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncWant(ctx, "test-unit", "dep-unit-2")
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "dep-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncComplete(ctx, "dep-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "dep-unit-2")
|
|
require.NoError(t, err)
|
|
err = client.SyncComplete(ctx, "dep-unit-2")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "start", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_satisfied_dependencies", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("want", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "want", "test-unit", "dep-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/want_success", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("want_multiple_deps", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "want", "test-unit", "dep-1", "dep-2", "dep-3", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
require.Contains(t, outBuf.String(), "Unit \"test-unit\" declared dependencies: [dep-1, dep-2, dep-3]")
|
|
require.Contains(t, outBuf.String(), "dep-1")
|
|
require.Contains(t, outBuf.String(), "dep-2")
|
|
require.Contains(t, outBuf.String(), "dep-3")
|
|
|
|
// Verify all dependencies were registered by checking status.
|
|
outBuf.Reset()
|
|
inv, _ = clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path, "--output", "json")
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
// The output should mention all three dependencies.
|
|
output := outBuf.String()
|
|
require.Contains(t, output, "dep-1")
|
|
require.Contains(t, output, "dep-2")
|
|
require.Contains(t, output, "dep-3")
|
|
})
|
|
|
|
t.Run("complete", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// First start the unit
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "test-unit")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "complete", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/complete_success", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("status_pending", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Set up a unit with unsatisfied dependency
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncWant(ctx, "test-unit", "dep-unit")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_pending", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("status_started", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Start a unit
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "test-unit")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_started", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("status_completed", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Start and complete a unit
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "test-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncComplete(ctx, "test-unit")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_completed", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("status_with_dependencies", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Set up a unit with dependencies, some satisfied, some not
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncWant(ctx, "test-unit", "dep-1")
|
|
require.NoError(t, err)
|
|
err = client.SyncWant(ctx, "test-unit", "dep-2")
|
|
require.NoError(t, err)
|
|
// Complete dep-1, leave dep-2 incomplete
|
|
err = client.SyncStart(ctx, "dep-1")
|
|
require.NoError(t, err)
|
|
err = client.SyncComplete(ctx, "dep-1")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_with_dependencies", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("status_json_format", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Set up a unit with dependencies
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncWant(ctx, "test-unit", "dep-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "dep-unit")
|
|
require.NoError(t, err)
|
|
err = client.SyncComplete(ctx, "dep-unit")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "status", "test-unit", "--output", "json", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/status_json_format", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("list_no_units", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "list", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err := inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_no_units", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("list_with_units", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Register some units in various states.
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
// unit-a: started
|
|
err = client.SyncStart(ctx, "unit-a")
|
|
require.NoError(t, err)
|
|
// unit-b: completed
|
|
err = client.SyncStart(ctx, "unit-b")
|
|
require.NoError(t, err)
|
|
err = client.SyncComplete(ctx, "unit-b")
|
|
require.NoError(t, err)
|
|
// unit-c: pending (has unsatisfied dependency on unit-a completing)
|
|
err = client.SyncWant(ctx, "unit-c", "unit-a")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "list", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_with_units", outBuf.Bytes(), nil)
|
|
})
|
|
|
|
t.Run("list_json_format", func(t *testing.T) {
|
|
t.Parallel()
|
|
path, cleanup := setupSocketServer(t)
|
|
defer cleanup()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Register a unit.
|
|
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
|
require.NoError(t, err)
|
|
err = client.SyncStart(ctx, "my-unit")
|
|
require.NoError(t, err)
|
|
client.Close()
|
|
|
|
var outBuf bytes.Buffer
|
|
inv, _ := clitest.New(t, "exp", "sync", "list", "--output", "json", "--socket-path", path)
|
|
inv.Stdout = &outBuf
|
|
inv.Stderr = &outBuf
|
|
|
|
err = inv.WithContext(ctx).Run()
|
|
require.NoError(t, err)
|
|
|
|
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_json_format", outBuf.Bytes(), nil)
|
|
})
|
|
}
|