diff --git a/backend/fakegithub/server.go b/backend/fakegithub/server.go index 5d556828..a6d40e1c 100644 --- a/backend/fakegithub/server.go +++ b/backend/fakegithub/server.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log" + "maps" "net/http" "slices" "sort" @@ -33,6 +34,7 @@ type repo struct { Commits map[string]*commit Refs map[string]string // "refs/heads/main" -> commit sha Secrets map[string]string // name -> encrypted value + Variables map[string]string // name -> value } type treeEntry struct { @@ -136,6 +138,11 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/secrets/public-key", s.handleGetPublicKey) s.mux.HandleFunc("PUT /repos/{owner}/{repo}/actions/secrets/{name}", s.handlePutSecret) + // Actions variables + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/variables/{name}", s.handleGetVariable) + s.mux.HandleFunc("POST /repos/{owner}/{repo}/actions/variables", s.handleCreateVariable) + s.mux.HandleFunc("PATCH /repos/{owner}/{repo}/actions/variables/{name}", s.handleUpdateVariable) + // Admin API (for test setup) s.mux.HandleFunc("POST /_admin/seed", s.handleAdminSeed) s.mux.HandleFunc("POST /_admin/reset", s.handleAdminReset) @@ -205,6 +212,7 @@ func (s *Server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { Commits: make(map[string]*commit), Refs: make(map[string]string), Secrets: make(map[string]string), + Variables: make(map[string]string), } if body.AutoInit { @@ -628,14 +636,82 @@ func (s *Server) handlePutSecret(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (s *Server) handleGetVariable(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + name := r.PathValue("name") + value, ok := rp.Variables[name] + if !ok { + writeError(w, http.StatusNotFound, "Not Found") + return + } + writeJSON(w, http.StatusOK, map[string]string{"name": name, "value": value}) +} + +func (s *Server) handleCreateVariable(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + var body struct { + Name string `json:"name"` + Value string `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if _, exists := rp.Variables[body.Name]; exists { + writeError(w, http.StatusConflict, "variable already exists") + return + } + rp.Variables[body.Name] = body.Value + w.WriteHeader(http.StatusCreated) +} + +func (s *Server) handleUpdateVariable(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + rp := s.getRepo(r) + if rp == nil { + writeError(w, http.StatusNotFound, "Not Found") + return + } + + name := r.PathValue("name") + var body struct { + Value string `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + rp.Variables[name] = body.Value + w.WriteHeader(http.StatusNoContent) +} + // --- Admin API handlers --- type seedRequest struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Branch string `json:"branch"` - Topics []string `json:"topics"` - Files []seedFile `json:"files"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Topics []string `json:"topics"` + Files []seedFile `json:"files"` + Variables map[string]string `json:"variables,omitempty"` } type seedFile struct { @@ -669,11 +745,13 @@ func (s *Server) handleAdminSeed(w http.ResponseWriter, r *http.Request) { Commits: make(map[string]*commit), Refs: make(map[string]string), Secrets: make(map[string]string), + Variables: make(map[string]string), } for _, f := range req.Files { rp.Files[f.Path] = f.Content } + maps.Copy(rp.Variables, req.Variables) buildGitObjects(rp) key := req.Owner + "/" + req.Repo diff --git a/backend/fakegithub/server_test.go b/backend/fakegithub/server_test.go index 5b4c3d6b..84151175 100644 --- a/backend/fakegithub/server_test.go +++ b/backend/fakegithub/server_test.go @@ -174,6 +174,32 @@ var _ = Describe("FakeGitHub Server", func() { }) }) + Describe("StoreVariable + GetVariable", func() { + It("stores a variable and reads it back", func() { + err := cl.StoreVariable(context.Background(), "testuser", "test-func", "CLUSTER_API_URL", "https://api.my-cluster.example.com:6443") + Expect(err).NotTo(HaveOccurred()) + + value, err := cl.GetVariable(context.Background(), "testuser", "test-func", "CLUSTER_API_URL") + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal("https://api.my-cluster.example.com:6443")) + }) + + It("returns empty string when variable does not exist", func() { + value, err := cl.GetVariable(context.Background(), "testuser", "test-func", "NO_SUCH_VAR") + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(BeEmpty()) + }) + + It("overwrites a variable when stored again", func() { + Expect(cl.StoreVariable(context.Background(), "testuser", "test-func", "CLUSTER_API_URL", "first")).To(Succeed()) + Expect(cl.StoreVariable(context.Background(), "testuser", "test-func", "CLUSTER_API_URL", "second")).To(Succeed()) + + value, err := cl.GetVariable(context.Background(), "testuser", "test-func", "CLUSTER_API_URL") + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal("second")) + }) + }) + Describe("DeleteRepo", func() { It("removes the repo so it is no longer listed", func() { err := cl.DeleteRepo(context.Background(), "testuser", "test-func") diff --git a/backend/handler/create.go b/backend/handler/create.go index 94d1a72a..28eee07a 100644 --- a/backend/handler/create.go +++ b/backend/handler/create.go @@ -17,6 +17,11 @@ import ( k8svalidation "k8s.io/apimachinery/pkg/util/validation" ) +const ( + repoSecretKubeconfig = "KUBECONFIG" + repoVarClusterAPIURL = "CLUSTER_API_URL" +) + var ( validBranch = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$`) validRuntimes = map[string]bool{"node": true, "python": true, "go": true, "quarkus": true} @@ -135,13 +140,20 @@ func (h *Handlers) createFunction(ctx context.Context, req createRequest, pat, o } }() - if err := client.StoreSecret(ctx, req.Owner, req.Repo, "KUBECONFIG", kubeconfig); err != nil { + if err := client.StoreSecret(ctx, req.Owner, req.Repo, repoSecretKubeconfig, kubeconfig); err != nil { if errors.Is(err, scm.ErrUnauthorized) { return err } slog.Error("failed to store CI secret", "owner", req.Owner, "repo", req.Repo, "err", err) return fmt.Errorf("%w: %w", errUpstream, fmt.Errorf("store secret: %w", err)) } + if err := client.StoreVariable(ctx, req.Owner, req.Repo, repoVarClusterAPIURL, h.externalAPIServerURL); err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + return err + } + slog.Error("failed to store cluster variable", "owner", req.Owner, "repo", req.Repo, "err", err) + return fmt.Errorf("%w: %w", errUpstream, fmt.Errorf("store variable: %w", err)) + } if err := client.PushFiles(ctx, req.Owner, req.Repo, req.Branch, "Initialize Knative function project", files); err != nil { if errors.Is(err, scm.ErrUnauthorized) { return err diff --git a/backend/handler/create_test.go b/backend/handler/create_test.go index f08a2d22..c5839e64 100644 --- a/backend/handler/create_test.go +++ b/backend/handler/create_test.go @@ -140,6 +140,24 @@ var _ = Describe("POST /api/v1/func/create", func() { withClusterStub(&cluster.ClientStub{}) }, http.StatusBadGateway), + Entry("StoreVariable returns ErrUnauthorized", func() { + withSCMStub(&scm.ClientStub{ + OnStoreVariable: func(ctx context.Context, owner, repo, name, value string) error { + return scm.ErrUnauthorized + }, + }) + withClusterStub(&cluster.ClientStub{}) + }, http.StatusUnauthorized), + + Entry("StoreVariable returns generic error", func() { + withSCMStub(&scm.ClientStub{ + OnStoreVariable: func(ctx context.Context, owner, repo, name, value string) error { + return errors.New("github unavailable") + }, + }) + withClusterStub(&cluster.ClientStub{}) + }, http.StatusBadGateway), + Entry("PushFiles returns ErrUnauthorized", func() { withSCMStub(&scm.ClientStub{ OnPushFiles: func(ctx context.Context, owner, repo, branch, message string, files []scm.FileEntry) error { @@ -348,6 +366,47 @@ var _ = Describe("POST /api/v1/func/create", func() { Expect(calls).To(HaveKey("deleteRepo")) }) + It("rolls back cluster resources and repo when StoreVariable fails", func() { + calls := map[string]int{} + recordCall := func(key string) { calls[key]++ } + + w := doCreate(func() { + withSCMStub(&scm.ClientStub{ + OnStoreVariable: func(ctx context.Context, owner, repo, name, value string) error { + return errors.New("github down") + }, + OnDeleteRepo: func(ctx context.Context, owner, repo string) error { + recordCall("deleteRepo") + return nil + }, + }) + withClusterStub(&cluster.ClientStub{ + OnDeleteServiceAccount: func(ctx context.Context, namespace string) error { + recordCall("deleteServiceAccount") + return nil + }, + OnDeleteRole: func(ctx context.Context, namespace string) error { + recordCall("deleteRole") + return nil + }, + OnDeleteRoleBinding: func(ctx context.Context, namespace string) error { + recordCall("deleteRoleBinding") + return nil + }, + OnDeleteImageBuilderBinding: func(ctx context.Context, namespace string) error { + recordCall("deleteImageBuilderBinding") + return nil + }, + }) + }) + Expect(w.Code).To(Equal(http.StatusBadGateway)) + Expect(calls).To(HaveKey("deleteServiceAccount")) + Expect(calls).To(HaveKey("deleteRole")) + Expect(calls).To(HaveKey("deleteRoleBinding")) + Expect(calls).To(HaveKey("deleteImageBuilderBinding")) + Expect(calls).To(HaveKey("deleteRepo")) + }) + It("rolls back cluster resources and repo when PushFiles fails", func() { calls := map[string]int{} recordCall := func(key string) { calls[key]++ } diff --git a/backend/handler/list.go b/backend/handler/list.go index 0999fa52..461ea8ed 100644 --- a/backend/handler/list.go +++ b/backend/handler/list.go @@ -82,7 +82,7 @@ func (h *Handlers) HandleListFunctions(w http.ResponseWriter, r *http.Request) { wg.Add(2) go func() { defer wg.Done() - repoFunctions, repoErr = listRepoFunctions(r.Context(), pat, namespace) + repoFunctions, repoErr = listRepoFunctions(r.Context(), pat, namespace, h.externalAPIServerURL) }() go func() { defer wg.Done() @@ -125,7 +125,7 @@ func (h *Handlers) HandleListFunctions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, items) } -func listRepoFunctions(ctx context.Context, pat, namespace string) ([]listItem, error) { +func listRepoFunctions(ctx context.Context, pat, namespace, clusterAPIURL string) ([]listItem, error) { client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) repos, err := client.ListRepos(ctx) @@ -134,55 +134,71 @@ func listRepoFunctions(ctx context.Context, pat, namespace string) ([]listItem, } items := make([]listItem, len(repos)) - for i, repo := range repos { - items[i] = listItem{ - Owner: repo.Owner, - RepoName: repo.Name, - RepoURL: repo.URL, - DefaultBranch: repo.DefaultBranch, - Source: sourceRepo, - } - } + excluded := make([]bool, len(repos)) g, gctx := errgroup.WithContext(ctx) g.SetLimit(10) for i, repo := range repos { g.Go(func() error { + items[i] = listItem{ + Owner: repo.Owner, + RepoName: repo.Name, + RepoURL: repo.URL, + DefaultBranch: repo.DefaultBranch, + Source: sourceRepo, + } + + repoClusterURL, err := client.GetVariable(gctx, repo.Owner, repo.Name, repoVarClusterAPIURL) + if err != nil { + slog.Warn("failed to read variable", "variable", repoVarClusterAPIURL, "repo", repo.Owner+"/"+repo.Name, "err", err) + // keep the repo on transient errors rather than hiding it + } else if repoClusterURL != clusterAPIURL { + // Filter out repos whose CLUSTER_API_URL variable does not match this cluster. The variable is written alongside + // the KUBECONFIG secret at repo creation, so a mismatch means the secret points to a different cluster + // (from where it was created) and deploying from this console would target the wrong cluster. + excluded[i] = true + return nil + } + content, err := client.GetFileContent(gctx, repo.Owner, repo.Name, repo.DefaultBranch, "func.yaml") if err != nil { slog.Warn("failed to read func.yaml", "repo", repo.Owner+"/"+repo.Name, "err", err) - items[i].Err = "failed to read func.yaml" + if namespace != "" { + excluded[i] = true + } else { + items[i].Err = "failed to read func.yaml" + } return nil } - name, namespace, runtime, parseErr := parseFuncYaml(content) + name, funcNamespace, runtime, parseErr := parseFuncYaml(content) if parseErr != nil { slog.Warn("failed to parse func.yaml", "repo", repo.Owner+"/"+repo.Name, "err", parseErr) - items[i].Err = "invalid func.yaml" + if namespace != "" { + excluded[i] = true + } else { + items[i].Err = "invalid func.yaml" + } return nil } items[i].Name = name - items[i].Namespace = namespace + items[i].Namespace = funcNamespace items[i].Runtime = runtime + + if namespace != "" && funcNamespace != namespace { + excluded[i] = true + } return nil }) } _ = g.Wait() - if namespace != "" { - items = filterByNamespace(items, namespace) - } - - return items, nil -} - -func filterByNamespace(items []listItem, namespace string) []listItem { - filtered := make([]listItem, 0, len(items)) - for _, item := range items { - if item.Namespace == namespace { + filtered := items[:0] + for i, item := range items { + if !excluded[i] { filtered = append(filtered, item) } } - return filtered + return filtered, nil } func (h *Handlers) listClusterFunctions(ctx context.Context, ocpToken, namespace string) ([]listItem, error) { diff --git a/backend/handler/list_test.go b/backend/handler/list_test.go index 055a2dc2..f257cda5 100644 --- a/backend/handler/list_test.go +++ b/backend/handler/list_test.go @@ -35,6 +35,9 @@ var _ = Describe("GET /api/v1/func/list", func() { {Owner: "alice", Name: "my-func", URL: "https://github.com/alice/my-func", DefaultBranch: "main"}, }, nil }, + OnGetVariable: func(ctx context.Context, owner, repo, name string) (string, error) { + return "https://api.my-cluster.example.com:6443", nil + }, OnGetFileContent: func(ctx context.Context, owner, repo, ref, path string) (string, error) { return "name: my-func\nnamespace: demo\nruntime: go\n", nil }, @@ -42,7 +45,7 @@ var _ = Describe("GET /api/v1/func/list", func() { withFunctionsClient(&functions.ClientStub{}) w := httptest.NewRecorder() - (&Handlers{}).HandleListFunctions(w, listRequest()) + (&Handlers{externalAPIServerURL: "https://api.my-cluster.example.com:6443"}).HandleListFunctions(w, listRequest()) Expect(w.Code).To(Equal(http.StatusOK)) var items []listItem @@ -295,6 +298,87 @@ var _ = Describe("GET /api/v1/func/list", func() { Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) + It("excludes repos whose CLUSTER_API_URL variable points to a different cluster", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{ + {Owner: "alice", Name: "this-cluster", URL: "https://github.com/alice/this-cluster", DefaultBranch: "main"}, + {Owner: "alice", Name: "other-cluster", URL: "https://github.com/alice/other-cluster", DefaultBranch: "main"}, + }, nil + }, + OnGetVariable: func(ctx context.Context, owner, repo, name string) (string, error) { + if repo == "other-cluster" { + return "https://api.other-cluster.example.com:6443", nil + } + return "https://api.my-cluster.example.com:6443", nil + }, + OnGetFileContent: func(ctx context.Context, owner, repo, ref, path string) (string, error) { + return "name: " + repo + "\nnamespace: demo\nruntime: go\n", nil + }, + }) + withFunctionsClient(&functions.ClientStub{}) + + w := httptest.NewRecorder() + h := &Handlers{externalAPIServerURL: "https://api.my-cluster.example.com:6443"} + h.HandleListFunctions(w, listRequest()) + + Expect(w.Code).To(Equal(http.StatusOK)) + var items []listItem + Expect(json.NewDecoder(w.Body).Decode(&items)).To(Succeed()) + Expect(items).To(HaveLen(1)) + Expect(items[0].RepoName).To(Equal("this-cluster")) + }) + + It("includes repos when GetVariable returns a transient error", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{ + {Owner: "alice", Name: "my-func", URL: "https://github.com/alice/my-func", DefaultBranch: "main"}, + }, nil + }, + OnGetVariable: func(ctx context.Context, owner, repo, name string) (string, error) { + return "", errors.New("github unavailable") + }, + OnGetFileContent: func(ctx context.Context, owner, repo, ref, path string) (string, error) { + return "name: my-func\nnamespace: demo\nruntime: go\n", nil + }, + }) + withFunctionsClient(&functions.ClientStub{}) + + w := httptest.NewRecorder() + h := &Handlers{externalAPIServerURL: "https://api.my-cluster.example.com:6443"} + h.HandleListFunctions(w, listRequest()) + + Expect(w.Code).To(Equal(http.StatusOK)) + var items []listItem + Expect(json.NewDecoder(w.Body).Decode(&items)).To(Succeed()) + Expect(items).To(HaveLen(1)) + Expect(items[0].RepoName).To(Equal("my-func")) + }) + + It("excludes repos without a CLUSTER_API_URL variable", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{ + {Owner: "alice", Name: "legacy-func", URL: "https://github.com/alice/legacy-func", DefaultBranch: "main"}, + }, nil + }, + OnGetFileContent: func(ctx context.Context, owner, repo, ref, path string) (string, error) { + return "name: legacy-func\nnamespace: demo\nruntime: go\n", nil + }, + }) + withFunctionsClient(&functions.ClientStub{}) + + w := httptest.NewRecorder() + h := &Handlers{externalAPIServerURL: "https://api.my-cluster.example.com:6443"} + h.HandleListFunctions(w, listRequest()) + + Expect(w.Code).To(Equal(http.StatusOK)) + var items []listItem + Expect(json.NewDecoder(w.Body).Decode(&items)).To(Succeed()) + Expect(items).To(BeEmpty()) + }) + It("returns 401 when the SCM token is invalid", func() { withSCMStub(&scm.ClientStub{ OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { diff --git a/backend/scm/client.go b/backend/scm/client.go index 6f4e7cf4..a2c27229 100644 --- a/backend/scm/client.go +++ b/backend/scm/client.go @@ -48,6 +48,8 @@ type Client interface { PushFiles(ctx context.Context, owner, repo, branch, message string, files []FileEntry) error InitRepo(ctx context.Context, owner, name, branch string, topics []string) error StoreSecret(ctx context.Context, owner, repo, name, value string) error + GetVariable(ctx context.Context, owner, repo, name string) (string, error) + StoreVariable(ctx context.Context, owner, repo, name, value string) error DeleteRepo(ctx context.Context, owner, repo string) error } @@ -79,6 +81,8 @@ type ClientStub struct { OnPushFiles func(ctx context.Context, owner, repo, branch, message string, files []FileEntry) error OnInitRepo func(ctx context.Context, owner, name, branch string, topics []string) error OnStoreSecret func(ctx context.Context, owner, repo, name, value string) error + OnGetVariable func(ctx context.Context, owner, repo, name string) (string, error) + OnStoreVariable func(ctx context.Context, owner, repo, name, value string) error OnDeleteRepo func(ctx context.Context, owner, repo string) error } @@ -131,6 +135,20 @@ func (s *ClientStub) StoreSecret(ctx context.Context, owner, repo, name, value s return nil } +func (s *ClientStub) GetVariable(ctx context.Context, owner, repo, name string) (string, error) { + if s.OnGetVariable != nil { + return s.OnGetVariable(ctx, owner, repo, name) + } + return "", nil +} + +func (s *ClientStub) StoreVariable(ctx context.Context, owner, repo, name, value string) error { + if s.OnStoreVariable != nil { + return s.OnStoreVariable(ctx, owner, repo, name, value) + } + return nil +} + func (s *ClientStub) DeleteRepo(ctx context.Context, owner, repo string) error { if s.OnDeleteRepo != nil { return s.OnDeleteRepo(ctx, owner, repo) diff --git a/backend/scm/github/client.go b/backend/scm/github/client.go index 5b2f575e..9b5df977 100644 --- a/backend/scm/github/client.go +++ b/backend/scm/github/client.go @@ -272,6 +272,40 @@ func (c *ghClient) InitRepo(ctx context.Context, owner, name, branch string, top return nil } +func (c *ghClient) GetVariable(ctx context.Context, owner, repo, name string) (string, error) { + variable, _, err := c.client.Actions.GetRepoVariable(ctx, owner, repo, name) + if err != nil { + var ghErr *ghlib.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + return "", nil + } + return "", fmt.Errorf("get variable %s: %w", name, mapErr(err)) + } + return variable.GetValue(), nil +} + +func (c *ghClient) StoreVariable(ctx context.Context, owner, repo, name, value string) error { + _, err := c.client.Actions.CreateRepoVariable(ctx, owner, repo, ghlib.ActionsCreateVariableRequest{ + Name: name, + Value: value, + }) + if err != nil { + var ghErr *ghlib.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusConflict { + newValue := value + _, updateErr := c.client.Actions.UpdateRepoVariable(ctx, owner, repo, name, ghlib.ActionsUpdateVariableRequest{ + Value: &newValue, + }) + if updateErr != nil { + return fmt.Errorf("update variable %s: %w", name, mapErr(updateErr)) + } + return nil + } + return fmt.Errorf("store variable %s: %w", name, mapErr(err)) + } + return nil +} + func (c *ghClient) StoreSecret(ctx context.Context, owner, repo, name, value string) error { pubKey, _, err := c.client.Actions.GetRepoPublicKey(ctx, owner, repo) if err != nil { diff --git a/e2e/helpers/fakegithub.ts b/e2e/helpers/fakegithub.ts index bc520971..3e22a951 100644 --- a/e2e/helpers/fakegithub.ts +++ b/e2e/helpers/fakegithub.ts @@ -4,6 +4,7 @@ import { FAKE_GH_PAT } from './constants'; interface DevEnv { fakeGithubPort?: number; + clusterAPIURL?: string; } function readDevEnv(): DevEnv { @@ -25,6 +26,15 @@ export function fakeGithubUrl(): string { return `http://localhost:${env.fakeGithubPort}`; } +export function clusterAPIURL(): string { + if (process.env.CLUSTER_API_URL) return process.env.CLUSTER_API_URL; + const env = readDevEnv(); + if (!env.clusterAPIURL) { + throw new Error('clusterAPIURL not found in .dev-env.json. Start dev with: make dev-fake-gh'); + } + return env.clusterAPIURL; +} + interface SeedFile { path: string; mode: string; @@ -37,12 +47,14 @@ export async function seedRepo( branch: string, topics: string[], files: SeedFile[], + variables?: Record, ): Promise { const url = fakeGithubUrl(); + const mergedVariables = { CLUSTER_API_URL: clusterAPIURL(), ...variables }; const resp = await fetch(`${url}/_admin/seed`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ owner, repo: name, branch, topics, files }), + body: JSON.stringify({ owner, repo: name, branch, topics, files, variables: mergedVariables }), }); if (!resp.ok) { throw new Error( diff --git a/e2e/use-cases/list/cluster-filter.test.ts b/e2e/use-cases/list/cluster-filter.test.ts new file mode 100644 index 00000000..539b97d7 --- /dev/null +++ b/e2e/use-cases/list/cluster-filter.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from '../../fixtures/authenticated-page'; +import { navigateToFunctionsList } from '../../helpers/navigation'; +import { E2E_USER, PRESEEDED_FUNC_NAME, PRESEEDED_FUNC_NAMESPACE } from '../../helpers/constants'; +import { deleteRepoOnFakeGithub, seedRepo } from '../../helpers/fakegithub'; + +const OTHER_CLUSTER_REPO = 'func-from-other-cluster'; +const RUNTIME = 'go'; +const OTHER_CLUSTER_API_URL = 'https://api.other-cluster.example.com:6443'; + +test.describe('Cluster filter', () => { + test.beforeAll(async () => { + await seedRepo( + E2E_USER, + OTHER_CLUSTER_REPO, + 'main', + ['serverless-function'], + [ + { + path: 'func.yaml', + mode: '100644', + content: `name: ${OTHER_CLUSTER_REPO}\nruntime: ${RUNTIME}\nnamespace: ${PRESEEDED_FUNC_NAMESPACE}\n`, + }, + ], + { CLUSTER_API_URL: OTHER_CLUSTER_API_URL }, + ); + }); + + test.afterAll(async () => { + await deleteRepoOnFakeGithub(E2E_USER, OTHER_CLUSTER_REPO); + }); + + test('does not show functions whose CLUSTER_API_URL points to a different cluster', async ({ + page, + }) => { + await test.step('navigate to functions list', async () => { + await navigateToFunctionsList(page); + }); + + await test.step('verify the preseeded function is visible', async () => { + const grid = page.getByRole('grid', { name: 'Functions' }); + await expect(grid).toBeVisible({ timeout: 30_000 }); + await expect( + grid.locator(`tbody tr:has(td:text-is("${PRESEEDED_FUNC_NAME}"))`), + ).toBeVisible(); + }); + + await test.step('verify the other-cluster repo is absent', async () => { + const grid = page.getByRole('grid', { name: 'Functions' }); + await expect( + grid.locator(`tbody tr:has(td:text-is("${OTHER_CLUSTER_REPO}"))`), + ).not.toBeVisible(); + }); + }); +}); diff --git a/hack/dev.sh b/hack/dev.sh index 0005a789..007a888c 100755 --- a/hack/dev.sh +++ b/hack/dev.sh @@ -91,7 +91,8 @@ write_dev_env() { "backendPort": $BACKEND_PORT, "pluginPort": $PLUGIN_PORT, "consolePort": $CONSOLE_PORT, - "fakeGithubPort": $FAKE_GH_PORT + "fakeGithubPort": $FAKE_GH_PORT, + "clusterAPIURL": "$KUBE_API_SERVER" } EOF else @@ -280,9 +281,9 @@ main() { check_prerequisites install_dependencies stop_dev + resolve_kube_api_server write_dev_env extract_cluster_ca - resolve_kube_api_server trap 'stop_dev' EXIT INT TERM if $FAKE_GH; then start_fakegithub diff --git a/hack/test-prow-e2e.sh b/hack/test-prow-e2e.sh index f6d238de..97433a33 100755 --- a/hack/test-prow-e2e.sh +++ b/hack/test-prow-e2e.sh @@ -76,5 +76,7 @@ make install-frontend log::info "Installing Playwright browsers..." npx playwright install chromium +export CLUSTER_API_URL=$(oc get infrastructure cluster -o jsonpath='{.status.apiServerURL}') + log::info "Running Playwright e2e tests..." make test-e2e