feat(ws): initial commit for backend (#7)

* feat(ws): initial commit for backend

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* Fixing docker build

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* Fixing git ignore

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

---------

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>
This commit is contained in:
Eder Ignatowicz 2024-05-30 20:45:52 -04:00 committed by GitHub
parent 20a3de3bd7
commit 9a945fb4bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 705 additions and 0 deletions

1
workspaces/backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/bin

View File

@ -0,0 +1,36 @@
# Use the golang image to build the application
FROM golang:1.22.2 AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the go source files
COPY cmd/ cmd/
COPY api/ api/
COPY config/ config/
COPY data/ data/
# Build the Go application
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o backend ./cmd/main.go
# Use distroless as minimal base image to package the application binary
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/backend ./
USER 65532:65532
# Expose port 4000
EXPOSE 4000
# Define environment variables
ENV PORT 4001
ENV ENV development
ENTRYPOINT ["/backend"]

View File

@ -0,0 +1,33 @@
CONTAINER_TOOL ?= docker
IMG ?= nbv2-backend:latest
.PHONY: all
all: build
.PHONY: help
help: ## Display this help.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
.PHONY: fmt
fmt:
go fmt ./...
.PHONY: vet
vet: .
go vet ./...
.PHONY: test
test:
go test ./...
.PHONY: build
build: fmt vet test
go build -o bin/backend cmd/main.go
.PHONY: run
run: fmt vet
PORT=4000 go run ./cmd/main.go
.PHONY: docker-build
docker-build:
$(CONTAINER_TOOL) build -t ${IMG} .

View File

@ -0,0 +1,26 @@
# Kubeflow Workspaces Backend
The Kubeflow Workspaces Backend is the _backend for frontend_ (BFF) used by the Kubeflow Workspaces UI as part of [Kubeflow Notebooks 2.0](https://github.com/kubeflow/kubeflow/issues/7156).
> ⚠️ __Warning__ ⚠️
>
> The Kubeflow Workspaces Backend is a work in progress and is __NOT__ currently ready for use.
> We greatly appreciate any contributions.
# Building and Deploying
TBD
# Development
## Getting started
### Endpoints
| URL Pattern | Handler | Action |
|---------------------|--------------------|-------------------------------|
| GET /v1/healthcheck | HealthcheckHandler | Show application information. |
### Sample local calls
```
# GET /v1/healthcheck
curl -i localhost:4000/api/v1/healthcheck/
```

View File

@ -0,0 +1,56 @@
/*
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 (
"github.com/kubeflow/notebooks/workspaces/backend/config"
"github.com/kubeflow/notebooks/workspaces/backend/data"
"log/slog"
"net/http"
"github.com/julienschmidt/httprouter"
)
const (
Version = "1.0.0"
HealthCheckPath = "/api/v1/healthcheck/"
)
type App struct {
config config.EnvConfig
logger *slog.Logger
models data.Models
}
func NewApp(cfg config.EnvConfig, logger *slog.Logger) *App {
app := &App{
config: cfg,
logger: logger,
}
return app
}
func (app *App) Routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
router.GET(HealthCheckPath, app.HealthcheckHandler)
return app.RecoverPanic(app.enableCORS(router))
}

View File

@ -0,0 +1,119 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
)
type HTTPError struct {
StatusCode int `json:"-"`
ErrorResponse
}
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (app *App) LogError(r *http.Request, err error) {
var (
method = r.Method
uri = r.URL.RequestURI()
)
app.logger.Error(err.Error(), "method", method, "uri", uri)
}
func (app *App) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
httpError := &HTTPError{
StatusCode: http.StatusBadRequest,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusBadRequest),
Message: err.Error(),
},
}
app.errorResponse(w, r, httpError)
}
func (app *App) errorResponse(w http.ResponseWriter, r *http.Request, error *HTTPError) {
env := Envelope{"error": error}
err := app.WriteJSON(w, error.StatusCode, env, nil)
if err != nil {
app.LogError(r, err)
w.WriteHeader(error.StatusCode)
}
}
func (app *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
app.LogError(r, err)
httpError := &HTTPError{
StatusCode: http.StatusInternalServerError,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusInternalServerError),
Message: "the server encountered a problem and could not process your request",
},
}
app.errorResponse(w, r, httpError)
}
func (app *App) notFoundResponse(w http.ResponseWriter, r *http.Request) {
httpError := &HTTPError{
StatusCode: http.StatusNotFound,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusNotFound),
Message: "the requested resource could not be found",
},
}
app.errorResponse(w, r, httpError)
}
func (app *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
httpError := &HTTPError{
StatusCode: http.StatusMethodNotAllowed,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusMethodNotAllowed),
Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method),
},
}
app.errorResponse(w, r, httpError)
}
func (app *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
message, err := json.Marshal(errors)
if err != nil {
message = []byte("{}")
}
httpError := &HTTPError{
StatusCode: http.StatusUnprocessableEntity,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusUnprocessableEntity),
Message: string(message),
},
}
app.errorResponse(w, r, httpError)
}

View File

@ -0,0 +1,66 @@
/*
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"
"github.com/kubeflow/notebooks/workspaces/backend/config"
"github.com/kubeflow/notebooks/workspaces/backend/data"
"github.com/stretchr/testify/assert"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthCheckHandler(t *testing.T) {
app := App{config: config.EnvConfig{
Port: 4000,
}}
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, HealthCheckPath, nil)
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 data.HealthCheckModel
err = json.Unmarshal(body, &healthCheckRes)
if err != nil {
t.Fatalf("Error unmarshalling response JSON: %v", err)
}
expected := data.HealthCheckModel{
Status: "available",
SystemInfo: data.SystemInfo{
Version: Version,
},
}
assert.Equal(t, expected, healthCheckRes)
}

View File

@ -0,0 +1,38 @@
/*
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 (
"github.com/julienschmidt/httprouter"
"net/http"
)
func (app *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
healthCheck, err := app.models.HealthCheck.HealthCheck(Version)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.WriteJSON(w, http.StatusOK, healthCheck, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}

View File

@ -0,0 +1,103 @@
/*
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"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
type Envelope map[string]any
func (app *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}
func (app *App) ReadJSON(w http.ResponseWriter, r *http.Request, dst any) error {
maxBytes := 1_048_576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
var maxBytesError *http.MaxBytesError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("body contains badly-formed JSON")
case errors.As(err, &unmarshalTypeError):
if unmarshalTypeError.Field != "" {
return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
}
return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)
case errors.Is(err, io.EOF):
return errors.New("body must not be empty")
case errors.As(err, &maxBytesError):
return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit)
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("body contains unknown key %s", fieldName)
case errors.As(err, &invalidUnmarshalError):
panic(err)
default:
return err
}
}
err = dec.Decode(&struct{}{})
if !errors.Is(err, io.EOF) {
return errors.New("body must only contain a single JSON value")
}
return nil
}

View File

@ -0,0 +1,45 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"fmt"
"net/http"
)
func (app *App) RecoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
func (app *App) enableCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO(ederign) restrict CORS to a much smaller set of trusted origins.
// TODO(ederign) deal with preflight requests
w.Header().Set("Access-Control-Allow-Origin", "*")
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,69 @@
/*
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 main
import (
"flag"
"fmt"
application "github.com/kubeflow/notebooks/workspaces/backend/api"
"github.com/kubeflow/notebooks/workspaces/backend/config"
"log/slog"
"net/http"
"os"
"strconv"
"time"
)
func main() {
var cfg config.EnvConfig
flag.IntVar(&cfg.Port, "port", getEnvAsInt("PORT", 4000), "API server port")
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
app := application.NewApp(cfg, logger)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: app.Routes(),
IdleTimeout: time.Minute,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
logger.Info("starting server", "addr", srv.Addr)
err := srv.ListenAndServe()
logger.Error(err.Error())
os.Exit(1)
}
func getEnv(key, defaultVal string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultVal
}
func getEnvAsInt(name string, defaultVal int) int {
if value, exists := os.LookupEnv(name); exists {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultVal
}

View File

@ -0,0 +1,21 @@
/*
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 config
type EnvConfig struct {
Port int
}

View File

@ -0,0 +1,38 @@
/*
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 data
type SystemInfo struct {
Version string `json:"version"`
}
type HealthCheckModel struct {
Status string `json:"status"`
SystemInfo SystemInfo `json:"system_info"`
}
func (m HealthCheckModel) HealthCheck(version string) (HealthCheckModel, error) {
var res = HealthCheckModel{
Status: "available",
SystemInfo: SystemInfo{
Version: version,
},
}
return res, nil
}

View File

@ -0,0 +1,28 @@
/*
Copyright 2024.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package data
// Models struct is a single convenient container to hold and represent all our data.
type Models struct {
HealthCheck HealthCheckModel
}
func NewModels() Models {
return Models{
HealthCheck: HealthCheckModel{},
}
}

14
workspaces/backend/go.mod Normal file
View File

@ -0,0 +1,14 @@
module github.com/kubeflow/notebooks/workspaces/backend
go 1.22.2
require (
github.com/julienschmidt/httprouter v1.3.0
github.com/stretchr/testify v1.8.4
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

12
workspaces/backend/go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=