-
Notifications
You must be signed in to change notification settings - Fork 54
feat(ws): Implement pause workspace functionality as backend API #328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
/* | ||
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" | ||
"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" | ||
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces" | ||
) | ||
|
||
// PauseWorkspaceHandler handles the pause workspace action | ||
// | ||
// @Summary Pause workspace | ||
// @Description Pauses a workspace, stopping all associated pods. | ||
// @Tags workspaces | ||
// @Accept json | ||
// @Produce json | ||
// @Param namespace path string true "Namespace of the workspace" example(default) | ||
// @Param workspaceName path string true "Name of the workspace" example(my-workspace) | ||
// @Success 200 {object} EmptyResponse "Successful action. Returns an empty JSON object." | ||
// @Failure 400 {object} ErrorEnvelope "Bad Request. Invalid workspace kind name format." | ||
// @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 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server." | ||
// @Router /workspaces/{namespace}/{workspaceName}/actions/pause [post] | ||
// @Security ApiKeyAuth | ||
func (a *App) PauseWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { | ||
namespace := ps.ByName(NamespacePathParam) | ||
workspaceName := ps.ByName(ResourceNamePathParam) | ||
|
||
// validate path parameters | ||
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 | ||
} | ||
|
||
// =========================== 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 | ||
} | ||
// ============================================================ | ||
|
||
err := a.repositories.Workspace.PauseWorkspace(r.Context(), namespace, workspaceName) | ||
if err != nil { | ||
if errors.Is(err, repository.ErrWorkspaceNotFound) { | ||
a.notFoundResponse(w, r) | ||
return | ||
} | ||
a.serverErrorResponse(w, r, err) | ||
return | ||
} | ||
|
||
// Return 200 OK with empty JSON object | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. instead of returning a empty, shall we return the workspace object , so frontend can use this information ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. very valid feedback and happy to implement as such if folks agree that is preferred. my rationale for structuring this as such (with an "empty object") was:
Given those 2 points above - I leaned towards But again, it was something I myself wondered during implementation - and willing to change if we get another +1 that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ack, thanks for the explanation behind the return type, i guess, i need to fimilarize myself with frontend a little bit, how the state of the paused/start are being displayed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Andy, for consistency and forward compatibility, returning an envelope object is preferred even for action endpoints like pause. It will make it much easier on FE if the backend always returns an envelope (plus the right status code) and never an empty response. This will help, for instance, in case of an error, and avoid a bunch of if and else statements on the FE. { type PauseResponse struct { If we standardize on envelope makes us easy to evolve etc. |
||
err = a.WriteJSON(w, http.StatusOK, EmptyResponse{}, nil) | ||
if err != nil { | ||
a.serverErrorResponse(w, r, err) | ||
return | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
/* | ||
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 ( | ||
"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" | ||
) | ||
|
||
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 HTTP request") | ||
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) | ||
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) | ||
req, err := http.NewRequest(http.MethodPost, path, http.NoBody) | ||
Expect(err).NotTo(HaveOccurred()) | ||
|
||
By("setting the auth headers") | ||
req.Header.Set(userIdHeader, adminUser) | ||
req.Header.Set("Content-Type", "application/merge-patch+json") | ||
|
||
By("executing PauseWorkspaceHandler") | ||
ps := httprouter.Params{ | ||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, | ||
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, | ||
} | ||
rr := httptest.NewRecorder() | ||
a.PauseWorkspaceHandler(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 is an empty JSON object") | ||
Expect(string(body)).To(Equal("{}\n")) | ||
|
||
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 return 404 for a non-existent workspace", func() { | ||
missingWorkspaceName := "non-existent-workspace" | ||
|
||
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, http.NoBody) | ||
Expect(err).NotTo(HaveOccurred()) | ||
|
||
By("setting the auth headers") | ||
req.Header.Set(userIdHeader, adminUser) | ||
|
||
By("executing PauseWorkspaceHandler") | ||
ps := httprouter.Params{ | ||
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, | ||
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName}, | ||
} | ||
rr := httptest.NewRecorder() | ||
a.PauseWorkspaceHandler(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()) | ||
}) | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick: Seems like the table format is missing here.
can be neglected for next time