Skip to content
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

feat(bff): serving static assets on bff #690

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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: 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/ 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>
Loading