diff --git a/api/apiv1/design/api.go b/api/apiv1/design/api.go index 19dd1e2e..e811ae6e 100644 --- a/api/apiv1/design/api.go +++ b/api/apiv1/design/api.go @@ -89,6 +89,7 @@ var _ = g.Service("control-plane", func() { g.Payload(ClusterJoinToken) g.Error("cluster_already_initialized") g.Error("invalid_join_token") + g.Error("invalid_input") g.HTTP(func() { g.POST("/v1/cluster/join") @@ -117,6 +118,7 @@ var _ = g.Service("control-plane", func() { g.Payload(ClusterJoinRequest) g.Error("cluster_not_initialized") g.Error("invalid_join_token") + g.Error("invalid_input") g.HTTP(func() { g.POST("/v1/internal/cluster/join-options") diff --git a/api/apiv1/gen/control_plane/client.go b/api/apiv1/gen/control_plane/client.go index 12894d74..40f90abd 100644 --- a/api/apiv1/gen/control_plane/client.go +++ b/api/apiv1/gen/control_plane/client.go @@ -92,6 +92,7 @@ func (c *Client) InitCluster(ctx context.Context, p *InitClusterRequest) (res *C // JoinCluster may return the following errors: // - "cluster_already_initialized" (type *goa.ServiceError) // - "invalid_join_token" (type *goa.ServiceError) +// - "invalid_input" (type *goa.ServiceError) // - "server_error" (type *goa.ServiceError) // - error: internal error func (c *Client) JoinCluster(ctx context.Context, p *ClusterJoinToken) (err error) { @@ -119,6 +120,7 @@ func (c *Client) GetJoinToken(ctx context.Context) (res *ClusterJoinToken, err e // GetJoinOptions may return the following errors: // - "cluster_not_initialized" (type *goa.ServiceError) // - "invalid_join_token" (type *goa.ServiceError) +// - "invalid_input" (type *goa.ServiceError) // - "server_error" (type *goa.ServiceError) // - error: internal error func (c *Client) GetJoinOptions(ctx context.Context, p *ClusterJoinRequest) (res *ClusterJoinOptions, err error) { diff --git a/api/apiv1/gen/control_plane/service.go b/api/apiv1/gen/control_plane/service.go index 42f2a09e..f316a1ae 100644 --- a/api/apiv1/gen/control_plane/service.go +++ b/api/apiv1/gen/control_plane/service.go @@ -992,16 +992,16 @@ func MakeInvalidJoinToken(err error) *goa.ServiceError { return goa.NewServiceError(err, "invalid_join_token", false, false, false) } -// MakeClusterNotInitialized builds a goa.ServiceError from an error. -func MakeClusterNotInitialized(err error) *goa.ServiceError { - return goa.NewServiceError(err, "cluster_not_initialized", false, false, false) -} - // MakeInvalidInput builds a goa.ServiceError from an error. func MakeInvalidInput(err error) *goa.ServiceError { return goa.NewServiceError(err, "invalid_input", false, false, false) } +// MakeClusterNotInitialized builds a goa.ServiceError from an error. +func MakeClusterNotInitialized(err error) *goa.ServiceError { + return goa.NewServiceError(err, "cluster_not_initialized", false, false, false) +} + // MakeNotFound builds a goa.ServiceError from an error. func MakeNotFound(err error) *goa.ServiceError { return goa.NewServiceError(err, "not_found", false, false, false) diff --git a/api/apiv1/gen/http/control_plane/client/encode_decode.go b/api/apiv1/gen/http/control_plane/client/encode_decode.go index e9ba78db..3675b7ea 100644 --- a/api/apiv1/gen/http/control_plane/client/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/client/encode_decode.go @@ -176,6 +176,7 @@ func EncodeJoinClusterRequest(encoder func(*http.Request) goahttp.Encoder) func( // DecodeJoinClusterResponse may return the following errors: // - "cluster_already_initialized" (type *controlplane.APIError): http.StatusConflict // - "invalid_join_token" (type *controlplane.APIError): http.StatusUnauthorized +// - "invalid_input" (type *controlplane.APIError): http.StatusBadRequest // - "server_error" (type *controlplane.APIError): http.StatusInternalServerError // - error: internal error func DecodeJoinClusterResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { @@ -223,6 +224,20 @@ func DecodeJoinClusterResponse(decoder func(*http.Response) goahttp.Decoder, res return nil, goahttp.ErrValidationError("control-plane", "join-cluster", err) } return nil, NewJoinClusterInvalidJoinToken(&body) + case http.StatusBadRequest: + var ( + body JoinClusterInvalidInputResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("control-plane", "join-cluster", err) + } + err = ValidateJoinClusterInvalidInputResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("control-plane", "join-cluster", err) + } + return nil, NewJoinClusterInvalidInput(&body) case http.StatusInternalServerError: var ( body JoinClusterServerErrorResponseBody @@ -368,6 +383,7 @@ func EncodeGetJoinOptionsRequest(encoder func(*http.Request) goahttp.Encoder) fu // DecodeGetJoinOptionsResponse may return the following errors: // - "cluster_not_initialized" (type *controlplane.APIError): http.StatusConflict // - "invalid_join_token" (type *controlplane.APIError): http.StatusUnauthorized +// - "invalid_input" (type *controlplane.APIError): http.StatusBadRequest // - "server_error" (type *controlplane.APIError): http.StatusInternalServerError // - error: internal error func DecodeGetJoinOptionsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { @@ -428,6 +444,20 @@ func DecodeGetJoinOptionsResponse(decoder func(*http.Response) goahttp.Decoder, return nil, goahttp.ErrValidationError("control-plane", "get-join-options", err) } return nil, NewGetJoinOptionsInvalidJoinToken(&body) + case http.StatusBadRequest: + var ( + body GetJoinOptionsInvalidInputResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("control-plane", "get-join-options", err) + } + err = ValidateGetJoinOptionsInvalidInputResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("control-plane", "get-join-options", err) + } + return nil, NewGetJoinOptionsInvalidInput(&body) case http.StatusInternalServerError: var ( body GetJoinOptionsServerErrorResponseBody diff --git a/api/apiv1/gen/http/control_plane/client/types.go b/api/apiv1/gen/http/control_plane/client/types.go index e8d2f55e..7643fa3f 100644 --- a/api/apiv1/gen/http/control_plane/client/types.go +++ b/api/apiv1/gen/http/control_plane/client/types.go @@ -422,6 +422,16 @@ type JoinClusterInvalidJoinTokenResponseBody struct { Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` } +// JoinClusterInvalidInputResponseBody is the type of the "control-plane" +// service "join-cluster" endpoint HTTP response body for the "invalid_input" +// error. +type JoinClusterInvalidInputResponseBody struct { + // The name of the error. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // The error message. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + // JoinClusterServerErrorResponseBody is the type of the "control-plane" // service "join-cluster" endpoint HTTP response body for the "server_error" // error. @@ -472,6 +482,16 @@ type GetJoinOptionsInvalidJoinTokenResponseBody struct { Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` } +// GetJoinOptionsInvalidInputResponseBody is the type of the "control-plane" +// service "get-join-options" endpoint HTTP response body for the +// "invalid_input" error. +type GetJoinOptionsInvalidInputResponseBody struct { + // The name of the error. + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + // The error message. + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + // GetJoinOptionsServerErrorResponseBody is the type of the "control-plane" // service "get-join-options" endpoint HTTP response body for the // "server_error" error. @@ -2575,6 +2595,17 @@ func NewJoinClusterInvalidJoinToken(body *JoinClusterInvalidJoinTokenResponseBod return v } +// NewJoinClusterInvalidInput builds a control-plane service join-cluster +// endpoint invalid_input error. +func NewJoinClusterInvalidInput(body *JoinClusterInvalidInputResponseBody) *controlplane.APIError { + v := &controlplane.APIError{ + Name: *body.Name, + Message: *body.Message, + } + + return v +} + // NewJoinClusterServerError builds a control-plane service join-cluster // endpoint server_error error. func NewJoinClusterServerError(body *JoinClusterServerErrorResponseBody) *controlplane.APIError { @@ -2651,6 +2682,17 @@ func NewGetJoinOptionsInvalidJoinToken(body *GetJoinOptionsInvalidJoinTokenRespo return v } +// NewGetJoinOptionsInvalidInput builds a control-plane service +// get-join-options endpoint invalid_input error. +func NewGetJoinOptionsInvalidInput(body *GetJoinOptionsInvalidInputResponseBody) *controlplane.APIError { + v := &controlplane.APIError{ + Name: *body.Name, + Message: *body.Message, + } + + return v +} + // NewGetJoinOptionsServerError builds a control-plane service get-join-options // endpoint server_error error. func NewGetJoinOptionsServerError(body *GetJoinOptionsServerErrorResponseBody) *controlplane.APIError { @@ -4478,6 +4520,18 @@ func ValidateJoinClusterInvalidJoinTokenResponseBody(body *JoinClusterInvalidJoi return } +// ValidateJoinClusterInvalidInputResponseBody runs the validations defined on +// join-cluster_invalid_input_response_body +func ValidateJoinClusterInvalidInputResponseBody(body *JoinClusterInvalidInputResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + // ValidateJoinClusterServerErrorResponseBody runs the validations defined on // join-cluster_server_error_response_body func ValidateJoinClusterServerErrorResponseBody(body *JoinClusterServerErrorResponseBody) (err error) { @@ -4538,6 +4592,18 @@ func ValidateGetJoinOptionsInvalidJoinTokenResponseBody(body *GetJoinOptionsInva return } +// ValidateGetJoinOptionsInvalidInputResponseBody runs the validations defined +// on get-join-options_invalid_input_response_body +func ValidateGetJoinOptionsInvalidInputResponseBody(body *GetJoinOptionsInvalidInputResponseBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + // ValidateGetJoinOptionsServerErrorResponseBody runs the validations defined // on get-join-options_server_error_response_body func ValidateGetJoinOptionsServerErrorResponseBody(body *GetJoinOptionsServerErrorResponseBody) (err error) { diff --git a/api/apiv1/gen/http/control_plane/server/encode_decode.go b/api/apiv1/gen/http/control_plane/server/encode_decode.go index d603649b..7cb30152 100644 --- a/api/apiv1/gen/http/control_plane/server/encode_decode.go +++ b/api/apiv1/gen/http/control_plane/server/encode_decode.go @@ -193,6 +193,19 @@ func EncodeJoinClusterError(encoder func(context.Context, http.ResponseWriter) g w.Header().Set("goa-error", res.GoaErrorName()) w.WriteHeader(http.StatusUnauthorized) return enc.Encode(body) + case "invalid_input": + var res *controlplane.APIError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewJoinClusterInvalidInputResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusBadRequest) + return enc.Encode(body) case "server_error": var res *controlplane.APIError errors.As(v, &res) @@ -343,6 +356,19 @@ func EncodeGetJoinOptionsError(encoder func(context.Context, http.ResponseWriter w.Header().Set("goa-error", res.GoaErrorName()) w.WriteHeader(http.StatusUnauthorized) return enc.Encode(body) + case "invalid_input": + var res *controlplane.APIError + errors.As(v, &res) + enc := encoder(ctx, w) + var body any + if formatter != nil { + body = formatter(ctx, res) + } else { + body = NewGetJoinOptionsInvalidInputResponseBody(res) + } + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusBadRequest) + return enc.Encode(body) case "server_error": var res *controlplane.APIError errors.As(v, &res) diff --git a/api/apiv1/gen/http/control_plane/server/types.go b/api/apiv1/gen/http/control_plane/server/types.go index 630edba7..bb70c8da 100644 --- a/api/apiv1/gen/http/control_plane/server/types.go +++ b/api/apiv1/gen/http/control_plane/server/types.go @@ -422,6 +422,16 @@ type JoinClusterInvalidJoinTokenResponseBody struct { Message string `form:"message" json:"message" xml:"message"` } +// JoinClusterInvalidInputResponseBody is the type of the "control-plane" +// service "join-cluster" endpoint HTTP response body for the "invalid_input" +// error. +type JoinClusterInvalidInputResponseBody struct { + // The name of the error. + Name string `form:"name" json:"name" xml:"name"` + // The error message. + Message string `form:"message" json:"message" xml:"message"` +} + // JoinClusterServerErrorResponseBody is the type of the "control-plane" // service "join-cluster" endpoint HTTP response body for the "server_error" // error. @@ -472,6 +482,16 @@ type GetJoinOptionsInvalidJoinTokenResponseBody struct { Message string `form:"message" json:"message" xml:"message"` } +// GetJoinOptionsInvalidInputResponseBody is the type of the "control-plane" +// service "get-join-options" endpoint HTTP response body for the +// "invalid_input" error. +type GetJoinOptionsInvalidInputResponseBody struct { + // The name of the error. + Name string `form:"name" json:"name" xml:"name"` + // The error message. + Message string `form:"message" json:"message" xml:"message"` +} + // GetJoinOptionsServerErrorResponseBody is the type of the "control-plane" // service "get-join-options" endpoint HTTP response body for the // "server_error" error. @@ -2826,6 +2846,16 @@ func NewJoinClusterInvalidJoinTokenResponseBody(res *controlplane.APIError) *Joi return body } +// NewJoinClusterInvalidInputResponseBody builds the HTTP response body from +// the result of the "join-cluster" endpoint of the "control-plane" service. +func NewJoinClusterInvalidInputResponseBody(res *controlplane.APIError) *JoinClusterInvalidInputResponseBody { + body := &JoinClusterInvalidInputResponseBody{ + Name: res.Name, + Message: res.Message, + } + return body +} + // NewJoinClusterServerErrorResponseBody builds the HTTP response body from the // result of the "join-cluster" endpoint of the "control-plane" service. func NewJoinClusterServerErrorResponseBody(res *controlplane.APIError) *JoinClusterServerErrorResponseBody { @@ -2879,6 +2909,16 @@ func NewGetJoinOptionsInvalidJoinTokenResponseBody(res *controlplane.APIError) * return body } +// NewGetJoinOptionsInvalidInputResponseBody builds the HTTP response body from +// the result of the "get-join-options" endpoint of the "control-plane" service. +func NewGetJoinOptionsInvalidInputResponseBody(res *controlplane.APIError) *GetJoinOptionsInvalidInputResponseBody { + body := &GetJoinOptionsInvalidInputResponseBody{ + Name: res.Name, + Message: res.Message, + } + return body +} + // NewGetJoinOptionsServerErrorResponseBody builds the HTTP response body from // the result of the "get-join-options" endpoint of the "control-plane" service. func NewGetJoinOptionsServerErrorResponseBody(res *controlplane.APIError) *GetJoinOptionsServerErrorResponseBody { diff --git a/api/apiv1/gen/http/openapi.json b/api/apiv1/gen/http/openapi.json index 39fb43d2..46f698bc 100644 --- a/api/apiv1/gen/http/openapi.json +++ b/api/apiv1/gen/http/openapi.json @@ -154,6 +154,16 @@ "204": { "description": "No Content response." }, + "400": { + "description": "Bad Request response.", + "schema": { + "$ref": "#/definitions/APIError", + "required": [ + "name", + "message" + ] + } + }, "401": { "description": "Unauthorized response.", "schema": { diff --git a/api/apiv1/gen/http/openapi.yaml b/api/apiv1/gen/http/openapi.yaml index fb7e6a78..c142a679 100644 --- a/api/apiv1/gen/http/openapi.yaml +++ b/api/apiv1/gen/http/openapi.yaml @@ -109,6 +109,13 @@ paths: responses: "204": description: No Content response. + "400": + description: Bad Request response. + schema: + $ref: '#/definitions/APIError' + required: + - name + - message "401": description: Unauthorized response. schema: diff --git a/api/apiv1/gen/http/openapi3.json b/api/apiv1/gen/http/openapi3.json index 06c41324..f19024a7 100644 --- a/api/apiv1/gen/http/openapi3.json +++ b/api/apiv1/gen/http/openapi3.json @@ -305,6 +305,20 @@ "204": { "description": "No Content response." }, + "400": { + "description": "invalid_input: Bad Request response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + }, + "example": { + "message": "A longer description of the error.", + "name": "error_name" + } + } + } + }, "401": { "description": "invalid_join_token: Unauthorized response.", "content": { diff --git a/api/apiv1/gen/http/openapi3.yaml b/api/apiv1/gen/http/openapi3.yaml index bbcf4e82..156b2f93 100644 --- a/api/apiv1/gen/http/openapi3.yaml +++ b/api/apiv1/gen/http/openapi3.yaml @@ -205,6 +205,15 @@ paths: responses: "204": description: No Content response. + "400": + description: 'invalid_input: Bad Request response.' + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + example: + message: A longer description of the error. + name: error_name "401": description: 'invalid_join_token: Unauthorized response.' content: diff --git a/server/internal/api/apiv1/errors.go b/server/internal/api/apiv1/errors.go index 8c64a2ce..1f4d6243 100644 --- a/server/internal/api/apiv1/errors.go +++ b/server/internal/api/apiv1/errors.go @@ -2,6 +2,7 @@ package apiv1 import ( "errors" + "fmt" goa "goa.design/goa/v3/pkg" @@ -42,6 +43,11 @@ var ( ErrOperationNotSupported = newAPIError(errOperationNotSupported, "operation not supported by this control plane server") ) +// ErrHostAlreadyExistsWithID returns an error indicating that a host with the given ID already exists. +func ErrHostAlreadyExistsWithID(hostID string) *api.APIError { + return newAPIError(errInvalidInput, fmt.Sprintf("a host with ID %s already exists in the cluster", hostID)) +} + func apiErr(err error) error { var goaErr *goa.ServiceError var apiErr *api.APIError diff --git a/server/internal/api/apiv1/post_init_handlers.go b/server/internal/api/apiv1/post_init_handlers.go index 85be148c..831d8294 100644 --- a/server/internal/api/apiv1/post_init_handlers.go +++ b/server/internal/api/apiv1/post_init_handlers.go @@ -95,6 +95,11 @@ func (s *PostInitHandlers) GetJoinOptions(ctx context.Context, req *api.ClusterJ return nil, apiErr(err) } + // Validate that the host ID is unique in the cluster + if err := validateHostIDUniqueness(ctx, s.hostSvc, hostID); err != nil { + return nil, apiErr(err) + } + creds, err := s.etcd.AddHost(ctx, etcd.HostCredentialOptions{ HostID: hostID, Hostname: req.Hostname, diff --git a/server/internal/api/apiv1/validate.go b/server/internal/api/apiv1/validate.go index 65610b75..b4474715 100644 --- a/server/internal/api/apiv1/validate.go +++ b/server/internal/api/apiv1/validate.go @@ -1,6 +1,7 @@ package apiv1 import ( + "context" "errors" "fmt" "path/filepath" @@ -11,7 +12,9 @@ import ( api "github.com/pgEdge/control-plane/api/apiv1/gen/control_plane" "github.com/pgEdge/control-plane/server/internal/database" "github.com/pgEdge/control-plane/server/internal/ds" + "github.com/pgEdge/control-plane/server/internal/host" "github.com/pgEdge/control-plane/server/internal/pgbackrest" + "github.com/pgEdge/control-plane/server/internal/storage" "github.com/pgEdge/control-plane/server/internal/utils" ) @@ -428,3 +431,20 @@ func validateIdentifier(ident string, path []string) error { return nil } + +// validateHostIDUniqueness checks that the given host ID does not already exist in the cluster. +// Returns an error if the host ID already exists. +func validateHostIDUniqueness(ctx context.Context, hostSvc *host.Service, hostID string) error { + _, err := hostSvc.GetHost(ctx, hostID) + switch { + case err == nil: + // Host already exists - this is a duplicate + return ErrHostAlreadyExistsWithID(hostID) + case errors.Is(err, storage.ErrNotFound): + // Host doesn't exist - good, host ID is unique + return nil + default: + // Other errors (connection failures, permission errors, etc.) should be propagated + return fmt.Errorf("failed to check for existing host: %w", err) + } +}