Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions pkg/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ type CLIOptions struct {
}

var (
cliOptions CLIOptions
registryURL string
registryToken string
cliOptions CLIOptions
registryURL string
registryToken string
// Package-level var so tests can stub out the real docker-compose check.
isDockerComposeAvailable = utils.IsDockerComposeAvailable
Comment on lines +52 to +53
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine for now - I think this indicates a clear smell with the daemon manager component though. The interface should abstract whether docker compose is available, or we define something like a no-op daemon manager and call the right constructor in the CLI run logic, etc.

)

// Configure applies options to the root command (e.g. for tests or alternate entry points).
Expand Down Expand Up @@ -224,16 +226,8 @@ func preRunSetup(ctx context.Context, cmd *cobra.Command, baseURL, token string,
}

if autoStartDaemon {
if !utils.IsDockerComposeAvailable() {
fmt.Println("Docker compose is not available. Please install docker compose and try again.")
fmt.Println("See https://docs.docker.com/compose/install/ for installation instructions.")
fmt.Println("agent registry uses docker compose to start the server and the agent gateway.")
return nil, fmt.Errorf("docker compose is not available")
}
if !dm.IsRunning() {
if err := dm.Start(); err != nil {
return nil, fmt.Errorf("failed to start daemon: %w", err)
}
if err := ensureDaemonRunning(dm, baseURL); err != nil {
return nil, err
}
}

Expand Down Expand Up @@ -265,3 +259,22 @@ func preRunSetup(ctx context.Context, cmd *cobra.Command, baseURL, token string,
}
return c, nil
}

func ensureDaemonRunning(dm types.DaemonManager, baseURL string) error {
if !isDockerComposeAvailable() {
fmt.Println("Docker compose is not available. Please install docker compose and try again.")
fmt.Println("See https://docs.docker.com/compose/install/ for installation instructions.")
fmt.Println("agent registry uses docker compose to start the server and the agent gateway.")
return fmt.Errorf("docker compose is not available")
}
if dm.IsRunning() {
return nil
}
Comment on lines +264 to +272
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this logic be flipped? We check if dm.IsRunning() first before validating whether docker compose is available in the env?

if err := dm.Start(); err != nil {
return fmt.Errorf("failed to start daemon: %w", err)
}
if err := dm.WaitForReady(baseURL); err != nil {
return fmt.Errorf("daemon started but not ready: %w", err)
}
return nil
}
88 changes: 85 additions & 3 deletions pkg/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"errors"
"strings"
"testing"

"github.com/agentregistry-dev/agentregistry/internal/client"
Expand Down Expand Up @@ -326,15 +327,18 @@ func TestPreRunSetup(t *testing.T) {

// mockDaemonManager for unit tests.
type mockDaemonManager struct {
running bool
startCalled bool
running bool
startCalled bool
startErr error
waitReadyErr error
}

func (m *mockDaemonManager) IsRunning() bool { return m.running }
func (m *mockDaemonManager) Start() error {
m.startCalled = true
return nil
return m.startErr
}
func (m *mockDaemonManager) WaitForReady(baseURL string) error { return m.waitReadyErr }

// mockAuthnProvider for unit tests.
type mockAuthnProvider struct {
Expand All @@ -351,3 +355,81 @@ func (m *mockAuthnProvider) Authenticate(context.Context) (string, error) {

var _ types.DaemonManager = (*mockDaemonManager)(nil)
var _ types.CLIAuthnProvider = (*mockAuthnProvider)(nil)

func TestEnsureDaemonRunning(t *testing.T) {
startErr := errors.New("start failed")
waitErr := errors.New("not ready")

tests := []struct {
name string
dockerAvail bool
dm *mockDaemonManager
wantErr string
wantStartCalled bool
}{
{
name: "docker compose not available",
dockerAvail: false,
dm: &mockDaemonManager{},
wantErr: "docker compose is not available",
},
{
name: "daemon already running",
dockerAvail: true,
dm: &mockDaemonManager{running: true},
wantErr: "",
wantStartCalled: false,
},
{
name: "daemon not running starts successfully",
dockerAvail: true,
dm: &mockDaemonManager{running: false},
wantErr: "",
wantStartCalled: true,
},
{
name: "daemon start fails",
dockerAvail: true,
dm: &mockDaemonManager{running: false, startErr: startErr},
wantErr: "failed to start daemon",
wantStartCalled: true,
},
{
name: "daemon started but not ready",
dockerAvail: true,
dm: &mockDaemonManager{running: false, waitReadyErr: waitErr},
wantErr: "daemon started but not ready",
wantStartCalled: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origCheck := isDockerComposeAvailable
isDockerComposeAvailable = func() bool { return tt.dockerAvail }
defer func() { isDockerComposeAvailable = origCheck }()

err := ensureDaemonRunning(tt.dm, "http://localhost:12121")

if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
} else {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
}
}

if tt.wantStartCalled && !tt.dm.startCalled {
t.Error("expected Start() to be called")
}
if !tt.wantStartCalled && tt.dm.startCalled {
t.Error("expected Start() not to be called")
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ func (d *DefaultDaemonManager) Start() error {
return nil
}

func (d *DefaultDaemonManager) WaitForReady(baseURL string) error {
pingURL := strings.TrimRight(baseURL, "/") + "/ping"
httpClient := &http.Client{Timeout: 2 * time.Second}
deadline := time.Now().Add(30 * time.Second)
delay := 500 * time.Millisecond

for time.Now().Before(deadline) {
resp, err := httpClient.Get(pingURL)
if err == nil {
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
time.Sleep(delay)
delay = min(delay*2, 4*time.Second)
}
return fmt.Errorf("daemon did not become ready within 30 seconds")
}

func (d *DefaultDaemonManager) IsRunning() bool {
// First check if a server is responding on the API port (local or Docker)
if isServerResponding() {
Expand Down
2 changes: 2 additions & 0 deletions pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ type DaemonManager interface {
IsRunning() bool
// Start starts the daemon, blocking until it's ready
Start() error
// WaitForReady polls the daemon API until it responds or the timeout expires
WaitForReady(baseURL string) error
Comment on lines +155 to +156
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have IsRunning & Start methods defined for this interface. Wondering whether it makes sense to deflate this method into Start() since we already call --wait on docker compose. The one benefit is being able to call Start and then WaitForReady by consumers of this interface but the value seems low.

Overall, fine with extending this for now. I think there's some other cruft in this interface that's worth addressing first.

}

// CLIAuthnProvider provides authentication for CLI commands.
Expand Down
Loading