Skip to content
Merged
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
150 changes: 150 additions & 0 deletions docs/api-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# API v2

Orchestrator API v2 provides a cleaner, more consistent REST API with structured JSON responses and proper HTTP status codes.

All v2 endpoints are mounted under `/api/v2` (respecting the configured `URLPrefix`).

## Response Format

All v2 endpoints return a consistent JSON envelope:

### Success

```json
{
"status": "ok",
"data": { ... }
}
```

### Error

```json
{
"status": "error",
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error description"
}
}
```

HTTP status codes are used appropriately:

- `200` for successful requests
- `400` for invalid input (bad instance key, etc.)
- `404` for resources not found (unknown cluster, instance, etc.)
- `500` for internal server errors
- `503` for unavailable services (e.g., ProxySQL not configured)

## Endpoints

### Clusters

#### `GET /api/v2/clusters`

Returns a list of all known clusters with metadata.

**Response:** Array of cluster info objects including cluster name, alias, master instance, count of instances, etc.

#### `GET /api/v2/clusters/{name}`

Returns detailed information about a specific cluster. The `{name}` parameter can be a cluster name, alias, or instance key hint.

**Response:** Single cluster info object.

**Errors:**
- `404` if the cluster cannot be resolved.

#### `GET /api/v2/clusters/{name}/instances`

Returns all instances belonging to a given cluster.

**Response:** Array of instance objects.

**Errors:**
- `404` if the cluster cannot be resolved.

#### `GET /api/v2/clusters/{name}/topology`

Returns the ASCII topology representation for a cluster.

**Response:**

```json
{
"status": "ok",
"data": {
"clusterName": "my-cluster:3306",
"topology": "..."
}
}
```

**Errors:**
- `404` if the cluster cannot be resolved.

### Instances

#### `GET /api/v2/instances/{host}/{port}`

Returns detailed information about a specific MySQL instance.

**Response:** Single instance object with replication status, version info, etc.

**Errors:**
- `400` if the instance key is invalid.
- `404` if the instance is not found.

### Recoveries

#### `GET /api/v2/recoveries`

Returns recent recovery entries.

**Query Parameters:**
- `cluster` (optional): filter by cluster name
- `alias` (optional): filter by cluster alias
- `page` (optional): page number for pagination (default: 0)

**Response:** Array of recovery objects.

#### `GET /api/v2/recoveries/active`

Returns currently active (in-progress) recoveries.

**Response:** Array of active recovery objects.

### Status

#### `GET /api/v2/status`

Returns the health status of the orchestrator node.

**Response:** Health status object including hostname, active node info, raft status, etc.

**Errors:**
- `500` if the node is unhealthy.

### ProxySQL

#### `GET /api/v2/proxysql/servers`

Returns all servers from ProxySQL's `runtime_mysql_servers` table.

**Response:** Array of ProxySQL server entry objects.

**Errors:**
- `503` if ProxySQL is not configured.
- `500` if querying ProxySQL fails.

## Migration from API v1

The v2 API wraps the same underlying functions as v1 but provides:

1. **Consistent response envelope**: All responses use the `V2APIResponse` struct with `status`, `data`, `error`, and `message` fields.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation states that the response envelope includes a top-level message field. However, this field is unused in the API responses, and the error message is contained within the error object. This description is misleading and should be corrected to reflect the actual response structure.

Suggested change
1. **Consistent response envelope**: All responses use the `V2APIResponse` struct with `status`, `data`, `error`, and `message` fields.
1. **Consistent response envelope**: All responses use the `V2APIResponse` struct with `status`, `data`, and `error` fields.

2. **Proper HTTP status codes**: v1 always returned 200 with error info in the body; v2 uses appropriate 4xx/5xx codes.
3. **RESTful URL structure**: Resources are nested logically (e.g., `/clusters/{name}/instances` instead of `/cluster/{clusterHint}`).
4. **Structured error codes**: Machine-readable error codes (e.g., `NOT_FOUND`, `CLUSTER_LIST_ERROR`) alongside human-readable messages.

