Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion workspaces/backend/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/kubeflow/notebooks/workspaces/backend/api/constants"
Expand Down Expand Up @@ -54,6 +55,7 @@ func NewApp(
scheme *runtime.Scheme,
reqAuthN authenticator.Request,
reqAuthZ authorizer.Authorizer,
clientset kubernetes.Interface,
) (*App, error) {

// TODO: log the configuration on startup
Expand All @@ -68,7 +70,7 @@ func NewApp(
app := &App{
Config: cfg,
logger: logger,
repositories: repositories.NewRepositories(cfg, cl, configMapClient),
repositories: repositories.NewRepositories(cfg, cl, configMapClient, clientset),
Scheme: scheme,
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
RequestAuthN: reqAuthN,
Expand Down Expand Up @@ -106,6 +108,7 @@ func (a *App) Routes() http.Handler {
router.DELETE(constants.WorkspacesByNamePath, a.DeleteWorkspaceHandler)
router.POST(constants.PauseWorkspacePath, a.PauseActionWorkspaceHandler)
router.GET(constants.WorkspacePodTemplateDetailsPath, a.GetWorkspacePodTemplateDetailsHandler)
router.GET(constants.WorkspacePodLogsBatchPath, a.GetWorkspaceLogsHandler)

// workspacekinds
router.GET(constants.AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
Expand Down
1 change: 1 addition & 0 deletions workspaces/backend/api/constants/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
WorkspaceActionsPath = WorkspacesByNamePath + "/actions"
PauseWorkspacePath = WorkspaceActionsPath + "/pause"
WorkspacePodTemplateDetailsPath = WorkspacesByNamePath + "/podtemplate/details"
WorkspacePodLogsBatchPath = WorkspacesByNamePath + "/podtemplate/logs/batch"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
WorkspacePodLogsBatchPath = WorkspacesByNamePath + "/podtemplate/logs/batch"
WorkspacePodTemplatePodLogsBatchPath = WorkspacesByNamePath + "/podtemplate/logs/batch"


// workspacekinds
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
Expand Down
12 changes: 12 additions & 0 deletions workspaces/backend/api/response_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,18 @@ func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) {
a.errorResponse(w, r, httpError)
}

// HTTP: 404 with a caller-provided message.
func (a *App) notFoundResponseWithMessage(w http.ResponseWriter, r *http.Request, err error) {
httpError := &HTTPError{
StatusCode: http.StatusNotFound,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusNotFound),
Message: err.Error(),
},
}
a.errorResponse(w, r, httpError)
}

Comment on lines +188 to +199

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to discussion - but I'm not sure this function is really warranted.. all existing endpoints use the existing a.notFoundResponse(w, r) and I think that works well enough as is..

Would prefer to keep with convention here - but let me know if I am overlooking something and/or why you think we should add this...

// HTTP: 405
func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
httpError := &HTTPError{
Expand Down
18 changes: 18 additions & 0 deletions workspaces/backend/api/response_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package api

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
Expand Down Expand Up @@ -273,4 +274,21 @@ var _ = Describe("Error Response Functions", func() {
})
}
})

