diff --git a/pkg/api/README.md b/pkg/api/README.md index 44d31fef..3c175edc 100644 --- a/pkg/api/README.md +++ b/pkg/api/README.md @@ -54,6 +54,7 @@ Lazy creation means a connection failure to one database doesn't block startup o | GET | `/api/status` | `handleStatus` | All active schema changes | | GET | `/api/history/{database}` | `handleDatabaseHistory` | Apply history for a database | | GET | `/api/databases/{database}/environments` | `handleDatabaseEnvironments` | List environments | +| GET | `/api/mysql/databases` | `handleListMysqlDatabases` | List configured MySQL databases | | GET | `/api/logs/{database}` | `handleLogs` | Apply logs for a database | | GET | `/api/logs` | `handleLogsWithoutDatabase` | Logs by apply ID | @@ -106,6 +107,7 @@ See the top-level [README](../../README.md) for configuration examples. | `plan_handlers.go` | Plan and Apply HTTP handlers | | `control_handlers.go` | Cutover, Stop, Start, Volume, Revert handlers | | `progress_handlers.go` | Progress, Status, History handlers | +| `mysql_handlers.go` | MySQL inventory handlers | | `health_handlers.go` | Health checks and JSON helpers | | `lock_handlers.go` | Lock acquire/release/list handlers | | `log_handlers.go` | Apply log handlers | diff --git a/pkg/api/mysql_handlers.go b/pkg/api/mysql_handlers.go new file mode 100644 index 00000000..ead071d3 --- /dev/null +++ b/pkg/api/mysql_handlers.go @@ -0,0 +1,68 @@ +package api + +import ( + "net/http" + "sort" + + "github.com/block/schemabot/pkg/apitypes" + "github.com/block/schemabot/pkg/storage" +) + +// HandleListMysqlDatabases is the HTTP handler for GET /api/mysql/databases. +func (s *Service) HandleListMysqlDatabases(w http.ResponseWriter, r *http.Request) { + s.handleListMysqlDatabases(w, r) +} + +func (s *Service) handleListMysqlDatabases(w http.ResponseWriter, r *http.Request) { + environment := r.URL.Query().Get("environment") + resp := s.ListMysqlDatabases(environment) + s.writeJSON(w, http.StatusOK, resp) +} + +// ListMysqlDatabases returns MySQL databases from the local SchemaBot catalog. +func (s *Service) ListMysqlDatabases(environmentFilter string) *apitypes.ListMysqlDatabasesResponse { + databases := make([]*apitypes.MysqlDatabaseResponse, 0, len(s.config.Databases)) + for name, dbConfig := range s.config.Databases { + if dbConfig.Type != storage.DatabaseTypeMySQL { + s.logger.Debug("skipping non-MySQL database in inventory list", + "database", name, + "database_type", dbConfig.Type) + continue + } + + environments := matchingEnvironments(dbConfig.Environments, environmentFilter) + if len(environments) == 0 { + s.logger.Debug("skipping MySQL database with no matching environments", + "database", name, + "environment_filter", environmentFilter) + continue + } + + databases = append(databases, &apitypes.MysqlDatabaseResponse{ + Database: name, + DatabaseType: dbConfig.Type, + Deployment: name, + Environments: environments, + }) + } + + sort.Slice(databases, func(i, j int) bool { + return databases[i].Database < databases[j].Database + }) + + return &apitypes.ListMysqlDatabasesResponse{ + Databases: databases, + Count: len(databases), + } +} + +func matchingEnvironments(configs map[string]EnvironmentConfig, environmentFilter string) []string { + environments := make([]string, 0, len(configs)) + for environment := range configs { + if environmentFilter == "" || environment == environmentFilter { + environments = append(environments, environment) + } + } + sort.Strings(environments) + return environments +} diff --git a/pkg/api/mysql_handlers_test.go b/pkg/api/mysql_handlers_test.go new file mode 100644 index 00000000..13d959d1 --- /dev/null +++ b/pkg/api/mysql_handlers_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/block/schemabot/pkg/apitypes" + "github.com/block/schemabot/pkg/storage" +) + +func TestListMysqlDatabases(t *testing.T) { + svc := newMysqlInventoryTestService(&ServerConfig{ + Databases: map[string]DatabaseConfig{ + "app_vitess": { + Type: storage.DatabaseTypeVitess, + Environments: map[string]EnvironmentConfig{ + "staging": {DSN: "vitess-dsn"}, + }, + }, + "orders": { + Type: storage.DatabaseTypeMySQL, + Environments: map[string]EnvironmentConfig{ + "production": {DSN: "prod-dsn"}, + "staging": {DSN: "staging-dsn"}, + }, + }, + "payments": { + Type: storage.DatabaseTypeMySQL, + Environments: map[string]EnvironmentConfig{ + "staging": {DSN: "payments-dsn"}, + }, + }, + }, + }) + mux := http.NewServeMux() + svc.ConfigureRoutes(mux) + + req := httptest.NewRequestWithContext(t.Context(), "GET", "/api/mysql/databases", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.NotContains(t, w.Body.String(), "prod-dsn") + assert.NotContains(t, w.Body.String(), "staging-dsn") + + var resp apitypes.ListMysqlDatabasesResponse + err := json.NewDecoder(w.Body).Decode(&resp) + require.NoError(t, err, "failed to decode response") + + require.Len(t, resp.Databases, 2) + assert.Equal(t, 2, resp.Count) + assert.Equal(t, "orders", resp.Databases[0].Database) + assert.Equal(t, storage.DatabaseTypeMySQL, resp.Databases[0].DatabaseType) + assert.Equal(t, "orders", resp.Databases[0].Deployment) + assert.Equal(t, []string{"production", "staging"}, resp.Databases[0].Environments) + assert.Equal(t, "payments", resp.Databases[1].Database) + assert.Equal(t, []string{"staging"}, resp.Databases[1].Environments) +} + +func TestListMysqlDatabasesEnvironmentFilter(t *testing.T) { + svc := newMysqlInventoryTestService(&ServerConfig{ + Databases: map[string]DatabaseConfig{ + "orders": { + Type: storage.DatabaseTypeMySQL, + Environments: map[string]EnvironmentConfig{ + "production": {DSN: "prod-dsn"}, + "staging": {DSN: "staging-dsn"}, + }, + }, + "payments": { + Type: storage.DatabaseTypeMySQL, + Environments: map[string]EnvironmentConfig{ + "staging": {DSN: "payments-dsn"}, + }, + }, + }, + }) + mux := http.NewServeMux() + svc.ConfigureRoutes(mux) + + req := httptest.NewRequestWithContext(t.Context(), "GET", "/api/mysql/databases?environment=production", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp apitypes.ListMysqlDatabasesResponse + err := json.NewDecoder(w.Body).Decode(&resp) + require.NoError(t, err, "failed to decode response") + + require.Len(t, resp.Databases, 1) + assert.Equal(t, 1, resp.Count) + assert.Equal(t, "orders", resp.Databases[0].Database) + assert.Equal(t, []string{"production"}, resp.Databases[0].Environments) +} + +func TestListMysqlDatabasesWithoutConfiguredDatabases(t *testing.T) { + svc := newMysqlInventoryTestService(&ServerConfig{ + TernDeployments: TernConfig{ + "default": TernEndpoints{ + "staging": "localhost:9090", + }, + }, + }) + mux := http.NewServeMux() + svc.ConfigureRoutes(mux) + + req := httptest.NewRequestWithContext(t.Context(), "GET", "/api/mysql/databases", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp apitypes.ListMysqlDatabasesResponse + err := json.NewDecoder(w.Body).Decode(&resp) + require.NoError(t, err, "failed to decode response") + + assert.Empty(t, resp.Databases) + assert.Zero(t, resp.Count) +} + +func newMysqlInventoryTestService(config *ServerConfig) *Service { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + return New(&mockStorage{}, config, nil, logger) +} diff --git a/pkg/api/service.go b/pkg/api/service.go index bd2f5913..d11159da 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -398,6 +398,7 @@ func (s *Service) ConfigureRoutes(mux *http.ServeMux) { // Config API (for CLI to discover environments) mux.HandleFunc("GET /api/databases/{database}/environments", s.handleDatabaseEnvironments) + mux.HandleFunc("GET /api/mysql/databases", s.handleListMysqlDatabases) // Orchestration API mux.HandleFunc("POST /api/plan", s.handlePlan) diff --git a/pkg/apitypes/apitypes.go b/pkg/apitypes/apitypes.go index cf95a778..54136fb7 100644 --- a/pkg/apitypes/apitypes.go +++ b/pkg/apitypes/apitypes.go @@ -210,6 +210,20 @@ type TableChangeResponse struct { // GetTableName implements ddl.TableWithName for filtering Spirit internal tables. func (t *TableChangeResponse) GetTableName() string { return t.TableName } +// MysqlDatabaseResponse represents a MySQL database known to SchemaBot. +type MysqlDatabaseResponse struct { + Database string `json:"database"` + DatabaseType string `json:"database_type"` + Deployment string `json:"deployment,omitempty"` + Environments []string `json:"environments"` +} + +// ListMysqlDatabasesResponse is the response for GET /api/mysql/databases. +type ListMysqlDatabasesResponse struct { + Databases []*MysqlDatabaseResponse `json:"databases"` + Count int `json:"count"` +} + // LintViolationResponse represents a lint violation in the HTTP response. type LintViolationResponse struct { Message string `json:"message"`