Skip to content

Commit

Permalink
feat(bff): serving static assets on bff (kubeflow#690)
Browse files Browse the repository at this point in the history
* feat(bff): serving static assets on bff

Signed-off-by: Eder Ignatowicz <[email protected]>

* Update clients/ui/bff/Dockerfile

Co-authored-by: Griffin Sullivan <[email protected]>
Signed-off-by: Eder Ignatowicz <[email protected]>

---------

Signed-off-by: Eder Ignatowicz <[email protected]>
Co-authored-by: Griffin Sullivan <[email protected]>
  • Loading branch information
ederign and Griffin-Sullivan authored Jan 13, 2025
1 parent eb21a6d commit 4cedb36
Show file tree
Hide file tree
Showing 10 changed files with 176 additions and 32 deletions.
5 changes: 3 additions & 2 deletions clients/ui/bff/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ RUN go mod download
COPY cmd/main.go cmd/main.go
COPY internal/ internal/


# Copy the static assets
COPY $STATIC_ASSETS_DIR static/

# Build the Go application
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o bff ./cmd/main.go
Expand All @@ -29,4 +30,4 @@ USER 65532:65532
# Expose port 4000
EXPOSE 4000

ENTRYPOINT ["/bff"]
ENTRYPOINT ["/bff", "--static-assets-dir=/static"]
4 changes: 3 additions & 1 deletion clients/ui/bff/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ MOCK_MR_CLIENT ?= false
DEV_MODE ?= false
DEV_MODE_PORT ?= 8080
STANDALONE_MODE ?= true
#frontend static assets root directory
STATIC_ASSETS_DIR ?= ./static
# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
ENVTEST_K8S_VERSION = 1.29.0

Expand Down Expand Up @@ -48,7 +50,7 @@ build: fmt vet test ## Builds the project to produce a binary executable.
.PHONY: run
run: fmt vet envtest ## Runs the project.
ENVTEST_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \
go run ./cmd/main.go --port=$(PORT) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) --standalone-mode=$(STANDALONE_MODE)
go run ./cmd/main.go --port=$(PORT) --static-assets-dir=$(STATIC_ASSETS_DIR) --mock-k8s-client=$(MOCK_K8S_CLIENT) --mock-mr-client=$(MOCK_MR_CLIENT) --dev-mode=$(DEV_MODE) --dev-mode-port=$(DEV_MODE_PORT) --standalone-mode=$(STANDALONE_MODE)

.PHONY: docker-build
docker-build: ## Builds a container for the project.
Expand Down
1 change: 1 addition & 0 deletions clients/ui/bff/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func main() {
flag.BoolVar(&cfg.DevMode, "dev-mode", false, "Use development mode for access to local K8s cluster")
flag.IntVar(&cfg.DevModePort, "dev-mode-port", getEnvAsInt("DEV_MODE_PORT", 8080), "Use port when in development mode")
flag.BoolVar(&cfg.StandaloneMode, "standalone-mode", false, "Use standalone mode for enabling endpoints in standalone mode")
flag.StringVar(&cfg.StaticAssetsDir, "static-assets-dir", "./static", "Configure frontend static assets root directory")
flag.Parse()

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
Expand Down
56 changes: 33 additions & 23 deletions clients/ui/bff/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ package api
import (
"context"
"fmt"
"log/slog"
"net/http"

"github.com/kubeflow/model-registry/ui/bff/internal/config"
"github.com/kubeflow/model-registry/ui/bff/internal/integrations"
"github.com/kubeflow/model-registry/ui/bff/internal/repositories"
"log/slog"
"net/http"

"github.com/julienschmidt/httprouter"
"github.com/kubeflow/model-registry/ui/bff/internal/mocks"
Expand Down Expand Up @@ -86,34 +85,45 @@ func (app *App) Shutdown(ctx context.Context, logger *slog.Logger) error {
}

func (app *App) Routes() http.Handler {
router := httprouter.New()
// Router for /api/v1/*
apiRouter := httprouter.New()

router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
apiRouter.NotFound = http.HandlerFunc(app.notFoundResponse)
apiRouter.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

// HTTP client routes (requests that we forward to Model Registry API)
// on those, we perform SAR on Specific Service on a given namespace
router.GET(HealthCheckPath, app.HealthcheckHandler)
router.GET(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllRegisteredModelsHandler))))
router.GET(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetRegisteredModelHandler))))
router.POST(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateRegisteredModelHandler))))
router.PATCH(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateRegisteredModelHandler))))
router.GET(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelVersionsForRegisteredModelHandler))))
router.POST(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionForRegisteredModelHandler))))
router.GET(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient((app.GetModelVersionHandler)))))
router.POST(ModelVersionListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionHandler))))
router.PATCH(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler))))
router.GET(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler))))
router.POST(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler))))
router.PATCH(ModelRegistryPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler))))
apiRouter.GET(HealthCheckPath, app.HealthcheckHandler)
apiRouter.GET(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllRegisteredModelsHandler))))
apiRouter.GET(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetRegisteredModelHandler))))
apiRouter.POST(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateRegisteredModelHandler))))
apiRouter.PATCH(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateRegisteredModelHandler))))
apiRouter.GET(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelVersionsForRegisteredModelHandler))))
apiRouter.POST(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionForRegisteredModelHandler))))
apiRouter.GET(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient((app.GetModelVersionHandler)))))
apiRouter.POST(ModelVersionListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionHandler))))
apiRouter.PATCH(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler))))
apiRouter.GET(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler))))
apiRouter.POST(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler))))
apiRouter.PATCH(ModelRegistryPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler))))