The v1 API remains fully functional. Both APIs can be used simultaneously.
1 change: 1 addition & 0 deletions go/app/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func standardHttp(continuousDiscovery bool) {
http.API.URLPrefix = config.Config.URLPrefix
http.Web.URLPrefix = config.Config.URLPrefix
http.API.RegisterRequests(router)
http.RegisterV2Routes(router)
http.Web.RegisterRequests(router)

// Serve
Expand Down
235 changes: 235 additions & 0 deletions go/http/apiv2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/*
Copyright 2024 Orchestrator Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
Comment on lines +1 to +15
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File header copyright differs from the standard used across existing go/http/* sources (most use Copyright 2014 Outbrain Inc.). If this repo expects consistent license headers, please align this new file’s header with the existing convention to avoid future legal/attribution inconsistencies.

Copilot uses AI. Check for mistakes.

package http

import (
"fmt"
"net/http"
"strconv"

"github.com/go-chi/chi/v5"

"github.com/proxysql/orchestrator/go/config"
"github.com/proxysql/orchestrator/go/inst"
"github.com/proxysql/orchestrator/go/logic"
"github.com/proxysql/orchestrator/go/process"
"github.com/proxysql/orchestrator/go/proxysql"
)

// V2APIResponse is the standard response envelope for API v2 endpoints.
type V2APIResponse struct {
Status string `json:"status"`
Data interface{} `json:"data,omitempty"`
Error *V2APIError `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
Comment on lines +34 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Message field in V2APIResponse is declared but never used in any of the response helper functions (respondOK, respondError, respondNotFound). This can be confusing for developers maintaining this code and for API consumers, as the error message is already part of the V2APIError struct. To improve clarity and remove dead code, this field should be removed.

Note that this change will require updating the TestV2APIResponseJSONOmitempty test in go/http/apiv2_test.go.

type V2APIResponse struct {
	Status  string      `json:"status"`
	Data    interface{} `json:"data,omitempty"`
	Error   *V2APIError `json:"error,omitempty"`
}


// V2APIError represents an error in the API v2 response.
type V2APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}

// respondOK writes a successful JSON response with the given data.
func respondOK(w http.ResponseWriter, data interface{}) {
renderJSON(w, http.StatusOK, &V2APIResponse{
Status: "ok",
Data: data,
})
}

// respondError writes an error JSON response with the given HTTP status, error code, and message.
func respondError(w http.ResponseWriter, status int, code string, message string) {
renderJSON(w, status, &V2APIResponse{
Status: "error",
Error: &V2APIError{
Code: code,
Message: message,
},
})
}

// respondNotFound writes a 404 JSON response with the given message.
func respondNotFound(w http.ResponseWriter, message string) {
respondError(w, http.StatusNotFound, "NOT_FOUND", message)
}

// RegisterV2Routes mounts all v2 API routes under /api/v2 on the given router.
func RegisterV2Routes(r chi.Router) {
prefix := config.Config.URLPrefix
r.Route(fmt.Sprintf("%s/api/v2", prefix), func(r chi.Router) {
// Cluster endpoints
r.Get("/clusters", V2Clusters)
r.Get("/clusters/{name}", V2ClusterInfo)
r.Get("/clusters/{name}/instances", V2ClusterInstances)
r.Get("/clusters/{name}/topology", V2Topology)

// Instance endpoints
r.Get("/instances/{host}/{port}", V2Instance)

// Recovery endpoints
r.Get("/recoveries", V2Recoveries)
r.Get("/recoveries/active", V2ActiveRecoveries)

// Status endpoints
r.Get("/status", V2Status)

// ProxySQL endpoints
r.Get("/proxysql/servers", V2ProxySQLServers)
})
Comment on lines +71 to +93
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API v1 routes are registered with raftReverseProxyMiddleware when raft is enabled (see HttpAPI.registerSingleAPIRequest), ensuring followers proxy requests to the leader. The v2 routes are mounted without this middleware, so in raft mode requests hitting a follower won’t be proxied and may behave inconsistently vs v1. Consider wrapping the entire /api/v2 route group with r.With(raftReverseProxyMiddleware) (the middleware is a no-op when raft is disabled).

Copilot uses AI. Check for mistakes.
}

// V2Clusters returns a list of all known clusters with metadata.
func V2Clusters(w http.ResponseWriter, r *http.Request) {
clustersInfo, err := inst.ReadClustersInfo("")
if err != nil {
respondError(w, http.StatusInternalServerError, "CLUSTER_LIST_ERROR", fmt.Sprintf("Failed to read clusters: %v", err))
return
}
respondOK(w, clustersInfo)
}

// V2ClusterInfo returns detailed information about a specific cluster.
func V2ClusterInfo(w http.ResponseWriter, r *http.Request) {
clusterName := chi.URLParam(r, "name")
clusterName, err := figureClusterName(clusterName)
if err != nil {
respondNotFound(w, fmt.Sprintf("Cluster not found: %v", err))
return
}
clusterInfo, err := inst.ReadClusterInfo(clusterName)
if err != nil {
respondError(w, http.StatusInternalServerError, "CLUSTER_INFO_ERROR", fmt.Sprintf("Failed to read cluster info: %v", err))
return
}
respondOK(w, clusterInfo)
}

// V2ClusterInstances returns all instances belonging to a given cluster.
func V2ClusterInstances(w http.ResponseWriter, r *http.Request) {
clusterName := chi.URLParam(r, "name")
clusterName, err := figureClusterName(clusterName)
if err != nil {
respondNotFound(w, fmt.Sprintf("Cluster not found: %v", err))
return
}
instances, err := inst.ReadClusterInstances(clusterName)
if err != nil {
respondError(w, http.StatusInternalServerError, "CLUSTER_INSTANCES_ERROR", fmt.Sprintf("Failed to read cluster instances: %v", err))
return
}
respondOK(w, instances)
}

// V2Instance returns detailed information about a specific MySQL instance.
func V2Instance(w http.ResponseWriter, r *http.Request) {
host := chi.URLParam(r, "host")
port := chi.URLParam(r, "port")

instanceKey, err := inst.NewResolveInstanceKeyStrings(host, port)
if err != nil {
respondError(w, http.StatusBadRequest, "INVALID_INSTANCE", fmt.Sprintf("Invalid instance key: %v", err))
return
}
instanceKey, err = inst.FigureInstanceKey(instanceKey, nil)
if err != nil {
respondError(w, http.StatusBadRequest, "INVALID_INSTANCE", fmt.Sprintf("Cannot resolve instance: %v", err))
return
}
instance, found, err := inst.ReadInstance(instanceKey)
if err != nil {
respondError(w, http.StatusInternalServerError, "INSTANCE_READ_ERROR", fmt.Sprintf("Failed to read instance: %v", err))
return
}
if !found {
respondNotFound(w, fmt.Sprintf("Instance not found: %s:%s", host, port))
return
}
respondOK(w, instance)
}

// V2Topology returns the ASCII topology representation for a given cluster.
func V2Topology(w http.ResponseWriter, r *http.Request) {
clusterName := chi.URLParam(r, "name")
clusterName, err := figureClusterName(clusterName)
if err != nil {
respondNotFound(w, fmt.Sprintf("Cluster not found: %v", err))
return
}
asciiOutput, err := inst.ASCIITopology(clusterName, "", false, false)
if err != nil {
respondError(w, http.StatusInternalServerError, "TOPOLOGY_ERROR", fmt.Sprintf("Failed to generate topology: %v", err))
return
}
respondOK(w, map[string]interface{}{
"clusterName": clusterName,
"topology": asciiOutput,
})
}

// V2Recoveries returns recent recovery entries, optionally filtered by cluster.
func V2Recoveries(w http.ResponseWriter, r *http.Request) {
clusterName := r.URL.Query().Get("cluster")
clusterAlias := r.URL.Query().Get("alias")
page := 0
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil {
page = p
}
Comment on lines +190 to +192
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

page query parsing allows negative values (and ignores parse errors by silently defaulting). logic.ReadRecentRecoveries computes offset as page*config.AuditPageSize, so a negative page yields a negative SQL offset and can break the query. Clamp page to >= 0 (matching v1) and consider returning a 400 for non-integer/negative values instead of silently accepting them.

Suggested change
if p, err := strconv.Atoi(pageStr); err == nil {
page = p
}
p, err := strconv.Atoi(pageStr)
if err != nil || p < 0 {
respondError(w, http.StatusBadRequest, "INVALID_PAGE", "Invalid 'page' parameter; must be a non-negative integer")
return
}
page = p

Copilot uses AI. Check for mistakes.
}
recoveries, err := logic.ReadRecentRecoveries(clusterName, clusterAlias, false, page)
if err != nil {
respondError(w, http.StatusInternalServerError, "RECOVERIES_ERROR", fmt.Sprintf("Failed to read recoveries: %v", err))
return
}
respondOK(w, recoveries)
}

// V2ActiveRecoveries returns currently active (in-progress) recoveries.
func V2ActiveRecoveries(w http.ResponseWriter, r *http.Request) {
recoveries, err := logic.ReadActiveRecoveries()
if err != nil {
respondError(w, http.StatusInternalServerError, "ACTIVE_RECOVERIES_ERROR", fmt.Sprintf("Failed to read active recoveries: %v", err))
return
}
respondOK(w, recoveries)
}

// V2Status returns the health status of the orchestrator node.
func V2Status(w http.ResponseWriter, r *http.Request) {
health, err := process.HealthTest()
if err != nil {
respondError(w, http.StatusInternalServerError, "UNHEALTHY", fmt.Sprintf("Application node is unhealthy: %v", err))
return
}
respondOK(w, health)
}

// V2ProxySQLServers returns all servers from ProxySQL's runtime_mysql_servers table.
func V2ProxySQLServers(w http.ResponseWriter, r *http.Request) {
hook := proxysql.GetHook()
if hook == nil || !hook.IsConfigured() {
respondError(w, http.StatusServiceUnavailable, "PROXYSQL_NOT_CONFIGURED", "ProxySQL is not configured")
return
}
servers, err := hook.GetClient().GetServers()
if err != nil {
respondError(w, http.StatusInternalServerError, "PROXYSQL_ERROR", fmt.Sprintf("Failed to query ProxySQL servers: %v", err))
return
}
respondOK(w, servers)
}
Loading
Loading