From ad77d3ebd6c6511b5244cf23ba8c20241892bf96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Wed, 8 Apr 2026 12:13:17 +0200 Subject: [PATCH] Add support for function monorepos --- README.md | 25 +++++ internal/controller/function_controller.go | 2 +- .../controller/function_controller_test.go | 7 +- internal/git/Manager_mock.go | 34 ++++--- internal/git/manager.go | 5 +- internal/git/repository.go | 4 +- test/e2e/func_deploy_test.go | 96 +++++++++++++++++++ test/e2e/func_middleware_update_test.go | 2 +- test/utils/git.go | 16 +++- 9 files changed, 165 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index af19990..89f1867 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,31 @@ The status will include: - Middleware update conditions - Whether the function needs rebuilding due to outdated middleware +## Advanced Use Cases + +### Functions in Monorepos + +For functions located in a subdirectory of a repository (e.g., in a monorepo), use the `repository.path` field to specify the path to your function: + +```yaml +apiVersion: functions.dev/v1alpha1 +kind: Function +metadata: + name: my-function + namespace: default +spec: + repository: + url: https://github.com/your-org/your-monorepo.git + path: functions/my-function + authSecretRef: + name: git-credentials + registry: + authSecretRef: + name: registry-credentials +``` + +The operator will clone the repository and use the specified path as the function root directory. + ## Development ### Local Development Cluster diff --git a/internal/controller/function_controller.go b/internal/controller/function_controller.go index 65a816c..d060c9a 100644 --- a/internal/controller/function_controller.go +++ b/internal/controller/function_controller.go @@ -145,7 +145,7 @@ func (r *FunctionReconciler) prepareSource(ctx context.Context, function *v1alph } } - repo, err := r.GitManager.CloneRepository(ctx, function.Spec.Repository.URL, branchReference, gitAuthSecret.Data) + repo, err := r.GitManager.CloneRepository(ctx, function.Spec.Repository.URL, function.Spec.Repository.Path, branchReference, gitAuthSecret.Data) if err != nil { function.MarkSourceNotReady("GitCloneFailed", "Failed to clone repository: %s", err.Error()) return nil, nil, fmt.Errorf("failed to setup git repository: %w", err) diff --git a/internal/controller/function_controller_test.go b/internal/controller/function_controller_test.go index 47143f9..14f0295 100644 --- a/internal/controller/function_controller_test.go +++ b/internal/controller/function_controller_test.go @@ -114,7 +114,7 @@ var _ = Describe("Function Controller", func() { funcMock.EXPECT().GetMiddlewareVersion(mock.Anything, functionName, resourceNamespace).Return("v1.0.0", nil) funcMock.EXPECT().Deploy(mock.Anything, mock.Anything, resourceNamespace, funccli.DeployOptions{}).Return(nil) - gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "my-branch", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) + gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "", "my-branch", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) }, }), Entry("should skip deploy when middleware already up to date", reconcileTestCase{ @@ -128,7 +128,7 @@ var _ = Describe("Function Controller", func() { funcMock.EXPECT().GetLatestMiddlewareVersion(mock.Anything, mock.Anything, mock.Anything).Return("v1.0.0", nil) funcMock.EXPECT().GetMiddlewareVersion(mock.Anything, functionName, resourceNamespace).Return("v1.0.0", nil) - gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "my-branch", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) + gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "", "my-branch", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) }, }), Entry("should use main as default branch", reconcileTestCase{ @@ -146,7 +146,7 @@ var _ = Describe("Function Controller", func() { funcMock.EXPECT().GetLatestMiddlewareVersion(mock.Anything, mock.Anything, mock.Anything).Return("v1.0.0", nil) funcMock.EXPECT().GetMiddlewareVersion(mock.Anything, functionName, resourceNamespace).Return("v1.0.0", nil) - gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "main", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) + gitMock.EXPECT().CloneRepository(mock.Anything, "https://github.com/foo/bar", "", "main", mock.Anything).Return(createTmpGitRepo(functions.Function{Name: "func-go"}), nil) }, }), ) @@ -178,5 +178,6 @@ func createTmpGitRepo(function functions.Function) *git.Repository { return &git.Repository{ CloneDir: tempDir, + SubPath: ".", } } diff --git a/internal/git/Manager_mock.go b/internal/git/Manager_mock.go index 83a0d87..7ec8640 100644 --- a/internal/git/Manager_mock.go +++ b/internal/git/Manager_mock.go @@ -38,8 +38,8 @@ func (_m *MockManager) EXPECT() *MockManager_Expecter { } // CloneRepository provides a mock function for the type MockManager -func (_mock *MockManager) CloneRepository(ctx context.Context, url string, reference string, auth map[string][]byte) (*Repository, error) { - ret := _mock.Called(ctx, url, reference, auth) +func (_mock *MockManager) CloneRepository(ctx context.Context, url string, subPath string, reference string, auth map[string][]byte) (*Repository, error) { + ret := _mock.Called(ctx, url, subPath, reference, auth) if len(ret) == 0 { panic("no return value specified for CloneRepository") @@ -47,18 +47,18 @@ func (_mock *MockManager) CloneRepository(ctx context.Context, url string, refer var r0 *Repository var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string][]byte) (*Repository, error)); ok { - return returnFunc(ctx, url, reference, auth) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, map[string][]byte) (*Repository, error)); ok { + return returnFunc(ctx, url, subPath, reference, auth) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string][]byte) *Repository); ok { - r0 = returnFunc(ctx, url, reference, auth) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, map[string][]byte) *Repository); ok { + r0 = returnFunc(ctx, url, subPath, reference, auth) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*Repository) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, map[string][]byte) error); ok { - r1 = returnFunc(ctx, url, reference, auth) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, map[string][]byte) error); ok { + r1 = returnFunc(ctx, url, subPath, reference, auth) } else { r1 = ret.Error(1) } @@ -73,13 +73,14 @@ type MockManager_CloneRepository_Call struct { // CloneRepository is a helper method to define mock.On call // - ctx context.Context // - url string +// - subPath string // - reference string // - auth map[string][]byte -func (_e *MockManager_Expecter) CloneRepository(ctx interface{}, url interface{}, reference interface{}, auth interface{}) *MockManager_CloneRepository_Call { - return &MockManager_CloneRepository_Call{Call: _e.mock.On("CloneRepository", ctx, url, reference, auth)} +func (_e *MockManager_Expecter) CloneRepository(ctx interface{}, url interface{}, subPath interface{}, reference interface{}, auth interface{}) *MockManager_CloneRepository_Call { + return &MockManager_CloneRepository_Call{Call: _e.mock.On("CloneRepository", ctx, url, subPath, reference, auth)} } -func (_c *MockManager_CloneRepository_Call) Run(run func(ctx context.Context, url string, reference string, auth map[string][]byte)) *MockManager_CloneRepository_Call { +func (_c *MockManager_CloneRepository_Call) Run(run func(ctx context.Context, url string, subPath string, reference string, auth map[string][]byte)) *MockManager_CloneRepository_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -93,15 +94,20 @@ func (_c *MockManager_CloneRepository_Call) Run(run func(ctx context.Context, ur if args[2] != nil { arg2 = args[2].(string) } - var arg3 map[string][]byte + var arg3 string if args[3] != nil { - arg3 = args[3].(map[string][]byte) + arg3 = args[3].(string) + } + var arg4 map[string][]byte + if args[4] != nil { + arg4 = args[4].(map[string][]byte) } run( arg0, arg1, arg2, arg3, + arg4, ) }) return _c @@ -112,7 +118,7 @@ func (_c *MockManager_CloneRepository_Call) Return(repository *Repository, err e return _c } -func (_c *MockManager_CloneRepository_Call) RunAndReturn(run func(ctx context.Context, url string, reference string, auth map[string][]byte) (*Repository, error)) *MockManager_CloneRepository_Call { +func (_c *MockManager_CloneRepository_Call) RunAndReturn(run func(ctx context.Context, url string, subPath string, reference string, auth map[string][]byte) (*Repository, error)) *MockManager_CloneRepository_Call { _c.Call.Return(run) return _c } diff --git a/internal/git/manager.go b/internal/git/manager.go index 7ec8278..f9f48b2 100644 --- a/internal/git/manager.go +++ b/internal/git/manager.go @@ -20,7 +20,7 @@ const ( ) type Manager interface { - CloneRepository(ctx context.Context, url, reference string, auth map[string][]byte) (*Repository, error) + CloneRepository(ctx context.Context, url, subPath, reference string, auth map[string][]byte) (*Repository, error) } func NewManager() Manager { @@ -29,7 +29,7 @@ func NewManager() Manager { type managerImpl struct{} -func (m *managerImpl) CloneRepository(ctx context.Context, repoUrl, reference string, auth map[string][]byte) (*Repository, error) { +func (m *managerImpl) CloneRepository(ctx context.Context, repoUrl, subPath, reference string, auth map[string][]byte) (*Repository, error) { timer := prometheus.NewTimer(monitoring.GitCloneDuration) defer timer.ObserveDuration() @@ -57,6 +57,7 @@ func (m *managerImpl) CloneRepository(ctx context.Context, repoUrl, reference st return &Repository{ CloneDir: targetDir, + SubPath: subPath, }, nil } diff --git a/internal/git/repository.go b/internal/git/repository.go index ce3bea7..67c0b0f 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -2,14 +2,16 @@ package git import ( "os" + "path" ) type Repository struct { CloneDir string + SubPath string } func (r *Repository) Path() string { - return r.CloneDir + return path.Join(r.CloneDir, r.SubPath) } func (r *Repository) Cleanup() error { diff --git a/test/e2e/func_deploy_test.go b/test/e2e/func_deploy_test.go index c8d1cb6..77285e7 100644 --- a/test/e2e/func_deploy_test.go +++ b/test/e2e/func_deploy_test.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "time" functionsdevv1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" @@ -126,6 +127,101 @@ var _ = Describe("Operator", func() { Eventually(funcBecomeReady, 6*time.Minute).Should(Succeed()) }) }) + Context("with a function in a subdirectory in a monorepo", func() { + var repoURL string + var repoDir string + const subPath = "function-subdir" + var functionName, functionNamespace string + + BeforeEach(func() { + // Create repository provider resources with automatic cleanup + username, password, _, cleanup, err := repoProvider.CreateRandomUser() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(cleanup) + + _, repoURL, cleanup, err = repoProvider.CreateRandomRepo(username, false) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(cleanup) + + // Initialize repository with function code + repoDir, err = utils.InitializeRepoWithFunctionInSubDir(repoURL, subPath, username, password, "go") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(os.RemoveAll, repoDir) + + functionNamespace, err = utils.GetTestNamespace() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(cleanupNamespaces, functionNamespace) + + functionDir := filepath.Join(repoDir, subPath) + + // Deploy function using func CLI + out, err := utils.RunFunc("deploy", + "--namespace", functionNamespace, + "--path", functionDir, + "--registry", registry, + fmt.Sprintf("--registry-insecure=%t", registryInsecure)) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + // Cleanup func deployment + DeferCleanup(func() { + _, _ = utils.RunFunc("delete", "--path", functionDir, "--namespace", functionNamespace) + }) + + // Commit func.yaml changes + err = utils.CommitAndPush(repoDir, "Update func.yaml after deploy", filepath.Join(subPath, "func.yaml")) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + logFailedTestDetails(functionName, functionNamespace) + + // Cleanup function resource + if functionName != "" { + cmd := exec.Command("kubectl", "delete", "function", functionName, "-n", functionNamespace, "--ignore-not-found") + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + } + }) + + It("should mark the function as ready", func() { + // Create a Function resource + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "my-function-", + Namespace: functionNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: repoURL, + Path: subPath, + }, + }, + } + + err := k8sClient.Create(ctx, function) + Expect(err).NotTo(HaveOccurred()) + + functionName = function.Name + + funcBecomeReady := func(g Gomega) { + fn := &functionsdevv1alpha1.Function{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: function.Name, Namespace: function.Namespace}, fn) + g.Expect(err).NotTo(HaveOccurred()) + + for _, cond := range fn.Status.Conditions { + if cond.Type == functionsdevv1alpha1.TypeReady { + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + return + } + } + g.Expect(false).To(BeTrue(), "Ready condition not found") + } + + // redeploy could take a bit longer therefore give a bit more time + Eventually(funcBecomeReady, 6*time.Minute).Should(Succeed()) + }) + }) Context("with a not yet deployed function", func() { var repoURL string var repoDir string diff --git a/test/e2e/func_middleware_update_test.go b/test/e2e/func_middleware_update_test.go index 1bc53ef..b6ac572 100644 --- a/test/e2e/func_middleware_update_test.go +++ b/test/e2e/func_middleware_update_test.go @@ -67,7 +67,7 @@ var _ = Describe("Middleware Update", func() { // Initialize repository with function code using OLD func CLI version // v1.20.1 has no middleware-version label and uses instance-compatible templates oldFuncVersion := "v1.20.1" - repoDir, err = utils.InitializeRepoWithFunctionVersion(repoURL, username, password, "go", oldFuncVersion) + repoDir, err = utils.InitializeRepoWithFunctionVersion(repoURL, ".", username, password, "go", oldFuncVersion) Expect(err).NotTo(HaveOccurred()) DeferCleanup(os.RemoveAll, repoDir) diff --git a/test/utils/git.go b/test/utils/git.go index 445041f..6e4f4c1 100644 --- a/test/utils/git.go +++ b/test/utils/git.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "k8s.io/apimachinery/pkg/util/rand" @@ -33,24 +34,31 @@ func buildAuthURL(repoURL, username, password string) string { // InitializeRepoWithFunction creates a function project and pushes it to the Gitea repo func InitializeRepoWithFunction(repoURL, username, password, language string) (string, error) { - return InitializeRepoWithFunctionVersion(repoURL, username, password, language, "") + return InitializeRepoWithFunctionVersion(repoURL, ".", username, password, language, "") +} + +// InitializeRepoWithFunctionInSubDir creates a function project in a repos subdirectory and pushes it to the Gitea repo +func InitializeRepoWithFunctionInSubDir(repoURL, subDir, username, password, language string) (string, error) { + return InitializeRepoWithFunctionVersion(repoURL, subDir, username, password, language, "") } // InitializeRepoWithFunctionVersion creates a function project with a specific func CLI version // If version is empty, uses the current func CLI -func InitializeRepoWithFunctionVersion(repoURL, username, password, language, version string) (string, error) { +func InitializeRepoWithFunctionVersion(repoURL, subdir, username, password, language, version string) (string, error) { repoDir := fmt.Sprintf("%s/func-test-%s", os.TempDir(), rand.String(10)) // Build authenticated URL authURL := buildAuthURL(repoURL, username, password) + functionPath := filepath.Join(repoDir, subdir) + // Initialize function (func init creates the directory) if version == "" { - if _, err := RunFunc("init", "-l", language, repoDir); err != nil { + if _, err := RunFunc("init", "-l", language, functionPath); err != nil { return "", fmt.Errorf("failed to init function: %w", err) } } else { - if _, err := RunFuncWithVersion(version, "init", "-l", language, repoDir); err != nil { + if _, err := RunFuncWithVersion(version, "init", "-l", language, functionPath); err != nil { return "", fmt.Errorf("failed to init function: %w", err) } }