diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 00000000..6d589eb3 --- /dev/null +++ b/internal/cli/status.go @@ -0,0 +1,56 @@ +package cli + +import ( + "fmt" + + "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, and server version.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus() + }, +} + +func runStatus() error { + baseURL := client.DefaultBaseURL + if apiClient != nil { + baseURL = apiClient.BaseURL + } + + c := apiClient + if c == nil { + c = client.NewClient(baseURL, "") + } + + printer.PrintInfo(fmt.Sprintf("arctl version %s", version.Version)) + printer.PrintInfo("") + + health, err := c.CheckHealth() + if err != nil { + printer.PrintFailure(fmt.Sprintf("Daemon is not running (%s)", c.BaseURL)) + printer.PrintFailure("Database is not healthy") + return nil + } + + if health.Status == "ok" { + printer.PrintSuccess(fmt.Sprintf("Daemon is running (%s)", c.BaseURL)) + } else { + printer.PrintWarning(fmt.Sprintf("Daemon is degraded (%s)", c.BaseURL)) + } + + if health.Database == "ok" { + printer.PrintSuccess("Database is healthy") + } else { + printer.PrintFailure(fmt.Sprintf("Database is not healthy: %s", health.Database)) + } + + return nil +} diff --git a/internal/client/client.go b/internal/client/client.go index 31f32329..d24f0edc 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -168,6 +168,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() (*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 87826383..ed38475e 100644 --- a/internal/registry/api/router/v0.go +++ b/internal/registry/api/router/v0.go @@ -41,7 +41,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 38d7e8f0..43af716c 100644 --- a/internal/registry/database/postgres.go +++ b/internal/registry/database/postgres.go @@ -3457,6 +3457,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 b55d6ef2..3261b054 100644 --- a/internal/registry/service/registry_service.go +++ b/internal/registry/service/registry_service.go @@ -85,6 +85,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]registrytypes.DeploymentPlatformAdapter, diff --git a/internal/registry/service/service.go b/internal/registry/service/service.go index b8f1961c..18af3663 100644 --- a/internal/registry/service/service.go +++ b/internal/registry/service/service.go @@ -11,6 +11,8 @@ import ( // 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 9b2593e4..d65e4cf8 100644 --- a/internal/registry/service/testing/fake_registry.go +++ b/internal/registry/service/testing/fake_registry.go @@ -74,6 +74,7 @@ type FakeRegistry struct { GetDeploymentLogsFn func(ctx context.Context, deployment *models.Deployment) ([]string, error) CancelDeploymentFn func(ctx context.Context, deployment *models.Deployment) error ReconcileAllFn func(ctx context.Context) error + PingDBFn func(ctx context.Context) error // Prompt fields and hooks Prompts []*models.PromptResponse @@ -511,6 +512,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/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/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index f479292a..39b86b59 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}, 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) diff --git a/pkg/registry/database/database.go b/pkg/registry/database/database.go index 366ed410..8ab174bd 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 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 */