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:
asaadbalum 2025-07-06 09:57:21 +03:00 committed by GitHub
parent f90ee781ac
commit d38b24c76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 627 additions and 16 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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{

View File

@ -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()

View File

@ -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)
}

View File

@ -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(),
},
},
))
})
})
})

View File

@ -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
}

View File

@ -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()

View File

@ -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
}

View File

@ -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": {

View File

@ -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": {