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>
This commit is contained in:
parent
981527855a
commit
175e5a5841
|
|
@ -56,6 +56,9 @@ const (
|
||||||
// swagger
|
// swagger
|
||||||
SwaggerPath = PathPrefix + "/swagger/*any"
|
SwaggerPath = PathPrefix + "/swagger/*any"
|
||||||
SwaggerDocPath = PathPrefix + "/swagger/doc.json"
|
SwaggerDocPath = PathPrefix + "/swagger/doc.json"
|
||||||
|
|
||||||
|
// YAML manifest content type
|
||||||
|
ContentTypeYAMLManifest = "application/vnd.kubeflow-notebooks.manifest+yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
|
|
@ -106,6 +109,7 @@ func (a *App) Routes() http.Handler {
|
||||||
// workspacekinds
|
// workspacekinds
|
||||||
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
|
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
|
||||||
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
|
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
|
||||||
|
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
|
||||||
|
|
||||||
// swagger
|
// swagger
|
||||||
router.GET(SwaggerPath, a.GetSwaggerHandler)
|
router.GET(SwaggerPath, a.GetSwaggerHandler)
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
|
||||||
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
|
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
|
||||||
return path
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,17 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"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/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
|
||||||
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
||||||
|
|
@ -35,6 +41,74 @@ type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
|
||||||
|
|
||||||
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
|
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
|
||||||
|
|
||||||
|
// cachedRestrictedScheme holds the pre-built runtime scheme that only knows about WorkspaceKind.
|
||||||
|
var cachedRestrictedScheme *runtime.Scheme
|
||||||
|
|
||||||
|
// cachedUniversalDeserializer holds the pre-built universal deserializer for the restricted scheme.
|
||||||
|
var cachedUniversalDeserializer runtime.Decoder
|
||||||
|
|
||||||
|
// cachedExpectedGVK holds the expected GVK for WorkspaceKind, derived programmatically.
|
||||||
|
var cachedExpectedGVK schema.GroupVersionKind
|
||||||
|
|
||||||
|
// init builds the restricted scheme and deserializer once at package initialization time.
|
||||||
|
func init() {
|
||||||
|
restrictedScheme := runtime.NewScheme()
|
||||||
|
if err := kubefloworgv1beta1.AddToScheme(restrictedScheme); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to add WorkspaceKind types to restricted scheme: %v", err))
|
||||||
|
}
|
||||||
|
cachedRestrictedScheme = restrictedScheme
|
||||||
|
|
||||||
|
codecs := serializer.NewCodecFactory(cachedRestrictedScheme)
|
||||||
|
cachedUniversalDeserializer = codecs.UniversalDeserializer()
|
||||||
|
|
||||||
|
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
|
||||||
|
gvks, _, err := cachedRestrictedScheme.ObjectKinds(workspaceKind)
|
||||||
|
if err != nil || len(gvks) == 0 {
|
||||||
|
panic(fmt.Sprintf("failed to derive GVK from WorkspaceKind type: %v", err))
|
||||||
|
}
|
||||||
|
cachedExpectedGVK = gvks[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWorkspaceKindManifestBody reads and decodes a YAML request body into a WorkspaceKind object
|
||||||
|
// using Kubernetes runtime validation to ensure maximum security.
|
||||||
|
func (a *App) ParseWorkspaceKindManifestBody(w http.ResponseWriter, r *http.Request) (*kubefloworgv1beta1.WorkspaceKind, bool) {
|
||||||
|
// NOTE: A server-level middleware should enforce a max body size.
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
a.badRequestResponse(w, r, fmt.Errorf("failed to read request body: %w", err))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := r.Body.Close(); err != nil {
|
||||||
|
a.LogWarn(r, fmt.Sprintf("failed to close request body: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
a.badRequestResponse(w, r, errors.New("request body is empty"))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, gvk, err := cachedUniversalDeserializer.Decode(body, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
a.badRequestResponse(w, r, fmt.Errorf("failed to decode YAML manifest: %w", err))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if gvk.Kind != cachedExpectedGVK.Kind || gvk.Version != cachedExpectedGVK.Version {
|
||||||
|
a.badRequestResponse(w, r, fmt.Errorf("invalid GVK: expected %s, got %s", cachedExpectedGVK.Kind, gvk.Kind))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceKind, ok := obj.(*kubefloworgv1beta1.WorkspaceKind)
|
||||||
|
if !ok {
|
||||||
|
a.badRequestResponse(w, r, fmt.Errorf("unexpected type: got %T, want *WorkspaceKind", obj))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceKind, true
|
||||||
|
}
|
||||||
|
|
||||||
// GetWorkspaceKindHandler retrieves a specific workspace kind by name.
|
// GetWorkspaceKindHandler retrieves a specific workspace kind by name.
|
||||||
//
|
//
|
||||||
// @Summary Get workspace kind
|
// @Summary Get workspace kind
|
||||||
|
|
@ -123,3 +197,73 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
|
||||||
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
|
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
|
||||||
a.dataResponse(w, r, responseEnvelope)
|
a.dataResponse(w, r, responseEnvelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateWorkspaceKindHandler creates a new workspace kind from a YAML manifest.
|
||||||
|
|
||||||
|
// @Summary Create workspace kind
|
||||||
|
// @Description Creates a new workspace kind from a raw YAML manifest.
|
||||||
|
// @Tags workspacekinds
|
||||||
|
// @Accept application/vnd.kubeflow-notebooks.manifest+yaml
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body string true "Raw YAML manifest of the WorkspaceKind"
|
||||||
|
// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details."
|
||||||
|
// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing."
|
||||||
|
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
|
||||||
|
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind."
|
||||||
|
// @Failure 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists."
|
||||||
|
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
|
||||||
|
// @Failure 500 {object} ErrorEnvelope "Internal server error."
|
||||||
|
// @Router /workspacekinds [post]
|
||||||
|
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
|
// === Content-Type check ===
|
||||||
|
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Read body and parse with secure parser ===
|
||||||
|
newWsk, ok := a.ParseWorkspaceKindManifestBody(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Validate name exists in YAML ===
|
||||||
|
if newWsk.Name == "" {
|
||||||
|
a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTH ===
|
||||||
|
authPolicies := []*auth.ResourcePolicy{
|
||||||
|
auth.NewResourcePolicy(
|
||||||
|
auth.ResourceVerbCreate,
|
||||||
|
&kubefloworgv1beta1.WorkspaceKind{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if success := a.requireAuth(w, r, authPolicies); !success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Create ===
|
||||||
|
createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), newWsk)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
|
||||||
|
a.conflictResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// This handles validation errors from the K8s API Server (webhook)
|
||||||
|
if apierrors.IsInvalid(err) {
|
||||||
|
causes := helper.StatusCausesFromAPIStatus(err)
|
||||||
|
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.serverErrorResponse(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Return created object in envelope ===
|
||||||
|
location := a.LocationGetWorkspaceKind(createdModel.Name)
|
||||||
|
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
|
||||||
|
a.createdResponse(w, r, responseEnvelope, location)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -195,8 +196,6 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// NOTE: these tests assume a specific state of the cluster, so cannot be run in parallel with other tests.
|
|
||||||
// therefore, we run them using the `Serial` Ginkgo decorators.
|
|
||||||
Context("with no existing WorkspaceKinds", Serial, func() {
|
Context("with no existing WorkspaceKinds", Serial, func() {
|
||||||
|
|
||||||
It("should return an empty list of WorkspaceKinds", func() {
|
It("should return an empty list of WorkspaceKinds", func() {
|
||||||
|
|
@ -254,4 +253,143 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
||||||
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
|
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 runs before each "It" block, ensuring the validYAML is always available.
|
||||||
|
BeforeEach(func() {
|
||||||
|
validYAML = []byte(fmt.Sprintf(`
|
||||||
|
apiVersion: kubeflow.org/v1beta1
|
||||||
|
kind: WorkspaceKind
|
||||||
|
metadata:
|
||||||
|
name: %s
|
||||||
|
spec:
|
||||||
|
spawner:
|
||||||
|
displayName: "Test Jupyter Environment"
|
||||||
|
description: "A valid description for testing."
|
||||||
|
icon:
|
||||||
|
url: "https://example.com/icon.png"
|
||||||
|
logo:
|
||||||
|
url: "https://example.com/logo.svg"
|
||||||
|
podTemplate:
|
||||||
|
options:
|
||||||
|
imageConfig:
|
||||||
|
spawner:
|
||||||
|
default: "default-image"
|
||||||
|
values:
|
||||||
|
- id: "default-image"
|
||||||
|
name: "Jupyter Scipy"
|
||||||
|
path: "kubeflownotebooks/jupyter-scipy:v1.9.0"
|
||||||
|
spawner:
|
||||||
|
displayName: "Jupyter with SciPy v1.9.0"
|
||||||
|
spec:
|
||||||
|
image: "kubeflownotebooks/jupyter-scipy:v1.9.0"
|
||||||
|
ports:
|
||||||
|
- id: "notebook-port"
|
||||||
|
displayName: "Notebook Port"
|
||||||
|
port: 8888
|
||||||
|
protocol: "HTTP"
|
||||||
|
podConfig:
|
||||||
|
spawner:
|
||||||
|
default: "default-pod-config"
|
||||||
|
values:
|
||||||
|
- id: "default-pod-config"
|
||||||
|
name: "Default Resources"
|
||||||
|
spawner:
|
||||||
|
displayName: "Small CPU/RAM"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "1Gi"
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: "2Gi"
|
||||||
|
volumeMounts:
|
||||||
|
home: "/home/jovyan"
|
||||||
|
`, 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 new WorkspaceKind with valid YAML", func() {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||||
|
req.Header.Set(userIdHeader, adminUser)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||||
|
rs := rr.Result()
|
||||||
|
defer rs.Body.Close()
|
||||||
|
|
||||||
|
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return a 409 Conflict when creating a WorkspaceKind that already exists", func() {
|
||||||
|
By("creating the resource once successfully")
|
||||||
|
req1, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||||
|
req1.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||||
|
req1.Header.Set(userIdHeader, adminUser)
|
||||||
|
rr1 := httptest.NewRecorder()
|
||||||
|
a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{})
|
||||||
|
Expect(rr1.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
By("attempting to create the exact same resource a second time")
|
||||||
|
req2, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||||
|
req2.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||||
|
req2.Header.Set(userIdHeader, adminUser)
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{})
|
||||||
|
|
||||||
|
Expect(rr2.Code).To(Equal(http.StatusConflict))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should fail with 400 Bad Request when the YAML has the wrong kind", func() {
|
||||||
|
wrongKindYAML := []byte(`apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: i-am-the-wrong-kind`)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML))
|
||||||
|
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||||
|
req.Header.Set(userIdHeader, adminUser)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||||
|
|
||||||
|
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
// UPDATED: Check for the new, more specific error message
|
||||||
|
Expect(rr.Body.String()).To(ContainSubstring("no kind \\\"Pod\\\" is registered"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should fail with 400 Bad Request for an empty YAML object", func() {
|
||||||
|
invalidYAML := []byte("{}")
|
||||||
|
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||||
|
req.Header.Set(userIdHeader, adminUser)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||||
|
rs := rr.Result()
|
||||||
|
defer rs.Body.Close()
|
||||||
|
|
||||||
|
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest))
|
||||||
|
body, _ := io.ReadAll(rs.Body)
|
||||||
|
// UPDATED: Check for the new, more specific error message from the secure parser
|
||||||
|
Expect(string(body)).To(ContainSubstring("failed to decode YAML manifest"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
|
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
|
||||||
|
var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists")
|
||||||
|
|
||||||
type WorkspaceKindRepository struct {
|
type WorkspaceKindRepository struct {
|
||||||
client client.Client
|
client client.Client
|
||||||
|
|
@ -68,3 +69,21 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
|
||||||
|
|
||||||
return workspaceKindsModels, nil
|
return workspaceKindsModels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceKindRepository) Create(ctx context.Context, wk *kubefloworgv1beta1.WorkspaceKind) (models.WorkspaceKind, error) {
|
||||||
|
if err := r.client.Create(ctx, wk); err != nil {
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
return models.WorkspaceKind{}, ErrWorkspaceKindAlreadyExists
|
||||||
|
}
|
||||||
|
if apierrors.IsInvalid(err) {
|
||||||
|
// NOTE: we don't wrap this error so we can unpack it in the caller
|
||||||
|
return models.WorkspaceKind{}, err
|
||||||
|
}
|
||||||
|
return models.WorkspaceKind{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the created k8s object to our backend model before returning
|
||||||
|
createdModel := models.NewWorkspaceKindModelFromWorkspaceKind(wk)
|
||||||
|
|
||||||
|
return createdModel, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,74 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Creates a new workspace kind from a raw YAML manifest.",
|
||||||
|
"consumes": [
|
||||||
|
"application/vnd.kubeflow-notebooks.manifest+yaml"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"workspacekinds"
|
||||||
|
],
|
||||||
|
"summary": "Create workspace kind",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Raw YAML manifest of the WorkspaceKind",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful creation. Returns the newly created workspace kind details.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request. The YAML is invalid or a required field is missing.",
|
||||||
|
"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 the workspace kind.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict. A WorkspaceKind with the same name already exists.",
|
||||||
|
"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.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/workspacekinds/{name}": {
|
"/workspacekinds/{name}": {
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,74 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Creates a new workspace kind from a raw YAML manifest.",
|
||||||
|
"consumes": [
|
||||||
|
"application/vnd.kubeflow-notebooks.manifest+yaml"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"workspacekinds"
|
||||||
|
],
|
||||||
|
"summary": "Create workspace kind",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Raw YAML manifest of the WorkspaceKind",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful creation. Returns the newly created workspace kind details.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request. The YAML is invalid or a required field is missing.",
|
||||||
|
"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 the workspace kind.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict. A WorkspaceKind with the same name already exists.",
|
||||||
|
"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.",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/workspacekinds/{name}": {
|
"/workspacekinds/{name}": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue