From 152f4902af7067cca5b8ef911ee092f77a0dccf1 Mon Sep 17 00:00:00 2001 From: jayarora Date: Tue, 21 Apr 2026 19:37:00 +0100 Subject: [PATCH 1/2] Add code-only build support --- commands/build.go | 192 ++++++++++++++++++++++++++++++++++++++++++++++ common/common.go | 6 ++ 2 files changed, 198 insertions(+) diff --git a/commands/build.go b/commands/build.go index 6a698a51..dd11a402 100644 --- a/commands/build.go +++ b/commands/build.go @@ -17,11 +17,16 @@ package commands import ( + "archive/zip" "fmt" "github.com/fnproject/cli/common" "github.com/urfave/cli" + "io" "os" + "os/exec" "path/filepath" + "sort" + "strings" ) // BuildCommand returns build cli.command @@ -95,6 +100,18 @@ func (b *buildcmd) build(c *cli.Context) error { return err } + if ff.Code_only { + if ff.Runtime == "" && ff.Runtime_config != nil { + ff.Runtime = ff.Runtime_config.Runtime_name + } + archivePath, err := buildCodeOnlyArchive(dir, ff) + if err != nil { + return err + } + fmt.Printf("Code-only function packaged successfully: %s\n", archivePath) + return nil + } + buildArgs := c.StringSlice("build-arg") // Passing empty shape for build command @@ -122,3 +139,178 @@ func (b *buildcmd) build(c *cli.Context) error { return nil } } + +func buildCodeOnlyArchive(dir string, ff *common.FuncFileV20180708) (string, error) { + if err := validateCodeOnlyBuildTooling(ff); err != nil { + return "", err + } + archivePath := filepath.Join(dir, fmt.Sprintf("%s.%s.zip", ff.Name, ff.Version)) + if err := createCodeOnlyZipArchive(dir, archivePath, ff); err != nil { + return "", err + } + return archivePath, nil +} + +func validateCodeOnlyBuildTooling(ff *common.FuncFileV20180708) error { + runtimeName := "" + if ff.Runtime_config != nil { + runtimeName = strings.TrimSpace(ff.Runtime_config.Runtime_name) + } + baseRuntime := codeOnlyBaseRuntime(runtimeName) + switch { + case strings.HasPrefix(baseRuntime, "java"), strings.HasPrefix(baseRuntime, "kotlin"): + if _, err := exec.LookPath("mvn"); err != nil { + return fmt.Errorf("%s runtime selected, but Maven was not found in PATH. Install Maven and rerun `fn build`, or choose a different runtime", buildRuntimeDisplayName(baseRuntime)) + } + case strings.HasPrefix(baseRuntime, "python"): + if _, err := findFirstTool("python3", "python"); err != nil { + return fmt.Errorf("Python runtime selected, but Python was not found in PATH. Install Python and rerun `fn build`, or choose a different runtime") + } + case strings.HasPrefix(baseRuntime, "ruby"): + if _, err := findFirstTool("ruby"); err != nil { + return fmt.Errorf("Ruby runtime selected, but Ruby was not found in PATH. Install Ruby and rerun `fn build`, or choose a different runtime") + } + case strings.HasPrefix(baseRuntime, "go"): + if _, err := findFirstTool("go"); err != nil { + return fmt.Errorf("Go runtime selected, but Go was not found in PATH. Install Go and rerun `fn build`, or choose a different runtime") + } + } + return nil +} + +func createCodeOnlyZipArchive(dir, archivePath string, ff *common.FuncFileV20180708) error { + if err := os.RemoveAll(archivePath); err != nil { + return err + } + archiveFile, err := os.Create(archivePath) + if err != nil { + return err + } + defer archiveFile.Close() + + zipWriter := zip.NewWriter(archiveFile) + defer zipWriter.Close() + + var paths []string + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == archivePath { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if rel == "." || shouldSkipCodeOnlyBuildPath(rel) { + if info.IsDir() { + return nil + } + return nil + } + if info.IsDir() { + return nil + } + paths = append(paths, rel) + return nil + }) + if err != nil { + return err + } + + sort.Strings(paths) + for _, relPath := range paths { + fullPath := filepath.Join(dir, relPath) + archiveRelPath := codeOnlyArchivePath(relPath, ff) + if err := addFileToZip(zipWriter, fullPath, archiveRelPath); err != nil { + return err + } + } + + return zipWriter.Close() +} + +func codeOnlyArchivePath(relPath string, ff *common.FuncFileV20180708) string { + if ff == nil || ff.Runtime_config == nil { + return relPath + } + baseRuntime := codeOnlyBaseRuntime(strings.TrimSpace(ff.Runtime_config.Runtime_name)) + switch { + case strings.HasPrefix(baseRuntime, "python"): + return filepath.ToSlash(filepath.Join("function", relPath)) + default: + return relPath + } +} + +func shouldSkipCodeOnlyBuildPath(rel string) bool { + base := filepath.Base(rel) + if base == ".git" || base == ".idea" || base == ".vscode" || base == ".DS_Store" { + return true + } + if rel == "func.yaml" || rel == "func.yml" || rel == "func.json" { + return true + } + return false +} + +func addFileToZip(zipWriter *zip.Writer, fullPath, relPath string) error { + info, err := os.Stat(fullPath) + if err != nil { + return err + } + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = filepath.ToSlash(relPath) + header.Method = zip.Deflate + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + file, err := os.Open(fullPath) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(writer, file) + return err +} + +func codeOnlyBaseRuntime(runtimeName string) string { + lower := strings.ToLower(strings.TrimSpace(runtimeName)) + for _, sep := range []string{".", "-"} { + if idx := strings.Index(lower, sep); idx != -1 { + return lower[:idx] + } + } + return lower +} + +func buildRuntimeDisplayName(runtime string) string { + switch { + case strings.HasPrefix(runtime, "java"): + return "Java" + case strings.HasPrefix(runtime, "kotlin"): + return "Kotlin" + case strings.HasPrefix(runtime, "python"): + return "Python" + case strings.HasPrefix(runtime, "ruby"): + return "Ruby" + case strings.HasPrefix(runtime, "go"): + return "Go" + default: + return runtime + } +} + +func findFirstTool(names ...string) (string, error) { + for _, name := range names { + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + } + return "", fmt.Errorf("tool not found") +} diff --git a/common/common.go b/common/common.go index 9c28d88e..9e926ffe 100644 --- a/common/common.go +++ b/common/common.go @@ -220,6 +220,9 @@ func imageStampFuncFileV20180708(fpath string, funcfile *FuncFileV20180708) (*Fu dockerfile := filepath.Join(dir, "Dockerfile") // detect if build and run image both are absent and runtime is not docker then update them + if funcfile.Code_only { + return funcfile, nil + } if !Exists(dockerfile) && funcfile.Runtime != FuncfileDockerRuntime && funcfile.Build_image == "" && funcfile.Run_image == "" { helper := langs.GetLangHelper(funcfile.Runtime) @@ -366,6 +369,9 @@ func containerEngineBuild(verbose bool, fpath string, ff *FuncFile, buildArgs [] } func containerEngineBuildV20180708(verbose bool, fpath string, ff *FuncFileV20180708, buildArgs []string, noCache bool, shape string) error { + if ff.Code_only { + return nil + } containerEngineType, err := GetContainerEngineType() if err != nil { return err From 5078db0dc5cd4e1ecd8345532e89c53de158d031 Mon Sep 17 00:00:00 2001 From: jayarora Date: Thu, 23 Apr 2026 11:54:51 +0100 Subject: [PATCH 2/2] Add code-only build tests --- test/cli_code_only_build_test.go | 135 +++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 test/cli_code_only_build_test.go diff --git a/test/cli_code_only_build_test.go b/test/cli_code_only_build_test.go new file mode 100644 index 00000000..0190ca25 --- /dev/null +++ b/test/cli_code_only_build_test.go @@ -0,0 +1,135 @@ +package test + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fnproject/cli/common" + "github.com/fnproject/cli/testharness" +) + +func archivePathFromBuildOutput(t *testing.T, stdout string) string { + t.Helper() + const prefix = "Code-only function packaged successfully: " + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + t.Fatalf("could not find archive path in build output: %q", stdout) + return "" +} + +func zipEntryNames(t *testing.T, archivePath string) []string { + t.Helper() + reader, err := zip.OpenReader(archivePath) + if err != nil { + t.Fatalf("failed to open zip archive %s: %v", archivePath, err) + } + defer reader.Close() + + entries := make([]string, 0, len(reader.File)) + for _, f := range reader.File { + entries = append(entries, f.Name) + } + return entries +} + +func containsEntry(entries []string, target string) bool { + for _, entry := range entries { + if entry == target { + return true + } + } + return false +} + +func TestCodeOnlyBuild(t *testing.T) { + t.Run("python code-only build should create a versioned archive with function root and exclude func.yaml", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + funcName := h.NewFuncName(appName) + dirName := funcName + "_dir" + h.Fn("init", "--code-only", "--runtime-name", "python311.ol9", "--runtime-config-type", "function-update", "--name", funcName, dirName).AssertSuccess() + + h.Cd(dirName) + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + + if _, err := os.Stat(archivePath); err != nil { + t.Fatalf("expected archive at %s: %v", archivePath, err) + } + expectedArchiveName := fmt.Sprintf("%s.0.0.1.zip", funcName) + if filepath.Base(archivePath) != expectedArchiveName { + t.Fatalf("archive name was %q, expected %q", filepath.Base(archivePath), expectedArchiveName) + } + + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "function/hello_world.py") { + t.Fatalf("expected function/hello_world.py in archive, got entries: %v", entries) + } + if containsEntry(entries, "func.yaml") { + t.Fatalf("func.yaml should not be included in code-only archive, entries: %v", entries) + } + }) + + t.Run("go code-only build should create a versioned archive and exclude func.yaml", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + funcName := h.NewFuncName(appName) + dirName := funcName + "_dir" + h.Fn("init", "--code-only", "--runtime", "go", "--runtime-config-type", "function-update", "--name", funcName, dirName).AssertSuccess() + + h.Cd(dirName) + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + + if _, err := os.Stat(archivePath); err != nil { + t.Fatalf("expected archive at %s: %v", archivePath, err) + } + expectedArchiveName := fmt.Sprintf("%s.0.0.1.zip", funcName) + if filepath.Base(archivePath) != expectedArchiveName { + t.Fatalf("archive name was %q, expected %q", filepath.Base(archivePath), expectedArchiveName) + } + + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "func.go") { + t.Fatalf("expected func.go in archive, got entries: %v", entries) + } + if containsEntry(entries, "func.yaml") { + t.Fatalf("func.yaml should not be included in code-only archive, entries: %v", entries) + } + }) + + t.Run("java code-only build should require Maven", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + h.MkDir("hello-java") + h.Cd("hello-java") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-java +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: java +handler: com.example.fn.HelloFunction::handleRequest +`, common.LatestYamlVersion), 0644) + h.WithEnv("PATH", "/usr/bin:/bin") + + h.Fn("build").AssertFailed().AssertStderrContains("Maven was not found in PATH") + }) +} \ No newline at end of file