Describe("notFoundResponseWithMessage", func() {
It("should return 404 with the provided error message", func() {
testErr := errors.New("workspace not found")

app.notFoundResponseWithMessage(w, r, testErr)

Expect(w.Code).To(Equal(http.StatusNotFound))

var envelope ErrorEnvelope
err := json.Unmarshal(w.Body.Bytes(), &envelope)
Expect(err).NotTo(HaveOccurred())
Expect(envelope.Error).NotTo(BeNil())
Expect(envelope.Error.Code).To(Equal(strconv.Itoa(http.StatusNotFound)))
Expect(envelope.Error.Message).To(Equal(testErr.Error()))
})
})
})
7 changes: 6 additions & 1 deletion workspaces/backend/api/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
Expand Down Expand Up @@ -146,9 +147,13 @@ var _ = BeforeSuite(func() {
imageSourceConfigMapClient, err := helper.BuildImageSourceConfigMapClient(k8sManager)
Expect(err).NotTo(HaveOccurred())

By("creating the Kubernetes clientset")
clientset, err := kubernetes.NewForConfig(cfg)
Expect(err).NotTo(HaveOccurred())

By("creating the application")
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, imageSourceConfigMapClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, imageSourceConfigMapClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ, clientset)
Expect(err).NotTo(HaveOccurred())

go func() {
Expand Down
163 changes: 163 additions & 0 deletions workspaces/backend/api/workspace_logs_handler.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets call this workspace_podtemplate_logs_handler.go to be consistent with other *handler files (that have a /podtemplate segment in their URL

Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
Copyright 2024.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package api

import (
"errors"
"io"
"net/http"
"strconv"
"time"

"github.com/julienschmidt/httprouter"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"

"github.com/kubeflow/notebooks/workspaces/backend/api/constants"
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/podtemplate/logs"
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/logs"
)

const (
logsContainerQueryParam = "container"
logsTailLinesQueryParam = "tailLines"
logsPreviousQueryParam = "previous"
logSinceTimeQueryParam = "sinceTime"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logSinceTimeQueryParam = "sinceTime"
logsSinceTimeQueryParam = "sinceTime"

Comment on lines +38 to +41

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think those should potentially live in api/constants/query_params.go:

const (
NamespaceQueryParam = "namespace"
NamespaceFilterQueryParam = "namespaceFilter"
)

)

// GetWorkspaceLogsHandler returns a point-in-time snapshot of container logs for a workspace pod.
//
// @Summary Get workspace container logs (batch)
// @Description Returns a point-in-time snapshot of container logs for the workspace pod as a raw text/plain stream proxied directly from the Kubernetes pod logs API.
// @Tags workspaces
// @ID getWorkspaceLogsBatch
// @Produce plain
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=kubeflow-user-example-com)
// @Param name path string true "Name of the workspace" extensions(x-example=my-workspace)
// @Param container query string false "Target container name. Defaults to the first (primary) container."
// @Param tailLines query integer false "Number of lines from the end of the log to return. Defaults to 1000."
// @Param sinceTime query string false "Only return logs after this RFC3339 timestamp (e.g. 2026-07-15T10:30:00Z)."
// @Param previous query boolean false "If true, returns logs from the previous terminated container instance."
// @Success 200 {string} string "Raw container log stream (text/plain)."
// @Failure 400 {object} ErrorEnvelope "Bad Request. Container not found in workspace pod."
// @Failure 401 {object} ErrorEnvelope "Unauthorized."
// @Failure 403 {object} ErrorEnvelope "Forbidden."
// @Failure 404 {object} ErrorEnvelope "Workspace not found."
// @Failure 409 {object} ErrorEnvelope "Conflict. Workspace pod is not running."
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error."
// @Router /workspaces/{namespace}/{name}/podtemplate/logs/batch [get]
func (a *App) GetWorkspaceLogsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func (a *App) GetWorkspaceLogsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
func (a *App) GetWorkspacePodTemplateLogsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

namespace := ps.ByName(constants.NamespacePathParam)
workspaceName := ps.ByName(constants.ResourceNamePathParam)

// validate path parameters
var valErrs field.ErrorList //nolint:prealloc
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(constants.NamespacePathParam), namespace)...)
valErrs = append(valErrs, helper.ValidateWorkspaceName(field.NewPath(constants.ResourceNamePathParam), workspaceName)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// parse and validate query parameters
opts, valErrs := parseLogOptions(r)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgQueryParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := []*auth.ResourcePolicy{
auth.NewResourcePolicy(auth.VerbGet, auth.Workspaces, auth.ResourcePolicyResourceMeta{Namespace: namespace, Name: workspaceName}),
}
if _, ok := a.requireAuth(w, r, authPolicies); !ok {
return
}
// ============================================================

stream, err := a.repositories.Logs.OpenLogStream(r.Context(), namespace, workspaceName, opts)
if err != nil {
switch {
case errors.Is(err, repository.ErrWorkspaceNotFound):
a.notFoundResponseWithMessage(w, r, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to: https://github.com/kubeflow/notebooks/pull/1267/changes#r3695881990

I think we should just stick with a.notFoundResponse(w, r) to "keep it simple"

case errors.Is(err, repository.ErrPreviousLogsNotFound):
a.conflictResponse(w, r, err, nil)
case errors.Is(err, repository.ErrPodNotRunning):
a.conflictResponse(w, r, err, nil)
Comment on lines +102 to +103

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a conflictResponse really the right thing to return here? 🤔
I'm asking because this is also what is returned in case a workspace is paused:

$ curl -k -H "Kubeflow-Userid: admin" https://localhost:8443/workspaces/api/v1/workspaces/default/jupyterlab-workspace/podtemplate/logs/batch
{"error":{"code":"409","message":"workspace pod is not running","cause":{}}}

case errors.Is(err, repository.ErrContainerNotFound):
a.badRequestResponse(w, r, err)
case errors.Is(err, repository.ErrContainerNotRunning):
a.conflictResponse(w, r, err, nil)
default:
a.serverErrorResponse(w, r, err)
}
return
}
defer func() { _ = stream.Close() }()

// Success responses are always a raw text/plain stream.
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ (no change requested - just calling this out for transparency)

If the subsequent io.Copy fails mid-stream, you can't change the status code — so logging the error (as you're doing) is the correct fallback. The client will see a truncated response, which is the expected behavior for a streaming endpoint that breaks mid-flight.


// Proxy the log stream directly to the client.
if _, err := io.Copy(w, stream); err != nil {
a.logger.Error("error while streaming workspace logs", "error", err, "namespace", namespace, "workspace", workspaceName)
}
}

