From fa32ff00e365a148ec09b430b6a6df604b8fded9 Mon Sep 17 00:00:00 2001 From: Stanislav Jakuschevskij Date: Mon, 9 Mar 2026 19:26:27 +0100 Subject: [PATCH 1/2] refactor: move CI workflow to pkg/ci/github MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI workflow generation lived in cmd/ci/, tightly coupled to the command layer via viper flag reads and direct file I/O. This made it impossible to reuse or test the generator independently. Move workflow generation, printing, and writing into pkg/ci/github/ as a self-contained package. Introduce CI and PathWriter interfaces in pkg/functions/client.go so the client can orchestrate workflow generation without depending on the cmd layer. The cmd/config_ci.go command now resolves flags and delegates to client.GenerateCIWorkflow(). Also fix typos: --patform → --platform, functionl → function, proivdes → provides, precldes → precedes, sevices → services, suppot → support, Desribe → Describe, pipilines → pipelines, intance → instance. Issue #3256 Signed-off-by: Stanislav Jakuschevskij --- cmd/build.go | 2 +- cmd/ci/config.go | 292 ------------------ cmd/ci/config_test.go | 47 --- cmd/ci/workflow_test.go | 40 --- cmd/client.go | 2 + cmd/config.go | 13 +- cmd/config_ci.go | 253 +++++++++++---- cmd/config_ci_int_test.go | 12 +- cmd/config_ci_test.go | 70 ++--- cmd/config_test.go | 4 +- cmd/root.go | 4 +- {cmd/ci => pkg/ci/github}/common.go | 24 +- pkg/ci/github/config.go | 54 ++++ pkg/ci/github/generator.go | 45 +++ {cmd/ci => pkg/ci/github}/printer.go | 59 ++-- {cmd/ci => pkg/ci/github}/printer_test.go | 23 +- {cmd/ci => pkg/ci/github}/workflow.go | 73 +++-- pkg/ci/github/workflow_test.go | 32 ++ {cmd/ci => pkg/ci/github}/writer.go | 11 +- {cmd/ci => pkg/ci/github}/writer_test.go | 9 +- pkg/functions/client.go | 47 ++- .../cloudevents/test/integration.ts | 2 +- templates/typescript/http/src/index.ts | 4 +- templates/typescript/http/test/integration.ts | 2 +- templates/typescript/http/test/unit.ts | 2 +- 25 files changed, 547 insertions(+), 579 deletions(-) delete mode 100644 cmd/ci/config.go delete mode 100644 cmd/ci/config_test.go delete mode 100644 cmd/ci/workflow_test.go rename {cmd/ci => pkg/ci/github}/common.go (50%) create mode 100644 pkg/ci/github/config.go create mode 100644 pkg/ci/github/generator.go rename {cmd/ci => pkg/ci/github}/printer.go (55%) rename {cmd/ci => pkg/ci/github}/printer_test.go (72%) rename {cmd/ci => pkg/ci/github}/workflow.go (73%) create mode 100644 pkg/ci/github/workflow_test.go rename {cmd/ci => pkg/ci/github}/writer.go (84%) rename {cmd/ci => pkg/ci/github}/writer_test.go (90%) diff --git a/cmd/build.go b/cmd/build.go index 1c16cdce9b..f7f4e25822 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -502,7 +502,7 @@ func (c buildConfig) buildOptions() (oo []fn.BuildOption, err error) { if c.Platform != "" { parts := strings.Split(c.Platform, "/") if len(parts) != 2 { - return oo, fmt.Errorf("the value for --patform must be in the form [OS]/[Architecture]. eg \"linux/amd64\"") + return oo, fmt.Errorf("the value for --platform must be in the form [OS]/[Architecture]. eg \"linux/amd64\"") } oo = append(oo, fn.BuildWithPlatforms([]fn.Platform{{OS: parts[0], Architecture: parts[1]}})) } diff --git a/cmd/ci/config.go b/cmd/ci/config.go deleted file mode 100644 index 40a5dadea2..0000000000 --- a/cmd/ci/config.go +++ /dev/null @@ -1,292 +0,0 @@ -package ci - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/ory/viper" - "knative.dev/func/cmd/common" -) - -const ( - ConfigCIFeatureFlag = "FUNC_ENABLE_CI_CONFIG" - - PathFlag = "path" - - PlatformFlag = "platform" - DefaultPlatform = "github" - - DefaultGitHubWorkflowDir = ".github/workflows" - DefaultGitHubWorkflowFilename = "func-deploy.yaml" - - BranchFlag = "branch" - DefaultBranch = "main" - - WorkflowNameFlag = "workflow-name" - DefaultWorkflowName = "Func Deploy" - DefaultRemoteBuildWorkflowName = "Remote " + DefaultWorkflowName - - KubeconfigSecretNameFlag = "kubeconfig-secret-name" - DefaultKubeconfigSecretName = "KUBECONFIG" - - RegistryLoginUrlVariableNameFlag = "registry-login-url-variable-name" - DefaultRegistryLoginUrlVariableName = "REGISTRY_LOGIN_URL" - - RegistryUserVariableNameFlag = "registry-user-variable-name" - DefaultRegistryUserVariableName = "REGISTRY_USERNAME" - - RegistryPassSecretNameFlag = "registry-pass-secret-name" - DefaultRegistryPassSecretName = "REGISTRY_PASSWORD" - - RegistryUrlVariableNameFlag = "registry-url-variable-name" - DefaultRegistryUrlVariableName = "REGISTRY_URL" - - RegistryLoginFlag = "registry-login" - DefaultRegistryLogin = true - - WorkflowDispatchFlag = "workflow-dispatch" - DefaultWorkflowDispatch = false - - RemoteBuildFlag = "remote" - DefaultRemoteBuild = false - - SelfHostedRunnerFlag = "self-hosted-runner" - DefaultSelfHostedRunner = false - - TestStepFlag = "test-step" - DefaultTestStep = true - - ForceFlag = "force" - DefaultForce = false - - VerboseFlag = "verbose" - DefaultVerbose = false -) - -// CIConfig readonly configuration -type CIConfig struct { - githubWorkflowDir, - githubWorkflowFilename, - branch, - workflowName, - kubeconfigSecret, - registryLoginUrlVar, - registryUserVar, - registryPassSecret, - registryUrlVar string - registryLogin, - selfHostedRunner, - remoteBuild, - workflowDispatch, - testStep, - force, - verbose bool - fnRuntime, - fnRoot, - fnBuilder string -} - -func NewCIConfig( - fnLoader common.FunctionLoader, - currentBranch common.CurrentBranchFunc, - workingDir common.WorkDirFunc, - workflowNameExplicit bool, -) (CIConfig, error) { - if err := resolvePlatform(); err != nil { - return CIConfig{}, err - } - - path, err := resolvePath(workingDir) - if err != nil { - return CIConfig{}, err - } - - branch, err := resolveBranch(path, currentBranch) - if err != nil { - return CIConfig{}, err - } - - workflowName := resolveWorkflowName(workflowNameExplicit) - - f, err := fnLoader.Load(path) - if err != nil { - return CIConfig{}, err - } - - remoteBuild := viper.GetBool(RemoteBuildFlag) - fnBuilder, err := resolveBuilder(f.Runtime, remoteBuild) - if err != nil { - return CIConfig{}, err - } - - return CIConfig{ - githubWorkflowDir: DefaultGitHubWorkflowDir, - githubWorkflowFilename: DefaultGitHubWorkflowFilename, - branch: branch, - workflowName: workflowName, - kubeconfigSecret: viper.GetString(KubeconfigSecretNameFlag), - registryLoginUrlVar: viper.GetString(RegistryLoginUrlVariableNameFlag), - registryUserVar: viper.GetString(RegistryUserVariableNameFlag), - registryPassSecret: viper.GetString(RegistryPassSecretNameFlag), - registryUrlVar: viper.GetString(RegistryUrlVariableNameFlag), - registryLogin: viper.GetBool(RegistryLoginFlag), - selfHostedRunner: viper.GetBool(SelfHostedRunnerFlag), - remoteBuild: remoteBuild, - workflowDispatch: viper.GetBool(WorkflowDispatchFlag), - testStep: viper.GetBool(TestStepFlag), - force: viper.GetBool(ForceFlag), - verbose: viper.GetBool(VerboseFlag), - fnRuntime: f.Runtime, - fnRoot: f.Root, - fnBuilder: fnBuilder, - }, nil -} - -func resolvePlatform() error { - platform := viper.GetString(PlatformFlag) - if platform == "" { - return fmt.Errorf("platform must not be empty, supported: %s", DefaultPlatform) - } - if strings.ToLower(platform) != DefaultPlatform { - return fmt.Errorf("%s support is not implemented, supported: %s", platform, DefaultPlatform) - } - - return nil -} - -func resolvePath(workingDir common.WorkDirFunc) (string, error) { - path := viper.GetString(PathFlag) - if path != "" && path != "." { - return path, nil - } - - cwd, err := workingDir() - if err != nil { - return "", err - } - - return cwd, nil -} - -func resolveBranch(path string, currentBranch common.CurrentBranchFunc) (string, error) { - branch := viper.GetString(BranchFlag) - if branch != "" { - return branch, nil - } - - branch, err := currentBranch(path) - if err != nil { - return "", err - } - - return branch, nil -} - -func resolveWorkflowName(explicit bool) string { - workflowName := viper.GetString(WorkflowNameFlag) - if explicit { - return workflowName - } - - if viper.GetBool(RemoteBuildFlag) { - return DefaultRemoteBuildWorkflowName - } - - return DefaultWorkflowName -} - -func resolveBuilder(runtime string, remote bool) (string, error) { - switch runtime { - case "go": - if remote { - return "pack", nil - } - return "host", nil - - case "node", "typescript", "rust", "quarkus", "springboot": - return "pack", nil - - case "python": - if remote { - return "s2i", nil - } - return "host", nil - - default: - return "", fmt.Errorf("no builder support for runtime: %s", runtime) - } -} - -func (cc CIConfig) FnGitHubWorkflowFilepath() string { - fnGitHubWorkflowDir := filepath.Join(cc.fnRoot, cc.githubWorkflowDir) - return filepath.Join(fnGitHubWorkflowDir, cc.githubWorkflowFilename) -} - -func (cc CIConfig) OutputPath() string { - return filepath.Join(cc.githubWorkflowDir, cc.githubWorkflowFilename) -} - -func (cc CIConfig) Branch() string { - return cc.branch -} - -func (cc CIConfig) WorkflowName() string { - return cc.workflowName -} - -func (cc CIConfig) KubeconfigSecret() string { - return cc.kubeconfigSecret -} - -func (cc CIConfig) RegistryLoginUrlVar() string { - return cc.registryLoginUrlVar -} - -func (cc CIConfig) RegistryUserVar() string { - return cc.registryUserVar -} - -func (cc CIConfig) RegistryPassSecret() string { - return cc.registryPassSecret -} - -func (cc CIConfig) RegistryUrlVar() string { - return cc.registryUrlVar -} - -func (cc CIConfig) RegistryLogin() bool { - return cc.registryLogin -} - -func (cc CIConfig) SelfHostedRunner() bool { - return cc.selfHostedRunner -} - -func (cc CIConfig) RemoteBuild() bool { - return cc.remoteBuild -} - -func (cc CIConfig) WorkflowDispatch() bool { - return cc.workflowDispatch -} - -func (cc CIConfig) TestStep() bool { - return cc.testStep -} - -func (cc CIConfig) Force() bool { - return cc.force -} - -func (cc CIConfig) Verbose() bool { - return cc.verbose -} - -func (cc CIConfig) FnRuntime() string { - return cc.fnRuntime -} - -func (cc CIConfig) FnBuilder() string { - return cc.fnBuilder -} diff --git a/cmd/ci/config_test.go b/cmd/ci/config_test.go deleted file mode 100644 index 3694876522..0000000000 --- a/cmd/ci/config_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package ci - -import ( - "testing" - - "gotest.tools/v3/assert" -) - -// TestResolveBuilder covers the branching logic that selects the correct -// build strategy for each runtime × local/remote combination. -func TestResolveBuilder(t *testing.T) { - tests := []struct { - name string - runtime string - remote bool - want string - wantErr bool - }{ - {name: "go local", runtime: "go", remote: false, want: "host"}, - {name: "go remote", runtime: "go", remote: true, want: "pack"}, - {name: "node local", runtime: "node", remote: false, want: "pack"}, - {name: "node remote", runtime: "node", remote: true, want: "pack"}, - {name: "typescript local", runtime: "typescript", remote: false, want: "pack"}, - {name: "typescript remote", runtime: "typescript", remote: true, want: "pack"}, - {name: "rust local", runtime: "rust", remote: false, want: "pack"}, - {name: "rust remote", runtime: "rust", remote: true, want: "pack"}, - {name: "quarkus local", runtime: "quarkus", remote: false, want: "pack"}, - {name: "quarkus remote", runtime: "quarkus", remote: true, want: "pack"}, - {name: "springboot local", runtime: "springboot", remote: false, want: "pack"}, - {name: "springboot remote", runtime: "springboot", remote: true, want: "pack"}, - {name: "python local", runtime: "python", remote: false, want: "host"}, - {name: "python remote", runtime: "python", remote: true, want: "s2i"}, - {name: "unknown runtime", runtime: "fortran", remote: false, wantErr: true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := resolveBuilder(tc.runtime, tc.remote) - if tc.wantErr { - assert.Assert(t, err != nil, "expected an error for runtime %q", tc.runtime) - return - } - assert.NilError(t, err) - assert.Equal(t, got, tc.want) - }) - } -} diff --git a/cmd/ci/workflow_test.go b/cmd/ci/workflow_test.go deleted file mode 100644 index 8b5d3a551f..0000000000 --- a/cmd/ci/workflow_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package ci_test - -import ( - "bytes" - "strings" - "testing" - - "github.com/ory/viper" - "gotest.tools/v3/assert" - "knative.dev/func/cmd/ci" - "knative.dev/func/cmd/common" - fn "knative.dev/func/pkg/functions" -) - -func TestGitHubWorkflow_Export(t *testing.T) { - // GIVEN - viper.Set("platform", "github") - t.Cleanup(func() { viper.Reset() }) - loaderSaver := common.NewMockLoaderSaver() - loaderSaver.LoadFn = func(path string) (fn.Function, error) { - return fn.Function{Root: path, Runtime: "go"}, nil - } - bufferWriter := ci.NewBufferWriter() - - // WHEN - cfg, configErr := ci.NewCIConfig( - loaderSaver, - common.CurrentBranchStub("", nil), - common.WorkDirStub("", nil), - false, - ) - assert.NilError(t, configErr, "unexpected error when creating CIConfig") - - gw := ci.NewGitHubWorkflow(cfg, &bytes.Buffer{}) - exportErr := gw.Export(cfg.FnGitHubWorkflowFilepath(), bufferWriter, true, &bytes.Buffer{}) - - // THEN - assert.NilError(t, exportErr, "unexpected error when exporting GitHub Workflow") - assert.Assert(t, strings.Contains(bufferWriter.Buffer.String(), gw.Name)) -} diff --git a/cmd/client.go b/cmd/client.go index c4acf0802f..7170cf24ce 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -6,6 +6,7 @@ import ( "os" "github.com/ory/viper" + "knative.dev/func/pkg/ci/github" "knative.dev/func/pkg/keda" "knative.dev/func/cmd/prompt" @@ -85,6 +86,7 @@ func NewClient(cfg ClientConfig, options ...fn.Option) (*fn.Client, func()) { docker.WithVerbose(cfg.Verbose), docker.WithInsecure(cfg.InsecureSkipVerify))), fn.WithSyncer(operator.NewSyncer(operator.WithCredentialsProvider(c))), + fn.WithCI(github.NewWorkflowGenerator(cfg.Verbose)), } ) diff --git a/cmd/config.go b/cmd/config.go index 2dccd67560..f04d40512b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -8,7 +8,6 @@ import ( "github.com/ory/viper" "github.com/spf13/cobra" - "knative.dev/func/cmd/ci" "knative.dev/func/cmd/common" "knative.dev/func/pkg/config" fn "knative.dev/func/pkg/functions" @@ -16,7 +15,7 @@ import ( func NewConfigCmd( loaderSaver common.FunctionLoaderSaver, - writer ci.WorkflowWriter, + pathWriter fn.PathWriter, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, newClient ClientFactory, @@ -47,8 +46,14 @@ or from the directory specified with --path. cmd.AddCommand(NewConfigEnvsCmd(loaderSaver)) cmd.AddCommand(NewConfigVolumesCmd()) - if os.Getenv(ci.ConfigCIFeatureFlag) == "true" { - cmd.AddCommand(NewConfigCICmd(loaderSaver, writer, currentBranch, workingDir)) + if os.Getenv(ConfigCIFeatureFlag) == "true" { + cmd.AddCommand(NewConfigCICmd( + loaderSaver, + pathWriter, + currentBranch, + workingDir, + newClient, + )) } return cmd diff --git a/cmd/config_ci.go b/cmd/config_ci.go index 8d1a8b0b78..6974aee759 100644 --- a/cmd/config_ci.go +++ b/cmd/config_ci.go @@ -1,54 +1,78 @@ package cmd import ( - "io" + "fmt" + "strings" "github.com/ory/viper" "github.com/spf13/cobra" - "knative.dev/func/cmd/ci" "knative.dev/func/cmd/common" + "knative.dev/func/pkg/ci/github" + fn "knative.dev/func/pkg/functions" +) + +const ( + ConfigCIFeatureFlag = "FUNC_ENABLE_CI_CONFIG" + pathFlag = "path" + platformFlag = "platform" + branchFlag = "branch" + workflowNameFlag = "workflow-name" + kubeconfigSecretNameFlag = "kubeconfig-secret-name" + registryLoginUrlVariableNameFlag = "registry-login-url-variable-name" + registryUserVariableNameFlag = "registry-user-variable-name" + registryPassSecretNameFlag = "registry-pass-secret-name" + registryUrlVariableNameFlag = "registry-url-variable-name" + registryLoginFlag = "registry-login" + workflowDispatchFlag = "workflow-dispatch" + remoteBuildFlag = "remote" + selfHostedRunnerFlag = "self-hosted-runner" + testStepFlag = "test-step" + forceFlag = "force" + verboseFlag = "verbose" ) func NewConfigCICmd( loaderSaver common.FunctionLoaderSaver, - writer ci.WorkflowWriter, + pathWriter fn.PathWriter, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, + newClient ClientFactory, ) *cobra.Command { cmd := &cobra.Command{ Use: "ci", Short: "Generate a GitHub Workflow for function deployment", PreRunE: bindEnv( - ci.PathFlag, - ci.PlatformFlag, - ci.RegistryLoginFlag, - ci.WorkflowNameFlag, - ci.KubeconfigSecretNameFlag, - ci.RegistryLoginUrlVariableNameFlag, - ci.RegistryUserVariableNameFlag, - ci.RegistryPassSecretNameFlag, - ci.RegistryUrlVariableNameFlag, - ci.WorkflowDispatchFlag, - ci.RemoteBuildFlag, - ci.SelfHostedRunnerFlag, - ci.TestStepFlag, - ci.BranchFlag, - ci.ForceFlag, - ci.VerboseFlag, + pathFlag, + platformFlag, + registryLoginFlag, + workflowNameFlag, + kubeconfigSecretNameFlag, + registryLoginUrlVariableNameFlag, + registryUserVariableNameFlag, + registryPassSecretNameFlag, + registryUrlVariableNameFlag, + workflowDispatchFlag, + remoteBuildFlag, + selfHostedRunnerFlag, + testStepFlag, + branchFlag, + forceFlag, + verboseFlag, ), RunE: func(cmd *cobra.Command, args []string) (err error) { // Detect explicit config via CLI flag or env var workflowNameExplicit := - cmd.Flags().Changed(ci.WorkflowNameFlag) || viper.IsSet(ci.WorkflowNameFlag) + cmd.Flags().Changed(workflowNameFlag) || viper.IsSet(workflowNameFlag) return runConfigCIGitHub( + cmd, + pathWriter, loaderSaver, - writer, currentBranch, workingDir, - cmd.OutOrStdout(), workflowNameExplicit, + newClient, ) }, } @@ -56,120 +80,225 @@ func NewConfigCICmd( addPathFlag(cmd) cmd.Flags().String( - ci.PlatformFlag, - ci.DefaultPlatform, + platformFlag, + github.DefaultPlatform, "Pick a CI/CD platform for which a manifest will be generated. Currently only GitHub is supported.", ) cmd.Flags().String( - ci.BranchFlag, + branchFlag, "", "Use a custom branch name in the workflow", ) cmd.Flags().String( - ci.WorkflowNameFlag, - ci.DefaultWorkflowName, + workflowNameFlag, + github.DefaultWorkflowName, "Use a custom workflow name", ) cmd.Flags().String( - ci.KubeconfigSecretNameFlag, - ci.DefaultKubeconfigSecretName, + kubeconfigSecretNameFlag, + github.DefaultKubeconfigSecretName, "Use a custom secret name in the workflow, e.g. secret.YOUR_CUSTOM_KUBECONFIG", ) cmd.Flags().String( - ci.RegistryLoginUrlVariableNameFlag, - ci.DefaultRegistryLoginUrlVariableName, + registryLoginUrlVariableNameFlag, + github.DefaultRegistryLoginUrlVariableName, "Use a custom registry login url variable name in the workflow, e.g. vars.YOUR_REGISTRY_LOGIN_URL", ) cmd.Flags().String( - ci.RegistryUserVariableNameFlag, - ci.DefaultRegistryUserVariableName, + registryUserVariableNameFlag, + github.DefaultRegistryUserVariableName, "Use a custom registry user variable name in the workflow, e.g. vars.YOUR_REGISTRY_USER", ) cmd.Flags().String( - ci.RegistryPassSecretNameFlag, - ci.DefaultRegistryPassSecretName, + registryPassSecretNameFlag, + github.DefaultRegistryPassSecretName, "Use a custom registry pass secret name in the workflow, e.g. secret.YOUR_REGISTRY_PASSWORD", ) cmd.Flags().String( - ci.RegistryUrlVariableNameFlag, - ci.DefaultRegistryUrlVariableName, + registryUrlVariableNameFlag, + github.DefaultRegistryUrlVariableName, "Use a custom registry url variable name in the workflow, e.g. vars.YOUR_REGISTRY_URL", ) cmd.Flags().Bool( - ci.RegistryLoginFlag, - ci.DefaultRegistryLogin, + registryLoginFlag, + github.DefaultRegistryLogin, "Add a registry login step in the github workflow", ) cmd.Flags().Bool( - ci.WorkflowDispatchFlag, - ci.DefaultWorkflowDispatch, + workflowDispatchFlag, + github.DefaultWorkflowDispatch, "Add a workflow dispatch trigger for manual workflow execution", ) - _ = cmd.Flags().MarkHidden(ci.WorkflowDispatchFlag) + _ = cmd.Flags().MarkHidden(workflowDispatchFlag) cmd.Flags().Bool( - ci.RemoteBuildFlag, - ci.DefaultRemoteBuild, + remoteBuildFlag, + github.DefaultRemoteBuild, "Build the function on a Tekton-enabled cluster", ) cmd.Flags().Bool( - ci.SelfHostedRunnerFlag, - ci.DefaultSelfHostedRunner, + selfHostedRunnerFlag, + github.DefaultSelfHostedRunner, "Use a 'self-hosted' runner instead of the default 'ubuntu-latest' for local runner execution", ) cmd.Flags().Bool( - ci.TestStepFlag, - ci.DefaultTestStep, + testStepFlag, + github.DefaultTestStep, "Add a language-specific test step (supported: go, node, typescript, python, quarkus)", ) cmd.Flags().Bool( - ci.ForceFlag, - ci.DefaultForce, + forceFlag, + github.DefaultForce, "Use to overwrite an existing GitHub workflow", ) - addVerboseFlag(cmd, ci.DefaultVerbose) + addVerboseFlag(cmd, github.DefaultVerbose) return cmd } func runConfigCIGitHub( + cmd *cobra.Command, + pathWriter fn.PathWriter, fnLoaderSaver common.FunctionLoaderSaver, - writer ci.WorkflowWriter, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, - messageWriter io.Writer, workflowNameExplicit bool, + newClient ClientFactory, ) error { - cfg, err := ci.NewCIConfig(fnLoaderSaver, currentBranch, workingDir, workflowNameExplicit) + cfg, err := newCIConfig( + fnLoaderSaver, + currentBranch, + workingDir, + workflowNameExplicit, + ) if err != nil { return err } - githubWorkflow := ci.NewGitHubWorkflow(cfg, messageWriter) - if err := githubWorkflow.Export(cfg.FnGitHubWorkflowFilepath(), writer, cfg.Force(), messageWriter); err != nil { + client, done := newClient( + ClientConfig{Verbose: viper.GetBool(verboseFlag)}, + fn.WithPathWriter(pathWriter), + fn.WithStdout(cmd.OutOrStdout()), + ) + defer done() + + if err := client.GenerateCIWorkflow(cmd.Context(), cfg); err != nil { return err } - if cfg.Verbose() { - // best-effort user message; errors are non-critical - _ = ci.PrintConfiguration(messageWriter, cfg) - return nil + return nil +} + +func newCIConfig( + fnLoader common.FunctionLoader, + currentBranch common.CurrentBranchFunc, + workingDir common.WorkDirFunc, + workflowNameExplicit bool, +) (github.Config, error) { + if err := resolvePlatform(); err != nil { + return github.Config{}, err + } + + path, err := resolvePath(workingDir) + if err != nil { + return github.Config{}, err + } + + branch, err := resolveBranch(path, currentBranch) + if err != nil { + return github.Config{}, err + } + + workflowName := resolveWorkflowName(workflowNameExplicit) + + f, err := fnLoader.Load(path) + if err != nil { + return github.Config{}, err + } + + return github.Config{ + GithubWorkflowDir: github.DefaultGitHubWorkflowDir, + GithubWorkflowFilename: github.DefaultGitHubWorkflowFilename, + Branch: branch, + WorkflowName: workflowName, + KubeconfigSecret: viper.GetString(kubeconfigSecretNameFlag), + RegistryLoginUrlVar: viper.GetString(registryLoginUrlVariableNameFlag), + RegistryUserVar: viper.GetString(registryUserVariableNameFlag), + RegistryPassSecret: viper.GetString(registryPassSecretNameFlag), + RegistryUrlVar: viper.GetString(registryUrlVariableNameFlag), + RegistryLogin: viper.GetBool(registryLoginFlag), + SelfHostedRunner: viper.GetBool(selfHostedRunnerFlag), + RemoteBuild: viper.GetBool(remoteBuildFlag), + WorkflowDispatch: viper.GetBool(workflowDispatchFlag), + TestStep: viper.GetBool(testStepFlag), + Force: viper.GetBool(forceFlag), + FnRuntime: f.Runtime, + FnRoot: f.Root, + }, nil +} + +func resolvePlatform() error { + platform := viper.GetString(platformFlag) + if platform == "" { + return fmt.Errorf("platform must not be empty, supported: %s", github.DefaultPlatform) + } + if strings.ToLower(platform) != github.DefaultPlatform { + return fmt.Errorf("%s support is not implemented, supported: %s", platform, github.DefaultPlatform) } - // best-effort user message; errors are non-critical - _ = ci.PrintPostExportMessage(messageWriter, cfg) return nil } + +func resolvePath(workingDir common.WorkDirFunc) (string, error) { + path := viper.GetString(pathFlag) + if path != "" && path != "." { + return path, nil + } + + cwd, err := workingDir() + if err != nil { + return "", err + } + + return cwd, nil +} + +func resolveBranch(path string, currentBranch common.CurrentBranchFunc) (string, error) { + branch := viper.GetString(branchFlag) + if branch != "" { + return branch, nil + } + + branch, err := currentBranch(path) + if err != nil { + return "", err + } + + return branch, nil +} + +func resolveWorkflowName(explicit bool) string { + workflowName := viper.GetString(workflowNameFlag) + if explicit { + return workflowName + } + + if viper.GetBool(remoteBuildFlag) { + return github.DefaultRemoteBuildWorkflowName + } + + return github.DefaultWorkflowName +} diff --git a/cmd/config_ci_int_test.go b/cmd/config_ci_int_test.go index a097f637e1..af6b137438 100644 --- a/cmd/config_ci_int_test.go +++ b/cmd/config_ci_int_test.go @@ -11,9 +11,9 @@ import ( "github.com/ory/viper" "gotest.tools/v3/assert" fnCmd "knative.dev/func/cmd" - "knative.dev/func/cmd/ci" "knative.dev/func/cmd/common" cmdTest "knative.dev/func/cmd/testing" + "knative.dev/func/pkg/ci/github" fn "knative.dev/func/pkg/functions" fnTest "knative.dev/func/pkg/testing" ) @@ -70,7 +70,7 @@ func TestNewConfigCICmd_CreatesGitHubWorkflowDirectory(t *testing.T) { err := runConfigCiCmdIntegration(t, opts) assert.NilError(t, err) - _, statErr := os.Stat(filepath.Join(opts.withFunc.Root, ci.DefaultGitHubWorkflowDir)) + _, statErr := os.Stat(filepath.Join(opts.withFunc.Root, github.DefaultGitHubWorkflowDir)) assert.NilError(t, statErr) } @@ -106,7 +106,7 @@ func TestNewConfigCICmd_ForceFlagOverwritesExistingWorkflowOnFS(t *testing.T) { err := runConfigCiCmdIntegration(t, opts) content := readWorkflowFile(t, opts.withFunc.Root) - assert.ErrorIs(t, err, ci.ErrWorkflowExists) + assert.ErrorIs(t, err, github.ErrWorkflowExists) assert.Assert(t, yamlContains(content, workflowName)) assert.Assert(t, !strings.Contains(content, changedWorkflowName)) }) @@ -165,7 +165,7 @@ func runConfigCiCmdIntegration( // PRE-RUN PREP // all options for "func config ci" command - t.Setenv(ci.ConfigCIFeatureFlag, "true") + t.Setenv(fnCmd.ConfigCIFeatureFlag, "true") args := opts.args if len(opts.args) == 0 { @@ -176,7 +176,7 @@ func runConfigCiCmdIntegration( cmd := fnCmd.NewConfigCmd( common.DefaultLoaderSaver, - ci.DefaultWorkflowWriter, + github.DefaultWorkflowWriter, common.DefaultCurrentBranch, common.DefaultWorkDir, fnCmd.NewClient, @@ -190,7 +190,7 @@ func runConfigCiCmdIntegration( func readWorkflowFile(t *testing.T, root string) string { t.Helper() - path := filepath.Join(root, ci.DefaultGitHubWorkflowDir, ci.DefaultGitHubWorkflowFilename) + path := filepath.Join(root, github.DefaultGitHubWorkflowDir, github.DefaultGitHubWorkflowFilename) result, err := os.ReadFile(path) assert.NilError(t, err) diff --git a/cmd/config_ci_test.go b/cmd/config_ci_test.go index 3eb560f183..3cf22e365e 100644 --- a/cmd/config_ci_test.go +++ b/cmd/config_ci_test.go @@ -11,8 +11,8 @@ import ( "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" fnCmd "knative.dev/func/cmd" - "knative.dev/func/cmd/ci" "knative.dev/func/cmd/common" + "knative.dev/func/pkg/ci/github" fn "knative.dev/func/pkg/functions" ) @@ -82,12 +82,12 @@ func TestNewConfigCICmd_WorkflowNameResolution(t *testing.T) { { name: "default workflow name when no flags", args: nil, - expectedWorkflowName: ci.DefaultWorkflowName, + expectedWorkflowName: github.DefaultWorkflowName, }, { name: "remote build uses remote default workflow name", args: []string{"--remote"}, - expectedWorkflowName: ci.DefaultRemoteBuildWorkflowName, + expectedWorkflowName: github.DefaultRemoteBuildWorkflowName, }, { name: "custom name is preserved without remote", @@ -101,8 +101,8 @@ func TestNewConfigCICmd_WorkflowNameResolution(t *testing.T) { }, { name: "custom name is preserved if its equal to default workflow name and remote is set", - args: []string{"--workflow-name=" + ci.DefaultWorkflowName, "--remote"}, - expectedWorkflowName: ci.DefaultWorkflowName, + args: []string{"--workflow-name=" + github.DefaultWorkflowName, "--remote"}, + expectedWorkflowName: github.DefaultWorkflowName, }, } @@ -335,12 +335,12 @@ func TestNewConfigCICmd_PlatformFlagErrors(t *testing.T) { { name: "empty platform value", platformArg: "--platform=", - expectedErr: fmt.Sprintf("platform must not be empty, supported: %s", ci.DefaultPlatform), + expectedErr: fmt.Sprintf("platform must not be empty, supported: %s", github.DefaultPlatform), }, { name: "unsupported platform value", platformArg: "--platform=unsupported", - expectedErr: fmt.Sprintf("unsupported support is not implemented, supported: %s", ci.DefaultPlatform), + expectedErr: fmt.Sprintf("unsupported support is not implemented, supported: %s", github.DefaultPlatform), }, } @@ -362,7 +362,7 @@ func TestNewConfigCICmd_PlatformFlagErrors(t *testing.T) { func TestNewConfigCICmd_ForceFlagOverwritesExistingWorkflow(t *testing.T) { workflowName := "Func Deploy" changedWorkflowName := "Sales Service Deployment" - sharedWriter := ci.NewBufferWriter() + sharedWriter := github.NewBufferWriter() t.Run("initial workflow creation succeeds", func(t *testing.T) { opts := defaultOpts() @@ -382,7 +382,7 @@ func TestNewConfigCICmd_ForceFlagOverwritesExistingWorkflow(t *testing.T) { result := runConfigCiCmd(t, opts) - assert.ErrorIs(t, result.executeErr, ci.ErrWorkflowExists) + assert.ErrorIs(t, result.executeErr, github.ErrWorkflowExists) assert.Assert(t, yamlContains(result.gwYamlString, workflowName)) assert.Assert(t, !strings.Contains(result.gwYamlString, changedWorkflowName)) assert.Assert(t, !strings.Contains(result.stdOut, forceWarning)) @@ -406,9 +406,9 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { t.Run("verbose flag prints default Github Workflow configuration", func(t *testing.T) { opts := defaultOpts() opts.args = append(opts.args, "--verbose") - expectedMessage := fmt.Sprintf(ci.MainLayoutPlainText, + expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, defaultOutputPath, - ci.DefaultWorkflowName, + github.DefaultWorkflowName, issueBranch, "host", "disabled", @@ -417,12 +417,12 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { "enabled", "disabled", "disabled", - ) + fmt.Sprintf(ci.RequireManyPlainText, - "secrets."+ci.DefaultKubeconfigSecretName, - "secrets."+ci.DefaultRegistryPassSecretName, - "vars."+ci.DefaultRegistryLoginUrlVariableName, - "vars."+ci.DefaultRegistryUserVariableName, - "vars."+ci.DefaultRegistryUrlVariableName, + ) + fmt.Sprintf(github.RequireManyPlainText, + "secrets."+github.DefaultKubeconfigSecretName, + "secrets."+github.DefaultRegistryPassSecretName, + "vars."+github.DefaultRegistryLoginUrlVariableName, + "vars."+github.DefaultRegistryUserVariableName, + "vars."+github.DefaultRegistryUrlVariableName, ) result := runConfigCiCmd(t, opts) @@ -446,7 +446,7 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { "--registry-user-variable-name=DEV_REGISTRY_USER", "--registry-url-variable-name=DEV_REGISTRY_URL", ) - expectedMessage := fmt.Sprintf(ci.MainLayoutPlainText, + expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, defaultOutputPath, customWorkflowName, issueBranch, @@ -457,7 +457,7 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { "enabled", "enabled", "enabled", - ) + fmt.Sprintf(ci.RequireManyPlainText, + ) + fmt.Sprintf(github.RequireManyPlainText, "secrets.DEV_CLUSTER_KUBECONFIG", "secrets.DEV_REGISTRY_PASS", "vars.DEV_REGISTRY_LOGIN_URL", @@ -476,9 +476,9 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { "--verbose", "--registry-login=false", ) - expectedMessage := fmt.Sprintf(ci.MainLayoutPlainText, + expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, defaultOutputPath, - ci.DefaultWorkflowName, + github.DefaultWorkflowName, issueBranch, "host", "disabled", @@ -487,8 +487,8 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { "disabled", "disabled", "disabled", - ) + fmt.Sprintf(ci.RequireOnePlainText, - "secrets."+ci.DefaultKubeconfigSecretName, + ) + fmt.Sprintf(github.RequireOnePlainText, + "secrets."+github.DefaultKubeconfigSecretName, ) result := runConfigCiCmd(t, opts) @@ -500,13 +500,13 @@ func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { func TestNewConfigCICmd_PostExportMessageShown(t *testing.T) { t.Run("a message is shown with all secrets and variables for k8 and registry which needs creation", func(t *testing.T) { opts := defaultOpts() - expectedMessage := fmt.Sprintf(ci.PostExportManyPlainText, + expectedMessage := fmt.Sprintf(github.PostExportManyPlainText, defaultOutputPath, - "secrets."+ci.DefaultKubeconfigSecretName, - "secrets."+ci.DefaultRegistryPassSecretName, - "vars."+ci.DefaultRegistryLoginUrlVariableName, - "vars."+ci.DefaultRegistryUserVariableName, - "vars."+ci.DefaultRegistryUrlVariableName, + "secrets."+github.DefaultKubeconfigSecretName, + "secrets."+github.DefaultRegistryPassSecretName, + "vars."+github.DefaultRegistryLoginUrlVariableName, + "vars."+github.DefaultRegistryUserVariableName, + "vars."+github.DefaultRegistryUrlVariableName, ) result := runConfigCiCmd(t, opts) @@ -517,9 +517,9 @@ func TestNewConfigCICmd_PostExportMessageShown(t *testing.T) { t.Run("a message is shown with a secret for k8 which needs creation", func(t *testing.T) { opts := defaultOpts() opts.args = append(opts.args, "--registry-login=false") - expectedMessage := fmt.Sprintf(ci.PostExportOnePlainText, + expectedMessage := fmt.Sprintf(github.PostExportOnePlainText, defaultOutputPath, - "secrets."+ci.DefaultKubeconfigSecretName, + "secrets."+github.DefaultKubeconfigSecretName, ) result := runConfigCiCmd(t, opts) @@ -768,7 +768,7 @@ const ( runTestStepName = "Run tests" ) -var defaultOutputPath = filepath.Join(ci.DefaultGitHubWorkflowDir, ci.DefaultGitHubWorkflowFilename) +var defaultOutputPath = filepath.Join(github.DefaultGitHubWorkflowDir, github.DefaultGitHubWorkflowFilename) type opts struct { enableFeature bool @@ -781,7 +781,7 @@ type opts struct { dir string err error } - withWriter *ci.BufferWriter + withWriter *github.BufferWriter args []string } @@ -829,7 +829,7 @@ func runConfigCiCmd( // PRE-RUN PREP // all options for "func config ci" command if opts.enableFeature { - t.Setenv(ci.ConfigCIFeatureFlag, "true") + t.Setenv(fnCmd.ConfigCIFeatureFlag, "true") } loaderSaver := common.NewMockLoaderSaver() @@ -839,7 +839,7 @@ func runConfigCiCmd( writer := opts.withWriter if writer == nil { - writer = ci.NewBufferWriter() + writer = github.NewBufferWriter() } currentBranch := common.CurrentBranchStub( diff --git a/cmd/config_test.go b/cmd/config_test.go index 5317567613..2586cbc0bc 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -11,8 +11,8 @@ import ( "github.com/ory/viper" "github.com/spf13/cobra" fnCmd "knative.dev/func/cmd" - "knative.dev/func/cmd/ci" "knative.dev/func/cmd/common" + "knative.dev/func/pkg/ci/github" fn "knative.dev/func/pkg/functions" ) @@ -52,7 +52,7 @@ func TestListEnvs(t *testing.T) { func setupConfigEnvCmd(mock common.FunctionLoaderSaver, args ...string) *cobra.Command { cmd := fnCmd.NewConfigCmd( mock, - ci.NewBufferWriter(), + github.NewBufferWriter(), common.CurrentBranchStub("", nil), common.WorkDirStub("", nil), fnCmd.NewClient, diff --git a/cmd/root.go b/cmd/root.go index df89eb7479..8edeb0a7b0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,9 +14,9 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "knative.dev/client/pkg/util" - "knative.dev/func/cmd/ci" "knative.dev/func/cmd/common" "knative.dev/func/cmd/templates" + "knative.dev/func/pkg/ci/github" "knative.dev/func/pkg/config" fn "knative.dev/func/pkg/functions" "knative.dev/func/pkg/k8s" @@ -104,7 +104,7 @@ Learn more about Knative at: https://knative.dev`, cfg.Name), Commands: []*cobra.Command{ NewConfigCmd( common.DefaultLoaderSaver, - ci.DefaultWorkflowWriter, + github.DefaultWorkflowWriter, common.DefaultCurrentBranch, common.DefaultWorkDir, newClient, diff --git a/cmd/ci/common.go b/pkg/ci/github/common.go similarity index 50% rename from cmd/ci/common.go rename to pkg/ci/github/common.go index 6b03a95778..5fd105c86c 100644 --- a/cmd/ci/common.go +++ b/pkg/ci/github/common.go @@ -1,7 +1,29 @@ -package ci +package github import "fmt" +func determineBuilder(runtime string, remote bool) (string, error) { + switch runtime { + case "go": + if remote { + return "pack", nil + } + return "host", nil + + case "node", "typescript", "rust", "quarkus", "springboot": + return "pack", nil + + case "python": + if remote { + return "s2i", nil + } + return "host", nil + + default: + return "", fmt.Errorf("no builder support for runtime: %s", runtime) + } +} + func determineRunner(selfHosted bool) string { if selfHosted { return "self-hosted" diff --git a/pkg/ci/github/config.go b/pkg/ci/github/config.go new file mode 100644 index 0000000000..475e7935be --- /dev/null +++ b/pkg/ci/github/config.go @@ -0,0 +1,54 @@ +package github + +import ( + "path/filepath" +) + +const ( + DefaultPlatform = "github" + DefaultGitHubWorkflowDir = ".github/workflows" + DefaultGitHubWorkflowFilename = "func-deploy.yaml" + DefaultBranch = "main" + DefaultWorkflowName = "Func Deploy" + DefaultRemoteBuildWorkflowName = "Remote " + DefaultWorkflowName + DefaultKubeconfigSecretName = "KUBECONFIG" + DefaultRegistryLoginUrlVariableName = "REGISTRY_LOGIN_URL" + DefaultRegistryUserVariableName = "REGISTRY_USERNAME" + DefaultRegistryPassSecretName = "REGISTRY_PASSWORD" + DefaultRegistryUrlVariableName = "REGISTRY_URL" + DefaultRegistryLogin = true + DefaultWorkflowDispatch = false + DefaultRemoteBuild = false + DefaultSelfHostedRunner = false + DefaultTestStep = true + DefaultForce = false +) + +type Config struct { + GithubWorkflowDir, + GithubWorkflowFilename, + Branch, + WorkflowName, + KubeconfigSecret, + RegistryLoginUrlVar, + RegistryUserVar, + RegistryPassSecret, + RegistryUrlVar string + RegistryLogin, + SelfHostedRunner, + RemoteBuild, + WorkflowDispatch, + TestStep, + Force bool + FnRuntime, + FnRoot string +} + +func (cc Config) FnGitHubWorkflowFilepath() string { + fnGitHubWorkflowDir := filepath.Join(cc.FnRoot, cc.GithubWorkflowDir) + return filepath.Join(fnGitHubWorkflowDir, cc.GithubWorkflowFilename) +} + +func (cc Config) OutputPath() string { + return filepath.Join(cc.GithubWorkflowDir, cc.GithubWorkflowFilename) +} diff --git a/pkg/ci/github/generator.go b/pkg/ci/github/generator.go new file mode 100644 index 0000000000..5055e5238e --- /dev/null +++ b/pkg/ci/github/generator.go @@ -0,0 +1,45 @@ +package github + +import ( + "context" + "fmt" + "io" + + fn "knative.dev/func/pkg/functions" +) + +const DefaultVerbose = false + +type workflowGenerator struct { + verbose bool +} + +func NewWorkflowGenerator(verbose bool) *workflowGenerator { + return &workflowGenerator{verbose} +} + +func (g *workflowGenerator) Generate(ctx context.Context, config any, pathWriter fn.PathWriter, messageWriter io.Writer) error { + cfg, ok := config.(Config) + if !ok { + return fmt.Errorf("incorrect type of Config: %T", config) + } + + githubWorkflow, err := newGitHubWorkflow(cfg, messageWriter) + if err != nil { + return err + } + + if err := githubWorkflow.Export(cfg.FnGitHubWorkflowFilepath(), pathWriter, cfg.Force, messageWriter); err != nil { + return err + } + + if g.verbose { + // best-effort user message; errors are non-critical + _ = PrintConfiguration(messageWriter, cfg) + return nil + } + + // best-effort user message; errors are non-critical + _ = PrintPostExportMessage(messageWriter, cfg) + return nil +} diff --git a/cmd/ci/printer.go b/pkg/ci/github/printer.go similarity index 55% rename from cmd/ci/printer.go rename to pkg/ci/github/printer.go index 6470717b45..45954674e5 100644 --- a/cmd/ci/printer.go +++ b/pkg/ci/github/printer.go @@ -1,4 +1,4 @@ -package ci +package github import ( "fmt" @@ -12,7 +12,7 @@ GitHub Workflow Configuration Workflow name: %s Branch: %s Builder: %s - Remote build: %s + Remote build %s Runner: %s Test step: %s Registry login: %s @@ -49,29 +49,34 @@ Create the following Secret on github.com: %s ` ) -func PrintConfiguration(w io.Writer, conf CIConfig) error { +func PrintConfiguration(w io.Writer, conf Config) error { + builder, err := determineBuilder(conf.FnRuntime, conf.RemoteBuild) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, MainLayoutPlainText, conf.OutputPath(), - conf.WorkflowName(), - conf.Branch(), - conf.FnBuilder(), - enabledOrDisabled(conf.RemoteBuild()), - determineRunner(conf.SelfHostedRunner()), - enabledOrDisabled(conf.TestStep()), - enabledOrDisabled(conf.RegistryLogin()), - enabledOrDisabled(conf.WorkflowDispatch()), - enabledOrDisabled(conf.Force()), + conf.WorkflowName, + conf.Branch, + builder, + enabledOrDisabled(conf.RemoteBuild), + determineRunner(conf.SelfHostedRunner), + enabledOrDisabled(conf.TestStep), + enabledOrDisabled(conf.RegistryLogin), + enabledOrDisabled(conf.WorkflowDispatch), + enabledOrDisabled(conf.Force), ); err != nil { return err } - if conf.RegistryLogin() { + if conf.RegistryLogin { if _, err := fmt.Fprintf(w, RequireManyPlainText, - secretsPrefix(conf.KubeconfigSecret()), - secretsPrefix(conf.RegistryPassSecret()), - varsPrefix(conf.RegistryLoginUrlVar()), - varsPrefix(conf.RegistryUserVar()), - varsPrefix(conf.RegistryUrlVar()), + secretsPrefix(conf.KubeconfigSecret), + secretsPrefix(conf.RegistryPassSecret), + varsPrefix(conf.RegistryLoginUrlVar), + varsPrefix(conf.RegistryUserVar), + varsPrefix(conf.RegistryUrlVar), ); err != nil { return err } @@ -81,7 +86,7 @@ func PrintConfiguration(w io.Writer, conf CIConfig) error { if _, err := fmt.Fprintf(w, RequireOnePlainText, - secretsPrefix(conf.KubeconfigSecret()), + secretsPrefix(conf.KubeconfigSecret), ); err != nil { return err } @@ -89,22 +94,22 @@ func PrintConfiguration(w io.Writer, conf CIConfig) error { return nil } -func PrintPostExportMessage(w io.Writer, conf CIConfig) error { - if conf.RegistryLogin() { +func PrintPostExportMessage(w io.Writer, conf Config) error { + if conf.RegistryLogin { _, err := fmt.Fprintf(w, PostExportManyPlainText, conf.OutputPath(), - secretsPrefix(conf.KubeconfigSecret()), - secretsPrefix(conf.RegistryPassSecret()), - varsPrefix(conf.RegistryLoginUrlVar()), - varsPrefix(conf.RegistryUserVar()), - varsPrefix(conf.RegistryUrlVar()), + secretsPrefix(conf.KubeconfigSecret), + secretsPrefix(conf.RegistryPassSecret), + varsPrefix(conf.RegistryLoginUrlVar), + varsPrefix(conf.RegistryUserVar), + varsPrefix(conf.RegistryUrlVar), ) return err } _, err := fmt.Fprintf(w, PostExportOnePlainText, conf.OutputPath(), - secretsPrefix(conf.KubeconfigSecret()), + secretsPrefix(conf.KubeconfigSecret), ) return err } diff --git a/cmd/ci/printer_test.go b/pkg/ci/github/printer_test.go similarity index 72% rename from cmd/ci/printer_test.go rename to pkg/ci/github/printer_test.go index 423f41ba90..ec3d80792f 100644 --- a/cmd/ci/printer_test.go +++ b/pkg/ci/github/printer_test.go @@ -1,7 +1,9 @@ -package ci +package github import ( + "bytes" "errors" + "fmt" "testing" "gotest.tools/v3/assert" @@ -26,7 +28,7 @@ func (fw *failWriter) Write(p []byte) (int, error) { func TestPrintConfigurationFail(t *testing.T) { t.Run("main layout write fails", func(t *testing.T) { w := &failWriter{failOnCall: 1, err: errWrite} - conf := CIConfig{fnRuntime: "go"} + conf := Config{FnRuntime: "go"} err := PrintConfiguration(w, conf) @@ -35,7 +37,7 @@ func TestPrintConfigurationFail(t *testing.T) { t.Run("registry secrets write fails", func(t *testing.T) { w := &failWriter{failOnCall: 2, err: errWrite} - conf := CIConfig{registryLogin: true, fnRuntime: "go"} + conf := Config{RegistryLogin: true, FnRuntime: "go"} err := PrintConfiguration(w, conf) @@ -44,18 +46,27 @@ func TestPrintConfigurationFail(t *testing.T) { t.Run("single secret write fails", func(t *testing.T) { w := &failWriter{failOnCall: 2, err: errWrite} - conf := CIConfig{registryLogin: false, fnRuntime: "go"} + conf := Config{RegistryLogin: false, FnRuntime: "go"} err := PrintConfiguration(w, conf) assert.Error(t, err, errWrite.Error()) }) + + t.Run("unsupported runtime fails", func(t *testing.T) { + conf := Config{FnRuntime: "ruby"} + expectedErr := fmt.Errorf("no builder support for runtime: %s", conf.FnRuntime) + + err := PrintConfiguration(&bytes.Buffer{}, conf) + + assert.Error(t, err, expectedErr.Error()) + }) } func TestPrintPostExportMessageFail(t *testing.T) { t.Run("with registry login write fails", func(t *testing.T) { w := &failWriter{failOnCall: 1, err: errWrite} - conf := CIConfig{registryLogin: true} + conf := Config{RegistryLogin: true} err := PrintPostExportMessage(w, conf) @@ -64,7 +75,7 @@ func TestPrintPostExportMessageFail(t *testing.T) { t.Run("without registry login write fails", func(t *testing.T) { w := &failWriter{failOnCall: 1, err: errWrite} - conf := CIConfig{registryLogin: false} + conf := Config{RegistryLogin: false} err := PrintPostExportMessage(w, conf) diff --git a/cmd/ci/workflow.go b/pkg/ci/github/workflow.go similarity index 73% rename from cmd/ci/workflow.go rename to pkg/ci/github/workflow.go index fe4d546643..35aa3faef3 100644 --- a/cmd/ci/workflow.go +++ b/pkg/ci/github/workflow.go @@ -1,4 +1,4 @@ -package ci +package github import ( "bytes" @@ -7,6 +7,7 @@ import ( "io" "gopkg.in/yaml.v3" + fn "knative.dev/func/pkg/functions" ) const defaultFuncCliVersion = "knative-v1.21.0" @@ -14,7 +15,7 @@ const defaultFuncCliVersion = "knative-v1.21.0" // ErrWorkflowExists is returned when a GitHub workflow file already exists and --force is not specified. var ErrWorkflowExists = errors.New("existing GitHub workflow detected, overwrite using the --force option") -type githubWorkflow struct { +type workflow struct { Name string `yaml:"name"` On workflowTriggers `yaml:"on"` Jobs map[string]job `yaml:"jobs"` @@ -42,7 +43,7 @@ type step struct { With map[string]string `yaml:"with,omitempty"` } -func NewGitHubWorkflow(conf CIConfig, messageWriter io.Writer) *githubWorkflow { +func newGitHubWorkflow(conf Config, messageWriter io.Writer) (*workflow, error) { var steps []step steps = createCheckoutStep(steps) steps = createRuntimeTestStep(conf, messageWriter, steps) @@ -50,18 +51,21 @@ func NewGitHubWorkflow(conf CIConfig, messageWriter io.Writer) *githubWorkflow { steps = createRegistryLoginStep(conf, steps) steps = createFuncCLIInstallStep(steps) - steps = createFuncDeployStep(conf, steps) + steps, err := createFuncDeployStep(conf, steps) + if err != nil { + return nil, err + } - return &githubWorkflow{ - Name: conf.WorkflowName(), + return &workflow{ + Name: conf.WorkflowName, On: createPushTrigger(conf), Jobs: map[string]job{ "deploy": { - RunsOn: determineRunner(conf.SelfHostedRunner()), + RunsOn: determineRunner(conf.SelfHostedRunner), Steps: steps, }, }, - } + }, nil } func createCheckoutStep(steps []step) []step { @@ -71,14 +75,14 @@ func createCheckoutStep(steps []step) []step { return append(steps, *checkoutCode) } -func createRuntimeTestStep(conf CIConfig, messageWriter io.Writer, steps []step) []step { - if !conf.TestStep() { +func createRuntimeTestStep(conf Config, messageWriter io.Writer, steps []step) []step { + if !conf.TestStep { return steps } testStep := newStep("Run tests") - switch conf.FnRuntime() { + switch conf.FnRuntime { case "go": testStep.withRun("go test ./...") case "node", "typescript": @@ -89,32 +93,32 @@ func createRuntimeTestStep(conf CIConfig, messageWriter io.Writer, steps []step) testStep.withRun("./mvnw test") default: // best-effort user message; errors are non-critical - _, _ = fmt.Fprintf(messageWriter, "WARNING: test step not supported for runtime %s\n", conf.FnRuntime()) + _, _ = fmt.Fprintf(messageWriter, "WARNING: test step not supported for runtime %s\n", conf.FnRuntime) return steps } return append(steps, *testStep) } -func createK8ContextStep(conf CIConfig, steps []step) []step { +func createK8ContextStep(conf Config, steps []step) []step { setupK8Context := newStep("Setup Kubernetes context"). withUses("azure/k8s-set-context@v4"). withActionConfig("method", "kubeconfig"). - withActionConfig("kubeconfig", newSecret(conf.KubeconfigSecret())) + withActionConfig("kubeconfig", newSecret(conf.KubeconfigSecret)) return append(steps, *setupK8Context) } -func createRegistryLoginStep(conf CIConfig, steps []step) []step { - if !conf.RegistryLogin() { +func createRegistryLoginStep(conf Config, steps []step) []step { + if !conf.RegistryLogin { return steps } loginToContainerRegistry := newStep("Login to container registry"). withUses("docker/login-action@v3"). - withActionConfig("registry", newVariable(conf.RegistryLoginUrlVar())). - withActionConfig("username", newVariable(conf.RegistryUserVar())). - withActionConfig("password", newSecret(conf.RegistryPassSecret())) + withActionConfig("registry", newVariable(conf.RegistryLoginUrlVar)). + withActionConfig("username", newVariable(conf.RegistryUserVar)). + withActionConfig("password", newSecret(conf.RegistryPassSecret)) return append(steps, *loginToContainerRegistry) } @@ -128,31 +132,36 @@ func createFuncCLIInstallStep(steps []step) []step { return append(steps, *installFuncCli) } -func createFuncDeployStep(conf CIConfig, steps []step) []step { +func createFuncDeployStep(conf Config, steps []step) ([]step, error) { deployFuncStep := newStep("Deploy function"). - withEnv("FUNC_VERBOSE", "true"). - withEnv("FUNC_BUILDER", conf.FnBuilder()) + withEnv("FUNC_VERBOSE", "true") + + builder, err := determineBuilder(conf.FnRuntime, conf.RemoteBuild) + if err != nil { + return nil, err + } + deployFuncStep.withEnv("FUNC_BUILDER", builder) - if conf.RemoteBuild() { + if conf.RemoteBuild { deployFuncStep.withEnv("FUNC_REMOTE", "true") } - registryUrl := newVariable(conf.RegistryUrlVar()) - if conf.RegistryLogin() { - registryUrl = newVariable(conf.RegistryLoginUrlVar()) + "/" + newVariable(conf.RegistryUserVar()) + registryUrl := newVariable(conf.RegistryUrlVar) + if conf.RegistryLogin { + registryUrl = newVariable(conf.RegistryLoginUrlVar) + "/" + newVariable(conf.RegistryUserVar) } deployFuncStep.withEnv("FUNC_REGISTRY", registryUrl). withRun("func deploy") - return append(steps, *deployFuncStep) + return append(steps, *deployFuncStep), nil } -func createPushTrigger(conf CIConfig) workflowTriggers { +func createPushTrigger(conf Config) workflowTriggers { result := workflowTriggers{ - Push: &pushTrigger{Branches: []string{conf.Branch()}}, + Push: &pushTrigger{Branches: []string{conf.Branch}}, } - if conf.WorkflowDispatch() { + if conf.WorkflowDispatch { result.WorkflowDispatch = &struct{}{} } @@ -193,7 +202,7 @@ func (s *step) withEnv(key, value string) *step { return s } -func (gw *githubWorkflow) Export(path string, w WorkflowWriter, force bool, m io.Writer) error { +func (gw *workflow) Export(path string, w fn.PathWriter, force bool, m io.Writer) error { if !force && w.Exist(path) { return ErrWorkflowExists } @@ -211,7 +220,7 @@ func (gw *githubWorkflow) Export(path string, w WorkflowWriter, force bool, m io return w.Write(path, raw) } -func (gw *githubWorkflow) toYaml() ([]byte, error) { +func (gw *workflow) toYaml() ([]byte, error) { var buf bytes.Buffer encoder := yaml.NewEncoder(&buf) encoder.SetIndent(2) diff --git a/pkg/ci/github/workflow_test.go b/pkg/ci/github/workflow_test.go new file mode 100644 index 0000000000..433d0ad8e1 --- /dev/null +++ b/pkg/ci/github/workflow_test.go @@ -0,0 +1,32 @@ +package github + +import ( + "bytes" + "strings" + "testing" + + "github.com/ory/viper" + "gotest.tools/v3/assert" +) + +func TestGitHubWorkflow_Export(t *testing.T) { + // GIVEN + viper.Set("platform", "github") + t.Cleanup(func() { viper.Reset() }) + bufferWriter := NewBufferWriter() + + // WHEN + cfg := Config{ + FnRoot: "path/to/function", + FnRuntime: "go", + } + + gw, workflowErr := newGitHubWorkflow(cfg, &bytes.Buffer{}) + assert.NilError(t, workflowErr, "unexpected error when creating GitHub Workflow") + + exportErr := gw.Export(cfg.FnGitHubWorkflowFilepath(), bufferWriter, true, &bytes.Buffer{}) + + // THEN + assert.NilError(t, exportErr, "unexpected error when exporting GitHub Workflow") + assert.Assert(t, strings.Contains(bufferWriter.Buffer.String(), gw.Name)) +} diff --git a/cmd/ci/writer.go b/pkg/ci/github/writer.go similarity index 84% rename from cmd/ci/writer.go rename to pkg/ci/github/writer.go index f15be50b1e..abc418ac80 100644 --- a/cmd/ci/writer.go +++ b/pkg/ci/github/writer.go @@ -1,4 +1,4 @@ -package ci +package github import ( "bytes" @@ -14,12 +14,7 @@ const ( // DefaultWorkflowWriter is the default implementation for writing workflow files to disk. var DefaultWorkflowWriter = &fileWriter{} -// WorkflowWriter defines the interface for writing workflow files. -type WorkflowWriter interface { - Exist(path string) bool - Write(path string, raw []byte) error -} - +// fileWriter implements functions.PathWriter type fileWriter struct{} // Write writes raw bytes to the specified path, creating directories as needed. @@ -40,7 +35,7 @@ func (fw *fileWriter) Exist(path string) bool { return err == nil } -// BufferWriter is a test double (fake) that implements WorkflowWriter +// BufferWriter is a test double (fake) that implements functions.PathWriter // by writing to an in-memory buffer instead of the filesystem. type BufferWriter struct { Path string diff --git a/cmd/ci/writer_test.go b/pkg/ci/github/writer_test.go similarity index 90% rename from cmd/ci/writer_test.go rename to pkg/ci/github/writer_test.go index 5a64f13004..9f2e02a460 100644 --- a/cmd/ci/writer_test.go +++ b/pkg/ci/github/writer_test.go @@ -1,16 +1,15 @@ -package ci_test +package github import ( "testing" "gotest.tools/v3/assert" - "knative.dev/func/cmd/ci" ) // TestBufferWriter_WriteAndExist exercises the in-memory BufferWriter test double: // Exist is false on an empty buffer and true after Write. func TestBufferWriter_WriteAndExist(t *testing.T) { - bw := ci.NewBufferWriter() + bw := NewBufferWriter() assert.Assert(t, !bw.Exist("any/path"), "empty buffer should report Exist=false") @@ -25,7 +24,7 @@ func TestBufferWriter_WriteAndExist(t *testing.T) { // TestBufferWriter_Write_StoresPath checks that Write records the path that was // passed to it. func TestBufferWriter_Write_StoresPath(t *testing.T) { - bw := ci.NewBufferWriter() + bw := NewBufferWriter() path := ".github/workflows/func-deploy.yaml" assert.NilError(t, bw.Write(path, []byte("data"))) @@ -35,7 +34,7 @@ func TestBufferWriter_Write_StoresPath(t *testing.T) { // TestBufferWriter_Write_ResetsBuffer ensures that consecutive Write calls do // not accumulate content — each call starts with a fresh buffer. func TestBufferWriter_Write_ResetsBuffer(t *testing.T) { - bw := ci.NewBufferWriter() + bw := NewBufferWriter() assert.NilError(t, bw.Write("p", []byte("first"))) assert.NilError(t, bw.Write("p", []byte("second"))) diff --git a/pkg/functions/client.go b/pkg/functions/client.go index 893cc9af68..b3298c6ee8 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -43,7 +43,7 @@ var ( // use of this set is left up to the discretion of the builders // themselves. In the event the builder receives build options which // specify a set of platforms to use in leau of the default (see the - // BuildWithPlatforms functionl option), the builder should return + // BuildWithPlatforms function option), the builder should return // an error if the request can not proceed. DefaultPlatforms = []Platform{ {OS: "linux", Architecture: "amd64"}, @@ -83,6 +83,9 @@ type Client struct { mcpServer MCPServer // MCP Server startTimeout time.Duration // default start timeout for all runs syncer FunctionSyncer // Syncs Function CR after deploy + ci CI + stdout io.Writer + pathWriter PathWriter } // Scaffolder wraps a function with a service scaffolding (entrypoint) @@ -230,6 +233,16 @@ type MCPServer interface { Start(context.Context) error } +type CI interface { + Generate(context.Context, any, PathWriter, io.Writer) error +} + +// PathWriter defines the interface for writing files to a given path. +type PathWriter interface { + Exist(path string) bool + Write(path string, raw []byte) error +} + // New client for function management. func New(options ...Option) *Client { // Instantiate client with static defaults. @@ -246,6 +259,7 @@ func New(options ...Option) *Client { mcpServer: &noopMCPServer{}, transport: http.DefaultTransport, startTimeout: DefaultStartTimeout, + ci: &noopCI{}, } c.runner = newDefaultRunner(c, os.Stdout, os.Stderr) for _, o := range options { @@ -352,7 +366,7 @@ func WithDescribers(describers ...Describer) Option { } } -// WithDNSProvider proivdes a DNS provider implementation for registering the +// WithDNSProvider provides a DNS provider implementation for registering the // effective DNS name which is either explicitly set via WithName or is derived // from the root path. func WithDNSProvider(provider DNSProvider) Option { @@ -371,7 +385,7 @@ func WithRepositoriesPath(path string) Option { } // WithRepository sets a specific URL to a Git repository from which to pull -// templates. This setting's existence precldes the use of either the inbuilt +// templates. This setting's existence precedes the use of either the inbuilt // templates or any repositories from the extensible repositories path. func WithRepository(uri string) Option { return func(c *Client) { @@ -444,6 +458,24 @@ func WithStartTimeout(t time.Duration) Option { } } +func WithCI(ci CI) Option { + return func(c *Client) { + c.ci = ci + } +} + +func WithPathWriter(pw PathWriter) Option { + return func(c *Client) { + c.pathWriter = pw + } +} + +func WithStdout(out io.Writer) Option { + return func(c *Client) { + c.stdout = out + } +} + // ACCESSORS // --------- @@ -582,7 +614,6 @@ func (c *Client) New(ctx context.Context, cfg Function) (string, Function, error // Push the produced function image fmt.Fprintf(os.Stderr, "Pushing container image to registry\n") - if f, _, err = c.Push(ctx, f); err != nil { return route, f, err } @@ -1270,6 +1301,10 @@ func (c *Client) StartMCPServer(ctx context.Context) error { return c.mcpServer.Start(ctx) } +func (c *Client) GenerateCIWorkflow(ctx context.Context, config any) error { + return c.ci.Generate(ctx, config, c.pathWriter, c.stdout) +} + // ensureRunDataDir creates a .func directory at the given path, and // registers it as ignored in a .gitignore file. func ensureRunDataDir(root string) error { @@ -1636,3 +1671,7 @@ func (n *noopDNSProvider) Provide(_ Function) error { return nil } type noopMCPServer struct{} func (n *noopMCPServer) Start(_ context.Context) error { return nil } + +type noopCI struct{} + +func (n *noopCI) Generate(_ context.Context, _ any, _ PathWriter, _ io.Writer) error { return nil } diff --git a/templates/typescript/cloudevents/test/integration.ts b/templates/typescript/cloudevents/test/integration.ts index e7f57c566d..af72f3b417 100644 --- a/templates/typescript/cloudevents/test/integration.ts +++ b/templates/typescript/cloudevents/test/integration.ts @@ -3,7 +3,7 @@ import { CloudEvent, HTTP } from 'cloudevents'; import { start, InvokerOptions } from 'faas-js-runtime'; import request from 'supertest'; -import * as func from '../build'; +import * as func from '../src'; import test, { Test } from 'tape'; // Test typed CloudEvent data diff --git a/templates/typescript/http/src/index.ts b/templates/typescript/http/src/index.ts index 65c445d53e..7f90422ce0 100644 --- a/templates/typescript/http/src/index.ts +++ b/templates/typescript/http/src/index.ts @@ -1,4 +1,4 @@ -import { Context, StructuredReturn } from 'faas-js-runtime'; +import { Context, IncomingBody, StructuredReturn } from 'faas-js-runtime'; /** * Your HTTP handling function, invoked with each request. This is an example @@ -16,7 +16,7 @@ import { Context, StructuredReturn } from 'faas-js-runtime'; * @param {string} context.httpVersion the HTTP protocol version * See: https://github.com/knative/func/blob/main/docs/guides/nodejs.md#the-context-object */ -const handle = async (context: Context, body: string): Promise => { +const handle = async (context: Context, body?: IncomingBody): Promise => { // YOUR CODE HERE context.log.info(`body: ${body}`); return { diff --git a/templates/typescript/http/test/integration.ts b/templates/typescript/http/test/integration.ts index 20104b042c..497cb74a5e 100644 --- a/templates/typescript/http/test/integration.ts +++ b/templates/typescript/http/test/integration.ts @@ -2,7 +2,7 @@ import { start, InvokerOptions } from 'faas-js-runtime'; import request from 'supertest'; -import * as func from '../build'; +import * as func from '../src'; import test, { Test } from 'tape'; const data = { diff --git a/templates/typescript/http/test/unit.ts b/templates/typescript/http/test/unit.ts index 5b4426d875..374ce9b91e 100644 --- a/templates/typescript/http/test/unit.ts +++ b/templates/typescript/http/test/unit.ts @@ -3,7 +3,7 @@ import test from 'tape'; import { expectType } from 'tsd'; import { Context, HTTPFunction } from 'faas-js-runtime'; -import { handle } from '../build/index.js'; +import { handle } from '../src'; // Ensure that the function completes cleanly when passed a valid event. test('Unit: handles a valid request', async (t) => { From b1a2eef02db3d88bd1528070b342647349efe0ad Mon Sep 17 00:00:00 2001 From: Stanislav Jakuschevskij Date: Thu, 14 May 2026 18:05:32 +0200 Subject: [PATCH 2/2] refactor: address PR #3572 review feedback The CI interface on the client was too broad: it accepted any-typed config and required PathWriter and io.Writer to be threaded through the client as fields. The reviewer asked for a clean Generate(ctx, Function) signature and proper test separation between CLI and pkg layers. The client now holds a single CIGenerator interface with Generate(ctx, Function). All implementation details (writers, verbosity, workflow config) are owned by the concrete generator, injected at construction time via functional options. The PathWriter interface was replaced by a WorkflowWriter local to the github package, removing the reverse dependency on pkg/functions. To decouple CLI tests from generator internals, a ciGeneratorFactory was introduced following the existing ClientFactory/NewTestClient pattern. The factory takes the resolved WorkflowConfig and returns a CIGenerator, letting tests capture the config via a mock and verify that flags map correctly without inspecting generated YAML. Workflow structure tests were migrated from cmd/config_ci_test.go to pkg/ci/github/generator_test.go. The inadvertent whitespace change in printer.go (tab and missing colon on the Remote build line) was also reverted. Issue #3572 Signed-off-by: Stanislav Jakuschevskij --- cmd/client.go | 2 - cmd/config.go | 7 +- cmd/config_ci.go | 67 +- cmd/config_ci_int_test.go | 56 +- cmd/config_ci_test.go | 619 +-------------- cmd/config_test.go | 1 + cmd/root.go | 1 + pkg/ci/github/config.go | 54 -- pkg/ci/github/generator.go | 96 ++- pkg/ci/github/generator_test.go | 718 ++++++++++++++++++ pkg/ci/github/printer.go | 58 +- pkg/ci/github/printer_test.go | 25 +- pkg/ci/github/workflow.go | 164 +++- pkg/ci/github/workflow_test.go | 9 +- pkg/ci/github/writer.go | 10 +- pkg/ci/github/writer_test.go | 2 +- pkg/functions/client.go | 45 +- .../cloudevents/test/integration.ts | 2 +- templates/typescript/http/src/index.ts | 4 +- templates/typescript/http/test/integration.ts | 2 +- templates/typescript/http/test/unit.ts | 2 +- 21 files changed, 1137 insertions(+), 807 deletions(-) delete mode 100644 pkg/ci/github/config.go create mode 100644 pkg/ci/github/generator_test.go diff --git a/cmd/client.go b/cmd/client.go index 7170cf24ce..c4acf0802f 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -6,7 +6,6 @@ import ( "os" "github.com/ory/viper" - "knative.dev/func/pkg/ci/github" "knative.dev/func/pkg/keda" "knative.dev/func/cmd/prompt" @@ -86,7 +85,6 @@ func NewClient(cfg ClientConfig, options ...fn.Option) (*fn.Client, func()) { docker.WithVerbose(cfg.Verbose), docker.WithInsecure(cfg.InsecureSkipVerify))), fn.WithSyncer(operator.NewSyncer(operator.WithCredentialsProvider(c))), - fn.WithCI(github.NewWorkflowGenerator(cfg.Verbose)), } ) diff --git a/cmd/config.go b/cmd/config.go index f04d40512b..f5e1615728 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -9,15 +9,17 @@ import ( "github.com/spf13/cobra" "knative.dev/func/cmd/common" + "knative.dev/func/pkg/ci/github" "knative.dev/func/pkg/config" fn "knative.dev/func/pkg/functions" ) func NewConfigCmd( loaderSaver common.FunctionLoaderSaver, - pathWriter fn.PathWriter, + workflowWriter github.WorkflowWriter, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, + newCIGenerator ciGeneratorFactory, newClient ClientFactory, ) *cobra.Command { cmd := &cobra.Command{ @@ -49,9 +51,10 @@ or from the directory specified with --path. if os.Getenv(ConfigCIFeatureFlag) == "true" { cmd.AddCommand(NewConfigCICmd( loaderSaver, - pathWriter, + workflowWriter, currentBranch, workingDir, + newCIGenerator, newClient, )) } diff --git a/cmd/config_ci.go b/cmd/config_ci.go index 6974aee759..2c51dbd803 100644 --- a/cmd/config_ci.go +++ b/cmd/config_ci.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "io" "strings" "github.com/ory/viper" @@ -12,6 +13,11 @@ import ( fn "knative.dev/func/pkg/functions" ) +// ciGeneratorFactory creates a CIGenerator from resolved CLI flag values. +// Using a factory allows tests to capture the resolved config and inject +// a mock generator without running the real implementation. +type ciGeneratorFactory func(github.WorkflowConfig, github.WorkflowWriter, io.Writer, bool) fn.CIGenerator + const ( ConfigCIFeatureFlag = "FUNC_ENABLE_CI_CONFIG" pathFlag = "path" @@ -34,9 +40,10 @@ const ( func NewConfigCICmd( loaderSaver common.FunctionLoaderSaver, - pathWriter fn.PathWriter, + workflowWriter github.WorkflowWriter, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, + newCIGenerator ciGeneratorFactory, newClient ClientFactory, ) *cobra.Command { cmd := &cobra.Command{ @@ -67,11 +74,12 @@ func NewConfigCICmd( return runConfigCIGitHub( cmd, - pathWriter, + workflowWriter, loaderSaver, currentBranch, workingDir, workflowNameExplicit, + newCIGenerator, newClient, ) }, @@ -171,14 +179,15 @@ func NewConfigCICmd( func runConfigCIGitHub( cmd *cobra.Command, - pathWriter fn.PathWriter, + workflowWriter github.WorkflowWriter, fnLoaderSaver common.FunctionLoaderSaver, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, workflowNameExplicit bool, + newCIGenerator ciGeneratorFactory, newClient ClientFactory, ) error { - cfg, err := newCIConfig( + cfg, f, err := newConfigAndLoadedFunc( fnLoaderSaver, currentBranch, workingDir, @@ -188,48 +197,50 @@ func runConfigCIGitHub( return err } + verbose := viper.GetBool(verboseFlag) + ciGenerator := newCIGenerator(cfg, workflowWriter, cmd.OutOrStdout(), verbose) + client, done := newClient( - ClientConfig{Verbose: viper.GetBool(verboseFlag)}, - fn.WithPathWriter(pathWriter), - fn.WithStdout(cmd.OutOrStdout()), + ClientConfig{Verbose: verbose}, + fn.WithCIGenerator(ciGenerator), ) defer done() - if err := client.GenerateCIWorkflow(cmd.Context(), cfg); err != nil { + if err := client.GenerateCIWorkflow(cmd.Context(), f); err != nil { return err } return nil } -func newCIConfig( +func newConfigAndLoadedFunc( fnLoader common.FunctionLoader, currentBranch common.CurrentBranchFunc, workingDir common.WorkDirFunc, workflowNameExplicit bool, -) (github.Config, error) { +) (github.WorkflowConfig, fn.Function, error) { if err := resolvePlatform(); err != nil { - return github.Config{}, err + return github.WorkflowConfig{}, fn.Function{}, err } path, err := resolvePath(workingDir) if err != nil { - return github.Config{}, err + return github.WorkflowConfig{}, fn.Function{}, err } branch, err := resolveBranch(path, currentBranch) if err != nil { - return github.Config{}, err + return github.WorkflowConfig{}, fn.Function{}, err } workflowName := resolveWorkflowName(workflowNameExplicit) f, err := fnLoader.Load(path) if err != nil { - return github.Config{}, err + return github.WorkflowConfig{}, fn.Function{}, err } - return github.Config{ + return github.WorkflowConfig{ GithubWorkflowDir: github.DefaultGitHubWorkflowDir, GithubWorkflowFilename: github.DefaultGitHubWorkflowFilename, Branch: branch, @@ -245,9 +256,7 @@ func newCIConfig( WorkflowDispatch: viper.GetBool(workflowDispatchFlag), TestStep: viper.GetBool(testStepFlag), Force: viper.GetBool(forceFlag), - FnRuntime: f.Runtime, - FnRoot: f.Root, - }, nil + }, f, nil } func resolvePlatform() error { @@ -302,3 +311,25 @@ func resolveWorkflowName(explicit bool) string { return github.DefaultWorkflowName } + +// NewCIGeneratorFactory returns the default production factory that +// constructs a github.WorkflowGenerator from resolved flag values. +func NewCIGeneratorFactory() ciGeneratorFactory { + return func(cfg github.WorkflowConfig, ww github.WorkflowWriter, mw io.Writer, v bool) fn.CIGenerator { + return github.NewWorkflowGenerator( + github.WithWorkflowConfig(cfg), + github.WithWorkflowWriter(ww), + github.WithMessageWriter(mw), + github.WithVerbose(v), + ) + } +} + +// NewTestCIGeneratorFactory returns a test factory that captures the resolved +// WorkflowConfig and returns the given mock as the CIGenerator. +func NewTestCIGeneratorFactory(mock *github.WorkflowGeneratorMock) ciGeneratorFactory { + return func(cfg github.WorkflowConfig, _ github.WorkflowWriter, _ io.Writer, _ bool) fn.CIGenerator { + mock.Config = cfg + return mock + } +} diff --git a/cmd/config_ci_int_test.go b/cmd/config_ci_int_test.go index af6b137438..e606eec6a9 100644 --- a/cmd/config_ci_int_test.go +++ b/cmd/config_ci_int_test.go @@ -2,6 +2,7 @@ package cmd_test import ( "errors" + "fmt" "os" "path/filepath" "slices" @@ -10,6 +11,7 @@ import ( "github.com/ory/viper" "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" fnCmd "knative.dev/func/cmd" "knative.dev/func/cmd/common" cmdTest "knative.dev/func/cmd/testing" @@ -81,7 +83,7 @@ func TestNewConfigCICmd_WritesWorkflowFileToFSWithCorrectYAMLStructure(t *testin raw := readWorkflowFile(t, opts.withFunc.Root) assert.NilError(t, err) - assertDefaultWorkflowWithBranch(t, raw, mainBranch) + assertDefaultWorkflow(t, raw) } func TestNewConfigCICmd_ForceFlagOverwritesExistingWorkflowOnFS(t *testing.T) { @@ -131,6 +133,8 @@ func TestNewConfigCICmd_ForceFlagOverwritesExistingWorkflowOnFS(t *testing.T) { // START: Testing Framework // ------------------------ +const fnName = "github-ci-func" + type optsIntegration struct { withFunc *fn.Function args []string @@ -179,6 +183,7 @@ func runConfigCiCmdIntegration( github.DefaultWorkflowWriter, common.DefaultCurrentBranch, common.DefaultWorkDir, + fnCmd.NewCIGeneratorFactory(), fnCmd.NewClient, ) cmd.SetArgs(args) @@ -197,5 +202,54 @@ func readWorkflowFile(t *testing.T, root string) string { return string(result) } +func assertDefaultWorkflow(t *testing.T, actualGw string) { + t.Helper() + + assert.Assert(t, yamlContains(actualGw, "Func Deploy")) + assert.Assert(t, yamlContains(actualGw, "- main")) + + assert.Assert(t, yamlContains(actualGw, "ubuntu-latest")) + + assert.Assert(t, strings.Count(actualGw, "- name:") == 6) + + assert.Assert(t, yamlContains(actualGw, "Checkout code")) + assert.Assert(t, yamlContains(actualGw, "actions/checkout@v4")) + + assert.Assert(t, yamlContains(actualGw, "Run tests")) + assert.Assert(t, yamlContains(actualGw, "go test ./...")) + + assert.Assert(t, yamlContains(actualGw, "Setup Kubernetes context")) + assert.Assert(t, yamlContains(actualGw, "azure/k8s-set-context@v4")) + assert.Assert(t, yamlContains(actualGw, "method: kubeconfig")) + assert.Assert(t, yamlContains(actualGw, "kubeconfig: ${{ secrets.KUBECONFIG }}")) + + assert.Assert(t, yamlContains(actualGw, "Login to container registry")) + assert.Assert(t, yamlContains(actualGw, "docker/login-action@v3")) + assert.Assert(t, yamlContains(actualGw, "registry: ${{ vars.REGISTRY_LOGIN_URL }}")) + assert.Assert(t, yamlContains(actualGw, "username: ${{ vars.REGISTRY_USERNAME }}")) + assert.Assert(t, yamlContains(actualGw, "password: ${{ secrets.REGISTRY_PASSWORD }}")) + + assert.Assert(t, yamlContains(actualGw, "Install func cli")) + assert.Assert(t, yamlContains(actualGw, "functions-dev/action@main")) + assert.Assert(t, yamlContains(actualGw, "version: knative-v1.22.0")) + assert.Assert(t, yamlContains(actualGw, "name: func")) + + assert.Assert(t, yamlContains(actualGw, "Deploy function")) + assert.Assert(t, yamlContains(actualGw, `FUNC_VERBOSE: "true"`)) + assert.Assert(t, yamlContains(actualGw, "FUNC_REGISTRY: ${{ vars.REGISTRY_LOGIN_URL }}/${{ vars.REGISTRY_USERNAME }}")) + assert.Assert(t, yamlContains(actualGw, "func deploy")) +} + +func yamlContains(yaml, substr string) cmp.Comparison { + return func() cmp.Result { + if strings.Contains(yaml, substr) { + return cmp.ResultSuccess + } + return cmp.ResultFailure(fmt.Sprintf( + "missing '%s' in:\n\n%s", substr, yaml, + )) + } +} + // ---------------------- // END: Testing Framework diff --git a/cmd/config_ci_test.go b/cmd/config_ci_test.go index 3cf22e365e..459f388294 100644 --- a/cmd/config_ci_test.go +++ b/cmd/config_ci_test.go @@ -1,7 +1,6 @@ package cmd_test import ( - "bytes" "fmt" "path/filepath" "strings" @@ -9,18 +8,12 @@ import ( "github.com/ory/viper" "gotest.tools/v3/assert" - "gotest.tools/v3/assert/cmp" fnCmd "knative.dev/func/cmd" "knative.dev/func/cmd/common" "knative.dev/func/pkg/ci/github" fn "knative.dev/func/pkg/functions" ) -// START: Broad Unit Tests -// ----------------------- -// Execution is tested starting from the entrypoint "func config ci" including -// all components working together. Infrastructure components like the -// filesystem are mocked. func TestNewConfigCICmd_RequiresFeatureFlag(t *testing.T) { opts := defaultOpts() opts.enableFeature = false @@ -28,6 +21,7 @@ func TestNewConfigCICmd_RequiresFeatureFlag(t *testing.T) { result := runConfigCiCmd(t, opts) assert.ErrorContains(t, result.executeErr, "unknown command \"ci\" for \"config\"") + assert.Equal(t, result.generatorWasInvoked, false) } func TestNewConfigCICmd_CISubcommandExist(t *testing.T) { @@ -38,39 +32,7 @@ func TestNewConfigCICmd_CISubcommandExist(t *testing.T) { result := runConfigCiCmd(t, opts) assert.NilError(t, result.executeErr) -} - -func TestNewConfigCICmd_WritesWorkflowFile(t *testing.T) { - result := runConfigCiCmd(t, defaultOpts()) - - assert.NilError(t, result.executeErr) - assert.Assert(t, result.gwYamlString != "") -} - -func TestNewConfigCICmd_WorkflowYAMLHasCorrectStructure(t *testing.T) { - result := runConfigCiCmd(t, defaultOpts()) - - assert.NilError(t, result.executeErr) - assertDefaultWorkflow(t, result.gwYamlString) -} - -func TestNewConfigCICmd_WorkflowYAMLHasCustomValues(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.args = append(opts.args, - "--self-hosted-runner", - "--kubeconfig-secret-name=DEV_CLUSTER_KUBECONFIG", - "--registry-login-url-variable-name=DEV_REGISTRY_LOGIN_URL", - "--registry-user-variable-name=DEV_REGISTRY_USER", - "--registry-pass-secret-name=DEV_REGISTRY_PASS", - ) - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assertCustomWorkflow(t, result.gwYamlString) + assert.Equal(t, result.generatorWasInvoked, true) } func TestNewConfigCICmd_WorkflowNameResolution(t *testing.T) { @@ -117,7 +79,7 @@ func TestNewConfigCICmd_WorkflowNameResolution(t *testing.T) { // THEN assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, "name: "+tc.expectedWorkflowName)) + assert.Equal(t, result.workflowConfig.WorkflowName, tc.expectedWorkflowName) }) } } @@ -134,49 +96,7 @@ func TestNewConfigCICmd_WorkflowNameFromEnvVarPreserveWithRemote(t *testing.T) { // THEN assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, "name: "+customWorkflowName)) -} - -func TestNewConfigCICmd_WorkflowHasNoRegistryLogin(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.args = append(opts.args, "--registry-login=false") - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, !strings.Contains(result.gwYamlString, "docker/login-action@v3")) - assert.Assert(t, !strings.Contains(result.gwYamlString, "Login to container registry")) - assert.Assert(t, yamlContains(result.gwYamlString, "FUNC_REGISTRY: ${{ vars.REGISTRY_URL }}")) -} - -func TestNewConfigCICmd_RemoteBuildAndDeployWorkflow(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.args = append(opts.args, "--remote") - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, "Remote Func Deploy")) - assert.Assert(t, yamlContains(result.gwYamlString, `FUNC_REMOTE: "true"`)) -} - -func TestNewConfigCICmd_HasWorkflowDispatch(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.args = append(opts.args, "--workflow-dispatch") - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, "workflow_dispatch")) + assert.Equal(t, result.workflowConfig.WorkflowName, customWorkflowName) } func TestNewConfigCICmd_PathFlagResolution(t *testing.T) { @@ -188,25 +108,21 @@ func TestNewConfigCICmd_PathFlagResolution(t *testing.T) { testCases := []struct { name string pathArg string // empty means no --path flag - getwdReturn string expectedPath string }{ { name: "empty path uses cwd", pathArg: "", - getwdReturn: cwd, expectedPath: cwd, }, { name: "dot path uses cwd", pathArg: "--path=.", - getwdReturn: cwd, expectedPath: cwd, }, { name: "explicit func path used as-is", pathArg: "--path=" + explicitFuncPath, - getwdReturn: cwd, expectedPath: explicitFuncPath, }, } @@ -216,14 +132,14 @@ func TestNewConfigCICmd_PathFlagResolution(t *testing.T) { // GIVEN opts := defaultOpts() opts.args = append(opts.args, tc.pathArg) - opts.withFakeGetCwdReturn.dir = tc.getwdReturn + opts.withFakeGetCwdReturn.dir = cwd // WHEN result := runConfigCiCmd(t, opts) // THEN assert.NilError(t, result.executeErr) - assert.Assert(t, strings.Contains(result.actualPath, tc.expectedPath)) + assert.Assert(t, strings.Contains(result.fnRoot, tc.expectedPath)) }) } } @@ -274,7 +190,7 @@ func TestNewConfigCICmd_BranchFlagResolution(t *testing.T) { // THEN assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, "- "+tc.expectedBranch)) + assert.Equal(t, result.workflowConfig.Branch, tc.expectedBranch) }) } } @@ -359,420 +275,16 @@ func TestNewConfigCICmd_PlatformFlagErrors(t *testing.T) { } } -func TestNewConfigCICmd_ForceFlagOverwritesExistingWorkflow(t *testing.T) { - workflowName := "Func Deploy" - changedWorkflowName := "Sales Service Deployment" - sharedWriter := github.NewBufferWriter() - - t.Run("initial workflow creation succeeds", func(t *testing.T) { - opts := defaultOpts() - opts.withWriter = sharedWriter - - result := runConfigCiCmd(t, opts) - - assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, workflowName)) - assert.Assert(t, !strings.Contains(result.stdOut, forceWarning)) - }) - - t.Run("overwrite without force flag fails", func(t *testing.T) { - opts := defaultOpts() - opts.withWriter = sharedWriter - opts.args = append(opts.args, "--workflow-name="+changedWorkflowName) - - result := runConfigCiCmd(t, opts) - - assert.ErrorIs(t, result.executeErr, github.ErrWorkflowExists) - assert.Assert(t, yamlContains(result.gwYamlString, workflowName)) - assert.Assert(t, !strings.Contains(result.gwYamlString, changedWorkflowName)) - assert.Assert(t, !strings.Contains(result.stdOut, forceWarning)) - }) - - t.Run("overwrite with force flag succeeds and a warning message is printed to stdout", func(t *testing.T) { - opts := defaultOpts() - opts.withWriter = sharedWriter - opts.args = append(opts.args, "--workflow-name="+changedWorkflowName, "--force") - - result := runConfigCiCmd(t, opts) - - assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, changedWorkflowName)) - assert.Assert(t, !strings.Contains(result.gwYamlString, workflowName)) - assert.Assert(t, strings.Contains(result.stdOut, forceWarning)) - }) -} - -func TestNewConfigCICmd_VerboseFlagPrintsWorkflowDetails(t *testing.T) { - t.Run("verbose flag prints default Github Workflow configuration", func(t *testing.T) { - opts := defaultOpts() - opts.args = append(opts.args, "--verbose") - expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, - defaultOutputPath, - github.DefaultWorkflowName, - issueBranch, - "host", - "disabled", - "ubuntu-latest", - "enabled", - "enabled", - "disabled", - "disabled", - ) + fmt.Sprintf(github.RequireManyPlainText, - "secrets."+github.DefaultKubeconfigSecretName, - "secrets."+github.DefaultRegistryPassSecretName, - "vars."+github.DefaultRegistryLoginUrlVariableName, - "vars."+github.DefaultRegistryUserVariableName, - "vars."+github.DefaultRegistryUrlVariableName, - ) - - result := runConfigCiCmd(t, opts) - - assertMessage(t, result, expectedMessage) - }) - - t.Run("verbose flag prints custom Github Workflow configuration", func(t *testing.T) { - opts := defaultOpts() - opts.args = append(opts.args, - "--verbose", - "--self-hosted-runner", - "--workflow-name=Deploy Checkout Service", - "--remote", - "--test-step=false", - "--workflow-dispatch", - "--force", - "--kubeconfig-secret-name=DEV_CLUSTER_KUBECONFIG", - "--registry-pass-secret-name=DEV_REGISTRY_PASS", - "--registry-login-url-variable-name=DEV_REGISTRY_LOGIN_URL", - "--registry-user-variable-name=DEV_REGISTRY_USER", - "--registry-url-variable-name=DEV_REGISTRY_URL", - ) - expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, - defaultOutputPath, - customWorkflowName, - issueBranch, - "pack", - "enabled", - "self-hosted", - "disabled", - "enabled", - "enabled", - "enabled", - ) + fmt.Sprintf(github.RequireManyPlainText, - "secrets.DEV_CLUSTER_KUBECONFIG", - "secrets.DEV_REGISTRY_PASS", - "vars.DEV_REGISTRY_LOGIN_URL", - "vars.DEV_REGISTRY_USER", - "vars.DEV_REGISTRY_URL", - ) - - result := runConfigCiCmd(t, opts) - - assertMessage(t, result, expectedMessage) - }) - - t.Run("verbose flag prints custom Github Workflow configuration without registry login", func(t *testing.T) { - opts := defaultOpts() - opts.args = append(opts.args, - "--verbose", - "--registry-login=false", - ) - expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, - defaultOutputPath, - github.DefaultWorkflowName, - issueBranch, - "host", - "disabled", - "ubuntu-latest", - "enabled", - "disabled", - "disabled", - "disabled", - ) + fmt.Sprintf(github.RequireOnePlainText, - "secrets."+github.DefaultKubeconfigSecretName, - ) - - result := runConfigCiCmd(t, opts) - - assertMessage(t, result, expectedMessage) - }) -} - -func TestNewConfigCICmd_PostExportMessageShown(t *testing.T) { - t.Run("a message is shown with all secrets and variables for k8 and registry which needs creation", func(t *testing.T) { - opts := defaultOpts() - expectedMessage := fmt.Sprintf(github.PostExportManyPlainText, - defaultOutputPath, - "secrets."+github.DefaultKubeconfigSecretName, - "secrets."+github.DefaultRegistryPassSecretName, - "vars."+github.DefaultRegistryLoginUrlVariableName, - "vars."+github.DefaultRegistryUserVariableName, - "vars."+github.DefaultRegistryUrlVariableName, - ) - - result := runConfigCiCmd(t, opts) - - assertMessage(t, result, expectedMessage) - }) - - t.Run("a message is shown with a secret for k8 which needs creation", func(t *testing.T) { - opts := defaultOpts() - opts.args = append(opts.args, "--registry-login=false") - expectedMessage := fmt.Sprintf(github.PostExportOnePlainText, - defaultOutputPath, - "secrets."+github.DefaultKubeconfigSecretName, - ) - - result := runConfigCiCmd(t, opts) - - assertMessage(t, result, expectedMessage) - }) -} - -func TestNewConfigCICmd_VerboseAndPostExportMessageAreMutuallyExclusive(t *testing.T) { - t.Run("verbose flag shows configuration, not post-export message", func(t *testing.T) { - opts := defaultOpts() - opts.args = append(opts.args, "--verbose") - - result := runConfigCiCmd(t, opts) - - assert.NilError(t, result.executeErr) - assert.Assert(t, strings.Contains(result.stdOut, "GitHub Workflow Configuration")) - assert.Assert(t, !strings.Contains(result.stdOut, "GitHub Workflow created at:")) - }) - - t.Run("without verbose flag shows post-export message, not configuration", func(t *testing.T) { - opts := defaultOpts() - - result := runConfigCiCmd(t, opts) - - assert.NilError(t, result.executeErr) - assert.Assert(t, strings.Contains(result.stdOut, "GitHub Workflow created at:")) - assert.Assert(t, !strings.Contains(result.stdOut, "GitHub Workflow Configuration")) - }) -} - -func TestNewConfigCICmd_TestStepPerRuntime(t *testing.T) { - testCases := []struct { - name string - runtime string - expectedRun string - }{ - { - name: "go runtime adds go test step", - runtime: "go", - expectedRun: "go test ./...", - }, - { - name: "nodejs runtime adds npm test step", - runtime: "node", - expectedRun: "npm ci && npm test", - }, - { - name: "typescript runtime adds npm test step", - runtime: "typescript", - expectedRun: "npm ci && npm test", - }, - { - name: "python runtime adds python -m pytest step", - runtime: "python", - expectedRun: "pip install . && python -m pytest", - }, - { - name: "quarkus runtime adds mvnw test step", - runtime: "quarkus", - expectedRun: "./mvnw test", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.runtime = tc.runtime - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, yamlContains(result.gwYamlString, runTestStepName)) - assert.Assert(t, yamlContains(result.gwYamlString, tc.expectedRun)) - }) - } -} - -func TestNewConfigCICmd_TestStepSkipped(t *testing.T) { - t.Run("unsupported runtime skips test step and prints warning", func(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.runtime = "rust" - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, !strings.Contains(result.gwYamlString, runTestStepName)) - assert.Assert(t, strings.Contains(result.stdOut, "WARNING: test step not supported for runtime rust")) - }) - - t.Run("test step disabled via flag", func(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.args = append(opts.args, "--test-step=false") - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, !strings.Contains(result.gwYamlString, runTestStepName)) - assert.Assert(t, strings.Count(result.gwYamlString, "- name:") == 5) - }) -} - -func TestNewConfigCICmd_BuilderForRuntime(t *testing.T) { - testCases := []struct { - name, - runtime, - builder, - args string - }{ - { - name: "go function and local build", - args: "", - runtime: "go", - builder: "host", - }, - { - name: "go function and remote build", - args: "--remote", - runtime: "go", - builder: "pack", - }, - { - name: "python function and local build", - args: "", - runtime: "python", - builder: "host", - }, - { - name: "python function and remote build", - args: "--remote", - runtime: "python", - builder: "s2i", - }, - { - name: "node function and local build", - args: "", - runtime: "node", - builder: "pack", - }, - { - name: "node function and remote build", - args: "--remote", - runtime: "node", - builder: "pack", - }, - { - name: "typescript function and local build", - args: "", - runtime: "typescript", - builder: "pack", - }, - { - name: "typescript function and remote build", - args: "--remote", - runtime: "typescript", - builder: "pack", - }, - { - name: "rust function and local build", - args: "", - runtime: "rust", - builder: "pack", - }, - { - name: "rust function and remote build", - args: "--remote", - runtime: "rust", - builder: "pack", - }, - { - name: "quarkus function and local build", - args: "", - runtime: "quarkus", - builder: "pack", - }, - { - name: "quarkus function and remote build", - args: "--remote", - runtime: "quarkus", - builder: "pack", - }, - { - name: "springboot function and local build", - args: "", - runtime: "springboot", - builder: "pack", - }, - { - name: "springboot function and remote build", - args: "--remote", - runtime: "springboot", - builder: "pack", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.runtime = tc.runtime - opts.args = append(opts.args, tc.args) - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.NilError(t, result.executeErr) - assert.Assert(t, strings.Contains(result.gwYamlString, "FUNC_BUILDER: "+tc.builder)) - }) - } -} - -func TestNewConfigCICmd_BuilderForRuntimeError(t *testing.T) { - // GIVEN - opts := defaultOpts() - opts.runtime = "zig" - expectedErr := fmt.Errorf("no builder support for runtime: %s", opts.runtime) - - // WHEN - result := runConfigCiCmd(t, opts) - - // THEN - assert.Error(t, result.executeErr, expectedErr.Error()) -} - -// --------------------- -// END: Broad Unit Tests - // START: Testing Framework // ------------------------ const ( mainBranch = "main" issueBranch = "issue-778-current-branch" - fnName = "github-ci-func" - forceWarning = "WARNING: --force flag is set, overwriting existing GitHub Workflow file" customWorkflowName = "Deploy Checkout Service" - runTestStepName = "Run tests" ) -var defaultOutputPath = filepath.Join(github.DefaultGitHubWorkflowDir, github.DefaultGitHubWorkflowFilename) - type opts struct { enableFeature bool - runtime string withFakeGitCliReturn struct { output string err error @@ -781,11 +293,10 @@ type opts struct { dir string err error } - withWriter *github.BufferWriter - args []string + args []string } -// defaultOpts returns test options for broad unit tests with sensible defaults: +// defaultOpts returns test options for unit tests with sensible defaults: // - enableFeature: true // - withFakeGitCliReturn: {output: issueBranch, err: nil} // - withFakeGetCwdReturn: {dir: "", err: nil} @@ -793,7 +304,6 @@ type opts struct { func defaultOpts() opts { return opts{ enableFeature: true, - runtime: "go", withFakeGitCliReturn: struct { output string err error @@ -808,16 +318,15 @@ func defaultOpts() opts { dir: "", err: nil, }, - withWriter: nil, - args: []string{"ci"}, + args: []string{"ci"}, } } type result struct { - executeErr error - gwYamlString, - actualPath, - stdOut string + executeErr error + generatorWasInvoked bool + workflowConfig github.WorkflowConfig + fnRoot string } func runConfigCiCmd( @@ -834,12 +343,7 @@ func runConfigCiCmd( loaderSaver := common.NewMockLoaderSaver() loaderSaver.LoadFn = func(path string) (fn.Function, error) { - return fn.Function{Root: path, Runtime: opts.runtime}, nil - } - - writer := opts.withWriter - if writer == nil { - writer = github.NewBufferWriter() + return fn.Function{Root: path, Runtime: "go"}, nil } currentBranch := common.CurrentBranchStub( @@ -852,19 +356,19 @@ func runConfigCiCmd( opts.withFakeGetCwdReturn.err, ) - messageBufferWriter := &bytes.Buffer{} + ciGeneratorFake := github.WorkflowGeneratorMock{} viper.Reset() cmd := fnCmd.NewConfigCmd( loaderSaver, - writer, + github.NewBufferWriter(), currentBranch, workingDir, + fnCmd.NewTestCIGeneratorFactory(&ciGeneratorFake), fnCmd.NewClient, ) cmd.SetArgs(opts.args) - cmd.SetOut(messageBufferWriter) // RUN err := cmd.Execute() @@ -872,92 +376,11 @@ func runConfigCiCmd( // POST-RUN GATHER return result{ err, - writer.Buffer.String(), - writer.Path, - messageBufferWriter.String(), + ciGeneratorFake.WasInvoked, + ciGeneratorFake.Config, + ciGeneratorFake.FnRoot, } } -// assertDefaultWorkflow asserts all the GitHub workflow value for correct values -// including the default values which can be changed: -// - runs-on: ubuntu-latest -// - kubeconfig: ${{ secrets.KUBECONFIG }} -// - registry: ${{ vars.REGISTRY_LOGIN_URL }}") -// - username: ${{ vars.REGISTRY_USERNAME }} -// - password: ${{ secrets.REGISTRY_PASSWORD }} -// - run: func deploy --registry=${{ vars.REGISTRY_LOGIN_URL }}/${{ vars.REGISTRY_USERNAME }} -v -func assertDefaultWorkflow(t *testing.T, actualGw string) { - t.Helper() - - assertDefaultWorkflowWithBranch(t, actualGw, issueBranch) -} - -func assertDefaultWorkflowWithBranch(t *testing.T, actualGw, branch string) { - t.Helper() - - assert.Assert(t, yamlContains(actualGw, "Func Deploy")) - assert.Assert(t, yamlContains(actualGw, "- "+branch)) - - assert.Assert(t, yamlContains(actualGw, "ubuntu-latest")) - - assert.Assert(t, strings.Count(actualGw, "- name:") == 6) - - assert.Assert(t, yamlContains(actualGw, "Checkout code")) - assert.Assert(t, yamlContains(actualGw, "actions/checkout@v4")) - - assert.Assert(t, yamlContains(actualGw, "Run tests")) - assert.Assert(t, yamlContains(actualGw, "go test ./...")) - - assert.Assert(t, yamlContains(actualGw, "Setup Kubernetes context")) - assert.Assert(t, yamlContains(actualGw, "azure/k8s-set-context@v4")) - assert.Assert(t, yamlContains(actualGw, "method: kubeconfig")) - assert.Assert(t, yamlContains(actualGw, "kubeconfig: ${{ secrets.KUBECONFIG }}")) - - assert.Assert(t, yamlContains(actualGw, "Login to container registry")) - assert.Assert(t, yamlContains(actualGw, "docker/login-action@v3")) - assert.Assert(t, yamlContains(actualGw, "registry: ${{ vars.REGISTRY_LOGIN_URL }}")) - assert.Assert(t, yamlContains(actualGw, "username: ${{ vars.REGISTRY_USERNAME }}")) - assert.Assert(t, yamlContains(actualGw, "password: ${{ secrets.REGISTRY_PASSWORD }}")) - - assert.Assert(t, yamlContains(actualGw, "Install func cli")) - assert.Assert(t, yamlContains(actualGw, "functions-dev/action@main")) - assert.Assert(t, yamlContains(actualGw, "version: knative-v1.21.0")) - assert.Assert(t, yamlContains(actualGw, "name: func")) - - assert.Assert(t, yamlContains(actualGw, "Deploy function")) - assert.Assert(t, yamlContains(actualGw, `FUNC_VERBOSE: "true"`)) - assert.Assert(t, yamlContains(actualGw, "FUNC_REGISTRY: ${{ vars.REGISTRY_LOGIN_URL }}/${{ vars.REGISTRY_USERNAME }}")) - assert.Assert(t, yamlContains(actualGw, "func deploy")) -} - -func yamlContains(yaml, substr string) cmp.Comparison { - return func() cmp.Result { - if strings.Contains(yaml, substr) { - return cmp.ResultSuccess - } - return cmp.ResultFailure(fmt.Sprintf( - "missing '%s' in:\n\n%s", substr, yaml, - )) - } -} - -func assertCustomWorkflow(t *testing.T, actualGw string) { - t.Helper() - - assert.Assert(t, yamlContains(actualGw, "self-hosted")) - assert.Assert(t, yamlContains(actualGw, "DEV_CLUSTER_KUBECONFIG")) - assert.Assert(t, yamlContains(actualGw, "DEV_REGISTRY_LOGIN_URL")) - assert.Assert(t, yamlContains(actualGw, "DEV_REGISTRY_USER")) - assert.Assert(t, yamlContains(actualGw, "DEV_REGISTRY_PASS")) -} - -func assertMessage(t *testing.T, res result, expectedMessage string) { - t.Helper() - - assert.NilError(t, res.executeErr) - assert.Assert(t, strings.Contains(res.stdOut, expectedMessage), - "\nexpected:\n%s\n\ngot:\n%s", expectedMessage, res.stdOut) -} - // ---------------------- // END: Testing Framework diff --git a/cmd/config_test.go b/cmd/config_test.go index 2586cbc0bc..0ce09cdb68 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -55,6 +55,7 @@ func setupConfigEnvCmd(mock common.FunctionLoaderSaver, args ...string) *cobra.C github.NewBufferWriter(), common.CurrentBranchStub("", nil), common.WorkDirStub("", nil), + fnCmd.NewTestCIGeneratorFactory(&github.WorkflowGeneratorMock{}), fnCmd.NewClient, ) cmd.SetArgs(append([]string{"envs"}, args...)) diff --git a/cmd/root.go b/cmd/root.go index 8edeb0a7b0..ec1e2e680f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -107,6 +107,7 @@ Learn more about Knative at: https://knative.dev`, cfg.Name), github.DefaultWorkflowWriter, common.DefaultCurrentBranch, common.DefaultWorkDir, + NewCIGeneratorFactory(), newClient, ), NewClusterCmd(), diff --git a/pkg/ci/github/config.go b/pkg/ci/github/config.go deleted file mode 100644 index 475e7935be..0000000000 --- a/pkg/ci/github/config.go +++ /dev/null @@ -1,54 +0,0 @@ -package github - -import ( - "path/filepath" -) - -const ( - DefaultPlatform = "github" - DefaultGitHubWorkflowDir = ".github/workflows" - DefaultGitHubWorkflowFilename = "func-deploy.yaml" - DefaultBranch = "main" - DefaultWorkflowName = "Func Deploy" - DefaultRemoteBuildWorkflowName = "Remote " + DefaultWorkflowName - DefaultKubeconfigSecretName = "KUBECONFIG" - DefaultRegistryLoginUrlVariableName = "REGISTRY_LOGIN_URL" - DefaultRegistryUserVariableName = "REGISTRY_USERNAME" - DefaultRegistryPassSecretName = "REGISTRY_PASSWORD" - DefaultRegistryUrlVariableName = "REGISTRY_URL" - DefaultRegistryLogin = true - DefaultWorkflowDispatch = false - DefaultRemoteBuild = false - DefaultSelfHostedRunner = false - DefaultTestStep = true - DefaultForce = false -) - -type Config struct { - GithubWorkflowDir, - GithubWorkflowFilename, - Branch, - WorkflowName, - KubeconfigSecret, - RegistryLoginUrlVar, - RegistryUserVar, - RegistryPassSecret, - RegistryUrlVar string - RegistryLogin, - SelfHostedRunner, - RemoteBuild, - WorkflowDispatch, - TestStep, - Force bool - FnRuntime, - FnRoot string -} - -func (cc Config) FnGitHubWorkflowFilepath() string { - fnGitHubWorkflowDir := filepath.Join(cc.FnRoot, cc.GithubWorkflowDir) - return filepath.Join(fnGitHubWorkflowDir, cc.GithubWorkflowFilename) -} - -func (cc Config) OutputPath() string { - return filepath.Join(cc.GithubWorkflowDir, cc.GithubWorkflowFilename) -} diff --git a/pkg/ci/github/generator.go b/pkg/ci/github/generator.go index 5055e5238e..6d5ed696d2 100644 --- a/pkg/ci/github/generator.go +++ b/pkg/ci/github/generator.go @@ -4,42 +4,112 @@ import ( "context" "fmt" "io" + "os" fn "knative.dev/func/pkg/functions" ) -const DefaultVerbose = false - type workflowGenerator struct { - verbose bool + verbose bool + workflowWriter WorkflowWriter + messageWriter io.Writer + cfg WorkflowConfig } -func NewWorkflowGenerator(verbose bool) *workflowGenerator { - return &workflowGenerator{verbose} +// Option configures a workflowGenerator. +type Option func(*workflowGenerator) + +// WithWorkflowConfig overrides the default workflow configuration. +// Empty string fields are backfilled with defaults after all options +// are applied. Boolean fields are used as-is since their zero value +// (false) is indistinguishable from an explicit false. +func WithWorkflowConfig(wc WorkflowConfig) Option { + return func(wg *workflowGenerator) { + wg.cfg = wc + } } -func (g *workflowGenerator) Generate(ctx context.Context, config any, pathWriter fn.PathWriter, messageWriter io.Writer) error { - cfg, ok := config.(Config) - if !ok { - return fmt.Errorf("incorrect type of Config: %T", config) +// WithVerbose enables detailed configuration output after generation. +func WithVerbose(v bool) Option { + return func(wg *workflowGenerator) { + wg.verbose = v } +} + +// WithWorkflowWriter sets the writer used to persist the workflow file. +func WithWorkflowWriter(ww WorkflowWriter) Option { + return func(wg *workflowGenerator) { + wg.workflowWriter = ww + } +} - githubWorkflow, err := newGitHubWorkflow(cfg, messageWriter) +// WithMessageWriter sets the writer used for user-facing messages. +func WithMessageWriter(mw io.Writer) Option { + return func(wg *workflowGenerator) { + wg.messageWriter = mw + } +} + +// NewWorkflowGenerator creates a workflow generator with sensible +// defaults: writes to disk via DefaultWorkflowWriter, prints to +// os.Stdout, and uses a full default WorkflowConfig. All defaults +// can be overridden via options. If WithWorkflowConfig is used, +// the provided config replaces the defaults and any empty string +// fields are backfilled with defaults. +func NewWorkflowGenerator(options ...Option) *workflowGenerator { + wg := &workflowGenerator{ + cfg: defaultWorkflowConfig(), + workflowWriter: DefaultWorkflowWriter, + messageWriter: os.Stdout, + } + + for _, o := range options { + o(wg) + } + + wg.cfg = setEmptyFieldsToDefaults(wg.cfg) + + return wg +} + +// Generate creates a GitHub Actions workflow file for the given function. +func (g *workflowGenerator) Generate(ctx context.Context, f fn.Function) error { + if f.Root == "" { + return fmt.Errorf("function root path can not be empty") + } + + githubWorkflow, err := newGitHubWorkflow(g.cfg, f.Runtime, g.messageWriter) if err != nil { return err } - if err := githubWorkflow.Export(cfg.FnGitHubWorkflowFilepath(), pathWriter, cfg.Force, messageWriter); err != nil { + if err := githubWorkflow.Export(g.cfg.fnGitHubWorkflowFilepath(f.Root), g.workflowWriter, g.cfg.Force, g.messageWriter); err != nil { return err } if g.verbose { // best-effort user message; errors are non-critical - _ = PrintConfiguration(messageWriter, cfg) + _ = PrintConfiguration(g.cfg, f.Runtime, g.messageWriter) return nil } // best-effort user message; errors are non-critical - _ = PrintPostExportMessage(messageWriter, cfg) + _ = PrintPostExportMessage(g.cfg, g.messageWriter) + return nil +} + +// WorkflowGeneratorMock is a test double that records calls to Generate +// for assertion in tests. +type WorkflowGeneratorMock struct { + WasInvoked bool // true after Generate is called + Config WorkflowConfig // captured config from factory + FnRoot string // captured function root from Generate +} + +// Generate implements fn.CIGenerator by recording the call. +func (gm *WorkflowGeneratorMock) Generate(_ context.Context, f fn.Function) error { + gm.WasInvoked = true + gm.FnRoot = f.Root + return nil } diff --git a/pkg/ci/github/generator_test.go b/pkg/ci/github/generator_test.go new file mode 100644 index 0000000000..fb458431f7 --- /dev/null +++ b/pkg/ci/github/generator_test.go @@ -0,0 +1,718 @@ +package github_test + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" + "knative.dev/func/pkg/ci/github" + fn "knative.dev/func/pkg/functions" +) + +// START: Broad Unit Tests +// ----------------------- +func TestCIGenerator_WritesWorkflowFile(t *testing.T) { + opts := defaultOpts() + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assert.Assert(t, result.gwYamlString != "") +} + +func TestCIGenerator_WorkflowYAMLHasCorrectStructure(t *testing.T) { + opts := defaultOpts() + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assertDefaultWorkflow(t, result.gwYamlString) +} + +func TestCIGenerator_WorkflowYAMLHasCustomValues(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.cfg.SelfHostedRunner = true + opts.cfg.KubeconfigSecret = "DEV_CLUSTER_KUBECONFIG" + opts.cfg.RegistryLoginUrlVar = "DEV_REGISTRY_LOGIN_URL" + opts.cfg.RegistryUserVar = "DEV_REGISTRY_USER" + opts.cfg.RegistryPassSecret = "DEV_REGISTRY_PASS" + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assertCustomWorkflow(t, result.gwYamlString) +} + +func TestCIGenerator_WorkflowHasNoRegistryLogin(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.cfg.RegistryLogin = false + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, !strings.Contains(result.gwYamlString, "docker/login-action@v3")) + assert.Assert(t, !strings.Contains(result.gwYamlString, "Login to container registry")) + assert.Assert(t, yamlContains(result.gwYamlString, "FUNC_REGISTRY: ${{ vars.REGISTRY_URL }}")) +} + +func TestCIGenerator_RemoteBuildAndDeployWorkflow(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.cfg.WorkflowName = "Remote Func Deploy" + opts.cfg.RemoteBuild = true + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, yamlContains(result.gwYamlString, "Remote Func Deploy")) + assert.Assert(t, yamlContains(result.gwYamlString, `FUNC_REMOTE: "true"`)) +} + +func TestCIGenerator_HasWorkflowDispatch(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.cfg.WorkflowDispatch = true + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, yamlContains(result.gwYamlString, "workflow_dispatch")) +} + +func TestCIGenerator_FunctionPath(t *testing.T) { + t.Run("non-empty function path is accepted", func(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.goFn.Root = filepath.Join("current-working-directory") // os-agnostic test path + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, strings.Contains(result.actualPath, opts.goFn.Root)) + }) + + t.Run("empty function path returns an error", func(t *testing.T) { + // GIVEN + expectedErr := fmt.Errorf("function root path can not be empty") + opts := defaultOpts() + opts.goFn.Root = "" // os-agnostic test path + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.Error(t, result.executeErr, expectedErr.Error()) + }) +} + +func TestCIGenerator_WorkflowConfig(t *testing.T) { + t.Run("applies all defaults when no config is provided", func(t *testing.T) { + opts := defaultOpts() + opts.cfg = nil + + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assertDefaultWorkflow(t, result.gwYamlString) + }) + + t.Run("fills empty strings but cannot default boolean fields to true", func(t *testing.T) { + opts := defaultOpts() + opts.cfg = &github.WorkflowConfig{} + + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assertSemiDefaultWorkflow(t, result.gwYamlString) + }) +} + +func TestCIGenerator_ForceFlagOverwritesExistingWorkflow(t *testing.T) { + workflowName := "Func Deploy" + changedWorkflowName := "Sales Service Deployment" + sharedWriter := github.NewBufferWriter() + + t.Run("initial workflow creation succeeds", func(t *testing.T) { + opts := defaultOpts() + opts.withWriter = sharedWriter + + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assert.Assert(t, yamlContains(result.gwYamlString, workflowName)) + assert.Assert(t, !strings.Contains(result.stdOut, forceWarning)) + }) + + t.Run("overwrite without force flag fails", func(t *testing.T) { + opts := defaultOpts() + opts.withWriter = sharedWriter + opts.cfg.WorkflowName = changedWorkflowName + + result := runGenerateWorkflow(t, opts) + + assert.ErrorIs(t, result.executeErr, github.ErrWorkflowExists) + assert.Assert(t, yamlContains(result.gwYamlString, workflowName)) + assert.Assert(t, !strings.Contains(result.gwYamlString, changedWorkflowName)) + assert.Assert(t, !strings.Contains(result.stdOut, forceWarning)) + }) + + t.Run("overwrite with force flag succeeds and a warning message is printed to stdout", func(t *testing.T) { + opts := defaultOpts() + opts.withWriter = sharedWriter + opts.cfg.WorkflowName = changedWorkflowName + opts.cfg.Force = true + + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assert.Assert(t, yamlContains(result.gwYamlString, changedWorkflowName)) + assert.Assert(t, !strings.Contains(result.gwYamlString, workflowName)) + assert.Assert(t, strings.Contains(result.stdOut, forceWarning)) + }) +} + +func TestCIGenerator_VerbosePrintsWorkflowDetails(t *testing.T) { + t.Run("default configuration", func(t *testing.T) { + opts := defaultOpts() + opts.verbose = true + + expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, + defaultOutputPath, + github.DefaultWorkflowName, + github.DefaultBranch, + "host", + "disabled", + "ubuntu-latest", + "enabled", + "enabled", + "disabled", + "disabled", + ) + fmt.Sprintf(github.RequireManyPlainText, + "secrets."+github.DefaultKubeconfigSecretName, + "secrets."+github.DefaultRegistryPassSecretName, + "vars."+github.DefaultRegistryLoginUrlVariableName, + "vars."+github.DefaultRegistryUserVariableName, + "vars."+github.DefaultRegistryUrlVariableName, + ) + + result := runGenerateWorkflow(t, opts) + + assertMessage(t, result, expectedMessage) + }) + + t.Run("custom configuration", func(t *testing.T) { + opts := defaultOpts() + opts.verbose = true + opts.cfg.SelfHostedRunner = true + opts.cfg.WorkflowName = "Deploy Checkout Service" + opts.cfg.RemoteBuild = true + opts.cfg.TestStep = false + opts.cfg.WorkflowDispatch = true + opts.cfg.Force = true + opts.cfg.KubeconfigSecret = "DEV_CLUSTER_KUBECONFIG" + opts.cfg.RegistryPassSecret = "DEV_REGISTRY_PASS" + opts.cfg.RegistryLoginUrlVar = "DEV_REGISTRY_LOGIN_URL" + opts.cfg.RegistryUserVar = "DEV_REGISTRY_USER" + opts.cfg.RegistryUrlVar = "DEV_REGISTRY_URL" + + expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, + defaultOutputPath, + "Deploy Checkout Service", + github.DefaultBranch, + "pack", + "enabled", + "self-hosted", + "disabled", + "enabled", + "enabled", + "enabled", + ) + fmt.Sprintf(github.RequireManyPlainText, + "secrets.DEV_CLUSTER_KUBECONFIG", + "secrets.DEV_REGISTRY_PASS", + "vars.DEV_REGISTRY_LOGIN_URL", + "vars.DEV_REGISTRY_USER", + "vars.DEV_REGISTRY_URL", + ) + + result := runGenerateWorkflow(t, opts) + + assertMessage(t, result, expectedMessage) + }) + + t.Run("without registry login", func(t *testing.T) { + opts := defaultOpts() + opts.verbose = true + opts.cfg.RegistryLogin = false + + expectedMessage := fmt.Sprintf(github.MainLayoutPlainText, + defaultOutputPath, + github.DefaultWorkflowName, + github.DefaultBranch, + "host", + "disabled", + "ubuntu-latest", + "enabled", + "disabled", + "disabled", + "disabled", + ) + fmt.Sprintf(github.RequireOnePlainText, + "secrets."+github.DefaultKubeconfigSecretName, + ) + + result := runGenerateWorkflow(t, opts) + + assertMessage(t, result, expectedMessage) + }) +} + +func TestCIGenerator_PostExportMessageShown(t *testing.T) { + t.Run("shows all secrets and variables for k8s and registry", func(t *testing.T) { + opts := defaultOpts() + + expectedMessage := fmt.Sprintf(github.PostExportManyPlainText, + defaultOutputPath, + "secrets."+github.DefaultKubeconfigSecretName, + "secrets."+github.DefaultRegistryPassSecretName, + "vars."+github.DefaultRegistryLoginUrlVariableName, + "vars."+github.DefaultRegistryUserVariableName, + "vars."+github.DefaultRegistryUrlVariableName, + ) + + result := runGenerateWorkflow(t, opts) + + assertMessage(t, result, expectedMessage) + }) + + t.Run("shows only k8s secret when registry login is disabled", func(t *testing.T) { + opts := defaultOpts() + opts.cfg.RegistryLogin = false + + expectedMessage := fmt.Sprintf(github.PostExportOnePlainText, + defaultOutputPath, + "secrets."+github.DefaultKubeconfigSecretName, + ) + + result := runGenerateWorkflow(t, opts) + + assertMessage(t, result, expectedMessage) + }) +} + +func TestCIGenerator_VerboseAndPostExportMessageAreMutuallyExclusive(t *testing.T) { + t.Run("verbose shows configuration, not post-export message", func(t *testing.T) { + opts := defaultOpts() + opts.verbose = true + + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assert.Assert(t, strings.Contains(result.stdOut, "GitHub Workflow Configuration")) + assert.Assert(t, !strings.Contains(result.stdOut, "GitHub Workflow created at:")) + }) + + t.Run("non-verbose shows post-export message, not configuration", func(t *testing.T) { + opts := defaultOpts() + + result := runGenerateWorkflow(t, opts) + + assert.NilError(t, result.executeErr) + assert.Assert(t, strings.Contains(result.stdOut, "GitHub Workflow created at:")) + assert.Assert(t, !strings.Contains(result.stdOut, "GitHub Workflow Configuration")) + }) +} + +func TestCIGenerator_TestStepPerRuntime(t *testing.T) { + testCases := []struct { + name string + runtime string + expectedRun string + }{ + { + name: "go runtime adds go test step", + runtime: "go", + expectedRun: "go test ./...", + }, + { + name: "nodejs runtime adds npm test step", + runtime: "node", + expectedRun: "npm ci && npm test", + }, + { + name: "typescript runtime adds npm test step", + runtime: "typescript", + expectedRun: "npm ci && npm test", + }, + { + name: "python runtime adds python -m pytest step", + runtime: "python", + expectedRun: "pip install . && python -m pytest", + }, + { + name: "quarkus runtime adds mvnw test step", + runtime: "quarkus", + expectedRun: "./mvnw test", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.goFn.Runtime = tc.runtime + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, yamlContains(result.gwYamlString, "Run tests")) + assert.Assert(t, yamlContains(result.gwYamlString, tc.expectedRun)) + }) + } +} + +func TestCIGenerator_TestStepSkipped(t *testing.T) { + t.Run("unsupported runtime skips test step and prints warning", func(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.goFn.Runtime = "rust" + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, !strings.Contains(result.gwYamlString, "Run tests")) + assert.Assert(t, strings.Contains(result.stdOut, "WARNING: test step not supported for runtime rust")) + }) + + t.Run("test step disabled via config", func(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.cfg.TestStep = false + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, !strings.Contains(result.gwYamlString, "Run tests")) + assert.Assert(t, strings.Count(result.gwYamlString, "- name:") == 5) + }) +} + +func TestCIGenerator_BuilderForRuntime(t *testing.T) { + testCases := []struct { + name string + runtime string + builder string + remote bool + }{ + { + name: "go function and local build", + runtime: "go", + builder: "host", + }, + { + name: "go function and remote build", + runtime: "go", + builder: "pack", + remote: true, + }, + { + name: "python function and local build", + runtime: "python", + builder: "host", + }, + { + name: "python function and remote build", + runtime: "python", + builder: "s2i", + remote: true, + }, + { + name: "node function and local build", + runtime: "node", + builder: "pack", + }, + { + name: "node function and remote build", + runtime: "node", + builder: "pack", + remote: true, + }, + { + name: "typescript function and local build", + runtime: "typescript", + builder: "pack", + }, + { + name: "typescript function and remote build", + runtime: "typescript", + builder: "pack", + remote: true, + }, + { + name: "rust function and local build", + runtime: "rust", + builder: "pack", + }, + { + name: "rust function and remote build", + runtime: "rust", + builder: "pack", + remote: true, + }, + { + name: "quarkus function and local build", + runtime: "quarkus", + builder: "pack", + }, + { + name: "quarkus function and remote build", + runtime: "quarkus", + builder: "pack", + remote: true, + }, + { + name: "springboot function and local build", + runtime: "springboot", + builder: "pack", + }, + { + name: "springboot function and remote build", + runtime: "springboot", + builder: "pack", + remote: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.goFn.Runtime = tc.runtime + opts.cfg.RemoteBuild = tc.remote + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.NilError(t, result.executeErr) + assert.Assert(t, strings.Contains(result.gwYamlString, "FUNC_BUILDER: "+tc.builder)) + }) + } +} + +func TestCIGenerator_BuilderForRuntimeError(t *testing.T) { + // GIVEN + opts := defaultOpts() + opts.goFn.Runtime = "zig" + + // WHEN + result := runGenerateWorkflow(t, opts) + + // THEN + assert.Error(t, result.executeErr, "no builder support for runtime: zig") +} + +// --------------------- +// END: Broad Unit Tests + +// START: Testing Framework +// ------------------------ +const ( + forceWarning = "WARNING: --force flag is set, overwriting existing GitHub Workflow file" +) + +var defaultOutputPath = filepath.Join(github.DefaultGitHubWorkflowDir, github.DefaultGitHubWorkflowFilename) + +type result struct { + actualPath string + executeErr error + gwYamlString string + stdOut string +} + +type opts struct { + cfg *github.WorkflowConfig + goFn fn.Function + withWriter *github.BufferWriter + verbose bool +} + +func defaultOpts() opts { + cfg := &github.WorkflowConfig{ + GithubWorkflowDir: github.DefaultGitHubWorkflowDir, + GithubWorkflowFilename: github.DefaultGitHubWorkflowFilename, + Branch: github.DefaultBranch, + WorkflowName: github.DefaultWorkflowName, + KubeconfigSecret: github.DefaultKubeconfigSecretName, + RegistryLoginUrlVar: github.DefaultRegistryLoginUrlVariableName, + RegistryUserVar: github.DefaultRegistryUserVariableName, + RegistryPassSecret: github.DefaultRegistryPassSecretName, + RegistryUrlVar: github.DefaultRegistryUrlVariableName, + RegistryLogin: github.DefaultRegistryLogin, + SelfHostedRunner: github.DefaultSelfHostedRunner, + RemoteBuild: github.DefaultRemoteBuild, + WorkflowDispatch: github.DefaultWorkflowDispatch, + TestStep: github.DefaultTestStep, + Force: github.DefaultForce, + } + goFn := fn.Function{Root: "path/to/func", Runtime: "go"} + return opts{ + cfg: cfg, + goFn: goFn, + } +} + +func runGenerateWorkflow(t *testing.T, opts opts) result { + t.Helper() + + writer := opts.withWriter + if writer == nil { + writer = github.NewBufferWriter() + } + + messageBufferWriter := &bytes.Buffer{} + + generatorOptions := []github.Option{ + github.WithWorkflowWriter(writer), + github.WithMessageWriter(messageBufferWriter), + github.WithVerbose(opts.verbose), + } + + if opts.cfg != nil { + generatorOptions = append(generatorOptions, github.WithWorkflowConfig(*opts.cfg)) + } + + generator := github.NewWorkflowGenerator(generatorOptions...) + err := generator.Generate(t.Context(), opts.goFn) + + return result{ + writer.Path, + err, + writer.Buffer.String(), + messageBufferWriter.String(), + } +} + +// assertDefaultWorkflow verifies the generated YAML contains the full +// default workflow structure: all six steps, default branch, runner, +// secrets, variables, and deploy command. +func assertDefaultWorkflow(t *testing.T, actualGw string) { + t.Helper() + + assert.Assert(t, yamlContains(actualGw, "Func Deploy")) + assert.Assert(t, yamlContains(actualGw, "- main")) + + assert.Assert(t, yamlContains(actualGw, "ubuntu-latest")) + + assert.Assert(t, strings.Count(actualGw, "- name:") == 6) + + assert.Assert(t, yamlContains(actualGw, "Checkout code")) + assert.Assert(t, yamlContains(actualGw, "actions/checkout@v4")) + + assert.Assert(t, yamlContains(actualGw, "Run tests")) + assert.Assert(t, yamlContains(actualGw, "go test ./...")) + + assert.Assert(t, yamlContains(actualGw, "Setup Kubernetes context")) + assert.Assert(t, yamlContains(actualGw, "azure/k8s-set-context@v4")) + assert.Assert(t, yamlContains(actualGw, "method: kubeconfig")) + assert.Assert(t, yamlContains(actualGw, "kubeconfig: ${{ secrets.KUBECONFIG }}")) + + assert.Assert(t, yamlContains(actualGw, "Login to container registry")) + assert.Assert(t, yamlContains(actualGw, "docker/login-action@v3")) + assert.Assert(t, yamlContains(actualGw, "registry: ${{ vars.REGISTRY_LOGIN_URL }}")) + assert.Assert(t, yamlContains(actualGw, "username: ${{ vars.REGISTRY_USERNAME }}")) + assert.Assert(t, yamlContains(actualGw, "password: ${{ secrets.REGISTRY_PASSWORD }}")) + + assert.Assert(t, yamlContains(actualGw, "Install func cli")) + assert.Assert(t, yamlContains(actualGw, "functions-dev/action@main")) + assert.Assert(t, yamlContains(actualGw, "version: knative-v1.22.0")) + assert.Assert(t, yamlContains(actualGw, "name: func")) + + assert.Assert(t, yamlContains(actualGw, "Deploy function")) + assert.Assert(t, yamlContains(actualGw, `FUNC_VERBOSE: "true"`)) + assert.Assert(t, yamlContains(actualGw, "FUNC_REGISTRY: ${{ vars.REGISTRY_LOGIN_URL }}/${{ vars.REGISTRY_USERNAME }}")) + assert.Assert(t, yamlContains(actualGw, "func deploy")) +} + +// assertSemiDefaultWorkflow verifies the workflow generated from an empty +// WorkflowConfig{}. String fields are backfilled with defaults, but boolean +// fields stay at zero (false), so RegistryLogin and TestStep are off — +// resulting in four steps instead of six and no registry login action. +func assertSemiDefaultWorkflow(t *testing.T, actualGw string) { + t.Helper() + + assert.Assert(t, yamlContains(actualGw, "Func Deploy")) + assert.Assert(t, yamlContains(actualGw, "- main")) + + assert.Assert(t, yamlContains(actualGw, "ubuntu-latest")) + + assert.Assert(t, strings.Count(actualGw, "- name:") == 4) + + assert.Assert(t, yamlContains(actualGw, "Checkout code")) + assert.Assert(t, yamlContains(actualGw, "actions/checkout@v4")) + + assert.Assert(t, yamlContains(actualGw, "Setup Kubernetes context")) + assert.Assert(t, yamlContains(actualGw, "azure/k8s-set-context@v4")) + assert.Assert(t, yamlContains(actualGw, "method: kubeconfig")) + assert.Assert(t, yamlContains(actualGw, "kubeconfig: ${{ secrets.KUBECONFIG }}")) + + assert.Assert(t, yamlContains(actualGw, "Install func cli")) + assert.Assert(t, yamlContains(actualGw, "functions-dev/action@main")) + assert.Assert(t, yamlContains(actualGw, "version: knative-v1.22.0")) + assert.Assert(t, yamlContains(actualGw, "name: func")) + + assert.Assert(t, yamlContains(actualGw, "Deploy function")) + assert.Assert(t, yamlContains(actualGw, `FUNC_VERBOSE: "true"`)) + assert.Assert(t, yamlContains(actualGw, "FUNC_REGISTRY: ${{ vars.REGISTRY_URL }}")) + assert.Assert(t, yamlContains(actualGw, "func deploy")) +} + +func yamlContains(yaml, substr string) cmp.Comparison { + return func() cmp.Result { + if strings.Contains(yaml, substr) { + return cmp.ResultSuccess + } + return cmp.ResultFailure(fmt.Sprintf( + "missing '%s' in:\n\n%s", substr, yaml, + )) + } +} + +func assertCustomWorkflow(t *testing.T, actualGw string) { + t.Helper() + + assert.Assert(t, yamlContains(actualGw, "self-hosted")) + assert.Assert(t, yamlContains(actualGw, "DEV_CLUSTER_KUBECONFIG")) + assert.Assert(t, yamlContains(actualGw, "DEV_REGISTRY_LOGIN_URL")) + assert.Assert(t, yamlContains(actualGw, "DEV_REGISTRY_USER")) + assert.Assert(t, yamlContains(actualGw, "DEV_REGISTRY_PASS")) +} + +func assertMessage(t *testing.T, res result, expectedMessage string) { + t.Helper() + + assert.NilError(t, res.executeErr) + assert.Assert(t, strings.Contains(res.stdOut, expectedMessage), + "\nexpected:\n%s\n\ngot:\n%s", expectedMessage, res.stdOut) +} + +// ---------------------- +// END: Testing Framework diff --git a/pkg/ci/github/printer.go b/pkg/ci/github/printer.go index 45954674e5..962801d373 100644 --- a/pkg/ci/github/printer.go +++ b/pkg/ci/github/printer.go @@ -12,7 +12,7 @@ GitHub Workflow Configuration Workflow name: %s Branch: %s Builder: %s - Remote build %s + Remote build: %s Runner: %s Test step: %s Registry login: %s @@ -49,34 +49,34 @@ Create the following Secret on github.com: %s ` ) -func PrintConfiguration(w io.Writer, conf Config) error { - builder, err := determineBuilder(conf.FnRuntime, conf.RemoteBuild) +func PrintConfiguration(cfg WorkflowConfig, runtime string, w io.Writer) error { + builder, err := determineBuilder(runtime, cfg.RemoteBuild) if err != nil { return err } if _, err := fmt.Fprintf(w, MainLayoutPlainText, - conf.OutputPath(), - conf.WorkflowName, - conf.Branch, + cfg.outputPath(), + cfg.WorkflowName, + cfg.Branch, builder, - enabledOrDisabled(conf.RemoteBuild), - determineRunner(conf.SelfHostedRunner), - enabledOrDisabled(conf.TestStep), - enabledOrDisabled(conf.RegistryLogin), - enabledOrDisabled(conf.WorkflowDispatch), - enabledOrDisabled(conf.Force), + enabledOrDisabled(cfg.RemoteBuild), + determineRunner(cfg.SelfHostedRunner), + enabledOrDisabled(cfg.TestStep), + enabledOrDisabled(cfg.RegistryLogin), + enabledOrDisabled(cfg.WorkflowDispatch), + enabledOrDisabled(cfg.Force), ); err != nil { return err } - if conf.RegistryLogin { + if cfg.RegistryLogin { if _, err := fmt.Fprintf(w, RequireManyPlainText, - secretsPrefix(conf.KubeconfigSecret), - secretsPrefix(conf.RegistryPassSecret), - varsPrefix(conf.RegistryLoginUrlVar), - varsPrefix(conf.RegistryUserVar), - varsPrefix(conf.RegistryUrlVar), + secretsPrefix(cfg.KubeconfigSecret), + secretsPrefix(cfg.RegistryPassSecret), + varsPrefix(cfg.RegistryLoginUrlVar), + varsPrefix(cfg.RegistryUserVar), + varsPrefix(cfg.RegistryUrlVar), ); err != nil { return err } @@ -86,7 +86,7 @@ func PrintConfiguration(w io.Writer, conf Config) error { if _, err := fmt.Fprintf(w, RequireOnePlainText, - secretsPrefix(conf.KubeconfigSecret), + secretsPrefix(cfg.KubeconfigSecret), ); err != nil { return err } @@ -94,22 +94,22 @@ func PrintConfiguration(w io.Writer, conf Config) error { return nil } -func PrintPostExportMessage(w io.Writer, conf Config) error { - if conf.RegistryLogin { +func PrintPostExportMessage(opts WorkflowConfig, w io.Writer) error { + if opts.RegistryLogin { _, err := fmt.Fprintf(w, PostExportManyPlainText, - conf.OutputPath(), - secretsPrefix(conf.KubeconfigSecret), - secretsPrefix(conf.RegistryPassSecret), - varsPrefix(conf.RegistryLoginUrlVar), - varsPrefix(conf.RegistryUserVar), - varsPrefix(conf.RegistryUrlVar), + opts.outputPath(), + secretsPrefix(opts.KubeconfigSecret), + secretsPrefix(opts.RegistryPassSecret), + varsPrefix(opts.RegistryLoginUrlVar), + varsPrefix(opts.RegistryUserVar), + varsPrefix(opts.RegistryUrlVar), ) return err } _, err := fmt.Fprintf(w, PostExportOnePlainText, - conf.OutputPath(), - secretsPrefix(conf.KubeconfigSecret), + opts.outputPath(), + secretsPrefix(opts.KubeconfigSecret), ) return err } diff --git a/pkg/ci/github/printer_test.go b/pkg/ci/github/printer_test.go index ec3d80792f..7b718b78fa 100644 --- a/pkg/ci/github/printer_test.go +++ b/pkg/ci/github/printer_test.go @@ -28,36 +28,35 @@ func (fw *failWriter) Write(p []byte) (int, error) { func TestPrintConfigurationFail(t *testing.T) { t.Run("main layout write fails", func(t *testing.T) { w := &failWriter{failOnCall: 1, err: errWrite} - conf := Config{FnRuntime: "go"} - err := PrintConfiguration(w, conf) + err := PrintConfiguration(WorkflowConfig{}, "go", w) assert.Error(t, err, errWrite.Error()) }) t.Run("registry secrets write fails", func(t *testing.T) { w := &failWriter{failOnCall: 2, err: errWrite} - conf := Config{RegistryLogin: true, FnRuntime: "go"} + opts := WorkflowConfig{RegistryLogin: true} - err := PrintConfiguration(w, conf) + err := PrintConfiguration(opts, "go", w) assert.Error(t, err, errWrite.Error()) }) t.Run("single secret write fails", func(t *testing.T) { w := &failWriter{failOnCall: 2, err: errWrite} - conf := Config{RegistryLogin: false, FnRuntime: "go"} + opts := WorkflowConfig{RegistryLogin: false} - err := PrintConfiguration(w, conf) + err := PrintConfiguration(opts, "go", w) assert.Error(t, err, errWrite.Error()) }) t.Run("unsupported runtime fails", func(t *testing.T) { - conf := Config{FnRuntime: "ruby"} - expectedErr := fmt.Errorf("no builder support for runtime: %s", conf.FnRuntime) + runtime := "ruby" + expectedErr := fmt.Errorf("no builder support for runtime: %s", runtime) - err := PrintConfiguration(&bytes.Buffer{}, conf) + err := PrintConfiguration(WorkflowConfig{}, runtime, &bytes.Buffer{}) assert.Error(t, err, expectedErr.Error()) }) @@ -66,18 +65,18 @@ func TestPrintConfigurationFail(t *testing.T) { func TestPrintPostExportMessageFail(t *testing.T) { t.Run("with registry login write fails", func(t *testing.T) { w := &failWriter{failOnCall: 1, err: errWrite} - conf := Config{RegistryLogin: true} + opts := WorkflowConfig{RegistryLogin: true} - err := PrintPostExportMessage(w, conf) + err := PrintPostExportMessage(opts, w) assert.Error(t, err, errWrite.Error()) }) t.Run("without registry login write fails", func(t *testing.T) { w := &failWriter{failOnCall: 1, err: errWrite} - conf := Config{RegistryLogin: false} + opts := WorkflowConfig{RegistryLogin: false} - err := PrintPostExportMessage(w, conf) + err := PrintPostExportMessage(opts, w) assert.Error(t, err, errWrite.Error()) }) diff --git a/pkg/ci/github/workflow.go b/pkg/ci/github/workflow.go index 35aa3faef3..85403760e2 100644 --- a/pkg/ci/github/workflow.go +++ b/pkg/ci/github/workflow.go @@ -5,16 +5,116 @@ import ( "errors" "fmt" "io" + "path/filepath" "gopkg.in/yaml.v3" - fn "knative.dev/func/pkg/functions" ) -const defaultFuncCliVersion = "knative-v1.21.0" - // ErrWorkflowExists is returned when a GitHub workflow file already exists and --force is not specified. var ErrWorkflowExists = errors.New("existing GitHub workflow detected, overwrite using the --force option") +const ( + defaultFuncCliVersion = "knative-v1.22.0" + DefaultPlatform = "github" + DefaultGitHubWorkflowDir = ".github/workflows" + DefaultGitHubWorkflowFilename = "func-deploy.yaml" + DefaultBranch = "main" + DefaultWorkflowName = "Func Deploy" + DefaultRemoteBuildWorkflowName = "Remote " + DefaultWorkflowName + DefaultKubeconfigSecretName = "KUBECONFIG" + DefaultRegistryLoginUrlVariableName = "REGISTRY_LOGIN_URL" + DefaultRegistryUserVariableName = "REGISTRY_USERNAME" + DefaultRegistryPassSecretName = "REGISTRY_PASSWORD" + DefaultRegistryUrlVariableName = "REGISTRY_URL" + DefaultRegistryLogin = true + DefaultWorkflowDispatch = false + DefaultRemoteBuild = false + DefaultSelfHostedRunner = false + DefaultTestStep = true + DefaultForce = false + DefaultVerbose = false +) + +// WorkflowConfig holds the settings for generating a GitHub Actions workflow. +type WorkflowConfig struct { + GithubWorkflowDir, + GithubWorkflowFilename, + Branch, + WorkflowName, + KubeconfigSecret, + RegistryLoginUrlVar, + RegistryUserVar, + RegistryPassSecret, + RegistryUrlVar string + RegistryLogin, + SelfHostedRunner, + RemoteBuild, + WorkflowDispatch, + TestStep, + Force bool +} + +func (o WorkflowConfig) fnGitHubWorkflowFilepath(fnRoot string) string { + fnGitHubWorkflowDir := filepath.Join(fnRoot, o.GithubWorkflowDir) + return filepath.Join(fnGitHubWorkflowDir, o.GithubWorkflowFilename) +} + +func (o WorkflowConfig) outputPath() string { + return filepath.Join(o.GithubWorkflowDir, o.GithubWorkflowFilename) +} + +func defaultWorkflowConfig() WorkflowConfig { + return WorkflowConfig{ + GithubWorkflowDir: DefaultGitHubWorkflowDir, + GithubWorkflowFilename: DefaultGitHubWorkflowFilename, + Branch: DefaultBranch, + WorkflowName: DefaultWorkflowName, + KubeconfigSecret: DefaultKubeconfigSecretName, + RegistryLoginUrlVar: DefaultRegistryLoginUrlVariableName, + RegistryUserVar: DefaultRegistryUserVariableName, + RegistryPassSecret: DefaultRegistryPassSecretName, + RegistryUrlVar: DefaultRegistryUrlVariableName, + RegistryLogin: DefaultRegistryLogin, + SelfHostedRunner: DefaultSelfHostedRunner, + RemoteBuild: DefaultRemoteBuild, + WorkflowDispatch: DefaultWorkflowDispatch, + TestStep: DefaultTestStep, + Force: DefaultForce, + } +} + +func setEmptyFieldsToDefaults(defaults WorkflowConfig) WorkflowConfig { + if defaults.GithubWorkflowDir == "" { + defaults.GithubWorkflowDir = DefaultGitHubWorkflowDir + } + if defaults.GithubWorkflowFilename == "" { + defaults.GithubWorkflowFilename = DefaultGitHubWorkflowFilename + } + if defaults.Branch == "" { + defaults.Branch = DefaultBranch + } + if defaults.WorkflowName == "" { + defaults.WorkflowName = DefaultWorkflowName + } + if defaults.KubeconfigSecret == "" { + defaults.KubeconfigSecret = DefaultKubeconfigSecretName + } + if defaults.RegistryLoginUrlVar == "" { + defaults.RegistryLoginUrlVar = DefaultRegistryLoginUrlVariableName + } + if defaults.RegistryUserVar == "" { + defaults.RegistryUserVar = DefaultRegistryUserVariableName + } + if defaults.RegistryPassSecret == "" { + defaults.RegistryPassSecret = DefaultRegistryPassSecretName + } + if defaults.RegistryUrlVar == "" { + defaults.RegistryUrlVar = DefaultRegistryUrlVariableName + } + + return defaults +} + type workflow struct { Name string `yaml:"name"` On workflowTriggers `yaml:"on"` @@ -43,25 +143,25 @@ type step struct { With map[string]string `yaml:"with,omitempty"` } -func newGitHubWorkflow(conf Config, messageWriter io.Writer) (*workflow, error) { +func newGitHubWorkflow(cfg WorkflowConfig, runtime string, messageWriter io.Writer) (*workflow, error) { var steps []step steps = createCheckoutStep(steps) - steps = createRuntimeTestStep(conf, messageWriter, steps) - steps = createK8ContextStep(conf, steps) - steps = createRegistryLoginStep(conf, steps) + steps = createRuntimeTestStep(cfg, runtime, messageWriter, steps) + steps = createK8ContextStep(cfg, steps) + steps = createRegistryLoginStep(cfg, steps) steps = createFuncCLIInstallStep(steps) - steps, err := createFuncDeployStep(conf, steps) + steps, err := createFuncDeployStep(cfg, runtime, steps) if err != nil { return nil, err } return &workflow{ - Name: conf.WorkflowName, - On: createPushTrigger(conf), + Name: cfg.WorkflowName, + On: createPushTrigger(cfg), Jobs: map[string]job{ "deploy": { - RunsOn: determineRunner(conf.SelfHostedRunner), + RunsOn: determineRunner(cfg.SelfHostedRunner), Steps: steps, }, }, @@ -75,14 +175,14 @@ func createCheckoutStep(steps []step) []step { return append(steps, *checkoutCode) } -func createRuntimeTestStep(conf Config, messageWriter io.Writer, steps []step) []step { - if !conf.TestStep { +func createRuntimeTestStep(opts WorkflowConfig, runtime string, messageWriter io.Writer, steps []step) []step { + if !opts.TestStep { return steps } testStep := newStep("Run tests") - switch conf.FnRuntime { + switch runtime { case "go": testStep.withRun("go test ./...") case "node", "typescript": @@ -93,32 +193,32 @@ func createRuntimeTestStep(conf Config, messageWriter io.Writer, steps []step) [ testStep.withRun("./mvnw test") default: // best-effort user message; errors are non-critical - _, _ = fmt.Fprintf(messageWriter, "WARNING: test step not supported for runtime %s\n", conf.FnRuntime) + _, _ = fmt.Fprintf(messageWriter, "WARNING: test step not supported for runtime %s\n", runtime) return steps } return append(steps, *testStep) } -func createK8ContextStep(conf Config, steps []step) []step { +func createK8ContextStep(opts WorkflowConfig, steps []step) []step { setupK8Context := newStep("Setup Kubernetes context"). withUses("azure/k8s-set-context@v4"). withActionConfig("method", "kubeconfig"). - withActionConfig("kubeconfig", newSecret(conf.KubeconfigSecret)) + withActionConfig("kubeconfig", newSecret(opts.KubeconfigSecret)) return append(steps, *setupK8Context) } -func createRegistryLoginStep(conf Config, steps []step) []step { - if !conf.RegistryLogin { +func createRegistryLoginStep(opts WorkflowConfig, steps []step) []step { + if !opts.RegistryLogin { return steps } loginToContainerRegistry := newStep("Login to container registry"). withUses("docker/login-action@v3"). - withActionConfig("registry", newVariable(conf.RegistryLoginUrlVar)). - withActionConfig("username", newVariable(conf.RegistryUserVar)). - withActionConfig("password", newSecret(conf.RegistryPassSecret)) + withActionConfig("registry", newVariable(opts.RegistryLoginUrlVar)). + withActionConfig("username", newVariable(opts.RegistryUserVar)). + withActionConfig("password", newSecret(opts.RegistryPassSecret)) return append(steps, *loginToContainerRegistry) } @@ -132,23 +232,23 @@ func createFuncCLIInstallStep(steps []step) []step { return append(steps, *installFuncCli) } -func createFuncDeployStep(conf Config, steps []step) ([]step, error) { +func createFuncDeployStep(opts WorkflowConfig, runtime string, steps []step) ([]step, error) { deployFuncStep := newStep("Deploy function"). withEnv("FUNC_VERBOSE", "true") - builder, err := determineBuilder(conf.FnRuntime, conf.RemoteBuild) + builder, err := determineBuilder(runtime, opts.RemoteBuild) if err != nil { return nil, err } deployFuncStep.withEnv("FUNC_BUILDER", builder) - if conf.RemoteBuild { + if opts.RemoteBuild { deployFuncStep.withEnv("FUNC_REMOTE", "true") } - registryUrl := newVariable(conf.RegistryUrlVar) - if conf.RegistryLogin { - registryUrl = newVariable(conf.RegistryLoginUrlVar) + "/" + newVariable(conf.RegistryUserVar) + registryUrl := newVariable(opts.RegistryUrlVar) + if opts.RegistryLogin { + registryUrl = newVariable(opts.RegistryLoginUrlVar) + "/" + newVariable(opts.RegistryUserVar) } deployFuncStep.withEnv("FUNC_REGISTRY", registryUrl). withRun("func deploy") @@ -156,12 +256,12 @@ func createFuncDeployStep(conf Config, steps []step) ([]step, error) { return append(steps, *deployFuncStep), nil } -func createPushTrigger(conf Config) workflowTriggers { +func createPushTrigger(opts WorkflowConfig) workflowTriggers { result := workflowTriggers{ - Push: &pushTrigger{Branches: []string{conf.Branch}}, + Push: &pushTrigger{Branches: []string{opts.Branch}}, } - if conf.WorkflowDispatch { + if opts.WorkflowDispatch { result.WorkflowDispatch = &struct{}{} } @@ -202,7 +302,7 @@ func (s *step) withEnv(key, value string) *step { return s } -func (gw *workflow) Export(path string, w fn.PathWriter, force bool, m io.Writer) error { +func (gw *workflow) Export(path string, w WorkflowWriter, force bool, m io.Writer) error { if !force && w.Exist(path) { return ErrWorkflowExists } diff --git a/pkg/ci/github/workflow_test.go b/pkg/ci/github/workflow_test.go index 433d0ad8e1..9c1fca1708 100644 --- a/pkg/ci/github/workflow_test.go +++ b/pkg/ci/github/workflow_test.go @@ -16,15 +16,12 @@ func TestGitHubWorkflow_Export(t *testing.T) { bufferWriter := NewBufferWriter() // WHEN - cfg := Config{ - FnRoot: "path/to/function", - FnRuntime: "go", - } + opts := WorkflowConfig{} - gw, workflowErr := newGitHubWorkflow(cfg, &bytes.Buffer{}) + gw, workflowErr := newGitHubWorkflow(WorkflowConfig{}, "go", &bytes.Buffer{}) assert.NilError(t, workflowErr, "unexpected error when creating GitHub Workflow") - exportErr := gw.Export(cfg.FnGitHubWorkflowFilepath(), bufferWriter, true, &bytes.Buffer{}) + exportErr := gw.Export(opts.fnGitHubWorkflowFilepath("path/to/functions"), bufferWriter, true, &bytes.Buffer{}) // THEN assert.NilError(t, exportErr, "unexpected error when exporting GitHub Workflow") diff --git a/pkg/ci/github/writer.go b/pkg/ci/github/writer.go index abc418ac80..565bac54a2 100644 --- a/pkg/ci/github/writer.go +++ b/pkg/ci/github/writer.go @@ -11,10 +11,16 @@ const ( filePerm = 0644 // u: rw-, g: r--, o: r-- ) +// WorkflowWriter defines the interface for writing workflow files to a given path. +type WorkflowWriter interface { + Exist(path string) bool + Write(path string, raw []byte) error +} + // DefaultWorkflowWriter is the default implementation for writing workflow files to disk. var DefaultWorkflowWriter = &fileWriter{} -// fileWriter implements functions.PathWriter +// fileWriter implements WorkflowWriter type fileWriter struct{} // Write writes raw bytes to the specified path, creating directories as needed. @@ -35,7 +41,7 @@ func (fw *fileWriter) Exist(path string) bool { return err == nil } -// BufferWriter is a test double (fake) that implements functions.PathWriter +// BufferWriter is a test double (fake) that implements WorkflowWriter // by writing to an in-memory buffer instead of the filesystem. type BufferWriter struct { Path string diff --git a/pkg/ci/github/writer_test.go b/pkg/ci/github/writer_test.go index 9f2e02a460..7d77eab734 100644 --- a/pkg/ci/github/writer_test.go +++ b/pkg/ci/github/writer_test.go @@ -32,7 +32,7 @@ func TestBufferWriter_Write_StoresPath(t *testing.T) { } // TestBufferWriter_Write_ResetsBuffer ensures that consecutive Write calls do -// not accumulate content — each call starts with a fresh buffer. +// not accumulate content, each call starts with a fresh buffer. func TestBufferWriter_Write_ResetsBuffer(t *testing.T) { bw := NewBufferWriter() diff --git a/pkg/functions/client.go b/pkg/functions/client.go index b3298c6ee8..a7f45c4254 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -83,9 +83,7 @@ type Client struct { mcpServer MCPServer // MCP Server startTimeout time.Duration // default start timeout for all runs syncer FunctionSyncer // Syncs Function CR after deploy - ci CI - stdout io.Writer - pathWriter PathWriter + ciGenerator CIGenerator // CI workflow generator } // Scaffolder wraps a function with a service scaffolding (entrypoint) @@ -233,14 +231,9 @@ type MCPServer interface { Start(context.Context) error } -type CI interface { - Generate(context.Context, any, PathWriter, io.Writer) error -} - -// PathWriter defines the interface for writing files to a given path. -type PathWriter interface { - Exist(path string) bool - Write(path string, raw []byte) error +// CIGenerator creates CI/CD workflow files for a given function. +type CIGenerator interface { + Generate(context.Context, Function) error } // New client for function management. @@ -259,7 +252,7 @@ func New(options ...Option) *Client { mcpServer: &noopMCPServer{}, transport: http.DefaultTransport, startTimeout: DefaultStartTimeout, - ci: &noopCI{}, + ciGenerator: &noopCIGenerator{}, } c.runner = newDefaultRunner(c, os.Stdout, os.Stderr) for _, o := range options { @@ -385,7 +378,7 @@ func WithRepositoriesPath(path string) Option { } // WithRepository sets a specific URL to a Git repository from which to pull -// templates. This setting's existence precedes the use of either the inbuilt +// templates. This setting's existence precludes the use of either the inbuilt // templates or any repositories from the extensible repositories path. func WithRepository(uri string) Option { return func(c *Client) { @@ -458,21 +451,10 @@ func WithStartTimeout(t time.Duration) Option { } } -func WithCI(ci CI) Option { - return func(c *Client) { - c.ci = ci - } -} - -func WithPathWriter(pw PathWriter) Option { - return func(c *Client) { - c.pathWriter = pw - } -} - -func WithStdout(out io.Writer) Option { +// WithCIGenerator sets the CI workflow generator implementation. +func WithCIGenerator(cig CIGenerator) Option { return func(c *Client) { - c.stdout = out + c.ciGenerator = cig } } @@ -1301,8 +1283,9 @@ func (c *Client) StartMCPServer(ctx context.Context) error { return c.mcpServer.Start(ctx) } -func (c *Client) GenerateCIWorkflow(ctx context.Context, config any) error { - return c.ci.Generate(ctx, config, c.pathWriter, c.stdout) +// GenerateCIWorkflow delegates to the configured CIGenerator. +func (c *Client) GenerateCIWorkflow(ctx context.Context, f Function) error { + return c.ciGenerator.Generate(ctx, f) } // ensureRunDataDir creates a .func directory at the given path, and @@ -1672,6 +1655,6 @@ type noopMCPServer struct{} func (n *noopMCPServer) Start(_ context.Context) error { return nil } -type noopCI struct{} +type noopCIGenerator struct{} -func (n *noopCI) Generate(_ context.Context, _ any, _ PathWriter, _ io.Writer) error { return nil } +func (n *noopCIGenerator) Generate(_ context.Context, _ Function) error { return nil } diff --git a/templates/typescript/cloudevents/test/integration.ts b/templates/typescript/cloudevents/test/integration.ts index af72f3b417..e7f57c566d 100644 --- a/templates/typescript/cloudevents/test/integration.ts +++ b/templates/typescript/cloudevents/test/integration.ts @@ -3,7 +3,7 @@ import { CloudEvent, HTTP } from 'cloudevents'; import { start, InvokerOptions } from 'faas-js-runtime'; import request from 'supertest'; -import * as func from '../src'; +import * as func from '../build'; import test, { Test } from 'tape'; // Test typed CloudEvent data diff --git a/templates/typescript/http/src/index.ts b/templates/typescript/http/src/index.ts index 7f90422ce0..65c445d53e 100644 --- a/templates/typescript/http/src/index.ts +++ b/templates/typescript/http/src/index.ts @@ -1,4 +1,4 @@ -import { Context, IncomingBody, StructuredReturn } from 'faas-js-runtime'; +import { Context, StructuredReturn } from 'faas-js-runtime'; /** * Your HTTP handling function, invoked with each request. This is an example @@ -16,7 +16,7 @@ import { Context, IncomingBody, StructuredReturn } from 'faas-js-runtime'; * @param {string} context.httpVersion the HTTP protocol version * See: https://github.com/knative/func/blob/main/docs/guides/nodejs.md#the-context-object */ -const handle = async (context: Context, body?: IncomingBody): Promise => { +const handle = async (context: Context, body: string): Promise => { // YOUR CODE HERE context.log.info(`body: ${body}`); return { diff --git a/templates/typescript/http/test/integration.ts b/templates/typescript/http/test/integration.ts index 497cb74a5e..20104b042c 100644 --- a/templates/typescript/http/test/integration.ts +++ b/templates/typescript/http/test/integration.ts @@ -2,7 +2,7 @@ import { start, InvokerOptions } from 'faas-js-runtime'; import request from 'supertest'; -import * as func from '../src'; +import * as func from '../build'; import test, { Test } from 'tape'; const data = { diff --git a/templates/typescript/http/test/unit.ts b/templates/typescript/http/test/unit.ts index 374ce9b91e..5b4426d875 100644 --- a/templates/typescript/http/test/unit.ts +++ b/templates/typescript/http/test/unit.ts @@ -3,7 +3,7 @@ import test from 'tape'; import { expectType } from 'tsd'; import { Context, HTTPFunction } from 'faas-js-runtime'; -import { handle } from '../src'; +import { handle } from '../build/index.js'; // Ensure that the function completes cleanly when passed a valid event. test('Unit: handles a valid request', async (t) => {