feat(ws): add WorkspaceCreate model to backend (#205)
Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
This commit is contained in:
parent
6f147902d7
commit
e5d4e41dfe
|
|
@ -7,21 +7,29 @@ The Kubeflow Workspaces Backend is the _backend for frontend_ (BFF) used by the
|
||||||
> We greatly appreciate any contributions.
|
> We greatly appreciate any contributions.
|
||||||
|
|
||||||
# Building and Deploying
|
# Building and Deploying
|
||||||
|
|
||||||
TBD
|
TBD
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
Run the following command to build the BFF:
|
Run the following command to build the BFF:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
make build
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
After building it, you can run our app with:
|
After building it, you can run our app with:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want to use a different port:
|
If you want to use a different port:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
make run PORT=8000
|
make run PORT=8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
| URL Pattern | Handler | Action |
|
| URL Pattern | Handler | Action |
|
||||||
|
|
@ -43,56 +51,99 @@ make run PORT=8000
|
||||||
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
|
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
|
||||||
|
|
||||||
### Sample local calls
|
### Sample local calls
|
||||||
```
|
|
||||||
|
Healthcheck:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/healthcheck
|
# GET /api/v1/healthcheck
|
||||||
curl -i localhost:4000/api/v1/healthcheck
|
curl -i localhost:4000/api/v1/healthcheck
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
List all Namespaces:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/namespaces
|
# GET /api/v1/namespaces
|
||||||
curl -i localhost:4000/api/v1/namespaces
|
curl -i localhost:4000/api/v1/namespaces
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
List all Workspaces:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/workspaces/
|
# GET /api/v1/workspaces/
|
||||||
curl -i localhost:4000/api/v1/workspaces
|
curl -i localhost:4000/api/v1/workspaces
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
List all Workspaces in a Namespace:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/workspaces/{namespace}
|
# GET /api/v1/workspaces/{namespace}
|
||||||
curl -i localhost:4000/api/v1/workspaces/default
|
curl -i localhost:4000/api/v1/workspaces/default
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
Create a Workspace:
|
||||||
|
|
||||||
|
```shell
|
||||||
# POST /api/v1/workspaces/{namespace}
|
# POST /api/v1/workspaces/{namespace}
|
||||||
curl -X POST http://localhost:4000/api/v1/workspaces/default \
|
curl -X POST http://localhost:4000/api/v1/workspaces/default \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
|
"data": {
|
||||||
"name": "dora",
|
"name": "dora",
|
||||||
|
"kind": "jupyterlab",
|
||||||
"paused": false,
|
"paused": false,
|
||||||
"defer_updates": false,
|
"defer_updates": false,
|
||||||
"kind": "jupyterlab",
|
"pod_template": {
|
||||||
"image_config": "jupyterlab_scipy_190",
|
"pod_metadata": {
|
||||||
"pod_config": "tiny_cpu",
|
"labels": {
|
||||||
"home_volume": "workspace-home-bella",
|
"app": "dora"
|
||||||
"data_volumes": [
|
},
|
||||||
{
|
"annotations": {
|
||||||
"pvc_name": "workspace-data-bella",
|
"app": "dora"
|
||||||
"mount_path": "/data/my-data",
|
}
|
||||||
"read_only": false
|
},
|
||||||
|
"volumes": {
|
||||||
|
"home": "workspace-home-bella",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"pvc_name": "workspace-data-bella",
|
||||||
|
"mount_path": "/data/my-data",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"image_config": "jupyterlab_scipy_190",
|
||||||
|
"pod_config": "tiny_cpu"
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}'
|
}
|
||||||
```
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Get a Workspace:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/workspaces/{namespace}/{name}
|
# GET /api/v1/workspaces/{namespace}/{name}
|
||||||
curl -i localhost:4000/api/v1/workspaces/default/dora
|
curl -i localhost:4000/api/v1/workspaces/default/dora
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
Delete a Workspace:
|
||||||
|
|
||||||
|
```shell
|
||||||
# DELETE /api/v1/workspaces/{namespace}/{name}
|
# DELETE /api/v1/workspaces/{namespace}/{name}
|
||||||
curl -X DELETE localhost:4000/api/v1/workspaces/workspace-test/dora
|
curl -X DELETE localhost:4000/api/v1/workspaces/default/dora
|
||||||
```
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
List all WorkspaceKinds:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/workspacekinds
|
# GET /api/v1/workspacekinds
|
||||||
curl -i localhost:4000/api/v1/workspacekinds
|
curl -i localhost:4000/api/v1/workspacekinds
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
Get a WorkspaceKind:
|
||||||
|
|
||||||
|
```shell
|
||||||
# GET /api/v1/workspacekinds/{name}
|
# GET /api/v1/workspacekinds/{name}
|
||||||
curl -i localhost:4000/api/v1/workspacekinds/jupyterlab
|
curl -i localhost:4000/api/v1/workspacekinds/jupyterlab
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,20 @@ const (
|
||||||
Version = "1.0.0"
|
Version = "1.0.0"
|
||||||
PathPrefix = "/api/v1"
|
PathPrefix = "/api/v1"
|
||||||
|
|
||||||
|
NamespacePathParam = "namespace"
|
||||||
|
ResourceNamePathParam = "name"
|
||||||
|
|
||||||
// healthcheck
|
// healthcheck
|
||||||
HealthCheckPath = PathPrefix + "/healthcheck"
|
HealthCheckPath = PathPrefix + "/healthcheck"
|
||||||
|
|
||||||
// workspaces
|
// workspaces
|
||||||
AllWorkspacesPath = PathPrefix + "/workspaces"
|
AllWorkspacesPath = PathPrefix + "/workspaces"
|
||||||
NamespacePathParam = "namespace"
|
|
||||||
WorkspaceNamePathParam = "name"
|
|
||||||
WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam
|
WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam
|
||||||
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + WorkspaceNamePathParam
|
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + ResourceNamePathParam
|
||||||
|
|
||||||
// workspacekinds
|
// workspacekinds
|
||||||
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
|
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
|
||||||
WorkspaceKindNamePathParam = "name"
|
WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + ResourceNamePathParam
|
||||||
WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + WorkspaceNamePathParam
|
|
||||||
|
|
||||||
// namespaces
|
// namespaces
|
||||||
AllNamespacesPath = PathPrefix + "/namespaces"
|
AllNamespacesPath = PathPrefix + "/namespaces"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ var _ = Describe("HealthCheck Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,20 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Envelope is the body of all requests and responses that contain data.
|
||||||
|
// NOTE: error responses use the ErrorEnvelope type
|
||||||
type Envelope[D any] struct {
|
type Envelope[D any] struct {
|
||||||
|
// TODO: make all declarations of Envelope use pointers for D
|
||||||
Data D `json:"data"`
|
Data D `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteJSON writes a JSON response with the given status code, data, and headers.
|
||||||
func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
|
func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
|
||||||
|
|
||||||
js, err := json.MarshalIndent(data, "", "\t")
|
js, err := json.MarshalIndent(data, "", "\t")
|
||||||
|
|
@ -47,3 +54,42 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecodeJSON decodes the JSON request body into the given value.
|
||||||
|
func (a *App) DecodeJSON(r *http.Request, v any) error {
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(v); err != nil {
|
||||||
|
return fmt.Errorf("error decoding JSON: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (a *App) ValidateContentType(w http.ResponseWriter, r *http.Request, expectedMediaType string) bool {
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
a.unsupportedMediaTypeResponse(w, r, fmt.Errorf("Content-Type header is missing"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
a.badRequestResponse(w, r, fmt.Errorf("error parsing Content-Type header: %w", err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if mediaType != expectedMediaType {
|
||||||
|
a.unsupportedMediaTypeResponse(w, r, fmt.Errorf("unsupported media type: %s, expected: %s", mediaType, expectedMediaType))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocationGetWorkspace returns the GET location (HTTP path) for a workspace resource.
|
||||||
|
func (a *App) LocationGetWorkspace(namespace, name string) string {
|
||||||
|
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespace, 1)
|
||||||
|
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// LogError logs an error message with the request details.
|
||||||
|
func (a *App) LogError(r *http.Request, err error) {
|
||||||
|
var (
|
||||||
|
method = r.Method
|
||||||
|
uri = r.URL.RequestURI()
|
||||||
|
)
|
||||||
|
a.logger.Error(err.Error(), "method", method, "uri", uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogWarn logs a warning message with the request details.
|
||||||
|
func (a *App) LogWarn(r *http.Request, message string) {
|
||||||
|
var (
|
||||||
|
method = r.Method
|
||||||
|
uri = r.URL.RequestURI()
|
||||||
|
)
|
||||||
|
a.logger.Warn(message, "method", method, "uri", uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogInfo logs an info message with the request details.
|
||||||
|
func (a *App) LogInfo(r *http.Request, message string) {
|
||||||
|
var (
|
||||||
|
method = r.Method
|
||||||
|
uri = r.URL.RequestURI()
|
||||||
|
)
|
||||||
|
a.logger.Info(message, "method", method, "uri", uri)
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ import (
|
||||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces"
|
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NamespacesEnvelope Envelope[[]models.Namespace]
|
type NamespaceListEnvelope Envelope[[]models.Namespace]
|
||||||
|
|
||||||
func (a *App) GetNamespacesHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (a *App) GetNamespacesHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
|
||||||
|
|
@ -48,12 +48,6 @@ func (a *App) GetNamespacesHandler(w http.ResponseWriter, r *http.Request, _ htt
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
namespacesEnvelope := NamespacesEnvelope{
|
responseEnvelope := &NamespaceListEnvelope{Data: namespaces}
|
||||||
Data: namespaces,
|
a.dataResponse(w, r, responseEnvelope)
|
||||||
}
|
|
||||||
|
|
||||||
err = a.WriteJSON(w, http.StatusOK, namespacesEnvelope, nil)
|
|
||||||
if err != nil {
|
|
||||||
a.serverErrorResponse(w, r, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,14 @@ var _ = Describe("Namespaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to NamespacesEnvelope")
|
By("unmarshalling the response JSON to NamespaceListEnvelope")
|
||||||
var response NamespacesEnvelope
|
var response NamespaceListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,56 +17,47 @@ limitations under the License.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
errMsgPathParamsInvalid = "path parameters were invalid"
|
||||||
|
errMsgRequestBodyInvalid = "request body was invalid"
|
||||||
|
errMsgKubernetesValidation = "kubernetes validation error (note: .cause.validation_errors[] correspond to the internal k8s object, not the request body)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorEnvelope is the body of all error responses.
|
||||||
|
type ErrorEnvelope struct {
|
||||||
|
Error *HTTPError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPError struct {
|
type HTTPError struct {
|
||||||
StatusCode int `json:"-"`
|
StatusCode int `json:"-"`
|
||||||
ErrorResponse
|
ErrorResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
Cause *ErrorCause `json:"cause,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorEnvelope struct {
|
type ErrorCause struct {
|
||||||
Error *HTTPError `json:"error"`
|
ValidationErrors []ValidationError `json:"validation_errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LogError(r *http.Request, err error) {
|
type ValidationError struct {
|
||||||
var (
|
Type field.ErrorType `json:"type"`
|
||||||
method = r.Method
|
Field string `json:"field"`
|
||||||
uri = r.URL.RequestURI()
|
Message string `json:"message"`
|
||||||
)
|
|
||||||
|
|
||||||
a.logger.Error(err.Error(), "method", method, "uri", uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) LogWarn(r *http.Request, message string) {
|
|
||||||
var (
|
|
||||||
method = r.Method
|
|
||||||
uri = r.URL.RequestURI()
|
|
||||||
)
|
|
||||||
|
|
||||||
a.logger.Warn(message, "method", method, "uri", uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:unused
|
|
||||||
func (a *App) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
httpError := &HTTPError{
|
|
||||||
StatusCode: http.StatusBadRequest,
|
|
||||||
ErrorResponse: ErrorResponse{
|
|
||||||
Code: strconv.Itoa(http.StatusBadRequest),
|
|
||||||
Message: err.Error(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
a.errorResponse(w, r, httpError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errorResponse writes an error response to the client.
|
||||||
func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, httpError *HTTPError) {
|
func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, httpError *HTTPError) {
|
||||||
env := ErrorEnvelope{Error: httpError}
|
env := ErrorEnvelope{Error: httpError}
|
||||||
|
|
||||||
|
|
@ -77,6 +68,7 @@ func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, httpError *H
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP: 500
|
||||||
func (a *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
|
func (a *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
a.LogError(r, err)
|
a.LogError(r, err)
|
||||||
|
|
||||||
|
|
@ -90,28 +82,19 @@ func (a *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err er
|
||||||
a.errorResponse(w, r, httpError)
|
a.errorResponse(w, r, httpError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) {
|
// HTTP: 400
|
||||||
|
func (a *App) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
httpError := &HTTPError{
|
httpError := &HTTPError{
|
||||||
StatusCode: http.StatusNotFound,
|
StatusCode: http.StatusBadRequest,
|
||||||
ErrorResponse: ErrorResponse{
|
ErrorResponse: ErrorResponse{
|
||||||
Code: strconv.Itoa(http.StatusNotFound),
|
Code: strconv.Itoa(http.StatusBadRequest),
|
||||||
Message: "the requested resource could not be found",
|
Message: err.Error(),
|
||||||
},
|
|
||||||
}
|
|
||||||
a.errorResponse(w, r, httpError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
|
|
||||||
httpError := &HTTPError{
|
|
||||||
StatusCode: http.StatusMethodNotAllowed,
|
|
||||||
ErrorResponse: ErrorResponse{
|
|
||||||
Code: strconv.Itoa(http.StatusMethodNotAllowed),
|
|
||||||
Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
a.errorResponse(w, r, httpError)
|
a.errorResponse(w, r, httpError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP: 401
|
||||||
func (a *App) unauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
func (a *App) unauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
||||||
httpError := &HTTPError{
|
httpError := &HTTPError{
|
||||||
StatusCode: http.StatusUnauthorized,
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
|
@ -123,6 +106,7 @@ func (a *App) unauthorizedResponse(w http.ResponseWriter, r *http.Request) {
|
||||||
a.errorResponse(w, r, httpError)
|
a.errorResponse(w, r, httpError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP: 403
|
||||||
func (a *App) forbiddenResponse(w http.ResponseWriter, r *http.Request, msg string) {
|
func (a *App) forbiddenResponse(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
a.LogWarn(r, msg)
|
a.LogWarn(r, msg)
|
||||||
|
|
||||||
|
|
@ -136,18 +120,84 @@ func (a *App) forbiddenResponse(w http.ResponseWriter, r *http.Request, msg stri
|
||||||
a.errorResponse(w, r, httpError)
|
a.errorResponse(w, r, httpError)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:unused
|
// HTTP: 404
|
||||||
func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
|
func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) {
|
||||||
message, err := json.Marshal(errors)
|
httpError := &HTTPError{
|
||||||
if err != nil {
|
StatusCode: http.StatusNotFound,
|
||||||
message = []byte("{}")
|
ErrorResponse: ErrorResponse{
|
||||||
|
Code: strconv.Itoa(http.StatusNotFound),
|
||||||
|
Message: "the requested resource could not be found",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a.errorResponse(w, r, httpError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP: 405
|
||||||
|
func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
|
||||||
|
httpError := &HTTPError{
|
||||||
|
StatusCode: http.StatusMethodNotAllowed,
|
||||||
|
ErrorResponse: ErrorResponse{
|
||||||
|
Code: strconv.Itoa(http.StatusMethodNotAllowed),
|
||||||
|
Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a.errorResponse(w, r, httpError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP: 409
|
||||||
|
func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
httpError := &HTTPError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
ErrorResponse: ErrorResponse{
|
||||||
|
Code: strconv.Itoa(http.StatusConflict),
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a.errorResponse(w, r, httpError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP:415
|
||||||
|
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
httpError := &HTTPError{
|
||||||
|
StatusCode: http.StatusUnsupportedMediaType,
|
||||||
|
ErrorResponse: ErrorResponse{
|
||||||
|
Code: strconv.Itoa(http.StatusUnsupportedMediaType),
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a.errorResponse(w, r, httpError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP: 422
|
||||||
|
func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, msg string, errs field.ErrorList, k8sCauses []metav1.StatusCause) {
|
||||||
|
valErrs := make([]ValidationError, len(errs)+len(k8sCauses))
|
||||||
|
|
||||||
|
// convert field errors to validation errors
|
||||||
|
for i, err := range errs {
|
||||||
|
valErrs[i] = ValidationError{
|
||||||
|
Type: err.Type,
|
||||||
|
Field: err.Field,
|
||||||
|
Message: err.ErrorBody(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert k8s causes to validation errors
|
||||||
|
for i, cause := range k8sCauses {
|
||||||
|
valErrs[i+len(errs)] = ValidationError{
|
||||||
|
Type: field.ErrorType(cause.Type),
|
||||||
|
Field: cause.Field,
|
||||||
|
Message: cause.Message,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpError := &HTTPError{
|
httpError := &HTTPError{
|
||||||
StatusCode: http.StatusUnprocessableEntity,
|
StatusCode: http.StatusUnprocessableEntity,
|
||||||
ErrorResponse: ErrorResponse{
|
ErrorResponse: ErrorResponse{
|
||||||
Code: strconv.Itoa(http.StatusUnprocessableEntity),
|
Code: strconv.Itoa(http.StatusUnprocessableEntity),
|
||||||
Message: string(message),
|
Message: msg,
|
||||||
|
Cause: &ErrorCause{
|
||||||
|
ValidationErrors: valErrs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
a.errorResponse(w, r, httpError)
|
a.errorResponse(w, r, httpError)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// HTTP: 200
|
||||||
|
func (a *App) dataResponse(w http.ResponseWriter, r *http.Request, body any) {
|
||||||
|
err := a.WriteJSON(w, http.StatusOK, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
a.serverErrorResponse(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP: 201
|
||||||
|
func (a *App) createdResponse(w http.ResponseWriter, r *http.Request, body any, location string) {
|
||||||
|
w.Header().Set("Location", location)
|
||||||
|
err := a.WriteJSON(w, http.StatusCreated, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
a.serverErrorResponse(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP: 204
|
||||||
|
func (a *App) deletedResponse(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,8 @@ const (
|
||||||
groupsHeader = "groups-header"
|
groupsHeader = "groups-header"
|
||||||
|
|
||||||
adminUser = "notebooks-admin"
|
adminUser = "notebooks-admin"
|
||||||
|
|
||||||
|
descUnexpectedHTTPStatus = "unexpected HTTP status code, response body: %s"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -125,13 +127,6 @@ var _ = BeforeSuite(func() {
|
||||||
},
|
},
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
By("listing the clusterRoles")
|
|
||||||
clusterRoles := &rbacv1.ClusterRoleList{}
|
|
||||||
Expect(k8sClient.List(ctx, clusterRoles)).To(Succeed())
|
|
||||||
for _, clusterRole := range clusterRoles.Items {
|
|
||||||
fmt.Printf("ClusterRole: %s\n", clusterRole.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
By("setting up the controller manager")
|
By("setting up the controller manager")
|
||||||
k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
|
k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
|
||||||
Scheme: scheme.Scheme,
|
Scheme: scheme.Scheme,
|
||||||
|
|
|
||||||
|
|
@ -18,26 +18,31 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
|
||||||
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
||||||
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
|
||||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
|
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
|
||||||
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
|
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKind]
|
type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
|
||||||
|
|
||||||
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
|
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
|
||||||
|
|
||||||
func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
name := ps.ByName("name")
|
name := ps.ByName(ResourceNamePathParam)
|
||||||
if name == "" {
|
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("workspace kind name is missing"))
|
// validate path parameters
|
||||||
|
var valErrs field.ErrorList
|
||||||
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), name)...)
|
||||||
|
if len(valErrs) > 0 {
|
||||||
|
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,14 +70,8 @@ func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceKindEnvelope := WorkspaceKindEnvelope{
|
responseEnvelope := &WorkspaceKindEnvelope{Data: workspaceKind}
|
||||||
Data: workspaceKind,
|
a.dataResponse(w, r, responseEnvelope)
|
||||||
}
|
|
||||||
|
|
||||||
err = a.WriteJSON(w, http.StatusOK, workspaceKindEnvelope, nil)
|
|
||||||
if err != nil {
|
|
||||||
a.serverErrorResponse(w, r, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
|
@ -94,12 +93,6 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceKindsEnvelope := WorkspaceKindsEnvelope{
|
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
|
||||||
Data: workspaceKinds,
|
a.dataResponse(w, r, responseEnvelope)
|
||||||
}
|
|
||||||
|
|
||||||
err = a.WriteJSON(w, http.StatusOK, workspaceKindsEnvelope, nil)
|
|
||||||
if err != nil {
|
|
||||||
a.serverErrorResponse(w, r, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,14 +117,14 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspaceKindsEnvelope")
|
By("unmarshalling the response JSON to WorkspaceKindListEnvelope")
|
||||||
var response WorkspaceKindsEnvelope
|
var response WorkspaceKindListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -150,7 +150,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
|
|
||||||
It("should retrieve a single WorkspaceKind successfully", func() {
|
It("should retrieve a single WorkspaceKind successfully", func() {
|
||||||
By("creating the HTTP request")
|
By("creating the HTTP request")
|
||||||
path := strings.Replace(WorkspaceKindsByNamePath, ":"+WorkspaceKindNamePathParam, workspaceKind1Name, 1)
|
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, workspaceKind1Name, 1)
|
||||||
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
|
|
||||||
By("executing GetWorkspaceKindHandler")
|
By("executing GetWorkspaceKindHandler")
|
||||||
ps := httprouter.Params{
|
ps := httprouter.Params{
|
||||||
httprouter.Param{Key: WorkspaceKindNamePathParam, Value: workspaceKind1Name},
|
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceKind1Name},
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
a.GetWorkspaceKindHandler(rr, req, ps)
|
a.GetWorkspaceKindHandler(rr, req, ps)
|
||||||
|
|
@ -167,7 +167,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
|
@ -215,14 +215,14 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspaceKindsEnvelope")
|
By("unmarshalling the response JSON to WorkspaceKindListEnvelope")
|
||||||
var response WorkspaceKindsEnvelope
|
var response WorkspaceKindListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -234,7 +234,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
missingWorkspaceKindName := "non-existent-workspacekind"
|
missingWorkspaceKindName := "non-existent-workspacekind"
|
||||||
|
|
||||||
By("creating the HTTP request")
|
By("creating the HTTP request")
|
||||||
path := strings.Replace(WorkspaceKindsByNamePath, ":"+WorkspaceNamePathParam, missingWorkspaceKindName, 1)
|
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, missingWorkspaceKindName, 1)
|
||||||
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
|
|
||||||
By("executing GetWorkspaceKindHandler")
|
By("executing GetWorkspaceKindHandler")
|
||||||
ps := httprouter.Params{
|
ps := httprouter.Params{
|
||||||
httprouter.Param{Key: WorkspaceNamePathParam, Value: missingWorkspaceKindName},
|
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceKindName},
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
a.GetWorkspaceKindHandler(rr, req, ps)
|
a.GetWorkspaceKindHandler(rr, req, ps)
|
||||||
|
|
@ -251,7 +251,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusNotFound))
|
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,34 +17,38 @@ limitations under the License.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
|
||||||
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
||||||
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
|
||||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces"
|
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces"
|
||||||
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
|
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspacesEnvelope Envelope[[]models.Workspace]
|
type WorkspaceCreateEnvelope Envelope[*models.WorkspaceCreate]
|
||||||
|
|
||||||
|
type WorkspaceListEnvelope Envelope[[]models.Workspace]
|
||||||
|
|
||||||
type WorkspaceEnvelope Envelope[models.Workspace]
|
type WorkspaceEnvelope Envelope[models.Workspace]
|
||||||
|
|
||||||
func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
namespace := ps.ByName(NamespacePathParam)
|
namespace := ps.ByName(NamespacePathParam)
|
||||||
if namespace == "" {
|
workspaceName := ps.ByName(ResourceNamePathParam)
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("namespace is nil"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceName := ps.ByName(WorkspaceNamePathParam)
|
// validate path parameters
|
||||||
if workspaceName == "" {
|
var valErrs field.ErrorList
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("workspaceName is nil"))
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
|
||||||
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), workspaceName)...)
|
||||||
|
if len(valErrs) > 0 {
|
||||||
|
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,20 +79,24 @@ func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps htt
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelRegistryRes := WorkspaceEnvelope{
|
responseEnvelope := &WorkspaceEnvelope{Data: workspace}
|
||||||
Data: workspace,
|
a.dataResponse(w, r, responseEnvelope)
|
||||||
}
|
|
||||||
|
|
||||||
err = a.WriteJSON(w, http.StatusOK, modelRegistryRes, nil)
|
|
||||||
if err != nil {
|
|
||||||
a.serverErrorResponse(w, r, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
namespace := ps.ByName(NamespacePathParam)
|
namespace := ps.ByName(NamespacePathParam)
|
||||||
|
|
||||||
|
// validate path parameters
|
||||||
|
// NOTE: namespace is optional, if not provided, we list all workspaces across all namespaces
|
||||||
|
var valErrs field.ErrorList
|
||||||
|
if namespace != "" {
|
||||||
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
|
||||||
|
}
|
||||||
|
if len(valErrs) > 0 {
|
||||||
|
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// =========================== AUTH ===========================
|
// =========================== AUTH ===========================
|
||||||
authPolicies := []*auth.ResourcePolicy{
|
authPolicies := []*auth.ResourcePolicy{
|
||||||
auth.NewResourcePolicy(
|
auth.NewResourcePolicy(
|
||||||
|
|
@ -117,30 +125,48 @@ func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps ht
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelRegistryRes := WorkspacesEnvelope{
|
responseEnvelope := &WorkspaceListEnvelope{Data: workspaces}
|
||||||
Data: workspaces,
|
a.dataResponse(w, r, responseEnvelope)
|
||||||
}
|
|
||||||
|
|
||||||
err = a.WriteJSON(w, http.StatusOK, modelRegistryRes, nil)
|
|
||||||
if err != nil {
|
|
||||||
a.serverErrorResponse(w, r, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
namespace := ps.ByName("namespace")
|
namespace := ps.ByName(NamespacePathParam)
|
||||||
if namespace == "" {
|
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("namespace is missing"))
|
// validate path parameters
|
||||||
|
var valErrs field.ErrorList
|
||||||
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
|
||||||
|
if len(valErrs) > 0 {
|
||||||
|
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceModel := &models.Workspace{}
|
// validate the Content-Type header
|
||||||
if err := json.NewDecoder(r.Body).Decode(workspaceModel); err != nil {
|
if success := a.ValidateContentType(w, r, "application/json"); !success {
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON: %w", err))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
workspaceModel.Namespace = namespace
|
// decode the request body
|
||||||
|
bodyEnvelope := &WorkspaceCreateEnvelope{}
|
||||||
|
err := a.DecodeJSON(r, bodyEnvelope)
|
||||||
|
if err != nil {
|
||||||
|
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate the request body
|
||||||
|
dataPath := field.NewPath("data")
|
||||||
|
if bodyEnvelope.Data == nil {
|
||||||
|
valErrs = field.ErrorList{field.Required(dataPath, "data is required")}
|
||||||
|
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
valErrs = bodyEnvelope.Data.Validate(dataPath)
|
||||||
|
if len(valErrs) > 0 {
|
||||||
|
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceCreate := bodyEnvelope.Data
|
||||||
|
|
||||||
// =========================== AUTH ===========================
|
// =========================== AUTH ===========================
|
||||||
authPolicies := []*auth.ResourcePolicy{
|
authPolicies := []*auth.ResourcePolicy{
|
||||||
|
|
@ -149,7 +175,7 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
|
||||||
&kubefloworgv1beta1.Workspace{
|
&kubefloworgv1beta1.Workspace{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Name: workspaceModel.Name,
|
Name: workspaceCreate.Name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -159,34 +185,38 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
|
||||||
}
|
}
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
createdWorkspace, err := a.repositories.Workspace.CreateWorkspace(r.Context(), workspaceModel)
|
createdWorkspace, err := a.repositories.Workspace.CreateWorkspace(r.Context(), workspaceCreate, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrWorkspaceAlreadyExists) {
|
||||||
|
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: %w", err))
|
a.serverErrorResponse(w, r, fmt.Errorf("error creating workspace: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return created workspace as JSON
|
// calculate the GET location for the created workspace (for the Location header)
|
||||||
workspaceEnvelope := WorkspaceEnvelope{
|
location := a.LocationGetWorkspace(namespace, createdWorkspace.Name)
|
||||||
Data: createdWorkspace,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Location", r.URL.Path)
|
responseEnvelope := &WorkspaceCreateEnvelope{Data: createdWorkspace}
|
||||||
err = a.WriteJSON(w, http.StatusCreated, workspaceEnvelope, nil)
|
a.createdResponse(w, r, responseEnvelope, location)
|
||||||
if err != nil {
|
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("error writing JSON: %w", err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DeleteWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (a *App) DeleteWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
namespace := ps.ByName("namespace")
|
namespace := ps.ByName(NamespacePathParam)
|
||||||
if namespace == "" {
|
workspaceName := ps.ByName(ResourceNamePathParam)
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("namespace is missing"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceName := ps.ByName("name")
|
// validate path parameters
|
||||||
if workspaceName == "" {
|
var valErrs field.ErrorList
|
||||||
a.serverErrorResponse(w, r, fmt.Errorf("workspace name is missing"))
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
|
||||||
|
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), workspaceName)...)
|
||||||
|
if len(valErrs) > 0 {
|
||||||
|
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,5 +247,5 @@ func (a *App) DeleteWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
a.deletedResponse(w, r)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,14 +172,14 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspacesEnvelope")
|
By("unmarshalling the response JSON to WorkspaceListEnvelope")
|
||||||
var response WorkspacesEnvelope
|
var response WorkspaceListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -229,14 +229,14 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspacesEnvelope")
|
By("unmarshalling the response JSON to WorkspaceListEnvelope")
|
||||||
var response WorkspacesEnvelope
|
var response WorkspaceListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -267,7 +267,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
It("should retrieve a single Workspace successfully", func() {
|
It("should retrieve a single Workspace successfully", func() {
|
||||||
By("creating the HTTP request")
|
By("creating the HTTP request")
|
||||||
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
|
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||||
path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName1, 1)
|
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||||
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -277,7 +277,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
By("executing GetWorkspaceHandler")
|
By("executing GetWorkspaceHandler")
|
||||||
ps := httprouter.Params{
|
ps := httprouter.Params{
|
||||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||||
httprouter.Param{Key: WorkspaceNamePathParam, Value: workspaceName1},
|
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
a.GetWorkspaceHandler(rr, req, ps)
|
a.GetWorkspaceHandler(rr, req, ps)
|
||||||
|
|
@ -285,7 +285,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
|
@ -439,14 +439,14 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspacesEnvelope")
|
By("unmarshalling the response JSON to WorkspaceListEnvelope")
|
||||||
var response WorkspacesEnvelope
|
var response WorkspaceListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -492,7 +492,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
It("should retrieve a single invalid Workspace successfully", func() {
|
It("should retrieve a single invalid Workspace successfully", func() {
|
||||||
By("creating the HTTP request")
|
By("creating the HTTP request")
|
||||||
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
|
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||||
path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceMissingWskName, 1)
|
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceMissingWskName, 1)
|
||||||
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -502,7 +502,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
By("executing GetWorkspaceHandler")
|
By("executing GetWorkspaceHandler")
|
||||||
ps := httprouter.Params{
|
ps := httprouter.Params{
|
||||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||||
httprouter.Param{Key: WorkspaceNamePathParam, Value: workspaceMissingWskName},
|
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceMissingWskName},
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
a.GetWorkspaceHandler(rr, req, ps)
|
a.GetWorkspaceHandler(rr, req, ps)
|
||||||
|
|
@ -510,7 +510,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
|
@ -551,14 +551,14 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspacesEnvelope")
|
By("unmarshalling the response JSON to WorkspaceListEnvelope")
|
||||||
var response WorkspacesEnvelope
|
var response WorkspaceListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -587,14 +587,14 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusOK))
|
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("reading the HTTP response body")
|
By("reading the HTTP response body")
|
||||||
body, err := io.ReadAll(rs.Body)
|
body, err := io.ReadAll(rs.Body)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("unmarshalling the response JSON to WorkspacesEnvelope")
|
By("unmarshalling the response JSON to WorkspaceListEnvelope")
|
||||||
var response WorkspacesEnvelope
|
var response WorkspaceListEnvelope
|
||||||
err = json.Unmarshal(body, &response)
|
err = json.Unmarshal(body, &response)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -608,7 +608,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
|
|
||||||
By("creating the HTTP request")
|
By("creating the HTTP request")
|
||||||
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, missingNamespace, 1)
|
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, missingNamespace, 1)
|
||||||
path = strings.Replace(path, ":"+WorkspaceNamePathParam, missingWorkspaceName, 1)
|
path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1)
|
||||||
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -618,7 +618,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
By("executing GetWorkspaceHandler")
|
By("executing GetWorkspaceHandler")
|
||||||
ps := httprouter.Params{
|
ps := httprouter.Params{
|
||||||
httprouter.Param{Key: NamespacePathParam, Value: missingNamespace},
|
httprouter.Param{Key: NamespacePathParam, Value: missingNamespace},
|
||||||
httprouter.Param{Key: WorkspaceNamePathParam, Value: missingWorkspaceName},
|
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName},
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
a.GetWorkspaceHandler(rr, req, ps)
|
a.GetWorkspaceHandler(rr, req, ps)
|
||||||
|
|
@ -626,7 +626,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusNotFound))
|
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -688,51 +688,46 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
|
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
|
||||||
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
|
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
|
||||||
|
|
||||||
By("defining the Workspace to create")
|
By("defining a WorkspaceCreate model")
|
||||||
workspace := &kubefloworgv1beta1.Workspace{
|
workspaceCreate := &models.WorkspaceCreate{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
Name: workspaceName,
|
||||||
Name: workspaceName,
|
Kind: workspaceKindName,
|
||||||
Namespace: namespaceNameCrud,
|
Paused: false,
|
||||||
},
|
DeferUpdates: false,
|
||||||
Spec: kubefloworgv1beta1.WorkspaceSpec{
|
PodTemplate: models.PodTemplateMutate{
|
||||||
Kind: workspaceKindName,
|
PodMetadata: models.PodMetadataMutate{
|
||||||
Paused: ptr.To(false),
|
Labels: map[string]string{
|
||||||
DeferUpdates: ptr.To(false),
|
"app": "dora",
|
||||||
PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{
|
},
|
||||||
PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{
|
Annotations: map[string]string{
|
||||||
Labels: map[string]string{
|
"app": "dora",
|
||||||
"app": "dora",
|
},
|
||||||
},
|
},
|
||||||
Annotations: map[string]string{
|
Volumes: models.PodVolumesMutate{
|
||||||
"app": "dora",
|
Home: ptr.To("my-home-pvc"),
|
||||||
|
Data: []models.PodVolumeMount{
|
||||||
|
{
|
||||||
|
PVCName: "my-data-pvc",
|
||||||
|
MountPath: "/data/1",
|
||||||
|
ReadOnly: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Volumes: kubefloworgv1beta1.WorkspacePodVolumes{
|
},
|
||||||
Home: ptr.To("my-home-pvc"),
|
Options: models.PodTemplateOptionsMutate{
|
||||||
Data: []kubefloworgv1beta1.PodVolumeMount{
|
ImageConfig: "jupyterlab_scipy_180",
|
||||||
{
|
PodConfig: "tiny_cpu",
|
||||||
PVCName: "my-data-pvc",
|
|
||||||
MountPath: "/data",
|
|
||||||
ReadOnly: ptr.To(false),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Options: kubefloworgv1beta1.WorkspacePodOptions{
|
|
||||||
ImageConfig: "jupyterlab_scipy_180",
|
|
||||||
PodConfig: "tiny_cpu",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
workspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)
|
bodyEnvelope := WorkspaceCreateEnvelope{Data: workspaceCreate}
|
||||||
|
|
||||||
By("marshaling the Workspace model to JSON")
|
By("marshaling the WorkspaceCreate model to JSON")
|
||||||
workspaceModelJSON, err := json.Marshal(workspaceModel)
|
bodyEnvelopeJSON, err := json.Marshal(bodyEnvelope)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
By("creating an HTTP request to create the Workspace")
|
By("creating an HTTP request to create the Workspace")
|
||||||
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
||||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(workspaceModelJSON)))
|
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|
@ -752,18 +747,32 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusCreated))
|
Expect(rs.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("getting the created Workspace from the Kubernetes API")
|
By("getting the created Workspace from the Kubernetes API")
|
||||||
createdWorkspace := &kubefloworgv1beta1.Workspace{}
|
createdWorkspace := &kubefloworgv1beta1.Workspace{}
|
||||||
Expect(k8sClient.Get(ctx, workspaceKey, createdWorkspace)).To(Succeed())
|
Expect(k8sClient.Get(ctx, workspaceKey, createdWorkspace)).To(Succeed())
|
||||||
|
|
||||||
By("ensuring the created Workspace matches the expected Workspace")
|
By("ensuring the created Workspace matches the expected Workspace")
|
||||||
Expect(createdWorkspace.Spec).To(BeComparableTo(workspace.Spec))
|
Expect(createdWorkspace.ObjectMeta.Name).To(Equal(workspaceName))
|
||||||
|
Expect(createdWorkspace.Spec.Kind).To(Equal(workspaceKindName))
|
||||||
|
Expect(createdWorkspace.Spec.Paused).To(Equal(&workspaceCreate.Paused))
|
||||||
|
Expect(createdWorkspace.Spec.DeferUpdates).To(Equal(&workspaceCreate.DeferUpdates))
|
||||||
|
Expect(createdWorkspace.Spec.PodTemplate.PodMetadata.Labels).To(Equal(workspaceCreate.PodTemplate.PodMetadata.Labels))
|
||||||
|
Expect(createdWorkspace.Spec.PodTemplate.PodMetadata.Annotations).To(Equal(workspaceCreate.PodTemplate.PodMetadata.Annotations))
|
||||||
|
Expect(createdWorkspace.Spec.PodTemplate.Volumes.Home).To(Equal(workspaceCreate.PodTemplate.Volumes.Home))
|
||||||
|
expected := []kubefloworgv1beta1.PodVolumeMount{
|
||||||
|
{
|
||||||
|
PVCName: workspaceCreate.PodTemplate.Volumes.Data[0].PVCName,
|
||||||
|
MountPath: workspaceCreate.PodTemplate.Volumes.Data[0].MountPath,
|
||||||
|
ReadOnly: &workspaceCreate.PodTemplate.Volumes.Data[0].ReadOnly,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(createdWorkspace.Spec.PodTemplate.Volumes.Data).To(Equal(expected))
|
||||||
|
|
||||||
By("creating an HTTP request to delete the Workspace")
|
By("creating an HTTP request to delete the Workspace")
|
||||||
path = strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
path = strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
||||||
path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName, 1)
|
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName, 1)
|
||||||
req, err = http.NewRequest(http.MethodDelete, path, http.NoBody)
|
req, err = http.NewRequest(http.MethodDelete, path, http.NoBody)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
|
@ -778,7 +787,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
Value: namespaceNameCrud,
|
Value: namespaceNameCrud,
|
||||||
},
|
},
|
||||||
httprouter.Param{
|
httprouter.Param{
|
||||||
Key: WorkspaceNamePathParam,
|
Key: ResourceNamePathParam,
|
||||||
Value: workspaceName,
|
Value: workspaceName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -787,7 +796,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
defer rs.Body.Close()
|
defer rs.Body.Close()
|
||||||
|
|
||||||
By("verifying the HTTP response status code")
|
By("verifying the HTTP response status code")
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusNoContent))
|
Expect(rs.StatusCode).To(Equal(http.StatusNoContent), descUnexpectedHTTPStatus, rr.Body.String())
|
||||||
|
|
||||||
By("ensuring the Workspace has been deleted")
|
By("ensuring the Workspace has been deleted")
|
||||||
deletedWorkspace := &kubefloworgv1beta1.Workspace{}
|
deletedWorkspace := &kubefloworgv1beta1.Workspace{}
|
||||||
|
|
@ -795,5 +804,9 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(apierrors.IsNotFound(err)).To(BeTrue())
|
Expect(apierrors.IsNotFound(err)).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: test when fail to create a Workspace when:
|
||||||
|
// - body payload invalid (missing name/kind, and/or non RCF 1123 name)
|
||||||
|
// - invalid namespace HTTP path parameter (also test for other API handlers)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusCausesFromAPIStatus extracts status causes from a Kubernetes apierrors.APIStatus validation error.
|
||||||
|
// NOTE: we use this to convert them to our own validation error format.
|
||||||
|
func StatusCausesFromAPIStatus(err error) []metav1.StatusCause {
|
||||||
|
// ensure this is an APIStatus error
|
||||||
|
var statusErr apierrors.APIStatus
|
||||||
|
if status, ok := err.(apierrors.APIStatus); ok || errors.As(err, &status) {
|
||||||
|
statusErr = status
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// only attempt to extract if the status is a validation error
|
||||||
|
errStatus := statusErr.Status()
|
||||||
|
if errStatus.Reason != metav1.StatusReasonInvalid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errStatus.Details.Causes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFieldIsNotEmpty validates a field is not empty.
|
||||||
|
func ValidateFieldIsNotEmpty(path *field.Path, value string) field.ErrorList {
|
||||||
|
var errs field.ErrorList
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
errs = append(errs, field.Required(path, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFieldIsDNS1123Subdomain validates a field contains an RCF 1123 DNS subdomain.
|
||||||
|
// USED FOR:
|
||||||
|
// - names of: Workspaces, WorkspaceKinds, Secrets, etc.
|
||||||
|
func ValidateFieldIsDNS1123Subdomain(path *field.Path, value string) field.ErrorList {
|
||||||
|
var errs field.ErrorList
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
errs = append(errs, field.Required(path, ""))
|
||||||
|
} else {
|
||||||
|
failures := validation.IsDNS1123Subdomain(value)
|
||||||
|
if len(failures) > 0 {
|
||||||
|
errs = append(errs, field.Invalid(path, value, strings.Join(failures, "; ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFieldIsDNS1123Label validates a field contains an RCF 1123 DNS label.
|
||||||
|
// USED FOR:
|
||||||
|
// - names of: Namespaces, Services, etc.
|
||||||
|
func ValidateFieldIsDNS1123Label(path *field.Path, value string) field.ErrorList {
|
||||||
|
var errs field.ErrorList
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
errs = append(errs, field.Required(path, ""))
|
||||||
|
} else {
|
||||||
|
failures := validation.IsDNS1123Label(value)
|
||||||
|
if len(failures) > 0 {
|
||||||
|
errs = append(errs, field.Invalid(path, value, strings.Join(failures, "; ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
@ -82,14 +82,10 @@ func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubef
|
||||||
dataVolumes := make([]PodVolumeInfo, len(ws.Spec.PodTemplate.Volumes.Data))
|
dataVolumes := make([]PodVolumeInfo, len(ws.Spec.PodTemplate.Volumes.Data))
|
||||||
for i := range ws.Spec.PodTemplate.Volumes.Data {
|
for i := range ws.Spec.PodTemplate.Volumes.Data {
|
||||||
volume := ws.Spec.PodTemplate.Volumes.Data[i]
|
volume := ws.Spec.PodTemplate.Volumes.Data[i]
|
||||||
readOnly := false
|
|
||||||
if volume.ReadOnly != nil {
|
|
||||||
readOnly = *volume.ReadOnly
|
|
||||||
}
|
|
||||||
dataVolumes[i] = PodVolumeInfo{
|
dataVolumes[i] = PodVolumeInfo{
|
||||||
PvcName: volume.PVCName,
|
PVCName: volume.PVCName,
|
||||||
MountPath: volume.MountPath,
|
MountPath: volume.MountPath,
|
||||||
ReadOnly: readOnly,
|
ReadOnly: ptr.Deref(volume.ReadOnly, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +144,7 @@ func buildHomeVolume(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.W
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PodVolumeInfo{
|
return &PodVolumeInfo{
|
||||||
PvcName: *ws.Spec.PodTemplate.Volumes.Home,
|
PVCName: *ws.Spec.PodTemplate.Volumes.Home,
|
||||||
MountPath: homeMountPath,
|
MountPath: homeMountPath,
|
||||||
// the home volume is ~always~ read-write
|
// the home volume is ~always~ read-write
|
||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package workspaces
|
||||||
|
|
||||||
|
import (
|
||||||
|
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||||
|
"k8s.io/utils/ptr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewWorkspaceCreateModelFromWorkspace creates WorkspaceCreate model from a Workspace object.
|
||||||
|
func NewWorkspaceCreateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *WorkspaceCreate {
|
||||||
|
podLabels := make(map[string]string)
|
||||||
|
podAnnotations := make(map[string]string)
|
||||||
|
if ws.Spec.PodTemplate.PodMetadata != nil {
|
||||||
|
// NOTE: we copy the maps to avoid creating a reference to the original maps.
|
||||||
|
for k, v := range ws.Spec.PodTemplate.PodMetadata.Labels {
|
||||||
|
podLabels[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range ws.Spec.PodTemplate.PodMetadata.Annotations {
|
||||||
|
podAnnotations[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataVolumes := make([]PodVolumeMount, len(ws.Spec.PodTemplate.Volumes.Data))
|
||||||
|
for i, v := range ws.Spec.PodTemplate.Volumes.Data {
|
||||||
|
dataVolumes[i] = PodVolumeMount{
|
||||||
|
PVCName: v.PVCName,
|
||||||
|
MountPath: v.MountPath,
|
||||||
|
ReadOnly: ptr.Deref(v.ReadOnly, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceCreateModel := &WorkspaceCreate{
|
||||||
|
Name: ws.Name,
|
||||||
|
Kind: ws.Spec.Kind,
|
||||||
|
Paused: ptr.Deref(ws.Spec.Paused, false),
|
||||||
|
DeferUpdates: ptr.Deref(ws.Spec.DeferUpdates, false),
|
||||||
|
PodTemplate: PodTemplateMutate{
|
||||||
|
PodMetadata: PodMetadataMutate{
|
||||||
|
Labels: podLabels,
|
||||||
|
Annotations: podAnnotations,
|
||||||
|
},
|
||||||
|
Volumes: PodVolumesMutate{
|
||||||
|
Home: ws.Spec.PodTemplate.Volumes.Home,
|
||||||
|
Data: dataVolumes,
|
||||||
|
},
|
||||||
|
Options: PodTemplateOptionsMutate{
|
||||||
|
ImageConfig: ws.Spec.PodTemplate.Options.ImageConfig,
|
||||||
|
PodConfig: ws.Spec.PodTemplate.Options.PodConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceCreateModel
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,8 @@ limitations under the License.
|
||||||
|
|
||||||
package workspaces
|
package workspaces
|
||||||
|
|
||||||
|
// Workspace represents a workspace in the system, and is returned by GET and LIST operations.
|
||||||
|
// NOTE: this type is not used for CREATE or UPDATE operations, see WorkspaceCreate
|
||||||
type Workspace struct {
|
type Workspace struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
|
|
@ -68,7 +70,7 @@ type PodVolumes struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PodVolumeInfo struct {
|
type PodVolumeInfo struct {
|
||||||
PvcName string `json:"pvc_name"`
|
PVCName string `json:"pvc_name"`
|
||||||
MountPath string `json:"mount_path"`
|
MountPath string `json:"mount_path"`
|
||||||
ReadOnly bool `json:"read_only"`
|
ReadOnly bool `json:"read_only"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package workspaces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
|
||||||
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkspaceCreate is used to create a new workspace.
|
||||||
|
type WorkspaceCreate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
DeferUpdates bool `json:"defer_updates"`
|
||||||
|
PodTemplate PodTemplateMutate `json:"pod_template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodTemplateMutate struct {
|
||||||
|
PodMetadata PodMetadataMutate `json:"pod_metadata"`
|
||||||
|
Volumes PodVolumesMutate `json:"volumes"`
|
||||||
|
Options PodTemplateOptionsMutate `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodMetadataMutate struct {
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
Annotations map[string]string `json:"annotations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodVolumesMutate struct {
|
||||||
|
Home *string `json:"home,omitempty"`
|
||||||
|
Data []PodVolumeMount `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodVolumeMount struct {
|
||||||
|
PVCName string `json:"pvc_name"`
|
||||||
|
MountPath string `json:"mount_path"`
|
||||||
|
ReadOnly bool `json:"read_only,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PodTemplateOptionsMutate struct {
|
||||||
|
ImageConfig string `json:"image_config"`
|
||||||
|
PodConfig string `json:"pod_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the WorkspaceCreate struct.
|
||||||
|
// NOTE: we only do basic validation, more complex validation is done by the controller when attempting to create the workspace.
|
||||||
|
func (w *WorkspaceCreate) Validate(prefix *field.Path) []*field.Error {
|
||||||
|
var errs []*field.Error
|
||||||
|
|
||||||
|
// validate the workspace name
|
||||||
|
namePath := prefix.Child("name")
|
||||||
|
errs = append(errs, helper.ValidateFieldIsDNS1123Subdomain(namePath, w.Name)...)
|
||||||
|
|
||||||
|
// validate the workspace kind name
|
||||||
|
kindPath := prefix.Child("kind")
|
||||||
|
errs = append(errs, helper.ValidateFieldIsDNS1123Subdomain(kindPath, w.Kind)...)
|
||||||
|
|
||||||
|
// validate the image config
|
||||||
|
imageConfigPath := prefix.Child("pod_template", "options", "image_config")
|
||||||
|
errs = append(errs, helper.ValidateFieldIsNotEmpty(imageConfigPath, w.PodTemplate.Options.ImageConfig)...)
|
||||||
|
|
||||||
|
// validate the pod config
|
||||||
|
podConfigPath := prefix.Child("pod_template", "options", "pod_config")
|
||||||
|
errs = append(errs, helper.ValidateFieldIsNotEmpty(podConfigPath, w.PodTemplate.Options.PodConfig)...)
|
||||||
|
|
||||||
|
// validate the data volumes
|
||||||
|
dataVolumesPath := prefix.Child("pod_template", "volumes", "data")
|
||||||
|
for i, volume := range w.PodTemplate.Volumes.Data {
|
||||||
|
volumePath := dataVolumesPath.Index(i)
|
||||||
|
errs = append(errs, helper.ValidateFieldIsNotEmpty(volumePath.Child("pvc_name"), volume.PVCName)...)
|
||||||
|
errs = append(errs, helper.ValidateFieldIsNotEmpty(volumePath.Child("mount_path"), volume.MountPath)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
@ -36,9 +36,6 @@ func NewNamespaceRepository(cl client.Client) *NamespaceRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.Namespace, error) {
|
func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.Namespace, error) {
|
||||||
|
|
||||||
// TODO(ederign): Implement subject access review here to fetch only
|
|
||||||
// namespaces that "kubeflow-userid" has access to
|
|
||||||
namespaceList := &v1.NamespaceList{}
|
namespaceList := &v1.NamespaceList{}
|
||||||
err := r.client.List(ctx, namespaceList, &client.ListOptions{})
|
err := r.client.List(ctx, namespaceList, &client.ListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ func (r *WorkspaceKindRepository) GetWorkspaceKind(ctx context.Context, name str
|
||||||
|
|
||||||
func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKind, error) {
|
func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKind, error) {
|
||||||
workspaceKindList := &kubefloworgv1beta1.WorkspaceKindList{}
|
workspaceKindList := &kubefloworgv1beta1.WorkspaceKindList{}
|
||||||
|
|
||||||
err := r.client.List(ctx, workspaceKindList)
|
err := r.client.List(ctx, workspaceKindList)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
|
var ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
|
||||||
var ErrRefWorkspaceKindNotExists = fmt.Errorf("referenced WorkspaceKind does not exist")
|
var ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists")
|
||||||
|
|
||||||
type WorkspaceRepository struct {
|
type WorkspaceRepository struct {
|
||||||
client client.Client
|
client client.Client
|
||||||
|
|
@ -126,51 +126,41 @@ func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.Wo
|
||||||
return workspacesModels, nil
|
return workspacesModels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceModel *models.Workspace) (models.Workspace, error) {
|
func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceCreate *models.WorkspaceCreate, namespace string) (*models.WorkspaceCreate, error) {
|
||||||
// get workspace kind
|
|
||||||
// NOTE: if the referenced workspace kind does not exist,
|
|
||||||
// we throw an error because the api would reject the workspace creation
|
|
||||||
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
|
|
||||||
workspaceKindName := workspaceModel.WorkspaceKind.Name
|
|
||||||
if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil {
|
|
||||||
if apierrors.IsNotFound(err) {
|
|
||||||
return models.Workspace{}, fmt.Errorf("%w: %s", ErrRefWorkspaceKindNotExists, workspaceKindName)
|
|
||||||
}
|
|
||||||
return models.Workspace{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// get data volumes from workspace model
|
// get data volumes from workspace model
|
||||||
dataVolumeMounts := make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceModel.PodTemplate.Volumes.Data))
|
dataVolumeMounts := make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceCreate.PodTemplate.Volumes.Data))
|
||||||
for i, dataVolume := range workspaceModel.PodTemplate.Volumes.Data {
|
for i, dataVolume := range workspaceCreate.PodTemplate.Volumes.Data {
|
||||||
dataVolumeMounts[i] = kubefloworgv1beta1.PodVolumeMount{
|
dataVolumeMounts[i] = kubefloworgv1beta1.PodVolumeMount{
|
||||||
PVCName: dataVolume.PvcName,
|
PVCName: dataVolume.PVCName,
|
||||||
MountPath: dataVolume.MountPath,
|
MountPath: dataVolume.MountPath,
|
||||||
ReadOnly: ptr.To(dataVolume.ReadOnly),
|
ReadOnly: ptr.To(dataVolume.ReadOnly),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// define workspace object from model
|
// define workspace object from model
|
||||||
|
workspaceName := workspaceCreate.Name
|
||||||
|
workspaceKindName := workspaceCreate.Kind
|
||||||
workspace := &kubefloworgv1beta1.Workspace{
|
workspace := &kubefloworgv1beta1.Workspace{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: workspaceModel.Name,
|
Name: workspaceName,
|
||||||
Namespace: workspaceModel.Namespace,
|
Namespace: namespace,
|
||||||
},
|
},
|
||||||
Spec: kubefloworgv1beta1.WorkspaceSpec{
|
Spec: kubefloworgv1beta1.WorkspaceSpec{
|
||||||
Paused: &workspaceModel.Paused,
|
Paused: &workspaceCreate.Paused,
|
||||||
DeferUpdates: &workspaceModel.DeferUpdates,
|
DeferUpdates: &workspaceCreate.DeferUpdates,
|
||||||
Kind: workspaceKindName,
|
Kind: workspaceKindName,
|
||||||
PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{
|
PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{
|
||||||
PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{
|
PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{
|
||||||
Labels: workspaceModel.PodTemplate.PodMetadata.Labels,
|
Labels: workspaceCreate.PodTemplate.PodMetadata.Labels,
|
||||||
Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations,
|
Annotations: workspaceCreate.PodTemplate.PodMetadata.Annotations,
|
||||||
},
|
},
|
||||||
Volumes: kubefloworgv1beta1.WorkspacePodVolumes{
|
Volumes: kubefloworgv1beta1.WorkspacePodVolumes{
|
||||||
Home: &workspaceModel.PodTemplate.Volumes.Home.PvcName,
|
Home: workspaceCreate.PodTemplate.Volumes.Home,
|
||||||
Data: dataVolumeMounts,
|
Data: dataVolumeMounts,
|
||||||
},
|
},
|
||||||
Options: kubefloworgv1beta1.WorkspacePodOptions{
|
Options: kubefloworgv1beta1.WorkspacePodOptions{
|
||||||
ImageConfig: workspaceModel.PodTemplate.Options.ImageConfig.Current.Id,
|
ImageConfig: workspaceCreate.PodTemplate.Options.ImageConfig,
|
||||||
PodConfig: workspaceModel.PodTemplate.Options.PodConfig.Current.Id,
|
PodConfig: workspaceCreate.PodTemplate.Options.PodConfig,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -178,11 +168,19 @@ func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceMode
|
||||||
|
|
||||||
// create workspace
|
// create workspace
|
||||||
if err := r.client.Create(ctx, workspace); err != nil {
|
if err := r.client.Create(ctx, workspace); err != nil {
|
||||||
return models.Workspace{}, err
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
return nil, ErrWorkspaceAlreadyExists
|
||||||
|
}
|
||||||
|
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 model
|
// convert the created workspace to a WorkspaceCreate model
|
||||||
createdWorkspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)
|
createdWorkspaceModel := models.NewWorkspaceCreateModelFromWorkspace(workspace)
|
||||||
|
|
||||||
return createdWorkspaceModel, nil
|
return createdWorkspaceModel, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue