feat(ws): backend api to create wsk with YAML (#434)
* feat(ws): Notebooks 2.0 // Backend // API that allows frontend to upload a YAML file containing a full new WorkspaceKind definition Signed-off-by: Asaad Balum <asaad.balum@gmail.com> * mathew: 1 Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> --------- Signed-off-by: Asaad Balum <asaad.balum@gmail.com> Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> Co-authored-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
This commit is contained in:
parent
f90ee781ac
commit
d38b24c76c
|
@ -17,11 +17,13 @@ limitations under the License.
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
@ -35,6 +37,9 @@ const (
|
|||
Version = "1.0.0"
|
||||
PathPrefix = "/api/v1"
|
||||
|
||||
MediaTypeJson = "application/json"
|
||||
MediaTypeYaml = "application/yaml"
|
||||
|
||||
NamespacePathParam = "namespace"
|
||||
ResourceNamePathParam = "name"
|
||||
|
||||
|
@ -59,12 +64,13 @@ const (
|
|||
)
|
||||
|
||||
type App struct {
|
||||
Config *config.EnvConfig
|
||||
logger *slog.Logger
|
||||
repositories *repositories.Repositories
|
||||
Scheme *runtime.Scheme
|
||||
RequestAuthN authenticator.Request
|
||||
RequestAuthZ authorizer.Authorizer
|
||||
Config *config.EnvConfig
|
||||
logger *slog.Logger
|
||||
repositories *repositories.Repositories
|
||||
Scheme *runtime.Scheme
|
||||
StrictYamlSerializer runtime.Serializer
|
||||
RequestAuthN authenticator.Request
|
||||
RequestAuthZ authorizer.Authorizer
|
||||
}
|
||||
|
||||
// NewApp creates a new instance of the app
|
||||
|
@ -72,13 +78,21 @@ func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme
|
|||
|
||||
// TODO: log the configuration on startup
|
||||
|
||||
// get a serializer for Kubernetes YAML
|
||||
codecFactory := serializer.NewCodecFactory(scheme)
|
||||
yamlSerializerInfo, found := runtime.SerializerInfoForMediaType(codecFactory.SupportedMediaTypes(), runtime.ContentTypeYAML)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("unable to find Kubernetes serializer for media type: %s", runtime.ContentTypeYAML)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
Config: cfg,
|
||||
logger: logger,
|
||||
repositories: repositories.NewRepositories(cl),
|
||||
Scheme: scheme,
|
||||
RequestAuthN: reqAuthN,
|
||||
RequestAuthZ: reqAuthZ,
|
||||
Config: cfg,
|
||||
logger: logger,
|
||||
repositories: repositories.NewRepositories(cl),
|
||||
Scheme: scheme,
|
||||
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
|
||||
RequestAuthN: reqAuthN,
|
||||
RequestAuthZ: reqAuthZ,
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
@ -106,6 +120,7 @@ func (a *App) Routes() http.Handler {
|
|||
// workspacekinds
|
||||
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
|
||||
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
|
||||
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
|
||||
|
||||
// swagger
|
||||
router.GET(SwaggerPath, a.GetSwaggerHandler)
|
||||
|
|
|
@ -18,6 +18,7 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
@ -46,7 +47,7 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt
|
|||
w.Header()[key] = value
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Type", MediaTypeJson)
|
||||
w.WriteHeader(status)
|
||||
_, err = w.Write(js)
|
||||
if err != nil {
|
||||
|
@ -61,11 +62,21 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
|
|||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(v); err != nil {
|
||||
// NOTE: we don't wrap this error so we can unpack it in the caller
|
||||
if a.IsMaxBytesError(err) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("error decoding JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsMaxBytesError checks if the error is an instance of http.MaxBytesError.
|
||||
func (a *App) IsMaxBytesError(err error) bool {
|
||||
var maxBytesError *http.MaxBytesError
|
||||
return errors.As(err, &maxBytesError)
|
||||
}
|
||||
|
||||
// ValidateContentType validates the Content-Type header of the request.
|
||||
// If this method returns false, the request has been handled and the caller should return immediately.
|
||||
// If this method returns true, the request has the correct Content-Type.
|
||||
|
@ -94,3 +105,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
|
|||
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
|
||||
return path
|
||||
}
|
||||
|
||||
// LocationGetWorkspaceKind returns the GET location (HTTP path) for a workspace kind resource.
|
||||
func (a *App) LocationGetWorkspaceKind(name string) string {
|
||||
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, name, 1)
|
||||
return path
|
||||
}
|
||||
|
|
|
@ -156,6 +156,18 @@ func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error
|
|||
a.errorResponse(w, r, httpError)
|
||||
}
|
||||
|
||||
// HTTP:413
|
||||
func (a *App) requestEntityTooLargeResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
httpError := &HTTPError{
|
||||
StatusCode: http.StatusRequestEntityTooLarge,
|
||||
ErrorResponse: ErrorResponse{
|
||||
Code: strconv.Itoa(http.StatusRequestEntityTooLarge),
|
||||
Message: err.Error(),
|
||||
},
|
||||
}
|
||||
a.errorResponse(w, r, httpError)
|
||||
}
|
||||
|
||||
// HTTP:415
|
||||
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||
httpError := &HTTPError{
|
||||
|
|
|
@ -150,6 +150,7 @@ var _ = BeforeSuite(func() {
|
|||
By("creating the application")
|
||||
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
|
||||
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
|
|
|
@ -18,11 +18,15 @@ package api
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
|
||||
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
||||
|
@ -31,6 +35,9 @@ import (
|
|||
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
|
||||
)
|
||||
|
||||
// TODO: this should wrap the models.WorkspaceKindUpdate once we implement the update handler
|
||||
type WorkspaceKindCreateEnvelope Envelope[*models.WorkspaceKind]
|
||||
|
||||
type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
|
||||
|
||||
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
|
||||
|
@ -123,3 +130,95 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
|
|||
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
|
||||
a.dataResponse(w, r, responseEnvelope)
|
||||
}
|
||||
|
||||
// CreateWorkspaceKindHandler creates a new workspace kind.
|
||||
//
|
||||
// @Summary Create workspace kind
|
||||
// @Description Creates a new workspace kind.
|
||||
// @Tags workspacekinds
|
||||
// @Accept application/yaml
|
||||
// @Produce json
|
||||
// @Param body body string true "Kubernetes YAML manifest of a WorkspaceKind"
|
||||
// @Success 201 {object} WorkspaceKindEnvelope "WorkspaceKind created successfully"
|
||||
// @Failure 400 {object} ErrorEnvelope "Bad Request."
|
||||
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
|
||||
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create WorkspaceKind."
|
||||
// @Failure 409 {object} ErrorEnvelope "Conflict. WorkspaceKind with the same name already exists."
|
||||
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large.""
|
||||
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
|
||||
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
|
||||
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
|
||||
// @Router /workspacekinds [post]
|
||||
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
|
||||
// validate the Content-Type header
|
||||
if success := a.ValidateContentType(w, r, MediaTypeYaml); !success {
|
||||
return
|
||||
}
|
||||
|
||||
// decode the request body
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
if a.IsMaxBytesError(err) {
|
||||
a.requestEntityTooLargeResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
a.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
|
||||
err = runtime.DecodeInto(a.StrictYamlSerializer, bodyBytes, workspaceKind)
|
||||
if err != nil {
|
||||
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// validate the workspace kind
|
||||
// NOTE: we only do basic validation so we know it's safe to send to the Kubernetes API server
|
||||
// comprehensive validation will be done by Kubernetes
|
||||
// NOTE: checking the name field is non-empty also verifies that the workspace kind is not nil/empty
|
||||
var valErrs field.ErrorList
|
||||
wskNamePath := field.NewPath("metadata", "name")
|
||||
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(wskNamePath, workspaceKind.Name)...)
|
||||
if len(valErrs) > 0 {
|
||||
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// =========================== AUTH ===========================
|
||||
authPolicies := []*auth.ResourcePolicy{
|
||||
auth.NewResourcePolicy(
|
||||
auth.ResourceVerbCreate,
|
||||
&kubefloworgv1beta1.WorkspaceKind{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: workspaceKind.Name,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
if success := a.requireAuth(w, r, authPolicies); !success {
|
||||
return
|
||||
}
|
||||
// ============================================================
|
||||
|
||||
createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
|
||||
a.conflictResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
if apierrors.IsInvalid(err) {
|
||||
causes := helper.StatusCausesFromAPIStatus(err)
|
||||
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
|
||||
return
|
||||
}
|
||||
a.serverErrorResponse(w, r, fmt.Errorf("error creating workspace kind: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// calculate the GET location for the created workspace kind (for the Location header)
|
||||
location := a.LocationGetWorkspaceKind(createdWorkspaceKind.Name)
|
||||
|
||||
responseEnvelope := &WorkspaceKindCreateEnvelope{Data: createdWorkspaceKind}
|
||||
a.createdResponse(w, r, responseEnvelope, location)
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -31,6 +32,7 @@ import (
|
|||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
|
||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
|
||||
)
|
||||
|
@ -254,4 +256,255 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
|||
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel.
|
||||
// therefore, we run them using the `Serial` Ginkgo decorator.
|
||||
Context("when creating a WorkspaceKind", Serial, func() {
|
||||
|
||||
var newWorkspaceKindName = "wsk-create-test"
|
||||
var validYAML []byte
|
||||
|
||||
BeforeEach(func() {
|
||||
validYAML = []byte(fmt.Sprintf(`
|
||||
apiVersion: kubeflow.org/v1beta1
|
||||
kind: WorkspaceKind
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
spawner:
|
||||
displayName: "JupyterLab Notebook"
|
||||
description: "A Workspace which runs JupyterLab in a Pod"
|
||||
icon:
|
||||
url: "https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"
|
||||
logo:
|
||||
url: "https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg"
|
||||
podTemplate:
|
||||
serviceAccount:
|
||||
name: "default-editor"
|
||||
volumeMounts:
|
||||
home: "/home/jovyan"
|
||||
options:
|
||||
imageConfig:
|
||||
spawner:
|
||||
default: "jupyterlab_scipy_190"
|
||||
values:
|
||||
- id: "jupyterlab_scipy_190"
|
||||
spawner:
|
||||
displayName: "jupyter-scipy:v1.9.0"
|
||||
description: "JupyterLab, with SciPy Packages"
|
||||
spec:
|
||||
image: "ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0"
|
||||
imagePullPolicy: "IfNotPresent"
|
||||
ports:
|
||||
- id: "jupyterlab"
|
||||
displayName: "JupyterLab"
|
||||
port: 8888
|
||||
protocol: "HTTP"
|
||||
podConfig:
|
||||
spawner:
|
||||
default: "tiny_cpu"
|
||||
values:
|
||||
- id: "tiny_cpu"
|
||||
spawner:
|
||||
displayName: "Tiny CPU"
|
||||
description: "Pod with 0.1 CPU, 128 Mb RAM"
|
||||
spec:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
`, newWorkspaceKindName))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
By("cleaning up the created WorkspaceKind")
|
||||
wsk := &kubefloworgv1beta1.WorkspaceKind{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: newWorkspaceKindName,
|
||||
},
|
||||
}
|
||||
_ = k8sClient.Delete(ctx, wsk)
|
||||
})
|
||||
|
||||
It("should succeed when creating a WorkspaceKind with valid YAML", func() {
|
||||
By("creating the HTTP request")
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing CreateWorkspaceKindHandler")
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("verifying the resource was created in the cluster")
|
||||
createdWsk := &kubefloworgv1beta1.WorkspaceKind{}
|
||||
err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, createdWsk)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should fail to create a WorkspaceKind with no name in the YAML", func() {
|
||||
missingNameYAML := []byte(`
|
||||
apiVersion: kubeflow.org/v1beta1
|
||||
kind: WorkspaceKind
|
||||
metadata: {}
|
||||
spec:
|
||||
spawner:
|
||||
displayName: "This will fail"`)
|
||||
|
||||
By("creating the HTTP request")
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(missingNameYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing CreateWorkspaceKindHandler")
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("decoding the error response")
|
||||
var response ErrorEnvelope
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("verifying the error message indicates a validation failure")
|
||||
Expect(response.Error.Cause.ValidationErrors).To(BeComparableTo(
|
||||
[]ValidationError{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
Message: field.ErrorTypeRequired.String(),
|
||||
},
|
||||
},
|
||||
))
|
||||
})
|
||||
|
||||
It("should fail to create a WorkspaceKind that already exists", func() {
|
||||
By("creating the HTTP request")
|
||||
req1, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req1.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req1.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing CreateWorkspaceKindHandler for the first time")
|
||||
rr1 := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{})
|
||||
rs1 := rr1.Result()
|
||||
defer rs1.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code for the first request")
|
||||
Expect(rs1.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr1.Body.String())
|
||||
|
||||
By("creating a second HTTP request")
|
||||
req2, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req2.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req2.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing CreateWorkspaceKindHandler for the second time")
|
||||
rr2 := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{})
|
||||
rs2 := rr2.Result()
|
||||
defer rs2.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code for the second request")
|
||||
Expect(rs2.StatusCode).To(Equal(http.StatusConflict), descUnexpectedHTTPStatus, rr1.Body.String())
|
||||
})
|
||||
|
||||
It("should fail when the YAML has the wrong kind", func() {
|
||||
wrongKindYAML := []byte(`
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: i-am-the-wrong-kind`)
|
||||
|
||||
By("creating the HTTP request")
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing CreateWorkspaceKindHandler")
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
Expect(rr.Body.String()).To(ContainSubstring("unable to decode /v1, Kind=Pod into *v1beta1.WorkspaceKind"))
|
||||
})
|
||||
|
||||
It("should fail when the body is not valid YAML", func() {
|
||||
notYAML := []byte(`this is not yaml {`)
|
||||
|
||||
By("creating the HTTP request")
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(notYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing CreateWorkspaceKindHandler")
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("decoding the error response")
|
||||
var response ErrorEnvelope
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("verifying the error message indicates a decoding failure")
|
||||
Expect(response.Error.Message).To(ContainSubstring("error decoding request body: couldn't get version/kind; json parse error"))
|
||||
})
|
||||
|
||||
It("should fail for an empty YAML object", func() {
|
||||
invalidYAML := []byte("{}")
|
||||
|
||||
By("creating the HTTP request")
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", MediaTypeYaml)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
By("executing the CreateWorkspaceKindHandler")
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("decoding the error response")
|
||||
var response ErrorEnvelope
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("verifying the error message indicates a validation failure")
|
||||
Expect(response.Error.Cause.ValidationErrors).To(BeComparableTo(
|
||||
[]ValidationError{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
Message: field.ErrorTypeRequired.String(),
|
||||
},
|
||||
},
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -176,6 +176,8 @@ func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps ht
|
|||
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
|
||||
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace."
|
||||
// @Failure 409 {object} ErrorEnvelope "Conflict. Workspace with the same name already exists."
|
||||
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
|
||||
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
|
||||
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
|
||||
// @Router /workspaces/{namespace} [post]
|
||||
func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
|
@ -190,7 +192,7 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
|
|||
}
|
||||
|
||||
// validate the Content-Type header
|
||||
if success := a.ValidateContentType(w, r, "application/json"); !success {
|
||||
if success := a.ValidateContentType(w, r, MediaTypeJson); !success {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -198,6 +200,10 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
|
|||
bodyEnvelope := &WorkspaceCreateEnvelope{}
|
||||
err := a.DecodeJSON(r, bodyEnvelope)
|
||||
if err != nil {
|
||||
if a.IsMaxBytesError(err) {
|
||||
a.requestEntityTooLargeResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -729,7 +729,7 @@ var _ = Describe("Workspaces Handler", func() {
|
|||
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Type", MediaTypeJson)
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
@ -845,7 +845,7 @@ var _ = Describe("Workspaces Handler", func() {
|
|||
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Type", MediaTypeJson)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
)
|
||||
|
||||
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
|
||||
var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists")
|
||||
|
||||
type WorkspaceKindRepository struct {
|
||||
client client.Client
|
||||
|
@ -68,3 +69,26 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
|
|||
|
||||
return workspaceKindsModels, nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) (*models.WorkspaceKind, error) {
|
||||
// create workspace kind
|
||||
if err := r.client.Create(ctx, workspaceKind); err != nil {
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
return nil, ErrWorkspaceKindAlreadyExists
|
||||
}
|
||||
if apierrors.IsInvalid(err) {
|
||||
// NOTE: we don't wrap this error so we can unpack it in the caller
|
||||
// and extract the validation errors returned by the Kubernetes API server
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert the created workspace to a WorkspaceKindUpdate model
|
||||
//
|
||||
// TODO: this function should return the WorkspaceKindUpdate model, once the update WSK api is implemented
|
||||
//
|
||||
createdWorkspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind)
|
||||
|
||||
return &createdWorkspaceKindModel, nil
|
||||
}
|
||||
|
|
|
@ -122,6 +122,86 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new workspace kind.",
|
||||
"consumes": [
|
||||
"application/yaml"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"workspacekinds"
|
||||
],
|
||||
"summary": "Create workspace kind",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Kubernetes YAML manifest of a WorkspaceKind",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "WorkspaceKind created successfully",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized. Authentication is required.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden. User does not have permission to create WorkspaceKind.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict. WorkspaceKind with the same name already exists.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large. The request body is too large.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"415": {
|
||||
"description": "Unsupported Media Type. Content-Type header is not correct.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity. Validation error.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspacekinds/{name}": {
|
||||
|
@ -352,6 +432,18 @@ const docTemplate = `{
|
|||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large. The request body is too large.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"415": {
|
||||
"description": "Unsupported Media Type. Content-Type header is not correct.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||
"schema": {
|
||||
|
|
|
@ -120,6 +120,86 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new workspace kind.",
|
||||
"consumes": [
|
||||
"application/yaml"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"workspacekinds"
|
||||
],
|
||||
"summary": "Create workspace kind",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Kubernetes YAML manifest of a WorkspaceKind",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "WorkspaceKind created successfully",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized. Authentication is required.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden. User does not have permission to create WorkspaceKind.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict. WorkspaceKind with the same name already exists.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large. The request body is too large.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"415": {
|
||||
"description": "Unsupported Media Type. Content-Type header is not correct.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity. Validation error.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspacekinds/{name}": {
|
||||
|
@ -350,6 +430,18 @@
|
|||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large. The request body is too large.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"415": {
|
||||
"description": "Unsupported Media Type. Content-Type header is not correct.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||
"schema": {
|
||||
|
|
Loading…
Reference in New Issue