Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions commands/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
6 changes: 6 additions & 0 deletions common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions test/cli_code_only_build_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}