-
Notifications
You must be signed in to change notification settings - Fork 137
feat: implement endpoint to support fetching workspace pod log #1267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: notebooks-v2
Are you sure you want to change the base?
Changes from all commits
b46b48f
a381c9e
3973c0d
9326cf3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Open to discussion - but I'm not sure this function is really warranted.. all existing endpoints use the existing 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{ | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets call this |
| 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" | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Comment on lines
+38
to
+41
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think those should potentially live in notebooks/workspaces/backend/api/constants/query_params.go Lines 17 to 20 in 97159cb
|
||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| // 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) { | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
| 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) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related to: https://github.com/kubeflow/notebooks/pull/1267/changes#r3695881990 I think we should just stick with |
||||||||||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is a |
||||||||||||||
| 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) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ℹ️ (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), | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we should consider adding We hard-code the name |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think to more closely follow the codebase conventions we could potentially introduce 3 new helper functions and add them to 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
} |
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.