// parseLogOptions parses and validates the log-related query parameters.
func parseLogOptions(r *http.Request) (*models.LogOptions, field.ErrorList) {
var valErrs field.ErrorList
query := r.URL.Query()

opts := &models.LogOptions{
Container: query.Get(logsContainerQueryParam),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should consider adding kubectl.kubernetes.io/default-container to the Workspace pod we reconcile in the controller (?). That being a controller change - its certainly out of scope for THIS PR - but I'm curious if we can/should rely on this implicit defaulting here...

We hard-code the name main - perhaps we should default to that to ensure reasonable default behavior when container name not provided? Unless we are sure this default wouldn't ever result in sidecars being chosen...

}

if raw := query.Get(logsTailLinesQueryParam); raw != "" {
tail, err := strconv.ParseInt(raw, 10, 64)
if err != nil || tail <= 0 {
valErrs = append(valErrs, field.Invalid(field.NewPath(logsTailLinesQueryParam), raw, "must be a positive integer"))
} else {
opts.TailLines = tail
}
}

if raw := query.Get(logsPreviousQueryParam); raw != "" {
previous, err := strconv.ParseBool(raw)
if err != nil {
valErrs = append(valErrs, field.Invalid(field.NewPath(logsPreviousQueryParam), raw, "must be a boolean"))
} else {
opts.Previous = previous
}
}

if raw := query.Get(logSinceTimeQueryParam); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
valErrs = append(valErrs, field.Invalid(field.NewPath(logSinceTimeQueryParam), raw, "must be a valid RFC3339 timestamp"))
} else {
sinceTime := metav1.NewTime(t)
opts.SinceTime = &sinceTime
}
}

return opts, valErrs
}
Comment on lines +126 to +163

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think to more closely follow the codebase conventions we could potentially introduce 3 new helper functions and add them to internal/helper/validation.go:

func ValidateFieldIsPositiveInt64(path *field.Path, value string) (int64, field.ErrorList)
func ValidateFieldIsBool(path *field.Path, value string) (bool, field.ErrorList)
func ValidateFieldIsRFC3339Time(path *field.Path, value string) (metav1.Time, field.ErrorList)

Also we could validate the container name:

if raw := query.Get(logsContainerQueryParam); raw != "" { 
  valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Label(field.NewPath(logsContainerQueryParam), raw)...)
  opts.Container = raw
}

Loading