feat(ws): refactor backend models and repositories (#192)

Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
This commit is contained in:
Mathew Wicks 2025-02-04 09:20:37 -08:00 committed by GitHub
parent 16f97f86d9
commit 710cbd4753
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1739 additions and 917 deletions

View File

@ -77,16 +77,20 @@ func (a *App) Routes() http.Handler {
router.NotFound = http.HandlerFunc(a.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(a.methodNotAllowedResponse)
router.GET(HealthCheckPath, a.HealthcheckHandler)
// healthcheck
router.GET(HealthCheckPath, a.GetHealthcheckHandler)
// namespaces
router.GET(AllNamespacesPath, a.GetNamespacesHandler)
// workspaces
router.GET(AllWorkspacesPath, a.GetWorkspacesHandler)
router.GET(WorkspacesByNamespacePath, a.GetWorkspacesHandler)
router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler)
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)
// workspacekinds
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)

View File

@ -1,70 +0,0 @@
/*
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"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)
func TestHealthCheckHandler(t *testing.T) {
app := App{
Config: config.EnvConfig{
Port: 4000,
},
repositories: repositories.NewRepositories(k8sClient),
}
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, HealthCheckPath, http.NoBody)
if err != nil {
t.Fatal(err)
}
app.HealthcheckHandler(rr, req, nil)
rs := rr.Result()
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal("Failed to read response body")
}
var healthCheckRes models.HealthCheckModel
err = json.Unmarshal(body, &healthCheckRes)
if err != nil {
t.Fatalf("Error unmarshalling response JSON: %v", err)
}
expected := models.HealthCheckModel{
Status: "available",
SystemInfo: models.SystemInfo{
Version: Version,
},
}
assert.Equal(t, expected, healthCheckRes)
}

View File

@ -22,7 +22,7 @@ import (
"github.com/julienschmidt/httprouter"
)
func (a *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
func (a *App) GetHealthcheckHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
healthCheck, err := a.repositories.HealthCheck.HealthCheck(Version)
if err != nil {
@ -34,5 +34,4 @@ func (a *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps http
if err != nil {
a.serverErrorResponse(w, r, err)
}
}

View File

@ -0,0 +1,85 @@
/*
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"
"io"
"net/http"
"net/http/httptest"
"github.com/julienschmidt/httprouter"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/health_check"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)
var _ = Describe("HealthCheck Handler", func() {
var (
a App
)
Context("when backend is healthy", func() {
BeforeEach(func() {
repos := repositories.NewRepositories(k8sClient)
a = App{
Config: config.EnvConfig{
Port: 4000,
},
repositories: repos,
}
})
It("should return a health check response", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodGet, HealthCheckPath, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetHealthCheckHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.GetHealthcheckHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON to HealthCheck")
var response models.HealthCheck
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("ensuring that the health check is as expected")
expected := models.HealthCheck{
Status: models.ServiceStatusHealthy,
SystemInfo: models.SystemInfo{
Version: Version,
},
}
Expect(response).To(BeComparableTo(expected))
})
})
})

View File

@ -21,10 +21,10 @@ import (
"github.com/julienschmidt/httprouter"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces"
)
type NamespacesEnvelope Envelope[[]models.NamespaceModel]
type NamespacesEnvelope Envelope[[]models.Namespace]
func (a *App) GetNamespacesHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

View File

@ -22,43 +22,40 @@ import (
"net/http"
"net/http/httptest"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/julienschmidt/httprouter"
. "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"
"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)
var _ = Describe("Namespaces Handler", func() {
var (
a App
testRouter *httprouter.Router
a App
)
BeforeEach(func() {
repos := repositories.NewRepositories(k8sClient)
a = App{
Config: config.EnvConfig{
Port: 4000,
},
repositories: repos,
}
// 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("when namespaces exist", Serial, func() {
testRouter = httprouter.New()
testRouter.GET("/api/namespaces", a.GetNamespacesHandler)
})
Context("when namespaces exist", func() {
const namespaceName1 = "namespaceone"
const namespaceName2 = "namespacetwo"
const namespaceName1 = "get-ns-test-ns1"
const namespaceName2 = "get-ns-test-ns2"
BeforeEach(func() {
By("creating namespaces")
repos := repositories.NewRepositories(k8sClient)
a = App{
Config: config.EnvConfig{
Port: 4000,
},
repositories: repos,
}
By("creating Namespace 1")
namespace1 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName1,
@ -66,18 +63,17 @@ var _ = Describe("Namespaces Handler", func() {
}
Expect(k8sClient.Create(ctx, namespace1)).To(Succeed())
By("creating Namespace 2")
namespace2 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName2,
},
}
Expect(k8sClient.Create(ctx, namespace2)).To(Succeed())
})
AfterEach(func() {
By("deleting namespaces")
By("deleting the namespace1")
By("deleting Namespace 1")
namespace1 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName1,
@ -85,7 +81,7 @@ var _ = Describe("Namespaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed())
By("deleting the namespace2")
By("deleting Namespace 2")
namespace2 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName2,
@ -96,32 +92,40 @@ var _ = Describe("Namespaces Handler", func() {
It("should retrieve all namespaces successfully", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodGet, "/api/namespaces", http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
req, err := http.NewRequest(http.MethodGet, AllNamespacesPath, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetNamespacesHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
testRouter.ServeHTTP(rr, req)
a.GetNamespacesHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to NamespacesEnvelope")
var response NamespacesEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("asserting that the created namespaces are in the response")
By("getting the Namespaces from the Kubernetes API")
namespace1 := &corev1.Namespace{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: namespaceName1}, namespace1)).To(Succeed())
namespace2 := &corev1.Namespace{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: namespaceName2}, namespace2)).To(Succeed())
By("ensuring the response contains the expected Namespaces")
// NOTE: we use `ContainElements` instead of `ConsistOf` because envtest creates some namespaces by default
Expect(response.Data).To(ContainElements(
models.NamespaceModel{Name: namespaceName1},
models.NamespaceModel{Name: namespaceName2},
), "Expected created namespaces to be in the response")
models.NewNamespaceModelFromNamespace(namespace1),
models.NewNamespaceModelFromNamespace(namespace2),
))
})
})
})

View File

@ -23,12 +23,13 @@ import (
"github.com/julienschmidt/httprouter"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
)
type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKindModel]
type WorkspaceKindEnvelope Envelope[models.WorkspaceKindModel]
type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKind]
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
name := ps.ByName("name")
@ -40,7 +41,7 @@ func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps
workspaceKind, err := a.repositories.WorkspaceKind.GetWorkspaceKind(r.Context(), name)
if err != nil {
if errors.Is(err, repositories.ErrWorkspaceKindNotFound) {
if errors.Is(err, repository.ErrWorkspaceKindNotFound) {
a.notFoundResponse(w, r)
return
}

View File

@ -33,28 +33,33 @@ import (
"k8s.io/apimachinery/pkg/types"
"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)
var _ = Describe("WorkspaceKinds Handler", func() {
Context("with existing workspacekinds", Ordered, func() {
const namespaceName1 = "namespace-kind"
// 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 WorkspaceKinds", Serial, Ordered, func() {
const namespaceName1 = "wsk-exist-test-ns1"
var (
a App
a App
workspaceKind1Name string
workspaceKind2Name string
workspaceKind1Key types.NamespacedName
workspaceKind2Name string
workspaceKind2Key types.NamespacedName
)
BeforeAll(func() {
uniqueName := "wskind-update-test"
workspaceKind1Name = fmt.Sprintf("workspacekind1-%s", uniqueName)
uniqueName := "wsk-exist-test"
workspaceKind1Name = fmt.Sprintf("workspacekind-1-%s", uniqueName)
workspaceKind1Key = types.NamespacedName{Name: workspaceKind1Name}
workspaceKind2Name = fmt.Sprintf("workspacekind2-%s", uniqueName)
workspaceKind2Name = fmt.Sprintf("workspacekind-2-%s", uniqueName)
workspaceKind2Key = types.NamespacedName{Name: workspaceKind2Name}
repos := repositories.NewRepositories(k8sClient)
@ -65,7 +70,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
repositories: repos,
}
By("creating namespaces")
By("creating Namespace 1")
namespace1 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName1,
@ -73,19 +78,17 @@ var _ = Describe("WorkspaceKinds Handler", func() {
}
Expect(k8sClient.Create(ctx, namespace1)).To(Succeed())
By("creating a WorkspaceKind1")
By("creating WorkspaceKind 1")
workspaceKind1 := NewExampleWorkspaceKind(workspaceKind1Name)
Expect(k8sClient.Create(ctx, workspaceKind1)).To(Succeed())
By("creating a WorkspaceKind1")
By("creating WorkspaceKind 2")
workspaceKind2 := NewExampleWorkspaceKind(workspaceKind2Name)
Expect(k8sClient.Create(ctx, workspaceKind2)).To(Succeed())
})
AfterAll(func() {
By("deleting the WorkspaceKind1")
By("deleting WorkspaceKind 1")
workspaceKind1 := &kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceKind1Name,
@ -93,7 +96,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspaceKind1)).To(Succeed())
By("deleting the WorkspaceKind2")
By("deleting WorkspaceKind 2")
workspaceKind2 := &kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceKind2Name,
@ -101,7 +104,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspaceKind2)).To(Succeed())
By("deleting the namespace1")
By("deleting Namespace 1")
namespace1 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName1,
@ -111,10 +114,10 @@ var _ = Describe("WorkspaceKinds Handler", func() {
})
It("should retrieve the all workspacekinds successfully", func() {
It("should retrieve the all WorkspaceKinds successfully", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodGet, WorkspacesByNamespacePath, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
req, err := http.NewRequest(http.MethodGet, AllWorkspaceKindsPath, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspaceKindsHandler")
ps := httprouter.Params{}
@ -124,50 +127,46 @@ var _ = Describe("WorkspaceKinds Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to WorkspaceKindsEnvelope")
var response WorkspaceKindsEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("retrieving workspaceKindsData in the response")
workspaceKindsData := response.Data
By("converting workspaceKindsData to JSON and back to []WorkspaceKindsModel")
workspaceKindsJSON, err := json.Marshal(workspaceKindsData)
Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces repositories")
var workspaceKinds []models.WorkspaceKindModel
err = json.Unmarshal(workspaceKindsJSON, &workspaceKinds)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON")
By("asserting that the retrieved workspaces kinds match the expected workspacekinds")
By("getting the WorkspaceKinds from the Kubernetes API")
workspacekind1 := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed())
workspacekind2 := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKind2Key, workspacekind2)).To(Succeed())
expectedWorkspaceKinds := []models.WorkspaceKindModel{
By("ensuring the response contains the expected WorkspaceKinds")
Expect(response.Data).To(ConsistOf(
models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1),
models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind2),
}
Expect(workspaceKinds).To(ConsistOf(expectedWorkspaceKinds))
))
By("ensuring the wrapped data can be marshaled to JSON and back to []WorkspaceKind")
dataJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
var dataObject []models.WorkspaceKind
err = json.Unmarshal(dataJSON, &dataObject)
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []WorkspaceKind")
})
It("should retrieve a single workspacekind successfully", func() {
It("should retrieve a single WorkspaceKind successfully", func() {
By("creating the HTTP request")
path := strings.Replace(WorkspaceKindsByNamePath, ":name", workspaceKind1Name, 1)
path := strings.Replace(WorkspaceKindsByNamePath, ":"+WorkspaceKindNamePathParam, workspaceKind1Name, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspaceKindHandler")
ps := httprouter.Params{
httprouter.Param{Key: "name", Value: workspaceKind1Name},
httprouter.Param{Key: WorkspaceKindNamePathParam, Value: workspaceKind1Name},
}
rr := httptest.NewRecorder()
a.GetWorkspaceKindHandler(rr, req, ps)
@ -175,31 +174,37 @@ var _ = Describe("WorkspaceKinds Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to WorkspaceKindEnvelope")
var response WorkspaceKindEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("retrieving workspaceKindData in the response")
workspaceKindData := response.Data
By("comparing the retrieved workspacekind with the expected")
By("getting the WorkspaceKind from the Kubernetes API")
workspacekind1 := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed())
By("ensuring the response matches the expected WorkspaceKind")
expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1)
Expect(workspaceKindData).To(Equal(expectedWorkspaceKind))
})
Expect(response.Data).To(BeComparableTo(expectedWorkspaceKind))
By("ensuring the wrapped data can be marshaled to JSON and back")
dataJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
var dataObject models.WorkspaceKind
err = json.Unmarshal(dataJSON, &dataObject)
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to WorkspaceKind")
})
})
Context("when there are no workspacekinds ", 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() {
var a App
@ -212,10 +217,11 @@ var _ = Describe("WorkspaceKinds Handler", func() {
repositories: repos,
}
})
It("should return an empty list of workspacekinds", func() {
It("should return an empty list of WorkspaceKinds", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodGet, AllWorkspaceKindsPath, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspacesHandler")
ps := httprouter.Params{}
@ -225,36 +231,32 @@ var _ = Describe("WorkspaceKinds Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to WorkspaceKindsEnvelope")
var response WorkspaceKindsEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("asserting that the 'workspaces' list is empty")
workspaceskindsJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces data")
var workspaceKinds []models.WorkspaceKindModel
err = json.Unmarshal(workspaceskindsJSON, &workspaceKinds)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON")
Expect(workspaceKinds).To(BeEmpty(), "Expected no workspaces in the response")
By("ensuring that no WorkspaceKinds were returned")
Expect(response.Data).To(BeEmpty())
})
It("should return 404 for a non-existent workspacekind", func() {
By("creating the HTTP request for a non-existent workspacekind")
path := strings.Replace(WorkspaceKindsByNamePath, ":name", "non-existent-workspacekind", 1)
It("should return 404 for a non-existent WorkspaceKind", func() {
missingWorkspaceKindName := "non-existent-workspacekind"
By("creating the HTTP request")
path := strings.Replace(WorkspaceKindsByNamePath, ":"+WorkspaceNamePathParam, missingWorkspaceKindName, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspaceKindHandler")
ps := httprouter.Params{
httprouter.Param{Key: "name", Value: "non-existent-workspacekind"},
httprouter.Param{Key: WorkspaceNamePathParam, Value: missingWorkspaceKindName},
}
rr := httptest.NewRecorder()
a.GetWorkspaceKindHandler(rr, req, ps)
@ -262,7 +264,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), "Expected HTTP status 404 Not Found")
Expect(rs.StatusCode).To(Equal(http.StatusNotFound))
})
})
})

View File

@ -24,18 +24,19 @@ import (
"github.com/julienschmidt/httprouter"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces"
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
)
type WorkspacesEnvelope Envelope[[]models.WorkspaceModel]
type WorkspaceEnvelope Envelope[models.WorkspaceModel]
type WorkspacesEnvelope Envelope[[]models.Workspace]
type WorkspaceEnvelope Envelope[models.Workspace]
func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)
workspaceName := ps.ByName(WorkspaceNamePathParam)
var workspace models.WorkspaceModel
var workspace models.Workspace
var err error
if namespace == "" {
a.serverErrorResponse(w, r, fmt.Errorf("namespace is nil"))
@ -48,7 +49,7 @@ func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps htt
workspace, err = a.repositories.Workspace.GetWorkspace(r.Context(), namespace, workspaceName)
if err != nil {
if errors.Is(err, repositories.ErrWorkspaceNotFound) {
if errors.Is(err, repository.ErrWorkspaceNotFound) {
a.notFoundResponse(w, r)
return
}
@ -70,7 +71,7 @@ func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps htt
func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)
var workspaces []models.WorkspaceModel
var workspaces []models.Workspace
var err error
if namespace == "" {
workspaces, err = a.repositories.Workspace.GetAllWorkspaces(r.Context())
@ -100,7 +101,7 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
return
}
workspaceModel := &models.WorkspaceModel{}
workspaceModel := &models.Workspace{}
if err := json.NewDecoder(r.Body).Decode(workspaceModel); err != nil {
a.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON: %w", err))
return
@ -142,7 +143,7 @@ func (a *App) DeleteWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
err := a.repositories.Workspace.DeleteWorkspace(r.Context(), namespace, workspaceName)
if err != nil {
if errors.Is(err, repositories.ErrWorkspaceNotFound) {
if errors.Is(err, repository.ErrWorkspaceNotFound) {
a.notFoundResponse(w, r)
return
}

View File

@ -29,37 +29,50 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/ptr"
"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)
var _ = Describe("Workspaces Handler", func() {
Context("with existing workspaces", Ordered, func() {
const namespaceName1 = "namespace1"
const namespaceName2 = "namespace2"
// 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-exist-ns1"
const namespaceName2 = "ws-exist-ns2"
var (
a App
workspaceName1 string
workspaceKey1 types.NamespacedName
workspaceName2 string
workspaceKey2 types.NamespacedName
workspaceName3 string
workspaceKey3 types.NamespacedName
a App
workspaceName1 string
workspaceKey1 types.NamespacedName
workspaceName2 string
workspaceKey2 types.NamespacedName
workspaceName3 string
workspaceKey3 types.NamespacedName
workspaceKindName string
workspaceKindKey types.NamespacedName
)
BeforeAll(func() {
uniqueName := "wsk-update-test"
workspaceName1 = fmt.Sprintf("workspace1-%s", uniqueName)
workspaceName2 = fmt.Sprintf("workspace2-%s", uniqueName)
workspaceName3 = fmt.Sprintf("workspace3-%s", uniqueName)
uniqueName := "ws-exist-test"
workspaceName1 = fmt.Sprintf("workspace-1-%s", uniqueName)
workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1}
workspaceName2 = fmt.Sprintf("workspace-2-%s", uniqueName)
workspaceKey2 = types.NamespacedName{Name: workspaceName2, Namespace: namespaceName1}
workspaceName3 = fmt.Sprintf("workspace-3-%s", uniqueName)
workspaceKey3 = types.NamespacedName{Name: workspaceName3, Namespace: namespaceName2}
workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName)
workspaceKindKey = types.NamespacedName{Name: workspaceKindName}
repos := repositories.NewRepositories(k8sClient)
a = App{
@ -69,7 +82,7 @@ var _ = Describe("Workspaces Handler", func() {
repositories: repos,
}
By("creating namespaces")
By("creating Namespace 1")
namespace1 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName1,
@ -77,6 +90,7 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Create(ctx, namespace1)).To(Succeed())
By("creating Namespace 2")
namespace2 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName2,
@ -88,24 +102,21 @@ var _ = Describe("Workspaces Handler", func() {
workspaceKind := NewExampleWorkspaceKind(workspaceKindName)
Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed())
By("creating the Workspace1 at namespaceName1")
By("creating Workspace 1 in Namespace 1")
workspace1 := NewExampleWorkspace(workspaceName1, namespaceName1, workspaceKindName)
Expect(k8sClient.Create(ctx, workspace1)).To(Succeed())
workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1}
By("creating the Workspace2 at namespaceName1")
By("creating Workspace 2 in Namespace 1")
workspace2 := NewExampleWorkspace(workspaceName2, namespaceName1, workspaceKindName)
Expect(k8sClient.Create(ctx, workspace2)).To(Succeed())
workspaceKey2 = types.NamespacedName{Name: workspaceName2, Namespace: namespaceName1}
By("creating the Workspace3 at namespaceName2")
By("creating Workspace 3 in Namespace 2")
workspace3 := NewExampleWorkspace(workspaceName3, namespaceName2, workspaceKindName)
Expect(k8sClient.Create(ctx, workspace3)).To(Succeed())
workspaceKey3 = types.NamespacedName{Name: workspaceName3, Namespace: namespaceName2}
})
AfterAll(func() {
By("deleting the Workspace1 at namespaceName1")
By("deleting Workspace 1 from Namespace 1")
workspace1 := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceName1,
@ -114,7 +125,7 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspace1)).To(Succeed())
By("deleting the Workspace2 at namespaceName1")
By("deleting Workspace 2 from Namespace 1")
workspace2 := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceName2,
@ -123,7 +134,7 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspace2)).To(Succeed())
By("deleting the Workspace3 at namespaceName2")
By("deleting Workspace 3 from Namespace 2")
workspace3 := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceName3,
@ -132,7 +143,7 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspace3)).To(Succeed())
By("deleting the WorkspaceKind")
By("deleting WorkspaceKind")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceKindName,
@ -140,7 +151,7 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed())
By("deleting the namespace1")
By("deleting Namespace 1")
namespace1 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName1,
@ -148,7 +159,7 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed())
By("deleting the namespace2")
By("deleting Namespace 2")
namespace2 := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceName2,
@ -157,11 +168,10 @@ var _ = Describe("Workspaces Handler", func() {
Expect(k8sClient.Delete(ctx, namespace2)).To(Succeed())
})
It("should retrieve the workspaces from all namespaces successfully", func() {
It("should retrieve Workspaces from all namespaces successfully", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodGet, WorkspacesByNamespacePath, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
req, err := http.NewRequest(http.MethodGet, AllWorkspacesPath, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspacesHandler")
ps := httprouter.Params{}
@ -171,29 +181,22 @@ var _ = Describe("Workspaces Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to WorkspacesEnvelope")
var response WorkspacesEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("checking if 'workspaces' key exists in the response")
workspacesData := response.Data
By("getting the WorkspaceKind from the Kubernetes API")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
By("converting workspacesData to JSON and back to []WorkspaceModel")
workspacesJSON, err := json.Marshal(workspacesData)
Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces repositories")
var workspaces []models.WorkspaceModel
err = json.Unmarshal(workspacesJSON, &workspaces)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON")
By("asserting that the retrieved workspaces match the expected workspaces")
By("getting the Workspaces from the Kubernetes API")
workspace1 := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceKey1, workspace1)).To(Succeed())
workspace2 := &kubefloworgv1beta1.Workspace{}
@ -201,31 +204,30 @@ var _ = Describe("Workspaces Handler", func() {
workspace3 := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceKey3, workspace3)).To(Succeed())
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: workspaceKindName}, workspaceKind)).To(Succeed())
expectedWorkspaces := []models.WorkspaceModel{
repositories.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind),
repositories.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind),
repositories.NewWorkspaceModelFromWorkspace(workspace3, workspaceKind),
}
Expect(workspaces).To(ConsistOf(expectedWorkspaces))
By("ensuring the response contains the expected Workspaces")
Expect(response.Data).To(ConsistOf(
models.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind),
models.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind),
models.NewWorkspaceModelFromWorkspace(workspace3, workspaceKind),
))
By("ensuring the response can be marshaled to JSON and back to []Workspace")
dataJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
var dataObject []models.Workspace
err = json.Unmarshal(dataJSON, &dataObject)
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []Workspace")
})
It("should retrieve the workspaces from namespaceName1 successfully", func() {
It("should retrieve Workspaces from Namespace 1 successfully", func() {
By("creating the HTTP request")
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceName1, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspacesHandler")
ps := httprouter.Params{
httprouter.Param{
Key: NamespacePathParam,
Value: namespaceName1,
},
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
}
rr := httptest.NewRecorder()
a.GetWorkspacesHandler(rr, req, ps)
@ -233,46 +235,313 @@ var _ = Describe("Workspaces Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to WorkspacesEnvelope")
var response WorkspacesEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("converting workspaces Data to JSON and back to []WorkspaceModel")
workspacesJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces repositories")
By("getting the WorkspaceKind from the Kubernetes API")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
var workspaces []models.WorkspaceModel
err = json.Unmarshal(workspacesJSON, &workspaces)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON")
By("asserting that the retrieved workspaces match the expected workspaces")
By("getting the Workspaces from the Kubernetes API")
workspace1 := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceKey1, workspace1)).To(Succeed())
workspace2 := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceKey2, workspace2)).To(Succeed())
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: workspaceKindName}, workspaceKind)).To(Succeed())
expectedWorkspaces := []models.WorkspaceModel{
repositories.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind),
repositories.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind),
}
Expect(workspaces).To(ConsistOf(expectedWorkspaces))
By("ensuring the response contains the expected Workspaces")
Expect(response.Data).To(ConsistOf(
models.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind),
models.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind),
))
By("ensuring the response can be marshaled to JSON and back to []Workspace")
dataJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
var dataObject []models.Workspace
err = json.Unmarshal(dataJSON, &dataObject)
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []Workspace")
})
It("should retrieve a single Workspace successfully", func() {
By("creating the HTTP request")
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName1, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspaceHandler")
ps := httprouter.Params{
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
httprouter.Param{Key: WorkspaceNamePathParam, Value: workspaceName1},
}
rr := httptest.NewRecorder()
a.GetWorkspaceHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON to WorkspaceEnvelope")
var response WorkspaceEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("getting the WorkspaceKind from the Kubernetes API")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
By("getting the Workspace from the Kubernetes API")
workspace := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
By("ensuring the response matches the expected Workspace")
Expect(response.Data).To(BeComparableTo(models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)))
By("ensuring the response can be marshaled to JSON and back to Workspace")
dataJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
var dataObject models.Workspace
err = json.Unmarshal(dataJSON, &dataObject)
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to Workspace")
})
})
Context("when there are no workspaces", func() {
const otherNamespace = "otherNamespace"
// 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 invalid Workspaces", Serial, Ordered, func() {
const namespaceName1 = "ws-invalid-ns1"
var (
a App
workspaceMissingWskName string
workspaceMissingWskKey types.NamespacedName
workspaceInvalidPodConfig string
workspaceInvalidPodConfigKey types.NamespacedName
workspaceInvalidImageConfig string
workspaceInvalidImageConfigKey types.NamespacedName
workspaceKindName string
workspaceKindKey types.NamespacedName
)
BeforeAll(func() {
uniqueName := "ws-invalid-test"
workspaceMissingWskName = fmt.Sprintf("workspace-mising-wsk-%s", uniqueName)
workspaceMissingWskKey = types.NamespacedName{Name: workspaceMissingWskName, Namespace: namespaceName1}
workspaceInvalidPodConfig = fmt.Sprintf("workspace-invalid-pc-%s", uniqueName)
workspaceInvalidPodConfigKey = types.NamespacedName{Name: workspaceInvalidPodConfig, Namespace: namespaceName1}
workspaceInvalidImageConfig = fmt.Sprintf("workspace-invalid-ic-%s", uniqueName)
workspaceInvalidImageConfigKey = types.NamespacedName{Name: workspaceInvalidImageConfig, Namespace: namespaceName1}
workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName)
workspaceKindKey = types.NamespacedName{Name: workspaceKindName}
repos := repositories.NewRepositories(k8sClient)
a = App{
Config: config.EnvConfig{
Port: 4000,
},
repositories: repos,
}
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 with missing WorkspaceKind")
workspaceMissingWsk := NewExampleWorkspace(workspaceMissingWskName, namespaceName1, "bad-wsk")
Expect(k8sClient.Create(ctx, workspaceMissingWsk)).To(Succeed())
By("creating Workspace with invalid PodConfig")
workspaceInvalidPodConfig := NewExampleWorkspace(workspaceInvalidPodConfig, namespaceName1, workspaceKindName)
workspaceInvalidPodConfig.Spec.PodTemplate.Options.PodConfig = "bad-pc"
Expect(k8sClient.Create(ctx, workspaceInvalidPodConfig)).To(Succeed())
By("creating Workspace with invalid ImageConfig")
workspaceInvalidImageConfig := NewExampleWorkspace(workspaceInvalidImageConfig, namespaceName1, workspaceKindName)
workspaceInvalidImageConfig.Spec.PodTemplate.Options.ImageConfig = "bad-ic"
Expect(k8sClient.Create(ctx, workspaceInvalidImageConfig)).To(Succeed())
})
AfterAll(func() {
By("deleting Workspace with missing WorkspaceKind")
workspaceMissingWsk := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceMissingWskName,
Namespace: namespaceName1,
},
}
Expect(k8sClient.Delete(ctx, workspaceMissingWsk)).To(Succeed())
By("deleting Workspace with invalid PodConfig")
workspaceInvalidPodConfig := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceInvalidPodConfig,
Namespace: namespaceName1,
},
}
Expect(k8sClient.Delete(ctx, workspaceInvalidPodConfig)).To(Succeed())
By("deleting Workspace with invalid ImageConfig")
workspaceInvalidImageConfig := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceInvalidImageConfig,
Namespace: namespaceName1,
},
}
Expect(k8sClient.Delete(ctx, workspaceInvalidImageConfig)).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 retrieve invalid Workspaces from Namespace 1 successfully", func() {
By("creating the HTTP request")
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceName1, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspacesHandler")
ps := httprouter.Params{
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
}
rr := httptest.NewRecorder()
a.GetWorkspacesHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON to WorkspacesEnvelope")
var response WorkspacesEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("getting the WorkspaceKind from the Kubernetes API")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
By("getting the Workspaces from the Kubernetes API")
workspaceMissingWsk := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceMissingWskKey, workspaceMissingWsk)).To(Succeed())
workspaceInvalidPodConfig := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceInvalidPodConfigKey, workspaceInvalidPodConfig)).To(Succeed())
workspaceInvalidImageConfig := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceInvalidImageConfigKey, workspaceInvalidImageConfig)).To(Succeed())
By("ensuring the model for Workspace with missing WorkspaceKind is as expected")
workspaceMissingWskModel := models.NewWorkspaceModelFromWorkspace(workspaceMissingWsk, nil)
Expect(workspaceMissingWskModel.WorkspaceKind.Missing).To(BeTrue())
Expect(workspaceMissingWskModel.PodTemplate.Volumes.Home.MountPath).To(Equal(models.UnknownHomeMountPath))
Expect(workspaceMissingWskModel.PodTemplate.Options.PodConfig.Current.DisplayName).To(Equal(models.UnknownPodConfig))
Expect(workspaceMissingWskModel.PodTemplate.Options.PodConfig.Current.Description).To(Equal(models.UnknownPodConfig))
Expect(workspaceMissingWskModel.PodTemplate.Options.ImageConfig.Current.DisplayName).To(Equal(models.UnknownImageConfig))
Expect(workspaceMissingWskModel.PodTemplate.Options.ImageConfig.Current.Description).To(Equal(models.UnknownImageConfig))
By("ensuring the model for Workspace with invalid PodConfig is as expected")
workspaceInvalidPodConfigModel := models.NewWorkspaceModelFromWorkspace(workspaceInvalidPodConfig, workspaceKind)
Expect(workspaceInvalidPodConfigModel.PodTemplate.Options.PodConfig.Current.DisplayName).To(Equal(models.UnknownPodConfig))
Expect(workspaceInvalidPodConfigModel.PodTemplate.Options.PodConfig.Current.Description).To(Equal(models.UnknownPodConfig))
By("ensuring the model for Workspace with invalid ImageConfig is as expected")
workspaceInvalidImageConfigModel := models.NewWorkspaceModelFromWorkspace(workspaceInvalidImageConfig, workspaceKind)
Expect(workspaceInvalidImageConfigModel.PodTemplate.Options.ImageConfig.Current.DisplayName).To(Equal(models.UnknownImageConfig))
Expect(workspaceInvalidImageConfigModel.PodTemplate.Options.ImageConfig.Current.Description).To(Equal(models.UnknownImageConfig))
By("ensuring the response contains the expected Workspaces")
Expect(response.Data).To(ConsistOf(
workspaceMissingWskModel,
workspaceInvalidPodConfigModel,
workspaceInvalidImageConfigModel,
))
})
It("should retrieve a single invalid Workspace successfully", func() {
By("creating the HTTP request")
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceMissingWskName, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspaceHandler")
ps := httprouter.Params{
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
httprouter.Param{Key: WorkspaceNamePathParam, Value: workspaceMissingWskName},
}
rr := httptest.NewRecorder()
a.GetWorkspaceHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON to WorkspaceEnvelope")
var response WorkspaceEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("getting the Workspace from the Kubernetes API")
workspaceMissingWsk := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceMissingWskKey, workspaceMissingWsk)).To(Succeed())
By("ensuring the response matches the expected Workspace")
workspaceMissingWskModel := models.NewWorkspaceModelFromWorkspace(workspaceMissingWsk, nil)
Expect(response.Data).To(BeComparableTo(workspaceMissingWskModel))
})
})
// 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 Workspaces", Serial, func() {
var a App
BeforeEach(func() {
@ -284,18 +553,46 @@ var _ = Describe("Workspaces Handler", func() {
repositories: repos,
}
})
It("should return an empty list of workspaces", func() {
It("should return an empty list of Workspaces for all namespaces", func() {
By("creating the HTTP request")
path := strings.Replace(AllWorkspacesPath, ":"+NamespacePathParam, otherNamespace, 1)
req, err := http.NewRequest(http.MethodGet, AllWorkspacesPath, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspacesHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.GetWorkspacesHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON to WorkspacesEnvelope")
var response WorkspacesEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("ensuring that no Workspaces were returned")
Expect(response.Data).To(BeEmpty())
})
It("should return an empty list of Workspaces for a non-existent namespace", func() {
missingNamespace := "non-existent-namespace"
By("creating the HTTP request")
path := strings.Replace(AllWorkspacesPath, ":"+NamespacePathParam, missingNamespace, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspacesHandler")
ps := httprouter.Params{
httprouter.Param{
Key: NamespacePathParam,
Value: otherNamespace,
},
httprouter.Param{Key: NamespacePathParam, Value: missingNamespace},
}
rr := httptest.NewRecorder()
a.GetWorkspacesHandler(rr, req, ps)
@ -303,40 +600,66 @@ var _ = Describe("Workspaces Handler", func() {
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
Expect(rs.StatusCode).To(Equal(http.StatusOK))
By("reading the HTTP response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
Expect(err).NotTo(HaveOccurred())
By("unmarshalling the response JSON")
By("unmarshalling the response JSON to WorkspacesEnvelope")
var response WorkspacesEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
Expect(err).NotTo(HaveOccurred())
By("asserting that the 'workspaces' list is empty")
workspacesJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces data")
By("ensuring that no Workspaces were returned")
Expect(response.Data).To(BeEmpty())
})
var workspaces []models.WorkspaceModel
err = json.Unmarshal(workspacesJSON, &workspaces)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON")
Expect(workspaces).To(BeEmpty(), "Expected no workspaces in the response")
It("should return 404 for a non-existent Workspace", func() {
missingNamespace := "non-existent-namespace"
missingWorkspaceName := "non-existent-workspace"
By("creating the HTTP request")
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, missingNamespace, 1)
path = strings.Replace(path, ":"+WorkspaceNamePathParam, missingWorkspaceName, 1)
req, err := http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing GetWorkspaceHandler")
ps := httprouter.Params{
httprouter.Param{Key: NamespacePathParam, Value: missingNamespace},
httprouter.Param{Key: WorkspaceNamePathParam, Value: missingWorkspaceName},
}
rr := httptest.NewRecorder()
a.GetWorkspaceHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusNotFound))
})
})
Context("CRUD workspace", Ordered, func() {
// NOTE: the tests in this context work on the same resources, they must be run in order.
// therefore, we run them using the `Ordered` Ginkgo decorator.
Context("CRUD Workspaces", Ordered, func() {
const namespaceNameCrud = "namespace-crud"
const namespaceNameCrud = "ws-crud-ns"
var (
a App
workspaceName string
workspaceKey types.NamespacedName
workspaceKindName string
workspaceKindKey types.NamespacedName
)
BeforeAll(func() {
uniqueName := "wsk-update-test"
uniqueName := "ws-crud-test"
workspaceName = fmt.Sprintf("workspace-%s", uniqueName)
workspaceKey = types.NamespacedName{Name: workspaceName, Namespace: namespaceNameCrud}
workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName)
workspaceKindKey = types.NamespacedName{Name: workspaceKindName}
repos := repositories.NewRepositories(k8sClient)
a = App{
@ -346,7 +669,7 @@ var _ = Describe("Workspaces Handler", func() {
repositories: repos,
}
By("creating namespace")
By("creating the Namespace")
namespaceA := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceNameCrud,
@ -354,14 +677,13 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Create(ctx, namespaceA)).To(Succeed())
By("creating a WorkspaceKind")
By("creating the WorkspaceKind")
workspaceKind := NewExampleWorkspaceKind(workspaceKindName)
Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed())
})
AfterAll(func() {
By("deleting the WorkspaceKind")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
@ -370,86 +692,70 @@ var _ = Describe("Workspaces Handler", func() {
}
Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed())
By("deleting the namespace")
By("deleting the Namespace")
namespaceA := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespaceNameCrud,
},
}
Expect(k8sClient.Delete(ctx, namespaceA)).To(Succeed())
})
It("should create, retrieve and delete workspace successfully", func() {
It("should create and delete a Workspace successfully", func() {
By("creating the workspace via the API")
workspaceName := "dora"
workspaceModel := models.WorkspaceModel{
Name: workspaceName,
Namespace: namespaceNameCrud,
WorkspaceKind: models.WorkspaceKind{
Name: workspaceKindName,
Type: "POD_TEMPLATE",
By("getting the WorkspaceKind from the Kubernetes API")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
By("defining the Workspace to create")
workspace := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceName,
Namespace: namespaceNameCrud,
},
DeferUpdates: false,
Paused: false,
PausedTime: 0,
State: "",
StateMessage: "",
PodTemplate: models.PodTemplate{
PodMetadata: &models.PodMetadata{
Labels: map[string]string{
"app": "dora",
},
Annotations: map[string]string{
"app": "dora",
},
},
Volumes: &models.Volumes{
Home: &models.DataVolumeModel{
PvcName: "my-data-pvc",
MountPath: "/home/jovyan",
ReadOnly: false,
},
Data: []models.DataVolumeModel{
{
PvcName: "my-data-pvc",
MountPath: "/data",
ReadOnly: false,
Spec: kubefloworgv1beta1.WorkspaceSpec{
Kind: workspaceKindName,
Paused: ptr.To(false),
DeferUpdates: ptr.To(false),
PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{
PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{
Labels: map[string]string{
"app": "dora",
},
Annotations: map[string]string{
"app": "dora",
},
},
},
ImageConfig: &models.ImageConfig{
Current: "WorkspaceKind",
Desired: "", // Status is coming with empty value
RedirectChain: []*models.RedirectChain{},
},
PodConfig: &models.PodConfig{
Current: "WorkspaceKind",
Desired: "WorkspaceKind",
RedirectChain: []*models.RedirectChain{},
},
},
Activity: models.Activity{
LastActivity: 0,
LastUpdate: 0,
LastProbe: &models.Probe{
StartTimeMs: 0,
EndTimeMs: 0,
Result: "default_result",
Message: "default_message",
Volumes: kubefloworgv1beta1.WorkspacePodVolumes{
Home: ptr.To("my-home-pvc"),
Data: []kubefloworgv1beta1.PodVolumeMount{
{
PVCName: "my-data-pvc",
MountPath: "/data",
ReadOnly: ptr.To(false),
},
},
},
Options: kubefloworgv1beta1.WorkspacePodOptions{
ImageConfig: "jupyterlab_scipy_180",
PodConfig: "tiny_cpu",
},
},
},
}
workspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)
workspaceJSON, err := json.Marshal(workspaceModel)
Expect(err).NotTo(HaveOccurred(), "Failed to marshal WorkspaceModel to JSON")
By("marshaling the Workspace model to JSON")
workspaceModelJSON, err := json.Marshal(workspaceModel)
Expect(err).NotTo(HaveOccurred())
By("creating an HTTP request to create the Workspace")
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(workspaceJSON)))
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(workspaceModelJSON)))
Expect(err).NotTo(HaveOccurred())
req.Header.Set("Content-Type", "application/json")
By("executing CreateWorkspaceHandler")
rr := httptest.NewRecorder()
ps := httprouter.Params{
httprouter.Param{
@ -457,18 +763,28 @@ var _ = Describe("Workspaces Handler", func() {
Value: namespaceNameCrud,
},
}
a.CreateWorkspaceHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code for creation")
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Expected HTTP status 201 Created")
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusCreated))
By("retrieving the created workspace via the API")
By("getting the created Workspace from the Kubernetes API")
createdWorkspace := &kubefloworgv1beta1.Workspace{}
Expect(k8sClient.Get(ctx, workspaceKey, createdWorkspace)).To(Succeed())
By("ensuring the created Workspace matches the expected Workspace")
Expect(createdWorkspace.Spec).To(BeComparableTo(workspace.Spec))
By("creating an HTTP request to delete the Workspace")
path = strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName, 1)
req, err = http.NewRequest(http.MethodDelete, path, http.NoBody)
Expect(err).NotTo(HaveOccurred())
By("executing DeleteWorkspaceHandler")
rr = httptest.NewRecorder()
ps = httprouter.Params{
httprouter.Param{
Key: NamespacePathParam,
@ -479,69 +795,18 @@ var _ = Describe("Workspaces Handler", func() {
Value: workspaceName,
},
}
req, err = http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
rr = httptest.NewRecorder()
a.GetWorkspaceHandler(rr, req, ps)
rs = rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code for retrieval")
Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK")
By("reading the HTTP response body for retrieval")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body")
By("unmarshalling the response JSON for retrieval")
var response WorkspaceEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON")
response.Data.Activity.LastActivity = 0
By("checking if the retrieved workspace matches the expected workspace")
retrievedWorkspaceJSON, err := json.Marshal(response.Data)
Expect(err).NotTo(HaveOccurred(), "Failed to marshal retrieved workspace to JSON")
originalWorkspaceJSON, err := json.Marshal(workspaceModel)
Expect(err).NotTo(HaveOccurred(), "Failed to marshal original workspace to JSON")
Expect(retrievedWorkspaceJSON).To(MatchJSON(originalWorkspaceJSON), "The retrieved workspace does not match the created one")
By("deleting the workspace via the API")
req, err = http.NewRequest(http.MethodDelete, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request for deletion")
rr = httptest.NewRecorder()
a.DeleteWorkspaceHandler(rr, req, ps)
rs = rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code for deletion")
Expect(rs.StatusCode).To(Equal(http.StatusNoContent), "Expected HTTP status 204 No Content")
By("verifying the workspace has been deleted")
req, err = http.NewRequest(http.MethodGet, path, http.NoBody)
Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request")
rr = httptest.NewRecorder()
a.GetWorkspaceHandler(rr, req, ps)
rs = rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code for not found")
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), "Expected HTTP status 200 OK")
By("double check via k9client")
workspace := &kubefloworgv1beta1.Workspace{}
err = k8sClient.Get(ctx, types.NamespacedName{Name: "dora", Namespace: namespaceNameCrud}, workspace)
Expect(err).To(HaveOccurred(), "Expected error when retrieving the deleted workspace")
Expect(err).To(MatchError(`workspaces.kubeflow.org "dora" not found`))
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusNoContent))
By("ensuring the Workspace has been deleted")
deletedWorkspace := &kubefloworgv1beta1.Workspace{}
err = k8sClient.Get(ctx, workspaceKey, deletedWorkspace)
Expect(err).To(HaveOccurred())
Expect(apierrors.IsNotFound(err)).To(BeTrue())
})
})
})

View File

@ -14,13 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package models
package health_check
type HealthCheck struct {
Status ServiceStatus `json:"status"`
SystemInfo SystemInfo `json:"system_info"`
}
type SystemInfo struct {
Version string `json:"version"`
}
type HealthCheckModel struct {
Status string `json:"status"`
SystemInfo SystemInfo `json:"system_info"`
}
type ServiceStatus string
const (
ServiceStatusHealthy ServiceStatus = "Healthy"
ServiceStatusUnhealthy ServiceStatus = "Unhealthy"
)

View File

@ -0,0 +1,26 @@
/*
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 namespaces
import corev1 "k8s.io/api/core/v1"
// NewNamespaceModelFromNamespace creates a new Namespace model from a Namespace object.
func NewNamespaceModelFromNamespace(ns *corev1.Namespace) Namespace {
return Namespace{
Name: ns.Name,
}
}

View File

@ -14,14 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package models
package namespaces
type NamespaceModel struct {
type Namespace struct {
Name string `json:"name"`
}
func NewNamespaceModelFromNamespace(name string) NamespaceModel {
return NamespaceModel{
Name: name,
}
}

View File

@ -1,97 +0,0 @@
/*
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 models
import (
"strings"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
)
type WorkspaceKindModel struct {
Name string `json:"name"`
PodTemplate PodTemplateModel `json:"pod_template"`
Spawner SpawnerModel `json:"spawner"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
type PodTemplateModel struct {
ImageConfig string `json:"image_config"`
PodConfig string `json:"pod_config"`
Resources ResourceModel `json:"resources"`
}
type ResourceModel struct {
Cpu string `json:"cpu"`
Memory string `json:"memory"`
}
type SpawnerModel struct {
DisplayName string `json:"display_name"`
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
DeprecationMessage string `json:"deprecation_message"`
Hidden bool `json:"hidden"`
}
func NewWorkspaceKindModelFromWorkspaceKind(item *kubefloworgv1beta1.WorkspaceKind) WorkspaceKindModel {
deprecated := false
if item.Spec.Spawner.Deprecated != nil {
deprecated = *item.Spec.Spawner.Deprecated
}
hidden := false
if item.Spec.Spawner.Hidden != nil {
hidden = *item.Spec.Spawner.Hidden
}
deprecationMessage := ""
if item.Spec.Spawner.DeprecationMessage != nil {
deprecationMessage = *item.Spec.Spawner.DeprecationMessage
}
cpuValues := make([]string, len(item.Spec.PodTemplate.Options.PodConfig.Values))
memoryValues := make([]string, len(item.Spec.PodTemplate.Options.PodConfig.Values))
for i, value := range item.Spec.PodTemplate.Options.PodConfig.Values {
cpuValues[i] = value.Spec.Resources.Requests.Cpu().String()
memoryValues[i] = value.Spec.Resources.Requests.Memory().String()
}
workspaceKindModel := WorkspaceKindModel{
Name: item.Name,
Labels: item.Labels,
Annotations: item.Annotations,
Spawner: SpawnerModel{
DisplayName: item.Spec.Spawner.DisplayName,
Description: item.Spec.Spawner.Description,
Deprecated: deprecated,
DeprecationMessage: deprecationMessage,
Hidden: hidden,
},
PodTemplate: PodTemplateModel{
ImageConfig: item.Spec.PodTemplate.Options.ImageConfig.Spawner.Default,
PodConfig: strings.Join(cpuValues, ",") + "|" + strings.Join(memoryValues, ","),
Resources: ResourceModel{
Cpu: strings.Join(cpuValues, ", "),
Memory: strings.Join(memoryValues, ", "),
},
},
}
return workspaceKindModel
}

View File

@ -0,0 +1,155 @@
/*
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 workspacekinds
import (
"fmt"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
"k8s.io/utils/ptr"
)
// NewWorkspaceKindModelFromWorkspaceKind creates a WorkspaceKind model from a WorkspaceKind object.
func NewWorkspaceKindModelFromWorkspaceKind(wsk *kubefloworgv1beta1.WorkspaceKind) WorkspaceKind {
podLabels := make(map[string]string)
podAnnotations := make(map[string]string)
if wsk.Spec.PodTemplate.PodMetadata != nil {
// NOTE: we copy the maps to avoid creating a reference to the original maps.
for k, v := range wsk.Spec.PodTemplate.PodMetadata.Labels {
podLabels[k] = v
}
for k, v := range wsk.Spec.PodTemplate.PodMetadata.Annotations {
podAnnotations[k] = v
}
}
// TODO: icons can either be a remote URL or read from a ConfigMap.
// in BOTH cases, we should cache and serve the image under a path on the backend API:
// /api/v1/workspacekinds/{name}/assets/icon
iconRef := ImageRef{
URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/icon", wsk.Name),
}
// TODO: logos can either be a remote URL or read from a ConfigMap.
// in BOTH cases, we should cache and serve the image under a path on the backend API:
// /api/v1/workspacekinds/{name}/assets/logo
logoRef := ImageRef{
URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/logo", wsk.Name),
}
return WorkspaceKind{
Name: wsk.Name,
DisplayName: wsk.Spec.Spawner.DisplayName,
Description: wsk.Spec.Spawner.Description,
Deprecated: ptr.Deref(wsk.Spec.Spawner.Deprecated, false),
DeprecationMessage: ptr.Deref(wsk.Spec.Spawner.DeprecationMessage, ""),
Hidden: ptr.Deref(wsk.Spec.Spawner.Hidden, false),
Icon: iconRef,
Logo: logoRef,
PodTemplate: PodTemplate{
PodMetadata: PodMetadata{
Labels: podLabels,
Annotations: podAnnotations,
},
VolumeMounts: PodVolumeMounts{
Home: wsk.Spec.PodTemplate.VolumeMounts.Home,
},
Options: PodTemplateOptions{
ImageConfig: ImageConfig{
Default: wsk.Spec.PodTemplate.Options.ImageConfig.Spawner.Default,
Values: buildImageConfigValues(wsk.Spec.PodTemplate.Options.ImageConfig),
},
PodConfig: PodConfig{
Default: wsk.Spec.PodTemplate.Options.PodConfig.Spawner.Default,
Values: buildPodConfigValues(wsk.Spec.PodTemplate.Options.PodConfig),
},
},
},
}
}
func buildImageConfigValues(imageConfig kubefloworgv1beta1.ImageConfig) []ImageConfigValue {
imageConfigValues := make([]ImageConfigValue, len(imageConfig.Values))
for i := range imageConfig.Values {
option := imageConfig.Values[i]
imageConfigValues[i] = ImageConfigValue{
Id: option.Id,
DisplayName: option.Spawner.DisplayName,
Description: ptr.Deref(option.Spawner.Description, ""),
Labels: buildOptionLabels(option.Spawner.Labels),
Hidden: ptr.Deref(option.Spawner.Hidden, false),
Redirect: buildOptionRedirect(option.Redirect),
}
}
return imageConfigValues
}
func buildPodConfigValues(podConfig kubefloworgv1beta1.PodConfig) []PodConfigValue {
podConfigValues := make([]PodConfigValue, len(podConfig.Values))
for i := range podConfig.Values {
option := podConfig.Values[i]
podConfigValues[i] = PodConfigValue{
Id: option.Id,
DisplayName: option.Spawner.DisplayName,
Description: ptr.Deref(option.Spawner.Description, ""),
Labels: buildOptionLabels(option.Spawner.Labels),
Hidden: ptr.Deref(option.Spawner.Hidden, false),
Redirect: buildOptionRedirect(option.Redirect),
}
}
return podConfigValues
}
func buildOptionLabels(labels []kubefloworgv1beta1.OptionSpawnerLabel) []OptionLabel {
optionLabels := make([]OptionLabel, len(labels))
for i := range labels {
optionLabels[i] = OptionLabel{
Key: labels[i].Key,
Value: labels[i].Value,
}
}
return optionLabels
}
func buildOptionRedirect(redirect *kubefloworgv1beta1.OptionRedirect) *OptionRedirect {
if redirect == nil {
return nil
}
var message *RedirectMessage
if redirect.Message != nil {
messageLevel := RedirectMessageLevelInfo
switch redirect.Message.Level {
case kubefloworgv1beta1.RedirectMessageLevelInfo:
messageLevel = RedirectMessageLevelInfo
case kubefloworgv1beta1.RedirectMessageLevelWarning:
messageLevel = RedirectMessageLevelWarning
case kubefloworgv1beta1.RedirectMessageLevelDanger:
messageLevel = RedirectMessageLevelDanger
}
message = &RedirectMessage{
Text: redirect.Message.Text,
Level: messageLevel,
}
}
return &OptionRedirect{
To: redirect.To,
Message: message,
}
}

View File

@ -0,0 +1,104 @@
/*
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 workspacekinds
type WorkspaceKind struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
DeprecationMessage string `json:"deprecation_message"`
Hidden bool `json:"hidden"`
Icon ImageRef `json:"icon"`
Logo ImageRef `json:"logo"`
PodTemplate PodTemplate `json:"pod_template"`
}
type ImageRef struct {
URL string `json:"url"`
}
type PodTemplate struct {
PodMetadata PodMetadata `json:"pod_metadata"`
VolumeMounts PodVolumeMounts `json:"volume_mounts"`
Options PodTemplateOptions `json:"options"`
}
type PodMetadata struct {
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
type PodVolumeMounts struct {
Home string `json:"home"`
}
type PodTemplateOptions struct {
ImageConfig ImageConfig `json:"image_config"`
PodConfig PodConfig `json:"pod_config"`
}
type ImageConfig struct {
Default string `json:"default"`
Values []ImageConfigValue `json:"values"`
}
type ImageConfigValue struct {
Id string `json:"id"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Labels []OptionLabel `json:"labels"`
Hidden bool `json:"hidden"`
Redirect *OptionRedirect `json:"redirect,omitempty"`
}
type PodConfig struct {
Default string `json:"default"`
Values []PodConfigValue `json:"values"`
}
type PodConfigValue struct {
Id string `json:"id"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Labels []OptionLabel `json:"labels"`
Hidden bool `json:"hidden"`
Redirect *OptionRedirect `json:"redirect,omitempty"`
}
type OptionLabel struct {
Key string `json:"key"`
Value string `json:"value"`
}
type OptionRedirect struct {
To string `json:"to"`
Message *RedirectMessage `json:"message,omitempty"`
}
type RedirectMessage struct {
Text string `json:"text"`
Level RedirectMessageLevel `json:"level"`
}
type RedirectMessageLevel string
const (
RedirectMessageLevelInfo RedirectMessageLevel = "Info"
RedirectMessageLevelWarning RedirectMessageLevel = "Warning"
RedirectMessageLevelDanger RedirectMessageLevel = "Danger"
)

View File

@ -1,86 +0,0 @@
/*
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 models
type WorkspaceModel struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
WorkspaceKind WorkspaceKind `json:"workspace_kind"`
DeferUpdates bool `json:"defer_updates"`
Paused bool `json:"paused"`
PausedTime int64 `json:"paused_time"`
State string `json:"state"`
StateMessage string `json:"state_message"`
PodTemplate PodTemplate `json:"pod_template"`
Activity Activity `json:"activity"`
}
type PodTemplate struct {
PodMetadata *PodMetadata `json:"pod_metadata"`
Volumes *Volumes `json:"volumes"`
ImageConfig *ImageConfig `json:"image_config"`
PodConfig *PodConfig `json:"pod_config"`
}
type PodMetadata struct {
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
type Volumes struct {
Home *DataVolumeModel `json:"home"`
Data []DataVolumeModel `json:"data"`
}
type ImageConfig struct {
Current string `json:"current"`
Desired string `json:"desired"`
RedirectChain []*RedirectChain `json:"redirect_chain"`
}
type PodConfig struct {
Current string `json:"current"`
Desired string `json:"desired"`
RedirectChain []*RedirectChain `json:"redirect_chain"`
}
type RedirectChain struct {
Source string `json:"source"`
Target string `json:"target"`
}
type Activity struct {
LastActivity int64 `json:"last_activity"` // Unix Epoch time
LastUpdate int64 `json:"last_update"` // Unix Epoch time
LastProbe *Probe `json:"last_probe"`
}
type Probe struct {
StartTimeMs int64 `json:"start_time_ms"` // Unix Epoch time in milliseconds
EndTimeMs int64 `json:"end_time_ms"` // Unix Epoch time in milliseconds
Result string `json:"result"`
Message string `json:"message"`
}
type WorkspaceKind struct {
Name string `json:"name"`
Type string `json:"type"`
}
type DataVolumeModel struct {
PvcName string `json:"pvc_name"`
MountPath string `json:"mount_path"`
ReadOnly bool `json:"read_only"`
}

View File

@ -0,0 +1,328 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package workspaces
import (
"fmt"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
"k8s.io/utils/ptr"
)
const (
UnknownHomeMountPath = "__UNKNOWN_HOME_MOUNT_PATH__"
UnknownImageConfig = "__UNKNOWN_IMAGE_CONFIG__"
UnknownPodConfig = "__UNKNOWN_POD_CONFIG__"
)
// NewWorkspaceModelFromWorkspace creates a Workspace model from a Workspace and WorkspaceKind object.
// NOTE: the WorkspaceKind might not exist, so we handle the case where it is nil or has no UID.
func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) Workspace {
// ensure the provided WorkspaceKind matches the Workspace
if wskExists(wsk) && ws.Spec.Kind != wsk.Name {
panic("provided WorkspaceKind does not match the Workspace")
}
// TODO: icons can either be a remote URL or read from a ConfigMap.
// in BOTH cases, we should cache and serve the image under a path on the backend API:
// /api/v1/workspacekinds/{name}/assets/icon
iconRef := ImageRef{
URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/icon", ws.Spec.Kind),
}
// TODO: logos can either be a remote URL or read from a ConfigMap.
// in BOTH cases, we should cache and serve the image under a path on the backend API:
// /api/v1/workspacekinds/{name}/assets/logo
logoRef := ImageRef{
URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/logo", ws.Spec.Kind),
}
wsState := WorkspaceStateUnknown
switch ws.Status.State {
case kubefloworgv1beta1.WorkspaceStateRunning:
wsState = WorkspaceStateRunning
case kubefloworgv1beta1.WorkspaceStateTerminating:
wsState = WorkspaceStateTerminating
case kubefloworgv1beta1.WorkspaceStatePaused:
wsState = WorkspaceStatePaused
case kubefloworgv1beta1.WorkspaceStatePending:
wsState = WorkspaceStatePending
case kubefloworgv1beta1.WorkspaceStateError:
wsState = WorkspaceStateError
case kubefloworgv1beta1.WorkspaceStateUnknown:
wsState = WorkspaceStateUnknown
}
podLabels := make(map[string]string)
podAnnotations := make(map[string]string)
if ws.Spec.PodTemplate.PodMetadata != nil {
// NOTE: we copy the maps to avoid creating a reference to the original maps.
for k, v := range ws.Spec.PodTemplate.PodMetadata.Labels {
podLabels[k] = v
}
for k, v := range ws.Spec.PodTemplate.PodMetadata.Annotations {
podAnnotations[k] = v
}
}
dataVolumes := make([]PodVolumeInfo, len(ws.Spec.PodTemplate.Volumes.Data))
for i := range ws.Spec.PodTemplate.Volumes.Data {
volume := ws.Spec.PodTemplate.Volumes.Data[i]
readOnly := false
if volume.ReadOnly != nil {
readOnly = *volume.ReadOnly
}
dataVolumes[i] = PodVolumeInfo{
PvcName: volume.PVCName,
MountPath: volume.MountPath,
ReadOnly: readOnly,
}
}
workspaceModel := Workspace{
Name: ws.Name,
Namespace: ws.Namespace,
WorkspaceKind: WorkspaceKindInfo{
Name: ws.Spec.Kind,
Missing: !wskExists(wsk),
Icon: iconRef,
Logo: logoRef,
},
DeferUpdates: ptr.Deref(ws.Spec.DeferUpdates, false),
Paused: ptr.Deref(ws.Spec.Paused, false),
PausedTime: ws.Status.PauseTime,
State: wsState,
StateMessage: ws.Status.StateMessage,
PodTemplate: PodTemplate{
PodMetadata: PodMetadata{
Labels: podLabels,
Annotations: podAnnotations,
},
Volumes: PodVolumes{
Home: buildHomeVolume(ws, wsk),
Data: dataVolumes,
},
Options: PodTemplateOptions{
ImageConfig: buildImageConfig(ws, wsk),
PodConfig: buildPodConfig(ws, wsk),
},
},
Activity: Activity{
LastActivity: ws.Status.Activity.LastActivity,
LastUpdate: ws.Status.Activity.LastUpdate,
// TODO: populate LastProbe when culling is implemented:
// https://github.com/kubeflow/notebooks/issues/38
LastProbe: nil,
},
}
return workspaceModel
}
func wskExists(wsk *kubefloworgv1beta1.WorkspaceKind) bool {
return wsk != nil && wsk.UID != ""
}
func buildHomeVolume(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) *PodVolumeInfo {
if ws.Spec.PodTemplate.Volumes.Home == nil {
return nil
}
// we only know the mount path if the WorkspaceKind exists
homeMountPath := UnknownHomeMountPath
if wskExists(wsk) {
homeMountPath = wsk.Spec.PodTemplate.VolumeMounts.Home
}
return &PodVolumeInfo{
PvcName: *ws.Spec.PodTemplate.Volumes.Home,
MountPath: homeMountPath,
// the home volume is ~always~ read-write
ReadOnly: false,
}
}
func buildImageConfig(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) ImageConfig {
// create a map of image configs from the WorkspaceKind for easy lookup by ID
// NOTE: we can only build this map if the WorkspaceKind exists, otherwise it will be empty
imageConfigMap := make(map[string]kubefloworgv1beta1.ImageConfigValue)
if wskExists(wsk) {
imageConfigMap = make(map[string]kubefloworgv1beta1.ImageConfigValue, len(wsk.Spec.PodTemplate.Options.ImageConfig.Values))
for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values {
imageConfigMap[value.Id] = value
}
}
// get the current image config
currentImageConfig := OptionInfo{
Id: ws.Spec.PodTemplate.Options.ImageConfig,
DisplayName: UnknownImageConfig,
Description: UnknownImageConfig,
Labels: nil,
}
if cfg, ok := imageConfigMap[currentImageConfig.Id]; ok {
currentImageConfig.DisplayName = cfg.Spawner.DisplayName
currentImageConfig.Description = ptr.Deref(cfg.Spawner.Description, "")
currentImageConfig.Labels = buildOptionLabels(cfg.Spawner.Labels)
}
// get the desired image config
// NOTE: the desired image config will be nil if it is the same as the current image config
var desiredImageConfig *OptionInfo
desiredImageConfigId := ws.Status.PodTemplateOptions.ImageConfig.Desired
if desiredImageConfigId != "" && desiredImageConfigId != currentImageConfig.Id {
desiredImageConfig = &OptionInfo{
Id: desiredImageConfigId,
DisplayName: UnknownImageConfig,
Description: UnknownImageConfig,
Labels: nil,
}
if cfg, ok := imageConfigMap[desiredImageConfig.Id]; ok {
desiredImageConfig.DisplayName = cfg.Spawner.DisplayName
desiredImageConfig.Description = ptr.Deref(cfg.Spawner.Description, "")
desiredImageConfig.Labels = buildOptionLabels(cfg.Spawner.Labels)
}
}
// build the redirect chain
// NOTE: the redirect chain will be nil (not an empty slice) if there are no redirects
var redirectChain []RedirectStep
numRedirects := len(ws.Status.PodTemplateOptions.ImageConfig.RedirectChain)
if numRedirects > 0 {
redirectChain = make([]RedirectStep, numRedirects)
}
for i := range ws.Status.PodTemplateOptions.ImageConfig.RedirectChain {
step := ws.Status.PodTemplateOptions.ImageConfig.RedirectChain[i]
redirectChain[i] = RedirectStep{
SourceId: step.Source,
TargetId: step.Target,
}
if cfg, ok := imageConfigMap[step.Source]; ok {
// skip the redirect if it's not the target we expect
if cfg.Redirect != nil && cfg.Redirect.To == step.Target {
redirectChain[i].Message = buildRedirectMessage(cfg.Redirect.Message)
}
}
}
return ImageConfig{
Current: currentImageConfig,
Desired: desiredImageConfig,
RedirectChain: redirectChain,
}
}
func buildPodConfig(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) PodConfig {
// create a map of pod configs from the WorkspaceKind for easy lookup by ID
// NOTE: we can only build this map if the WorkspaceKind exists, otherwise it will be empty
podConfigMap := make(map[string]kubefloworgv1beta1.PodConfigValue)
if wskExists(wsk) {
podConfigMap = make(map[string]kubefloworgv1beta1.PodConfigValue, len(wsk.Spec.PodTemplate.Options.PodConfig.Values))
for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values {
podConfigMap[value.Id] = value
}
}
// get the current pod config
currentPodConfig := OptionInfo{
Id: ws.Spec.PodTemplate.Options.PodConfig,
DisplayName: UnknownPodConfig,
Description: UnknownPodConfig,
Labels: nil,
}
if cfg, ok := podConfigMap[currentPodConfig.Id]; ok {
currentPodConfig.DisplayName = cfg.Spawner.DisplayName
currentPodConfig.Description = ptr.Deref(cfg.Spawner.Description, "")
currentPodConfig.Labels = buildOptionLabels(cfg.Spawner.Labels)
}
// get the desired pod config
// NOTE: the desired pod config will be nil if it is the same as the current pod config
var desiredPodConfig *OptionInfo
desiredPodConfigId := ws.Status.PodTemplateOptions.PodConfig.Desired
if desiredPodConfigId != "" && desiredPodConfigId != currentPodConfig.Id {
desiredPodConfig = &OptionInfo{
Id: desiredPodConfigId,
DisplayName: UnknownPodConfig,
Description: UnknownPodConfig,
Labels: nil,
}
if cfg, ok := podConfigMap[desiredPodConfig.Id]; ok {
desiredPodConfig.DisplayName = cfg.Spawner.DisplayName
desiredPodConfig.Description = ptr.Deref(cfg.Spawner.Description, "")
desiredPodConfig.Labels = buildOptionLabels(cfg.Spawner.Labels)
}
}
// build the redirect chain
// NOTE: the redirect chain will be nil (not an empty slice) if there are no redirects
var redirectChain []RedirectStep
numRedirects := len(ws.Status.PodTemplateOptions.PodConfig.RedirectChain)
if numRedirects > 0 {
redirectChain = make([]RedirectStep, numRedirects)
}
for i := range ws.Status.PodTemplateOptions.PodConfig.RedirectChain {
step := ws.Status.PodTemplateOptions.PodConfig.RedirectChain[i]
redirectChain[i] = RedirectStep{
SourceId: step.Source,
TargetId: step.Target,
}
if cfg, ok := podConfigMap[step.Source]; ok {
// skip the redirect if it's not the target we expect
if cfg.Redirect != nil && cfg.Redirect.To == step.Target {
redirectChain[i].Message = buildRedirectMessage(cfg.Redirect.Message)
}
}
}
return PodConfig{
Current: currentPodConfig,
Desired: desiredPodConfig,
RedirectChain: redirectChain,
}
}
func buildOptionLabels(labels []kubefloworgv1beta1.OptionSpawnerLabel) []OptionLabel {
optionLabels := make([]OptionLabel, len(labels))
for i := range labels {
optionLabels[i] = OptionLabel{
Key: labels[i].Key,
Value: labels[i].Value,
}
}
return optionLabels
}
func buildRedirectMessage(msg *kubefloworgv1beta1.RedirectMessage) *RedirectMessage {
if msg == nil {
return nil
}
messageLevel := RedirectMessageLevelInfo
switch msg.Level {
case kubefloworgv1beta1.RedirectMessageLevelInfo:
messageLevel = RedirectMessageLevelInfo
case kubefloworgv1beta1.RedirectMessageLevelWarning:
messageLevel = RedirectMessageLevelWarning
case kubefloworgv1beta1.RedirectMessageLevelDanger:
messageLevel = RedirectMessageLevelDanger
}
return &RedirectMessage{
Text: msg.Text,
Level: messageLevel,
}
}

View File

@ -0,0 +1,143 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package workspaces
type Workspace struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
WorkspaceKind WorkspaceKindInfo `json:"workspace_kind"`
DeferUpdates bool `json:"defer_updates"`
Paused bool `json:"paused"`
PausedTime int64 `json:"paused_time"`
State WorkspaceState `json:"state"`
StateMessage string `json:"state_message"`
PodTemplate PodTemplate `json:"pod_template"`
Activity Activity `json:"activity"`
}
type WorkspaceState string
const (
WorkspaceStateRunning WorkspaceState = "Running"
WorkspaceStateTerminating WorkspaceState = "Terminating"
WorkspaceStatePaused WorkspaceState = "Paused"
WorkspaceStatePending WorkspaceState = "Pending"
WorkspaceStateError WorkspaceState = "Error"
WorkspaceStateUnknown WorkspaceState = "Unknown"
)
type WorkspaceKindInfo struct {
Name string `json:"name"`
Missing bool `json:"missing"`
Icon ImageRef `json:"icon"`
Logo ImageRef `json:"logo"`
}
type ImageRef struct {
URL string `json:"url"`
}
type PodTemplate struct {
PodMetadata PodMetadata `json:"pod_metadata"`
Volumes PodVolumes `json:"volumes"`
Options PodTemplateOptions `json:"options"`
}
type PodMetadata struct {
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
type PodVolumes struct {
Home *PodVolumeInfo `json:"home,omitempty"`
Data []PodVolumeInfo `json:"data"`
}
type PodVolumeInfo struct {
PvcName string `json:"pvc_name"`
MountPath string `json:"mount_path"`
ReadOnly bool `json:"read_only"`
}
type PodTemplateOptions struct {
ImageConfig ImageConfig `json:"image_config"`
PodConfig PodConfig `json:"pod_config"`
}
type ImageConfig struct {
Current OptionInfo `json:"current"`
Desired *OptionInfo `json:"desired,omitempty"`
RedirectChain []RedirectStep `json:"redirect_chain,omitempty"`
}
type PodConfig struct {
Current OptionInfo `json:"current"`
Desired *OptionInfo `json:"desired,omitempty"`
RedirectChain []RedirectStep `json:"redirect_chain,omitempty"`
}
type OptionInfo struct {
Id string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Labels []OptionLabel `json:"labels"`
}
type OptionLabel struct {
Key string `json:"key"`
Value string `json:"value"`
}
type RedirectStep struct {
SourceId string `json:"source_id"`
TargetId string `json:"target_id"`
Message *RedirectMessage `json:"message,omitempty"`
}
type RedirectMessage struct {
Text string `json:"text"`
Level RedirectMessageLevel `json:"level"`
}
type RedirectMessageLevel string
const (
RedirectMessageLevelInfo RedirectMessageLevel = "Info"
RedirectMessageLevelWarning RedirectMessageLevel = "Warning"
RedirectMessageLevelDanger RedirectMessageLevel = "Danger"
)
type Activity struct {
LastActivity int64 `json:"last_activity"` // Unix Epoch time
LastUpdate int64 `json:"last_update"` // Unix Epoch time
LastProbe *LastProbeInfo `json:"last_probe,omitempty"`
}
type LastProbeInfo struct {
StartTimeMs int64 `json:"start_time_ms"` // Unix Epoch time in milliseconds
EndTimeMs int64 `json:"end_time_ms"` // Unix Epoch time in milliseconds
Result ProbeResult `json:"result"`
Message string `json:"message"`
}
type ProbeResult string
const (
ProbeResultSuccess ProbeResult = "Success"
ProbeResultFailure ProbeResult = "Failure"
ProbeResultTimeout ProbeResult = "Timeout"
)

View File

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package repositories
package health_check
import "github.com/kubeflow/notebooks/workspaces/backend/internal/models"
import (
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/health_check"
)
type HealthCheckRepository struct{}
@ -24,10 +26,11 @@ func NewHealthCheckRepository() *HealthCheckRepository {
return &HealthCheckRepository{}
}
func (r *HealthCheckRepository) HealthCheck(version string) (models.HealthCheckModel, error) {
func (r *HealthCheckRepository) HealthCheck(version string) (models.HealthCheck, error) {
var res = models.HealthCheckModel{
Status: "available",
var res = models.HealthCheck{
// TODO: implement actual health check logic
Status: models.ServiceStatusHealthy,
SystemInfo: models.SystemInfo{
Version: version,
},

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package repositories
package namespaces
import (
"context"
@ -22,7 +22,7 @@ import (
v1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces"
)
type NamespaceRepository struct {
@ -35,7 +35,7 @@ func NewNamespaceRepository(cl client.Client) *NamespaceRepository {
}
}
func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.NamespaceModel, error) {
func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.Namespace, error) {
// TODO(ederign): Implement subject access review here to fetch only
// namespaces that "kubeflow-userid" has access to
@ -45,9 +45,10 @@ func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.Names
return nil, err
}
namespaces := make([]models.NamespaceModel, len(namespaceList.Items))
for i, ns := range namespaceList.Items {
namespaces[i] = models.NewNamespaceModelFromNamespace(ns.Name)
namespaces := make([]models.Namespace, len(namespaceList.Items))
for i := range namespaceList.Items {
namespace := &namespaceList.Items[i]
namespaces[i] = models.NewNamespaceModelFromNamespace(namespace)
}
return namespaces, nil
}

View File

@ -18,21 +18,27 @@ package repositories
import (
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/health_check"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/namespaces"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
)
// Models struct is a single convenient container to hold and represent all our repositories.
// Repositories is a single convenient container to hold and represent all our repositories.
type Repositories struct {
HealthCheck *HealthCheckRepository
Workspace *WorkspaceRepository
WorkspaceKind *WorkspaceKindRepository
Namespace *NamespaceRepository
HealthCheck *health_check.HealthCheckRepository
Namespace *namespaces.NamespaceRepository
Workspace *workspaces.WorkspaceRepository
WorkspaceKind *workspacekinds.WorkspaceKindRepository
}
// NewRepositories creates a new Repositories instance from a controller-runtime client.
func NewRepositories(cl client.Client) *Repositories {
return &Repositories{
HealthCheck: NewHealthCheckRepository(),
Workspace: NewWorkspaceRepository(cl),
WorkspaceKind: NewWorkspaceKindRepository(cl),
Namespace: NewNamespaceRepository(cl),
HealthCheck: health_check.NewHealthCheckRepository(),
Namespace: namespaces.NewNamespaceRepository(cl),
Workspace: workspaces.NewWorkspaceRepository(cl),
WorkspaceKind: workspacekinds.NewWorkspaceKindRepository(cl),
}
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package repositories
package workspacekinds
import (
"context"
@ -24,7 +24,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
)
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
@ -39,21 +39,21 @@ func NewWorkspaceKindRepository(cl client.Client) *WorkspaceKindRepository {
}
}
func (r *WorkspaceKindRepository) GetWorkspaceKind(ctx context.Context, name string) (models.WorkspaceKindModel, error) {
func (r *WorkspaceKindRepository) GetWorkspaceKind(ctx context.Context, name string) (models.WorkspaceKind, error) {
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
err := r.client.Get(ctx, client.ObjectKey{Name: name}, workspaceKind)
if err != nil {
if apierrors.IsNotFound(err) {
return models.WorkspaceKindModel{}, ErrWorkspaceKindNotFound
return models.WorkspaceKind{}, ErrWorkspaceKindNotFound
}
return models.WorkspaceKindModel{}, err
return models.WorkspaceKind{}, err
}
workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind)
return workspaceKindModel, nil
}
func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKindModel, error) {
func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKind, error) {
workspaceKindList := &kubefloworgv1beta1.WorkspaceKindList{}
err := r.client.List(ctx, workspaceKindList)
@ -61,10 +61,10 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
return nil, err
}
workspaceKindsModels := make([]models.WorkspaceKindModel, len(workspaceKindList.Items))
for i, item := range workspaceKindList.Items {
workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(&item)
workspaceKindsModels[i] = workspaceKindModel
workspaceKindsModels := make([]models.WorkspaceKind, len(workspaceKindList.Items))
for i := range workspaceKindList.Items {
workspaceKind := &workspaceKindList.Items[i]
workspaceKindsModels[i] = models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind)
}
return workspaceKindsModels, nil

View File

@ -1,259 +0,0 @@
/*
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 repositories
import (
"context"
"errors"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
)
var ErrWorkspaceNotFound = errors.New("workspace not found")
type WorkspaceRepository struct {
client client.Client
}
func NewWorkspaceRepository(cl client.Client) *WorkspaceRepository {
return &WorkspaceRepository{
client: cl,
}
}
func (r *WorkspaceRepository) GetWorkspace(ctx context.Context, namespace string, workspaceName string) (models.WorkspaceModel, error) {
workspace := &kubefloworgv1beta1.Workspace{}
if err := r.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: workspaceName}, workspace); err != nil {
if apierrors.IsNotFound(err) {
return models.WorkspaceModel{}, ErrWorkspaceNotFound
}
return models.WorkspaceModel{}, err
}
kind := &kubefloworgv1beta1.WorkspaceKind{}
if err := r.client.Get(ctx, client.ObjectKey{Name: workspace.Spec.Kind}, kind); err != nil {
return models.WorkspaceModel{}, err
}
return NewWorkspaceModelFromWorkspace(workspace, kind), nil
}
func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace string) ([]models.WorkspaceModel, error) {
workspaceList := &kubefloworgv1beta1.WorkspaceList{}
listOptions := []client.ListOption{
client.InNamespace(namespace),
}
err := r.client.List(ctx, workspaceList, listOptions...)
if err != nil {
return nil, err
}
workspacesModels := make([]models.WorkspaceModel, len(workspaceList.Items))
for i, item := range workspaceList.Items {
kind := &kubefloworgv1beta1.WorkspaceKind{}
if err := r.client.Get(ctx, client.ObjectKey{Name: item.Spec.Kind}, kind); err != nil {
return nil, err
}
workspacesModels[i] = NewWorkspaceModelFromWorkspace(&item, kind)
}
return workspacesModels, nil
}
func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.WorkspaceModel, error) {
workspaceList := &kubefloworgv1beta1.WorkspaceList{}
if err := r.client.List(ctx, workspaceList); err != nil {
return nil, err
}
workspacesModels := make([]models.WorkspaceModel, len(workspaceList.Items))
for i, item := range workspaceList.Items {
kind := &kubefloworgv1beta1.WorkspaceKind{}
if err := r.client.Get(ctx, client.ObjectKey{Name: item.Spec.Kind}, kind); err != nil {
return nil, err
}
workspacesModels[i] = NewWorkspaceModelFromWorkspace(&item, kind)
}
return workspacesModels, nil
}
func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceModel *models.WorkspaceModel) (models.WorkspaceModel, error) {
// TODO: review all fields
workspace := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceModel.Name,
Namespace: workspaceModel.Namespace,
// TODO: the pod and workspace labels should be separated
Labels: workspaceModel.PodTemplate.PodMetadata.Labels,
Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations,
},
Spec: kubefloworgv1beta1.WorkspaceSpec{
Paused: &workspaceModel.Paused,
DeferUpdates: &workspaceModel.DeferUpdates,
// TODO: verify if workspace kind exists on validation
Kind: workspaceModel.WorkspaceKind.Name,
PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{
PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{
Labels: workspaceModel.PodTemplate.PodMetadata.Labels,
Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations,
},
Volumes: kubefloworgv1beta1.WorkspacePodVolumes{
Home: &workspaceModel.PodTemplate.Volumes.Home.PvcName,
Data: []kubefloworgv1beta1.PodVolumeMount{},
},
Options: kubefloworgv1beta1.WorkspacePodOptions{
ImageConfig: workspaceModel.PodTemplate.ImageConfig.Current,
PodConfig: workspaceModel.PodTemplate.PodConfig.Current,
},
},
},
}
// TODO: create data volumes if necessary
workspace.Spec.PodTemplate.Volumes.Data = make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceModel.PodTemplate.Volumes.Data))
for i, dataVolume := range workspaceModel.PodTemplate.Volumes.Data {
// make a copy of readOnly because dataVolume is reassigned each loop
readOnly := dataVolume.ReadOnly
workspace.Spec.PodTemplate.Volumes.Data[i] = kubefloworgv1beta1.PodVolumeMount{
PVCName: dataVolume.PvcName,
MountPath: dataVolume.MountPath,
ReadOnly: &readOnly,
}
}
if err := r.client.Create(ctx, workspace); err != nil {
return models.WorkspaceModel{}, err
}
kind := &kubefloworgv1beta1.WorkspaceKind{}
if err := r.client.Get(ctx, client.ObjectKey{Name: workspace.Spec.Kind}, kind); err != nil {
return models.WorkspaceModel{}, err
}
return NewWorkspaceModelFromWorkspace(workspace, kind), nil
}
func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, workspaceName string) error {
workspace := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: workspaceName,
},
}
if err := r.client.Delete(ctx, workspace); err != nil {
if apierrors.IsNotFound(err) {
return ErrWorkspaceNotFound
}
return err
}
return nil
}
func NewWorkspaceModelFromWorkspace(item *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) models.WorkspaceModel {
dataVolumes := make([]models.DataVolumeModel, len(item.Spec.PodTemplate.Volumes.Data))
for i, volume := range item.Spec.PodTemplate.Volumes.Data {
dataVolumes[i] = models.DataVolumeModel{
PvcName: volume.PVCName,
MountPath: volume.MountPath,
ReadOnly: *volume.ReadOnly,
}
}
imageConfigRedirectChain := make([]*models.RedirectChain, len(item.Status.PodTemplateOptions.ImageConfig.RedirectChain))
for i, chain := range item.Status.PodTemplateOptions.ImageConfig.RedirectChain {
imageConfigRedirectChain[i] = &models.RedirectChain{
Source: chain.Source,
Target: chain.Target,
}
}
podConfigRedirectChain := make([]*models.RedirectChain, len(item.Status.PodTemplateOptions.PodConfig.RedirectChain))
for i, chain := range item.Status.PodTemplateOptions.PodConfig.RedirectChain {
podConfigRedirectChain[i] = &models.RedirectChain{
Source: chain.Source,
Target: chain.Target,
}
}
podMetadataLabels := item.Spec.PodTemplate.PodMetadata.Labels
if podMetadataLabels == nil {
podMetadataLabels = map[string]string{}
}
podMetadataAnnotations := item.Spec.PodTemplate.PodMetadata.Annotations
if podMetadataAnnotations == nil {
podMetadataAnnotations = map[string]string{}
}
workspaceModel := models.WorkspaceModel{
Name: item.ObjectMeta.Name,
Namespace: item.Namespace,
WorkspaceKind: models.WorkspaceKind{
Name: item.Spec.Kind,
Type: "POD_TEMPLATE",
},
DeferUpdates: *item.Spec.DeferUpdates,
Paused: *item.Spec.Paused,
PausedTime: item.Status.PauseTime,
State: string(item.Status.State),
StateMessage: item.Status.StateMessage,
PodTemplate: models.PodTemplate{
PodMetadata: &models.PodMetadata{
Labels: podMetadataLabels,
Annotations: podMetadataAnnotations,
},
Volumes: &models.Volumes{
Home: &models.DataVolumeModel{
PvcName: *item.Spec.PodTemplate.Volumes.Home,
MountPath: wsk.Spec.PodTemplate.VolumeMounts.Home,
ReadOnly: false, // From where to get this value?
},
Data: dataVolumes,
},
ImageConfig: &models.ImageConfig{
Current: item.Spec.PodTemplate.Options.ImageConfig,
Desired: item.Status.PodTemplateOptions.ImageConfig.Desired,
RedirectChain: imageConfigRedirectChain,
},
PodConfig: &models.PodConfig{
Current: item.Spec.PodTemplate.Options.PodConfig,
Desired: item.Spec.PodTemplate.Options.PodConfig,
RedirectChain: podConfigRedirectChain,
},
},
Activity: models.Activity{
LastActivity: item.Status.Activity.LastActivity,
LastUpdate: item.Status.Activity.LastUpdate,
// TODO: update these fields when the last probe is implemented
LastProbe: &models.Probe{
StartTimeMs: 0,
EndTimeMs: 0,
Result: "default_result",
Message: "default_message",
},
},
}
return workspaceModel
}

View File

@ -0,0 +1,206 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package workspaces
import (
"context"
"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/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces"
)
var ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
var ErrRefWorkspaceKindNotExists = fmt.Errorf("referenced WorkspaceKind does not exist")
type WorkspaceRepository struct {
client client.Client
}
func NewWorkspaceRepository(cl client.Client) *WorkspaceRepository {
return &WorkspaceRepository{
client: cl,
}
}
func (r *WorkspaceRepository) GetWorkspace(ctx context.Context, namespace string, workspaceName string) (models.Workspace, error) {
// get workspace
workspace := &kubefloworgv1beta1.Workspace{}
if err := r.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: workspaceName}, workspace); err != nil {
if apierrors.IsNotFound(err) {
return models.Workspace{}, ErrWorkspaceNotFound
}
return models.Workspace{}, err
}
// get workspace kind, if it exists
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
workspaceKindName := workspace.Spec.Kind
if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil {
// ignore error if workspace kind does not exist, as we can still create a model without it
if !apierrors.IsNotFound(err) {
return models.Workspace{}, err
}
}
// convert workspace to model
workspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)
return workspaceModel, nil
}
func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace string) ([]models.Workspace, error) {
// get all workspaces in the namespace
workspaceList := &kubefloworgv1beta1.WorkspaceList{}
listOptions := []client.ListOption{
client.InNamespace(namespace),
}
err := r.client.List(ctx, workspaceList, listOptions...)
if err != nil {
return nil, err
}
// convert workspaces to models
workspacesModels := make([]models.Workspace, len(workspaceList.Items))
for i, workspace := range workspaceList.Items {
// get workspace kind, if it exists
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
workspaceKindName := workspace.Spec.Kind
if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil {
// ignore error if workspace kind does not exist, as we can still create a model without it
if !apierrors.IsNotFound(err) {
return nil, err
}
}
workspacesModels[i] = models.NewWorkspaceModelFromWorkspace(&workspace, workspaceKind)
}
return workspacesModels, nil
}
func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.Workspace, error) {
// get all workspaces in the cluster
workspaceList := &kubefloworgv1beta1.WorkspaceList{}
if err := r.client.List(ctx, workspaceList); err != nil {
return nil, err
}
// convert workspaces to models
workspacesModels := make([]models.Workspace, len(workspaceList.Items))
for i, workspace := range workspaceList.Items {
// get workspace kind, if it exists
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
workspaceKindName := workspace.Spec.Kind
if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil {
// ignore error if workspace kind does not exist, as we can still create a model without it
if !apierrors.IsNotFound(err) {
return nil, err
}
}
workspacesModels[i] = models.NewWorkspaceModelFromWorkspace(&workspace, workspaceKind)
}
return workspacesModels, nil
}
func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceModel *models.Workspace) (models.Workspace, error) {
// get workspace kind
// NOTE: if the referenced workspace kind does not exist,
// we throw an error because the api would reject the workspace creation
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
workspaceKindName := workspaceModel.WorkspaceKind.Name
if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil {
if apierrors.IsNotFound(err) {
return models.Workspace{}, fmt.Errorf("%w: %s", ErrRefWorkspaceKindNotExists, workspaceKindName)
}
return models.Workspace{}, err
}
// get data volumes from workspace model
dataVolumeMounts := make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceModel.PodTemplate.Volumes.Data))
for i, dataVolume := range workspaceModel.PodTemplate.Volumes.Data {
dataVolumeMounts[i] = kubefloworgv1beta1.PodVolumeMount{
PVCName: dataVolume.PvcName,
MountPath: dataVolume.MountPath,
ReadOnly: ptr.To(dataVolume.ReadOnly),
}
}
// define workspace object from model
workspace := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceModel.Name,
Namespace: workspaceModel.Namespace,
},
Spec: kubefloworgv1beta1.WorkspaceSpec{
Paused: &workspaceModel.Paused,
DeferUpdates: &workspaceModel.DeferUpdates,
Kind: workspaceKindName,
PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{
PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{
Labels: workspaceModel.PodTemplate.PodMetadata.Labels,
Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations,
},
Volumes: kubefloworgv1beta1.WorkspacePodVolumes{
Home: &workspaceModel.PodTemplate.Volumes.Home.PvcName,
Data: dataVolumeMounts,
},
Options: kubefloworgv1beta1.WorkspacePodOptions{
ImageConfig: workspaceModel.PodTemplate.Options.ImageConfig.Current.Id,
PodConfig: workspaceModel.PodTemplate.Options.PodConfig.Current.Id,
},
},
},
}
// create workspace
if err := r.client.Create(ctx, workspace); err != nil {
return models.Workspace{}, err
}
// convert the created workspace to a model
createdWorkspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)
return createdWorkspaceModel, nil
}
func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, workspaceName string) error {
workspace := &kubefloworgv1beta1.Workspace{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: workspaceName,
},
}
if err := r.client.Delete(ctx, workspace); err != nil {
if apierrors.IsNotFound(err) {
return ErrWorkspaceNotFound
}
return err
}
return nil
}