diff --git a/clients/ui/bff/Dockerfile b/clients/ui/bff/Dockerfile index c06ebe761..77254d1f4 100644 --- a/clients/ui/bff/Dockerfile +++ b/clients/ui/bff/Dockerfile @@ -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 @@ -29,4 +30,4 @@ USER 65532:65532 # Expose port 4000 EXPOSE 4000 -ENTRYPOINT ["/bff"] +ENTRYPOINT ["/bff", "--static-assets-dir=/static"] diff --git a/clients/ui/bff/Makefile b/clients/ui/bff/Makefile index 987077181..4d8660bab 100644 --- a/clients/ui/bff/Makefile +++ b/clients/ui/bff/Makefile @@ -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 @@ -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. diff --git a/clients/ui/bff/cmd/main.go b/clients/ui/bff/cmd/main.go index 5ed0979d4..e767515da 100644 --- a/clients/ui/bff/cmd/main.go +++ b/clients/ui/bff/cmd/main.go @@ -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)) diff --git a/clients/ui/bff/internal/api/app.go b/clients/ui/bff/internal/api/app.go index c144c232e..bc8fe0fa0 100644 --- a/clients/ui/bff/internal/api/app.go +++ b/clients/ui/bff/internal/api/app.go @@ -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" @@ -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))) } diff --git a/clients/ui/bff/internal/api/app_test.go b/clients/ui/bff/internal/api/app_test.go new file mode 100644 index 000000000..e7cd23f81 --- /dev/null +++ b/clients/ui/bff/internal/api/app_test.go @@ -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)) + }) + + }) +}) diff --git a/clients/ui/bff/internal/api/middleware.go b/clients/ui/bff/internal/api/middleware.go index 00a41b665..d70fe9acb 100644 --- a/clients/ui/bff/internal/api/middleware.go +++ b/clients/ui/bff/internal/api/middleware.go @@ -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. diff --git a/clients/ui/bff/internal/api/test_utils.go b/clients/ui/bff/internal/api/test_utils.go index 945e7d0e4..952a03337 100644 --- a/clients/ui/bff/internal/api/test_utils.go +++ b/clients/ui/bff/internal/api/test_utils.go @@ -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) { @@ -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 +} diff --git a/clients/ui/bff/internal/config/environment.go b/clients/ui/bff/internal/config/environment.go index 7b905e121..6017701c8 100644 --- a/clients/ui/bff/internal/config/environment.go +++ b/clients/ui/bff/internal/config/environment.go @@ -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 } diff --git a/clients/ui/bff/static/index.html b/clients/ui/bff/static/index.html new file mode 100644 index 000000000..41c73cf02 --- /dev/null +++ b/clients/ui/bff/static/index.html @@ -0,0 +1,10 @@ + + +
+This is a placeholder page for the serving frontend.
+ + diff --git a/clients/ui/bff/static/sub/test.html b/clients/ui/bff/static/sub/test.html new file mode 100644 index 000000000..a28fa30f2 --- /dev/null +++ b/clients/ui/bff/static/sub/test.html @@ -0,0 +1,10 @@ + + + +This is a placeholder page for the serving frontend.
+ +