// Kubernetes routes
router.GET(UserPath, app.UserHandler)
apiRouter.GET(UserPath, app.UserHandler)
// Perform SAR to Get List Services by Namspace
router.GET(ModelRegistryListPath, app.AttachNamespace(app.PerformSARonGetListServicesByNamespace(app.ModelRegistryHandler)))
apiRouter.GET(ModelRegistryListPath, app.AttachNamespace(app.PerformSARonGetListServicesByNamespace(app.ModelRegistryHandler)))
if app.config.StandaloneMode {
router.GET(NamespaceListPath, app.GetNamespacesHandler)
apiRouter.GET(NamespaceListPath, app.GetNamespacesHandler)
}

return app.RecoverPanic(app.enableCORS(app.InjectUserHeaders(router)))
// App Router
appMux := http.NewServeMux()
// handler for api calls
appMux.Handle("/api/v1/", apiRouter)

// file server for the frontend
staticAccessDir := http.Dir(app.config.StaticAssetsDir)
fileServer := http.FileServer(staticAccessDir)
appMux.Handle("/", fileServer)

return app.RecoverPanic(app.enableCORS(app.InjectUserHeaders(appMux)))
}
73 changes: 73 additions & 0 deletions clients/ui/bff/internal/api/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package api

import (
"github.com/kubeflow/model-registry/ui/bff/internal/config"
"github.com/kubeflow/model-registry/ui/bff/internal/repositories"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"io"
"net/http"
httptest "net/http/httptest"
)

var _ = Describe("Static File serving Test", func() {
var (
server *httptest.Server
client *http.Client
)

Context("serving static files at /", Ordered, func() {

BeforeAll(func() {
envConfig := config.EnvConfig{
StaticAssetsDir: resolveStaticAssetsDirOnTests(),
}
app := &App{
kubernetesClient: k8sClient,
repositories: repositories.NewRepositories(mockMRClient),
logger: logger,
config: envConfig,
}

server = httptest.NewServer(app.Routes())
client = server.Client()
})

AfterAll(func() {
server.Close()
})

It("should serve index.html from the root path", func() {
resp, err := client.Get(server.URL + "/")
Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()

Expect(resp.StatusCode).To(Equal(http.StatusOK))

body, err := io.ReadAll(resp.Body)
Expect(err).NotTo(HaveOccurred())
Expect(string(body)).To(ContainSubstring("BFF Stub Page"))
})

It("should serve subfolders from the root path", func() {
resp, err := client.Get(server.URL + "/sub/test.html")
Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()

Expect(resp.StatusCode).To(Equal(http.StatusOK))

body, err := io.ReadAll(resp.Body)
Expect(err).NotTo(HaveOccurred())
Expect(string(body)).To(ContainSubstring("BFF Stub Subfolder Page"))
})

It("should return 404 for a non-existent static file", func() {
resp, err := client.Get(server.URL + "/non-existent.html")
Expect(err).NotTo(HaveOccurred())
defer resp.Body.Close()

Expect(resp.StatusCode).To(Equal(http.StatusNotFound))
})

})
})
6 changes: 6 additions & 0 deletions clients/ui/bff/internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ func (app *App) RecoverPanic(next http.Handler) http.Handler {
func (app *App) InjectUserHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

//skip use headers check if we are not on /api/v1
if !strings.HasPrefix(r.URL.Path, PathPrefix) {
next.ServeHTTP(w, r)
return
}

userIdHeader := r.Header.Get(KubeflowUserIDHeader)
userGroupsHeader := r.Header.Get(KubeflowUserGroupsIdHeader)
//`kubeflow-userid`: Contains the user's email address.
Expand Down
30 changes: 30 additions & 0 deletions clients/ui/bff/internal/api/test_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
)

func setupApiTest[T any](method string, url string, body interface{}, k8sClient k8s.KubernetesClientInterface, kubeflowUserIDHeaderValue string, namespace string) (T, *http.Response, error) {
Expand Down Expand Up @@ -79,3 +81,31 @@ func setupApiTest[T any](method string, url string, body interface{}, k8sClient

return entity, rs, nil
}

func resolveStaticAssetsDirOnTests() string {
// Fall back to finding project root for testing
projectRoot, err := findProjectRootOnTests()
if err != nil {
panic("Failed to find project root: ")
}

return filepath.Join(projectRoot, "static")
}

// on tests findProjectRoot searches for the project root by locating go.mod
func findProjectRootOnTests() (string, error) {
currentDir, err := os.Getwd()
if err != nil {
return "", err
}

// Traverse up until go.mod is found
for currentDir != "/" {
if _, err := os.Stat(filepath.Join(currentDir, "go.mod")); err == nil {
return currentDir, nil
}
currentDir = filepath.Dir(currentDir)
}

return "", os.ErrNotExist
}
13 changes: 7 additions & 6 deletions clients/ui/bff/internal/config/environment.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package config

type EnvConfig struct {
Port int
MockK8Client bool
MockMRClient bool
DevMode bool
StandaloneMode bool
DevModePort int
Port int
MockK8Client bool
MockMRClient bool
DevMode bool
StandaloneMode bool
DevModePort int
StaticAssetsDir string
}
10 changes: 10 additions & 0 deletions clients/ui/bff/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>BFF Stub Page</title>
</head>
<body>
<h1>Welcome to the BFF Stub Page</h1>
<p>This is a placeholder page for the serving frontend.</p>
</body>
</html>
10 changes: 10 additions & 0 deletions clients/ui/bff/static/sub/test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>BFF Stub Subfolder Page</title>
</head>
<body>
<h1>Welcome to the BFF Stub Subfolder Page</h1>
<p>This is a placeholder page for the serving frontend.</p>
</body>
</html>

0 comments on commit 4cedb36

Please sign in to comment.