diff --git a/pkg/cli/root.go b/pkg/cli/root.go index d1a66a16..2da4b702 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -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 ) // Configure applies options to the root command (e.g. for tests or alternate entry points). @@ -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 } } @@ -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 + } + 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 +} diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index a8c2118c..8149891a 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "errors" + "strings" "testing" "github.com/agentregistry-dev/agentregistry/internal/client" @@ -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 { @@ -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") + } + }) + } +} diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 625e4962..7d1c682f 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -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() { diff --git a/pkg/types/types.go b/pkg/types/types.go index 445058a1..c50e1f1b 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -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 } // CLIAuthnProvider provides authentication for CLI commands.