From 1061b66c638be647ec25bd1fb845a631db8471ed Mon Sep 17 00:00:00 2001 From: "Optimus (AI Agent)" Date: Wed, 11 Mar 2026 16:12:26 +0000 Subject: [PATCH 1/4] feat(cli): add `arctl status` command Add a read-only status command that reports the current state of the daemon, database connectivity, server version, and registry resource counts without attempting to start or modify the daemon. The command is added to skipCommands in preRunDaemonBehavior so PersistentPreRunE is skipped entirely - no daemon auto-start, no client initialization. The status command creates its own lightweight client only when the daemon is already reachable. When the daemon is not running the output is: arctl version x Daemon is not running (http://localhost:12121/v0) x Database is not reachable Registry resources: could not retrieve resources Co-Authored-By: Claude Opus 4.6 --- internal/cli/status.go | 147 ++++++++++++++++++++++++++++++++++++ internal/cli/status_test.go | 110 +++++++++++++++++++++++++++ pkg/cli/commands_test.go | 1 + pkg/cli/root.go | 2 + pkg/cli/root_test.go | 6 ++ 5 files changed, 266 insertions(+) create mode 100644 internal/cli/status.go create mode 100644 internal/cli/status_test.go diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 00000000..3242b378 --- /dev/null +++ b/internal/cli/status.go @@ -0,0 +1,147 @@ +package cli + +import ( + "fmt" + "net/http" + "time" + + "github.com/agentregistry-dev/agentregistry/internal/client" + "github.com/agentregistry-dev/agentregistry/internal/version" + "github.com/agentregistry-dev/agentregistry/pkg/printer" + "github.com/spf13/cobra" +) + +// StatusCmd shows the status of the daemon and database connectivity. +var StatusCmd = &cobra.Command{ + Use: "status", + Short: "Show status of the daemon and database", + Long: `Displays the current status of the AgentRegistry daemon, database connectivity, server version, and registry resource counts.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus() + }, +} + +func runStatus() error { + fmt.Printf("arctl version %s\n", version.Version) + fmt.Println() + + // Determine the base URL for health checks + baseURL := client.DefaultBaseURL + if apiClient != nil { + baseURL = apiClient.BaseURL + } + + // Check daemon / API health + daemonStatus := checkDaemonHealth(baseURL) + if daemonStatus.healthy { + printer.PrintSuccess(fmt.Sprintf("Daemon is running (%s)", daemonStatus.url)) + } else { + printer.PrintError(fmt.Sprintf("Daemon is not running (%s)", daemonStatus.url)) + printer.PrintError("Database is not reachable") + fmt.Println() + fmt.Println("Registry resources: could not retrieve resources") + return nil + } + + // When PersistentPreRunE is skipped (status is in skipCommands), + // apiClient is nil. Create a local client for querying the daemon. + c := apiClient + if c == nil { + c = client.NewClient(baseURL, "") + } + + // Server version + serverVersion, err := c.GetVersion() + if err != nil { + printer.PrintWarning(fmt.Sprintf("Could not retrieve server version: %v", err)) + } else { + fmt.Printf(" Server version: %s\n", serverVersion.Version) + fmt.Printf(" Server commit: %s\n", serverVersion.GitCommit) + fmt.Printf(" Server build date: %s\n", serverVersion.BuildTime) + } + + fmt.Println() + + // Database connectivity (inferred from ability to list resources) + dbOK := true + + servers, err := c.GetPublishedServers() + serverCount := 0 + if err != nil { + printer.PrintWarning(fmt.Sprintf("Could not list MCP servers: %v", err)) + dbOK = false + } else { + serverCount = len(servers) + } + + agents, err := c.GetAgents() + agentCount := 0 + if err != nil { + printer.PrintWarning(fmt.Sprintf("Could not list agents: %v", err)) + dbOK = false + } else { + agentCount = len(agents) + } + + skills, err := c.GetSkills() + skillCount := 0 + if err != nil { + printer.PrintWarning(fmt.Sprintf("Could not list skills: %v", err)) + dbOK = false + } else { + skillCount = len(skills) + } + + prompts, err := c.GetPrompts() + promptCount := 0 + if err != nil { + printer.PrintWarning(fmt.Sprintf("Could not list prompts: %v", err)) + dbOK = false + } else { + promptCount = len(prompts) + } + + if dbOK { + printer.PrintSuccess("Database is reachable") + } else { + printer.PrintWarning("Database may have issues (some queries failed)") + } + + fmt.Println() + fmt.Println("Registry resources:") + + tp := printer.NewTablePrinter(nil) + tp.SetHeaders("Resource", "Count") + tp.AddRow("MCP Servers", fmt.Sprintf("%d", serverCount)) + tp.AddRow("Agents", fmt.Sprintf("%d", agentCount)) + tp.AddRow("Skills", fmt.Sprintf("%d", skillCount)) + tp.AddRow("Prompts", fmt.Sprintf("%d", promptCount)) + if err := tp.Render(); err != nil { + return fmt.Errorf("rendering table: %w", err) + } + + return nil +} + +type daemonHealthResult struct { + healthy bool + url string + err string +} + +func checkDaemonHealth(baseURL string) daemonHealthResult { + healthURL := baseURL + "/ping" + + httpClient := &http.Client{Timeout: 5 * time.Second} + resp, err := httpClient.Get(healthURL) + if err != nil { + return daemonHealthResult{healthy: false, url: baseURL, err: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return daemonHealthResult{healthy: false, url: baseURL, err: fmt.Sprintf("HTTP %d", resp.StatusCode)} + } + + return daemonHealthResult{healthy: true, url: baseURL} +} diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go new file mode 100644 index 00000000..c31a6db3 --- /dev/null +++ b/internal/cli/status_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/agentregistry-dev/agentregistry/internal/client" +) + +func TestStatusCmd_Metadata(t *testing.T) { + if StatusCmd.Use != "status" { + t.Errorf("StatusCmd.Use = %q, want %q", StatusCmd.Use, "status") + } + if StatusCmd.Short == "" { + t.Error("StatusCmd.Short is empty") + } +} + +func TestCheckDaemonHealth_Healthy(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/ping" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + result := checkDaemonHealth(ts.URL + "/v0") + if !result.healthy { + t.Errorf("expected healthy=true, got false (err=%s)", result.err) + } +} + +func TestCheckDaemonHealth_Unhealthy(t *testing.T) { + // Use a server that always returns 500 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + result := checkDaemonHealth(ts.URL + "/v0") + if result.healthy { + t.Error("expected healthy=false, got true") + } +} + +func TestCheckDaemonHealth_Unreachable(t *testing.T) { + result := checkDaemonHealth("http://localhost:19999/v0") + if result.healthy { + t.Error("expected healthy=false for unreachable server, got true") + } +} + +func TestRunStatus_DaemonDown(t *testing.T) { + apiClient = nil + + // Should not error; prints "not running" and returns nil + err := runStatus() + if err != nil { + t.Errorf("runStatus() returned error for unreachable daemon: %v", err) + } +} + +func TestRunStatus_FullStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v0/ping": + w.WriteHeader(http.StatusOK) + case "/v0/version": + json.NewEncoder(w).Encode(map[string]string{ + "version": "1.0.0", + "gitCommit": "abc123", + "buildTime": "2026-01-01", + }) + case "/v0/servers": + json.NewEncoder(w).Encode(map[string]any{ + "servers": []any{}, + "metadata": map[string]string{"next_cursor": ""}, + }) + case "/v0/agents": + json.NewEncoder(w).Encode(map[string]any{ + "agents": []any{}, + "metadata": map[string]string{"next_cursor": ""}, + }) + case "/v0/skills": + json.NewEncoder(w).Encode(map[string]any{ + "skills": []any{}, + "metadata": map[string]string{"next_cursor": ""}, + }) + case "/v0/prompts": + json.NewEncoder(w).Encode(map[string]any{ + "prompts": []any{}, + "metadata": map[string]string{"next_cursor": ""}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + apiClient = client.NewClient(ts.URL, "") + err := runStatus() + if err != nil { + t.Errorf("runStatus() returned error: %v", err) + } +} diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 4cb258e4..ec07caa9 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -21,6 +21,7 @@ func TestCommandTree(t *testing.T) { "mcp", "prompt", "skill", + "status", "version", } diff --git a/pkg/cli/root.go b/pkg/cli/root.go index e52fff12..10be121f 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -97,6 +97,7 @@ func init() { rootCmd.AddCommand(prompt.PromptCmd) rootCmd.AddCommand(configure.ConfigureCmd) rootCmd.AddCommand(cli.VersionCmd) + rootCmd.AddCommand(cli.StatusCmd) rootCmd.AddCommand(cli.ImportCmd) rootCmd.AddCommand(cli.ExportCmd) rootCmd.AddCommand(cli.EmbeddingsCmd) @@ -176,6 +177,7 @@ var preRunDaemonBehavior = struct { "agent": {"init": true}, "mcp": {"init": true}, "skill": {"init": true}, + "arctl": {"status": true}, }, } diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index a8c2118c..a5dbf0a5 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -78,6 +78,10 @@ func TestPreRunBehavior(t *testing.T) { listCmd := &cobra.Command{Use: "list"} agentCmd.AddCommand(listCmd) + arctlCmd := &cobra.Command{Use: "arctl"} + statusCmd := &cobra.Command{Use: "status"} + arctlCmd.AddCommand(statusCmd) + skillCmd := &cobra.Command{Use: "skill"} skillInitCmd := &cobra.Command{Use: "init"} skillCmd.AddCommand(skillInitCmd) @@ -98,6 +102,8 @@ func TestPreRunBehavior(t *testing.T) { {"mcp init", mcpInitCmd, "http://localhost:12121", true, false}, {"skill init", skillInitCmd, "http://localhost:12121", true, false}, {"mcp init python (subcommand of init)", initPythonCmd, "http://localhost:12121", true, false}, + // Status command: skip setup entirely (creates its own client internally) + {"status localhost:12121", statusCmd, "http://localhost:12121", true, false}, // No skip for other commands; auto-start depends on URL {"agent list localhost:12121", listCmd, "http://localhost:12121", false, true}, {"agent list other port", listCmd, "http://localhost:8080", false, false}, From 6223c266993dc8e9d1aa0202fbc68e279ac2fd99 Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Wed, 11 Mar 2026 14:34:23 -0400 Subject: [PATCH 2/4] add db ping functionality, return db state on /health, and update status to reflect this Signed-off-by: Fabian Gonzalez --- internal/cli/status.go | 117 ++---------------- internal/cli/status_test.go | 110 ---------------- internal/client/client.go | 19 +++ internal/registry/api/handlers/v0/health.go | 19 ++- .../registry/api/handlers/v0/health_test.go | 32 +++-- .../api/handlers/v0/telemetry_test.go | 2 +- internal/registry/api/router/v0.go | 2 +- internal/registry/database/postgres.go | 5 + internal/registry/service/registry_service.go | 5 + internal/registry/service/service.go | 2 + .../registry/service/testing/fake_registry.go | 8 ++ pkg/registry/database/database.go | 2 + 12 files changed, 97 insertions(+), 226 deletions(-) delete mode 100644 internal/cli/status_test.go diff --git a/internal/cli/status.go b/internal/cli/status.go index 3242b378..18b71fe6 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -2,8 +2,6 @@ package cli import ( "fmt" - "net/http" - "time" "github.com/agentregistry-dev/agentregistry/internal/client" "github.com/agentregistry-dev/agentregistry/internal/version" @@ -15,133 +13,44 @@ import ( var StatusCmd = &cobra.Command{ Use: "status", Short: "Show status of the daemon and database", - Long: `Displays the current status of the AgentRegistry daemon, database connectivity, server version, and registry resource counts.`, + Long: `Displays the current status of the AgentRegistry daemon, database connectivity, and server version.`, RunE: func(cmd *cobra.Command, args []string) error { return runStatus() }, } func runStatus() error { - fmt.Printf("arctl version %s\n", version.Version) - fmt.Println() - - // Determine the base URL for health checks baseURL := client.DefaultBaseURL if apiClient != nil { baseURL = apiClient.BaseURL } - // Check daemon / API health - daemonStatus := checkDaemonHealth(baseURL) - if daemonStatus.healthy { - printer.PrintSuccess(fmt.Sprintf("Daemon is running (%s)", daemonStatus.url)) - } else { - printer.PrintError(fmt.Sprintf("Daemon is not running (%s)", daemonStatus.url)) - printer.PrintError("Database is not reachable") - fmt.Println() - fmt.Println("Registry resources: could not retrieve resources") - return nil - } - - // When PersistentPreRunE is skipped (status is in skipCommands), - // apiClient is nil. Create a local client for querying the daemon. c := apiClient if c == nil { c = client.NewClient(baseURL, "") } - // Server version - serverVersion, err := c.GetVersion() - if err != nil { - printer.PrintWarning(fmt.Sprintf("Could not retrieve server version: %v", err)) - } else { - fmt.Printf(" Server version: %s\n", serverVersion.Version) - fmt.Printf(" Server commit: %s\n", serverVersion.GitCommit) - fmt.Printf(" Server build date: %s\n", serverVersion.BuildTime) - } - - fmt.Println() - - // Database connectivity (inferred from ability to list resources) - dbOK := true - - servers, err := c.GetPublishedServers() - serverCount := 0 - if err != nil { - printer.PrintWarning(fmt.Sprintf("Could not list MCP servers: %v", err)) - dbOK = false - } else { - serverCount = len(servers) - } - - agents, err := c.GetAgents() - agentCount := 0 - if err != nil { - printer.PrintWarning(fmt.Sprintf("Could not list agents: %v", err)) - dbOK = false - } else { - agentCount = len(agents) - } + printer.PrintInfo(fmt.Sprintf("arctl version %s", version.Version)) + printer.PrintInfo("") - skills, err := c.GetSkills() - skillCount := 0 + health, err := c.CheckHealth() if err != nil { - printer.PrintWarning(fmt.Sprintf("Could not list skills: %v", err)) - dbOK = false - } else { - skillCount = len(skills) + printer.PrintError(fmt.Sprintf("Daemon is not running (%s)", c.BaseURL)) + printer.PrintError("Database is not healthy") + return nil } - prompts, err := c.GetPrompts() - promptCount := 0 - if err != nil { - printer.PrintWarning(fmt.Sprintf("Could not list prompts: %v", err)) - dbOK = false + if health.Status == "ok" { + printer.PrintSuccess(fmt.Sprintf("Daemon is running (%s)", c.BaseURL)) } else { - promptCount = len(prompts) + printer.PrintWarning(fmt.Sprintf("Daemon is degraded (%s)", c.BaseURL)) } - if dbOK { - printer.PrintSuccess("Database is reachable") + if health.Database == "ok" { + printer.PrintSuccess("Database is healthy") } else { - printer.PrintWarning("Database may have issues (some queries failed)") - } - - fmt.Println() - fmt.Println("Registry resources:") - - tp := printer.NewTablePrinter(nil) - tp.SetHeaders("Resource", "Count") - tp.AddRow("MCP Servers", fmt.Sprintf("%d", serverCount)) - tp.AddRow("Agents", fmt.Sprintf("%d", agentCount)) - tp.AddRow("Skills", fmt.Sprintf("%d", skillCount)) - tp.AddRow("Prompts", fmt.Sprintf("%d", promptCount)) - if err := tp.Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) + printer.PrintError(fmt.Sprintf("Database is not healthy: %s", health.Database)) } return nil } - -type daemonHealthResult struct { - healthy bool - url string - err string -} - -func checkDaemonHealth(baseURL string) daemonHealthResult { - healthURL := baseURL + "/ping" - - httpClient := &http.Client{Timeout: 5 * time.Second} - resp, err := httpClient.Get(healthURL) - if err != nil { - return daemonHealthResult{healthy: false, url: baseURL, err: err.Error()} - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return daemonHealthResult{healthy: false, url: baseURL, err: fmt.Sprintf("HTTP %d", resp.StatusCode)} - } - - return daemonHealthResult{healthy: true, url: baseURL} -} diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go deleted file mode 100644 index c31a6db3..00000000 --- a/internal/cli/status_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package cli - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/agentregistry-dev/agentregistry/internal/client" -) - -func TestStatusCmd_Metadata(t *testing.T) { - if StatusCmd.Use != "status" { - t.Errorf("StatusCmd.Use = %q, want %q", StatusCmd.Use, "status") - } - if StatusCmd.Short == "" { - t.Error("StatusCmd.Short is empty") - } -} - -func TestCheckDaemonHealth_Healthy(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v0/ping" { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - result := checkDaemonHealth(ts.URL + "/v0") - if !result.healthy { - t.Errorf("expected healthy=true, got false (err=%s)", result.err) - } -} - -func TestCheckDaemonHealth_Unhealthy(t *testing.T) { - // Use a server that always returns 500 - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer ts.Close() - - result := checkDaemonHealth(ts.URL + "/v0") - if result.healthy { - t.Error("expected healthy=false, got true") - } -} - -func TestCheckDaemonHealth_Unreachable(t *testing.T) { - result := checkDaemonHealth("http://localhost:19999/v0") - if result.healthy { - t.Error("expected healthy=false for unreachable server, got true") - } -} - -func TestRunStatus_DaemonDown(t *testing.T) { - apiClient = nil - - // Should not error; prints "not running" and returns nil - err := runStatus() - if err != nil { - t.Errorf("runStatus() returned error for unreachable daemon: %v", err) - } -} - -func TestRunStatus_FullStatus(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/v0/ping": - w.WriteHeader(http.StatusOK) - case "/v0/version": - json.NewEncoder(w).Encode(map[string]string{ - "version": "1.0.0", - "gitCommit": "abc123", - "buildTime": "2026-01-01", - }) - case "/v0/servers": - json.NewEncoder(w).Encode(map[string]any{ - "servers": []any{}, - "metadata": map[string]string{"next_cursor": ""}, - }) - case "/v0/agents": - json.NewEncoder(w).Encode(map[string]any{ - "agents": []any{}, - "metadata": map[string]string{"next_cursor": ""}, - }) - case "/v0/skills": - json.NewEncoder(w).Encode(map[string]any{ - "skills": []any{}, - "metadata": map[string]string{"next_cursor": ""}, - }) - case "/v0/prompts": - json.NewEncoder(w).Encode(map[string]any{ - "prompts": []any{}, - "metadata": map[string]string{"next_cursor": ""}, - }) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer ts.Close() - - apiClient = client.NewClient(ts.URL, "") - err := runStatus() - if err != nil { - t.Errorf("runStatus() returned error: %v", err) - } -} diff --git a/internal/client/client.go b/internal/client/client.go index 25870997..26698a5e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -149,6 +149,25 @@ func (c *Client) Ping() error { return c.doJSON(req, nil) } +// HealthResponse represents the response from the /health endpoint. +type HealthResponse struct { + Status string `json:"status"` + Database string `json:"database"` +} + +// CheckHealth queries the /health endpoint and returns structured health status. +func (c *Client) CheckHealth() (*HealthResponse, error) { + req, err := c.newRequest(http.MethodGet, "/health") + if err != nil { + return nil, err + } + var resp HealthResponse + if err := c.doJSON(req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + func (c *Client) GetVersion() (*internalv0.VersionBody, error) { req, err := c.newRequest(http.MethodGet, "/version") if err != nil { diff --git a/internal/registry/api/handlers/v0/health.go b/internal/registry/api/handlers/v0/health.go index fa0fe17b..4110a2ff 100644 --- a/internal/registry/api/handlers/v0/health.go +++ b/internal/registry/api/handlers/v0/health.go @@ -2,6 +2,7 @@ package v0 import ( "context" + "log/slog" "net/http" "strings" @@ -10,6 +11,7 @@ import ( "go.opentelemetry.io/otel/metric" "github.com/agentregistry-dev/agentregistry/internal/registry/config" + "github.com/agentregistry-dev/agentregistry/internal/registry/service" "github.com/agentregistry-dev/agentregistry/internal/registry/telemetry" "github.com/agentregistry-dev/agentregistry/pkg/types" ) @@ -17,25 +19,36 @@ import ( // HealthBody represents the health check response body type HealthBody struct { Status string `json:"status" example:"ok" doc:"Health status"` + Database string `json:"database" example:"ok" doc:"Database connectivity status"` GitHubClientID string `json:"github_client_id,omitempty" doc:"GitHub OAuth App Client ID"` } // RegisterHealthEndpoint registers the health check endpoint with a custom path prefix -func RegisterHealthEndpoint(api huma.API, pathPrefix string, cfg *config.Config, metrics *telemetry.Metrics) { +func RegisterHealthEndpoint(api huma.API, pathPrefix string, cfg *config.Config, metrics *telemetry.Metrics, registry service.RegistryService) { huma.Register(api, huma.Operation{ OperationID: "get-health" + strings.ReplaceAll(pathPrefix, "/", "-"), Method: http.MethodGet, Path: pathPrefix + "/health", Summary: "Health check", - Description: "Check the health status of the API", + Description: "Check the health status of the API and its dependencies", Tags: []string{"health"}, }, func(ctx context.Context, _ *struct{}) (*types.Response[HealthBody], error) { // Record the health check metrics recordHealthMetrics(ctx, metrics, pathPrefix+"/health", cfg.Version) + status := "ok" + dbStatus := "ok" + + if err := registry.PingDB(ctx); err != nil { + slog.Warn("health check: database ping failed", "error", err) + status = "degraded" + dbStatus = "unavailable" + } + return &types.Response[HealthBody]{ Body: HealthBody{ - Status: "ok", + Status: status, + Database: dbStatus, GitHubClientID: cfg.GithubClientID, }, }, nil diff --git a/internal/registry/api/handlers/v0/health_test.go b/internal/registry/api/handlers/v0/health_test.go index 70b97a11..6d912d5e 100644 --- a/internal/registry/api/handlers/v0/health_test.go +++ b/internal/registry/api/handlers/v0/health_test.go @@ -2,6 +2,7 @@ package v0_test import ( "context" + "errors" "net/http" "net/http/httptest" "testing" @@ -12,14 +13,15 @@ import ( v0 "github.com/agentregistry-dev/agentregistry/internal/registry/api/handlers/v0" "github.com/agentregistry-dev/agentregistry/internal/registry/config" + servicetesting "github.com/agentregistry-dev/agentregistry/internal/registry/service/testing" "github.com/agentregistry-dev/agentregistry/internal/registry/telemetry" ) func TestHealthEndpoint(t *testing.T) { - // Test cases testCases := []struct { name string config *config.Config + pingErr error expectedStatus int expectedBody v0.HealthBody }{ @@ -31,6 +33,7 @@ func TestHealthEndpoint(t *testing.T) { expectedStatus: http.StatusOK, expectedBody: v0.HealthBody{ Status: "ok", + Database: "ok", GitHubClientID: "test-github-client-id", }, }, @@ -41,22 +44,36 @@ func TestHealthEndpoint(t *testing.T) { }, expectedStatus: http.StatusOK, expectedBody: v0.HealthBody{ - Status: "ok", - GitHubClientID: "", + Status: "ok", + Database: "ok", + }, + }, + { + name: "returns degraded when database is unavailable", + config: &config.Config{ + GithubClientID: "", + }, + pingErr: errors.New("connection refused"), + expectedStatus: http.StatusOK, + expectedBody: v0.HealthBody{ + Status: "degraded", + Database: "unavailable", }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Create a new test API mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) shutdownTelemetry, metrics, _ := telemetry.InitMetrics("test") - // Register the health endpoint - v0.RegisterHealthEndpoint(api, "/v0", tc.config, metrics) + fakeRegistry := servicetesting.NewFakeRegistry() + if tc.pingErr != nil { + fakeRegistry.PingDBFn = func(_ context.Context) error { return tc.pingErr } + } + v0.RegisterHealthEndpoint(api, "/v0", tc.config, metrics, fakeRegistry) // Create a test request req := httptest.NewRequest(http.MethodGet, "/v0/health", nil) @@ -74,7 +91,8 @@ func TestHealthEndpoint(t *testing.T) { // Check the response body // Since Huma adds a $schema field, we'll check individual fields body := w.Body.String() - assert.Contains(t, body, `"status":"ok"`) + assert.Contains(t, body, `"status":"`+tc.expectedBody.Status+`"`) + assert.Contains(t, body, `"database":"`+tc.expectedBody.Database+`"`) if tc.config.GithubClientID != "" { assert.Contains(t, body, `"github_client_id":"test-github-client-id"`) diff --git a/internal/registry/api/handlers/v0/telemetry_test.go b/internal/registry/api/handlers/v0/telemetry_test.go index 3e709216..da5207f9 100644 --- a/internal/registry/api/handlers/v0/telemetry_test.go +++ b/internal/registry/api/handlers/v0/telemetry_test.go @@ -57,7 +57,7 @@ func TestPrometheusHandler(t *testing.T) { api.UseMiddleware(router.MetricTelemetryMiddleware(metrics, router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"), )) - v0.RegisterHealthEndpoint(api, "/v0", cfg, metrics) + v0.RegisterHealthEndpoint(api, "/v0", cfg, metrics, registryService) v0.RegisterServersEndpoints(api, "/v0", registryService) // Add /metrics for Prometheus metrics using promhttp diff --git a/internal/registry/api/router/v0.go b/internal/registry/api/router/v0.go index 5a53a4be..6cd0e300 100644 --- a/internal/registry/api/router/v0.go +++ b/internal/registry/api/router/v0.go @@ -40,7 +40,7 @@ func RegisterRoutes( ) { pathPrefix := "/v0" - v0.RegisterHealthEndpoint(api, pathPrefix, cfg, metrics) + v0.RegisterHealthEndpoint(api, pathPrefix, cfg, metrics, registry) v0.RegisterPingEndpoint(api, pathPrefix) v0.RegisterVersionEndpoint(api, pathPrefix, versionInfo) v0.RegisterServersEndpoints(api, pathPrefix, registry) diff --git a/internal/registry/database/postgres.go b/internal/registry/database/postgres.go index c308848b..62a9b619 100644 --- a/internal/registry/database/postgres.go +++ b/internal/registry/database/postgres.go @@ -3406,6 +3406,11 @@ func (db *PostgreSQL) DeletePrompt(ctx context.Context, tx pgx.Tx, promptName, v return nil } +// Ping verifies database connectivity with a lightweight round-trip. +func (db *PostgreSQL) Ping(ctx context.Context) error { + return db.pool.Ping(ctx) +} + // Close closes the database connection func (db *PostgreSQL) Close() error { db.pool.Close() diff --git a/internal/registry/service/registry_service.go b/internal/registry/service/registry_service.go index f1a41894..14ce0bc0 100644 --- a/internal/registry/service/registry_service.go +++ b/internal/registry/service/registry_service.go @@ -96,6 +96,11 @@ func NewRegistryService( } } +// PingDB verifies database connectivity delegating to the database layer. +func (s *registryServiceImpl) PingDB(ctx context.Context) error { + return s.db.Ping(ctx) +} + // SetPlatformAdapters wires platform extension adapters into the service. func (s *registryServiceImpl) SetPlatformAdapters( deploymentPlatforms map[string]DeploymentPlatformDeployer, diff --git a/internal/registry/service/service.go b/internal/registry/service/service.go index 46f3621e..c29e185e 100644 --- a/internal/registry/service/service.go +++ b/internal/registry/service/service.go @@ -15,6 +15,8 @@ type Reconciler interface { // RegistryService defines the interface for registry operations type RegistryService interface { + // PingDB verifies database connectivity + PingDB(ctx context.Context) error // ListServers retrieve all servers with optional filtering ListServers(ctx context.Context, filter *database.ServerFilter, cursor string, limit int) ([]*apiv0.ServerResponse, string, error) // GetServerByName retrieve latest version of a server by server name diff --git a/internal/registry/service/testing/fake_registry.go b/internal/registry/service/testing/fake_registry.go index 4915aec2..a4ec9f49 100644 --- a/internal/registry/service/testing/fake_registry.go +++ b/internal/registry/service/testing/fake_registry.go @@ -72,6 +72,7 @@ type FakeRegistry struct { GetDeploymentLogsFn func(ctx context.Context, deployment *models.Deployment, platform string) ([]string, error) CancelDeploymentFn func(ctx context.Context, deployment *models.Deployment, platform string) error ReconcileAllFn func(ctx context.Context) error + PingDBFn func(ctx context.Context) error // Prompt fields and hooks Prompts []*models.PromptResponse @@ -502,6 +503,13 @@ func (f *FakeRegistry) DeletePrompt(ctx context.Context, promptName, version str return database.ErrNotFound } +func (f *FakeRegistry) PingDB(ctx context.Context) error { + if f.PingDBFn != nil { + return f.PingDBFn(ctx) + } + return nil +} + func (f *FakeRegistry) ReconcileAll(ctx context.Context) error { if f.ReconcileAllFn != nil { return f.ReconcileAllFn(ctx) diff --git a/pkg/registry/database/database.go b/pkg/registry/database/database.go index 7f99e246..ce748937 100644 --- a/pkg/registry/database/database.go +++ b/pkg/registry/database/database.go @@ -145,6 +145,8 @@ type Database interface { GetServerReadme(ctx context.Context, tx pgx.Tx, serverName, version string) (*ServerReadme, error) // GetLatestServerReadme retrieves the README blob for the latest server version GetLatestServerReadme(ctx context.Context, tx pgx.Tx, serverName string) (*ServerReadme, error) + // Ping verifies database connectivity with a lightweight round-trip + Ping(ctx context.Context) error // InTransaction executes a function within a database transaction InTransaction(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error // Close closes the database connection From b2fe022eff8de4de508e40012cda1ce1f4c57927 Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Wed, 11 Mar 2026 14:52:39 -0400 Subject: [PATCH 3/4] manually pick PrintFailure changes Signed-off-by: Fabian Gonzalez --- internal/cli/status.go | 6 +++--- pkg/printer/printer.go | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/cli/status.go b/internal/cli/status.go index 18b71fe6..6d589eb3 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -35,8 +35,8 @@ func runStatus() error { health, err := c.CheckHealth() if err != nil { - printer.PrintError(fmt.Sprintf("Daemon is not running (%s)", c.BaseURL)) - printer.PrintError("Database is not healthy") + printer.PrintFailure(fmt.Sprintf("Daemon is not running (%s)", c.BaseURL)) + printer.PrintFailure("Database is not healthy") return nil } @@ -49,7 +49,7 @@ func runStatus() error { if health.Database == "ok" { printer.PrintSuccess("Database is healthy") } else { - printer.PrintError(fmt.Sprintf("Database is not healthy: %s", health.Database)) + printer.PrintFailure(fmt.Sprintf("Database is not healthy: %s", health.Database)) } return nil diff --git a/pkg/printer/printer.go b/pkg/printer/printer.go index eaad79d8..284e3554 100644 --- a/pkg/printer/printer.go +++ b/pkg/printer/printer.go @@ -46,6 +46,13 @@ func PrintError(message string) { fmt.Fprintf(os.Stderr, "Error: %s\n", message) } +// PrintFailure prints a negative-status message with a cross-mark prefix to stdout. +// Use for reporting expected negative conditions (e.g. "daemon is not running") +// rather than PrintError which implies an unexpected error. +func PrintFailure(message string) { + _, _ = fmt.Fprintf(os.Stdout, "✗ %s\n", message) +} + // PrintWarning prints a warning message func PrintWarning(message string) { _, _ = fmt.Fprintf(os.Stdout, "Warning: %s\n", message) From 21fdac16d1746ebd93a1bc4a85ffece8c1d6e07b Mon Sep 17 00:00:00 2001 From: Fabian Gonzalez Date: Wed, 11 Mar 2026 14:55:34 -0400 Subject: [PATCH 4/4] generate new /health spec Signed-off-by: Fabian Gonzalez --- openapi.yaml | 8 +++++++- ui/lib/api/sdk.gen.ts | 2 +- ui/lib/api/types.gen.ts | 4 ++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index e4eb787e..87a58b18 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -558,7 +558,7 @@ paths: tags: - health summary: Health check - description: Check the health status of the API + description: Check the health status of the API and its dependencies operationId: get-health-v0 responses: "200": @@ -1992,6 +1992,11 @@ components: type: object additionalProperties: false properties: + database: + type: string + description: Database connectivity status + examples: + - ok github_client_id: type: string description: GitHub OAuth App Client ID @@ -2002,6 +2007,7 @@ components: - ok required: - status + - database Icon: type: object additionalProperties: false diff --git a/ui/lib/api/sdk.gen.ts b/ui/lib/api/sdk.gen.ts index ced8584b..ce8087d1 100644 --- a/ui/lib/api/sdk.gen.ts +++ b/ui/lib/api/sdk.gen.ts @@ -168,7 +168,7 @@ export const getDeploymentLogs = (options: /** * Health check * - * Check the health status of the API + * Check the health status of the API and its dependencies */ export const getHealthV0 = (options?: Options) => (options?.client ?? client).get({ url: '/v0/health', ...options }); diff --git a/ui/lib/api/types.gen.ts b/ui/lib/api/types.gen.ts index 382f69a6..4c595f91 100644 --- a/ui/lib/api/types.gen.ts +++ b/ui/lib/api/types.gen.ts @@ -277,6 +277,10 @@ export type GitHubTokenExchangeInputBody = { }; export type HealthBody = { + /** + * Database connectivity status + */ + database: string; /** * GitHub OAuth App Client ID */