Skip to content
Draft
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
2 changes: 2 additions & 0 deletions pkg/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand Down
68 changes: 68 additions & 0 deletions pkg/api/mysql_handlers.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions pkg/api/mysql_handlers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions pkg/api/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions pkg/apitypes/apitypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading