feat(ws): add workspace pause actions backend API (#340)
related: #298 - Added PauseActionWorkspaceHandler to handle pausing or unpausing a given workspace - Introduced single new route for starting and pausing workspaces in the API. - `api/v1/workspaces/{namespace}/{name}/actions/pause` - pausing or unpausing operation is specified in the request payload - Created a new WorkspaceActionPauseEnvelope type for successful responses. - Leveraging JSONPatch / client.RawPatch to ensure Workspace in "valid state" before attempting action - for `start`: `spec.paused` must be `true`, and `status.state` must be `Paused` - for `pause`: `spec.paused` must be `false` - note: I would love to have a `status.state` check here of `status.state != Paused`, but that type of comparison is not supported in [JSONPatch](https://datatracker.ietf.org/doc/html/rfc6902#section-4.6) - Added tests for the new API, including success and error cases. - Updated README/OpenAPI documentation to include the new endpoints. --- As an interesting "edge case" worth calling out, the following payload is currently honored by the API: ``` { "data": {} } ``` Given the `WorkspaceActionPause` struct is simply `{"paused": true|false}`, the "empty" Envelope presented above deserializes the JSON using the zero value of `bool` (which is `false`). Our validation today is always performed against the **deserialized** object, and as such impossible to distinguish the following cases: ``` { "data": {} } ``` vs ``` { "data": { "paused": false } } ``` The effort and (relative) complexity to prevent this and return a `422` in this scenario was not deemed "worth it" for the time being. As a result, a test case has been added for this specific scenario to at minimum document this "strange" behavior. - Clients, however, should **NOT** rely on this behavior and always provide a fully defined `WorkspaceActionPause` JSON object to ensure future compatibility. Signed-off-by: Andy Stoneberg <astonebe@redhat.com>
This commit is contained in:
parent
e747ad5e84
commit
843e5c3c48
|
@ -27,29 +27,30 @@ make run
|
|||
If you want to use a different port:
|
||||
|
||||
```shell
|
||||
make run PORT=8000
|
||||
make run PORT=8000
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
| URL Pattern | Handler | Action |
|
||||
|----------------------------------------------|------------------------|-----------------------------------------|
|
||||
| GET /api/v1/healthcheck | healthcheck_handler | Show application information |
|
||||
| GET /api/v1/namespaces | namespaces_handler | Get all Namespaces |
|
||||
| GET /api/v1/swagger/ | swagger_handler | Swagger API documentation |
|
||||
| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces |
|
||||
| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace |
|
||||
| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace |
|
||||
| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity |
|
||||
| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity |
|
||||
| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity |
|
||||
| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity |
|
||||
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
|
||||
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
|
||||
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
|
||||
| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
|
||||
| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
|
||||
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
|
||||
| URL Pattern | Handler | Action |
|
||||
|-----------------------------------------------------------|---------------------------|-----------------------------------------|
|
||||
| GET /api/v1/healthcheck | healthcheck_handler | Show application information |
|
||||
| GET /api/v1/namespaces | namespaces_handler | Get all Namespaces |
|
||||
| GET /api/v1/swagger/ | swagger_handler | Swagger API documentation |
|
||||
| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces |
|
||||
| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace |
|
||||
| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace |
|
||||
| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity |
|
||||
| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity |
|
||||
| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity |
|
||||
| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity |
|
||||
| POST /api/v1/workspaces/{namespace}/{name}/actions/pause | workspace_actions_handler | Set paused state of a workspace |
|
||||
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
|
||||
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
|
||||
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
|
||||
| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
|
||||
| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
|
||||
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
|
||||
|
||||
### Sample local calls
|
||||
|
||||
|
@ -128,6 +129,32 @@ Get a Workspace:
|
|||
curl -i localhost:4000/api/v1/workspaces/default/dora
|
||||
```
|
||||
|
||||
Pause a Workspace:
|
||||
|
||||
```shell
|
||||
# POST /api/v1/workspaces/{namespace}/{name}/actions/pause
|
||||
curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"data": {
|
||||
"paused": true
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Start a Workspace:
|
||||
|
||||
```shell
|
||||
# POST /api/v1/workspaces/{namespace}/{name}/actions/pause
|
||||
curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"data": {
|
||||
"paused": false
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Delete a Workspace:
|
||||
|
||||
```shell
|
||||
|
|
|
@ -50,6 +50,8 @@ const (
|
|||
AllWorkspacesPath = PathPrefix + "/workspaces"
|
||||
WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam
|
||||
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + ResourceNamePathParam
|
||||
WorkspaceActionsPath = WorkspacesByNamePath + "/actions"
|
||||
PauseWorkspacePath = WorkspaceActionsPath + "/pause"
|
||||
|
||||
// workspacekinds
|
||||
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
|
||||
|
@ -116,6 +118,7 @@ func (a *App) Routes() http.Handler {
|
|||
router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler)
|
||||
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
|
||||
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)
|
||||
router.POST(PauseWorkspacePath, a.PauseActionWorkspaceHandler)
|
||||
|
||||
// workspacekinds
|
||||
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||
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/helper"
|
||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions"
|
||||
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
|
||||
)
|
||||
|
||||
type WorkspaceActionPauseEnvelope Envelope[*models.WorkspaceActionPause]
|
||||
|
||||
// PauseActionWorkspaceHandler handles setting the paused state of a workspace.
|
||||
//
|
||||
// @Summary Pause or unpause a workspace
|
||||
// @Description Pauses or unpauses a workspace, stopping or resuming all associated pods.
|
||||
// @Tags workspaces
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=default)
|
||||
// @Param workspaceName path string true "Name of the workspace" extensions(x-example=my-workspace)
|
||||
// @Param body body WorkspaceActionPauseEnvelope true "Intended pause state of the workspace"
|
||||
// @Success 200 {object} WorkspaceActionPauseEnvelope "Successful action. Returns the current pause state."
|
||||
// @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 access the workspace."
|
||||
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist."
|
||||
// @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. Workspace is not in appropriate state."
|
||||
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
|
||||
// @Router /workspaces/{namespace}/{workspaceName}/actions/pause [post]
|
||||
func (a *App) PauseActionWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
namespace := ps.ByName(NamespacePathParam)
|
||||
workspaceName := ps.ByName(ResourceNamePathParam)
|
||||
|
||||
var valErrs field.ErrorList
|
||||
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
|
||||
}
|
||||
|
||||
if success := a.ValidateContentType(w, r, "application/json"); !success {
|
||||
return
|
||||
}
|
||||
|
||||
bodyEnvelope := &WorkspaceActionPauseEnvelope{}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
workspaceActionPause := bodyEnvelope.Data
|
||||
|
||||
// =========================== AUTH ===========================
|
||||
authPolicies := []*auth.ResourcePolicy{
|
||||
auth.NewResourcePolicy(
|
||||
auth.ResourceVerbUpdate,
|
||||
&kubefloworgv1beta1.Workspace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: namespace,
|
||||
Name: workspaceName,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
if success := a.requireAuth(w, r, authPolicies); !success {
|
||||
return
|
||||
}
|
||||
// ============================================================
|
||||
|
||||
workspaceActionPauseState, err := a.repositories.Workspace.HandlePauseAction(r.Context(), namespace, workspaceName, workspaceActionPause)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrWorkspaceNotFound) {
|
||||
a.notFoundResponse(w, r)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrWorkspaceInvalidState) {
|
||||
a.failedValidationResponse(w, r, err.Error(), nil, nil)
|
||||
return
|
||||
}
|
||||
a.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseEnvelope := &WorkspaceActionPauseEnvelope{
|
||||
Data: workspaceActionPauseState,
|
||||
}
|
||||
a.dataResponse(w, r, responseEnvelope)
|
||||
}
|
|
@ -0,0 +1,496 @@
|
|||
/*
|
||||
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions"
|
||||
)
|
||||
|
||||
var _ = Describe("Workspace Actions Handler", func() {
|
||||
|
||||
// NOTE: the tests in this context work on the same resources, they must be run in order.
|
||||
// also, they assume a specific state of the cluster, so cannot be run in parallel with other tests.
|
||||
// therefore, we run them using the `Ordered` and `Serial` Ginkgo decorators.
|
||||
Context("with existing Workspaces", Serial, Ordered, func() {
|
||||
|
||||
const namespaceName1 = "ws-ops-ns1"
|
||||
|
||||
var (
|
||||
workspaceName1 string
|
||||
workspaceKey1 types.NamespacedName
|
||||
workspaceKindName string
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
uniqueName := "ws-ops-test"
|
||||
workspaceName1 = fmt.Sprintf("workspace-1-%s", uniqueName)
|
||||
workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1}
|
||||
workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName)
|
||||
|
||||
By("creating Namespace 1")
|
||||
namespace1 := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: namespaceName1,
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, namespace1)).To(Succeed())
|
||||
|
||||
By("creating a WorkspaceKind")
|
||||
workspaceKind := NewExampleWorkspaceKind(workspaceKindName)
|
||||
Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed())
|
||||
|
||||
By("creating Workspace 1 in Namespace 1")
|
||||
workspace1 := NewExampleWorkspace(workspaceName1, namespaceName1, workspaceKindName)
|
||||
Expect(k8sClient.Create(ctx, workspace1)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterAll(func() {
|
||||
By("deleting Workspace 1 from Namespace 1")
|
||||
workspace1 := &kubefloworgv1beta1.Workspace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: workspaceName1,
|
||||
Namespace: namespaceName1,
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Delete(ctx, workspace1)).To(Succeed())
|
||||
|
||||
By("deleting WorkspaceKind")
|
||||
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: workspaceKindName,
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed())
|
||||
|
||||
By("deleting Namespace 1")
|
||||
namespace1 := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: namespaceName1,
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should pause a workspace successfully", func() {
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: true,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("reading the HTTP response body")
|
||||
body, err := io.ReadAll(rs.Body)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("verifying the response contains the pause state")
|
||||
var response WorkspaceActionPauseEnvelope
|
||||
err = json.Unmarshal(body, &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(response.Data).NotTo(BeNil())
|
||||
Expect(response.Data.Paused).To(BeTrue())
|
||||
|
||||
By("getting the Workspace from the Kubernetes API")
|
||||
workspace := &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
|
||||
By("ensuring the workspace is paused")
|
||||
Expect(workspace.Spec.Paused).To(Equal(ptr.To(true)))
|
||||
})
|
||||
|
||||
It("should start a workspace successfully", func() {
|
||||
By("setting the workspace's status state to Paused")
|
||||
workspace := &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
workspace.Status.State = kubefloworgv1beta1.WorkspaceStatePaused
|
||||
Expect(k8sClient.Status().Update(ctx, workspace)).To(Succeed())
|
||||
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: false,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("reading the HTTP response body")
|
||||
body, err := io.ReadAll(rs.Body)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("verifying the response contains the pause state")
|
||||
var response WorkspaceActionPauseEnvelope
|
||||
err = json.Unmarshal(body, &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(response.Data).NotTo(BeNil())
|
||||
Expect(response.Data.Paused).To(BeFalse())
|
||||
|
||||
By("getting the Workspace from the Kubernetes API")
|
||||
workspace = &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
|
||||
By("ensuring the workspace is not paused")
|
||||
Expect(workspace.Spec.Paused).To(Equal(ptr.To(false)))
|
||||
})
|
||||
|
||||
It("should return 404 for a non-existent workspace when starting", func() {
|
||||
missingWorkspaceName := "non-existent-workspace"
|
||||
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: false,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
|
||||
It("should return 404 for a non-existent workspace when pausing", func() {
|
||||
missingWorkspaceName := "non-existent-workspace"
|
||||
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: true,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
|
||||
It("should return 422 when starting a workspace that is not in Paused state", func() {
|
||||
By("setting the workspace's status state to Unknown and spec.paused to false")
|
||||
workspace := &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
workspace.Spec.Paused = ptr.To(false)
|
||||
workspace.Status.State = kubefloworgv1beta1.WorkspaceStateUnknown
|
||||
Expect(k8sClient.Update(ctx, workspace)).To(Succeed())
|
||||
Expect(k8sClient.Status().Update(ctx, workspace)).To(Succeed())
|
||||
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: false,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code is 422")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
|
||||
It("should return 422 when pausing a workspace that is already paused", func() {
|
||||
By("setting the workspace's spec.paused to true")
|
||||
workspace := &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
workspace.Spec.Paused = ptr.To(true)
|
||||
Expect(k8sClient.Update(ctx, workspace)).To(Succeed())
|
||||
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: true,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code is 422")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
|
||||
It("should return 422 when request body is missing data field", func() {
|
||||
By("creating the request body without data field")
|
||||
requestBody := map[string]interface{}{}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code is 422")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
|
||||
It("should return 415 when Content-Type is not application/json", func() {
|
||||
By("creating the request body")
|
||||
requestBody := &WorkspaceActionPauseEnvelope{
|
||||
Data: &models.WorkspaceActionPause{
|
||||
Paused: true,
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers with wrong Content-Type")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/merge-patch+json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code is 415")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusUnsupportedMediaType), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
})
|
||||
|
||||
// This test highlights that when the pause API receives a payload of {"data":{}},
|
||||
// the zero value for the 'Paused' field (false) is used. This is equivalent to
|
||||
// explicitly setting "paused": false. This test case is included to make the behavior
|
||||
// obvious for future maintainers. While this is not necessarily desired behavior,
|
||||
// the effort to add sufficient validation to the API is not worth the effort as it would
|
||||
// require a "framework" to validate the raw JSON payload before it is deserialized.
|
||||
It("should handle empty data object payload correctly", func() {
|
||||
By("setting the workspace's spec.paused to true and status state to Paused")
|
||||
workspace := &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
workspace.Spec.Paused = ptr.To(true)
|
||||
Expect(k8sClient.Update(ctx, workspace)).To(Succeed())
|
||||
workspace.Status.State = kubefloworgv1beta1.WorkspaceStatePaused
|
||||
Expect(k8sClient.Status().Update(ctx, workspace)).To(Succeed())
|
||||
|
||||
By("creating the request body with empty data object")
|
||||
requestBody := map[string]interface{}{
|
||||
"data": map[string]interface{}{},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("creating the HTTP request")
|
||||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
|
||||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
|
||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes)))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("setting the auth headers")
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
By("executing PauseActionWorkspaceHandler")
|
||||
ps := httprouter.Params{
|
||||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
|
||||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
a.PauseActionWorkspaceHandler(rr, req, ps)
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
By("verifying the HTTP response status code")
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
|
||||
|
||||
By("reading the HTTP response body")
|
||||
body, err := io.ReadAll(rs.Body)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
By("verifying the response contains the pause state")
|
||||
var response WorkspaceActionPauseEnvelope
|
||||
err = json.Unmarshal(body, &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(response.Data).NotTo(BeNil())
|
||||
Expect(response.Data.Paused).To(BeFalse())
|
||||
|
||||
By("getting the Workspace from the Kubernetes API")
|
||||
workspace = &kubefloworgv1beta1.Workspace{}
|
||||
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
|
||||
|
||||
By("ensuring the workspace is not paused (empty data object results in false)")
|
||||
Expect(workspace.Spec.Paused).To(Equal(ptr.To(false)))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
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 actions
|
||||
|
||||
import (
|
||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
func NewWorkspaceActionPauseFromWorkspace(ws *kubefloworgv1beta1.Workspace) *WorkspaceActionPause {
|
||||
return &WorkspaceActionPause{
|
||||
Paused: ptr.Deref(ws.Spec.Paused, false),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
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 actions
|
||||
|
||||
// WorkspaceActionPause represents the outcome of pause/start workspace actions
|
||||
type WorkspaceActionPause struct {
|
||||
Paused bool `json:"paused"`
|
||||
}
|
|
@ -18,19 +18,25 @@ package workspaces
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
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/types"
|
||||
"k8s.io/utils/ptr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces"
|
||||
action_models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions"
|
||||
)
|
||||
|
||||
var ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
|
||||
var ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists")
|
||||
var (
|
||||
ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
|
||||
ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists")
|
||||
ErrWorkspaceInvalidState = fmt.Errorf("workspace is in an invalid state for this operation")
|
||||
)
|
||||
|
||||
type WorkspaceRepository struct {
|
||||
client client.Client
|
||||
|
@ -214,3 +220,67 @@ func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, wo
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorkspacePatchOperation represents a single JSONPatch operation
|
||||
type WorkspacePatchOperation struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// HandlePauseAction handles pause/start operations for a workspace
|
||||
func (r *WorkspaceRepository) HandlePauseAction(ctx context.Context, namespace, workspaceName string, workspaceActionPause *action_models.WorkspaceActionPause) (*action_models.WorkspaceActionPause, error) {
|
||||
targetPauseState := workspaceActionPause.Paused
|
||||
|
||||
// Build patch operations incrementally
|
||||
patch := []WorkspacePatchOperation{
|
||||
{
|
||||
Op: "test",
|
||||
Path: "/spec/paused",
|
||||
Value: !targetPauseState, // Test current state (opposite of target state)
|
||||
},
|
||||
}
|
||||
|
||||
// For start operations, add additional test for paused state
|
||||
// "test" operations on JSON Patch only support strict equality checks, so we can't apply an additional test
|
||||
// for pause operations on the workspace as we'd want to check the workspace state != paused.
|
||||
if !targetPauseState {
|
||||
patch = append(patch, WorkspacePatchOperation{
|
||||
Op: "test",
|
||||
Path: "/status/state",
|
||||
Value: kubefloworgv1beta1.WorkspaceStatePaused,
|
||||
})
|
||||
}
|
||||
|
||||
// Always add the replace operation
|
||||
patch = append(patch, WorkspacePatchOperation{
|
||||
Op: "replace",
|
||||
Path: "/spec/paused",
|
||||
Value: targetPauseState,
|
||||
})
|
||||
|
||||
patchBytes, err := json.Marshal(patch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal patch: %w", err)
|
||||
}
|
||||
|
||||
workspace := &kubefloworgv1beta1.Workspace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: namespace,
|
||||
Name: workspaceName,
|
||||
},
|
||||
}
|
||||
|
||||
if err := r.client.Patch(ctx, workspace, client.RawPatch(types.JSONPatchType, patchBytes)); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, ErrWorkspaceNotFound
|
||||
}
|
||||
if apierrors.IsInvalid(err) {
|
||||
return nil, ErrWorkspaceInvalidState
|
||||
}
|
||||
return nil, fmt.Errorf("failed to patch workspace: %w", err)
|
||||
}
|
||||
|
||||
workspaceActionPauseModel := action_models.NewWorkspaceActionPauseFromWorkspace(workspace)
|
||||
return workspaceActionPauseModel, nil
|
||||
}
|
||||
|
|
|
@ -453,6 +453,104 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{namespace}/{workspaceName}/actions/pause": {
|
||||
"post": {
|
||||
"description": "Pauses or unpauses a workspace, stopping or resuming all associated pods.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"workspaces"
|
||||
],
|
||||
"summary": "Pause or unpause a workspace",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"x-example": "default",
|
||||
"description": "Namespace of the workspace",
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"x-example": "my-workspace",
|
||||
"description": "Name of the workspace",
|
||||
"name": "workspaceName",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Intended pause state of the workspace",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceActionPauseEnvelope"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful action. Returns the current pause state.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceActionPauseEnvelope"
|
||||
}
|
||||
},
|
||||
"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 access the workspace.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found. Workspace does not exist.",
|
||||
"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. Workspace is not in appropriate state.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{namespace}/{workspace_name}": {
|
||||
"get": {
|
||||
"description": "Returns details of a specific workspace identified by namespace and workspace name.",
|
||||
|
@ -592,6 +690,14 @@ const docTemplate = `{
|
|||
}
|
||||
},
|
||||
"definitions": {
|
||||
"actions.WorkspaceActionPause": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paused": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.ErrorCause": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -650,6 +756,14 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"api.WorkspaceActionPauseEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/actions.WorkspaceActionPause"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.WorkspaceCreateEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -451,6 +451,104 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{namespace}/{workspaceName}/actions/pause": {
|
||||
"post": {
|
||||
"description": "Pauses or unpauses a workspace, stopping or resuming all associated pods.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"workspaces"
|
||||
],
|
||||
"summary": "Pause or unpause a workspace",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"x-example": "default",
|
||||
"description": "Namespace of the workspace",
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"x-example": "my-workspace",
|
||||
"description": "Name of the workspace",
|
||||
"name": "workspaceName",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Intended pause state of the workspace",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceActionPauseEnvelope"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful action. Returns the current pause state.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceActionPauseEnvelope"
|
||||
}
|
||||
},
|
||||
"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 access the workspace.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found. Workspace does not exist.",
|
||||
"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. Workspace is not in appropriate state.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{namespace}/{workspace_name}": {
|
||||
"get": {
|
||||
"description": "Returns details of a specific workspace identified by namespace and workspace name.",
|
||||
|
@ -590,6 +688,14 @@
|
|||
}
|
||||
},
|
||||
"definitions": {
|
||||
"actions.WorkspaceActionPause": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paused": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.ErrorCause": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -648,6 +754,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"api.WorkspaceActionPauseEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/actions.WorkspaceActionPause"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.WorkspaceCreateEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
Loading…
Reference in New Issue