From a923b5b0bace15bf9bce9ad7d9e9ca665541d0ac Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 14:48:05 -0300 Subject: [PATCH 1/7] ROSAENG-62084 | feat: typed error codes for cluster and nodepool handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce APIError struct with stable code, HTTP status, and message — defined once in errorcodes.go and referenced by both handlers. Replaces scattered inline string literals, fixes duplicate codes (CREATE-002 used for three different conditions, nodepool CREATE-003 shared between conflict and failure), and adds cluster name length validation (CLUSTERS-MGMT-CREATE-006) derived from the HyperShift namespace formula. --- .../pkg/clients/hyperfleetdb/convert.go | 8 + platform-api/pkg/handlers/cluster.go | 82 ++++----- platform-api/pkg/handlers/cluster_test.go | 27 +++ platform-api/pkg/handlers/errorcodes.go | 164 ++++++++++++++++++ platform-api/pkg/handlers/nodepool.go | 78 ++++----- 5 files changed, 263 insertions(+), 96 deletions(-) create mode 100644 platform-api/pkg/handlers/errorcodes.go diff --git a/platform-api/pkg/clients/hyperfleetdb/convert.go b/platform-api/pkg/clients/hyperfleetdb/convert.go index 429528e4..7685777d 100644 --- a/platform-api/pkg/clients/hyperfleetdb/convert.go +++ b/platform-api/pkg/clients/hyperfleetdb/convert.go @@ -193,6 +193,14 @@ func metaTime(obj metav1.Object) time.Time { const clusterNSPrefix = "cluster-" +// clusterUUIDLen is the fixed length of a RFC 4122 UUID string (e.g. "4610b27e-8f77-4f4c-9661-c11b42e04dec"). +const clusterUUIDLen = 36 + +// MaxClusterNameLen is the maximum allowed cluster name length. +// HyperShift creates a control plane namespace as "-", +// which expands to "cluster--" and must fit within 63 characters (k8s namespace limit). +const MaxClusterNameLen = 63 - len(clusterNSPrefix) - clusterUUIDLen - len("-") + func clusterNamespace(clusterID string) string { return clusterNSPrefix + clusterID } diff --git a/platform-api/pkg/handlers/cluster.go b/platform-api/pkg/handlers/cluster.go index 7aa9e2dd..ed744e0d 100644 --- a/platform-api/pkg/handlers/cluster.go +++ b/platform-api/pkg/handlers/cluster.go @@ -70,7 +70,7 @@ func (h *ClusterHandler) List(w http.ResponseWriter, r *http.Request) { list, err := h.db.ListClusters(ctx, accountID) if err != nil { h.logger.Error("failed to list clusters", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-LIST-001", "Failed to list clusters") + writeAPIError(w, ErrClusterList) return } @@ -106,29 +106,37 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { var req types.ClusterCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-CREATE-001", "Invalid request body") + writeAPIError(w, ErrClusterCreateInvalidBody) return } if req.Name == "" || req.Spec == nil { - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-CREATE-002", "Missing required fields: name and spec") + writeAPIError(w, ErrClusterCreateMissingFields) + return + } + + if len(req.Name) > hyperfleetdb.MaxClusterNameLen { + writeAPIError(w, ErrClusterCreateNameTooLong, + fmt.Sprintf("Cluster name must be no more than %d characters", hyperfleetdb.MaxClusterNameLen)) return } if errs := h.validator.ValidateCreate(req.Spec, featuregate.Default); errs != nil { - h.writeValidationErrors(w, errs) + def := ErrClusterValidation + def.Errors = errs + writeAPIError(w, def) return } existing, err := h.db.ListClusters(ctx, accountID) if err != nil { h.logger.Error("failed to check cluster name uniqueness", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-CREATE-004", "Failed to validate cluster name") + writeAPIError(w, ErrClusterCreateNameCheck) return } for i := range existing.Items { if existing.Items[i].Name == req.Name { - h.writeError(w, http.StatusConflict, "CLUSTERS-MGMT-CREATE-005", + writeAPIError(w, ErrClusterCreateNameConflict, fmt.Sprintf("A cluster named %q already exists in this account", req.Name)) return } @@ -147,7 +155,7 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { cr, err := hyperfleetdb.PlatformCreateToClusterCR(clusterID, accountID, &req) if err != nil { h.logger.Error("failed to convert cluster spec", "error", err, "account_id", accountID) - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-CREATE-002", "Invalid cluster spec") + writeAPIError(w, ErrClusterCreateInvalidSpec) return } @@ -167,10 +175,10 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { } h.logger.Error("failed to create cluster", "error", err, "account_id", accountID) if hyperfleetdb.IsAlreadyExists(err) { - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-CREATE-007", "Unable to generate unique DNS identifier") + writeAPIError(w, ErrClusterCreateIDExhausted) return } - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-CREATE-003", "Failed to create cluster") + writeAPIError(w, ErrClusterCreateFailed) return } @@ -192,11 +200,11 @@ func (h *ClusterHandler) Get(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "CLUSTERS-MGMT-GET-001", "Cluster not found") + writeAPIError(w, ErrClusterGetNotFound) return } h.logger.Error("failed to get cluster", "error", err, "account_id", accountID, "cluster_id", clusterID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-GET-002", "Failed to get cluster") + writeAPIError(w, ErrClusterGetFailed) return } @@ -212,18 +220,18 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-UPDATE-001", "Failed to read request body") + writeAPIError(w, ErrClusterUpdateInvalidBody) return } var req types.ClusterUpdateRequest if err := json.Unmarshal(body, &req); err != nil { - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-UPDATE-001", "Invalid request body") + writeAPIError(w, ErrClusterUpdateInvalidBody) return } if req.Spec == nil { - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-UPDATE-002", "Missing required field: spec") + writeAPIError(w, ErrClusterUpdateMissingFields) return } @@ -232,16 +240,18 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "CLUSTERS-MGMT-UPDATE-003", "Cluster not found") + writeAPIError(w, ErrClusterUpdateNotFound) return } h.logger.Error("failed to get cluster for update", "error", err, "account_id", accountID, "cluster_id", clusterID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-UPDATE-004", "Failed to update cluster") + writeAPIError(w, ErrClusterUpdateFailed) return } if errs := h.validator.ValidateUpdate(req.Spec, &cr.Spec, featuregate.Default); errs != nil { - h.writeValidationErrors(w, errs) + def := ErrClusterValidation + def.Errors = errs + writeAPIError(w, def) return } @@ -252,19 +262,19 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { Spec json.RawMessage `json:"spec"` } if err := json.Unmarshal(body, &envelope); err != nil { - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-UPDATE-001", "Invalid request body") + writeAPIError(w, ErrClusterUpdateInvalidBody) return } if err := hyperfleetdb.MergeSpecJSON(&cr.Spec, envelope.Spec); err != nil { h.logger.Error("failed to merge cluster spec", "error", err) - h.writeError(w, http.StatusBadRequest, "CLUSTERS-MGMT-UPDATE-002", "Invalid cluster spec") + writeAPIError(w, ErrClusterUpdateInvalidSpec) return } if err := h.db.UpdateCluster(ctx, cr); err != nil { h.logger.Error("failed to update cluster", "error", err, "account_id", accountID, "cluster_id", clusterID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-UPDATE-004", "Failed to update cluster") + writeAPIError(w, ErrClusterUpdateFailed) return } @@ -283,11 +293,11 @@ func (h *ClusterHandler) Delete(w http.ResponseWriter, r *http.Request) { err := h.db.DeleteCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "CLUSTERS-MGMT-DELETE-001", "Cluster not found") + writeAPIError(w, ErrClusterDeleteNotFound) return } h.logger.Error("failed to delete cluster", "error", err, "account_id", accountID, "cluster_id", clusterID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-DELETE-002", "Failed to delete cluster") + writeAPIError(w, ErrClusterDeleteFailed) return } @@ -311,43 +321,19 @@ func (h *ClusterHandler) GetStatus(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "CLUSTERS-MGMT-STATUS-001", "Cluster not found") + writeAPIError(w, ErrClusterStatusNotFound) return } h.logger.Error("failed to get cluster status", "error", err, "account_id", accountID, "cluster_id", clusterID) - h.writeError(w, http.StatusInternalServerError, "CLUSTERS-MGMT-STATUS-002", "Failed to get cluster status") + writeAPIError(w, ErrClusterStatusFailed) return } h.writeJSON(w, http.StatusOK, hyperfleetdb.ClusterStatusFromCR(cr)) } -// Helper methods func (h *ClusterHandler) writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } - -func (h *ClusterHandler) writeValidationErrors(w http.ResponseWriter, errs validation.ValidationErrors) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnprocessableEntity) - resp := map[string]any{ - "kind": "Error", - "code": "CLUSTERS-MGMT-VALIDATION-001", - "reason": "Request validation failed", - "errors": errs, - } - _ = json.NewEncoder(w).Encode(resp) -} - -func (h *ClusterHandler) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/handlers/cluster_test.go b/platform-api/pkg/handlers/cluster_test.go index 6b9eb06c..3b3d6e8f 100644 --- a/platform-api/pkg/handlers/cluster_test.go +++ b/platform-api/pkg/handlers/cluster_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "sync" "sync/atomic" "testing" @@ -271,6 +272,32 @@ func TestClusterHandler_Create_MissingFields(t *testing.T) { } } +func TestClusterHandler_Create_NameTooLong(t *testing.T) { + scheme := newTestScheme() + fc := fake.NewClientBuilder().WithScheme(scheme).Build() + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + handler := NewClusterHandler(hyperfleetdb.NewClientFrom(fc, logger), "https://oidc.example.com", 0, logger) + + longName := strings.Repeat("a", hyperfleetdb.MaxClusterNameLen+1) + body, _ := json.Marshal(map[string]any{"name": longName, "spec": map[string]any{}}) + + req := httptest.NewRequest(http.MethodPost, "/api/v0/clusters", bytes.NewReader(body)) + req = req.WithContext(testContext(testAccountID)) + + w := httptest.NewRecorder() + handler.Create(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + + var errResp map[string]any + _ = json.NewDecoder(w.Body).Decode(&errResp) + if errResp["code"] != ErrClusterCreateNameTooLong.Code { + t.Errorf("expected code %s, got %v", ErrClusterCreateNameTooLong.Code, errResp["code"]) + } +} + func TestClusterHandler_Get_Success(t *testing.T) { scheme := newTestScheme() fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects( diff --git a/platform-api/pkg/handlers/errorcodes.go b/platform-api/pkg/handlers/errorcodes.go new file mode 100644 index 00000000..f9e639e8 --- /dev/null +++ b/platform-api/pkg/handlers/errorcodes.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" +) + +// APIError defines a typed error response. HTTPStatus drives the response code; +// Code, Message, and optional Errors are serialized to JSON under "kind":"Error". +type APIError struct { + Code string `json:"code"` + HTTPStatus int `json:"-"` + Message string `json:"reason"` + Errors any `json:"errors,omitempty"` +} + +// writeAPIError writes a typed JSON error response. +// reason overrides the default Message when provided. +func writeAPIError(w http.ResponseWriter, def APIError, reason ...string) { + if len(reason) > 0 { + def.Message = reason[0] + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(def.HTTPStatus) + _ = json.NewEncoder(w).Encode(struct { + Kind string `json:"kind"` + APIError + }{Kind: "Error", APIError: def}) +} + +// Cluster error codes +var ( + ErrClusterList APIError + + ErrClusterCreateInvalidBody APIError + ErrClusterCreateMissingFields APIError + ErrClusterCreateFailed APIError + ErrClusterCreateNameCheck APIError + ErrClusterCreateNameConflict APIError + ErrClusterCreateNameTooLong APIError + ErrClusterCreateIDExhausted APIError + ErrClusterCreateInvalidSpec APIError + + ErrClusterGetNotFound APIError + ErrClusterGetFailed APIError + + ErrClusterUpdateInvalidBody APIError + ErrClusterUpdateMissingFields APIError + ErrClusterUpdateNotFound APIError + ErrClusterUpdateFailed APIError + ErrClusterUpdateInvalidSpec APIError + + ErrClusterDeleteNotFound APIError + ErrClusterDeleteFailed APIError + + ErrClusterStatusNotFound APIError + ErrClusterStatusFailed APIError + + ErrClusterValidation APIError +) + +// NodePool error codes +var ( + ErrNodePoolList APIError + + ErrNodePoolCreateInvalidBody APIError + ErrNodePoolCreateMissingFields APIError + ErrNodePoolCreateNameConflict APIError + ErrNodePoolCreateClusterNotFound APIError + ErrNodePoolCreateClusterCheck APIError + ErrNodePoolCreateInvalidSpec APIError + ErrNodePoolCreateFailed APIError + + ErrNodePoolGetNotFound APIError + ErrNodePoolGetFailed APIError + + ErrNodePoolUpdateInvalidBody APIError + ErrNodePoolUpdateMissingFields APIError + ErrNodePoolUpdateNotFound APIError + ErrNodePoolUpdateFailed APIError + ErrNodePoolUpdateInvalidSpec APIError + + ErrNodePoolDeleteNotFound APIError + ErrNodePoolDeleteFailed APIError + + ErrNodePoolStatusNotFound APIError + ErrNodePoolStatusFailed APIError + + ErrNodePoolValidation APIError +) + +func init() { + // Cluster — List + ErrClusterList = APIError{Code: "CLUSTERS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list clusters"} + + // Cluster — Create + ErrClusterCreateInvalidBody = APIError{Code: "CLUSTERS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrClusterCreateMissingFields = APIError{Code: "CLUSTERS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required fields: name and spec"} + ErrClusterCreateFailed = APIError{Code: "CLUSTERS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create cluster"} + ErrClusterCreateNameCheck = APIError{Code: "CLUSTERS-MGMT-CREATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster name"} + ErrClusterCreateNameConflict = APIError{Code: "CLUSTERS-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Cluster name already exists in this account"} + ErrClusterCreateNameTooLong = APIError{Code: "CLUSTERS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: fmt.Sprintf("Cluster name must be no more than %d characters", hyperfleetdb.MaxClusterNameLen)} + ErrClusterCreateIDExhausted = APIError{Code: "CLUSTERS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Unable to generate unique DNS identifier"} + ErrClusterCreateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-CREATE-008", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} + + // Cluster — Get + ErrClusterGetNotFound = APIError{Code: "CLUSTERS-MGMT-GET-001", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} + ErrClusterGetFailed = APIError{Code: "CLUSTERS-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster"} + + // Cluster — Update + ErrClusterUpdateInvalidBody = APIError{Code: "CLUSTERS-MGMT-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrClusterUpdateMissingFields = APIError{Code: "CLUSTERS-MGMT-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required field: spec"} + ErrClusterUpdateNotFound = APIError{Code: "CLUSTERS-MGMT-UPDATE-003", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} + ErrClusterUpdateFailed = APIError{Code: "CLUSTERS-MGMT-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to update cluster"} + ErrClusterUpdateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-UPDATE-005", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} + + // Cluster — Delete + ErrClusterDeleteNotFound = APIError{Code: "CLUSTERS-MGMT-DELETE-001", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} + ErrClusterDeleteFailed = APIError{Code: "CLUSTERS-MGMT-DELETE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete cluster"} + + // Cluster — Status + ErrClusterStatusNotFound = APIError{Code: "CLUSTERS-MGMT-STATUS-001", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} + ErrClusterStatusFailed = APIError{Code: "CLUSTERS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster status"} + + // Cluster — Validation + ErrClusterValidation = APIError{Code: "CLUSTERS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "Request validation failed"} + + // NodePool — List + ErrNodePoolList = APIError{Code: "NODEPOOLS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list nodepools"} + + // NodePool — Create + ErrNodePoolCreateInvalidBody = APIError{Code: "NODEPOOLS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrNodePoolCreateMissingFields = APIError{Code: "NODEPOOLS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required fields: name, cluster_id, and spec"} + ErrNodePoolCreateNameConflict = APIError{Code: "NODEPOOLS-MGMT-CREATE-003", HTTPStatus: http.StatusConflict, Message: "NodePool already exists"} + ErrNodePoolCreateClusterNotFound = APIError{Code: "NODEPOOLS-MGMT-CREATE-004", HTTPStatus: http.StatusNotFound, Message: "Referenced cluster not found"} + ErrNodePoolCreateClusterCheck = APIError{Code: "NODEPOOLS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster reference"} + ErrNodePoolCreateInvalidSpec = APIError{Code: "NODEPOOLS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: "Invalid nodepool spec"} + ErrNodePoolCreateFailed = APIError{Code: "NODEPOOLS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create nodepool"} + + // NodePool — Get + ErrNodePoolGetNotFound = APIError{Code: "NODEPOOLS-MGMT-GET-001", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} + ErrNodePoolGetFailed = APIError{Code: "NODEPOOLS-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool"} + + // NodePool — Update + ErrNodePoolUpdateInvalidBody = APIError{Code: "NODEPOOLS-MGMT-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrNodePoolUpdateMissingFields = APIError{Code: "NODEPOOLS-MGMT-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required field: spec"} + ErrNodePoolUpdateNotFound = APIError{Code: "NODEPOOLS-MGMT-UPDATE-003", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} + ErrNodePoolUpdateFailed = APIError{Code: "NODEPOOLS-MGMT-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to update nodepool"} + ErrNodePoolUpdateInvalidSpec = APIError{Code: "NODEPOOLS-MGMT-UPDATE-005", HTTPStatus: http.StatusBadRequest, Message: "Invalid nodepool spec"} + + // NodePool — Delete + ErrNodePoolDeleteNotFound = APIError{Code: "NODEPOOLS-MGMT-DELETE-001", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} + ErrNodePoolDeleteFailed = APIError{Code: "NODEPOOLS-MGMT-DELETE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete nodepool"} + + // NodePool — Status + ErrNodePoolStatusNotFound = APIError{Code: "NODEPOOLS-MGMT-STATUS-001", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} + ErrNodePoolStatusFailed = APIError{Code: "NODEPOOLS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool status"} + + // NodePool — Validation + ErrNodePoolValidation = APIError{Code: "NODEPOOLS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "Request validation failed"} +} diff --git a/platform-api/pkg/handlers/nodepool.go b/platform-api/pkg/handlers/nodepool.go index 1d900d0c..5496ad37 100644 --- a/platform-api/pkg/handlers/nodepool.go +++ b/platform-api/pkg/handlers/nodepool.go @@ -7,13 +7,14 @@ import ( "net/http" "strconv" - "github.com/google/uuid" "github.com/gorilla/mux" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/types" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/validation" + + "github.com/google/uuid" ) type NodePoolHandler struct { @@ -58,7 +59,7 @@ func (h *NodePoolHandler) List(w http.ResponseWriter, r *http.Request) { list, err := h.db.ListNodePools(ctx, accountID, clusterID) if err != nil { h.logger.Error("failed to list nodepools", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-LIST-001", "Failed to list nodepools") + writeAPIError(w, ErrNodePoolList) return } @@ -92,27 +93,29 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { var req types.NodePoolCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-CREATE-001", "Invalid request body") + writeAPIError(w, ErrNodePoolCreateInvalidBody) return } if req.Name == "" || req.ClusterID == "" || req.Spec == nil { - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-CREATE-002", "Missing required fields: name, cluster_id, and spec") + writeAPIError(w, ErrNodePoolCreateMissingFields) return } if errs := h.validator.ValidateCreate(req.Spec, featuregate.Default); errs != nil { - h.writeValidationErrors(w, errs) + def := ErrNodePoolValidation + def.Errors = errs + writeAPIError(w, def) return } if _, err := h.db.GetCluster(ctx, accountID, req.ClusterID); err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "NODEPOOLS-MGMT-CREATE-004", "Referenced cluster not found") + writeAPIError(w, ErrNodePoolCreateClusterNotFound) return } h.logger.Error("failed to verify cluster exists", "error", err, "account_id", accountID, "cluster_id", req.ClusterID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-CREATE-005", "Failed to validate cluster reference") + writeAPIError(w, ErrNodePoolCreateClusterCheck) return } @@ -122,17 +125,17 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { cr, err := hyperfleetdb.PlatformCreateToNodePoolCR(accountID, internalPoolID, &req) if err != nil { h.logger.Error("failed to convert nodepool spec", "error", err, "account_id", accountID) - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-CREATE-002", "Invalid nodepool spec") + writeAPIError(w, ErrNodePoolCreateInvalidSpec) return } if err := h.db.CreateNodePool(ctx, accountID, cr); err != nil { h.logger.Error("failed to create nodepool", "error", err, "account_id", accountID) if hyperfleetdb.IsAlreadyExists(err) { - h.writeError(w, http.StatusConflict, "NODEPOOLS-MGMT-CREATE-003", "NodePool already exists") + writeAPIError(w, ErrNodePoolCreateNameConflict) return } - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-CREATE-003", "Failed to create nodepool") + writeAPIError(w, ErrNodePoolCreateFailed) return } @@ -150,11 +153,11 @@ func (h *NodePoolHandler) Get(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "NODEPOOLS-MGMT-GET-001", "NodePool not found") + writeAPIError(w, ErrNodePoolGetNotFound) return } h.logger.Error("failed to get nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-GET-002", "Failed to get nodepool") + writeAPIError(w, ErrNodePoolGetFailed) return } @@ -169,18 +172,18 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-UPDATE-001", "Failed to read request body") + writeAPIError(w, ErrNodePoolUpdateInvalidBody) return } var req types.NodePoolUpdateRequest if err := json.Unmarshal(body, &req); err != nil { - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-UPDATE-001", "Invalid request body") + writeAPIError(w, ErrNodePoolUpdateInvalidBody) return } if req.Spec == nil { - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-UPDATE-002", "Missing required field: spec") + writeAPIError(w, ErrNodePoolUpdateMissingFields) return } @@ -189,16 +192,18 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "NODEPOOLS-MGMT-UPDATE-003", "NodePool not found") + writeAPIError(w, ErrNodePoolUpdateNotFound) return } h.logger.Error("failed to get nodepool for update", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-UPDATE-004", "Failed to update nodepool") + writeAPIError(w, ErrNodePoolUpdateFailed) return } if errs := h.validator.ValidateUpdate(req.Spec, &cr.Spec, featuregate.Default); errs != nil { - h.writeValidationErrors(w, errs) + def := ErrNodePoolValidation + def.Errors = errs + writeAPIError(w, def) return } @@ -206,19 +211,19 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { Spec json.RawMessage `json:"spec"` } if err := json.Unmarshal(body, &envelope); err != nil { - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-UPDATE-001", "Invalid request body") + writeAPIError(w, ErrNodePoolUpdateInvalidBody) return } if err := hyperfleetdb.MergeSpecJSON(&cr.Spec, envelope.Spec); err != nil { h.logger.Error("failed to merge nodepool spec", "error", err) - h.writeError(w, http.StatusBadRequest, "NODEPOOLS-MGMT-UPDATE-002", "Invalid nodepool spec") + writeAPIError(w, ErrNodePoolUpdateInvalidSpec) return } if err := h.db.UpdateNodePool(ctx, cr); err != nil { h.logger.Error("failed to update nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-UPDATE-004", "Failed to update nodepool") + writeAPIError(w, ErrNodePoolUpdateFailed) return } @@ -236,11 +241,11 @@ func (h *NodePoolHandler) Delete(w http.ResponseWriter, r *http.Request) { err := h.db.DeleteNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "NODEPOOLS-MGMT-DELETE-001", "NodePool not found") + writeAPIError(w, ErrNodePoolDeleteNotFound) return } h.logger.Error("failed to delete nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-DELETE-002", "Failed to delete nodepool") + writeAPIError(w, ErrNodePoolDeleteFailed) return } @@ -263,11 +268,11 @@ func (h *NodePoolHandler) GetStatus(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "NODEPOOLS-MGMT-STATUS-001", "NodePool not found") + writeAPIError(w, ErrNodePoolStatusNotFound) return } h.logger.Error("failed to get nodepool status", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - h.writeError(w, http.StatusInternalServerError, "NODEPOOLS-MGMT-STATUS-002", "Failed to get nodepool status") + writeAPIError(w, ErrNodePoolStatusFailed) return } @@ -279,26 +284,3 @@ func (h *NodePoolHandler) writeJSON(w http.ResponseWriter, status int, data any) w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } - -func (h *NodePoolHandler) writeValidationErrors(w http.ResponseWriter, errs validation.ValidationErrors) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnprocessableEntity) - resp := map[string]any{ - "kind": "Error", - "code": "NODEPOOLS-MGMT-VALIDATION-001", - "reason": "Request validation failed", - "errors": errs, - } - _ = json.NewEncoder(w).Encode(resp) -} - -func (h *NodePoolHandler) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - _ = json.NewEncoder(w).Encode(resp) -} From aa00bedd5bde03a1185a76ca6ea2678ce65fa35e Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 16:44:44 -0300 Subject: [PATCH 2/7] ROSAENG-62084 | feat: centralized typed error system with builder API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces pkg/apierror as a shared leaf package providing APIError with WithErrors/WithReason builder methods and a Write function that derives reason from structured or plain errors automatically. All handlers and middleware now use typed error vars defined in errorcodes.go — no inline JSON, no hardcoded strings, no local message overrides. Integration test assertions updated to reference .Code fields instead of stale literals. --- platform-api/pkg/apierror/apierror.go | 61 ++++ platform-api/pkg/apierror/apierror_test.go | 267 ++++++++++++++++ platform-api/pkg/handlers/accounts.go | 31 +- platform-api/pkg/handlers/authz.go | 93 +++--- platform-api/pkg/handlers/cluster.go | 15 +- platform-api/pkg/handlers/cluster_test.go | 12 +- platform-api/pkg/handlers/errorcodes.go | 292 ++++++++++++++++-- platform-api/pkg/handlers/info.go | 7 +- platform-api/pkg/handlers/info_test.go | 4 +- .../pkg/handlers/management_cluster.go | 31 +- platform-api/pkg/handlers/nodepool.go | 8 +- platform-api/pkg/handlers/zoa.go | 48 ++- platform-api/pkg/handlers/zoa_test.go | 6 +- platform-api/pkg/middleware/account_check.go | 21 +- platform-api/pkg/middleware/admin_check.go | 22 +- .../pkg/middleware/admin_check_test.go | 16 +- platform-api/pkg/middleware/authorization.go | 18 +- .../pkg/middleware/authorization_test.go | 45 ++- platform-api/pkg/middleware/authz.go | 26 +- platform-api/pkg/middleware/errorcodes.go | 47 +++ platform-api/pkg/middleware/privileged.go | 20 +- platform-api/pkg/ratelimit/middleware.go | 19 +- platform-api/pkg/ratelimit/middleware_test.go | 4 +- .../pkg/validation/field_validator.go | 11 +- 24 files changed, 796 insertions(+), 328 deletions(-) create mode 100644 platform-api/pkg/apierror/apierror.go create mode 100644 platform-api/pkg/apierror/apierror_test.go create mode 100644 platform-api/pkg/middleware/errorcodes.go diff --git a/platform-api/pkg/apierror/apierror.go b/platform-api/pkg/apierror/apierror.go new file mode 100644 index 00000000..fd434b96 --- /dev/null +++ b/platform-api/pkg/apierror/apierror.go @@ -0,0 +1,61 @@ +package apierror + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// APIError is a typed error response. HTTPStatus drives the HTTP status code; +// Code, Message, and optional Errors are serialized to JSON under "kind":"Error". +// Reason, when set, is the fmt template used by WithReason() to build dynamic Errors. +type APIError struct { + Code string `json:"code"` + HTTPStatus int `json:"-"` + Message string `json:"reason"` + Errors any `json:"errors,omitempty"` + Reason string `json:"-"` +} + +// WithErrors returns a copy of e with Errors set to v for structured payloads +// (e.g. a slice of field-level validation errors). +func (e APIError) WithErrors(v any) APIError { + e.Errors = v + return e +} + +// WithReason returns a copy of e with Errors set by applying e.Reason to args +// via fmt.Errorf. Panics if e.Reason is empty so misconfiguration is caught at +// test time. +func (e APIError) WithReason(args ...any) APIError { + if e.Reason == "" { + panic(fmt.Sprintf("apierror: WithReason() called on %q which has no Reason template", e.Code)) + } + e.Errors = fmt.Errorf(e.Reason, args...) + return e +} + +// Write serializes def as a JSON error response. +// +// When Errors implements error, reason is derived from Errors.Error() so the +// top-level field always carries full detail. If the concrete Errors value has +// no exported fields (e.g. errors.New, fmt.Errorf) its JSON representation +// would be "{}", which adds no value; Write suppresses it from the output so +// that clients only see the populated reason and not an empty errors object. +func Write(w http.ResponseWriter, def APIError) { + if err, ok := def.Errors.(error); ok { + b, _ := json.Marshal(def.Errors) + if len(b) == 0 || string(b) == "{}" || string(b) == "null" { + // Plain error: derive reason from message, suppress empty errors field. + def.Message = err.Error() + def.Errors = nil + } + // Structured error: keep the static Message and let Errors serialize as-is. + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(def.HTTPStatus) + _ = json.NewEncoder(w).Encode(struct { + Kind string `json:"kind"` + APIError + }{Kind: "Error", APIError: def}) +} diff --git a/platform-api/pkg/apierror/apierror_test.go b/platform-api/pkg/apierror/apierror_test.go new file mode 100644 index 00000000..ae4a6eb3 --- /dev/null +++ b/platform-api/pkg/apierror/apierror_test.go @@ -0,0 +1,267 @@ +package apierror_test + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" +) + +var base = apierror.APIError{ + Code: "TEST-001", + HTTPStatus: http.StatusBadRequest, + Message: "something went wrong", +} + +// structuredError has exported fields so it marshals to non-empty JSON. +type structuredError struct { + Field string `json:"field"` + Detail string `json:"detail"` +} + +func (e *structuredError) Error() string { return e.Detail } + +func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + if err := json.NewDecoder(w.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + return out +} + +func write(def apierror.APIError) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + apierror.Write(w, def) + return w +} + +// --- WithErrors --- + +func TestWithErrors_SetsErrors(t *testing.T) { + payload := []string{"a", "b"} + got := base.WithErrors(payload) + if got.Errors == nil { + t.Fatal("expected Errors to be set") + } +} + +func TestWithErrors_DoesNotMutateBase(t *testing.T) { + _ = base.WithErrors("x") + if base.Errors != nil { + t.Fatal("WithErrors must not mutate the receiver") + } +} + +// --- WithReason --- + +func TestWithReason_AppliesTemplate(t *testing.T) { + e := apierror.APIError{Code: "X", HTTPStatus: 400, Message: "m", Reason: "hello %s"} + got := e.WithReason("world") + if got.Errors == nil { + t.Fatal("expected Errors to be set") + } + if got.Errors.(error).Error() != "hello world" { + t.Fatalf("unexpected reason: %v", got.Errors) + } +} + +func TestWithReason_WrapsErrorWithW(t *testing.T) { + sentinel := errors.New("sentinel") + e := apierror.APIError{Code: "X", HTTPStatus: 500, Message: "m", Reason: "%w"} + got := e.WithReason(sentinel) + if !errors.Is(got.Errors.(error), sentinel) { + t.Fatal("expected error chain to be preserved via %w") + } +} + +func TestWithReason_PanicsWithoutTemplate(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic when Reason is empty") + } + }() + base.WithReason("arg") +} + +func TestWithReason_DoesNotMutateBase(t *testing.T) { + e := apierror.APIError{Code: "X", HTTPStatus: 400, Message: "m", Reason: "%s"} + _ = e.WithReason("x") + if e.Errors != nil { + t.Fatal("WithReason must not mutate the receiver") + } +} + +// --- Write: HTTP envelope --- + +func TestWrite_StatusCode(t *testing.T) { + w := write(apierror.APIError{Code: "X", HTTPStatus: http.StatusNotFound, Message: "m"}) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestWrite_ContentType(t *testing.T) { + w := write(base) + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected application/json, got %q", ct) + } +} + +func TestWrite_KindIsError(t *testing.T) { + w := write(base) + resp := decode(t, w) + if resp["kind"] != "Error" { + t.Fatalf("expected kind=Error, got %v", resp["kind"]) + } +} + +func TestWrite_CodeAndReason(t *testing.T) { + w := write(base) + resp := decode(t, w) + if resp["code"] != "TEST-001" { + t.Fatalf("unexpected code: %v", resp["code"]) + } + if resp["reason"] != "something went wrong" { + t.Fatalf("unexpected reason: %v", resp["reason"]) + } +} + +// --- Write: plain error (no exported fields) --- + +func TestWrite_PlainError_ReasonFromError(t *testing.T) { + e := apierror.APIError{Code: "TEST-001", HTTPStatus: http.StatusNotFound, Message: "not found", Reason: "cluster %q not found"} + w := write(e.WithReason("abc")) + resp := decode(t, w) + if resp["reason"] != `cluster "abc" not found` { + t.Fatalf("unexpected reason: %v", resp["reason"]) + } +} + +func TestWrite_PlainError_ErrorsFieldSuppressed(t *testing.T) { + e := apierror.APIError{Code: "TEST-001", HTTPStatus: http.StatusBadRequest, Message: "bad", Reason: "%w"} + w := write(e.WithReason(errors.New("oops"))) + resp := decode(t, w) + if _, ok := resp["errors"]; ok { + t.Fatal("errors field must be suppressed for plain errors") + } +} + +// --- Write: structured error (exported fields) --- + +func TestWrite_StructuredError_ReasonIsStatic(t *testing.T) { + def := base.WithErrors(&structuredError{Field: "foo", Detail: "too long"}) + w := write(def) + resp := decode(t, w) + if resp["reason"] != "something went wrong" { + t.Fatalf("expected static reason, got %v", resp["reason"]) + } +} + +func TestWrite_StructuredError_ErrorsFieldPresent(t *testing.T) { + def := base.WithErrors(&structuredError{Field: "foo", Detail: "too long"}) + w := write(def) + resp := decode(t, w) + if resp["errors"] == nil { + t.Fatal("expected errors field to be present for structured errors") + } + errs := resp["errors"].(map[string]any) + if errs["field"] != "foo" { + t.Fatalf("unexpected errors.field: %v", errs["field"]) + } +} + +// --- Write: no errors --- + +func TestWrite_NoErrors_NoErrorsField(t *testing.T) { + w := write(base) + resp := decode(t, w) + if _, ok := resp["errors"]; ok { + t.Fatal("errors field must be absent when not set") + } +} + +// --- Write: full response format --- + +func TestWrite_ResponseFormat(t *testing.T) { + cases := []struct { + name string + def apierror.APIError + wantStatus int + wantKind string + wantCode string + wantReason string + wantErrors any // nil means field must be absent + forbidden []string // keys that must not appear in the response + }{ + { + name: "static message no errors", + def: apierror.APIError{Code: "A-001", HTTPStatus: http.StatusBadRequest, Message: "bad request"}, + wantStatus: http.StatusBadRequest, + wantKind: "Error", + wantCode: "A-001", + wantReason: "bad request", + forbidden: []string{"HTTPStatus", "http_status", "Reason", "Format"}, + }, + { + name: "plain error derives reason and suppresses errors field", + def: apierror.APIError{Code: "A-002", HTTPStatus: http.StatusNotFound, Message: "default", Reason: "item %q not found"}.WithReason("xyz"), + wantStatus: http.StatusNotFound, + wantKind: "Error", + wantCode: "A-002", + wantReason: `item "xyz" not found`, + forbidden: []string{"HTTPStatus", "http_status", "Reason", "Format"}, + }, + { + name: "structured error keeps static reason and exposes errors", + def: apierror.APIError{Code: "A-003", HTTPStatus: http.StatusUnprocessableEntity, Message: "validation failed"}.WithErrors(&structuredError{Field: "name", Detail: "required"}), + wantStatus: http.StatusUnprocessableEntity, + wantKind: "Error", + wantCode: "A-003", + wantReason: "validation failed", + wantErrors: map[string]any{"field": "name", "detail": "required"}, + forbidden: []string{"HTTPStatus", "http_status", "Reason", "Format"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := write(tc.def) + + if w.Code != tc.wantStatus { + t.Errorf("status: got %d, want %d", w.Code, tc.wantStatus) + } + + resp := decode(t, w) + + if resp["kind"] != tc.wantKind { + t.Errorf("kind: got %v, want %q", resp["kind"], tc.wantKind) + } + if resp["code"] != tc.wantCode { + t.Errorf("code: got %v, want %q", resp["code"], tc.wantCode) + } + if resp["reason"] != tc.wantReason { + t.Errorf("reason: got %v, want %q", resp["reason"], tc.wantReason) + } + + if tc.wantErrors == nil { + if _, ok := resp["errors"]; ok { + t.Errorf("errors: expected absent, got %v", resp["errors"]) + } + } else { + if resp["errors"] == nil { + t.Error("errors: expected present, got absent") + } + } + + for _, key := range tc.forbidden { + if _, ok := resp[key]; ok { + t.Errorf("internal field %q must not appear in response", key) + } + } + }) + } +} diff --git a/platform-api/pkg/handlers/accounts.go b/platform-api/pkg/handlers/accounts.go index ac444433..f1fcb180 100644 --- a/platform-api/pkg/handlers/accounts.go +++ b/platform-api/pkg/handlers/accounts.go @@ -57,12 +57,12 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { var req EnableAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAccountCreateInvalidBody) return } if req.AccountID == "" { - h.writeError(w, http.StatusBadRequest, "missing-account-id", "accountId is required") + writeAPIError(w, ErrAccountCreateMissingID) return } @@ -70,18 +70,18 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { existing, err := h.authorizer.GetAccount(ctx, req.AccountID) if err != nil { h.logger.Error("failed to check existing account", "error", err, "account_id", req.AccountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to check account status") + writeAPIError(w, ErrAccountCreateCheckFailed) return } if existing != nil { - h.writeError(w, http.StatusConflict, "account-exists", "Account is already enabled") + writeAPIError(w, ErrAccountCreateExists) return } account, err := h.authorizer.EnableAccount(ctx, req.AccountID, callerARN, req.Privileged) if err != nil { h.logger.Error("failed to enable account", "error", err, "account_id", req.AccountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to enable account") + writeAPIError(w, ErrAccountCreateFailed) return } @@ -106,7 +106,7 @@ func (h *AccountsHandler) List(w http.ResponseWriter, r *http.Request) { accounts, err := h.authorizer.ListAccounts(ctx) if err != nil { h.logger.Error("failed to list accounts", "error", err) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list accounts") + writeAPIError(w, ErrAccountListFailed) return } @@ -139,12 +139,12 @@ func (h *AccountsHandler) Get(w http.ResponseWriter, r *http.Request) { account, err := h.authorizer.GetAccount(ctx, accountID) if err != nil { h.logger.Error("failed to get account", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to get account") + writeAPIError(w, ErrAccountGetFailed) return } if account == nil { - h.writeError(w, http.StatusNotFound, "not-found", "Account not found") + writeAPIError(w, ErrAccountGetNotFound) return } @@ -171,7 +171,7 @@ func (h *AccountsHandler) Delete(w http.ResponseWriter, r *http.Request) { err := h.authorizer.DisableAccount(ctx, accountID) if err != nil { h.logger.Error("failed to disable account", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to disable account") + writeAPIError(w, ErrAccountDeleteFailed) return } @@ -179,16 +179,3 @@ func (h *AccountsHandler) Delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } - -func (h *AccountsHandler) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/handlers/authz.go b/platform-api/pkg/handlers/authz.go index 169d5134..137ff866 100644 --- a/platform-api/pkg/handlers/authz.go +++ b/platform-api/pkg/handlers/authz.go @@ -140,24 +140,24 @@ func (h *AuthzHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) { var req CreatePolicyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzPolicyCreateInvalidBody) return } if req.Name == "" { - h.writeError(w, http.StatusBadRequest, "missing-name", "name is required") + writeAPIError(w, ErrAuthzPolicyCreateMissingName) return } if req.Policy == "" { - h.writeError(w, http.StatusBadRequest, "missing-policy", "policy (Cedar text) is required") + writeAPIError(w, ErrAuthzPolicyCreateMissingText) return } p, err := h.service.CreatePolicy(ctx, accountID, req.Name, req.Description, req.Policy) if err != nil { h.logger.Error("failed to create policy", "error", err, "account_id", accountID) - h.writeError(w, http.StatusBadRequest, "invalid-policy", err.Error()) + writeAPIError(w, ErrAuthzPolicyCreateInvalid.WithReason(err)) return } @@ -180,7 +180,7 @@ func (h *AuthzHandler) ListPolicies(w http.ResponseWriter, r *http.Request) { policies, err := h.service.ListPolicies(ctx, accountID) if err != nil { h.logger.Error("failed to list policies", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list policies") + writeAPIError(w, ErrAuthzPolicyListFailed) return } @@ -212,12 +212,12 @@ func (h *AuthzHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { p, err := h.service.GetPolicy(ctx, accountID, policyID) if err != nil { h.logger.Error("failed to get policy", "error", err, "account_id", accountID, "policy_id", policyID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to get policy") + writeAPIError(w, ErrAuthzPolicyGetFailed) return } if p == nil { - h.writeError(w, http.StatusNotFound, "not-found", "Policy not found") + writeAPIError(w, ErrAuthzPolicyGetNotFound) return } @@ -239,14 +239,14 @@ func (h *AuthzHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) { var req CreatePolicyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzPolicyUpdateInvalidBody) return } p, err := h.service.UpdatePolicy(ctx, accountID, policyID, req.Name, req.Description, req.Policy) if err != nil { h.logger.Error("failed to update policy", "error", err, "account_id", accountID, "policy_id", policyID) - h.writeError(w, http.StatusBadRequest, "invalid-policy", err.Error()) + writeAPIError(w, ErrAuthzPolicyUpdateInvalid.WithReason(err)) return } @@ -270,10 +270,10 @@ func (h *AuthzHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) { if err != nil { h.logger.Error("failed to delete policy", "error", err, "account_id", accountID, "policy_id", policyID) if err.Error() == "cannot delete policy with existing attachments" { - h.writeError(w, http.StatusConflict, "policy-in-use", err.Error()) + writeAPIError(w, ErrAuthzPolicyDeleteInUse.WithReason(err)) return } - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to delete policy") + writeAPIError(w, ErrAuthzPolicyDeleteFailed) return } @@ -288,19 +288,19 @@ func (h *AuthzHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { var req CreateGroupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzGroupCreateInvalidBody) return } if req.Name == "" { - h.writeError(w, http.StatusBadRequest, "missing-name", "name is required") + writeAPIError(w, ErrAuthzGroupCreateMissingName) return } g, err := h.service.CreateGroup(ctx, accountID, req.Name, req.Description) if err != nil { h.logger.Error("failed to create group", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to create group") + writeAPIError(w, ErrAuthzGroupCreateFailed) return } @@ -323,7 +323,7 @@ func (h *AuthzHandler) ListGroups(w http.ResponseWriter, r *http.Request) { groups, err := h.service.ListGroups(ctx, accountID) if err != nil { h.logger.Error("failed to list groups", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list groups") + writeAPIError(w, ErrAuthzGroupListFailed) return } @@ -355,12 +355,12 @@ func (h *AuthzHandler) GetGroup(w http.ResponseWriter, r *http.Request) { g, err := h.service.GetGroup(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to get group", "error", err, "account_id", accountID, "group_id", groupID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to get group") + writeAPIError(w, ErrAuthzGroupGetFailed) return } if g == nil { - h.writeError(w, http.StatusNotFound, "not-found", "Group not found") + writeAPIError(w, ErrAuthzGroupGetNotFound) return } @@ -383,7 +383,7 @@ func (h *AuthzHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { err := h.service.DeleteGroup(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to delete group", "error", err, "account_id", accountID, "group_id", groupID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to delete group") + writeAPIError(w, ErrAuthzGroupDeleteFailed) return } @@ -398,7 +398,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request var req UpdateMembersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzGroupMembersUpdateInvalidBody) return } @@ -406,7 +406,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request for _, memberARN := range req.Add { if err := h.service.AddGroupMember(ctx, accountID, groupID, memberARN); err != nil { h.logger.Error("failed to add group member", "error", err, "account_id", accountID, "group_id", groupID, "member", memberARN) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to add group member") + writeAPIError(w, ErrAuthzGroupMembersUpdateAddFailed) return } } @@ -415,7 +415,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request for _, memberARN := range req.Remove { if err := h.service.RemoveGroupMember(ctx, accountID, groupID, memberARN); err != nil { h.logger.Error("failed to remove group member", "error", err, "account_id", accountID, "group_id", groupID, "member", memberARN) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to remove group member") + writeAPIError(w, ErrAuthzGroupMembersUpdateRemFailed) return } } @@ -424,7 +424,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request members, err := h.service.ListGroupMembers(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to list group members", "error", err, "account_id", accountID, "group_id", groupID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list group members") + writeAPIError(w, ErrAuthzGroupMembersUpdateListFailed) return } @@ -445,7 +445,7 @@ func (h *AuthzHandler) ListGroupMembers(w http.ResponseWriter, r *http.Request) members, err := h.service.ListGroupMembers(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to list group members", "error", err, "account_id", accountID, "group_id", groupID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list group members") + writeAPIError(w, ErrAuthzGroupMembersListFailed) return } @@ -465,24 +465,24 @@ func (h *AuthzHandler) CreateAttachment(w http.ResponseWriter, r *http.Request) var req CreateAttachmentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzAttachCreateInvalidBody) return } if req.PolicyID == "" || req.TargetType == "" || req.TargetID == "" { - h.writeError(w, http.StatusBadRequest, "missing-fields", "policyId, targetType, and targetId are required") + writeAPIError(w, ErrAuthzAttachCreateMissingFields) return } if req.TargetType != "user" && req.TargetType != "group" { - h.writeError(w, http.StatusBadRequest, "invalid-target-type", "targetType must be 'user' or 'group'") + writeAPIError(w, ErrAuthzAttachCreateInvalidTarget) return } a, err := h.service.AttachPolicy(ctx, accountID, req.PolicyID, authz.TargetType(req.TargetType), req.TargetID) if err != nil { h.logger.Error("failed to attach policy", "error", err, "account_id", accountID, "policy_id", req.PolicyID) - h.writeError(w, http.StatusBadRequest, "attachment-failed", err.Error()) + writeAPIError(w, ErrAuthzAttachCreateFailed.WithReason(err)) return } @@ -512,7 +512,7 @@ func (h *AuthzHandler) ListAttachments(w http.ResponseWriter, r *http.Request) { attachments, err := h.service.ListAttachments(ctx, accountID, filter) if err != nil { h.logger.Error("failed to list attachments", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list attachments") + writeAPIError(w, ErrAuthzAttachListFailed) return } @@ -545,7 +545,7 @@ func (h *AuthzHandler) DeleteAttachment(w http.ResponseWriter, r *http.Request) err := h.service.DetachPolicy(ctx, accountID, attachmentID) if err != nil { h.logger.Error("failed to detach policy", "error", err, "account_id", accountID, "attachment_id", attachmentID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to detach policy") + writeAPIError(w, ErrAuthzAttachDeleteFailed) return } @@ -561,24 +561,24 @@ func (h *AuthzHandler) AddAdmin(w http.ResponseWriter, r *http.Request) { var req AddAdminRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzAdminAddInvalidBody) return } if req.PrincipalARN == "" { - h.writeError(w, http.StatusBadRequest, "missing-principal-arn", "principalArn is required") + writeAPIError(w, ErrAuthzAdminAddMissingPrinc) return } err := h.service.AddAdmin(ctx, accountID, req.PrincipalARN, callerARN) if err != nil { h.logger.Error("failed to add admin", "error", err, "account_id", accountID, "principal_arn", req.PrincipalARN) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to add admin") + writeAPIError(w, ErrAuthzAdminAddFailed) return } - w.WriteHeader(http.StatusCreated) w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]any{ "kind": "Admin", "principalArn": req.PrincipalARN, @@ -592,7 +592,7 @@ func (h *AuthzHandler) ListAdmins(w http.ResponseWriter, r *http.Request) { admins, err := h.service.ListAdmins(ctx, accountID) if err != nil { h.logger.Error("failed to list admins", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to list admins") + writeAPIError(w, ErrAuthzAdminListFailed) return } @@ -614,7 +614,7 @@ func (h *AuthzHandler) RemoveAdmin(w http.ResponseWriter, r *http.Request) { err := h.service.RemoveAdmin(ctx, accountID, principalARN) if err != nil { h.logger.Error("failed to remove admin", "error", err, "account_id", accountID, "principal_arn", principalARN) - h.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to remove admin") + writeAPIError(w, ErrAuthzAdminDeleteFailed) return } @@ -628,22 +628,22 @@ func (h *AuthzHandler) CheckAuthorization(w http.ResponseWriter, r *http.Request var req CheckAuthorizationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrAuthzCheckInvalidBody) return } if req.Principal == "" { - h.writeError(w, http.StatusBadRequest, "missing-principal", "principal is required") + writeAPIError(w, ErrAuthzCheckMissingPrinc) return } if req.Action == "" { - h.writeError(w, http.StatusBadRequest, "missing-action", "action is required") + writeAPIError(w, ErrAuthzCheckMissingAction) return } if req.Resource == "" { - h.writeError(w, http.StatusBadRequest, "missing-resource", "resource is required") + writeAPIError(w, ErrAuthzCheckMissingRes) return } @@ -661,7 +661,7 @@ func (h *AuthzHandler) CheckAuthorization(w http.ResponseWriter, r *http.Request allowed, err := h.checker.Authorize(ctx, authzReq) if err != nil { h.logger.Error("authorization check failed", "error", err, "account_id", accountID, "principal", req.Principal, "action", req.Action) - h.writeError(w, http.StatusInternalServerError, "authorization-error", err.Error()) + writeAPIError(w, ErrAuthzCheckFailed.WithReason(err)) return } @@ -676,16 +676,3 @@ func (h *AuthzHandler) CheckAuthorization(w http.ResponseWriter, r *http.Request Decision: decision, }) } - -func (h *AuthzHandler) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/handlers/cluster.go b/platform-api/pkg/handlers/cluster.go index ed744e0d..f42ac8e5 100644 --- a/platform-api/pkg/handlers/cluster.go +++ b/platform-api/pkg/handlers/cluster.go @@ -2,7 +2,6 @@ package handlers import ( "encoding/json" - "fmt" "io" "log/slog" "net/http" @@ -116,15 +115,12 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { } if len(req.Name) > hyperfleetdb.MaxClusterNameLen { - writeAPIError(w, ErrClusterCreateNameTooLong, - fmt.Sprintf("Cluster name must be no more than %d characters", hyperfleetdb.MaxClusterNameLen)) + writeAPIError(w, ErrClusterCreateNameTooLong) return } if errs := h.validator.ValidateCreate(req.Spec, featuregate.Default); errs != nil { - def := ErrClusterValidation - def.Errors = errs - writeAPIError(w, def) + writeAPIError(w, ErrClusterValidation.WithErrors(errs)) return } @@ -136,8 +132,7 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { } for i := range existing.Items { if existing.Items[i].Name == req.Name { - writeAPIError(w, ErrClusterCreateNameConflict, - fmt.Sprintf("A cluster named %q already exists in this account", req.Name)) + writeAPIError(w, ErrClusterCreateNameConflict.WithReason(req.Name)) return } } @@ -249,9 +244,7 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { } if errs := h.validator.ValidateUpdate(req.Spec, &cr.Spec, featuregate.Default); errs != nil { - def := ErrClusterValidation - def.Errors = errs - writeAPIError(w, def) + writeAPIError(w, ErrClusterValidation.WithErrors(errs)) return } diff --git a/platform-api/pkg/handlers/cluster_test.go b/platform-api/pkg/handlers/cluster_test.go index 3b3d6e8f..73bc8016 100644 --- a/platform-api/pkg/handlers/cluster_test.go +++ b/platform-api/pkg/handlers/cluster_test.go @@ -347,8 +347,8 @@ func TestClusterHandler_Get_NotFound(t *testing.T) { var errResp map[string]any _ = json.NewDecoder(w.Body).Decode(&errResp) - if errResp["code"] != "CLUSTERS-MGMT-GET-001" { - t.Errorf("expected code CLUSTERS-MGMT-GET-001, got %v", errResp["code"]) + if errResp["code"] != ErrClusterGetNotFound.Code { + t.Errorf("expected code %s, got %v", ErrClusterGetNotFound.Code, errResp["code"]) } } @@ -553,8 +553,8 @@ func TestClusterHandler_Create_DuplicateName(t *testing.T) { var errResp map[string]any _ = json.NewDecoder(w.Body).Decode(&errResp) - if errResp["code"] != "CLUSTERS-MGMT-CREATE-005" { - t.Errorf("expected code CLUSTERS-MGMT-CREATE-005, got %v", errResp["code"]) + if errResp["code"] != ErrClusterCreateNameConflict.Code { + t.Errorf("expected code %s, got %v", ErrClusterCreateNameConflict.Code, errResp["code"]) } } @@ -661,8 +661,8 @@ func TestClusterHandler_Create_Hash4ExhaustedRetries(t *testing.T) { var errResp map[string]any _ = json.NewDecoder(w.Body).Decode(&errResp) - if errResp["code"] != "CLUSTERS-MGMT-CREATE-007" { - t.Errorf("expected code CLUSTERS-MGMT-CREATE-007, got %v", errResp["code"]) + if errResp["code"] != ErrClusterCreateIDExhausted.Code { + t.Errorf("expected code %s, got %v", ErrClusterCreateIDExhausted.Code, errResp["code"]) } } diff --git a/platform-api/pkg/handlers/errorcodes.go b/platform-api/pkg/handlers/errorcodes.go index f9e639e8..b7b98df6 100644 --- a/platform-api/pkg/handlers/errorcodes.go +++ b/platform-api/pkg/handlers/errorcodes.go @@ -1,36 +1,21 @@ package handlers import ( - "encoding/json" "fmt" "net/http" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" ) -// APIError defines a typed error response. HTTPStatus drives the response code; -// Code, Message, and optional Errors are serialized to JSON under "kind":"Error". -type APIError struct { - Code string `json:"code"` - HTTPStatus int `json:"-"` - Message string `json:"reason"` - Errors any `json:"errors,omitempty"` -} +// APIError is an alias for apierror.APIError so handler code uses the short form. +type APIError = apierror.APIError -// writeAPIError writes a typed JSON error response. -// reason overrides the default Message when provided. -func writeAPIError(w http.ResponseWriter, def APIError, reason ...string) { - if len(reason) > 0 { - def.Message = reason[0] - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(def.HTTPStatus) - _ = json.NewEncoder(w).Encode(struct { - Kind string `json:"kind"` - APIError - }{Kind: "Error", APIError: def}) +func writeAPIError(w http.ResponseWriter, def APIError) { + apierror.Write(w, def) } + // Cluster error codes var ( ErrClusterList APIError @@ -92,6 +77,135 @@ var ( ErrNodePoolValidation APIError ) +// Accounts error codes +var ( + ErrAccountCreateInvalidBody APIError + ErrAccountCreateMissingID APIError + ErrAccountCreateCheckFailed APIError + ErrAccountCreateExists APIError + ErrAccountCreateFailed APIError + + ErrAccountListFailed APIError + + ErrAccountGetFailed APIError + ErrAccountGetNotFound APIError + + ErrAccountDeleteFailed APIError +) + +// Management cluster error codes +var ( + ErrMCCreateInvalidBody APIError + ErrMCCreateMissingID APIError + ErrMCCreateMissingReg APIError + ErrMCCreateMissingAcct APIError + ErrMCCreateExists APIError + ErrMCCreateFailed APIError + + ErrMCListFailed APIError + + ErrMCGetNotFound APIError + ErrMCGetFailed APIError +) + +// Authz policy error codes +var ( + ErrAuthzPolicyCreateInvalidBody APIError + ErrAuthzPolicyCreateMissingName APIError + ErrAuthzPolicyCreateMissingText APIError + ErrAuthzPolicyCreateInvalid APIError + + ErrAuthzPolicyListFailed APIError + + ErrAuthzPolicyGetFailed APIError + ErrAuthzPolicyGetNotFound APIError + + ErrAuthzPolicyUpdateInvalidBody APIError + ErrAuthzPolicyUpdateInvalid APIError + + ErrAuthzPolicyDeleteFailed APIError + ErrAuthzPolicyDeleteInUse APIError +) + +// Authz group error codes +var ( + ErrAuthzGroupCreateInvalidBody APIError + ErrAuthzGroupCreateMissingName APIError + ErrAuthzGroupCreateFailed APIError + + ErrAuthzGroupListFailed APIError + + ErrAuthzGroupGetFailed APIError + ErrAuthzGroupGetNotFound APIError + + ErrAuthzGroupDeleteFailed APIError + + ErrAuthzGroupMembersUpdateInvalidBody APIError + ErrAuthzGroupMembersUpdateAddFailed APIError + ErrAuthzGroupMembersUpdateRemFailed APIError + ErrAuthzGroupMembersUpdateListFailed APIError + + ErrAuthzGroupMembersListFailed APIError +) + +// Authz attachment error codes +var ( + ErrAuthzAttachCreateInvalidBody APIError + ErrAuthzAttachCreateMissingFields APIError + ErrAuthzAttachCreateInvalidTarget APIError + ErrAuthzAttachCreateFailed APIError + + ErrAuthzAttachListFailed APIError + ErrAuthzAttachDeleteFailed APIError +) + +// Authz admin error codes +var ( + ErrAuthzAdminAddInvalidBody APIError + ErrAuthzAdminAddMissingPrinc APIError + ErrAuthzAdminAddFailed APIError + + ErrAuthzAdminListFailed APIError + ErrAuthzAdminDeleteFailed APIError +) + +// Authz check error codes +var ( + ErrAuthzCheckInvalidBody APIError + ErrAuthzCheckMissingPrinc APIError + ErrAuthzCheckMissingAction APIError + ErrAuthzCheckMissingRes APIError + ErrAuthzCheckFailed APIError +) + +// ZOA error codes +var ( + ErrZoaCreateUnknownAction APIError + ErrZoaCreateInvalidBody APIError + ErrZoaCreateMissingCluster APIError + ErrZoaCreateMissingJira APIError + ErrZoaCreateInvalidJira APIError + ErrZoaCreateInvalidParams APIError + ErrZoaCreateCooldown APIError + ErrZoaCreateMaxConcurrent APIError + ErrZoaCreateDryRunError APIError + ErrZoaCreateStoreFailed APIError + ErrZoaCreateRenderFailed APIError + ErrZoaCreateDispatchFailed APIError + ErrZoaCreateStoreSaveFailed APIError + + ErrZoaGetStoreFailed APIError + ErrZoaGetNotFound APIError + + ErrZoaListStoreFailed APIError + + ErrZoaAuditDisabled APIError + ErrZoaAuditListFailed APIError +) + +// Info error codes +var ErrInfoRegionalAccountUnavailable APIError + func init() { // Cluster — List ErrClusterList = APIError{Code: "CLUSTERS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list clusters"} @@ -101,7 +215,7 @@ func init() { ErrClusterCreateMissingFields = APIError{Code: "CLUSTERS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required fields: name and spec"} ErrClusterCreateFailed = APIError{Code: "CLUSTERS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create cluster"} ErrClusterCreateNameCheck = APIError{Code: "CLUSTERS-MGMT-CREATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster name"} - ErrClusterCreateNameConflict = APIError{Code: "CLUSTERS-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Cluster name already exists in this account"} + ErrClusterCreateNameConflict = APIError{Code: "CLUSTERS-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Cluster name already exists in this account", Reason: "a cluster named %q already exists in this account"} ErrClusterCreateNameTooLong = APIError{Code: "CLUSTERS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: fmt.Sprintf("Cluster name must be no more than %d characters", hyperfleetdb.MaxClusterNameLen)} ErrClusterCreateIDExhausted = APIError{Code: "CLUSTERS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Unable to generate unique DNS identifier"} ErrClusterCreateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-CREATE-008", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} @@ -126,7 +240,7 @@ func init() { ErrClusterStatusFailed = APIError{Code: "CLUSTERS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster status"} // Cluster — Validation - ErrClusterValidation = APIError{Code: "CLUSTERS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "Request validation failed"} + ErrClusterValidation = APIError{Code: "CLUSTERS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "A validation error has occurred, check the errors field for more information"} // NodePool — List ErrNodePoolList = APIError{Code: "NODEPOOLS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list nodepools"} @@ -160,5 +274,135 @@ func init() { ErrNodePoolStatusFailed = APIError{Code: "NODEPOOLS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool status"} // NodePool — Validation - ErrNodePoolValidation = APIError{Code: "NODEPOOLS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "Request validation failed"} + ErrNodePoolValidation = APIError{Code: "NODEPOOLS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "A validation error has occurred, check the errors field for more information"} + + // Accounts — Create + ErrAccountCreateInvalidBody = APIError{Code: "ACCOUNTS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAccountCreateMissingID = APIError{Code: "ACCOUNTS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "accountId is required"} + ErrAccountCreateCheckFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check account status"} + ErrAccountCreateExists = APIError{Code: "ACCOUNTS-MGMT-CREATE-004", HTTPStatus: http.StatusConflict, Message: "Account is already enabled"} + ErrAccountCreateFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to enable account"} + + // Accounts — List + ErrAccountListFailed = APIError{Code: "ACCOUNTS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list accounts"} + + // Accounts — Get + ErrAccountGetFailed = APIError{Code: "ACCOUNTS-MGMT-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get account"} + ErrAccountGetNotFound = APIError{Code: "ACCOUNTS-MGMT-GET-002", HTTPStatus: http.StatusNotFound, Message: "Account not found"} + + // Accounts — Delete + ErrAccountDeleteFailed = APIError{Code: "ACCOUNTS-MGMT-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to disable account"} + + // Management clusters — Create + ErrMCCreateInvalidBody = APIError{Code: "MC-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrMCCreateMissingID = APIError{Code: "MC-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "id is required"} + ErrMCCreateMissingReg = APIError{Code: "MC-MGMT-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "region is required"} + ErrMCCreateMissingAcct = APIError{Code: "MC-MGMT-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "accountId is required"} + ErrMCCreateExists = APIError{Code: "MC-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Management cluster already registered", Reason: "management cluster already registered: %s"} + ErrMCCreateFailed = APIError{Code: "MC-MGMT-CREATE-006", HTTPStatus: http.StatusInternalServerError, Message: "Failed to save management cluster config"} + + // Management clusters — List + ErrMCListFailed = APIError{Code: "MC-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to load management cluster config"} + + // Management clusters — Get + ErrMCGetNotFound = APIError{Code: "MC-MGMT-GET-001", HTTPStatus: http.StatusNotFound, Message: "Management cluster not found"} + ErrMCGetFailed = APIError{Code: "MC-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to load management cluster config"} + + // Authz — Policy — Create + ErrAuthzPolicyCreateInvalidBody = APIError{Code: "AUTHZ-POLICY-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzPolicyCreateMissingName = APIError{Code: "AUTHZ-POLICY-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "name is required"} + ErrAuthzPolicyCreateMissingText = APIError{Code: "AUTHZ-POLICY-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "policy (Cedar text) is required"} + ErrAuthzPolicyCreateInvalid = APIError{Code: "AUTHZ-POLICY-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "Invalid policy", Reason: "%w"} + + // Authz — Policy — List + ErrAuthzPolicyListFailed = APIError{Code: "AUTHZ-POLICY-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list policies"} + + // Authz — Policy — Get + ErrAuthzPolicyGetFailed = APIError{Code: "AUTHZ-POLICY-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get policy"} + ErrAuthzPolicyGetNotFound = APIError{Code: "AUTHZ-POLICY-GET-002", HTTPStatus: http.StatusNotFound, Message: "Policy not found"} + + // Authz — Policy — Update + ErrAuthzPolicyUpdateInvalidBody = APIError{Code: "AUTHZ-POLICY-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzPolicyUpdateInvalid = APIError{Code: "AUTHZ-POLICY-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Invalid policy", Reason: "%w"} + + // Authz — Policy — Delete + ErrAuthzPolicyDeleteFailed = APIError{Code: "AUTHZ-POLICY-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete policy"} + ErrAuthzPolicyDeleteInUse = APIError{Code: "AUTHZ-POLICY-DELETE-002", HTTPStatus: http.StatusConflict, Message: "Cannot delete policy with existing attachments", Reason: "%w"} + + // Authz — Group — Create + ErrAuthzGroupCreateInvalidBody = APIError{Code: "AUTHZ-GROUP-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzGroupCreateMissingName = APIError{Code: "AUTHZ-GROUP-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "name is required"} + ErrAuthzGroupCreateFailed = APIError{Code: "AUTHZ-GROUP-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create group"} + + // Authz — Group — List + ErrAuthzGroupListFailed = APIError{Code: "AUTHZ-GROUP-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list groups"} + + // Authz — Group — Get + ErrAuthzGroupGetFailed = APIError{Code: "AUTHZ-GROUP-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get group"} + ErrAuthzGroupGetNotFound = APIError{Code: "AUTHZ-GROUP-GET-002", HTTPStatus: http.StatusNotFound, Message: "Group not found"} + + // Authz — Group — Delete + ErrAuthzGroupDeleteFailed = APIError{Code: "AUTHZ-GROUP-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete group"} + + // Authz — Group — Members + ErrAuthzGroupMembersUpdateInvalidBody = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzGroupMembersUpdateAddFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to add group member"} + ErrAuthzGroupMembersUpdateRemFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to remove group member"} + ErrAuthzGroupMembersUpdateListFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list group members"} + ErrAuthzGroupMembersListFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list group members"} + + // Authz — Attachment — Create + ErrAuthzAttachCreateInvalidBody = APIError{Code: "AUTHZ-ATTACH-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzAttachCreateMissingFields = APIError{Code: "AUTHZ-ATTACH-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "policyId, targetType, and targetId are required"} + ErrAuthzAttachCreateInvalidTarget = APIError{Code: "AUTHZ-ATTACH-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "targetType must be 'user' or 'group'"} + ErrAuthzAttachCreateFailed = APIError{Code: "AUTHZ-ATTACH-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "Failed to attach policy", Reason: "%w"} + + // Authz — Attachment — List / Delete + ErrAuthzAttachListFailed = APIError{Code: "AUTHZ-ATTACH-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list attachments"} + ErrAuthzAttachDeleteFailed = APIError{Code: "AUTHZ-ATTACH-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to detach policy"} + + // Authz — Admin — Add + ErrAuthzAdminAddInvalidBody = APIError{Code: "AUTHZ-ADMIN-ADD-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzAdminAddMissingPrinc = APIError{Code: "AUTHZ-ADMIN-ADD-002", HTTPStatus: http.StatusBadRequest, Message: "principalArn is required"} + ErrAuthzAdminAddFailed = APIError{Code: "AUTHZ-ADMIN-ADD-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to add admin"} + + // Authz — Admin — List / Delete + ErrAuthzAdminListFailed = APIError{Code: "AUTHZ-ADMIN-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list admins"} + ErrAuthzAdminDeleteFailed = APIError{Code: "AUTHZ-ADMIN-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to remove admin"} + + // Authz — Check + ErrAuthzCheckInvalidBody = APIError{Code: "AUTHZ-CHECK-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzCheckMissingPrinc = APIError{Code: "AUTHZ-CHECK-002", HTTPStatus: http.StatusBadRequest, Message: "principal is required"} + ErrAuthzCheckMissingAction = APIError{Code: "AUTHZ-CHECK-003", HTTPStatus: http.StatusBadRequest, Message: "action is required"} + ErrAuthzCheckMissingRes = APIError{Code: "AUTHZ-CHECK-004", HTTPStatus: http.StatusBadRequest, Message: "resource is required"} + ErrAuthzCheckFailed = APIError{Code: "AUTHZ-CHECK-005", HTTPStatus: http.StatusInternalServerError, Message: "Authorization check failed", Reason: "%w"} + + // ZOA — Create + ErrZoaCreateUnknownAction = APIError{Code: "ZOA-CREATE-001", HTTPStatus: http.StatusNotFound, Message: "Trusted action not found", Reason: "trusted action not found: %s"} + ErrZoaCreateInvalidBody = APIError{Code: "ZOA-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrZoaCreateMissingCluster = APIError{Code: "ZOA-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "target_cluster is required"} + ErrZoaCreateMissingJira = APIError{Code: "ZOA-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "jira is required for all trusted actions (e.g. ROSAENG-1234)"} + ErrZoaCreateInvalidJira = APIError{Code: "ZOA-CREATE-005", HTTPStatus: http.StatusBadRequest, Message: "jira does not have correct format; expected PROJECT-NUMBER (e.g. ROSAENG-1234)"} + ErrZoaCreateInvalidParams = APIError{Code: "ZOA-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: "Invalid parameters", Reason: "%w"} + ErrZoaCreateCooldown = APIError{Code: "ZOA-CREATE-007", HTTPStatus: http.StatusTooManyRequests, Message: "Write cooldown in effect", Reason: "%w"} + ErrZoaCreateMaxConcurrent = APIError{Code: "ZOA-CREATE-008", HTTPStatus: http.StatusTooManyRequests, Message: "Too many concurrent executions on target", Reason: "%w"} + ErrZoaCreateDryRunError = APIError{Code: "ZOA-CREATE-009", HTTPStatus: http.StatusInternalServerError, Message: "Dry run action not found", Reason: "dry_run_action '%s' not found in registry"} + ErrZoaCreateStoreFailed = APIError{Code: "ZOA-CREATE-010", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create execution"} + ErrZoaCreateRenderFailed = APIError{Code: "ZOA-CREATE-011", HTTPStatus: http.StatusInternalServerError, Message: "Failed to build trusted action manifest"} + ErrZoaCreateDispatchFailed = APIError{Code: "ZOA-CREATE-012", HTTPStatus: http.StatusBadGateway, Message: "Failed to dispatch trusted action"} + ErrZoaCreateStoreSaveFailed = APIError{Code: "ZOA-CREATE-013", HTTPStatus: http.StatusInternalServerError, Message: "Failed to persist execution state"} + + // ZOA — Get + ErrZoaGetStoreFailed = APIError{Code: "ZOA-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to retrieve execution"} + ErrZoaGetNotFound = APIError{Code: "ZOA-GET-002", HTTPStatus: http.StatusNotFound, Message: "Execution not found"} + + // ZOA — List + ErrZoaListStoreFailed = APIError{Code: "ZOA-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list executions"} + + // ZOA — Audit + ErrZoaAuditDisabled = APIError{Code: "ZOA-AUDIT-001", HTTPStatus: http.StatusNotFound, Message: "Audit logging is not enabled"} + ErrZoaAuditListFailed = APIError{Code: "ZOA-AUDIT-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list audit log"} + + // Info + ErrInfoRegionalAccountUnavailable = APIError{Code: "INFO-001", HTTPStatus: http.StatusServiceUnavailable, Message: "regional account ID is not configured"} } diff --git a/platform-api/pkg/handlers/info.go b/platform-api/pkg/handlers/info.go index 013fb963..5b2e55a5 100644 --- a/platform-api/pkg/handlers/info.go +++ b/platform-api/pkg/handlers/info.go @@ -26,12 +26,7 @@ func (h *InfoHandler) Info(w http.ResponseWriter, r *http.Request) { // Target Group ARN format: arn:aws:elasticloadbalancing:{region}:{account_id}:targetgroup/{name}/{id} parts := strings.SplitN(tgARN, ":", 6) if len(parts) < 6 || parts[4] == "" { - w.WriteHeader(http.StatusServiceUnavailable) - _ = json.NewEncoder(w).Encode(map[string]string{ - "kind": "Error", - "code": "regional-account-unavailable", - "reason": "regional account ID is not configured", - }) + writeAPIError(w, ErrInfoRegionalAccountUnavailable) return } diff --git a/platform-api/pkg/handlers/info_test.go b/platform-api/pkg/handlers/info_test.go index d0a2606e..3cd558a3 100644 --- a/platform-api/pkg/handlers/info_test.go +++ b/platform-api/pkg/handlers/info_test.go @@ -51,7 +51,7 @@ func TestInfoHandler_MissingEnvVar(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if result["code"] != "regional-account-unavailable" { + if result["code"] != ErrInfoRegionalAccountUnavailable.Code { t.Errorf("expected code=regional-account-unavailable, got %s", result["code"]) } } @@ -73,7 +73,7 @@ func TestInfoHandler_MalformedARN(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if result["code"] != "regional-account-unavailable" { + if result["code"] != ErrInfoRegionalAccountUnavailable.Code { t.Errorf("expected code=regional-account-unavailable, got %s", result["code"]) } } diff --git a/platform-api/pkg/handlers/management_cluster.go b/platform-api/pkg/handlers/management_cluster.go index 5edc4551..19b8a927 100644 --- a/platform-api/pkg/handlers/management_cluster.go +++ b/platform-api/pkg/handlers/management_cluster.go @@ -52,21 +52,21 @@ func (h *ManagementClusterHandler) Create(w http.ResponseWriter, r *http.Request var req ManagementClusterCreateRequest if r.Body != nil && r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrMCCreateInvalidBody) return } } if req.ID == "" { - h.writeError(w, http.StatusBadRequest, "missing-id", "id is required") + writeAPIError(w, ErrMCCreateMissingID) return } if req.Region == "" { - h.writeError(w, http.StatusBadRequest, "missing-region", "region is required") + writeAPIError(w, ErrMCCreateMissingReg) return } if req.AccountID == "" { - h.writeError(w, http.StatusBadRequest, "missing-account-id", "accountId is required") + writeAPIError(w, ErrMCCreateMissingAcct) return } @@ -82,11 +82,11 @@ func (h *ManagementClusterHandler) Create(w http.ResponseWriter, r *http.Request if err := h.db.CreateManagementCluster(ctx, mc); err != nil { if hyperfleetdb.IsAlreadyExists(err) { - h.writeError(w, http.StatusConflict, "already-exists", "Management cluster already registered: "+req.ID) + writeAPIError(w, ErrMCCreateExists.WithReason(req.ID)) return } h.logger.Error("failed to create management cluster", "error", err) - h.writeError(w, http.StatusInternalServerError, "config-error", "Failed to save management cluster config") + writeAPIError(w, ErrMCCreateFailed) return } @@ -107,7 +107,7 @@ func (h *ManagementClusterHandler) List(w http.ResponseWriter, r *http.Request) list, err := h.db.ListManagementClusters(ctx) if err != nil { h.logger.Error("failed to list management clusters", "error", err) - h.writeError(w, http.StatusInternalServerError, "config-error", "Failed to load management cluster config") + writeAPIError(w, ErrMCListFailed) return } @@ -138,11 +138,11 @@ func (h *ManagementClusterHandler) Get(w http.ResponseWriter, r *http.Request) { mc, err := h.db.GetManagementCluster(ctx, id) if err != nil { if hyperfleetdb.IsNotFound(err) { - h.writeError(w, http.StatusNotFound, "not-found", "Management cluster not found") + writeAPIError(w, ErrMCGetNotFound) return } h.logger.Error("failed to get management cluster", "error", err, "id", id) - h.writeError(w, http.StatusInternalServerError, "config-error", "Failed to load management cluster config") + writeAPIError(w, ErrMCGetFailed) return } @@ -159,16 +159,3 @@ func mcToResponse(mc *hyperfleetv1alpha1.ManagementCluster) ManagementClusterRes AccountID: mc.Spec.AccountID, } } - -func (h *ManagementClusterHandler) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/handlers/nodepool.go b/platform-api/pkg/handlers/nodepool.go index 5496ad37..83b6b1b0 100644 --- a/platform-api/pkg/handlers/nodepool.go +++ b/platform-api/pkg/handlers/nodepool.go @@ -103,9 +103,7 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { } if errs := h.validator.ValidateCreate(req.Spec, featuregate.Default); errs != nil { - def := ErrNodePoolValidation - def.Errors = errs - writeAPIError(w, def) + writeAPIError(w, ErrNodePoolValidation.WithErrors(errs)) return } @@ -201,9 +199,7 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { } if errs := h.validator.ValidateUpdate(req.Spec, &cr.Spec, featuregate.Default); errs != nil { - def := ErrNodePoolValidation - def.Errors = errs - writeAPIError(w, def) + writeAPIError(w, ErrNodePoolValidation.WithErrors(errs)) return } diff --git a/platform-api/pkg/handlers/zoa.go b/platform-api/pkg/handlers/zoa.go index 9624c6d5..0b9feb18 100644 --- a/platform-api/pkg/handlers/zoa.go +++ b/platform-api/pkg/handlers/zoa.go @@ -77,30 +77,30 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { tmpl, ok := h.registry.Get(action) if !ok { - h.writeError(w, http.StatusNotFound, "unknown-action", "Trusted action not found: "+action) + writeAPIError(w, ErrZoaCreateUnknownAction.WithReason(action)) return } var req zoa.CreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - h.writeError(w, http.StatusBadRequest, "invalid-request", "Invalid request body") + writeAPIError(w, ErrZoaCreateInvalidBody) return } if req.TargetCluster == "" { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, "", "", "", "") - h.writeError(w, http.StatusBadRequest, "missing-target-cluster", "target_cluster is required") + writeAPIError(w, ErrZoaCreateMissingCluster) return } if req.Jira == "" { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, req.TargetCluster, "", "", "") - h.writeError(w, http.StatusBadRequest, "missing-jira", "jira is required for all trusted actions (e.g. ROSAENG-1234)") + writeAPIError(w, ErrZoaCreateMissingJira) return } if !isValidJiraFormat(req.Jira) { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, req.TargetCluster, "", req.Jira, "") - h.writeError(w, http.StatusBadRequest, "invalid-jira", "jira does not have correct format; expected PROJECT-NUMBER (e.g. ROSAENG-1234)") + writeAPIError(w, ErrZoaCreateInvalidJira) return } @@ -113,7 +113,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if err := validateParams(tmpl, cleanParams); err != nil { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, req.TargetCluster, "", req.Jira, "") - h.writeError(w, http.StatusBadRequest, "invalid-params", err.Error()) + writeAPIError(w, ErrZoaCreateInvalidParams.WithReason(err)) return } @@ -125,7 +125,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if cooldown > 0 { if err := h.checkWriteCooldown(ctx, accountID, action, req.TargetCluster, cooldown); err != nil { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusTooManyRequests, action, req.TargetCluster, "", req.Jira, "") - h.writeError(w, http.StatusTooManyRequests, "write-cooldown", err.Error()) + writeAPIError(w, ErrZoaCreateCooldown.WithReason(err)) return } } @@ -138,7 +138,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { } if err := h.checkMaxConcurrent(ctx, accountID, req.TargetCluster, maxConcurrent); err != nil { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusTooManyRequests, action, req.TargetCluster, "", req.Jira, "") - h.writeError(w, http.StatusTooManyRequests, "max-concurrent", err.Error()) + writeAPIError(w, ErrZoaCreateMaxConcurrent.WithReason(err)) return } } @@ -151,7 +151,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { executedAction = tmpl.DryRunAction dryTmpl, ok := h.registry.Get(executedAction) if !ok { - h.writeError(w, http.StatusInternalServerError, "dry-run-error", "dry_run_action '"+tmpl.DryRunAction+"' not found in registry") + writeAPIError(w, ErrZoaCreateDryRunError.WithReason(tmpl.DryRunAction)) return } tmpl = dryTmpl @@ -184,7 +184,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if err := h.store.Create(ctx, exec); err != nil { h.logger.Error("failed to create execution record", "error", err, "execution_id", execID) - h.writeError(w, http.StatusInternalServerError, "store-error", "Failed to create execution") + writeAPIError(w, ErrZoaCreateStoreFailed) return } @@ -206,14 +206,14 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if err != nil { h.logger.Error("failed to build manifest", "error", err, "execution_id", execID) _ = h.store.UpdateStatus(ctx, execID, zoa.StatusFailed, time.Now().UTC().Format(time.RFC3339), 0) - h.writeError(w, http.StatusInternalServerError, "render-error", "Failed to build trusted action manifest") + writeAPIError(w, ErrZoaCreateRenderFailed) return } if err := h.db.CreateManifest(ctx, zoa.JobNamespace, hfm); err != nil { h.logger.Error("failed to create manifest on hyperfleet-db", "error", err, "execution_id", execID) _ = h.store.UpdateStatus(ctx, execID, zoa.StatusFailed, time.Now().UTC().Format(time.RFC3339), 0) - h.writeError(w, http.StatusBadGateway, "dispatch-error", "Failed to dispatch trusted action") + writeAPIError(w, ErrZoaCreateDispatchFailed) return } @@ -222,7 +222,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { h.logger.Error("failed to update manifest name, cleaning up", "error", err, "execution_id", execID) _ = h.db.DeleteManifest(ctx, zoa.JobNamespace, hfm.Name) _ = h.store.UpdateStatus(ctx, execID, zoa.StatusFailed, time.Now().UTC().Format(time.RFC3339), 0) - h.writeError(w, http.StatusInternalServerError, "store-error", "Failed to persist execution state") + writeAPIError(w, ErrZoaCreateStoreSaveFailed) return } @@ -254,12 +254,12 @@ func (h *ZoaHandler) Get(w http.ResponseWriter, r *http.Request) { exec, err := h.store.Get(ctx, execID) if err != nil { h.logger.Error("failed to get execution", "error", err, "execution_id", execID) - h.writeError(w, http.StatusInternalServerError, "store-error", "Failed to retrieve execution") + writeAPIError(w, ErrZoaGetStoreFailed) return } if exec == nil { - h.writeError(w, http.StatusNotFound, "not-found", "Execution not found") + writeAPIError(w, ErrZoaGetNotFound) return } @@ -357,7 +357,7 @@ func (h *ZoaHandler) List(w http.ResponseWriter, r *http.Request) { executions, err := h.store.List(ctx, accountID, limit, filter) if err != nil { h.logger.Error("failed to list executions", "error", err, "account_id", accountID) - h.writeError(w, http.StatusInternalServerError, "store-error", "Failed to list executions") + writeAPIError(w, ErrZoaListStoreFailed) return } @@ -445,7 +445,7 @@ func (h *ZoaHandler) Describe(w http.ResponseWriter, r *http.Request) { tmpl, ok := h.registry.Get(action) if !ok { - h.writeError(w, http.StatusNotFound, "unknown-action", "Trusted action not found: "+action) + writeAPIError(w, ErrZoaCreateUnknownAction.WithReason(action)) return } @@ -582,16 +582,6 @@ func extractOperator(callerARN string) string { return callerARN } -func (h *ZoaHandler) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - }) -} - func (h *ZoaHandler) checkWriteCooldown(ctx context.Context, accountID, action, targetCluster string, cooldownSeconds int) error { since := time.Now().UTC().Add(-time.Duration(cooldownSeconds) * time.Second).Format(time.RFC3339) notDryRun := false @@ -663,7 +653,7 @@ func (h *ZoaHandler) recordAudit(ctx context.Context, r *http.Request, accountID // AuditList handles GET /api/v0/trusted-actions/audit func (h *ZoaHandler) AuditList(w http.ResponseWriter, r *http.Request) { if h.auditStore == nil { - h.writeError(w, http.StatusNotFound, "audit-disabled", "Audit logging is not enabled") + writeAPIError(w, ErrZoaAuditDisabled) return } @@ -697,7 +687,7 @@ func (h *ZoaHandler) AuditList(w http.ResponseWriter, r *http.Request) { entries, err := h.auditStore.List(ctx, accountID, limit, filter) if err != nil { h.logger.Error("failed to list audit entries", "error", err) - h.writeError(w, http.StatusInternalServerError, "store-error", "Failed to list audit log") + writeAPIError(w, ErrZoaAuditListFailed) return } diff --git a/platform-api/pkg/handlers/zoa_test.go b/platform-api/pkg/handlers/zoa_test.go index ab57b532..10c3a739 100644 --- a/platform-api/pkg/handlers/zoa_test.go +++ b/platform-api/pkg/handlers/zoa_test.go @@ -360,7 +360,7 @@ func TestZoaHandler_Create_UnknownParams(t *testing.T) { var errResp map[string]any err := json.NewDecoder(rr.Body).Decode(&errResp) require.NoError(t, err) - assert.Equal(t, "invalid-params", errResp["code"]) + assert.Equal(t, ErrZoaCreateInvalidParams.Code, errResp["code"]) assert.Contains(t, errResp["reason"], "unknown parameter 'namespace'") assert.Contains(t, errResp["reason"], "node_selector") } @@ -412,7 +412,7 @@ script: | assert.Equal(t, http.StatusTooManyRequests, rr.Code) var errResp map[string]any require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) - assert.Equal(t, "write-cooldown", errResp["code"]) + assert.Equal(t, ErrZoaCreateCooldown.Code, errResp["code"]) } func TestZoaHandler_Create_WriteCooldown_ForceBypass(t *testing.T) { @@ -499,7 +499,7 @@ func TestZoaHandler_Create_MaxConcurrent(t *testing.T) { assert.Equal(t, http.StatusTooManyRequests, rr.Code) var errResp map[string]any require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) - assert.Equal(t, "max-concurrent", errResp["code"]) + assert.Equal(t, ErrZoaCreateMaxConcurrent.Code, errResp["code"]) assert.Contains(t, errResp["reason"].(string), "10 active executions") } diff --git a/platform-api/pkg/middleware/account_check.go b/platform-api/pkg/middleware/account_check.go index b784a149..fb1ea19d 100644 --- a/platform-api/pkg/middleware/account_check.go +++ b/platform-api/pkg/middleware/account_check.go @@ -1,7 +1,6 @@ package middleware import ( - "encoding/json" "log/slog" "net/http" @@ -30,7 +29,7 @@ func (a *AccountCheck) RequireProvisioned(next http.Handler) http.Handler { accountID := GetAccountID(ctx) if accountID == "" { - a.writeError(w, http.StatusForbidden, "missing-account-id", "Account ID header is required") + writeError(w, ErrMissingAccountID) return } @@ -44,30 +43,16 @@ func (a *AccountCheck) RequireProvisioned(next http.Handler) http.Handler { provisioned, err := a.authorizer.IsAccountProvisioned(ctx, accountID) if err != nil { a.logger.Error("failed to check account provisioning status", "error", err, "account_id", accountID) - a.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to check account status") + writeError(w, ErrProvisionedCheckFailed) return } if !provisioned { a.logger.Warn("account not provisioned", "account_id", accountID) - a.writeError(w, http.StatusForbidden, "account-not-provisioned", - "Account is not provisioned for ROSA authorization. Contact your administrator.") + writeError(w, ErrAccountNotProvisioned) return } next.ServeHTTP(w, r) }) } - -func (a *AccountCheck) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/middleware/admin_check.go b/platform-api/pkg/middleware/admin_check.go index 4e284ea5..a818b84d 100644 --- a/platform-api/pkg/middleware/admin_check.go +++ b/platform-api/pkg/middleware/admin_check.go @@ -1,7 +1,6 @@ package middleware import ( - "encoding/json" "log/slog" "net/http" @@ -30,7 +29,7 @@ func (a *AdminCheck) RequireAdmin(next http.Handler) http.Handler { accountID := GetAccountID(ctx) if accountID == "" { - a.writeError(w, http.StatusForbidden, "missing-account-id", "Account ID header is required") + writeError(w, ErrMissingAccountID) return } @@ -42,36 +41,23 @@ func (a *AdminCheck) RequireAdmin(next http.Handler) http.Handler { callerARN := GetCallerARN(ctx) if callerARN == "" { - a.writeError(w, http.StatusForbidden, "missing-caller-arn", "Caller ARN header is required") + writeError(w, ErrMissingCallerARN) return } isAdmin, err := a.authorizer.IsAdmin(ctx, accountID, callerARN) if err != nil { a.logger.Error("failed to check admin status", "error", err, "account_id", accountID, "caller_arn", callerARN) - a.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to check admin status") + writeError(w, ErrAdminCheckFailed) return } if !isAdmin { a.logger.Warn("admin access denied", "account_id", accountID, "caller_arn", callerARN) - a.writeError(w, http.StatusForbidden, "not-admin", "This operation requires admin privileges") + writeError(w, ErrNotAdmin) return } next.ServeHTTP(w, r) }) } - -func (a *AdminCheck) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/middleware/admin_check_test.go b/platform-api/pkg/middleware/admin_check_test.go index ace7ccc8..96c7e6d9 100644 --- a/platform-api/pkg/middleware/admin_check_test.go +++ b/platform-api/pkg/middleware/admin_check_test.go @@ -117,8 +117,8 @@ func TestAdminCheck_RequireAdmin_NonAdminCaller(t *testing.T) { if errorResp["kind"] != "Error" { t.Errorf("expected kind=Error, got %v", errorResp["kind"]) } - if errorResp["code"] != "not-admin" { - t.Errorf("expected code=not-admin, got %v", errorResp["code"]) + if errorResp["code"] != ErrNotAdmin.Code { + t.Errorf("expected code=%s, got %v", ErrNotAdmin.Code, errorResp["code"]) } if errorResp["reason"] != "This operation requires admin privileges" { t.Errorf("expected reason='This operation requires admin privileges', got %v", errorResp["reason"]) @@ -189,8 +189,8 @@ func TestAdminCheck_RequireAdmin_MissingCallerARN(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { t.Fatalf("failed to decode error response: %v", err) } - if errorResp["code"] != "missing-caller-arn" { - t.Errorf("expected code=missing-caller-arn, got %v", errorResp["code"]) + if errorResp["code"] != ErrMissingCallerARN.Code { + t.Errorf("expected code=%s, got %v", ErrMissingCallerARN.Code, errorResp["code"]) } } @@ -228,8 +228,8 @@ func TestAdminCheck_RequireAdmin_IsAdminError(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { t.Fatalf("failed to decode error response: %v", err) } - if errorResp["code"] != "internal-error" { - t.Errorf("expected code=internal-error, got %v", errorResp["code"]) + if errorResp["code"] != ErrAdminCheckFailed.Code { + t.Errorf("expected code=%s, got %v", ErrAdminCheckFailed.Code, errorResp["code"]) } } @@ -259,7 +259,7 @@ func TestAdminCheck_RequireAdmin_MissingAccountID(t *testing.T) { if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { t.Fatalf("failed to decode error response: %v", err) } - if errorResp["code"] != "missing-account-id" { - t.Errorf("expected code=missing-account-id, got %v", errorResp["code"]) + if errorResp["code"] != ErrMissingAccountID.Code { + t.Errorf("expected code=%s, got %v", ErrMissingAccountID.Code, errorResp["code"]) } } diff --git a/platform-api/pkg/middleware/authorization.go b/platform-api/pkg/middleware/authorization.go index 2a303764..ae2efc27 100644 --- a/platform-api/pkg/middleware/authorization.go +++ b/platform-api/pkg/middleware/authorization.go @@ -1,7 +1,6 @@ package middleware import ( - "encoding/json" "log/slog" "net/http" ) @@ -32,29 +31,16 @@ func (a *Authorization) RequireAllowedAccount(next http.Handler) http.Handler { if accountID == "" { a.logger.Warn("missing account ID in request") - a.writeError(w, http.StatusForbidden, "missing-account-id", "Account ID header is required") + writeError(w, ErrMissingAccountID) return } if _, allowed := a.allowedAccounts[accountID]; !allowed { a.logger.Warn("account not allowed", "account_id", accountID) - a.writeError(w, http.StatusForbidden, "account-not-allowed", "account not allowed") + writeError(w, ErrAccountNotAllowed) return } next.ServeHTTP(w, r) }) } - -func (a *Authorization) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/middleware/authorization_test.go b/platform-api/pkg/middleware/authorization_test.go index d6a88fc3..c25553bc 100644 --- a/platform-api/pkg/middleware/authorization_test.go +++ b/platform-api/pkg/middleware/authorization_test.go @@ -70,8 +70,8 @@ func TestAuthorization_RequireAllowedAccount_NotAllowed(t *testing.T) { t.Errorf("expected kind=Error, got %v", errorResp["kind"]) } - if errorResp["code"] != "account-not-allowed" { - t.Errorf("expected code=account-not-allowed, got %v", errorResp["code"]) + if errorResp["code"] != ErrAccountNotAllowed.Code { + t.Errorf("expected code=%s, got %v", ErrAccountNotAllowed.Code, errorResp["code"]) } if errorResp["reason"] != "account not allowed" { @@ -114,8 +114,8 @@ func TestAuthorization_RequireAllowedAccount_MissingAccountID(t *testing.T) { t.Errorf("expected kind=Error, got %v", errorResp["kind"]) } - if errorResp["code"] != "missing-account-id" { - t.Errorf("expected code=missing-account-id, got %v", errorResp["code"]) + if errorResp["code"] != ErrMissingAccountID.Code { + t.Errorf("expected code=%s, got %v", ErrMissingAccountID.Code, errorResp["code"]) } if errorResp["reason"] != "Account ID header is required" { @@ -153,8 +153,8 @@ func TestAuthorization_RequireAllowedAccount_EmptyAccountID(t *testing.T) { t.Fatalf("failed to decode error response: %v", err) } - if errorResp["code"] != "missing-account-id" { - t.Errorf("expected code=missing-account-id, got %v", errorResp["code"]) + if errorResp["code"] != ErrMissingAccountID.Code { + t.Errorf("expected code=%s, got %v", ErrMissingAccountID.Code, errorResp["code"]) } } @@ -387,49 +387,40 @@ func TestAuthorization_RequireAllowedAccount_TwentyAccounts(t *testing.T) { t.Fatalf("failed to decode error response: %v", err) } - if errorResp["code"] != "account-not-allowed" { - t.Errorf("expected code=account-not-allowed, got %v", errorResp["code"]) + if errorResp["code"] != ErrAccountNotAllowed.Code { + t.Errorf("expected code=%s, got %v", ErrAccountNotAllowed.Code, errorResp["code"]) } }) } func TestAuthorization_WriteError(t *testing.T) { - logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - auth := NewAuthorization([]string{}, logger) - tests := []struct { name string - status int - code string - reason string + def APIError expectedStatus int expectedCode string expectedReason string }{ { - name: "forbidden error", - status: http.StatusForbidden, - code: "account-not-allowed", - reason: "account not allowed", + name: "account not allowed", + def: ErrAccountNotAllowed, expectedStatus: http.StatusForbidden, - expectedCode: "account-not-allowed", - expectedReason: "account not allowed", + expectedCode: ErrAccountNotAllowed.Code, + expectedReason: ErrAccountNotAllowed.Message, }, { - name: "missing account ID error", - status: http.StatusForbidden, - code: "missing-account-id", - reason: "Account ID header is required", + name: "missing account ID", + def: ErrMissingAccountID, expectedStatus: http.StatusForbidden, - expectedCode: "missing-account-id", - expectedReason: "Account ID header is required", + expectedCode: ErrMissingAccountID.Code, + expectedReason: ErrMissingAccountID.Message, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() - auth.writeError(w, tt.status, tt.code, tt.reason) + writeError(w, tt.def) if w.Code != tt.expectedStatus { t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) diff --git a/platform-api/pkg/middleware/authz.go b/platform-api/pkg/middleware/authz.go index a2a5586a..20063663 100644 --- a/platform-api/pkg/middleware/authz.go +++ b/platform-api/pkg/middleware/authz.go @@ -2,7 +2,6 @@ package middleware import ( "context" - "encoding/json" "log/slog" "net/http" "strings" @@ -47,12 +46,12 @@ func (a *Authz) Authorize(next http.Handler) http.Handler { callerARN := GetCallerARN(ctx) if accountID == "" { - a.writeError(w, http.StatusForbidden, "missing-account-id", "Account ID header is required") + writeError(w, ErrMissingAccountID) return } if callerARN == "" { - a.writeError(w, http.StatusForbidden, "missing-caller-arn", "Caller ARN header is required") + writeError(w, ErrMissingCallerARN) return } @@ -71,11 +70,10 @@ func (a *Authz) Authorize(next http.Handler) http.Handler { a.logger.Error("authorization check failed", "error", err, "account_id", accountID, "action", req.Action) // Check if it's a "not provisioned" error if strings.Contains(err.Error(), "not provisioned") { - a.writeError(w, http.StatusForbidden, "account-not-provisioned", - "Account is not provisioned for ROSA authorization") + writeError(w, ErrAccountNotProvisioned) return } - a.writeError(w, http.StatusInternalServerError, "authorization-error", "Authorization check failed") + writeError(w, ErrAuthorizationFailed) return } @@ -86,8 +84,7 @@ func (a *Authz) Authorize(next http.Handler) http.Handler { "action", req.Action, "resource", req.Resource, ) - a.writeError(w, http.StatusForbidden, "access-denied", - "You do not have permission to perform this action") + writeError(w, ErrAccessDenied) return } @@ -217,16 +214,3 @@ const ( contextKeyResourceTags contextKey = "resource_tags" contextKeyRequestTags contextKey = "request_tags" ) - -func (a *Authz) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} diff --git a/platform-api/pkg/middleware/errorcodes.go b/platform-api/pkg/middleware/errorcodes.go new file mode 100644 index 00000000..49fb525d --- /dev/null +++ b/platform-api/pkg/middleware/errorcodes.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "net/http" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" +) + +// APIError is an alias for apierror.APIError so middleware code uses the short form. +type APIError = apierror.APIError + +func writeError(w http.ResponseWriter, def APIError) { + apierror.Write(w, def) +} + +// Auth middleware error codes +var ( + ErrMissingAccountID APIError + ErrMissingCallerARN APIError + ErrInternalError APIError + ErrAccountNotProvisioned APIError + ErrNotAdmin APIError + ErrNotPrivileged APIError + ErrAccountNotAllowed APIError + ErrAuthorizationFailed APIError + ErrAccessDenied APIError + + ErrAdminCheckFailed APIError + ErrPrivilegedCheckFailed APIError + ErrProvisionedCheckFailed APIError +) + +func init() { + ErrMissingAccountID = APIError{Code: "AUTH-001", HTTPStatus: http.StatusForbidden, Message: "Account ID header is required"} + ErrMissingCallerARN = APIError{Code: "AUTH-002", HTTPStatus: http.StatusForbidden, Message: "Caller ARN header is required"} + ErrInternalError = APIError{Code: "AUTH-003", HTTPStatus: http.StatusInternalServerError, Message: "Internal server error"} + ErrAccountNotProvisioned = APIError{Code: "AUTH-004", HTTPStatus: http.StatusForbidden, Message: "Account is not provisioned for ROSA authorization. Contact your administrator."} + ErrNotAdmin = APIError{Code: "AUTH-005", HTTPStatus: http.StatusForbidden, Message: "This operation requires admin privileges"} + ErrNotPrivileged = APIError{Code: "AUTH-006", HTTPStatus: http.StatusForbidden, Message: "This operation requires a privileged account"} + ErrAccountNotAllowed = APIError{Code: "AUTH-007", HTTPStatus: http.StatusForbidden, Message: "account not allowed"} + ErrAuthorizationFailed = APIError{Code: "AUTH-008", HTTPStatus: http.StatusInternalServerError, Message: "Authorization check failed"} + ErrAccessDenied = APIError{Code: "AUTH-009", HTTPStatus: http.StatusForbidden, Message: "You do not have permission to perform this action"} + + ErrAdminCheckFailed = APIError{Code: "AUTH-010", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check admin status"} + ErrPrivilegedCheckFailed = APIError{Code: "AUTH-011", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check privileged status"} + ErrProvisionedCheckFailed = APIError{Code: "AUTH-012", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check account provisioning status"} +} diff --git a/platform-api/pkg/middleware/privileged.go b/platform-api/pkg/middleware/privileged.go index a1b4710c..73147a44 100644 --- a/platform-api/pkg/middleware/privileged.go +++ b/platform-api/pkg/middleware/privileged.go @@ -2,7 +2,6 @@ package middleware import ( "context" - "encoding/json" "log/slog" "net/http" @@ -58,7 +57,7 @@ func (p *Privileged) RequirePrivileged(next http.Handler) http.Handler { accountID := GetAccountID(ctx) if accountID == "" { - p.writeError(w, http.StatusForbidden, "missing-account-id", "Account ID header is required") + writeError(w, ErrMissingAccountID) return } @@ -69,14 +68,14 @@ func (p *Privileged) RequirePrivileged(next http.Handler) http.Handler { isPrivileged, err = p.authorizer.IsPrivileged(ctx, accountID) if err != nil { p.logger.Error("failed to check privileged status", "error", err, "account_id", accountID) - p.writeError(w, http.StatusInternalServerError, "internal-error", "Failed to check account status") + writeError(w, ErrPrivilegedCheckFailed) return } } if !isPrivileged { p.logger.Warn("privileged access denied", "account_id", accountID) - p.writeError(w, http.StatusForbidden, "not-privileged", "This operation requires a privileged account") + writeError(w, ErrNotPrivileged) return } @@ -84,19 +83,6 @@ func (p *Privileged) RequirePrivileged(next http.Handler) http.Handler { }) } -func (p *Privileged) writeError(w http.ResponseWriter, status int, code, reason string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - resp := map[string]any{ - "kind": "Error", - "code": code, - "reason": reason, - } - - _ = json.NewEncoder(w).Encode(resp) -} - // GetPrivileged retrieves the privileged status from context func GetPrivileged(ctx context.Context) bool { if v := ctx.Value(ContextKeyPrivileged); v != nil { diff --git a/platform-api/pkg/ratelimit/middleware.go b/platform-api/pkg/ratelimit/middleware.go index 8e253899..cb5044d0 100644 --- a/platform-api/pkg/ratelimit/middleware.go +++ b/platform-api/pkg/ratelimit/middleware.go @@ -2,7 +2,6 @@ package ratelimit import ( "context" - "encoding/json" "fmt" "log/slog" "math" @@ -14,6 +13,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" ) @@ -120,14 +120,13 @@ func (l *Limiter) findLimit(method, path string) RouteLimit { } } +var errRateLimit = apierror.APIError{ + Code: "RATE-LIMIT-001", + HTTPStatus: http.StatusTooManyRequests, + Message: "Too Many Requests", + Reason: "429 Too Many Requests — %s %s, %d req/%ds, retry after %ds", +} + func (l *Limiter) writeRateLimitError(w http.ResponseWriter, method, path string, limit RouteLimit, retryAfter int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusTooManyRequests) - if err := json.NewEncoder(w).Encode(map[string]any{ - "kind": "Error", - "code": "429", - "reason": fmt.Sprintf("429 Too Many Requests — %s %s, %d req/%ds, retry after %ds", method, path, limit.Rate, limit.Window, retryAfter), - }); err != nil { - l.logger.Warn("failed to write rate limit error response", "error_type", fmt.Sprintf("%T", err)) - } + apierror.Write(w, errRateLimit.WithReason(method, path, limit.Rate, limit.Window, retryAfter)) } diff --git a/platform-api/pkg/ratelimit/middleware_test.go b/platform-api/pkg/ratelimit/middleware_test.go index 5cb035b2..f33158dd 100644 --- a/platform-api/pkg/ratelimit/middleware_test.go +++ b/platform-api/pkg/ratelimit/middleware_test.go @@ -321,8 +321,8 @@ func TestMiddleware_429ResponseFormat(t *testing.T) { if body["kind"] != "Error" { t.Errorf("expected kind=Error, got %v", body["kind"]) } - if body["code"] != "429" { - t.Errorf("expected code=429, got %v", body["code"]) + if body["code"] != errRateLimit.Code { + t.Errorf("expected code=%s, got %v", errRateLimit.Code, body["code"]) } reason, _ := body["reason"].(string) if reason == "" { diff --git a/platform-api/pkg/validation/field_validator.go b/platform-api/pkg/validation/field_validator.go index eae425c4..ff5025fb 100644 --- a/platform-api/pkg/validation/field_validator.go +++ b/platform-api/pkg/validation/field_validator.go @@ -32,14 +32,11 @@ func (e ValidationErrors) Error() string { if len(e) == 0 { return "no validation errors" } - var sb strings.Builder - sb.WriteString("validation failed:\n") - for _, err := range e { - sb.WriteString(" ") - sb.WriteString(err.Error()) - sb.WriteString("\n") + msgs := make([]string, len(e)) + for i, err := range e { + msgs[i] = err.Error() } - return sb.String() + return strings.Join(msgs, "; ") } type FieldValidator struct { From 0da7ade911f375c2e83e86d245dc1880b299f4fe Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 17:46:54 -0300 Subject: [PATCH 3/7] Rename pkg/apierror to pkg/api and unify response writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves APIError, WriteError, and builders (WithReason, WithErrors) from pkg/apierror into pkg/api alongside the new api.Write for success responses. All handlers now route every response — success, no-content, and error — through these two functions instead of open-coded w.Header/w.WriteHeader/json.Encode triples. api.Write marshals to a buffer before committing headers so a marshal failure can still return a 500; write errors after headers are committed are returned to the caller for logging rather than silently discarded. --- .../internal/render/nodepool_test.go | 4 +- .../{apierror/apierror.go => api/error.go} | 36 ++-- .../apierror_test.go => api/error_test.go} | 46 ++-- platform-api/pkg/api/response.go | 26 +++ platform-api/pkg/handlers/accounts.go | 27 ++- platform-api/pkg/handlers/authz.go | 119 +++++----- platform-api/pkg/handlers/cluster.go | 31 +-- platform-api/pkg/handlers/errorcodes.go | 204 +++++++++--------- platform-api/pkg/handlers/health.go | 14 +- platform-api/pkg/handlers/info.go | 8 +- .../pkg/handlers/management_cluster.go | 19 +- platform-api/pkg/handlers/nodepool.go | 35 +-- platform-api/pkg/handlers/zoa.go | 41 ++-- platform-api/pkg/middleware/errorcodes.go | 31 +-- platform-api/pkg/ratelimit/middleware.go | 8 +- 15 files changed, 365 insertions(+), 284 deletions(-) rename platform-api/pkg/{apierror/apierror.go => api/error.go} (62%) rename platform-api/pkg/{apierror/apierror_test.go => api/error_test.go} (81%) create mode 100644 platform-api/pkg/api/response.go diff --git a/hyperfleet-operator/internal/render/nodepool_test.go b/hyperfleet-operator/internal/render/nodepool_test.go index e615bbc8..af53cc56 100644 --- a/hyperfleet-operator/internal/render/nodepool_test.go +++ b/hyperfleet-operator/internal/render/nodepool_test.go @@ -187,9 +187,9 @@ func TestNodePoolResourceLabels(t *testing.T) { func TestNodePoolResourceAutoRepair(t *testing.T) { tests := []struct { - name string + name string autoRepair *bool - want bool + want bool }{ {"nil defaults to true", nil, true}, {"explicit true", ptr.To(true), true}, diff --git a/platform-api/pkg/apierror/apierror.go b/platform-api/pkg/api/error.go similarity index 62% rename from platform-api/pkg/apierror/apierror.go rename to platform-api/pkg/api/error.go index fd434b96..c82acee4 100644 --- a/platform-api/pkg/apierror/apierror.go +++ b/platform-api/pkg/api/error.go @@ -1,4 +1,4 @@ -package apierror +package api import ( "encoding/json" @@ -29,22 +29,27 @@ func (e APIError) WithErrors(v any) APIError { // test time. func (e APIError) WithReason(args ...any) APIError { if e.Reason == "" { - panic(fmt.Sprintf("apierror: WithReason() called on %q which has no Reason template", e.Code)) + panic(fmt.Sprintf("api: WithReason() called on %q which has no Reason template", e.Code)) } e.Errors = fmt.Errorf(e.Reason, args...) return e } -// Write serializes def as a JSON error response. +// WriteError serializes def as a JSON error response. The return value follows +// the same contract as Write: a marshal failure is returned before headers are +// committed; a write failure after WriteHeader is unrecoverable but still +// returned so the caller can log it. // -// When Errors implements error, reason is derived from Errors.Error() so the -// top-level field always carries full detail. If the concrete Errors value has -// no exported fields (e.g. errors.New, fmt.Errorf) its JSON representation -// would be "{}", which adds no value; Write suppresses it from the output so -// that clients only see the populated reason and not an empty errors object. -func Write(w http.ResponseWriter, def APIError) { +// When Errors implements error and marshals to "{}" or "null" (plain errors), +// reason is derived from Errors.Error() and the errors field is suppressed. +// For structured Errors (exported fields), the static Message is kept and +// Errors serializes as-is. +func WriteError(w http.ResponseWriter, def APIError) error { if err, ok := def.Errors.(error); ok { - b, _ := json.Marshal(def.Errors) + b, merr := json.Marshal(def.Errors) + if merr != nil { + return merr + } if len(b) == 0 || string(b) == "{}" || string(b) == "null" { // Plain error: derive reason from message, suppress empty errors field. def.Message = err.Error() @@ -52,10 +57,15 @@ func Write(w http.ResponseWriter, def APIError) { } // Structured error: keep the static Message and let Errors serialize as-is. } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(def.HTTPStatus) - _ = json.NewEncoder(w).Encode(struct { + b, err := json.Marshal(struct { Kind string `json:"kind"` APIError }{Kind: "Error", APIError: def}) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(def.HTTPStatus) + _, err = w.Write(b) + return err } diff --git a/platform-api/pkg/apierror/apierror_test.go b/platform-api/pkg/api/error_test.go similarity index 81% rename from platform-api/pkg/apierror/apierror_test.go rename to platform-api/pkg/api/error_test.go index ae4a6eb3..a0deb26e 100644 --- a/platform-api/pkg/apierror/apierror_test.go +++ b/platform-api/pkg/api/error_test.go @@ -1,4 +1,4 @@ -package apierror_test +package api_test import ( "encoding/json" @@ -7,10 +7,10 @@ import ( "net/http/httptest" "testing" - "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" ) -var base = apierror.APIError{ +var base = api.APIError{ Code: "TEST-001", HTTPStatus: http.StatusBadRequest, Message: "something went wrong", @@ -33,9 +33,11 @@ func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any { return out } -func write(def apierror.APIError) *httptest.ResponseRecorder { +func write(def api.APIError) *httptest.ResponseRecorder { w := httptest.NewRecorder() - apierror.Write(w, def) + if err := api.WriteError(w, def); err != nil { + panic("WriteError: " + err.Error()) + } return w } @@ -59,7 +61,7 @@ func TestWithErrors_DoesNotMutateBase(t *testing.T) { // --- WithReason --- func TestWithReason_AppliesTemplate(t *testing.T) { - e := apierror.APIError{Code: "X", HTTPStatus: 400, Message: "m", Reason: "hello %s"} + e := api.APIError{Code: "X", HTTPStatus: 400, Message: "m", Reason: "hello %s"} got := e.WithReason("world") if got.Errors == nil { t.Fatal("expected Errors to be set") @@ -71,7 +73,7 @@ func TestWithReason_AppliesTemplate(t *testing.T) { func TestWithReason_WrapsErrorWithW(t *testing.T) { sentinel := errors.New("sentinel") - e := apierror.APIError{Code: "X", HTTPStatus: 500, Message: "m", Reason: "%w"} + e := api.APIError{Code: "X", HTTPStatus: 500, Message: "m", Reason: "%w"} got := e.WithReason(sentinel) if !errors.Is(got.Errors.(error), sentinel) { t.Fatal("expected error chain to be preserved via %w") @@ -88,7 +90,7 @@ func TestWithReason_PanicsWithoutTemplate(t *testing.T) { } func TestWithReason_DoesNotMutateBase(t *testing.T) { - e := apierror.APIError{Code: "X", HTTPStatus: 400, Message: "m", Reason: "%s"} + e := api.APIError{Code: "X", HTTPStatus: 400, Message: "m", Reason: "%s"} _ = e.WithReason("x") if e.Errors != nil { t.Fatal("WithReason must not mutate the receiver") @@ -98,7 +100,7 @@ func TestWithReason_DoesNotMutateBase(t *testing.T) { // --- Write: HTTP envelope --- func TestWrite_StatusCode(t *testing.T) { - w := write(apierror.APIError{Code: "X", HTTPStatus: http.StatusNotFound, Message: "m"}) + w := write(api.APIError{Code: "X", HTTPStatus: http.StatusNotFound, Message: "m"}) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } @@ -133,7 +135,7 @@ func TestWrite_CodeAndReason(t *testing.T) { // --- Write: plain error (no exported fields) --- func TestWrite_PlainError_ReasonFromError(t *testing.T) { - e := apierror.APIError{Code: "TEST-001", HTTPStatus: http.StatusNotFound, Message: "not found", Reason: "cluster %q not found"} + e := api.APIError{Code: "TEST-001", HTTPStatus: http.StatusNotFound, Message: "not found", Reason: "cluster %q not found"} w := write(e.WithReason("abc")) resp := decode(t, w) if resp["reason"] != `cluster "abc" not found` { @@ -142,7 +144,7 @@ func TestWrite_PlainError_ReasonFromError(t *testing.T) { } func TestWrite_PlainError_ErrorsFieldSuppressed(t *testing.T) { - e := apierror.APIError{Code: "TEST-001", HTTPStatus: http.StatusBadRequest, Message: "bad", Reason: "%w"} + e := api.APIError{Code: "TEST-001", HTTPStatus: http.StatusBadRequest, Message: "bad", Reason: "%w"} w := write(e.WithReason(errors.New("oops"))) resp := decode(t, w) if _, ok := resp["errors"]; ok { @@ -188,18 +190,18 @@ func TestWrite_NoErrors_NoErrorsField(t *testing.T) { func TestWrite_ResponseFormat(t *testing.T) { cases := []struct { - name string - def apierror.APIError - wantStatus int - wantKind string - wantCode string - wantReason string - wantErrors any // nil means field must be absent - forbidden []string // keys that must not appear in the response + name string + def api.APIError + wantStatus int + wantKind string + wantCode string + wantReason string + wantErrors any // nil means field must be absent + forbidden []string // keys that must not appear in the response }{ { name: "static message no errors", - def: apierror.APIError{Code: "A-001", HTTPStatus: http.StatusBadRequest, Message: "bad request"}, + def: api.APIError{Code: "A-001", HTTPStatus: http.StatusBadRequest, Message: "bad request"}, wantStatus: http.StatusBadRequest, wantKind: "Error", wantCode: "A-001", @@ -208,7 +210,7 @@ func TestWrite_ResponseFormat(t *testing.T) { }, { name: "plain error derives reason and suppresses errors field", - def: apierror.APIError{Code: "A-002", HTTPStatus: http.StatusNotFound, Message: "default", Reason: "item %q not found"}.WithReason("xyz"), + def: api.APIError{Code: "A-002", HTTPStatus: http.StatusNotFound, Message: "default", Reason: "item %q not found"}.WithReason("xyz"), wantStatus: http.StatusNotFound, wantKind: "Error", wantCode: "A-002", @@ -217,7 +219,7 @@ func TestWrite_ResponseFormat(t *testing.T) { }, { name: "structured error keeps static reason and exposes errors", - def: apierror.APIError{Code: "A-003", HTTPStatus: http.StatusUnprocessableEntity, Message: "validation failed"}.WithErrors(&structuredError{Field: "name", Detail: "required"}), + def: api.APIError{Code: "A-003", HTTPStatus: http.StatusUnprocessableEntity, Message: "validation failed"}.WithErrors(&structuredError{Field: "name", Detail: "required"}), wantStatus: http.StatusUnprocessableEntity, wantKind: "Error", wantCode: "A-003", diff --git a/platform-api/pkg/api/response.go b/platform-api/pkg/api/response.go new file mode 100644 index 00000000..a1f53b4e --- /dev/null +++ b/platform-api/pkg/api/response.go @@ -0,0 +1,26 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// Write serializes data as a JSON response with the given HTTP status code. +// If data is nil, only the status code is written (suitable for 204 No Content). +// Encoding is done before committing headers; if marshal fails the caller can +// still write an error response. If the write itself fails (headers already +// committed), the caller should log and move on — the connection is broken. +func Write(w http.ResponseWriter, status int, data any) error { + if data == nil { + w.WriteHeader(status) + return nil + } + b, err := json.Marshal(data) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, err = w.Write(b) + return err +} diff --git a/platform-api/pkg/handlers/accounts.go b/platform-api/pkg/handlers/accounts.go index f1fcb180..d1baa8fe 100644 --- a/platform-api/pkg/handlers/accounts.go +++ b/platform-api/pkg/handlers/accounts.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/mux" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/authz" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" ) @@ -87,16 +88,16 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { h.logger.Info("account enabled", "account_id", req.AccountID, "privileged", req.Privileged) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(AccountResponse{ + if err := api.Write(w, http.StatusCreated, AccountResponse{ Kind: "Account", AccountID: account.AccountID, PolicyStoreID: account.PolicyStoreID, Privileged: account.Privileged, CreatedAt: account.CreatedAt, CreatedBy: account.CreatedBy, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // List handles GET /api/v0/accounts @@ -122,12 +123,13 @@ func (h *AccountsHandler) List(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(AccountListResponse{ + if err := api.Write(w, http.StatusOK, AccountListResponse{ Kind: "AccountList", Items: items, Total: len(items), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Get handles GET /api/v0/accounts/{id} @@ -148,15 +150,16 @@ func (h *AccountsHandler) Get(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(AccountResponse{ + if err := api.Write(w, http.StatusOK, AccountResponse{ Kind: "Account", AccountID: account.AccountID, PolicyStoreID: account.PolicyStoreID, Privileged: account.Privileged, CreatedAt: account.CreatedAt, CreatedBy: account.CreatedBy, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Delete handles DELETE /api/v0/accounts/{id} @@ -177,5 +180,7 @@ func (h *AccountsHandler) Delete(w http.ResponseWriter, r *http.Request) { h.logger.Info("account disabled", "account_id", accountID) - w.WriteHeader(http.StatusNoContent) + if err := api.Write(w, http.StatusNoContent, nil); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/handlers/authz.go b/platform-api/pkg/handlers/authz.go index 137ff866..e4bba7da 100644 --- a/platform-api/pkg/handlers/authz.go +++ b/platform-api/pkg/handlers/authz.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/mux" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/authz" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" ) @@ -161,15 +162,15 @@ func (h *AuthzHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(PolicyResponse{ + if err := api.Write(w, http.StatusCreated, PolicyResponse{ Kind: "Policy", PolicyID: p.PolicyID, Name: p.Name, Description: p.Description, CreatedAt: p.CreatedAt, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } //nolint:dupl // ListPolicies and ListGroups are structurally similar but operate on different types @@ -195,12 +196,13 @@ func (h *AuthzHandler) ListPolicies(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(PolicyListResponse{ + if err := api.Write(w, http.StatusOK, PolicyListResponse{ Kind: "PolicyList", Items: items, Total: len(items), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { @@ -221,14 +223,15 @@ func (h *AuthzHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(PolicyResponse{ + if err := api.Write(w, http.StatusOK, PolicyResponse{ Kind: "Policy", PolicyID: p.PolicyID, Name: p.Name, Description: p.Description, CreatedAt: p.CreatedAt, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) { @@ -250,14 +253,15 @@ func (h *AuthzHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(PolicyResponse{ + if err := api.Write(w, http.StatusOK, PolicyResponse{ Kind: "Policy", PolicyID: p.PolicyID, Name: p.Name, Description: p.Description, CreatedAt: p.CreatedAt, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) { @@ -277,7 +281,9 @@ func (h *AuthzHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusNoContent) + if err := api.Write(w, http.StatusNoContent, nil); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Group Handlers @@ -304,15 +310,15 @@ func (h *AuthzHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(GroupResponse{ + if err := api.Write(w, http.StatusCreated, GroupResponse{ Kind: "Group", GroupID: g.GroupID, Name: g.Name, Description: g.Description, CreatedAt: g.CreatedAt, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } //nolint:dupl // ListGroups and ListPolicies are structurally similar but operate on different types @@ -338,12 +344,13 @@ func (h *AuthzHandler) ListGroups(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(GroupListResponse{ + if err := api.Write(w, http.StatusOK, GroupListResponse{ Kind: "GroupList", Items: items, Total: len(items), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) GetGroup(w http.ResponseWriter, r *http.Request) { @@ -364,14 +371,15 @@ func (h *AuthzHandler) GetGroup(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(GroupResponse{ + if err := api.Write(w, http.StatusOK, GroupResponse{ Kind: "Group", GroupID: g.GroupID, Name: g.Name, Description: g.Description, CreatedAt: g.CreatedAt, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { @@ -387,7 +395,9 @@ func (h *AuthzHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusNoContent) + if err := api.Write(w, http.StatusNoContent, nil); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request) { @@ -428,12 +438,13 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(MemberListResponse{ + if err := api.Write(w, http.StatusOK, MemberListResponse{ Kind: "MemberList", Items: members, Total: len(members), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) ListGroupMembers(w http.ResponseWriter, r *http.Request) { @@ -449,12 +460,13 @@ func (h *AuthzHandler) ListGroupMembers(w http.ResponseWriter, r *http.Request) return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(MemberListResponse{ + if err := api.Write(w, http.StatusOK, MemberListResponse{ Kind: "MemberList", Items: members, Total: len(members), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Attachment Handlers @@ -486,16 +498,16 @@ func (h *AuthzHandler) CreateAttachment(w http.ResponseWriter, r *http.Request) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(AttachmentResponse{ + if err := api.Write(w, http.StatusCreated, AttachmentResponse{ Kind: "Attachment", AttachmentID: a.AttachmentID, PolicyID: a.PolicyID, TargetType: string(a.TargetType), TargetID: a.TargetID, CreatedAt: a.CreatedAt, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) ListAttachments(w http.ResponseWriter, r *http.Request) { @@ -528,12 +540,13 @@ func (h *AuthzHandler) ListAttachments(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(AttachmentListResponse{ + if err := api.Write(w, http.StatusOK, AttachmentListResponse{ Kind: "AttachmentList", Items: items, Total: len(items), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { @@ -549,7 +562,9 @@ func (h *AuthzHandler) DeleteAttachment(w http.ResponseWriter, r *http.Request) return } - w.WriteHeader(http.StatusNoContent) + if err := api.Write(w, http.StatusNoContent, nil); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Admin Handlers @@ -577,12 +592,12 @@ func (h *AuthzHandler) AddAdmin(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{ + if err := api.Write(w, http.StatusCreated, map[string]any{ "kind": "Admin", "principalArn": req.PrincipalARN, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) ListAdmins(w http.ResponseWriter, r *http.Request) { @@ -596,12 +611,13 @@ func (h *AuthzHandler) ListAdmins(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(AdminListResponse{ + if err := api.Write(w, http.StatusOK, AdminListResponse{ Kind: "AdminList", Items: admins, Total: len(admins), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *AuthzHandler) RemoveAdmin(w http.ResponseWriter, r *http.Request) { @@ -618,7 +634,9 @@ func (h *AuthzHandler) RemoveAdmin(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusNoContent) + if err := api.Write(w, http.StatusNoContent, nil); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // CheckAuthorization evaluates an authorization request and returns the decision. @@ -670,9 +688,10 @@ func (h *AuthzHandler) CheckAuthorization(w http.ResponseWriter, r *http.Request decision = "ALLOW" } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(CheckAuthorizationResponse{ + if err := api.Write(w, http.StatusOK, CheckAuthorizationResponse{ Kind: "AuthorizationDecision", Decision: decision, - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/handlers/cluster.go b/platform-api/pkg/handlers/cluster.go index f42ac8e5..1db3e565 100644 --- a/platform-api/pkg/handlers/cluster.go +++ b/platform-api/pkg/handlers/cluster.go @@ -13,6 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/types" @@ -95,7 +96,9 @@ func (h *ClusterHandler) List(w http.ResponseWriter, r *http.Request) { "offset": offset, } - h.writeJSON(w, http.StatusOK, response) + if err := api.Write(w, http.StatusOK, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Create handles POST /api/v0/clusters @@ -178,7 +181,9 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { } cluster := hyperfleetdb.ClusterCRToPlatform(cr) - h.writeJSON(w, http.StatusCreated, cluster) + if err := api.Write(w, http.StatusCreated, cluster); err != nil { + h.logger.Error("failed to write response", "error", err) + } return } } @@ -203,7 +208,9 @@ func (h *ClusterHandler) Get(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusOK, hyperfleetdb.ClusterCRToPlatform(cr)) + if err := api.Write(w, http.StatusOK, hyperfleetdb.ClusterCRToPlatform(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Update handles PUT /api/v0/clusters/{id} @@ -271,7 +278,9 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusOK, hyperfleetdb.ClusterCRToPlatform(cr)) + if err := api.Write(w, http.StatusOK, hyperfleetdb.ClusterCRToPlatform(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Delete handles DELETE /api/v0/clusters/{id} @@ -299,7 +308,9 @@ func (h *ClusterHandler) Delete(w http.ResponseWriter, r *http.Request) { "cluster_id": clusterID, } - h.writeJSON(w, http.StatusAccepted, response) + if err := api.Write(w, http.StatusAccepted, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // GetStatus handles GET /api/v0/clusters/{id}/statuses @@ -322,11 +333,7 @@ func (h *ClusterHandler) GetStatus(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusOK, hyperfleetdb.ClusterStatusFromCR(cr)) -} - -func (h *ClusterHandler) writeJSON(w http.ResponseWriter, status int, data any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(data) + if err := api.Write(w, http.StatusOK, hyperfleetdb.ClusterStatusFromCR(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/handlers/errorcodes.go b/platform-api/pkg/handlers/errorcodes.go index b7b98df6..8a96b029 100644 --- a/platform-api/pkg/handlers/errorcodes.go +++ b/platform-api/pkg/handlers/errorcodes.go @@ -4,18 +4,18 @@ import ( "fmt" "net/http" - "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" ) -// APIError is an alias for apierror.APIError so handler code uses the short form. -type APIError = apierror.APIError +// APIError is an alias for api.APIError so handler code uses the short form. +type APIError = api.APIError +// TODO: add a logger parameter so write errors can be logged. func writeAPIError(w http.ResponseWriter, def APIError) { - apierror.Write(w, def) + _ = api.WriteError(w, def) } - // Cluster error codes var ( ErrClusterList APIError @@ -150,10 +150,10 @@ var ( // Authz attachment error codes var ( - ErrAuthzAttachCreateInvalidBody APIError - ErrAuthzAttachCreateMissingFields APIError - ErrAuthzAttachCreateInvalidTarget APIError - ErrAuthzAttachCreateFailed APIError + ErrAuthzAttachCreateInvalidBody APIError + ErrAuthzAttachCreateMissingFields APIError + ErrAuthzAttachCreateInvalidTarget APIError + ErrAuthzAttachCreateFailed APIError ErrAuthzAttachListFailed APIError ErrAuthzAttachDeleteFailed APIError @@ -161,9 +161,9 @@ var ( // Authz admin error codes var ( - ErrAuthzAdminAddInvalidBody APIError - ErrAuthzAdminAddMissingPrinc APIError - ErrAuthzAdminAddFailed APIError + ErrAuthzAdminAddInvalidBody APIError + ErrAuthzAdminAddMissingPrinc APIError + ErrAuthzAdminAddFailed APIError ErrAuthzAdminListFailed APIError ErrAuthzAdminDeleteFailed APIError @@ -171,27 +171,27 @@ var ( // Authz check error codes var ( - ErrAuthzCheckInvalidBody APIError - ErrAuthzCheckMissingPrinc APIError - ErrAuthzCheckMissingAction APIError - ErrAuthzCheckMissingRes APIError - ErrAuthzCheckFailed APIError + ErrAuthzCheckInvalidBody APIError + ErrAuthzCheckMissingPrinc APIError + ErrAuthzCheckMissingAction APIError + ErrAuthzCheckMissingRes APIError + ErrAuthzCheckFailed APIError ) // ZOA error codes var ( - ErrZoaCreateUnknownAction APIError - ErrZoaCreateInvalidBody APIError - ErrZoaCreateMissingCluster APIError - ErrZoaCreateMissingJira APIError - ErrZoaCreateInvalidJira APIError - ErrZoaCreateInvalidParams APIError - ErrZoaCreateCooldown APIError - ErrZoaCreateMaxConcurrent APIError - ErrZoaCreateDryRunError APIError - ErrZoaCreateStoreFailed APIError - ErrZoaCreateRenderFailed APIError - ErrZoaCreateDispatchFailed APIError + ErrZoaCreateUnknownAction APIError + ErrZoaCreateInvalidBody APIError + ErrZoaCreateMissingCluster APIError + ErrZoaCreateMissingJira APIError + ErrZoaCreateInvalidJira APIError + ErrZoaCreateInvalidParams APIError + ErrZoaCreateCooldown APIError + ErrZoaCreateMaxConcurrent APIError + ErrZoaCreateDryRunError APIError + ErrZoaCreateStoreFailed APIError + ErrZoaCreateRenderFailed APIError + ErrZoaCreateDispatchFailed APIError ErrZoaCreateStoreSaveFailed APIError ErrZoaGetStoreFailed APIError @@ -199,8 +199,8 @@ var ( ErrZoaListStoreFailed APIError - ErrZoaAuditDisabled APIError - ErrZoaAuditListFailed APIError + ErrZoaAuditDisabled APIError + ErrZoaAuditListFailed APIError ) // Info error codes @@ -211,33 +211,33 @@ func init() { ErrClusterList = APIError{Code: "CLUSTERS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list clusters"} // Cluster — Create - ErrClusterCreateInvalidBody = APIError{Code: "CLUSTERS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrClusterCreateInvalidBody = APIError{Code: "CLUSTERS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrClusterCreateMissingFields = APIError{Code: "CLUSTERS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required fields: name and spec"} - ErrClusterCreateFailed = APIError{Code: "CLUSTERS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create cluster"} - ErrClusterCreateNameCheck = APIError{Code: "CLUSTERS-MGMT-CREATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster name"} - ErrClusterCreateNameConflict = APIError{Code: "CLUSTERS-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Cluster name already exists in this account", Reason: "a cluster named %q already exists in this account"} - ErrClusterCreateNameTooLong = APIError{Code: "CLUSTERS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: fmt.Sprintf("Cluster name must be no more than %d characters", hyperfleetdb.MaxClusterNameLen)} - ErrClusterCreateIDExhausted = APIError{Code: "CLUSTERS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Unable to generate unique DNS identifier"} - ErrClusterCreateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-CREATE-008", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} + ErrClusterCreateFailed = APIError{Code: "CLUSTERS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create cluster"} + ErrClusterCreateNameCheck = APIError{Code: "CLUSTERS-MGMT-CREATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster name"} + ErrClusterCreateNameConflict = APIError{Code: "CLUSTERS-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Cluster name already exists in this account", Reason: "a cluster named %q already exists in this account"} + ErrClusterCreateNameTooLong = APIError{Code: "CLUSTERS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: fmt.Sprintf("Cluster name must be no more than %d characters", hyperfleetdb.MaxClusterNameLen)} + ErrClusterCreateIDExhausted = APIError{Code: "CLUSTERS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Unable to generate unique DNS identifier"} + ErrClusterCreateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-CREATE-008", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} // Cluster — Get ErrClusterGetNotFound = APIError{Code: "CLUSTERS-MGMT-GET-001", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} - ErrClusterGetFailed = APIError{Code: "CLUSTERS-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster"} + ErrClusterGetFailed = APIError{Code: "CLUSTERS-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster"} // Cluster — Update - ErrClusterUpdateInvalidBody = APIError{Code: "CLUSTERS-MGMT-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrClusterUpdateInvalidBody = APIError{Code: "CLUSTERS-MGMT-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrClusterUpdateMissingFields = APIError{Code: "CLUSTERS-MGMT-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required field: spec"} - ErrClusterUpdateNotFound = APIError{Code: "CLUSTERS-MGMT-UPDATE-003", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} - ErrClusterUpdateFailed = APIError{Code: "CLUSTERS-MGMT-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to update cluster"} - ErrClusterUpdateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-UPDATE-005", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} + ErrClusterUpdateNotFound = APIError{Code: "CLUSTERS-MGMT-UPDATE-003", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} + ErrClusterUpdateFailed = APIError{Code: "CLUSTERS-MGMT-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to update cluster"} + ErrClusterUpdateInvalidSpec = APIError{Code: "CLUSTERS-MGMT-UPDATE-005", HTTPStatus: http.StatusBadRequest, Message: "Invalid cluster spec"} // Cluster — Delete ErrClusterDeleteNotFound = APIError{Code: "CLUSTERS-MGMT-DELETE-001", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} - ErrClusterDeleteFailed = APIError{Code: "CLUSTERS-MGMT-DELETE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete cluster"} + ErrClusterDeleteFailed = APIError{Code: "CLUSTERS-MGMT-DELETE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete cluster"} // Cluster — Status ErrClusterStatusNotFound = APIError{Code: "CLUSTERS-MGMT-STATUS-001", HTTPStatus: http.StatusNotFound, Message: "Cluster not found"} - ErrClusterStatusFailed = APIError{Code: "CLUSTERS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster status"} + ErrClusterStatusFailed = APIError{Code: "CLUSTERS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get cluster status"} // Cluster — Validation ErrClusterValidation = APIError{Code: "CLUSTERS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "A validation error has occurred, check the errors field for more information"} @@ -246,48 +246,48 @@ func init() { ErrNodePoolList = APIError{Code: "NODEPOOLS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list nodepools"} // NodePool — Create - ErrNodePoolCreateInvalidBody = APIError{Code: "NODEPOOLS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrNodePoolCreateMissingFields = APIError{Code: "NODEPOOLS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required fields: name, cluster_id, and spec"} - ErrNodePoolCreateNameConflict = APIError{Code: "NODEPOOLS-MGMT-CREATE-003", HTTPStatus: http.StatusConflict, Message: "NodePool already exists"} + ErrNodePoolCreateInvalidBody = APIError{Code: "NODEPOOLS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrNodePoolCreateMissingFields = APIError{Code: "NODEPOOLS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required fields: name, cluster_id, and spec"} + ErrNodePoolCreateNameConflict = APIError{Code: "NODEPOOLS-MGMT-CREATE-003", HTTPStatus: http.StatusConflict, Message: "NodePool already exists"} ErrNodePoolCreateClusterNotFound = APIError{Code: "NODEPOOLS-MGMT-CREATE-004", HTTPStatus: http.StatusNotFound, Message: "Referenced cluster not found"} - ErrNodePoolCreateClusterCheck = APIError{Code: "NODEPOOLS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster reference"} - ErrNodePoolCreateInvalidSpec = APIError{Code: "NODEPOOLS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: "Invalid nodepool spec"} - ErrNodePoolCreateFailed = APIError{Code: "NODEPOOLS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create nodepool"} + ErrNodePoolCreateClusterCheck = APIError{Code: "NODEPOOLS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to validate cluster reference"} + ErrNodePoolCreateInvalidSpec = APIError{Code: "NODEPOOLS-MGMT-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: "Invalid nodepool spec"} + ErrNodePoolCreateFailed = APIError{Code: "NODEPOOLS-MGMT-CREATE-007", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create nodepool"} // NodePool — Get ErrNodePoolGetNotFound = APIError{Code: "NODEPOOLS-MGMT-GET-001", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} - ErrNodePoolGetFailed = APIError{Code: "NODEPOOLS-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool"} + ErrNodePoolGetFailed = APIError{Code: "NODEPOOLS-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool"} // NodePool — Update - ErrNodePoolUpdateInvalidBody = APIError{Code: "NODEPOOLS-MGMT-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrNodePoolUpdateInvalidBody = APIError{Code: "NODEPOOLS-MGMT-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrNodePoolUpdateMissingFields = APIError{Code: "NODEPOOLS-MGMT-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Missing required field: spec"} - ErrNodePoolUpdateNotFound = APIError{Code: "NODEPOOLS-MGMT-UPDATE-003", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} - ErrNodePoolUpdateFailed = APIError{Code: "NODEPOOLS-MGMT-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to update nodepool"} - ErrNodePoolUpdateInvalidSpec = APIError{Code: "NODEPOOLS-MGMT-UPDATE-005", HTTPStatus: http.StatusBadRequest, Message: "Invalid nodepool spec"} + ErrNodePoolUpdateNotFound = APIError{Code: "NODEPOOLS-MGMT-UPDATE-003", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} + ErrNodePoolUpdateFailed = APIError{Code: "NODEPOOLS-MGMT-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to update nodepool"} + ErrNodePoolUpdateInvalidSpec = APIError{Code: "NODEPOOLS-MGMT-UPDATE-005", HTTPStatus: http.StatusBadRequest, Message: "Invalid nodepool spec"} // NodePool — Delete ErrNodePoolDeleteNotFound = APIError{Code: "NODEPOOLS-MGMT-DELETE-001", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} - ErrNodePoolDeleteFailed = APIError{Code: "NODEPOOLS-MGMT-DELETE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete nodepool"} + ErrNodePoolDeleteFailed = APIError{Code: "NODEPOOLS-MGMT-DELETE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete nodepool"} // NodePool — Status ErrNodePoolStatusNotFound = APIError{Code: "NODEPOOLS-MGMT-STATUS-001", HTTPStatus: http.StatusNotFound, Message: "NodePool not found"} - ErrNodePoolStatusFailed = APIError{Code: "NODEPOOLS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool status"} + ErrNodePoolStatusFailed = APIError{Code: "NODEPOOLS-MGMT-STATUS-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get nodepool status"} // NodePool — Validation ErrNodePoolValidation = APIError{Code: "NODEPOOLS-MGMT-VALIDATION-001", HTTPStatus: http.StatusUnprocessableEntity, Message: "A validation error has occurred, check the errors field for more information"} // Accounts — Create ErrAccountCreateInvalidBody = APIError{Code: "ACCOUNTS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrAccountCreateMissingID = APIError{Code: "ACCOUNTS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "accountId is required"} + ErrAccountCreateMissingID = APIError{Code: "ACCOUNTS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "accountId is required"} ErrAccountCreateCheckFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check account status"} - ErrAccountCreateExists = APIError{Code: "ACCOUNTS-MGMT-CREATE-004", HTTPStatus: http.StatusConflict, Message: "Account is already enabled"} - ErrAccountCreateFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to enable account"} + ErrAccountCreateExists = APIError{Code: "ACCOUNTS-MGMT-CREATE-004", HTTPStatus: http.StatusConflict, Message: "Account is already enabled"} + ErrAccountCreateFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to enable account"} // Accounts — List ErrAccountListFailed = APIError{Code: "ACCOUNTS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list accounts"} // Accounts — Get - ErrAccountGetFailed = APIError{Code: "ACCOUNTS-MGMT-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get account"} + ErrAccountGetFailed = APIError{Code: "ACCOUNTS-MGMT-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get account"} ErrAccountGetNotFound = APIError{Code: "ACCOUNTS-MGMT-GET-002", HTTPStatus: http.StatusNotFound, Message: "Account not found"} // Accounts — Delete @@ -295,50 +295,50 @@ func init() { // Management clusters — Create ErrMCCreateInvalidBody = APIError{Code: "MC-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrMCCreateMissingID = APIError{Code: "MC-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "id is required"} - ErrMCCreateMissingReg = APIError{Code: "MC-MGMT-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "region is required"} + ErrMCCreateMissingID = APIError{Code: "MC-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "id is required"} + ErrMCCreateMissingReg = APIError{Code: "MC-MGMT-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "region is required"} ErrMCCreateMissingAcct = APIError{Code: "MC-MGMT-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "accountId is required"} - ErrMCCreateExists = APIError{Code: "MC-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Management cluster already registered", Reason: "management cluster already registered: %s"} - ErrMCCreateFailed = APIError{Code: "MC-MGMT-CREATE-006", HTTPStatus: http.StatusInternalServerError, Message: "Failed to save management cluster config"} + ErrMCCreateExists = APIError{Code: "MC-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Management cluster already registered", Reason: "management cluster already registered: %s"} + ErrMCCreateFailed = APIError{Code: "MC-MGMT-CREATE-006", HTTPStatus: http.StatusInternalServerError, Message: "Failed to save management cluster config"} // Management clusters — List ErrMCListFailed = APIError{Code: "MC-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to load management cluster config"} // Management clusters — Get ErrMCGetNotFound = APIError{Code: "MC-MGMT-GET-001", HTTPStatus: http.StatusNotFound, Message: "Management cluster not found"} - ErrMCGetFailed = APIError{Code: "MC-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to load management cluster config"} + ErrMCGetFailed = APIError{Code: "MC-MGMT-GET-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to load management cluster config"} // Authz — Policy — Create ErrAuthzPolicyCreateInvalidBody = APIError{Code: "AUTHZ-POLICY-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrAuthzPolicyCreateMissingName = APIError{Code: "AUTHZ-POLICY-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "name is required"} ErrAuthzPolicyCreateMissingText = APIError{Code: "AUTHZ-POLICY-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "policy (Cedar text) is required"} - ErrAuthzPolicyCreateInvalid = APIError{Code: "AUTHZ-POLICY-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "Invalid policy", Reason: "%w"} + ErrAuthzPolicyCreateInvalid = APIError{Code: "AUTHZ-POLICY-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "Invalid policy", Reason: "%w"} // Authz — Policy — List ErrAuthzPolicyListFailed = APIError{Code: "AUTHZ-POLICY-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list policies"} // Authz — Policy — Get - ErrAuthzPolicyGetFailed = APIError{Code: "AUTHZ-POLICY-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get policy"} + ErrAuthzPolicyGetFailed = APIError{Code: "AUTHZ-POLICY-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get policy"} ErrAuthzPolicyGetNotFound = APIError{Code: "AUTHZ-POLICY-GET-002", HTTPStatus: http.StatusNotFound, Message: "Policy not found"} // Authz — Policy — Update ErrAuthzPolicyUpdateInvalidBody = APIError{Code: "AUTHZ-POLICY-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrAuthzPolicyUpdateInvalid = APIError{Code: "AUTHZ-POLICY-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Invalid policy", Reason: "%w"} + ErrAuthzPolicyUpdateInvalid = APIError{Code: "AUTHZ-POLICY-UPDATE-002", HTTPStatus: http.StatusBadRequest, Message: "Invalid policy", Reason: "%w"} // Authz — Policy — Delete ErrAuthzPolicyDeleteFailed = APIError{Code: "AUTHZ-POLICY-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to delete policy"} - ErrAuthzPolicyDeleteInUse = APIError{Code: "AUTHZ-POLICY-DELETE-002", HTTPStatus: http.StatusConflict, Message: "Cannot delete policy with existing attachments", Reason: "%w"} + ErrAuthzPolicyDeleteInUse = APIError{Code: "AUTHZ-POLICY-DELETE-002", HTTPStatus: http.StatusConflict, Message: "Cannot delete policy with existing attachments", Reason: "%w"} // Authz — Group — Create ErrAuthzGroupCreateInvalidBody = APIError{Code: "AUTHZ-GROUP-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrAuthzGroupCreateMissingName = APIError{Code: "AUTHZ-GROUP-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "name is required"} - ErrAuthzGroupCreateFailed = APIError{Code: "AUTHZ-GROUP-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create group"} + ErrAuthzGroupCreateFailed = APIError{Code: "AUTHZ-GROUP-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create group"} // Authz — Group — List ErrAuthzGroupListFailed = APIError{Code: "AUTHZ-GROUP-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list groups"} // Authz — Group — Get - ErrAuthzGroupGetFailed = APIError{Code: "AUTHZ-GROUP-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get group"} + ErrAuthzGroupGetFailed = APIError{Code: "AUTHZ-GROUP-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to get group"} ErrAuthzGroupGetNotFound = APIError{Code: "AUTHZ-GROUP-GET-002", HTTPStatus: http.StatusNotFound, Message: "Group not found"} // Authz — Group — Delete @@ -346,61 +346,61 @@ func init() { // Authz — Group — Members ErrAuthzGroupMembersUpdateInvalidBody = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrAuthzGroupMembersUpdateAddFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to add group member"} - ErrAuthzGroupMembersUpdateRemFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to remove group member"} - ErrAuthzGroupMembersUpdateListFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list group members"} - ErrAuthzGroupMembersListFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list group members"} + ErrAuthzGroupMembersUpdateAddFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to add group member"} + ErrAuthzGroupMembersUpdateRemFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to remove group member"} + ErrAuthzGroupMembersUpdateListFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-UPDATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list group members"} + ErrAuthzGroupMembersListFailed = APIError{Code: "AUTHZ-GROUP-MEMBERS-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list group members"} // Authz — Attachment — Create - ErrAuthzAttachCreateInvalidBody = APIError{Code: "AUTHZ-ATTACH-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzAttachCreateInvalidBody = APIError{Code: "AUTHZ-ATTACH-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrAuthzAttachCreateMissingFields = APIError{Code: "AUTHZ-ATTACH-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "policyId, targetType, and targetId are required"} ErrAuthzAttachCreateInvalidTarget = APIError{Code: "AUTHZ-ATTACH-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "targetType must be 'user' or 'group'"} - ErrAuthzAttachCreateFailed = APIError{Code: "AUTHZ-ATTACH-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "Failed to attach policy", Reason: "%w"} + ErrAuthzAttachCreateFailed = APIError{Code: "AUTHZ-ATTACH-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "Failed to attach policy", Reason: "%w"} // Authz — Attachment — List / Delete - ErrAuthzAttachListFailed = APIError{Code: "AUTHZ-ATTACH-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list attachments"} + ErrAuthzAttachListFailed = APIError{Code: "AUTHZ-ATTACH-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list attachments"} ErrAuthzAttachDeleteFailed = APIError{Code: "AUTHZ-ATTACH-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to detach policy"} // Authz — Admin — Add - ErrAuthzAdminAddInvalidBody = APIError{Code: "AUTHZ-ADMIN-ADD-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzAdminAddInvalidBody = APIError{Code: "AUTHZ-ADMIN-ADD-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrAuthzAdminAddMissingPrinc = APIError{Code: "AUTHZ-ADMIN-ADD-002", HTTPStatus: http.StatusBadRequest, Message: "principalArn is required"} - ErrAuthzAdminAddFailed = APIError{Code: "AUTHZ-ADMIN-ADD-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to add admin"} + ErrAuthzAdminAddFailed = APIError{Code: "AUTHZ-ADMIN-ADD-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to add admin"} // Authz — Admin — List / Delete - ErrAuthzAdminListFailed = APIError{Code: "AUTHZ-ADMIN-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list admins"} + ErrAuthzAdminListFailed = APIError{Code: "AUTHZ-ADMIN-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list admins"} ErrAuthzAdminDeleteFailed = APIError{Code: "AUTHZ-ADMIN-DELETE-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to remove admin"} // Authz — Check - ErrAuthzCheckInvalidBody = APIError{Code: "AUTHZ-CHECK-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrAuthzCheckMissingPrinc = APIError{Code: "AUTHZ-CHECK-002", HTTPStatus: http.StatusBadRequest, Message: "principal is required"} + ErrAuthzCheckInvalidBody = APIError{Code: "AUTHZ-CHECK-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrAuthzCheckMissingPrinc = APIError{Code: "AUTHZ-CHECK-002", HTTPStatus: http.StatusBadRequest, Message: "principal is required"} ErrAuthzCheckMissingAction = APIError{Code: "AUTHZ-CHECK-003", HTTPStatus: http.StatusBadRequest, Message: "action is required"} - ErrAuthzCheckMissingRes = APIError{Code: "AUTHZ-CHECK-004", HTTPStatus: http.StatusBadRequest, Message: "resource is required"} - ErrAuthzCheckFailed = APIError{Code: "AUTHZ-CHECK-005", HTTPStatus: http.StatusInternalServerError, Message: "Authorization check failed", Reason: "%w"} + ErrAuthzCheckMissingRes = APIError{Code: "AUTHZ-CHECK-004", HTTPStatus: http.StatusBadRequest, Message: "resource is required"} + ErrAuthzCheckFailed = APIError{Code: "AUTHZ-CHECK-005", HTTPStatus: http.StatusInternalServerError, Message: "Authorization check failed", Reason: "%w"} // ZOA — Create - ErrZoaCreateUnknownAction = APIError{Code: "ZOA-CREATE-001", HTTPStatus: http.StatusNotFound, Message: "Trusted action not found", Reason: "trusted action not found: %s"} - ErrZoaCreateInvalidBody = APIError{Code: "ZOA-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} - ErrZoaCreateMissingCluster = APIError{Code: "ZOA-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "target_cluster is required"} - ErrZoaCreateMissingJira = APIError{Code: "ZOA-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "jira is required for all trusted actions (e.g. ROSAENG-1234)"} - ErrZoaCreateInvalidJira = APIError{Code: "ZOA-CREATE-005", HTTPStatus: http.StatusBadRequest, Message: "jira does not have correct format; expected PROJECT-NUMBER (e.g. ROSAENG-1234)"} - ErrZoaCreateInvalidParams = APIError{Code: "ZOA-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: "Invalid parameters", Reason: "%w"} - ErrZoaCreateCooldown = APIError{Code: "ZOA-CREATE-007", HTTPStatus: http.StatusTooManyRequests, Message: "Write cooldown in effect", Reason: "%w"} - ErrZoaCreateMaxConcurrent = APIError{Code: "ZOA-CREATE-008", HTTPStatus: http.StatusTooManyRequests, Message: "Too many concurrent executions on target", Reason: "%w"} - ErrZoaCreateDryRunError = APIError{Code: "ZOA-CREATE-009", HTTPStatus: http.StatusInternalServerError, Message: "Dry run action not found", Reason: "dry_run_action '%s' not found in registry"} - ErrZoaCreateStoreFailed = APIError{Code: "ZOA-CREATE-010", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create execution"} - ErrZoaCreateRenderFailed = APIError{Code: "ZOA-CREATE-011", HTTPStatus: http.StatusInternalServerError, Message: "Failed to build trusted action manifest"} - ErrZoaCreateDispatchFailed = APIError{Code: "ZOA-CREATE-012", HTTPStatus: http.StatusBadGateway, Message: "Failed to dispatch trusted action"} + ErrZoaCreateUnknownAction = APIError{Code: "ZOA-CREATE-001", HTTPStatus: http.StatusNotFound, Message: "Trusted action not found", Reason: "trusted action not found: %s"} + ErrZoaCreateInvalidBody = APIError{Code: "ZOA-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} + ErrZoaCreateMissingCluster = APIError{Code: "ZOA-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "target_cluster is required"} + ErrZoaCreateMissingJira = APIError{Code: "ZOA-CREATE-004", HTTPStatus: http.StatusBadRequest, Message: "jira is required for all trusted actions (e.g. ROSAENG-1234)"} + ErrZoaCreateInvalidJira = APIError{Code: "ZOA-CREATE-005", HTTPStatus: http.StatusBadRequest, Message: "jira does not have correct format; expected PROJECT-NUMBER (e.g. ROSAENG-1234)"} + ErrZoaCreateInvalidParams = APIError{Code: "ZOA-CREATE-006", HTTPStatus: http.StatusBadRequest, Message: "Invalid parameters", Reason: "%w"} + ErrZoaCreateCooldown = APIError{Code: "ZOA-CREATE-007", HTTPStatus: http.StatusTooManyRequests, Message: "Write cooldown in effect", Reason: "%w"} + ErrZoaCreateMaxConcurrent = APIError{Code: "ZOA-CREATE-008", HTTPStatus: http.StatusTooManyRequests, Message: "Too many concurrent executions on target", Reason: "%w"} + ErrZoaCreateDryRunError = APIError{Code: "ZOA-CREATE-009", HTTPStatus: http.StatusInternalServerError, Message: "Dry run action not found", Reason: "dry_run_action '%s' not found in registry"} + ErrZoaCreateStoreFailed = APIError{Code: "ZOA-CREATE-010", HTTPStatus: http.StatusInternalServerError, Message: "Failed to create execution"} + ErrZoaCreateRenderFailed = APIError{Code: "ZOA-CREATE-011", HTTPStatus: http.StatusInternalServerError, Message: "Failed to build trusted action manifest"} + ErrZoaCreateDispatchFailed = APIError{Code: "ZOA-CREATE-012", HTTPStatus: http.StatusBadGateway, Message: "Failed to dispatch trusted action"} ErrZoaCreateStoreSaveFailed = APIError{Code: "ZOA-CREATE-013", HTTPStatus: http.StatusInternalServerError, Message: "Failed to persist execution state"} // ZOA — Get ErrZoaGetStoreFailed = APIError{Code: "ZOA-GET-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to retrieve execution"} - ErrZoaGetNotFound = APIError{Code: "ZOA-GET-002", HTTPStatus: http.StatusNotFound, Message: "Execution not found"} + ErrZoaGetNotFound = APIError{Code: "ZOA-GET-002", HTTPStatus: http.StatusNotFound, Message: "Execution not found"} // ZOA — List ErrZoaListStoreFailed = APIError{Code: "ZOA-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list executions"} - // ZOA — Audit - ErrZoaAuditDisabled = APIError{Code: "ZOA-AUDIT-001", HTTPStatus: http.StatusNotFound, Message: "Audit logging is not enabled"} + // ZOA — Audit + ErrZoaAuditDisabled = APIError{Code: "ZOA-AUDIT-001", HTTPStatus: http.StatusNotFound, Message: "Audit logging is not enabled"} ErrZoaAuditListFailed = APIError{Code: "ZOA-AUDIT-002", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list audit log"} // Info diff --git a/platform-api/pkg/handlers/health.go b/platform-api/pkg/handlers/health.go index 2e1f31b2..c3252f66 100644 --- a/platform-api/pkg/handlers/health.go +++ b/platform-api/pkg/handlers/health.go @@ -1,12 +1,14 @@ package handlers import ( - "encoding/json" "net/http" "sync/atomic" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" ) // HealthHandler handles health check endpoints +// TODO: add a logger field so write errors can be logged. type HealthHandler struct { ready *atomic.Bool } @@ -27,19 +29,15 @@ func (h *HealthHandler) SetReady(ready bool) { // Liveness handles GET /live func (h *HealthHandler) Liveness(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + _ = api.Write(w, http.StatusOK, map[string]string{"status": "ok"}) } // Readiness handles GET /ready func (h *HealthHandler) Readiness(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if !h.ready.Load() { - w.WriteHeader(http.StatusServiceUnavailable) - _ = json.NewEncoder(w).Encode(map[string]string{"status": "unavailable"}) + _ = api.Write(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable"}) return } - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + _ = api.Write(w, http.StatusOK, map[string]string{"status": "ok"}) } diff --git a/platform-api/pkg/handlers/info.go b/platform-api/pkg/handlers/info.go index 5b2e55a5..194b658d 100644 --- a/platform-api/pkg/handlers/info.go +++ b/platform-api/pkg/handlers/info.go @@ -1,14 +1,16 @@ package handlers import ( - "encoding/json" "fmt" "net/http" "os" "strings" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" ) // InfoHandler handles the info endpoint +// TODO: add a logger field so write errors can be logged. type InfoHandler struct{} // NewInfoHandler creates a new InfoHandler @@ -20,8 +22,6 @@ func NewInfoHandler() *InfoHandler { // Returns the ARN of the IAM role used to invoke Lambda functions in this regional account. // The account ID is parsed from the TARGET_GROUP_ARN environment variable. func (h *InfoHandler) Info(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - tgARN := os.Getenv("TARGET_GROUP_ARN") // Target Group ARN format: arn:aws:elasticloadbalancing:{region}:{account_id}:targetgroup/{name}/{id} parts := strings.SplitN(tgARN, ":", 6) @@ -33,5 +33,5 @@ func (h *InfoHandler) Info(w http.ResponseWriter, r *http.Request) { accountID := parts[4] arn := fmt.Sprintf("arn:aws:iam::%s:role/LambdaExecutor", accountID) - _ = json.NewEncoder(w).Encode(map[string]string{"arn": arn}) + _ = api.Write(w, http.StatusOK, map[string]string{"arn": arn}) } diff --git a/platform-api/pkg/handlers/management_cluster.go b/platform-api/pkg/handlers/management_cluster.go index 19b8a927..2a3afc5f 100644 --- a/platform-api/pkg/handlers/management_cluster.go +++ b/platform-api/pkg/handlers/management_cluster.go @@ -10,6 +10,7 @@ import ( hyperfleetv1alpha1 "github.com/openshift-online/rosa-hyperfleet-api/api/v1alpha1" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" ) @@ -92,9 +93,9 @@ func (h *ManagementClusterHandler) Create(w http.ResponseWriter, r *http.Request h.logger.Info("management cluster created", "id", mc.Name, "account_id", accountID) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(mcToResponse(mc)) + if err := api.Write(w, http.StatusCreated, mcToResponse(mc)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // List handles GET /api/v0/management_clusters @@ -118,12 +119,13 @@ func (h *ManagementClusterHandler) List(w http.ResponseWriter, r *http.Request) h.logger.Debug("management clusters listed", "total", len(clusters), "account_id", accountID) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ + if err := api.Write(w, http.StatusOK, map[string]any{ "kind": "ManagementClusterList", "items": clusters, "total": len(clusters), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Get handles GET /api/v0/management_clusters/{id} @@ -148,8 +150,9 @@ func (h *ManagementClusterHandler) Get(w http.ResponseWriter, r *http.Request) { h.logger.Debug("management cluster retrieved", "id", mc.Name, "account_id", accountID) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(mcToResponse(mc)) + if err := api.Write(w, http.StatusOK, mcToResponse(mc)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func mcToResponse(mc *hyperfleetv1alpha1.ManagementCluster) ManagementClusterResponse { diff --git a/platform-api/pkg/handlers/nodepool.go b/platform-api/pkg/handlers/nodepool.go index 83b6b1b0..fea95b45 100644 --- a/platform-api/pkg/handlers/nodepool.go +++ b/platform-api/pkg/handlers/nodepool.go @@ -7,14 +7,15 @@ import ( "net/http" "strconv" + "github.com/google/uuid" "github.com/gorilla/mux" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/internal/codegen/featuregate" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/types" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/validation" - - "github.com/google/uuid" ) type NodePoolHandler struct { @@ -84,7 +85,9 @@ func (h *NodePoolHandler) List(w http.ResponseWriter, r *http.Request) { "offset": offset, } - h.writeJSON(w, http.StatusOK, response) + if err := api.Write(w, http.StatusOK, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { @@ -137,7 +140,9 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusCreated, hyperfleetdb.NodePoolCRToPlatform(cr)) + if err := api.Write(w, http.StatusCreated, hyperfleetdb.NodePoolCRToPlatform(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *NodePoolHandler) Get(w http.ResponseWriter, r *http.Request) { @@ -159,7 +164,9 @@ func (h *NodePoolHandler) Get(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusOK, hyperfleetdb.NodePoolCRToPlatform(cr)) + if err := api.Write(w, http.StatusOK, hyperfleetdb.NodePoolCRToPlatform(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { @@ -223,7 +230,9 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusOK, hyperfleetdb.NodePoolCRToPlatform(cr)) + if err := api.Write(w, http.StatusOK, hyperfleetdb.NodePoolCRToPlatform(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *NodePoolHandler) Delete(w http.ResponseWriter, r *http.Request) { @@ -250,7 +259,9 @@ func (h *NodePoolHandler) Delete(w http.ResponseWriter, r *http.Request) { "nodepool_id": nodepoolID, } - h.writeJSON(w, http.StatusAccepted, response) + if err := api.Write(w, http.StatusAccepted, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *NodePoolHandler) GetStatus(w http.ResponseWriter, r *http.Request) { @@ -272,11 +283,7 @@ func (h *NodePoolHandler) GetStatus(w http.ResponseWriter, r *http.Request) { return } - h.writeJSON(w, http.StatusOK, hyperfleetdb.NodePoolStatusFromCR(cr)) -} - -func (h *NodePoolHandler) writeJSON(w http.ResponseWriter, status int, data any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(data) + if err := api.Write(w, http.StatusOK, hyperfleetdb.NodePoolStatusFromCR(cr)); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/handlers/zoa.go b/platform-api/pkg/handlers/zoa.go index 0b9feb18..aea5ccea 100644 --- a/platform-api/pkg/handlers/zoa.go +++ b/platform-api/pkg/handlers/zoa.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/clients/hyperfleetdb" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/zoa" @@ -238,9 +239,9 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { h.recordAudit(ctx, r, accountID, callerARN, operator, http.StatusAccepted, originalAction, req.TargetCluster, execID, req.Jira, string(exec.ApprovalState)) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(exec) + if err := api.Write(w, http.StatusAccepted, exec); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Get handles GET /api/v0/trusted-actions/runs/{id} @@ -305,9 +306,9 @@ func (h *ZoaHandler) Get(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(response) + if err := api.Write(w, http.StatusOK, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // List handles GET /api/v0/trusted-actions/runs @@ -373,9 +374,9 @@ func (h *ZoaHandler) List(w http.ResponseWriter, r *http.Request) { operator := extractOperator(callerARN) h.recordAudit(ctx, r, accountID, callerARN, operator, http.StatusOK, "", "", "", "", "") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(response) + if err := api.Write(w, http.StatusOK, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // parseSince converts a duration shorthand (e.g. "1h", "24h", "7d") or RFC3339 timestamp @@ -431,12 +432,12 @@ func (h *ZoaHandler) Catalog(w http.ResponseWriter, r *http.Request) { }) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ + if err := api.Write(w, http.StatusOK, map[string]any{ "items": items, "total": len(items), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Describe handles GET /api/v0/trusted-actions/{action} @@ -461,9 +462,9 @@ func (h *ZoaHandler) Describe(w http.ResponseWriter, r *http.Request) { RequiredFields: []string{"target_cluster", "jira"}, } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(response) + if err := api.Write(w, http.StatusOK, response); err != nil { + h.logger.Error("failed to write response", "error", err) + } } func (h *ZoaHandler) fetchS3Content(ctx context.Context, s3URI string) ([]byte, error) { @@ -695,11 +696,11 @@ func (h *ZoaHandler) AuditList(w http.ResponseWriter, r *http.Request) { operator := extractOperator(callerARN) h.recordAudit(ctx, r, accountID, callerARN, operator, http.StatusOK, "", "", "", "", "") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]any{ + if err := api.Write(w, http.StatusOK, map[string]any{ "kind": "AuditList", "items": entries, "total": len(entries), - }) + }); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/middleware/errorcodes.go b/platform-api/pkg/middleware/errorcodes.go index 49fb525d..4448c05d 100644 --- a/platform-api/pkg/middleware/errorcodes.go +++ b/platform-api/pkg/middleware/errorcodes.go @@ -3,14 +3,15 @@ package middleware import ( "net/http" - "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" ) -// APIError is an alias for apierror.APIError so middleware code uses the short form. -type APIError = apierror.APIError +// APIError is an alias for api.APIError so middleware code uses the short form. +type APIError = api.APIError +// TODO: add a logger parameter so write errors can be logged. func writeError(w http.ResponseWriter, def APIError) { - apierror.Write(w, def) + _ = api.WriteError(w, def) } // Auth middleware error codes @@ -31,17 +32,17 @@ var ( ) func init() { - ErrMissingAccountID = APIError{Code: "AUTH-001", HTTPStatus: http.StatusForbidden, Message: "Account ID header is required"} - ErrMissingCallerARN = APIError{Code: "AUTH-002", HTTPStatus: http.StatusForbidden, Message: "Caller ARN header is required"} - ErrInternalError = APIError{Code: "AUTH-003", HTTPStatus: http.StatusInternalServerError, Message: "Internal server error"} + ErrMissingAccountID = APIError{Code: "AUTH-001", HTTPStatus: http.StatusForbidden, Message: "Account ID header is required"} + ErrMissingCallerARN = APIError{Code: "AUTH-002", HTTPStatus: http.StatusForbidden, Message: "Caller ARN header is required"} + ErrInternalError = APIError{Code: "AUTH-003", HTTPStatus: http.StatusInternalServerError, Message: "Internal server error"} ErrAccountNotProvisioned = APIError{Code: "AUTH-004", HTTPStatus: http.StatusForbidden, Message: "Account is not provisioned for ROSA authorization. Contact your administrator."} - ErrNotAdmin = APIError{Code: "AUTH-005", HTTPStatus: http.StatusForbidden, Message: "This operation requires admin privileges"} - ErrNotPrivileged = APIError{Code: "AUTH-006", HTTPStatus: http.StatusForbidden, Message: "This operation requires a privileged account"} - ErrAccountNotAllowed = APIError{Code: "AUTH-007", HTTPStatus: http.StatusForbidden, Message: "account not allowed"} - ErrAuthorizationFailed = APIError{Code: "AUTH-008", HTTPStatus: http.StatusInternalServerError, Message: "Authorization check failed"} - ErrAccessDenied = APIError{Code: "AUTH-009", HTTPStatus: http.StatusForbidden, Message: "You do not have permission to perform this action"} - - ErrAdminCheckFailed = APIError{Code: "AUTH-010", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check admin status"} - ErrPrivilegedCheckFailed = APIError{Code: "AUTH-011", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check privileged status"} + ErrNotAdmin = APIError{Code: "AUTH-005", HTTPStatus: http.StatusForbidden, Message: "This operation requires admin privileges"} + ErrNotPrivileged = APIError{Code: "AUTH-006", HTTPStatus: http.StatusForbidden, Message: "This operation requires a privileged account"} + ErrAccountNotAllowed = APIError{Code: "AUTH-007", HTTPStatus: http.StatusForbidden, Message: "account not allowed"} + ErrAuthorizationFailed = APIError{Code: "AUTH-008", HTTPStatus: http.StatusInternalServerError, Message: "Authorization check failed"} + ErrAccessDenied = APIError{Code: "AUTH-009", HTTPStatus: http.StatusForbidden, Message: "You do not have permission to perform this action"} + + ErrAdminCheckFailed = APIError{Code: "AUTH-010", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check admin status"} + ErrPrivilegedCheckFailed = APIError{Code: "AUTH-011", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check privileged status"} ErrProvisionedCheckFailed = APIError{Code: "AUTH-012", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check account provisioning status"} } diff --git a/platform-api/pkg/ratelimit/middleware.go b/platform-api/pkg/ratelimit/middleware.go index cb5044d0..becf35d0 100644 --- a/platform-api/pkg/ratelimit/middleware.go +++ b/platform-api/pkg/ratelimit/middleware.go @@ -13,7 +13,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/apierror" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" ) @@ -120,7 +120,7 @@ func (l *Limiter) findLimit(method, path string) RouteLimit { } } -var errRateLimit = apierror.APIError{ +var errRateLimit = api.APIError{ Code: "RATE-LIMIT-001", HTTPStatus: http.StatusTooManyRequests, Message: "Too Many Requests", @@ -128,5 +128,7 @@ var errRateLimit = apierror.APIError{ } func (l *Limiter) writeRateLimitError(w http.ResponseWriter, method, path string, limit RouteLimit, retryAfter int) { - apierror.Write(w, errRateLimit.WithReason(method, path, limit.Rate, limit.Window, retryAfter)) + if err := api.WriteError(w, errRateLimit.WithReason(method, path, limit.Rate, limit.Window, retryAfter)); err != nil { + l.logger.Error("failed to write rate limit error response", "error", err) + } } From e8711574b0322fac39a2ef9959ffe6dfbbac1150 Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 18:28:14 -0300 Subject: [PATCH 4/7] ROSAENG-62084 | fix: propagate write errors through handlers and middleware WriteError now uses marshal-before-commit and returns error. All writeAPIError and writeError wrappers accept a *slog.Logger and log failures. InfoHandler and HealthHandler gain logger fields, removing the last TODO stubs. --- platform-api/pkg/handlers/accounts.go | 18 ++--- platform-api/pkg/handlers/authz.go | 78 +++++++++---------- platform-api/pkg/handlers/cluster.go | 50 ++++++------ platform-api/pkg/handlers/errorcodes.go | 8 +- platform-api/pkg/handlers/health.go | 22 ++++-- platform-api/pkg/handlers/info.go | 16 ++-- platform-api/pkg/handlers/info_test.go | 7 +- .../pkg/handlers/management_cluster.go | 18 ++--- platform-api/pkg/handlers/nodepool.go | 48 ++++++------ platform-api/pkg/handlers/zoa.go | 38 ++++----- platform-api/pkg/middleware/account_check.go | 6 +- platform-api/pkg/middleware/admin_check.go | 8 +- platform-api/pkg/middleware/authorization.go | 4 +- .../pkg/middleware/authorization_test.go | 2 +- platform-api/pkg/middleware/authz.go | 10 +-- platform-api/pkg/middleware/errorcodes.go | 8 +- platform-api/pkg/middleware/privileged.go | 6 +- platform-api/pkg/server/server.go | 4 +- 18 files changed, 184 insertions(+), 167 deletions(-) diff --git a/platform-api/pkg/handlers/accounts.go b/platform-api/pkg/handlers/accounts.go index d1baa8fe..8a42845b 100644 --- a/platform-api/pkg/handlers/accounts.go +++ b/platform-api/pkg/handlers/accounts.go @@ -58,12 +58,12 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { var req EnableAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAccountCreateInvalidBody) + writeAPIError(w, ErrAccountCreateInvalidBody, h.logger) return } if req.AccountID == "" { - writeAPIError(w, ErrAccountCreateMissingID) + writeAPIError(w, ErrAccountCreateMissingID, h.logger) return } @@ -71,18 +71,18 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { existing, err := h.authorizer.GetAccount(ctx, req.AccountID) if err != nil { h.logger.Error("failed to check existing account", "error", err, "account_id", req.AccountID) - writeAPIError(w, ErrAccountCreateCheckFailed) + writeAPIError(w, ErrAccountCreateCheckFailed, h.logger) return } if existing != nil { - writeAPIError(w, ErrAccountCreateExists) + writeAPIError(w, ErrAccountCreateExists, h.logger) return } account, err := h.authorizer.EnableAccount(ctx, req.AccountID, callerARN, req.Privileged) if err != nil { h.logger.Error("failed to enable account", "error", err, "account_id", req.AccountID) - writeAPIError(w, ErrAccountCreateFailed) + writeAPIError(w, ErrAccountCreateFailed, h.logger) return } @@ -107,7 +107,7 @@ func (h *AccountsHandler) List(w http.ResponseWriter, r *http.Request) { accounts, err := h.authorizer.ListAccounts(ctx) if err != nil { h.logger.Error("failed to list accounts", "error", err) - writeAPIError(w, ErrAccountListFailed) + writeAPIError(w, ErrAccountListFailed, h.logger) return } @@ -141,12 +141,12 @@ func (h *AccountsHandler) Get(w http.ResponseWriter, r *http.Request) { account, err := h.authorizer.GetAccount(ctx, accountID) if err != nil { h.logger.Error("failed to get account", "error", err, "account_id", accountID) - writeAPIError(w, ErrAccountGetFailed) + writeAPIError(w, ErrAccountGetFailed, h.logger) return } if account == nil { - writeAPIError(w, ErrAccountGetNotFound) + writeAPIError(w, ErrAccountGetNotFound, h.logger) return } @@ -174,7 +174,7 @@ func (h *AccountsHandler) Delete(w http.ResponseWriter, r *http.Request) { err := h.authorizer.DisableAccount(ctx, accountID) if err != nil { h.logger.Error("failed to disable account", "error", err, "account_id", accountID) - writeAPIError(w, ErrAccountDeleteFailed) + writeAPIError(w, ErrAccountDeleteFailed, h.logger) return } diff --git a/platform-api/pkg/handlers/authz.go b/platform-api/pkg/handlers/authz.go index e4bba7da..6516d07f 100644 --- a/platform-api/pkg/handlers/authz.go +++ b/platform-api/pkg/handlers/authz.go @@ -141,24 +141,24 @@ func (h *AuthzHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) { var req CreatePolicyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzPolicyCreateInvalidBody) + writeAPIError(w, ErrAuthzPolicyCreateInvalidBody, h.logger) return } if req.Name == "" { - writeAPIError(w, ErrAuthzPolicyCreateMissingName) + writeAPIError(w, ErrAuthzPolicyCreateMissingName, h.logger) return } if req.Policy == "" { - writeAPIError(w, ErrAuthzPolicyCreateMissingText) + writeAPIError(w, ErrAuthzPolicyCreateMissingText, h.logger) return } p, err := h.service.CreatePolicy(ctx, accountID, req.Name, req.Description, req.Policy) if err != nil { h.logger.Error("failed to create policy", "error", err, "account_id", accountID) - writeAPIError(w, ErrAuthzPolicyCreateInvalid.WithReason(err)) + writeAPIError(w, ErrAuthzPolicyCreateInvalid.WithReason(err), h.logger) return } @@ -181,7 +181,7 @@ func (h *AuthzHandler) ListPolicies(w http.ResponseWriter, r *http.Request) { policies, err := h.service.ListPolicies(ctx, accountID) if err != nil { h.logger.Error("failed to list policies", "error", err, "account_id", accountID) - writeAPIError(w, ErrAuthzPolicyListFailed) + writeAPIError(w, ErrAuthzPolicyListFailed, h.logger) return } @@ -214,12 +214,12 @@ func (h *AuthzHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { p, err := h.service.GetPolicy(ctx, accountID, policyID) if err != nil { h.logger.Error("failed to get policy", "error", err, "account_id", accountID, "policy_id", policyID) - writeAPIError(w, ErrAuthzPolicyGetFailed) + writeAPIError(w, ErrAuthzPolicyGetFailed, h.logger) return } if p == nil { - writeAPIError(w, ErrAuthzPolicyGetNotFound) + writeAPIError(w, ErrAuthzPolicyGetNotFound, h.logger) return } @@ -242,14 +242,14 @@ func (h *AuthzHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) { var req CreatePolicyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzPolicyUpdateInvalidBody) + writeAPIError(w, ErrAuthzPolicyUpdateInvalidBody, h.logger) return } p, err := h.service.UpdatePolicy(ctx, accountID, policyID, req.Name, req.Description, req.Policy) if err != nil { h.logger.Error("failed to update policy", "error", err, "account_id", accountID, "policy_id", policyID) - writeAPIError(w, ErrAuthzPolicyUpdateInvalid.WithReason(err)) + writeAPIError(w, ErrAuthzPolicyUpdateInvalid.WithReason(err), h.logger) return } @@ -274,10 +274,10 @@ func (h *AuthzHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) { if err != nil { h.logger.Error("failed to delete policy", "error", err, "account_id", accountID, "policy_id", policyID) if err.Error() == "cannot delete policy with existing attachments" { - writeAPIError(w, ErrAuthzPolicyDeleteInUse.WithReason(err)) + writeAPIError(w, ErrAuthzPolicyDeleteInUse.WithReason(err), h.logger) return } - writeAPIError(w, ErrAuthzPolicyDeleteFailed) + writeAPIError(w, ErrAuthzPolicyDeleteFailed, h.logger) return } @@ -294,19 +294,19 @@ func (h *AuthzHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { var req CreateGroupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzGroupCreateInvalidBody) + writeAPIError(w, ErrAuthzGroupCreateInvalidBody, h.logger) return } if req.Name == "" { - writeAPIError(w, ErrAuthzGroupCreateMissingName) + writeAPIError(w, ErrAuthzGroupCreateMissingName, h.logger) return } g, err := h.service.CreateGroup(ctx, accountID, req.Name, req.Description) if err != nil { h.logger.Error("failed to create group", "error", err, "account_id", accountID) - writeAPIError(w, ErrAuthzGroupCreateFailed) + writeAPIError(w, ErrAuthzGroupCreateFailed, h.logger) return } @@ -329,7 +329,7 @@ func (h *AuthzHandler) ListGroups(w http.ResponseWriter, r *http.Request) { groups, err := h.service.ListGroups(ctx, accountID) if err != nil { h.logger.Error("failed to list groups", "error", err, "account_id", accountID) - writeAPIError(w, ErrAuthzGroupListFailed) + writeAPIError(w, ErrAuthzGroupListFailed, h.logger) return } @@ -362,12 +362,12 @@ func (h *AuthzHandler) GetGroup(w http.ResponseWriter, r *http.Request) { g, err := h.service.GetGroup(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to get group", "error", err, "account_id", accountID, "group_id", groupID) - writeAPIError(w, ErrAuthzGroupGetFailed) + writeAPIError(w, ErrAuthzGroupGetFailed, h.logger) return } if g == nil { - writeAPIError(w, ErrAuthzGroupGetNotFound) + writeAPIError(w, ErrAuthzGroupGetNotFound, h.logger) return } @@ -391,7 +391,7 @@ func (h *AuthzHandler) DeleteGroup(w http.ResponseWriter, r *http.Request) { err := h.service.DeleteGroup(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to delete group", "error", err, "account_id", accountID, "group_id", groupID) - writeAPIError(w, ErrAuthzGroupDeleteFailed) + writeAPIError(w, ErrAuthzGroupDeleteFailed, h.logger) return } @@ -408,7 +408,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request var req UpdateMembersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzGroupMembersUpdateInvalidBody) + writeAPIError(w, ErrAuthzGroupMembersUpdateInvalidBody, h.logger) return } @@ -416,7 +416,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request for _, memberARN := range req.Add { if err := h.service.AddGroupMember(ctx, accountID, groupID, memberARN); err != nil { h.logger.Error("failed to add group member", "error", err, "account_id", accountID, "group_id", groupID, "member", memberARN) - writeAPIError(w, ErrAuthzGroupMembersUpdateAddFailed) + writeAPIError(w, ErrAuthzGroupMembersUpdateAddFailed, h.logger) return } } @@ -425,7 +425,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request for _, memberARN := range req.Remove { if err := h.service.RemoveGroupMember(ctx, accountID, groupID, memberARN); err != nil { h.logger.Error("failed to remove group member", "error", err, "account_id", accountID, "group_id", groupID, "member", memberARN) - writeAPIError(w, ErrAuthzGroupMembersUpdateRemFailed) + writeAPIError(w, ErrAuthzGroupMembersUpdateRemFailed, h.logger) return } } @@ -434,7 +434,7 @@ func (h *AuthzHandler) UpdateGroupMembers(w http.ResponseWriter, r *http.Request members, err := h.service.ListGroupMembers(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to list group members", "error", err, "account_id", accountID, "group_id", groupID) - writeAPIError(w, ErrAuthzGroupMembersUpdateListFailed) + writeAPIError(w, ErrAuthzGroupMembersUpdateListFailed, h.logger) return } @@ -456,7 +456,7 @@ func (h *AuthzHandler) ListGroupMembers(w http.ResponseWriter, r *http.Request) members, err := h.service.ListGroupMembers(ctx, accountID, groupID) if err != nil { h.logger.Error("failed to list group members", "error", err, "account_id", accountID, "group_id", groupID) - writeAPIError(w, ErrAuthzGroupMembersListFailed) + writeAPIError(w, ErrAuthzGroupMembersListFailed, h.logger) return } @@ -477,24 +477,24 @@ func (h *AuthzHandler) CreateAttachment(w http.ResponseWriter, r *http.Request) var req CreateAttachmentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzAttachCreateInvalidBody) + writeAPIError(w, ErrAuthzAttachCreateInvalidBody, h.logger) return } if req.PolicyID == "" || req.TargetType == "" || req.TargetID == "" { - writeAPIError(w, ErrAuthzAttachCreateMissingFields) + writeAPIError(w, ErrAuthzAttachCreateMissingFields, h.logger) return } if req.TargetType != "user" && req.TargetType != "group" { - writeAPIError(w, ErrAuthzAttachCreateInvalidTarget) + writeAPIError(w, ErrAuthzAttachCreateInvalidTarget, h.logger) return } a, err := h.service.AttachPolicy(ctx, accountID, req.PolicyID, authz.TargetType(req.TargetType), req.TargetID) if err != nil { h.logger.Error("failed to attach policy", "error", err, "account_id", accountID, "policy_id", req.PolicyID) - writeAPIError(w, ErrAuthzAttachCreateFailed.WithReason(err)) + writeAPIError(w, ErrAuthzAttachCreateFailed.WithReason(err), h.logger) return } @@ -524,7 +524,7 @@ func (h *AuthzHandler) ListAttachments(w http.ResponseWriter, r *http.Request) { attachments, err := h.service.ListAttachments(ctx, accountID, filter) if err != nil { h.logger.Error("failed to list attachments", "error", err, "account_id", accountID) - writeAPIError(w, ErrAuthzAttachListFailed) + writeAPIError(w, ErrAuthzAttachListFailed, h.logger) return } @@ -558,7 +558,7 @@ func (h *AuthzHandler) DeleteAttachment(w http.ResponseWriter, r *http.Request) err := h.service.DetachPolicy(ctx, accountID, attachmentID) if err != nil { h.logger.Error("failed to detach policy", "error", err, "account_id", accountID, "attachment_id", attachmentID) - writeAPIError(w, ErrAuthzAttachDeleteFailed) + writeAPIError(w, ErrAuthzAttachDeleteFailed, h.logger) return } @@ -576,19 +576,19 @@ func (h *AuthzHandler) AddAdmin(w http.ResponseWriter, r *http.Request) { var req AddAdminRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzAdminAddInvalidBody) + writeAPIError(w, ErrAuthzAdminAddInvalidBody, h.logger) return } if req.PrincipalARN == "" { - writeAPIError(w, ErrAuthzAdminAddMissingPrinc) + writeAPIError(w, ErrAuthzAdminAddMissingPrinc, h.logger) return } err := h.service.AddAdmin(ctx, accountID, req.PrincipalARN, callerARN) if err != nil { h.logger.Error("failed to add admin", "error", err, "account_id", accountID, "principal_arn", req.PrincipalARN) - writeAPIError(w, ErrAuthzAdminAddFailed) + writeAPIError(w, ErrAuthzAdminAddFailed, h.logger) return } @@ -607,7 +607,7 @@ func (h *AuthzHandler) ListAdmins(w http.ResponseWriter, r *http.Request) { admins, err := h.service.ListAdmins(ctx, accountID) if err != nil { h.logger.Error("failed to list admins", "error", err, "account_id", accountID) - writeAPIError(w, ErrAuthzAdminListFailed) + writeAPIError(w, ErrAuthzAdminListFailed, h.logger) return } @@ -630,7 +630,7 @@ func (h *AuthzHandler) RemoveAdmin(w http.ResponseWriter, r *http.Request) { err := h.service.RemoveAdmin(ctx, accountID, principalARN) if err != nil { h.logger.Error("failed to remove admin", "error", err, "account_id", accountID, "principal_arn", principalARN) - writeAPIError(w, ErrAuthzAdminDeleteFailed) + writeAPIError(w, ErrAuthzAdminDeleteFailed, h.logger) return } @@ -646,22 +646,22 @@ func (h *AuthzHandler) CheckAuthorization(w http.ResponseWriter, r *http.Request var req CheckAuthorizationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrAuthzCheckInvalidBody) + writeAPIError(w, ErrAuthzCheckInvalidBody, h.logger) return } if req.Principal == "" { - writeAPIError(w, ErrAuthzCheckMissingPrinc) + writeAPIError(w, ErrAuthzCheckMissingPrinc, h.logger) return } if req.Action == "" { - writeAPIError(w, ErrAuthzCheckMissingAction) + writeAPIError(w, ErrAuthzCheckMissingAction, h.logger) return } if req.Resource == "" { - writeAPIError(w, ErrAuthzCheckMissingRes) + writeAPIError(w, ErrAuthzCheckMissingRes, h.logger) return } @@ -679,7 +679,7 @@ func (h *AuthzHandler) CheckAuthorization(w http.ResponseWriter, r *http.Request allowed, err := h.checker.Authorize(ctx, authzReq) if err != nil { h.logger.Error("authorization check failed", "error", err, "account_id", accountID, "principal", req.Principal, "action", req.Action) - writeAPIError(w, ErrAuthzCheckFailed.WithReason(err)) + writeAPIError(w, ErrAuthzCheckFailed.WithReason(err), h.logger) return } diff --git a/platform-api/pkg/handlers/cluster.go b/platform-api/pkg/handlers/cluster.go index 1db3e565..c2b10611 100644 --- a/platform-api/pkg/handlers/cluster.go +++ b/platform-api/pkg/handlers/cluster.go @@ -70,7 +70,7 @@ func (h *ClusterHandler) List(w http.ResponseWriter, r *http.Request) { list, err := h.db.ListClusters(ctx, accountID) if err != nil { h.logger.Error("failed to list clusters", "error", err, "account_id", accountID) - writeAPIError(w, ErrClusterList) + writeAPIError(w, ErrClusterList, h.logger) return } @@ -108,34 +108,34 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { var req types.ClusterCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrClusterCreateInvalidBody) + writeAPIError(w, ErrClusterCreateInvalidBody, h.logger) return } if req.Name == "" || req.Spec == nil { - writeAPIError(w, ErrClusterCreateMissingFields) + writeAPIError(w, ErrClusterCreateMissingFields, h.logger) return } if len(req.Name) > hyperfleetdb.MaxClusterNameLen { - writeAPIError(w, ErrClusterCreateNameTooLong) + writeAPIError(w, ErrClusterCreateNameTooLong, h.logger) return } if errs := h.validator.ValidateCreate(req.Spec, featuregate.Default); errs != nil { - writeAPIError(w, ErrClusterValidation.WithErrors(errs)) + writeAPIError(w, ErrClusterValidation.WithErrors(errs), h.logger) return } existing, err := h.db.ListClusters(ctx, accountID) if err != nil { h.logger.Error("failed to check cluster name uniqueness", "error", err, "account_id", accountID) - writeAPIError(w, ErrClusterCreateNameCheck) + writeAPIError(w, ErrClusterCreateNameCheck, h.logger) return } for i := range existing.Items { if existing.Items[i].Name == req.Name { - writeAPIError(w, ErrClusterCreateNameConflict.WithReason(req.Name)) + writeAPIError(w, ErrClusterCreateNameConflict.WithReason(req.Name), h.logger) return } } @@ -153,7 +153,7 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { cr, err := hyperfleetdb.PlatformCreateToClusterCR(clusterID, accountID, &req) if err != nil { h.logger.Error("failed to convert cluster spec", "error", err, "account_id", accountID) - writeAPIError(w, ErrClusterCreateInvalidSpec) + writeAPIError(w, ErrClusterCreateInvalidSpec, h.logger) return } @@ -173,10 +173,10 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) { } h.logger.Error("failed to create cluster", "error", err, "account_id", accountID) if hyperfleetdb.IsAlreadyExists(err) { - writeAPIError(w, ErrClusterCreateIDExhausted) + writeAPIError(w, ErrClusterCreateIDExhausted, h.logger) return } - writeAPIError(w, ErrClusterCreateFailed) + writeAPIError(w, ErrClusterCreateFailed, h.logger) return } @@ -200,11 +200,11 @@ func (h *ClusterHandler) Get(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrClusterGetNotFound) + writeAPIError(w, ErrClusterGetNotFound, h.logger) return } h.logger.Error("failed to get cluster", "error", err, "account_id", accountID, "cluster_id", clusterID) - writeAPIError(w, ErrClusterGetFailed) + writeAPIError(w, ErrClusterGetFailed, h.logger) return } @@ -222,18 +222,18 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - writeAPIError(w, ErrClusterUpdateInvalidBody) + writeAPIError(w, ErrClusterUpdateInvalidBody, h.logger) return } var req types.ClusterUpdateRequest if err := json.Unmarshal(body, &req); err != nil { - writeAPIError(w, ErrClusterUpdateInvalidBody) + writeAPIError(w, ErrClusterUpdateInvalidBody, h.logger) return } if req.Spec == nil { - writeAPIError(w, ErrClusterUpdateMissingFields) + writeAPIError(w, ErrClusterUpdateMissingFields, h.logger) return } @@ -242,16 +242,16 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrClusterUpdateNotFound) + writeAPIError(w, ErrClusterUpdateNotFound, h.logger) return } h.logger.Error("failed to get cluster for update", "error", err, "account_id", accountID, "cluster_id", clusterID) - writeAPIError(w, ErrClusterUpdateFailed) + writeAPIError(w, ErrClusterUpdateFailed, h.logger) return } if errs := h.validator.ValidateUpdate(req.Spec, &cr.Spec, featuregate.Default); errs != nil { - writeAPIError(w, ErrClusterValidation.WithErrors(errs)) + writeAPIError(w, ErrClusterValidation.WithErrors(errs), h.logger) return } @@ -262,19 +262,19 @@ func (h *ClusterHandler) Update(w http.ResponseWriter, r *http.Request) { Spec json.RawMessage `json:"spec"` } if err := json.Unmarshal(body, &envelope); err != nil { - writeAPIError(w, ErrClusterUpdateInvalidBody) + writeAPIError(w, ErrClusterUpdateInvalidBody, h.logger) return } if err := hyperfleetdb.MergeSpecJSON(&cr.Spec, envelope.Spec); err != nil { h.logger.Error("failed to merge cluster spec", "error", err) - writeAPIError(w, ErrClusterUpdateInvalidSpec) + writeAPIError(w, ErrClusterUpdateInvalidSpec, h.logger) return } if err := h.db.UpdateCluster(ctx, cr); err != nil { h.logger.Error("failed to update cluster", "error", err, "account_id", accountID, "cluster_id", clusterID) - writeAPIError(w, ErrClusterUpdateFailed) + writeAPIError(w, ErrClusterUpdateFailed, h.logger) return } @@ -295,11 +295,11 @@ func (h *ClusterHandler) Delete(w http.ResponseWriter, r *http.Request) { err := h.db.DeleteCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrClusterDeleteNotFound) + writeAPIError(w, ErrClusterDeleteNotFound, h.logger) return } h.logger.Error("failed to delete cluster", "error", err, "account_id", accountID, "cluster_id", clusterID) - writeAPIError(w, ErrClusterDeleteFailed) + writeAPIError(w, ErrClusterDeleteFailed, h.logger) return } @@ -325,11 +325,11 @@ func (h *ClusterHandler) GetStatus(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetCluster(ctx, accountID, clusterID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrClusterStatusNotFound) + writeAPIError(w, ErrClusterStatusNotFound, h.logger) return } h.logger.Error("failed to get cluster status", "error", err, "account_id", accountID, "cluster_id", clusterID) - writeAPIError(w, ErrClusterStatusFailed) + writeAPIError(w, ErrClusterStatusFailed, h.logger) return } diff --git a/platform-api/pkg/handlers/errorcodes.go b/platform-api/pkg/handlers/errorcodes.go index 8a96b029..05dc8647 100644 --- a/platform-api/pkg/handlers/errorcodes.go +++ b/platform-api/pkg/handlers/errorcodes.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log/slog" "net/http" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" @@ -11,9 +12,10 @@ import ( // APIError is an alias for api.APIError so handler code uses the short form. type APIError = api.APIError -// TODO: add a logger parameter so write errors can be logged. -func writeAPIError(w http.ResponseWriter, def APIError) { - _ = api.WriteError(w, def) +func writeAPIError(w http.ResponseWriter, def APIError, logger *slog.Logger) { + if err := api.WriteError(w, def); err != nil { + logger.Error("failed to write error response", "error", err) + } } // Cluster error codes diff --git a/platform-api/pkg/handlers/health.go b/platform-api/pkg/handlers/health.go index c3252f66..1c5a9f7d 100644 --- a/platform-api/pkg/handlers/health.go +++ b/platform-api/pkg/handlers/health.go @@ -1,6 +1,7 @@ package handlers import ( + "log/slog" "net/http" "sync/atomic" @@ -8,17 +9,18 @@ import ( ) // HealthHandler handles health check endpoints -// TODO: add a logger field so write errors can be logged. type HealthHandler struct { - ready *atomic.Bool + ready *atomic.Bool + logger *slog.Logger } // NewHealthHandler creates a new HealthHandler -func NewHealthHandler() *HealthHandler { +func NewHealthHandler(logger *slog.Logger) *HealthHandler { ready := &atomic.Bool{} ready.Store(true) return &HealthHandler{ - ready: ready, + ready: ready, + logger: logger, } } @@ -29,15 +31,21 @@ func (h *HealthHandler) SetReady(ready bool) { // Liveness handles GET /live func (h *HealthHandler) Liveness(w http.ResponseWriter, r *http.Request) { - _ = api.Write(w, http.StatusOK, map[string]string{"status": "ok"}) + if err := api.Write(w, http.StatusOK, map[string]string{"status": "ok"}); err != nil { + h.logger.Error("failed to write response", "error", err) + } } // Readiness handles GET /ready func (h *HealthHandler) Readiness(w http.ResponseWriter, r *http.Request) { if !h.ready.Load() { - _ = api.Write(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable"}) + if err := api.Write(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable"}); err != nil { + h.logger.Error("failed to write response", "error", err) + } return } - _ = api.Write(w, http.StatusOK, map[string]string{"status": "ok"}) + if err := api.Write(w, http.StatusOK, map[string]string{"status": "ok"}); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/handlers/info.go b/platform-api/pkg/handlers/info.go index 194b658d..8a091aff 100644 --- a/platform-api/pkg/handlers/info.go +++ b/platform-api/pkg/handlers/info.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log/slog" "net/http" "os" "strings" @@ -10,12 +11,13 @@ import ( ) // InfoHandler handles the info endpoint -// TODO: add a logger field so write errors can be logged. -type InfoHandler struct{} +type InfoHandler struct { + logger *slog.Logger +} // NewInfoHandler creates a new InfoHandler -func NewInfoHandler() *InfoHandler { - return &InfoHandler{} +func NewInfoHandler(logger *slog.Logger) *InfoHandler { + return &InfoHandler{logger: logger} } // Info handles GET /api/v0/info @@ -26,12 +28,14 @@ func (h *InfoHandler) Info(w http.ResponseWriter, r *http.Request) { // Target Group ARN format: arn:aws:elasticloadbalancing:{region}:{account_id}:targetgroup/{name}/{id} parts := strings.SplitN(tgARN, ":", 6) if len(parts) < 6 || parts[4] == "" { - writeAPIError(w, ErrInfoRegionalAccountUnavailable) + writeAPIError(w, ErrInfoRegionalAccountUnavailable, h.logger) return } accountID := parts[4] arn := fmt.Sprintf("arn:aws:iam::%s:role/LambdaExecutor", accountID) - _ = api.Write(w, http.StatusOK, map[string]string{"arn": arn}) + if err := api.Write(w, http.StatusOK, map[string]string{"arn": arn}); err != nil { + h.logger.Error("failed to write response", "error", err) + } } diff --git a/platform-api/pkg/handlers/info_test.go b/platform-api/pkg/handlers/info_test.go index 3cd558a3..30093f30 100644 --- a/platform-api/pkg/handlers/info_test.go +++ b/platform-api/pkg/handlers/info_test.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "log/slog" "net/http" "net/http/httptest" "testing" @@ -10,7 +11,7 @@ import ( func TestInfoHandler_Success(t *testing.T) { t.Setenv("TARGET_GROUP_ARN", "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/rosa-api/abc123") - handler := NewInfoHandler() + handler := NewInfoHandler(slog.Default()) req := httptest.NewRequest(http.MethodGet, "/api/v0/info", nil) w := httptest.NewRecorder() handler.Info(w, req) @@ -37,7 +38,7 @@ func TestInfoHandler_Success(t *testing.T) { func TestInfoHandler_MissingEnvVar(t *testing.T) { t.Setenv("TARGET_GROUP_ARN", "") - handler := NewInfoHandler() + handler := NewInfoHandler(slog.Default()) req := httptest.NewRequest(http.MethodGet, "/api/v0/info", nil) w := httptest.NewRecorder() handler.Info(w, req) @@ -59,7 +60,7 @@ func TestInfoHandler_MissingEnvVar(t *testing.T) { func TestInfoHandler_MalformedARN(t *testing.T) { t.Setenv("TARGET_GROUP_ARN", "not-a-valid-arn") - handler := NewInfoHandler() + handler := NewInfoHandler(slog.Default()) req := httptest.NewRequest(http.MethodGet, "/api/v0/info", nil) w := httptest.NewRecorder() handler.Info(w, req) diff --git a/platform-api/pkg/handlers/management_cluster.go b/platform-api/pkg/handlers/management_cluster.go index 2a3afc5f..8deaff43 100644 --- a/platform-api/pkg/handlers/management_cluster.go +++ b/platform-api/pkg/handlers/management_cluster.go @@ -53,21 +53,21 @@ func (h *ManagementClusterHandler) Create(w http.ResponseWriter, r *http.Request var req ManagementClusterCreateRequest if r.Body != nil && r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrMCCreateInvalidBody) + writeAPIError(w, ErrMCCreateInvalidBody, h.logger) return } } if req.ID == "" { - writeAPIError(w, ErrMCCreateMissingID) + writeAPIError(w, ErrMCCreateMissingID, h.logger) return } if req.Region == "" { - writeAPIError(w, ErrMCCreateMissingReg) + writeAPIError(w, ErrMCCreateMissingReg, h.logger) return } if req.AccountID == "" { - writeAPIError(w, ErrMCCreateMissingAcct) + writeAPIError(w, ErrMCCreateMissingAcct, h.logger) return } @@ -83,11 +83,11 @@ func (h *ManagementClusterHandler) Create(w http.ResponseWriter, r *http.Request if err := h.db.CreateManagementCluster(ctx, mc); err != nil { if hyperfleetdb.IsAlreadyExists(err) { - writeAPIError(w, ErrMCCreateExists.WithReason(req.ID)) + writeAPIError(w, ErrMCCreateExists.WithReason(req.ID), h.logger) return } h.logger.Error("failed to create management cluster", "error", err) - writeAPIError(w, ErrMCCreateFailed) + writeAPIError(w, ErrMCCreateFailed, h.logger) return } @@ -108,7 +108,7 @@ func (h *ManagementClusterHandler) List(w http.ResponseWriter, r *http.Request) list, err := h.db.ListManagementClusters(ctx) if err != nil { h.logger.Error("failed to list management clusters", "error", err) - writeAPIError(w, ErrMCListFailed) + writeAPIError(w, ErrMCListFailed, h.logger) return } @@ -140,11 +140,11 @@ func (h *ManagementClusterHandler) Get(w http.ResponseWriter, r *http.Request) { mc, err := h.db.GetManagementCluster(ctx, id) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrMCGetNotFound) + writeAPIError(w, ErrMCGetNotFound, h.logger) return } h.logger.Error("failed to get management cluster", "error", err, "id", id) - writeAPIError(w, ErrMCGetFailed) + writeAPIError(w, ErrMCGetFailed, h.logger) return } diff --git a/platform-api/pkg/handlers/nodepool.go b/platform-api/pkg/handlers/nodepool.go index fea95b45..e81274ff 100644 --- a/platform-api/pkg/handlers/nodepool.go +++ b/platform-api/pkg/handlers/nodepool.go @@ -60,7 +60,7 @@ func (h *NodePoolHandler) List(w http.ResponseWriter, r *http.Request) { list, err := h.db.ListNodePools(ctx, accountID, clusterID) if err != nil { h.logger.Error("failed to list nodepools", "error", err, "account_id", accountID) - writeAPIError(w, ErrNodePoolList) + writeAPIError(w, ErrNodePoolList, h.logger) return } @@ -96,27 +96,27 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { var req types.NodePoolCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrNodePoolCreateInvalidBody) + writeAPIError(w, ErrNodePoolCreateInvalidBody, h.logger) return } if req.Name == "" || req.ClusterID == "" || req.Spec == nil { - writeAPIError(w, ErrNodePoolCreateMissingFields) + writeAPIError(w, ErrNodePoolCreateMissingFields, h.logger) return } if errs := h.validator.ValidateCreate(req.Spec, featuregate.Default); errs != nil { - writeAPIError(w, ErrNodePoolValidation.WithErrors(errs)) + writeAPIError(w, ErrNodePoolValidation.WithErrors(errs), h.logger) return } if _, err := h.db.GetCluster(ctx, accountID, req.ClusterID); err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrNodePoolCreateClusterNotFound) + writeAPIError(w, ErrNodePoolCreateClusterNotFound, h.logger) return } h.logger.Error("failed to verify cluster exists", "error", err, "account_id", accountID, "cluster_id", req.ClusterID) - writeAPIError(w, ErrNodePoolCreateClusterCheck) + writeAPIError(w, ErrNodePoolCreateClusterCheck, h.logger) return } @@ -126,17 +126,17 @@ func (h *NodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { cr, err := hyperfleetdb.PlatformCreateToNodePoolCR(accountID, internalPoolID, &req) if err != nil { h.logger.Error("failed to convert nodepool spec", "error", err, "account_id", accountID) - writeAPIError(w, ErrNodePoolCreateInvalidSpec) + writeAPIError(w, ErrNodePoolCreateInvalidSpec, h.logger) return } if err := h.db.CreateNodePool(ctx, accountID, cr); err != nil { h.logger.Error("failed to create nodepool", "error", err, "account_id", accountID) if hyperfleetdb.IsAlreadyExists(err) { - writeAPIError(w, ErrNodePoolCreateNameConflict) + writeAPIError(w, ErrNodePoolCreateNameConflict, h.logger) return } - writeAPIError(w, ErrNodePoolCreateFailed) + writeAPIError(w, ErrNodePoolCreateFailed, h.logger) return } @@ -156,11 +156,11 @@ func (h *NodePoolHandler) Get(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrNodePoolGetNotFound) + writeAPIError(w, ErrNodePoolGetNotFound, h.logger) return } h.logger.Error("failed to get nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - writeAPIError(w, ErrNodePoolGetFailed) + writeAPIError(w, ErrNodePoolGetFailed, h.logger) return } @@ -177,18 +177,18 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - writeAPIError(w, ErrNodePoolUpdateInvalidBody) + writeAPIError(w, ErrNodePoolUpdateInvalidBody, h.logger) return } var req types.NodePoolUpdateRequest if err := json.Unmarshal(body, &req); err != nil { - writeAPIError(w, ErrNodePoolUpdateInvalidBody) + writeAPIError(w, ErrNodePoolUpdateInvalidBody, h.logger) return } if req.Spec == nil { - writeAPIError(w, ErrNodePoolUpdateMissingFields) + writeAPIError(w, ErrNodePoolUpdateMissingFields, h.logger) return } @@ -197,16 +197,16 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrNodePoolUpdateNotFound) + writeAPIError(w, ErrNodePoolUpdateNotFound, h.logger) return } h.logger.Error("failed to get nodepool for update", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - writeAPIError(w, ErrNodePoolUpdateFailed) + writeAPIError(w, ErrNodePoolUpdateFailed, h.logger) return } if errs := h.validator.ValidateUpdate(req.Spec, &cr.Spec, featuregate.Default); errs != nil { - writeAPIError(w, ErrNodePoolValidation.WithErrors(errs)) + writeAPIError(w, ErrNodePoolValidation.WithErrors(errs), h.logger) return } @@ -214,19 +214,19 @@ func (h *NodePoolHandler) Update(w http.ResponseWriter, r *http.Request) { Spec json.RawMessage `json:"spec"` } if err := json.Unmarshal(body, &envelope); err != nil { - writeAPIError(w, ErrNodePoolUpdateInvalidBody) + writeAPIError(w, ErrNodePoolUpdateInvalidBody, h.logger) return } if err := hyperfleetdb.MergeSpecJSON(&cr.Spec, envelope.Spec); err != nil { h.logger.Error("failed to merge nodepool spec", "error", err) - writeAPIError(w, ErrNodePoolUpdateInvalidSpec) + writeAPIError(w, ErrNodePoolUpdateInvalidSpec, h.logger) return } if err := h.db.UpdateNodePool(ctx, cr); err != nil { h.logger.Error("failed to update nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - writeAPIError(w, ErrNodePoolUpdateFailed) + writeAPIError(w, ErrNodePoolUpdateFailed, h.logger) return } @@ -246,11 +246,11 @@ func (h *NodePoolHandler) Delete(w http.ResponseWriter, r *http.Request) { err := h.db.DeleteNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrNodePoolDeleteNotFound) + writeAPIError(w, ErrNodePoolDeleteNotFound, h.logger) return } h.logger.Error("failed to delete nodepool", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - writeAPIError(w, ErrNodePoolDeleteFailed) + writeAPIError(w, ErrNodePoolDeleteFailed, h.logger) return } @@ -275,11 +275,11 @@ func (h *NodePoolHandler) GetStatus(w http.ResponseWriter, r *http.Request) { cr, err := h.db.GetNodePool(ctx, accountID, nodepoolID) if err != nil { if hyperfleetdb.IsNotFound(err) { - writeAPIError(w, ErrNodePoolStatusNotFound) + writeAPIError(w, ErrNodePoolStatusNotFound, h.logger) return } h.logger.Error("failed to get nodepool status", "error", err, "account_id", accountID, "nodepool_id", nodepoolID) - writeAPIError(w, ErrNodePoolStatusFailed) + writeAPIError(w, ErrNodePoolStatusFailed, h.logger) return } diff --git a/platform-api/pkg/handlers/zoa.go b/platform-api/pkg/handlers/zoa.go index aea5ccea..c2f821a6 100644 --- a/platform-api/pkg/handlers/zoa.go +++ b/platform-api/pkg/handlers/zoa.go @@ -78,30 +78,30 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { tmpl, ok := h.registry.Get(action) if !ok { - writeAPIError(w, ErrZoaCreateUnknownAction.WithReason(action)) + writeAPIError(w, ErrZoaCreateUnknownAction.WithReason(action), h.logger) return } var req zoa.CreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAPIError(w, ErrZoaCreateInvalidBody) + writeAPIError(w, ErrZoaCreateInvalidBody, h.logger) return } if req.TargetCluster == "" { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, "", "", "", "") - writeAPIError(w, ErrZoaCreateMissingCluster) + writeAPIError(w, ErrZoaCreateMissingCluster, h.logger) return } if req.Jira == "" { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, req.TargetCluster, "", "", "") - writeAPIError(w, ErrZoaCreateMissingJira) + writeAPIError(w, ErrZoaCreateMissingJira, h.logger) return } if !isValidJiraFormat(req.Jira) { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, req.TargetCluster, "", req.Jira, "") - writeAPIError(w, ErrZoaCreateInvalidJira) + writeAPIError(w, ErrZoaCreateInvalidJira, h.logger) return } @@ -114,7 +114,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if err := validateParams(tmpl, cleanParams); err != nil { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusBadRequest, action, req.TargetCluster, "", req.Jira, "") - writeAPIError(w, ErrZoaCreateInvalidParams.WithReason(err)) + writeAPIError(w, ErrZoaCreateInvalidParams.WithReason(err), h.logger) return } @@ -126,7 +126,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if cooldown > 0 { if err := h.checkWriteCooldown(ctx, accountID, action, req.TargetCluster, cooldown); err != nil { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusTooManyRequests, action, req.TargetCluster, "", req.Jira, "") - writeAPIError(w, ErrZoaCreateCooldown.WithReason(err)) + writeAPIError(w, ErrZoaCreateCooldown.WithReason(err), h.logger) return } } @@ -139,7 +139,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { } if err := h.checkMaxConcurrent(ctx, accountID, req.TargetCluster, maxConcurrent); err != nil { h.recordAudit(ctx, r, accountID, callerARN, extractOperator(callerARN), http.StatusTooManyRequests, action, req.TargetCluster, "", req.Jira, "") - writeAPIError(w, ErrZoaCreateMaxConcurrent.WithReason(err)) + writeAPIError(w, ErrZoaCreateMaxConcurrent.WithReason(err), h.logger) return } } @@ -152,7 +152,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { executedAction = tmpl.DryRunAction dryTmpl, ok := h.registry.Get(executedAction) if !ok { - writeAPIError(w, ErrZoaCreateDryRunError.WithReason(tmpl.DryRunAction)) + writeAPIError(w, ErrZoaCreateDryRunError.WithReason(tmpl.DryRunAction), h.logger) return } tmpl = dryTmpl @@ -185,7 +185,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if err := h.store.Create(ctx, exec); err != nil { h.logger.Error("failed to create execution record", "error", err, "execution_id", execID) - writeAPIError(w, ErrZoaCreateStoreFailed) + writeAPIError(w, ErrZoaCreateStoreFailed, h.logger) return } @@ -207,14 +207,14 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { if err != nil { h.logger.Error("failed to build manifest", "error", err, "execution_id", execID) _ = h.store.UpdateStatus(ctx, execID, zoa.StatusFailed, time.Now().UTC().Format(time.RFC3339), 0) - writeAPIError(w, ErrZoaCreateRenderFailed) + writeAPIError(w, ErrZoaCreateRenderFailed, h.logger) return } if err := h.db.CreateManifest(ctx, zoa.JobNamespace, hfm); err != nil { h.logger.Error("failed to create manifest on hyperfleet-db", "error", err, "execution_id", execID) _ = h.store.UpdateStatus(ctx, execID, zoa.StatusFailed, time.Now().UTC().Format(time.RFC3339), 0) - writeAPIError(w, ErrZoaCreateDispatchFailed) + writeAPIError(w, ErrZoaCreateDispatchFailed, h.logger) return } @@ -223,7 +223,7 @@ func (h *ZoaHandler) Create(w http.ResponseWriter, r *http.Request) { h.logger.Error("failed to update manifest name, cleaning up", "error", err, "execution_id", execID) _ = h.db.DeleteManifest(ctx, zoa.JobNamespace, hfm.Name) _ = h.store.UpdateStatus(ctx, execID, zoa.StatusFailed, time.Now().UTC().Format(time.RFC3339), 0) - writeAPIError(w, ErrZoaCreateStoreSaveFailed) + writeAPIError(w, ErrZoaCreateStoreSaveFailed, h.logger) return } @@ -255,12 +255,12 @@ func (h *ZoaHandler) Get(w http.ResponseWriter, r *http.Request) { exec, err := h.store.Get(ctx, execID) if err != nil { h.logger.Error("failed to get execution", "error", err, "execution_id", execID) - writeAPIError(w, ErrZoaGetStoreFailed) + writeAPIError(w, ErrZoaGetStoreFailed, h.logger) return } if exec == nil { - writeAPIError(w, ErrZoaGetNotFound) + writeAPIError(w, ErrZoaGetNotFound, h.logger) return } @@ -358,7 +358,7 @@ func (h *ZoaHandler) List(w http.ResponseWriter, r *http.Request) { executions, err := h.store.List(ctx, accountID, limit, filter) if err != nil { h.logger.Error("failed to list executions", "error", err, "account_id", accountID) - writeAPIError(w, ErrZoaListStoreFailed) + writeAPIError(w, ErrZoaListStoreFailed, h.logger) return } @@ -446,7 +446,7 @@ func (h *ZoaHandler) Describe(w http.ResponseWriter, r *http.Request) { tmpl, ok := h.registry.Get(action) if !ok { - writeAPIError(w, ErrZoaCreateUnknownAction.WithReason(action)) + writeAPIError(w, ErrZoaCreateUnknownAction.WithReason(action), h.logger) return } @@ -654,7 +654,7 @@ func (h *ZoaHandler) recordAudit(ctx context.Context, r *http.Request, accountID // AuditList handles GET /api/v0/trusted-actions/audit func (h *ZoaHandler) AuditList(w http.ResponseWriter, r *http.Request) { if h.auditStore == nil { - writeAPIError(w, ErrZoaAuditDisabled) + writeAPIError(w, ErrZoaAuditDisabled, h.logger) return } @@ -688,7 +688,7 @@ func (h *ZoaHandler) AuditList(w http.ResponseWriter, r *http.Request) { entries, err := h.auditStore.List(ctx, accountID, limit, filter) if err != nil { h.logger.Error("failed to list audit entries", "error", err) - writeAPIError(w, ErrZoaAuditListFailed) + writeAPIError(w, ErrZoaAuditListFailed, h.logger) return } diff --git a/platform-api/pkg/middleware/account_check.go b/platform-api/pkg/middleware/account_check.go index fb1ea19d..4e4fdc0f 100644 --- a/platform-api/pkg/middleware/account_check.go +++ b/platform-api/pkg/middleware/account_check.go @@ -29,7 +29,7 @@ func (a *AccountCheck) RequireProvisioned(next http.Handler) http.Handler { accountID := GetAccountID(ctx) if accountID == "" { - writeError(w, ErrMissingAccountID) + writeError(w, ErrMissingAccountID, a.logger) return } @@ -43,13 +43,13 @@ func (a *AccountCheck) RequireProvisioned(next http.Handler) http.Handler { provisioned, err := a.authorizer.IsAccountProvisioned(ctx, accountID) if err != nil { a.logger.Error("failed to check account provisioning status", "error", err, "account_id", accountID) - writeError(w, ErrProvisionedCheckFailed) + writeError(w, ErrProvisionedCheckFailed, a.logger) return } if !provisioned { a.logger.Warn("account not provisioned", "account_id", accountID) - writeError(w, ErrAccountNotProvisioned) + writeError(w, ErrAccountNotProvisioned, a.logger) return } diff --git a/platform-api/pkg/middleware/admin_check.go b/platform-api/pkg/middleware/admin_check.go index a818b84d..7c9005a8 100644 --- a/platform-api/pkg/middleware/admin_check.go +++ b/platform-api/pkg/middleware/admin_check.go @@ -29,7 +29,7 @@ func (a *AdminCheck) RequireAdmin(next http.Handler) http.Handler { accountID := GetAccountID(ctx) if accountID == "" { - writeError(w, ErrMissingAccountID) + writeError(w, ErrMissingAccountID, a.logger) return } @@ -41,20 +41,20 @@ func (a *AdminCheck) RequireAdmin(next http.Handler) http.Handler { callerARN := GetCallerARN(ctx) if callerARN == "" { - writeError(w, ErrMissingCallerARN) + writeError(w, ErrMissingCallerARN, a.logger) return } isAdmin, err := a.authorizer.IsAdmin(ctx, accountID, callerARN) if err != nil { a.logger.Error("failed to check admin status", "error", err, "account_id", accountID, "caller_arn", callerARN) - writeError(w, ErrAdminCheckFailed) + writeError(w, ErrAdminCheckFailed, a.logger) return } if !isAdmin { a.logger.Warn("admin access denied", "account_id", accountID, "caller_arn", callerARN) - writeError(w, ErrNotAdmin) + writeError(w, ErrNotAdmin, a.logger) return } diff --git a/platform-api/pkg/middleware/authorization.go b/platform-api/pkg/middleware/authorization.go index ae2efc27..266dcfd6 100644 --- a/platform-api/pkg/middleware/authorization.go +++ b/platform-api/pkg/middleware/authorization.go @@ -31,13 +31,13 @@ func (a *Authorization) RequireAllowedAccount(next http.Handler) http.Handler { if accountID == "" { a.logger.Warn("missing account ID in request") - writeError(w, ErrMissingAccountID) + writeError(w, ErrMissingAccountID, a.logger) return } if _, allowed := a.allowedAccounts[accountID]; !allowed { a.logger.Warn("account not allowed", "account_id", accountID) - writeError(w, ErrAccountNotAllowed) + writeError(w, ErrAccountNotAllowed, a.logger) return } diff --git a/platform-api/pkg/middleware/authorization_test.go b/platform-api/pkg/middleware/authorization_test.go index c25553bc..47b25162 100644 --- a/platform-api/pkg/middleware/authorization_test.go +++ b/platform-api/pkg/middleware/authorization_test.go @@ -420,7 +420,7 @@ func TestAuthorization_WriteError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() - writeError(w, tt.def) + writeError(w, tt.def, slog.Default()) if w.Code != tt.expectedStatus { t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) diff --git a/platform-api/pkg/middleware/authz.go b/platform-api/pkg/middleware/authz.go index 20063663..d45aa47a 100644 --- a/platform-api/pkg/middleware/authz.go +++ b/platform-api/pkg/middleware/authz.go @@ -46,12 +46,12 @@ func (a *Authz) Authorize(next http.Handler) http.Handler { callerARN := GetCallerARN(ctx) if accountID == "" { - writeError(w, ErrMissingAccountID) + writeError(w, ErrMissingAccountID, a.logger) return } if callerARN == "" { - writeError(w, ErrMissingCallerARN) + writeError(w, ErrMissingCallerARN, a.logger) return } @@ -70,10 +70,10 @@ func (a *Authz) Authorize(next http.Handler) http.Handler { a.logger.Error("authorization check failed", "error", err, "account_id", accountID, "action", req.Action) // Check if it's a "not provisioned" error if strings.Contains(err.Error(), "not provisioned") { - writeError(w, ErrAccountNotProvisioned) + writeError(w, ErrAccountNotProvisioned, a.logger) return } - writeError(w, ErrAuthorizationFailed) + writeError(w, ErrAuthorizationFailed, a.logger) return } @@ -84,7 +84,7 @@ func (a *Authz) Authorize(next http.Handler) http.Handler { "action", req.Action, "resource", req.Resource, ) - writeError(w, ErrAccessDenied) + writeError(w, ErrAccessDenied, a.logger) return } diff --git a/platform-api/pkg/middleware/errorcodes.go b/platform-api/pkg/middleware/errorcodes.go index 4448c05d..da3a6aae 100644 --- a/platform-api/pkg/middleware/errorcodes.go +++ b/platform-api/pkg/middleware/errorcodes.go @@ -1,6 +1,7 @@ package middleware import ( + "log/slog" "net/http" "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/api" @@ -9,9 +10,10 @@ import ( // APIError is an alias for api.APIError so middleware code uses the short form. type APIError = api.APIError -// TODO: add a logger parameter so write errors can be logged. -func writeError(w http.ResponseWriter, def APIError) { - _ = api.WriteError(w, def) +func writeError(w http.ResponseWriter, def APIError, logger *slog.Logger) { + if err := api.WriteError(w, def); err != nil { + logger.Error("failed to write error response", "error", err) + } } // Auth middleware error codes diff --git a/platform-api/pkg/middleware/privileged.go b/platform-api/pkg/middleware/privileged.go index 73147a44..30adb65e 100644 --- a/platform-api/pkg/middleware/privileged.go +++ b/platform-api/pkg/middleware/privileged.go @@ -57,7 +57,7 @@ func (p *Privileged) RequirePrivileged(next http.Handler) http.Handler { accountID := GetAccountID(ctx) if accountID == "" { - writeError(w, ErrMissingAccountID) + writeError(w, ErrMissingAccountID, p.logger) return } @@ -68,14 +68,14 @@ func (p *Privileged) RequirePrivileged(next http.Handler) http.Handler { isPrivileged, err = p.authorizer.IsPrivileged(ctx, accountID) if err != nil { p.logger.Error("failed to check privileged status", "error", err, "account_id", accountID) - writeError(w, ErrPrivilegedCheckFailed) + writeError(w, ErrPrivilegedCheckFailed, p.logger) return } } if !isPrivileged { p.logger.Warn("privileged access denied", "account_id", accountID) - writeError(w, ErrNotPrivileged) + writeError(w, ErrNotPrivileged, p.logger) return } diff --git a/platform-api/pkg/server/server.go b/platform-api/pkg/server/server.go index 8ced26c7..ebb28a9d 100644 --- a/platform-api/pkg/server/server.go +++ b/platform-api/pkg/server/server.go @@ -42,8 +42,8 @@ func New(cfg *config.Config, dbClient *hyperfleetdb.Client, logger *slog.Logger) ctx := context.Background() // Create handlers - healthHandler := apphandlers.NewHealthHandler() - infoHandler := apphandlers.NewInfoHandler() + healthHandler := apphandlers.NewHealthHandler(logger) + infoHandler := apphandlers.NewInfoHandler(logger) mgmtClusterHandler := apphandlers.NewManagementClusterHandler(dbClient, logger) clusterHandler := apphandlers.NewClusterHandler(dbClient, cfg.Regional.OIDCIssuerBaseURL, cfg.Regional.DefaultClusterExpiration, logger) nodePoolHandler := apphandlers.NewNodePoolHandler(dbClient, logger) From a4c76f41810057b1a1e65feab22da26a62803ea6 Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 18:45:30 -0300 Subject: [PATCH 5/7] ROSAENG-62084 | fix: marshal-before-commit, fallback 500, and log redaction - WriteError returns error and writes fallback 500 on marshal failure - Write sends centralized 500 via WriteError when json.Marshal fails - fallbackBody pre-computed in errorcodes.go init() to avoid init ordering bug - Redact first half of customer identifiers in success logs --- platform-api/pkg/api/error.go | 14 ++++++++++ platform-api/pkg/api/errorcodes.go | 27 +++++++++++++++++++ platform-api/pkg/api/response.go | 11 +++++--- platform-api/pkg/handlers/accounts.go | 2 +- .../pkg/handlers/management_cluster.go | 2 +- platform-api/pkg/handlers/redact.go | 17 ++++++++++++ platform-api/pkg/handlers/redact_test.go | 24 +++++++++++++++++ 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 platform-api/pkg/api/errorcodes.go create mode 100644 platform-api/pkg/handlers/redact.go create mode 100644 platform-api/pkg/handlers/redact_test.go diff --git a/platform-api/pkg/api/error.go b/platform-api/pkg/api/error.go index c82acee4..a65b88be 100644 --- a/platform-api/pkg/api/error.go +++ b/platform-api/pkg/api/error.go @@ -48,6 +48,7 @@ func WriteError(w http.ResponseWriter, def APIError) error { if err, ok := def.Errors.(error); ok { b, merr := json.Marshal(def.Errors) if merr != nil { + writeFallback(w) return merr } if len(b) == 0 || string(b) == "{}" || string(b) == "null" { @@ -62,6 +63,7 @@ func WriteError(w http.ResponseWriter, def APIError) error { APIError }{Kind: "Error", APIError: def}) if err != nil { + writeFallback(w) return err } w.Header().Set("Content-Type", "application/json") @@ -69,3 +71,15 @@ func WriteError(w http.ResponseWriter, def APIError) error { _, err = w.Write(b) return err } + +// fallbackBody is the pre-marshaled form of ErrInternalMarshal, populated by +// errorcodes.go's init() after ErrInternalMarshal is set. +var fallbackBody []byte + +// writeFallback writes a 500 JSON body when normal serialization has failed. +// It must not call Write or WriteError to avoid circular/recursive calls. +func writeFallback(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(fallbackBody) +} diff --git a/platform-api/pkg/api/errorcodes.go b/platform-api/pkg/api/errorcodes.go new file mode 100644 index 00000000..9b40b8a0 --- /dev/null +++ b/platform-api/pkg/api/errorcodes.go @@ -0,0 +1,27 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +// ErrInternalMarshal is written when response serialization fails before any +// headers have been committed, ensuring the client receives a proper 500 instead +// of an empty default 200. +var ErrInternalMarshal APIError + +func init() { + ErrInternalMarshal = APIError{ + Code: "INTERNAL-001", + HTTPStatus: http.StatusInternalServerError, + Message: "internal server error", + } + var err error + fallbackBody, err = json.Marshal(struct { + Kind string `json:"kind"` + APIError + }{Kind: "Error", APIError: ErrInternalMarshal}) + if err != nil { + panic("api: failed to marshal fallback error body: " + err.Error()) + } +} diff --git a/platform-api/pkg/api/response.go b/platform-api/pkg/api/response.go index a1f53b4e..89829cef 100644 --- a/platform-api/pkg/api/response.go +++ b/platform-api/pkg/api/response.go @@ -2,14 +2,16 @@ package api import ( "encoding/json" + "fmt" "net/http" ) // Write serializes data as a JSON response with the given HTTP status code. // If data is nil, only the status code is written (suitable for 204 No Content). -// Encoding is done before committing headers; if marshal fails the caller can -// still write an error response. If the write itself fails (headers already -// committed), the caller should log and move on — the connection is broken. +// If marshaling fails, a 500 error response is written before the error is +// returned so the client never receives an empty default 200. If the write +// itself fails (headers already committed), the error is returned for the +// caller to log — the connection is already broken. func Write(w http.ResponseWriter, status int, data any) error { if data == nil { w.WriteHeader(status) @@ -17,6 +19,9 @@ func Write(w http.ResponseWriter, status int, data any) error { } b, err := json.Marshal(data) if err != nil { + if werr := WriteError(w, ErrInternalMarshal); werr != nil { + return fmt.Errorf("marshal: %w; write error response: %v", err, werr) + } return err } w.Header().Set("Content-Type", "application/json") diff --git a/platform-api/pkg/handlers/accounts.go b/platform-api/pkg/handlers/accounts.go index 8a42845b..cd1e52da 100644 --- a/platform-api/pkg/handlers/accounts.go +++ b/platform-api/pkg/handlers/accounts.go @@ -86,7 +86,7 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { return } - h.logger.Info("account enabled", "account_id", req.AccountID, "privileged", req.Privileged) + h.logger.Info("account enabled", "account_id", redact(req.AccountID), "privileged", req.Privileged) if err := api.Write(w, http.StatusCreated, AccountResponse{ Kind: "Account", diff --git a/platform-api/pkg/handlers/management_cluster.go b/platform-api/pkg/handlers/management_cluster.go index 8deaff43..35850f38 100644 --- a/platform-api/pkg/handlers/management_cluster.go +++ b/platform-api/pkg/handlers/management_cluster.go @@ -91,7 +91,7 @@ func (h *ManagementClusterHandler) Create(w http.ResponseWriter, r *http.Request return } - h.logger.Info("management cluster created", "id", mc.Name, "account_id", accountID) + h.logger.Info("management cluster created", "id", redact(mc.Name), "account_id", redact(accountID)) if err := api.Write(w, http.StatusCreated, mcToResponse(mc)); err != nil { h.logger.Error("failed to write response", "error", err) diff --git a/platform-api/pkg/handlers/redact.go b/platform-api/pkg/handlers/redact.go new file mode 100644 index 00000000..a5c5a165 --- /dev/null +++ b/platform-api/pkg/handlers/redact.go @@ -0,0 +1,17 @@ +package handlers + +import ( + "math" + "strings" +) + +// redact masks the first half of s with asterisks for safe logging of +// customer identifiers. +func redact(s string) string { + if len(s) == 0 { + return s + } + runes := []rune(s) + half := int(math.Ceil(float64(len(runes)) / 2)) + return strings.Repeat("*", half) + string(runes[half:]) +} diff --git a/platform-api/pkg/handlers/redact_test.go b/platform-api/pkg/handlers/redact_test.go new file mode 100644 index 00000000..703bbe0d --- /dev/null +++ b/platform-api/pkg/handlers/redact_test.go @@ -0,0 +1,24 @@ +package handlers + +import "testing" + +func TestRedact(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"a", "*"}, + {"ab", "*b"}, + {"abcd", "**cd"}, + {"123456789012", "******789012"}, + {"odd", "**d"}, + {"aé", "*é"}, + } + + for _, tt := range tests { + if got := redact(tt.input); got != tt.want { + t.Errorf("redact(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} From 616190485591473ac104378b17b6ec9b4b44f624 Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 20:42:26 -0300 Subject: [PATCH 6/7] ROSAENG-62084 | test: fix rate limit e2e assertions for typed error code Assert status code explicitly and update code field from "429" to "RATE-LIMIT-001" to match the typed error definition. --- test/e2e-api/ratelimit_e2e_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e-api/ratelimit_e2e_test.go b/test/e2e-api/ratelimit_e2e_test.go index 0cf1386f..a467cc7f 100644 --- a/test/e2e-api/ratelimit_e2e_test.go +++ b/test/e2e-api/ratelimit_e2e_test.go @@ -156,11 +156,13 @@ var _ = Describe("Rate Limiting", Ordered, Label("ratelimit"), func() { Expect(err).NotTo(HaveOccurred()) Expect(retryAfter).To(BeNumerically(">=", 1)) + Expect(rateLimitedResp.StatusCode).To(Equal(http.StatusTooManyRequests)) + var body map[string]interface{} err = json.Unmarshal(rateLimitedResp.Body, &body) Expect(err).NotTo(HaveOccurred()) Expect(body["kind"]).To(Equal("Error")) - Expect(body["code"]).To(Equal("429")) + Expect(body["code"]).To(Equal("RATE-LIMIT-001")) Expect(body["reason"]).To(ContainSubstring("Too Many Requests")) }) From a8021d8136ebc073fa4bd0600d6714b8f8923b3b Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Wed, 12 Aug 2026 23:01:10 -0300 Subject: [PATCH 7/7] ROSAENG-62084 | test: align e2e error code assertions with typed error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stale legacy strings with the actual typed codes: - "missing-target-cluster" → ZOA-CREATE-003 - "missing-jira" → ZOA-CREATE-004 - "invalid-jira" → ZOA-CREATE-005 - "invalid-params" → ZOA-CREATE-006 - "write-cooldown" → ZOA-CREATE-007 - "account-exists" → ACCOUNTS-MGMT-CREATE-004 --- test/e2e-cli/cluster_test.go | 4 ++-- test/e2e-sdk/sdk_sanity_test.go | 2 +- test/e2e-zoa/zoa_test.go | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/e2e-cli/cluster_test.go b/test/e2e-cli/cluster_test.go index f35c93ea..b46087cc 100644 --- a/test/e2e-cli/cluster_test.go +++ b/test/e2e-cli/cluster_test.go @@ -360,8 +360,8 @@ var _ = Describe("ROSACTL CLI E2E Tests", Ordered, func() { case http.StatusConflict: var errBody map[string]interface{} Expect(json.Unmarshal(response.Body, &errBody)).To(Succeed()) - Expect(errBody["code"]).To(Equal("account-exists"), "unexpected 409 body: %s", string(response.Body)) - GinkgoWriter.Printf("Customer account %s already enabled (409 account-exists)\n", customerAccountID) + Expect(errBody["code"]).To(Equal("ACCOUNTS-MGMT-CREATE-004"), "unexpected 409 body: %s", string(response.Body)) + GinkgoWriter.Printf("Customer account %s already enabled (409 ACCOUNTS-MGMT-CREATE-004)\n", customerAccountID) default: Fail(fmt.Sprintf("failed to enable customer account: status %d body: %s", response.StatusCode, string(response.Body))) } diff --git a/test/e2e-sdk/sdk_sanity_test.go b/test/e2e-sdk/sdk_sanity_test.go index 83b720d6..0759f357 100644 --- a/test/e2e-sdk/sdk_sanity_test.go +++ b/test/e2e-sdk/sdk_sanity_test.go @@ -284,7 +284,7 @@ var _ = Describe("SDK E2E: cluster and nodepool lifecycle", Ordered, func() { case http.StatusConflict: var body map[string]interface{} Expect(json.Unmarshal(resp.Body, &body)).To(Succeed()) - Expect(body["code"]).To(Equal("account-exists"), + Expect(body["code"]).To(Equal("ACCOUNTS-MGMT-CREATE-004"), "unexpected 409 body: %s", string(resp.Body)) GinkgoWriter.Printf("Customer account %s already registered\n", customerAccountID) default: diff --git a/test/e2e-zoa/zoa_test.go b/test/e2e-zoa/zoa_test.go index 30881122..322d65ec 100644 --- a/test/e2e-zoa/zoa_test.go +++ b/test/e2e-zoa/zoa_test.go @@ -127,7 +127,7 @@ var _ = Describe("ZOA Trusted Actions", Ordered, func() { Reason string `json:"reason"` } Expect(json.Unmarshal(resp.Body, &errResp)).To(Succeed()) - Expect(errResp.Code).To(Equal("missing-target-cluster")) + Expect(errResp.Code).To(Equal("ZOA-CREATE-003")) }) It("should reject request without jira ticket", func() { @@ -142,7 +142,7 @@ var _ = Describe("ZOA Trusted Actions", Ordered, func() { Code string `json:"code"` } Expect(json.Unmarshal(resp.Body, &errResp)).To(Succeed()) - Expect(errResp.Code).To(Equal("missing-jira")) + Expect(errResp.Code).To(Equal("ZOA-CREATE-004")) }) It("should reject request with invalid jira format", func() { @@ -158,7 +158,7 @@ var _ = Describe("ZOA Trusted Actions", Ordered, func() { Code string `json:"code"` } Expect(json.Unmarshal(resp.Body, &errResp)).To(Succeed()) - Expect(errResp.Code).To(Equal("invalid-jira")) + Expect(errResp.Code).To(Equal("ZOA-CREATE-005")) }) It("should reject request with unknown parameters", func() { @@ -177,7 +177,7 @@ var _ = Describe("ZOA Trusted Actions", Ordered, func() { Code string `json:"code"` } Expect(json.Unmarshal(resp.Body, &errResp)).To(Succeed()) - Expect(errResp.Code).To(Equal("invalid-params")) + Expect(errResp.Code).To(Equal("ZOA-CREATE-006")) }) It("should dispatch get_nodes and complete successfully (full wait)", func() { @@ -330,7 +330,7 @@ var _ = Describe("ZOA Trusted Actions", Ordered, func() { Code string `json:"code"` } Expect(json.Unmarshal(resp.Body, &errResp)).To(Succeed()) - Expect(errResp.Code).To(Equal("write-cooldown")) + Expect(errResp.Code).To(Equal("ZOA-CREATE-007")) GinkgoWriter.Printf("Second call correctly rejected: %s\n", errResp.Code) By("Dispatching with force=true (should bypass cooldown)")