mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement agent socket api, client and cli (#20758)
closes: https://github.com/coder/coder/issues/10352 closes: https://github.com/coder/internal/issues/1094 closes: https://github.com/coder/internal/issues/1095 In this pull request, we enable a new set of experimental cli commands grouped under `coder exp sync`. These commands allow any process acting within a coder workspace to inform the coder agent of its requirements and execution progress. The coder agent will then relay this information to other processes that have subscribed. These commands are: ``` # Check if this feature is enabled in your environment coder exp sync ping # express that your unit depends on another coder exp sync want <unit> <dependency_unit> # express that your unit intends to start a portion of the script that requires # other units to have completed first. This command blocks until all dependencies have been met coder exp sync start <unit> # express that your unit has completes its work, allowing dependent units to begin their execution coder exp sync complete <unit> ``` Example: In order to automatically run claude code in a new workspace, it must first have a git repository cloned. The scripts responsible for cloning the repository and for running claude code would coordinate in the following way: ```bash # Script A: Claude code # Inform the agent that the claude script wants the git script. # That is, the git script must have completed before the claude script can begin its execution coder exp sync want claude git # Inform the agent that we would now like to begin execution of claude. # This command will block until the git script (and any other defined dependencies) # have completed coder exp sync start claude # Now we run claude code and any other commands we need claude ... # Once our script has completed, we inform the agent, so that any scripts that depend on this one # may begin their execution coder exp sync complete claude ``` ```bash # Script B: Git # Because the git script does not have any dependencies, we can simply inform the agent that we # intend to start coder exp sync start git git clone ssh://git@github.com/coder/coder # Once the repository have been cloned, we inform the agent that this script is complete, so that # scripts that depend on it may begin their execution. coder exp sync complete git ``` Notes: * Unit names (ie. `claude` and `git`) given as input to the sync commands are arbitrary strings. You do not have to conform to specific identifiers. We recommend naming your scripts descriptively, but succinctly. * Scripts unit names should be well documented. Other scripts will need to know the names you've chosen in order to depend on yours. Therefore, you --------- Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
This commit is contained in:
co-authored by
Mathias Fredriksson
parent
ee58f40cad
commit
ce627bf23f
@@ -57,6 +57,8 @@ func workspaceAgent() *serpent.Command {
|
||||
devcontainers bool
|
||||
devcontainerProjectDiscovery bool
|
||||
devcontainerDiscoveryAutostart bool
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
)
|
||||
agentAuth := &AgentAuth{}
|
||||
cmd := &serpent.Command{
|
||||
@@ -317,6 +319,8 @@ func workspaceAgent() *serpent.Command {
|
||||
agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery),
|
||||
agentcontainers.WithDiscoveryAutostart(devcontainerDiscoveryAutostart),
|
||||
},
|
||||
SocketPath: socketPath,
|
||||
SocketServerEnabled: socketServerEnabled,
|
||||
})
|
||||
|
||||
if debugAddress != "" {
|
||||
@@ -477,6 +481,19 @@ func workspaceAgent() *serpent.Command {
|
||||
Description: "Allow the agent to autostart devcontainer projects it discovers based on their configuration.",
|
||||
Value: serpent.BoolOf(&devcontainerDiscoveryAutostart),
|
||||
},
|
||||
{
|
||||
Flag: "socket-server-enabled",
|
||||
Default: "false",
|
||||
Env: "CODER_AGENT_SOCKET_SERVER_ENABLED",
|
||||
Description: "Enable the agent socket server.",
|
||||
Value: serpent.BoolOf(&socketServerEnabled),
|
||||
},
|
||||
{
|
||||
Flag: "socket-path",
|
||||
Env: "CODER_AGENT_SOCKET_PATH",
|
||||
Description: "Specify the path for the agent socket.",
|
||||
Value: serpent.StringOf(&socketPath),
|
||||
},
|
||||
}
|
||||
agentAuth.AttachOptions(cmd, false)
|
||||
return cmd
|
||||
|
||||
@@ -150,6 +150,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
|
||||
r.mcpCommand(),
|
||||
r.promptExample(),
|
||||
r.rptyCommand(),
|
||||
r.syncCommand(),
|
||||
r.boundary(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,31 @@ func TestCommandHelp(t *testing.T) {
|
||||
Name: "coder provisioner jobs list --output json",
|
||||
Cmd: []string{"provisioner", "jobs", "list", "--output", "json"},
|
||||
},
|
||||
// TODO (SasSwart): Remove these once the sync commands are promoted out of experimental.
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync --help",
|
||||
Cmd: []string{"exp", "sync", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync ping --help",
|
||||
Cmd: []string{"exp", "sync", "ping", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync start --help",
|
||||
Cmd: []string{"exp", "sync", "start", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync want --help",
|
||||
Cmd: []string{"exp", "sync", "want", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync complete --help",
|
||||
Cmd: []string{"exp", "sync", "complete", "--help"},
|
||||
},
|
||||
clitest.CommandHelpCase{
|
||||
Name: "coder exp sync status --help",
|
||||
Cmd: []string{"exp", "sync", "status", "--help"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) syncCommand() *serpent.Command {
|
||||
var socketPath string
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "sync",
|
||||
Short: "Manage unit dependencies for coordinated startup",
|
||||
Long: "Commands for orchestrating unit startup order in workspaces. Units are most commonly coder scripts. Use these commands to declare dependencies between units, coordinate their startup sequence, and ensure units start only after their dependencies are ready. This helps prevent race conditions and startup failures.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
return i.Command.HelpHandler(i)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.syncPing(&socketPath),
|
||||
r.syncStart(&socketPath),
|
||||
r.syncWant(&socketPath),
|
||||
r.syncComplete(&socketPath),
|
||||
r.syncStatus(&socketPath),
|
||||
},
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Flag: "socket-path",
|
||||
Env: "CODER_AGENT_SOCKET_PATH",
|
||||
Description: "Specify the path for the agent socket.",
|
||||
Value: serpent.StringOf(&socketPath),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) syncComplete(socketPath *string) *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "complete <unit>",
|
||||
Short: "Mark a unit as complete",
|
||||
Long: "Mark a unit as complete. Indicating to other units that it has completed its work. This allows units that depend on it to proceed with their startup.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
ctx := i.Context()
|
||||
|
||||
if len(i.Args) != 1 {
|
||||
return xerrors.New("exactly one unit name is required")
|
||||
}
|
||||
unit := unit.ID(i.Args[0])
|
||||
|
||||
opts := []agentsocket.Option{}
|
||||
if *socketPath != "" {
|
||||
opts = append(opts, agentsocket.WithPath(*socketPath))
|
||||
}
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to agent socket: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.SyncComplete(ctx, unit); err != nil {
|
||||
return xerrors.Errorf("complete unit failed: %w", err)
|
||||
}
|
||||
|
||||
cliui.Info(i.Stdout, "Success")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) syncPing(socketPath *string) *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "ping",
|
||||
Short: "Test agent socket connectivity and health",
|
||||
Long: "Test connectivity to the local Coder agent socket to verify the agent is running and responsive. Useful for troubleshooting startup issues or verifying the agent is accessible before running other sync commands.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
ctx := i.Context()
|
||||
|
||||
opts := []agentsocket.Option{}
|
||||
if *socketPath != "" {
|
||||
opts = append(opts, agentsocket.WithPath(*socketPath))
|
||||
}
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to agent socket: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
err = client.Ping(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("ping failed: %w", err)
|
||||
}
|
||||
|
||||
cliui.Info(i.Stdout, "Success")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
)
|
||||
|
||||
const (
|
||||
syncPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
func (*RootCmd) syncStart(socketPath *string) *serpent.Command {
|
||||
var timeout time.Duration
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "start <unit>",
|
||||
Short: "Wait until all unit dependencies are satisfied",
|
||||
Long: "Wait until all dependencies are satisfied, consider the unit to have started, then allow it to proceed. This command polls until dependencies are ready, then marks the unit as started.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
ctx := i.Context()
|
||||
|
||||
if len(i.Args) != 1 {
|
||||
return xerrors.New("exactly one unit name is required")
|
||||
}
|
||||
unitName := unit.ID(i.Args[0])
|
||||
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
opts := []agentsocket.Option{}
|
||||
if *socketPath != "" {
|
||||
opts = append(opts, agentsocket.WithPath(*socketPath))
|
||||
}
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to agent socket: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ready, err := client.SyncReady(ctx, unitName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("error checking dependencies: %w", err)
|
||||
}
|
||||
|
||||
if !ready {
|
||||
cliui.Infof(i.Stdout, "Waiting for dependencies of unit '%s' to be satisfied...", unitName)
|
||||
|
||||
ticker := time.NewTicker(syncPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
pollLoop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return xerrors.Errorf("timeout waiting for dependencies of unit '%s'", unitName)
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
ready, err := client.SyncReady(ctx, unitName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("error checking dependencies: %w", err)
|
||||
}
|
||||
if ready {
|
||||
break pollLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.SyncStart(ctx, unitName); err != nil {
|
||||
return xerrors.Errorf("start unit failed: %w", err)
|
||||
}
|
||||
|
||||
cliui.Info(i.Stdout, "Success")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, serpent.Option{
|
||||
Flag: "timeout",
|
||||
Description: "Maximum time to wait for dependencies (e.g., 30s, 5m). 5m by default.",
|
||||
Value: serpent.DurationOf(&timeout),
|
||||
Default: "5m",
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
)
|
||||
|
||||
func (*RootCmd) syncStatus(socketPath *string) *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat(
|
||||
[]agentsocket.DependencyInfo{},
|
||||
[]string{
|
||||
"depends on",
|
||||
"required status",
|
||||
"current status",
|
||||
"satisfied",
|
||||
},
|
||||
),
|
||||
func(data any) (any, error) {
|
||||
resp, ok := data.(agentsocket.SyncStatusResponse)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("expected agentsocket.SyncStatusResponse, got %T", data)
|
||||
}
|
||||
return resp.Dependencies, nil
|
||||
}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "status <unit>",
|
||||
Short: "Show unit status and dependency state",
|
||||
Long: "Show the current status of a unit, whether it is ready to start, and lists its dependencies. Shows which dependencies are satisfied and which are still pending. Supports multiple output formats.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
ctx := i.Context()
|
||||
|
||||
if len(i.Args) != 1 {
|
||||
return xerrors.New("exactly one unit name is required")
|
||||
}
|
||||
unit := unit.ID(i.Args[0])
|
||||
|
||||
opts := []agentsocket.Option{}
|
||||
if *socketPath != "" {
|
||||
opts = append(opts, agentsocket.WithPath(*socketPath))
|
||||
}
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to agent socket: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
statusResp, err := client.SyncStatus(ctx, unit)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get status failed: %w", err)
|
||||
}
|
||||
|
||||
var out string
|
||||
header := fmt.Sprintf("Unit: %s\nStatus: %s\nReady: %t\n\nDependencies:\n", unit, statusResp.Status, statusResp.IsReady)
|
||||
if formatter.FormatID() == "table" && len(statusResp.Dependencies) == 0 {
|
||||
out = header + "No dependencies found"
|
||||
} else {
|
||||
out, err = formatter.Format(ctx, statusResp)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("format status: %w", err)
|
||||
}
|
||||
|
||||
if formatter.FormatID() == "table" {
|
||||
out = header + out
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(i.Stdout, out)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"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 := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
|
||||
// Create parent directory if needed
|
||||
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 dependency: test-unit depends on dep-unit
|
||||
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Declare dependency
|
||||
err = client.SyncWant(ctx, "test-unit", "dep-unit")
|
||||
require.NoError(t, err)
|
||||
client.Close()
|
||||
|
||||
// Start a goroutine to complete the dependency after a short delay
|
||||
// This simulates the dependency being satisfied while start is waiting
|
||||
// The delay ensures the "Waiting..." message appears in the output
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
// Wait a moment to let the start command begin waiting and print the message
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
compCtx := context.Background()
|
||||
compClient, err := agentsocket.NewClient(compCtx, agentsocket.WithPath(path))
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
defer compClient.Close()
|
||||
|
||||
// Start and complete the dependency unit
|
||||
err = compClient.SyncStart(compCtx, "dep-unit")
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
err = compClient.SyncComplete(compCtx, "dep-unit")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
var outBuf bytes.Buffer
|
||||
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 dependency
|
||||
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 <-time.After(time.Second):
|
||||
// Goroutine should have finished by now
|
||||
}
|
||||
|
||||
clitest.TestGoldenFile(t, "TestSyncCommands_Golden/start_with_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("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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
)
|
||||
|
||||
func (*RootCmd) syncWant(socketPath *string) *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "want <unit> <depends-on>",
|
||||
Short: "Declare that a unit depends on another unit completing before it can start",
|
||||
Long: "Declare that a unit depends on another unit completing before it can start. The unit specified first will not start until the second has signaled that it has completed.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
ctx := i.Context()
|
||||
|
||||
if len(i.Args) != 2 {
|
||||
return xerrors.New("exactly two arguments are required: unit and depends-on")
|
||||
}
|
||||
dependentUnit := unit.ID(i.Args[0])
|
||||
dependsOn := unit.ID(i.Args[1])
|
||||
|
||||
opts := []agentsocket.Option{}
|
||||
if *socketPath != "" {
|
||||
opts = append(opts, agentsocket.WithPath(*socketPath))
|
||||
}
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("connect to agent socket: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.SyncWant(ctx, dependentUnit, dependsOn); err != nil {
|
||||
return xerrors.Errorf("declare dependency failed: %w", err)
|
||||
}
|
||||
|
||||
cliui.Info(i.Stdout, "Success")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Success
|
||||
@@ -0,0 +1 @@
|
||||
Success
|
||||
@@ -0,0 +1 @@
|
||||
Success
|
||||
@@ -0,0 +1,2 @@
|
||||
Waiting for dependencies of unit 'test-unit' to be satisfied...
|
||||
Success
|
||||
@@ -0,0 +1,6 @@
|
||||
Unit: test-unit
|
||||
Status: completed
|
||||
Ready: true
|
||||
|
||||
Dependencies:
|
||||
No dependencies found
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"unit_name": "test-unit",
|
||||
"status": "pending",
|
||||
"is_ready": true,
|
||||
"dependencies": [
|
||||
{
|
||||
"depends_on": "dep-unit",
|
||||
"required_status": "completed",
|
||||
"current_status": "completed",
|
||||
"is_satisfied": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Unit: test-unit
|
||||
Status: pending
|
||||
Ready: false
|
||||
|
||||
Dependencies:
|
||||
DEPENDS ON REQUIRED STATUS CURRENT STATUS SATISFIED
|
||||
dep-unit completed not registered false
|
||||
@@ -0,0 +1,6 @@
|
||||
Unit: test-unit
|
||||
Status: started
|
||||
Ready: true
|
||||
|
||||
Dependencies:
|
||||
No dependencies found
|
||||
@@ -0,0 +1,8 @@
|
||||
Unit: test-unit
|
||||
Status: pending
|
||||
Ready: false
|
||||
|
||||
Dependencies:
|
||||
DEPENDS ON REQUIRED STATUS CURRENT STATUS SATISFIED
|
||||
dep-1 completed completed true
|
||||
dep-2 completed not registered false
|
||||
@@ -0,0 +1 @@
|
||||
Success
|
||||
+6
@@ -67,6 +67,12 @@ OPTIONS:
|
||||
--script-data-dir string, $CODER_AGENT_SCRIPT_DATA_DIR (default: /tmp)
|
||||
Specify the location for storing script data.
|
||||
|
||||
--socket-path string, $CODER_AGENT_SOCKET_PATH
|
||||
Specify the path for the agent socket.
|
||||
|
||||
--socket-server-enabled bool, $CODER_AGENT_SOCKET_SERVER_ENABLED (default: false)
|
||||
Enable the agent socket server.
|
||||
|
||||
--ssh-max-timeout duration, $CODER_AGENT_SSH_MAX_TIMEOUT (default: 72h)
|
||||
Specify the max timeout for a SSH connection, it is advisable to set
|
||||
it to a minimum of 60s, but no more than 72h.
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder exp sync [flags]
|
||||
|
||||
Manage unit dependencies for coordinated startup
|
||||
|
||||
Commands for orchestrating unit startup order in workspaces. Units are most
|
||||
commonly coder scripts. Use these commands to declare dependencies between
|
||||
units, coordinate their startup sequence, and ensure units start only after
|
||||
their dependencies are ready. This helps prevent race conditions and startup
|
||||
failures.
|
||||
|
||||
SUBCOMMANDS:
|
||||
complete Mark a unit as complete
|
||||
ping Test agent socket connectivity and health
|
||||
start Wait until all unit dependencies are satisfied
|
||||
status Show unit status and dependency state
|
||||
want Declare that a unit depends on another unit completing before it
|
||||
can start
|
||||
|
||||
OPTIONS:
|
||||
--socket-path string, $CODER_AGENT_SOCKET_PATH
|
||||
Specify the path for the agent socket.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -0,0 +1,12 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder exp sync complete <unit>
|
||||
|
||||
Mark a unit as complete
|
||||
|
||||
Mark a unit as complete. Indicating to other units that it has completed its
|
||||
work. This allows units that depend on it to proceed with their startup.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder exp sync ping
|
||||
|
||||
Test agent socket connectivity and health
|
||||
|
||||
Test connectivity to the local Coder agent socket to verify the agent is
|
||||
running and responsive. Useful for troubleshooting startup issues or verifying
|
||||
the agent is accessible before running other sync commands.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder exp sync start [flags] <unit>
|
||||
|
||||
Wait until all unit dependencies are satisfied
|
||||
|
||||
Wait until all dependencies are satisfied, consider the unit to have started,
|
||||
then allow it to proceed. This command polls until dependencies are ready,
|
||||
then marks the unit as started.
|
||||
|
||||
OPTIONS:
|
||||
--timeout duration (default: 5m)
|
||||
Maximum time to wait for dependencies (e.g., 30s, 5m). 5m by default.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -0,0 +1,20 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder exp sync status [flags] <unit>
|
||||
|
||||
Show unit status and dependency state
|
||||
|
||||
Show the current status of a unit, whether it is ready to start, and lists its
|
||||
dependencies. Shows which dependencies are satisfied and which are still
|
||||
pending. Supports multiple output formats.
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [depends on|required status|current status|satisfied] (default: depends on,required status,current status,satisfied)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder exp sync want <unit> <depends-on>
|
||||
|
||||
Declare that a unit depends on another unit completing before it can start
|
||||
|
||||
Declare that a unit depends on another unit completing before it can start.
|
||||
The unit specified first will not start until the second has signaled that it
|
||||
has completed.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
Reference in New Issue
Block a user