diff --git a/commands/build.go b/commands/build.go index 4ca93a92..655599d0 100644 --- a/commands/build.go +++ b/commands/build.go @@ -17,11 +17,19 @@ package commands import ( + "archive/zip" "fmt" + client "github.com/fnproject/cli/client" "github.com/fnproject/cli/common" + apps "github.com/fnproject/cli/objects/app" "github.com/urfave/cli" + "io" "os" + "os/exec" "path/filepath" + "runtime" + "sort" + "strings" ) // BuildCommand returns build cli.command @@ -46,6 +54,10 @@ type buildcmd struct { func (b *buildcmd) flags() []cli.Flag { return []cli.Flag{ + cli.StringFlag{ + Name: "app", + Usage: "App name used to resolve target application shape for code-only builds.", + }, cli.BoolFlag{ Name: "verbose, v", Usage: "Verbose mode", @@ -95,6 +107,34 @@ 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 + } + appName := strings.TrimSpace(c.String("app")) + if appName == "" { + return fmt.Errorf("code-only build requires --app so the target application shape can be used for packaging") + } + provider, err := client.CurrentProvider() + if err != nil { + return err + } + app, err := apps.GetAppByName(provider.APIClientv2(), appName) + if err != nil { + return err + } + shape := app.Shape + if shape == "" { + shape = common.DefaultAppShape + } + archivePath, err := buildCodeOnlyArchive(dir, ff, shape) + 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 +162,623 @@ func (b *buildcmd) build(c *cli.Context) error { return nil } } + +func buildCodeOnlyArchive(dir string, ff *common.FuncFileV20180708, shape string) (string, error) { + if err := validateCodeOnlyBuildTooling(dir, ff); err != nil { + return "", err + } + archivePath := filepath.Join(dir, fmt.Sprintf("%s.%s.zip", ff.Name, ff.Version)) + if err := createCodeOnlyZipArchive(dir, archivePath, ff, shape); err != nil { + return "", err + } + return archivePath, nil +} + +func validateCodeOnlyBuildTooling(dir string, ff *common.FuncFileV20180708) error { + baseRuntime := detectCodeOnlyBaseRuntime(dir, ff) + switch { + case strings.HasPrefix(baseRuntime, "java"): + 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, "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") + } + case strings.HasPrefix(baseRuntime, "node"): + if _, err := findFirstTool("node"); err != nil { + return fmt.Errorf("Node.js runtime selected, but Node.js was not found in PATH. Install Node.js and rerun `fn build`, or choose a different runtime") + } + } + return nil +} + +func createCodeOnlyZipArchive(dir, archivePath string, ff *common.FuncFileV20180708, shape string) 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) + if err := addCodeOnlyArchiveContents(zipWriter, dir, paths, ff, shape); err != nil { + return err + } + + return zipWriter.Close() +} + + +func addCodeOnlyArchiveContents(zipWriter *zip.Writer, dir string, paths []string, ff *common.FuncFileV20180708, shape string) error { + baseRuntime := detectCodeOnlyBaseRuntime(dir, ff) + if strings.HasPrefix(baseRuntime, "go") { + return addGoCodeOnlyArchiveContents(zipWriter, dir, ff, shape) + } + if strings.HasPrefix(baseRuntime, "java") { + return addJavaCodeOnlyArchiveContents(zipWriter, dir, ff, shape) + } + if strings.HasPrefix(baseRuntime, "python") { + return addPythonCodeOnlyArchiveContents(zipWriter, dir, ff, shape) + } + if strings.HasPrefix(baseRuntime, "node") { + return addNodeCodeOnlyArchiveContents(zipWriter, dir, ff, shape) + } + for _, relPath := range paths { + fullPath := filepath.Join(dir, relPath) + archiveRelPath := codeOnlyArchivePath(relPath, ff) + if err := addFileToZip(zipWriter, fullPath, archiveRelPath); err != nil { + return err + } + } + return nil +} + +func detectCodeOnlyBaseRuntime(dir string, ff *common.FuncFileV20180708) string { + isKnown := func(base string) bool { + return base == "go" || base == "java" || base == "python" || base == "node" + } + if ff != nil { + if strings.TrimSpace(ff.Runtime) != "" { + if base := codeOnlyBaseRuntime(strings.TrimSpace(ff.Runtime)); base != "" { + if isKnown(base) { + return base + } + } + } + if ff.Runtime_config != nil && strings.TrimSpace(ff.Runtime_config.Runtime_name) != "" { + if base := codeOnlyBaseRuntime(strings.TrimSpace(ff.Runtime_config.Runtime_name)); base != "" { + if isKnown(base) { + return base + } + } + } + } + + if common.Exists(filepath.Join(dir, "go.mod")) { + return "go" + } + if common.Exists(filepath.Join(dir, "pom.xml")) || common.Exists(filepath.Join(dir, "build.gradle")) || common.Exists(filepath.Join(dir, "build.gradle.kts")) { + return "java" + } + if common.Exists(filepath.Join(dir, "function", "func.js")) || common.Exists(filepath.Join(dir, "package.json")) { + return "node" + } + if common.Exists(filepath.Join(dir, "function", "hello_world.py")) { + return "python" + } + return "" +} + + +func addNodeCodeOnlyArchiveContents(zipWriter *zip.Writer, dir string, ff *common.FuncFileV20180708, shape string) error { + functionDir := filepath.Join(dir, "function") + if !common.Exists(functionDir) { + return fmt.Errorf("node.js code-only build requires a function/ directory at the archive root") + } + packageJSON := filepath.Join(dir, "package.json") + nodeModulesDir := filepath.Join(dir, "node_modules") + if common.Exists(packageJSON) && !common.Exists(nodeModulesDir) { + npmBin, err := findFirstTool("npm") + if err != nil { + return fmt.Errorf("node.js code-only build requires npm to install @fnproject/fdk dependencies") + } + npmInstall := exec.Command(npmBin, "install", "--omit=dev") + npmInstall.Dir = dir + npmInstall.Stdout = os.Stdout + npmInstall.Stderr = os.Stderr + if err := npmInstall.Run(); err != nil { + return err + } + } + if err := addDirectoryToZip(zipWriter, functionDir, "function"); err != nil { + return err + } + if common.Exists(nodeModulesDir) { + if err := addDirectoryToZip(zipWriter, nodeModulesDir, "node_modules"); err != nil { + return err + } + } + if common.Exists(packageJSON) { + if err := addFileToZip(zipWriter, packageJSON, "package.json"); err != nil { + return err + } + } + resourcesDir := filepath.Join(dir, "resources") + if common.Exists(resourcesDir) { + if err := addDirectoryToZip(zipWriter, resourcesDir, "resources"); err != nil { + return err + } + } + nativeDir := filepath.Join(dir, "native") + if common.Exists(nativeDir) { + if err := addNodeNativeArchiveContents(zipWriter, nativeDir, shape); err != nil { + return err + } + } + return nil +} + +func addNodeNativeArchiveContents(zipWriter *zip.Writer, nativeDir, shape string) error { + entries, err := os.ReadDir(nativeDir) + if err != nil { + return err + } + valid := map[string]bool{"fn-arch-x86": true, "fn-arch-arm": true} + found := map[string]bool{} + for _, entry := range entries { + if !entry.IsDir() { + return fmt.Errorf("native/ must not contain files directly at its root") + } + if !valid[entry.Name()] { + return fmt.Errorf("native/ contains unsupported architecture directory %s", entry.Name()) + } + found[entry.Name()] = true + } + arches := codeOnlyGoTargetArchitectures(shape) + if len(arches) == 1 { + return fmt.Errorf("native/ is not allowed for single-architecture Node.js functions") + } + if !found["fn-arch-x86"] || !found["fn-arch-arm"] { + return fmt.Errorf("native/ must contain both fn-arch-x86 and fn-arch-arm for a multi-architecture application") + } + for name := range found { + archDir := filepath.Join(nativeDir, name) + if err := addDirectoryToZip(zipWriter, archDir, filepath.ToSlash(filepath.Join("native", name))); err != nil { + return err + } + } + return nil +} + +func addPythonCodeOnlyArchiveContents(zipWriter *zip.Writer, dir string, ff *common.FuncFileV20180708, shape string) error { + functionDir := filepath.Join(dir, "function") + if !common.Exists(functionDir) { + return fmt.Errorf("python code-only build requires a function/ directory at the archive root") + } + if err := addDirectoryToZip(zipWriter, functionDir, "function"); err != nil { + return err + } + pythonDir := filepath.Join(dir, "python") + if common.Exists(pythonDir) { + if err := addDirectoryToZip(zipWriter, pythonDir, "python"); err != nil { + return err + } + } + resourcesDir := filepath.Join(dir, "resources") + if common.Exists(resourcesDir) { + if err := addDirectoryToZip(zipWriter, resourcesDir, "resources"); err != nil { + return err + } + } + nativeDir := filepath.Join(dir, "native") + if common.Exists(nativeDir) { + if err := addPythonNativeArchiveContents(zipWriter, nativeDir, shape); err != nil { + return err + } + } + return nil +} + +func addPythonNativeArchiveContents(zipWriter *zip.Writer, nativeDir, shape string) error { + entries, err := os.ReadDir(nativeDir) + if err != nil { + return err + } + valid := map[string]bool{"fn-arch-x86": true, "fn-arch-arm": true} + found := map[string]bool{} + for _, entry := range entries { + if !entry.IsDir() { + return fmt.Errorf("native/ must not contain files directly at its root") + } + if !valid[entry.Name()] { + return fmt.Errorf("native/ contains unsupported architecture directory %s", entry.Name()) + } + found[entry.Name()] = true + } + arches := codeOnlyGoTargetArchitectures(shape) + if len(arches) == 1 { + return fmt.Errorf("native/ is not allowed for single-architecture Python functions") + } + if !found["fn-arch-x86"] || !found["fn-arch-arm"] { + return fmt.Errorf("native/ must contain both fn-arch-x86 and fn-arch-arm for a multi-architecture application") + } + for name := range found { + archDir := filepath.Join(nativeDir, name) + if err := addDirectoryToZip(zipWriter, archDir, filepath.ToSlash(filepath.Join("native", name))); err != nil { + return err + } + } + return nil +} + +func addJavaCodeOnlyArchiveContents(zipWriter *zip.Writer, dir string, ff *common.FuncFileV20180708, shape string) error { + jarPath, err := locateJavaCodeOnlyJar(dir) + if err != nil { + return err + } + if err := addFileToZip(zipWriter, jarPath, "main.jar"); err != nil { + return err + } + resourcesDir := filepath.Join(dir, "resources") + if common.Exists(resourcesDir) { + if err := addDirectoryToZip(zipWriter, resourcesDir, "resources"); err != nil { + return err + } + } + nativeDir := filepath.Join(dir, "native") + if common.Exists(nativeDir) { + if err := addJavaNativeArchiveContents(zipWriter, nativeDir, shape); err != nil { + return err + } + } + return nil +} + +func locateJavaCodeOnlyJar(dir string) (string, error) { + var jars []string + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.EqualFold(filepath.Ext(name), ".jar") { + jars = append(jars, filepath.Join(dir, name)) + } + } + if len(jars) == 0 { + for _, pattern := range []string{"target/*.jar", "build/libs/*.jar"} { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + for _, match := range matches { + if strings.EqualFold(filepath.Ext(match), ".jar") { + jars = append(jars, match) + } + } + } + } + unique := make([]string, 0, len(jars)) + seen := map[string]struct{}{} + for _, jar := range jars { + if _, ok := seen[jar]; ok { + continue + } + seen[jar] = struct{}{} + unique = append(unique, jar) + } + jars = unique + if len(jars) == 0 { + return "", fmt.Errorf("java code-only build requires exactly one .jar file at the archive root or a single build output under target/ or build/libs") + } + if len(jars) > 1 { + return "", fmt.Errorf("java code-only build requires exactly one .jar file, found %d", len(jars)) + } + return jars[0], nil +} + +func addJavaNativeArchiveContents(zipWriter *zip.Writer, nativeDir, shape string) error { + entries, err := os.ReadDir(nativeDir) + if err != nil { + return err + } + valid := map[string]bool{"fn-arch-x86": true, "fn-arch-arm": true} + found := map[string]bool{} + for _, entry := range entries { + if !entry.IsDir() { + return fmt.Errorf("native/ must not contain files directly at its root") + } + if !valid[entry.Name()] { + return fmt.Errorf("native/ contains unsupported architecture directory %s", entry.Name()) + } + found[entry.Name()] = true + } + arches := codeOnlyGoTargetArchitectures(shape) + if len(arches) == 1 { + required := goCodeOnlyArchiveSegment(arches[0]) + if !found[required] { + return fmt.Errorf("native/ must contain %s for the target application shape", required) + } + for name := range found { + if name != required { + return fmt.Errorf("native/ must contain only %s for the target application shape", required) + } + } + } else { + if !found["fn-arch-x86"] || !found["fn-arch-arm"] { + return fmt.Errorf("native/ must contain both fn-arch-x86 and fn-arch-arm for a multi-architecture application") + } + } + for name := range found { + archDir := filepath.Join(nativeDir, name) + if err := addDirectoryToZip(zipWriter, archDir, filepath.ToSlash(filepath.Join("native", name))); err != nil { + return err + } + } + return nil +} + +func addGoCodeOnlyArchiveContents(zipWriter *zip.Writer, dir string, ff *common.FuncFileV20180708, shape string) error { + architectures := codeOnlyGoTargetArchitectures(shape) + if len(architectures) == 1 { + binaryPath := filepath.Join(dir, "func") + if err := buildGoCodeOnlyBinary(dir, binaryPath, architectures[0]); err != nil { + return err + } + if err := addFileToZip(zipWriter, binaryPath, "func"); err != nil { + return err + } + resourcesDir := filepath.Join(dir, "resources") + if common.Exists(resourcesDir) { + if err := addDirectoryToZip(zipWriter, resourcesDir, "resources"); err != nil { + return err + } + } + return nil + } + for _, arch := range architectures { + segment := goCodeOnlyArchiveSegment(arch) + archDir := filepath.Join(dir, segment) + if err := os.MkdirAll(archDir, 0755); err != nil { + return err + } + binaryPath := filepath.Join(archDir, "func") + if err := buildGoCodeOnlyBinary(dir, binaryPath, arch); err != nil { + return err + } + if err := addFileToZip(zipWriter, binaryPath, filepath.ToSlash(filepath.Join(segment, "func"))); err != nil { + return err + } + resourcesDir := filepath.Join(dir, "resources") + if common.Exists(resourcesDir) { + if err := addDirectoryToZip(zipWriter, resourcesDir, filepath.ToSlash(filepath.Join(segment, "resources"))); err != nil { + return err + } + } + } + return nil +} + +func codeOnlyGoTargetArchitectures(shape string) []string { + if shape == "" { + switch runtime.GOARCH { + case "arm64": + return []string{"arm64"} + default: + return []string{"amd64"} + } + } + if archs, ok := common.TargetPlatformMap[shape]; ok && len(archs) > 0 { + parts := strings.Split(archs[0], "_") + return parts + } + return []string{"amd64"} +} + +func goCodeOnlyArchiveSegment(arch string) string { + if arch == "arm64" { + return "fn-arch-arm" + } + return "fn-arch-x86" +} + +func buildGoCodeOnlyBinary(dir, outputPath, arch string) error { + goBin := resolveGoBinary() + env := withEnvOverrides(os.Environ(), map[string]string{ + "GOOS": "linux", + "GOARCH": arch, + "GOFLAGS": "-mod=mod", + "GOTOOLCHAIN": "go1.24.0+auto", + }) + + modDownload := exec.Command(goBin, "mod", "download") + modDownload.Dir = dir + modDownload.Env = env + modDownload.Stdout = os.Stdout + modDownload.Stderr = os.Stderr + if err := modDownload.Run(); err != nil { + return err + } + + cmd := exec.Command(goBin, "build", "-trimpath", "-ldflags", "-s -w", "-o", outputPath, ".") + cmd.Dir = dir + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func withEnvOverrides(base []string, overrides map[string]string) []string { + filtered := make([]string, 0, len(base)+len(overrides)) + for _, kv := range base { + i := strings.Index(kv, "=") + if i <= 0 { + filtered = append(filtered, kv) + continue + } + k := kv[:i] + if _, drop := overrides[k]; drop { + continue + } + filtered = append(filtered, kv) + } + for k, v := range overrides { + filtered = append(filtered, k+"="+v) + } + return filtered +} + +func resolveGoBinary() string { + if override := strings.TrimSpace(os.Getenv("FN_GO_BIN")); override != "" { + return override + } + preferred := "/usr/local/go/bin/go" + if st, err := os.Stat(preferred); err == nil && !st.IsDir() { + return preferred + } + return "go" +} + +func addDirectoryToZip(zipWriter *zip.Writer, sourceDir, archivePrefix string) error { + return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + return addFileToZip(zipWriter, path, filepath.ToSlash(filepath.Join(archivePrefix, rel))) + }) +} + +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 strings.EqualFold(filepath.Ext(base), ".zip") { + 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)) + if lower == "ol9" || lower == "ol8" { + // Managed Go runtimes may be represented by generic OS names. + return "go" + } + 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, "python"): + return "Python" + case strings.HasPrefix(runtime, "go"): + return "Go" + case strings.HasPrefix(runtime, "node"): + return "Node.js" + 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/commands/commands.go b/commands/commands.go index 041a0b9a..feb14b27 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -24,6 +24,7 @@ import ( "github.com/fnproject/cli/objects/context" "github.com/fnproject/cli/objects/fn" "github.com/fnproject/cli/objects/pbf" + "github.com/fnproject/cli/objects/runtime" "github.com/fnproject/cli/objects/server" "github.com/fnproject/cli/objects/trigger" "github.com/urfave/cli" @@ -55,6 +56,7 @@ var Commands = Cmd{ "unset": UnsetCommand(), "update": UpdateCommand(), "use": UseCommand(), + "work-request": WorkRequestCommand(), } var CreateCmds = Cmd{ @@ -100,6 +102,7 @@ var DeleteCmds = Cmd{ var GetCmds = Cmd{ "config": ConfigCommand("get"), "pbfs": pbf.Get(), + "latest-runtime-version": runtime.GetLatestRuntimeVersion(), } var InspectCmds = Cmd{ @@ -116,6 +119,8 @@ var ListCmds = Cmd{ "pbfs": pbf.List(), "triggers": trigger.List(), "contexts": context.List(), + "runtimes": runtime.ListRuntimes(), + "runtime-versions": runtime.ListRuntimeVersions(), } var UnsetCmds = Cmd{ diff --git a/commands/deploy.go b/commands/deploy.go index c8cf0e06..d7b06f8b 100644 --- a/commands/deploy.go +++ b/commands/deploy.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "os" "os/exec" "path/filepath" @@ -34,6 +35,7 @@ import ( client "github.com/fnproject/cli/client" common "github.com/fnproject/cli/common" + config "github.com/fnproject/cli/config" apps "github.com/fnproject/cli/objects/app" function "github.com/fnproject/cli/objects/fn" trigger "github.com/fnproject/cli/objects/trigger" @@ -43,6 +45,7 @@ import ( "github.com/oracle/oci-go-sdk/v65/artifacts" ociCommon "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/keymanagement" + "github.com/spf13/viper" "github.com/urfave/cli" ) @@ -352,6 +355,10 @@ func (p *deploycmd) deployFuncV20180708(c *cli.Context, app *models.App, funcfil if funcfile.Name == "" { funcfile.Name = filepath.Base(filepath.Dir(funcfilePath)) // todo: should probably make a copy of ff before changing it } + + if funcfile.Code_only { + return p.deployCodeOnlyFunc(c, app, funcfilePath, funcfile) + } common.WarnIfOCIManagedFunctionSettingsUnsupported(os.Stderr, p.provider, funcfile.Name, funcfile) oracleProvider, _ := getOracleProvider() @@ -424,6 +431,105 @@ func (p *deploycmd) deployFuncV20180708(c *cli.Context, app *models.App, funcfil return p.updateFunction(c, app.ID, funcfile) } +func (p *deploycmd) deployCodeOnlyFunc(c *cli.Context, app *models.App, funcfilePath string, funcfile *common.FuncFileV20180708) error { + if !p.noBump { + funcfile2, err := common.BumpItV20180708(funcfilePath, common.Patch) + if err != nil { + return err + } + funcfile.Version = funcfile2.Version + } + + dir := filepath.Dir(funcfilePath) + archivePath, err := buildCodeOnlyArchive(dir, funcfile, app.Shape) + if err != nil { + return err + } + + fn := &models.Fn{} + if err := function.WithFuncFileV20180708(funcfile, fn); err != nil { + return fmt.Errorf("Error getting function with funcfile: %s", err) + } + fn.Name = funcfile.Name + fn.CodeOnly = true + fn.Handler = strings.TrimSpace(funcfile.Handler) + if funcfile.Runtime_config != nil { + fn.RuntimeName = strings.TrimSpace(funcfile.Runtime_config.Runtime_name) + fn.RuntimeVersionID = strings.TrimSpace(funcfile.Runtime_config.Runtime_version_id) + fn.RuntimeConfigType = normalizeRuntimeConfigTypeForDeploy(funcfile.Runtime_config.Type) + } + + bucket, namespace, configured, err := resolveCodeOnlyDeployTargetFromContext() + if err != nil { + return err + } + if configured { + objectName, err := pushCodeOnlyArchive(funcfile) + if err != nil { + return err + } + fn.SourceType = "object-storage" + fn.SourceBucketName = bucket + fn.SourceNamespace = namespace + fn.SourceObjectName = objectName + fn.SourceObjectVersion = "" + } else { + archiveBytes, err := ioutil.ReadFile(archivePath) + if err != nil { + return err + } + fn.SourceType = "direct" + fn.SourceFile = archivePath + fn.SourceArchive = archiveBytes + } + + return p.upsertCodeOnlyFunction(app.ID, fn) +} + +func (p *deploycmd) upsertCodeOnlyFunction(appID string, fn *models.Fn) error { + fnRes, err := function.GetFnByName(p.clientV2, appID, fn.Name) + if _, ok := err.(function.NameNotFoundError); ok { + created, err := function.CreateFn(p.clientV2, appID, fn) + if err != nil { + return err + } + fn.ID = created.ID + } else if err != nil { + return err + } else { + fn.ID = fnRes.ID + if err := function.PutFn(p.clientV2, fn.ID, fn); err != nil { + return err + } + } + return nil +} + +func normalizeRuntimeConfigTypeForDeploy(value string) string { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "function-update", "function_update": + return "FUNCTION_UPDATE" + case "manual": + return "MANUAL" + default: + return strings.ToUpper(strings.ReplaceAll(v, "-", "_")) + } +} + +func resolveCodeOnlyDeployTargetFromContext() (bucket, namespace string, configured bool, err error) { + contextName := viper.GetString(config.CurrentContext) + contextPath := filepath.Join(config.GetHomeDir(), ".fn", "contexts", contextName+".yaml") + ctxFile, err := config.NewContextFile(contextPath) + if err != nil { + return "", "", false, err + } + bucket = strings.TrimSpace(ctxFile.ObjectStorageBucketName) + namespace = strings.TrimSpace(ctxFile.ObjectStorageNamespace) + configured = bucket != "" && namespace != "" + return bucket, namespace, configured, nil +} + func (p *deploycmd) updateFunction(c *cli.Context, appID string, ff *common.FuncFileV20180708) error { if ff.Deploy != nil && ff.Deploy.OCI != nil && ff.Deploy.OCI.PBF != nil && strings.TrimSpace(ff.Deploy.OCI.PBF.ListingID) != "" { fmt.Printf("Updating function %s using PBF listing %s...\n", ff.Name, ff.Deploy.OCI.PBF.ListingID) diff --git a/commands/deploy_test.go b/commands/deploy_test.go new file mode 100644 index 00000000..1d30d4cc --- /dev/null +++ b/commands/deploy_test.go @@ -0,0 +1,111 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fnproject/cli/config" + "github.com/spf13/viper" +) + +func TestNormalizeRuntimeConfigTypeForDeploy(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "function-update hyphen", input: "function-update", want: "FUNCTION_UPDATE"}, + {name: "function_update underscore", input: "function_update", want: "FUNCTION_UPDATE"}, + {name: "manual", input: "manual", want: "MANUAL"}, + {name: "already upper", input: "FUNCTION_UPDATE", want: "FUNCTION_UPDATE"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := normalizeRuntimeConfigTypeForDeploy(tc.input) + if got != tc.want { + t.Fatalf("normalizeRuntimeConfigTypeForDeploy(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestResolveCodeOnlyDeployTargetFromContext(t *testing.T) { + t.Run("configured context should return bucket namespace and configured true", func(t *testing.T) { + oldHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", oldHome) }() + + tmpHome := t.TempDir() + if err := os.Setenv("HOME", tmpHome); err != nil { + t.Fatalf("failed to set HOME: %v", err) + } + + contextsDir := filepath.Join(tmpHome, ".fn", "contexts") + if err := os.MkdirAll(contextsDir, 0755); err != nil { + t.Fatalf("failed to create contexts dir: %v", err) + } + + contextName := "testctx" + contextPath := filepath.Join(contextsDir, contextName+".yaml") + content := []byte("provider: oracle\nobject_storage_bucket_name: code-only-test-files\nnamespace: oraclefunctionsdevelopm\n") + if err := os.WriteFile(contextPath, content, 0644); err != nil { + t.Fatalf("failed to write context file: %v", err) + } + + oldContext := viper.GetString(config.CurrentContext) + defer viper.Set(config.CurrentContext, oldContext) + viper.Set(config.CurrentContext, contextName) + + bucket, namespace, configured, err := resolveCodeOnlyDeployTargetFromContext() + if err != nil { + t.Fatalf("resolveCodeOnlyDeployTargetFromContext returned error: %v", err) + } + if bucket != "code-only-test-files" { + t.Fatalf("bucket = %q, want %q", bucket, "code-only-test-files") + } + if namespace != "oraclefunctionsdevelopm" { + t.Fatalf("namespace = %q, want %q", namespace, "oraclefunctionsdevelopm") + } + if !configured { + t.Fatal("configured = false, want true") + } + }) + + t.Run("missing bucket or namespace should return configured false", func(t *testing.T) { + oldHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", oldHome) }() + + tmpHome := t.TempDir() + if err := os.Setenv("HOME", tmpHome); err != nil { + t.Fatalf("failed to set HOME: %v", err) + } + + contextsDir := filepath.Join(tmpHome, ".fn", "contexts") + if err := os.MkdirAll(contextsDir, 0755); err != nil { + t.Fatalf("failed to create contexts dir: %v", err) + } + + contextName := "emptyctx" + contextPath := filepath.Join(contextsDir, contextName+".yaml") + content := []byte("provider: oracle\n") + if err := os.WriteFile(contextPath, content, 0644); err != nil { + t.Fatalf("failed to write context file: %v", err) + } + + oldContext := viper.GetString(config.CurrentContext) + defer viper.Set(config.CurrentContext, oldContext) + viper.Set(config.CurrentContext, contextName) + + bucket, namespace, configured, err := resolveCodeOnlyDeployTargetFromContext() + if err != nil { + t.Fatalf("resolveCodeOnlyDeployTargetFromContext returned error: %v", err) + } + if bucket != "" || namespace != "" { + t.Fatalf("expected empty bucket/namespace, got %q / %q", bucket, namespace) + } + if configured { + t.Fatal("configured = true, want false") + } + }) +} \ No newline at end of file diff --git a/commands/init.go b/commands/init.go index 0c90eec5..e4254715 100644 --- a/commands/init.go +++ b/commands/init.go @@ -39,6 +39,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -51,10 +52,13 @@ import ( ) type initFnCmd struct { - force bool - triggerType string - wd string - ff *common.FuncFileV20180708 + force bool + codeOnly bool + triggerType string + wd string + runtimeName string + runtimeConfigType string + ff *common.FuncFileV20180708 } func initFlags(a *initFnCmd) []cli.Flag { @@ -68,10 +72,25 @@ func initFlags(a *initFnCmd) []cli.Flag { Usage: "Overwrite existing func.yaml", Destination: &a.force, }, + cli.BoolFlag{ + Name: "code-only", + Usage: "Initialize a code-only function", + Destination: &a.codeOnly, + }, cli.StringFlag{ Name: "runtime", Usage: "Choose an existing runtime - " + langsList(), }, + cli.StringFlag{ + Name: "runtime-name", + Usage: "Specify the managed runtime name (e.g. python39.ol9) for code-only functions.", + Destination: &a.runtimeName, + }, + cli.StringFlag{ + Name: "runtime-config-type", + Usage: "Set the runtime configuration type for managed runtimes. Required for code-only functions.", + Destination: &a.runtimeConfigType, + }, cli.StringFlag{ Name: "init-image", Usage: "A Docker image which will create a function template", @@ -245,12 +264,28 @@ func (a *initFnCmd) init(c *cli.Context) error { runtime := c.String("runtime") initImage := c.String("init-image") + a.runtimeName = strings.TrimSpace(a.runtimeName) + a.runtimeConfigType = strings.TrimSpace(a.runtimeConfigType) if runtime != "" && initImage != "" { return fmt.Errorf("You can't supply --runtime with --init-image") } + if (a.codeOnly || a.runtimeName != "") && initImage != "" { + return fmt.Errorf("You can't supply --code-only with --init-image") + } + if runtime != "" && a.runtimeName != "" { + return fmt.Errorf("Specify either --runtime or --runtime-name, not both") + } + if a.codeOnly && runtime == common.FuncfileDockerRuntime { + return fmt.Errorf("Init does not support the '%s' runtime for code-only functions", runtime) + } runtimeSpecified := runtime != "" + codeOnlyInit := a.codeOnly || a.runtimeName != "" + precheckedRuntime := "" + if codeOnlyInit && a.runtimeConfigType == "" { + return fmt.Errorf("Code-only init requires --runtime-config-type") + } a.ff.Schema_version = common.LatestYamlVersion if runtimeSpecified { @@ -262,6 +297,16 @@ func (a *initFnCmd) init(c *cli.Context) error { return fmt.Errorf("Runtime %s is no more supported for new apps. Please use python or %s runtime for new apps.", runtime, runtime[:strings.LastIndex(runtime, ".")]) } } + + if runtime != "" { + precheckedRuntime = runtime + } else if a.runtimeName != "" { + precheckedRuntime = a.runtimeName + } + if err := precheckToolingForRuntime(precheckedRuntime); err != nil { + return err + } + path := c.Args().First() if path != "" { fmt.Printf("Creating function at: ./%s\n", path) @@ -327,9 +372,23 @@ func (a *initFnCmd) init(c *cli.Context) error { return errors.New("Function file already exists, aborting") } } - err = a.BuildFuncFileV20180708(c, dir) // TODO: Return LangHelper here, then don't need to refind the helper in generateBoilerplate() below - if err != nil { - return err + + if codeOnlyInit { + err = a.buildCodeOnlyFuncFile(c, runtime) + if err != nil { + return err + } + } else { + err = a.BuildFuncFileV20180708(c, dir) // TODO: Return LangHelper here, then don't need to refind the helper in generateBoilerplate() below + if err != nil { + return err + } + } + + if precheckedRuntime == "" { + if err := a.precheckTooling(); err != nil { + return err + } } a.ff.Schema_version = common.LatestYamlVersion @@ -342,7 +401,12 @@ func (a *initFnCmd) init(c *cli.Context) error { } else { // TODO: why don't we treat "docker" runtime as just another language helper? // Then can get rid of several Docker specific if/else's like this one. - if runtimeSpecified && runtime != common.FuncfileDockerRuntime { + if codeOnlyInit { + err := a.generateCodeOnlyBoilerplate(dir, runtime) + if err != nil { + return err + } + } else if runtimeSpecified && runtime != common.FuncfileDockerRuntime { err := a.generateBoilerplate(dir, runtime) if err != nil { return err @@ -358,6 +422,44 @@ func (a *initFnCmd) init(c *cli.Context) error { return nil } +func (a *initFnCmd) buildCodeOnlyFuncFile(c *cli.Context, runtime string) error { + a.ff.Version = c.String("version") + if err := ValidateFuncName(a.ff.Name); err != nil { + return err + } + + runtimeName := runtime + if runtimeName == "" { + runtimeName = a.runtimeName + } + if runtimeName == "" { + return fmt.Errorf("Code-only init requires --runtime-name or --runtime") + } + + a.setupCodeOnlyFuncFile(runtimeName) + return nil +} + +func (a *initFnCmd) setupCodeOnlyFuncFile(runtimeName string) { + a.ff.Code_only = true + a.ff.Runtime = "" + a.ff.Build_image = "" + a.ff.Run_image = "" + a.ff.Cmd = "" + a.ff.Entrypoint = "" + a.ff.Build = nil + + a.ff.Runtime_config = &common.RuntimeConfigV20180708{ + Type: a.runtimeConfigType, + Runtime_name: runtimeName, + Runtime_version_id: "", + } + + if requiresCodeOnlyHandler(runtimeName) { + a.ff.Handler = defaultCodeOnlyHandler(runtimeName) + } +} + func (a *initFnCmd) doInitImage(initImage string, c *cli.Context) error { err := common.RunInitImage(initImage, a.ff.Name) if err != nil { @@ -392,6 +494,279 @@ func (a *initFnCmd) generateBoilerplate(path, runtime string) error { return nil } +func (a *initFnCmd) generateCodeOnlyBoilerplate(path, runtime string) error { + runtimeName := runtime + if runtimeName == "" { + runtimeName = a.runtimeName + } + if runtimeName == "" && a.ff.Runtime_config != nil { + runtimeName = a.ff.Runtime_config.Runtime_name + } + if runtimeName == "" { + return nil + } + + baseRuntime := baseRuntimeFromName(runtimeName) + switch { + case strings.HasPrefix(baseRuntime, "python"): + generated, err := createPythonCodeOnlyBoilerplate(path) + if err != nil { + return err + } + if generated { + fmt.Println("Function boilerplate generated.") + } + case strings.HasPrefix(baseRuntime, "node"): + generated, err := createNodeCodeOnlyBoilerplate(path) + if err != nil { + return err + } + if generated { + fmt.Println("Function boilerplate generated.") + } + case strings.HasPrefix(baseRuntime, "java"): + generated, err := createJavaCodeOnlyBoilerplate(path, runtimeName) + if err != nil { + return err + } + if generated { + fmt.Println("Function boilerplate generated.") + } + case strings.HasPrefix(baseRuntime, "go"): + generated, err := createGoCodeOnlyBoilerplate(path, runtimeName) + if err != nil { + return err + } + if generated { + fmt.Println("Function boilerplate generated.") + } + default: + helper := langs.GetLangHelper(runtime) + if helper == nil { + helper = langs.GetLangHelper(baseRuntime) + } + if helper != nil && helper.HasBoilerplate() { + if err := helper.GenerateBoilerplate(path); err != nil { + if err == langs.ErrBoilerplateExists { + return nil + } + return err + } + fmt.Println("Function boilerplate generated.") + } + } + + return nil +} + +func (a *initFnCmd) precheckTooling() error { + runtime := a.ff.Runtime + if a.ff.Runtime_config != nil && a.ff.Runtime_config.Runtime_name != "" { + runtime = a.ff.Runtime_config.Runtime_name + } + + return precheckToolingForRuntime(runtime) +} + + +func precheckToolingForRuntime(runtime string) error { + runtimeName, requiredTool, candidates := runtimeToolRequirement(runtime) + if requiredTool == "" { + return nil + } + + for _, candidate := range candidates { + if _, err := exec.LookPath(candidate); err == nil { + return nil + } + } + + return fmt.Errorf("%s runtime selected, but %s was not found in PATH. Install %s and rerun `fn init`, or choose a different runtime", runtimeName, requiredTool, requiredTool) +} + +func runtimeToolRequirement(runtime string) (string, string, []string) { + baseRuntime := baseRuntimeFromName(runtime) + switch { + case strings.HasPrefix(baseRuntime, "java"): + return runtimeDisplayName(runtime), "Maven", []string{"mvn"} + case strings.HasPrefix(baseRuntime, "python"): + return runtimeDisplayName(runtime), "Python", []string{"python3", "python"} + case strings.HasPrefix(baseRuntime, "go"): + return runtimeDisplayName(runtime), "Go", []string{"go"} + case strings.HasPrefix(baseRuntime, "node") || strings.HasPrefix(baseRuntime, "javascript"): + return runtimeDisplayName(runtime), "Node.js", []string{"node"} + default: + return "", "", nil + } +} + +func runtimeDisplayName(runtime string) string { + baseRuntime := baseRuntimeFromName(runtime) + switch { + case strings.HasPrefix(baseRuntime, "java"): + return "Java" + case strings.HasPrefix(baseRuntime, "python"): + return "Python" + case strings.HasPrefix(baseRuntime, "go"): + return "Go" + case strings.HasPrefix(baseRuntime, "node") || strings.HasPrefix(baseRuntime, "javascript"): + return "Node.js" + default: + if runtime == "" { + return "Selected" + } + return strings.Title(baseRuntime) + } +} + +func requiresCodeOnlyHandler(runtime string) bool { + baseRuntime := baseRuntimeFromName(runtime) + return strings.HasPrefix(baseRuntime, "java") || + strings.HasPrefix(baseRuntime, "python") || + strings.HasPrefix(baseRuntime, "node") +} + +func defaultCodeOnlyHandler(runtime string) string { + baseRuntime := baseRuntimeFromName(runtime) + switch { + case strings.HasPrefix(baseRuntime, "java"): + return "com.example.fn.HelloFunction::handleRequest" + case strings.HasPrefix(baseRuntime, "python"): + return "hello_world.handler" + case strings.HasPrefix(baseRuntime, "node"): + return "func.js" + default: + return "handler" + } +} + +func baseRuntimeFromName(runtimeName string) string { + lower := strings.ToLower(strings.TrimSpace(runtimeName)) + if lower == "ol9" || lower == "ol8" { + // Managed Go runtimes may be exposed as generic OS runtime names. + return "go" + } + for _, sep := range []string{".", "-"} { + if idx := strings.Index(lower, sep); idx != -1 { + lower = lower[:idx] + break + } + } + return lower +} + +func createPythonCodeOnlyBoilerplate(path string) (bool, error) { + functionDir := filepath.Join(path, "function") + if err := os.MkdirAll(functionDir, 0755); err != nil { + return false, err + } + filePath := filepath.Join(functionDir, "hello_world.py") + if _, err := os.Stat(filePath); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, err + } + + content := "import json\n\n" + + "def handler(context, data=None):\n" + + " name = \"World\"\n" + + " if data:\n" + + " try:\n" + + " text = data.decode() if isinstance(data, (bytes, bytearray)) else str(data)\n" + + " payload = json.loads(text)\n" + + " name = payload.get(\"name\", name)\n" + + " except Exception:\n" + + " pass\n" + + " return {\n" + + " \"message\": f\"Hello {name}\"\n" + + " }\n" + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return false, err + } + + return true, nil +} + +func createNodeCodeOnlyBoilerplate(path string) (bool, error) { + functionDir := filepath.Join(path, "function") + if err := os.MkdirAll(functionDir, 0755); err != nil { + return false, err + } + packageJSONPath := filepath.Join(path, "package.json") + if _, err := os.Stat(packageJSONPath); os.IsNotExist(err) { + pkg := "{\n" + + " \"name\": \"code-only-node-fn\",\n" + + " \"version\": \"1.0.0\",\n" + + " \"main\": \"function/func.js\",\n" + + " \"dependencies\": {\n" + + " \"@fnproject/fdk\": \"latest\"\n" + + " }\n" + + "}\n" + if err := os.WriteFile(packageJSONPath, []byte(pkg), 0644); err != nil { + return false, err + } + } else if err != nil { + return false, err + } + + filePath := filepath.Join(functionDir, "func.js") + if _, err := os.Stat(filePath); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, err + } + + content := "const fdk = require('@fnproject/fdk');\n\n" + + "fdk.handle(function(input){\n" + + " let name = 'World';\n" + + " if (input && input.name) {\n" + + " name = input.name;\n" + + " }\n" + + " return { message: 'Hello ' + name };\n" + + "});\n" + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return false, err + } + + return true, nil +} + +func createJavaCodeOnlyBoilerplate(path, runtimeName string) (bool, error) { + helper := langs.GetLangHelper(runtimeName) + if helper == nil { + helper = langs.GetLangHelper(baseRuntimeFromName(runtimeName)) + } + if helper == nil || !helper.HasBoilerplate() { + return false, nil + } + if err := helper.GenerateBoilerplate(path); err != nil { + if err == langs.ErrBoilerplateExists { + return false, nil + } + return false, err + } + return true, nil +} + +func createGoCodeOnlyBoilerplate(path, runtimeName string) (bool, error) { + helper := langs.GetLangHelper(runtimeName) + if helper == nil { + helper = langs.GetLangHelper(baseRuntimeFromName(runtimeName)) + } + if helper == nil || !helper.HasBoilerplate() { + return false, nil + } + if err := helper.GenerateBoilerplate(path); err != nil { + if err == langs.ErrBoilerplateExists { + return false, nil + } + return false, err + } + return true, nil +} + func (a *initFnCmd) bindFn(fn *modelsV2.Fn) { ff := a.ff if fn.Memory > 0 { diff --git a/commands/push.go b/commands/push.go index 0a3efc8d..3ac48800 100644 --- a/commands/push.go +++ b/commands/push.go @@ -17,10 +17,20 @@ package commands import ( + "context" "errors" "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "github.com/fnproject/cli/client" "github.com/fnproject/cli/common" + "github.com/fnproject/cli/config" + fnprovider "github.com/fnproject/fn_go/provider/oracle" + "github.com/oracle/oci-go-sdk/v65/objectstorage" + "github.com/spf13/viper" "github.com/urfave/cli" ) @@ -75,6 +85,15 @@ func (p *pushcmd) push(c *cli.Context) error { return err } + if ff.Code_only { + objectName, err := pushCodeOnlyArchive(ff) + if err != nil { + return err + } + fmt.Printf("Code-only archive uploaded successfully as %s\n", objectName) + return nil + } + fmt.Println("pushing", ff.ImageNameV20180708()) if err := common.PushV20180708(ff); err != nil { @@ -103,3 +122,83 @@ func (p *pushcmd) push(c *cli.Context) error { fmt.Printf("Function %v pushed successfully to the registry.\n", ff.ImageName()) return nil } + +func pushCodeOnlyArchive(ff *common.FuncFileV20180708) (string, error) { + contextName := viper.GetString(config.CurrentContext) + contextPath := filepath.Join(config.GetHomeDir(), ".fn", "contexts", contextName+".yaml") + ctxFile, err := config.NewContextFile(contextPath) + if err != nil { + return "", err + } + if strings.TrimSpace(ctxFile.ObjectStorageBucketName) == "" || strings.TrimSpace(ctxFile.ObjectStorageNamespace) == "" { + return "", errors.New("code-only Object Storage target is not configured in the current context") + } + archivePath := fmt.Sprintf("%s.%s.zip", ff.Name, ff.Version) + if _, err := os.Stat(archivePath); err != nil { + return "", fmt.Errorf("built archive not found at %s: %w", archivePath, err) + } + provider, err := client.CurrentProvider() + if err != nil { + return "", err + } + ociProvider, ok := provider.(*fnprovider.OracleProvider) + if !ok || ociProvider == nil { + return "", errors.New("code-only archive push requires an oracle provider") + } + client, err := objectstorage.NewObjectStorageClientWithConfigurationProvider(ociProvider.ConfigurationProvider) + if err != nil { + return "", err + } + region, err := ociProvider.ConfigurationProvider.Region() + if err == nil { + client.SetRegion(region) + } + if client.Host == "" || strings.Contains(client.Host, "objectstorage..") { + if ociProvider.FnApiUrl != nil { + objectStorageHost, hostErr := objectStorageHostFromFnAPIURL(ociProvider.FnApiUrl) + if hostErr != nil { + return "", hostErr + } + client.Host = objectStorageHost + } + } + file, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "", err + } + objectName := archivePath + contentLength := info.Size() + request := objectstorage.PutObjectRequest{ + NamespaceName: &ctxFile.ObjectStorageNamespace, + BucketName: &ctxFile.ObjectStorageBucketName, + ObjectName: &objectName, + PutObjectBody: file, + ContentLength: &contentLength, + } + _, err = client.PutObject(context.Background(), request) + if err != nil { + return "", fmt.Errorf("failed to upload archive to Object Storage: %w", err) + } + return objectName, nil +} + +func objectStorageHostFromFnAPIURL(fnAPIURL *url.URL) (string, error) { + if fnAPIURL == nil { + return "", errors.New("unable to derive Object Storage host from nil Functions API URL") + } + hostParts := strings.Split(fnAPIURL.Host, ".") + if len(hostParts) < 4 { + return "", fmt.Errorf("unable to derive Object Storage host from Functions API host %s", fnAPIURL.Host) + } + region := hostParts[1] + domain := strings.Join(hostParts[3:], ".") + if region == "" || domain == "" { + return "", fmt.Errorf("unable to derive Object Storage host from Functions API host %s", fnAPIURL.Host) + } + return fmt.Sprintf("https://objectstorage.%s.%s", region, domain), nil +} diff --git a/commands/work_request.go b/commands/work_request.go new file mode 100644 index 00000000..bb8e25f4 --- /dev/null +++ b/commands/work_request.go @@ -0,0 +1,282 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "strings" + + cliClient "github.com/fnproject/cli/client" + appobj "github.com/fnproject/cli/objects/app" + fnobj "github.com/fnproject/cli/objects/fn" + v2Client "github.com/fnproject/fn_go/clientv2" + fnprovider "github.com/fnproject/fn_go/provider/oracle" + ociFunctions "github.com/oracle/oci-go-sdk/v65/functions" + "github.com/urfave/cli" +) + +type workRequestCmd struct { + provider *fnprovider.OracleProvider + clientV2 *v2Client.Fn +} + +type workRequestStatusView struct { + WorkRequestID string + FunctionName string + FunctionID string + Operation string + Status string + Error string + RecentLogs []string +} + +func WorkRequestCommand() cli.Command { + cmd := &workRequestCmd{} + return cli.Command{ + Name: "work-request", + Usage: "\tInspect Functions work requests", + Aliases: []string{"wr"}, + Category: "MANAGEMENT COMMAND", + Before: func(c *cli.Context) error { + provider, err := cliClient.CurrentProvider() + if err != nil { + return err + } + oracleProvider, ok := provider.(*fnprovider.OracleProvider) + if !ok || oracleProvider == nil { + return errors.New("work-request commands require an oracle provider") + } + cmd.provider = oracleProvider + cmd.clientV2 = provider.APIClientv2() + return nil + }, + Subcommands: []cli.Command{ + { + Name: "status", + Usage: "\tShow consolidated status, error, and recent logs for a work request or function", + ArgsUsage: "", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "app", + Usage: "App name when resolving the latest work request for a function name", + }, + cli.IntFlag{ + Name: "log-limit", + Usage: "Number of recent work request logs to show", + Value: 5, + }, + }, + Action: cmd.status, + }, + }, + } +} + +func (w *workRequestCmd) status(c *cli.Context) error { + target := strings.TrimSpace(c.Args().First()) + if target == "" { + return errors.New("work request id or function name is required") + } + logLimit := c.Int("log-limit") + if logLimit <= 0 { + logLimit = 5 + } + workRequestID, functionHint, err := w.resolveWorkRequestTarget(c, target) + if err != nil { + return err + } + view, err := w.loadWorkRequestStatus(workRequestID, functionHint, logLimit) + if err != nil { + return err + } + printWorkRequestStatusView(view) + return nil +} + +func (w *workRequestCmd) resolveWorkRequestTarget(c *cli.Context, target string) (string, string, error) { + if isWorkRequestID(target) { + return target, "", nil + } + appName := strings.TrimSpace(c.String("app")) + if appName == "" { + return "", "", errors.New("--app is required when resolving a function name to its latest work request") + } + appRes, err := appobj.GetAppByName(w.clientV2, appName) + if err != nil { + return "", "", err + } + fnRes, err := fnobj.GetFnByName(w.clientV2, appRes.ID, target) + if err != nil { + return "", "", err + } + wrClient, err := buildCLIWorkRequestClient(w.provider) + if err != nil { + return "", "", err + } + limit := 1 + resp, err := wrClient.ListWorkRequests(context.Background(), ociFunctions.ListWorkRequestsRequest{ + CompartmentId: &w.provider.CompartmentID, + ResourceId: &fnRes.ID, + Limit: &limit, + SortBy: ociFunctions.ListWorkRequestsSortByTimeaccepted, + SortOrder: ociFunctions.ListWorkRequestsSortOrderDesc, + }) + if err != nil { + return "", "", err + } + if len(resp.Items) == 0 || resp.Items[0].Id == nil { + return "", "", fmt.Errorf("no work requests found for function %s", target) + } + return *resp.Items[0].Id, fnRes.Name, nil +} + +func isWorkRequestID(value string) bool { + lower := strings.ToLower(strings.TrimSpace(value)) + return strings.HasPrefix(lower, "ocid1.") && strings.Contains(lower, "workrequest.") +} + +func (w *workRequestCmd) loadWorkRequestStatus(workRequestID, functionHint string, logLimit int) (*workRequestStatusView, error) { + wrClient, err := buildCLIWorkRequestClient(w.provider) + if err != nil { + return nil, err + } + resp, err := wrClient.GetWorkRequest(context.Background(), ociFunctions.GetWorkRequestRequest{WorkRequestId: &workRequestID}) + if err != nil { + return nil, err + } + view := &workRequestStatusView{ + WorkRequestID: workRequestID, + Operation: simplifyWorkRequestOperation(resp.OperationType), + Status: string(resp.Status), + FunctionName: functionHint, + } + functionID := extractFunctionResourceID(resp.WorkRequest) + view.FunctionID = functionID + if view.FunctionName == "" && functionID != "" { + if name, err := lookupFunctionName(w.provider, functionID); err == nil { + view.FunctionName = name + } + } + if view.FunctionName == "" && functionID != "" { + view.FunctionName = functionID + } + + if errResp, err := wrClient.ListWorkRequestErrors(context.Background(), ociFunctions.ListWorkRequestErrorsRequest{ + WorkRequestId: &workRequestID, + Limit: intPointer(1), + SortBy: ociFunctions.ListWorkRequestErrorsSortByTimestamp, + SortOrder: ociFunctions.ListWorkRequestErrorsSortOrderDesc, + }); err == nil && len(errResp.Items) > 0 && errResp.Items[0].Message != nil { + view.Error = *errResp.Items[0].Message + } + + logsResp, err := wrClient.ListWorkRequestLogs(context.Background(), ociFunctions.ListWorkRequestLogsRequest{ + WorkRequestId: &workRequestID, + Limit: intPointer(logLimit), + SortBy: ociFunctions.ListWorkRequestLogsSortByTimestamp, + SortOrder: ociFunctions.ListWorkRequestLogsSortOrderDesc, + }) + if err == nil && len(logsResp.Items) > 0 { + for i := len(logsResp.Items) - 1; i >= 0; i-- { + if logsResp.Items[i].Message != nil { + view.RecentLogs = append(view.RecentLogs, *logsResp.Items[i].Message) + } + } + } + + return view, nil +} + +func buildCLIWorkRequestClient(provider *fnprovider.OracleProvider) (*ociFunctions.WorkRequestManagementClient, error) { + client, err := ociFunctions.NewWorkRequestManagementClientWithConfigurationProvider(provider.ConfigurationProvider) + if err != nil { + return nil, err + } + if provider.FnApiUrl != nil { + client.Host = provider.FnApiUrl.String() + } else if region := getRegion(provider); region != "" { + client.SetRegion(region) + } + return &client, nil +} + +func buildCLIFunctionsManagementClient(provider *fnprovider.OracleProvider) (*ociFunctions.FunctionsManagementClient, error) { + client, err := ociFunctions.NewFunctionsManagementClientWithConfigurationProvider(provider.ConfigurationProvider) + if err != nil { + return nil, err + } + if provider.FnApiUrl != nil { + client.Host = provider.FnApiUrl.String() + } else if region := getRegion(provider); region != "" { + client.SetRegion(region) + } + return &client, nil +} + +func lookupFunctionName(provider *fnprovider.OracleProvider, functionID string) (string, error) { + client, err := buildCLIFunctionsManagementClient(provider) + if err != nil { + return "", err + } + resp, err := client.GetFunction(context.Background(), ociFunctions.GetFunctionRequest{FunctionId: &functionID}) + if err != nil { + return "", err + } + if resp.DisplayName == nil { + return "", errors.New("function display name not found") + } + return *resp.DisplayName, nil +} + +func extractFunctionResourceID(workRequest ociFunctions.WorkRequest) string { + for _, resource := range workRequest.Resources { + entityType := "" + if resource.EntityType != nil { + entityType = strings.ToLower(strings.TrimSpace(*resource.EntityType)) + } + if strings.Contains(entityType, "function") && resource.Identifier != nil { + return *resource.Identifier + } + } + for _, resource := range workRequest.Resources { + if resource.Identifier != nil { + return *resource.Identifier + } + } + return "" +} + +func simplifyWorkRequestOperation(operation ociFunctions.OperationTypeEnum) string { + value := strings.TrimSpace(string(operation)) + if value == "" { + return "UNKNOWN" + } + if idx := strings.Index(value, "_"); idx != -1 { + return value[:idx] + } + return value +} + +func printWorkRequestStatusView(view *workRequestStatusView) { + fmt.Printf("Work Request: %s\n", view.WorkRequestID) + if view.FunctionName != "" { + fmt.Printf("Function: %s\n", view.FunctionName) + } + if view.Operation != "" { + fmt.Printf("Operation: %s\n", view.Operation) + } + fmt.Printf("Status: %s\n", view.Status) + if view.Error != "" { + fmt.Printf("Error: %s\n", view.Error) + } + if len(view.RecentLogs) > 0 { + fmt.Println("Recent Logs:") + for _, entry := range view.RecentLogs { + fmt.Printf("- %s\n", entry) + } + } +} + +func intPointer(v int) *int { + return &v +} \ No newline at end of file diff --git a/commands/work_request_test.go b/commands/work_request_test.go new file mode 100644 index 00000000..76fb1c74 --- /dev/null +++ b/commands/work_request_test.go @@ -0,0 +1,171 @@ +package commands + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + ociFunctions "github.com/oracle/oci-go-sdk/v65/functions" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdout pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + outC := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + outC <- buf.String() + }() + + fn() + _ = w.Close() + output := <-outC + _ = r.Close() + return output +} + +func TestIsWorkRequestID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "valid functions work request ocid", input: "ocid1.functionsworkrequest.oc1..exampleuniqueID", want: true}, + {name: "valid mixed case trimmed", input: " OCID1.FunctionsWorkRequest.oc1..exampleuniqueID ", want: true}, + {name: "normal function name", input: "hello", want: false}, + {name: "plain ocid without workrequest", input: "ocid1.fnfunc.oc1..exampleuniqueID", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isWorkRequestID(tc.input) + if got != tc.want { + t.Fatalf("isWorkRequestID(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestSimplifyWorkRequestOperation(t *testing.T) { + tests := []struct { + name string + input ociFunctions.OperationTypeEnum + want string + }{ + {name: "create function", input: ociFunctions.OperationTypeEnum("CREATE_FUNCTION"), want: "CREATE"}, + {name: "update function", input: ociFunctions.OperationTypeEnum("UPDATE_FUNCTION"), want: "UPDATE"}, + {name: "blank", input: ociFunctions.OperationTypeEnum(""), want: "UNKNOWN"}, + {name: "single token", input: ociFunctions.OperationTypeEnum("DELETE"), want: "DELETE"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := simplifyWorkRequestOperation(tc.input) + if got != tc.want { + t.Fatalf("simplifyWorkRequestOperation(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestExtractFunctionResourceID(t *testing.T) { + functionEntityType := "function" + functionID := "ocid1.fnfunc.oc1..exampleuniqueID" + otherEntityType := "application" + otherID := "ocid1.fnapp.oc1..otherID" + + t.Run("prefers function resource identifiers", func(t *testing.T) { + wr := ociFunctions.WorkRequest{ + Resources: []ociFunctions.WorkRequestResource{ + {EntityType: &otherEntityType, Identifier: &otherID}, + {EntityType: &functionEntityType, Identifier: &functionID}, + }, + } + got := extractFunctionResourceID(wr) + if got != functionID { + t.Fatalf("extractFunctionResourceID() = %q, want %q", got, functionID) + } + }) + + t.Run("falls back to first identifier if no function entity exists", func(t *testing.T) { + wr := ociFunctions.WorkRequest{ + Resources: []ociFunctions.WorkRequestResource{ + {EntityType: &otherEntityType, Identifier: &otherID}, + }, + } + got := extractFunctionResourceID(wr) + if got != otherID { + t.Fatalf("extractFunctionResourceID() = %q, want %q", got, otherID) + } + }) + + t.Run("returns empty when no identifiers exist", func(t *testing.T) { + wr := ociFunctions.WorkRequest{} + got := extractFunctionResourceID(wr) + if got != "" { + t.Fatalf("extractFunctionResourceID() = %q, want empty string", got) + } + }) +} + +func TestPrintWorkRequestStatusView(t *testing.T) { + view := &workRequestStatusView{ + WorkRequestID: "ocid1.functionsworkrequest.oc1..exampleuniqueID", + FunctionName: "hello", + Operation: "CREATE", + Status: "SUCCEEDED", + Error: "", + RecentLogs: []string{"accepted", "completed"}, + } + + output := captureStdout(t, func() { + printWorkRequestStatusView(view) + }) + + checks := []string{ + "Work Request: ocid1.functionsworkrequest.oc1..exampleuniqueID", + "Function: hello", + "Operation: CREATE", + "Status: SUCCEEDED", + "Recent Logs:", + "- accepted", + "- completed", + } + + for _, check := range checks { + if !strings.Contains(output, check) { + t.Fatalf("expected output to contain %q, got: %s", check, output) + } + } +} + +func TestWorkRequestCommandRegistration(t *testing.T) { + cmd := WorkRequestCommand() + if cmd.Name != "work-request" { + t.Fatalf("command name = %q, want %q", cmd.Name, "work-request") + } + if len(cmd.Subcommands) == 0 { + t.Fatal("expected work-request command to have subcommands") + } + if cmd.Subcommands[0].Name != "status" { + t.Fatalf("first subcommand name = %q, want %q", cmd.Subcommands[0].Name, "status") + } + + registered, ok := Commands["work-request"] + if !ok { + t.Fatal("expected work-request command to be registered in Commands map") + } + if registered.Name != "work-request" { + t.Fatalf("registered command name = %q, want %q", registered.Name, "work-request") + } +} \ No newline at end of file diff --git a/common/common.go b/common/common.go index c3ece871..a126a546 100644 --- a/common/common.go +++ b/common/common.go @@ -222,6 +222,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) @@ -367,6 +370,9 @@ func containerEngineBuild(verbose bool, fpath string, ff *FuncFile, buildArgs [] } func containerEngineBuildV20180708(verbose bool, fpath string, ff *FuncFileV20180708, buildArgs []string, noCache bool, shape string, localDebug bool) error { + if ff.Code_only { + return nil + } containerEngineType, err := GetContainerEngineType() if err != nil { return err diff --git a/common/funcfile.go b/common/funcfile.go index 39581135..31cb598c 100644 --- a/common/funcfile.go +++ b/common/funcfile.go @@ -95,10 +95,10 @@ type OCIPBFSourceConfig struct { // OCIFunctionDeployConfig stores OCI-specific deploy configuration for a function. type OCIFunctionDeployConfig struct { ProvisionedConcurrency *OCIProvisionedConcurrencyConfig `yaml:"provisioned_concurrency,omitempty" json:"provisioned_concurrency,omitempty"` - DetachedMode *OCIDetachedModeConfig `yaml:"detached_mode,omitempty" json:"detached_mode,omitempty"` - PBF *OCIPBFSourceConfig `yaml:"pbf,omitempty" json:"pbf,omitempty"` - FreeformTags map[string]string `yaml:"freeform_tags,omitempty" json:"freeform_tags,omitempty"` - DefinedTags OCIDefinedTags `yaml:"defined_tags,omitempty" json:"defined_tags,omitempty"` + DetachedMode *OCIDetachedModeConfig `yaml:"detached_mode,omitempty" json:"detached_mode,omitempty"` + PBF *OCIPBFSourceConfig `yaml:"pbf,omitempty" json:"pbf,omitempty"` + FreeformTags map[string]string `yaml:"freeform_tags,omitempty" json:"freeform_tags,omitempty"` + DefinedTags OCIDefinedTags `yaml:"defined_tags,omitempty" json:"defined_tags,omitempty"` } // FuncDeployConfig stores deploy-time configuration sections in func.yaml. @@ -154,6 +154,7 @@ type FuncFileV20180708 struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` Version string `yaml:"version,omitempty" json:"version,omitempty"` + Code_only bool `yaml:"code_only,omitempty" json:"code_only,omitempty"` Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"` Build_image string `yaml:"build_image,omitempty" json:"build_image,omitempty"` // Image to use as base for building Run_image string `yaml:"run_image,omitempty" json:"run_image,omitempty"` // Image to use for running @@ -173,10 +174,19 @@ type FuncFileV20180708 struct { Build []string `yaml:"build,omitempty" json:"build,omitempty"` + Runtime_config *RuntimeConfigV20180708 `yaml:"runtime_config,omitempty" json:"runtime_config,omitempty"` + Handler string `yaml:"handler,omitempty" json:"handler,omitempty"` + Expects Expects `yaml:"expects,omitempty" json:"expects,omitempty"` Triggers []Trigger `yaml:"triggers,omitempty" json:"triggers,omitempty"` } +type RuntimeConfigV20180708 struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Runtime_name string `yaml:"runtime_name,omitempty" json:"runtime_name,omitempty"` + Runtime_version_id string `yaml:"runtime_version_id,omitempty" json:"runtime_version_id,omitempty"` +} + // Trigger represents a trigger for a FuncFileV20180708 type Trigger struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` @@ -351,7 +361,7 @@ func ParseFuncFileV20180708(path string) (ff *FuncFileV20180708, err error) { return nil, errUnexpectedFileFormat } - if err == nil && ff.Schema_version != V20180708 { + if err == nil && ff.Schema_version != V20180708 && ff.Schema_version != V20260325 { // todo: we should maybe not assume this, but it's more useful than saying 'version mismatch' for users... return nil, fmt.Errorf("unsupported func.yaml version, please use the migrate command to update your function metadata") } @@ -436,11 +446,15 @@ func (ff *FuncFileV20180708) HasOCIManagedFunctionSettings() bool { } oci := ff.Deploy.OCI - if oci.ProvisionedConcurrency != nil && (oci.ProvisionedConcurrency.Strategy != "" || oci.ProvisionedConcurrency.Count != nil) { - return true + if oci.ProvisionedConcurrency != nil { + if oci.ProvisionedConcurrency.Strategy != "" || oci.ProvisionedConcurrency.Count != nil { + return true + } } - if oci.DetachedMode != nil && (oci.DetachedMode.Timeout != "" || oci.DetachedMode.OnSuccess != nil || oci.DetachedMode.OnFailure != nil) { - return true + if oci.DetachedMode != nil { + if oci.DetachedMode.Timeout != "" || oci.DetachedMode.OnSuccess != nil || oci.DetachedMode.OnFailure != nil { + return true + } } if len(oci.FreeformTags) > 0 || len(oci.DefinedTags) > 0 { return true diff --git a/common/schema.go b/common/schema.go index 38ab1fe8..4e180e03 100644 --- a/common/schema.go +++ b/common/schema.go @@ -26,6 +26,7 @@ import ( const ( V20180708 = 20180708 + V20260325 = 20260325 LatestYamlVersion = V20180708 ) diff --git a/config/context_file.go b/config/context_file.go index 3de3de9c..b3028019 100644 --- a/config/context_file.go +++ b/config/context_file.go @@ -27,6 +27,8 @@ type ContextFile struct { ContextProvider string `yaml:"provider" json:"provider"` EnvFnAPIURL string `yaml:"api-url" json:"apiUrl"` EnvFnRegistry string `yaml:"registry" json:"registry"` + ObjectStorageBucketName string `yaml:"object_storage_bucket_name" json:"objectStorageBucketName"` + ObjectStorageNamespace string `yaml:"namespace" json:"namespace"` } // NewContextFile creates a new instance of the context YAML file diff --git a/config/context_file_test.go b/config/context_file_test.go index d46af45c..0a537bc7 100644 --- a/config/context_file_test.go +++ b/config/context_file_test.go @@ -60,6 +60,14 @@ func TestContextFile(t *testing.T) { if actual.EnvFnRegistry != tst.expected.EnvFnRegistry { t.Fatalf("EnvFnRegistry: expected '%s', but got '%s'", tst.expected.EnvFnRegistry, actual.EnvFnRegistry) } + + if actual.ObjectStorageBucketName != tst.expected.ObjectStorageBucketName { + t.Fatalf("ObjectStorageBucketName: expected '%s', but got '%s'", tst.expected.ObjectStorageBucketName, actual.ObjectStorageBucketName) + } + + if actual.ObjectStorageNamespace != tst.expected.ObjectStorageNamespace { + t.Fatalf("ObjectStorageNamespace: expected '%s', but got '%s'", tst.expected.ObjectStorageNamespace, actual.ObjectStorageNamespace) + } }) } } @@ -73,11 +81,15 @@ func prepareTestFiles(folder string) ([]testCase, error) { contents: ` api-url: http://localhost:8080 provider: default -registry: "someregistry"`, +registry: "someregistry" +object_storage_bucket_name: "code-only-test-files" +namespace: "oracledevnamespace"`, expected: &ContextFile{ - ContextProvider: "default", - EnvFnAPIURL: "http://localhost:8080", - EnvFnRegistry: "someregistry", + ContextProvider: "default", + EnvFnAPIURL: "http://localhost:8080", + EnvFnRegistry: "someregistry", + ObjectStorageBucketName: "code-only-test-files", + ObjectStorageNamespace: "oracledevnamespace", }, }, } diff --git a/langs/go.go b/langs/go.go index 0b47ceb3..9abc5e67 100644 --- a/langs/go.go +++ b/langs/go.go @@ -133,6 +133,9 @@ func (lh *GoLangHelper) GenerateBoilerplate(path string) error { } modFile := "go.mod" fdkVersion, _ := lh.GetLatestFDKVersion() + if strings.TrimSpace(fdkVersion) == "" { + fdkVersion = defaultGoFDKVersion + } if err := ioutil.WriteFile(modFile, []byte(fmt.Sprintf(modBoilerplate, fdkVersion)), os.FileMode(0644)); err != nil { return err } @@ -181,8 +184,11 @@ func myHandler(ctx context.Context, in io.Reader, out io.Writer) { modBoilerplate = ` module func +go 1.23 + require github.com/fnproject/fdk-go %s ` + defaultGoFDKVersion = "v0.1.9" ) func (h *GoLangHelper) FixImagesOnInit() bool { diff --git a/objects/fn/fns.go b/objects/fn/fns.go index 7c55ec46..aa2e6bb0 100644 --- a/objects/fn/fns.go +++ b/objects/fn/fns.go @@ -214,6 +214,50 @@ var FnFlags = []cli.Flag{ Name: "image", Usage: "Function image", }, + cli.BoolFlag{ + Name: "code-only", + Usage: "Create a code-only function using archive source details and runtime configuration", + }, + cli.StringFlag{ + Name: "source-type", + Usage: "Code-only source type: direct or object-storage", + }, + cli.StringFlag{ + Name: "source-file", + Usage: "Path to a zip archive for direct code-only source upload", + }, + cli.StringFlag{ + Name: "bucket-name", + Usage: "Object Storage bucket name for code-only source", + }, + cli.StringFlag{ + Name: "namespace", + Usage: "Object Storage namespace for code-only source", + }, + cli.StringFlag{ + Name: "object-name", + Usage: "Object Storage object name for code-only source", + }, + cli.StringFlag{ + Name: "object-version-id", + Usage: "Object Storage object version id for code-only source", + }, + cli.StringFlag{ + Name: "runtime-config-type", + Usage: "Runtime configuration type for code-only creation: function-update or manual", + }, + cli.StringFlag{ + Name: "runtime-name", + Usage: "Runtime name for code-only creation", + }, + cli.StringFlag{ + Name: "runtime-version-id", + Usage: "Runtime version OCID for manual runtime configuration", + }, + cli.StringFlag{ + Name: "handler", + Usage: "Handler for code-only archive functions", + }, cli.StringFlag{ Name: "pbf", Usage: "Create the function from a Pre-Built Function listing OCID", @@ -266,6 +310,74 @@ var updateFnFlags = append(append([]cli.Flag{}, FnFlags...), }, ) +type codeOnlyUpdateOptions struct { + codeOnly bool + sourceType string + sourceFile string + bucketName string + namespace string + objectName string + objectVersionID string + runtimeConfigType string + runtimeName string + runtimeVersionID string + handler string +} + +type codeOnlyCreateOptions struct { + codeOnly bool + sourceType string + sourceFile string + bucketName string + namespace string + objectName string + objectVersionID string + runtimeConfigType string + runtimeName string + runtimeVersionID string + handler string +} + +func readCodeOnlyCreateOptions(c *cli.Context) codeOnlyCreateOptions { + return codeOnlyCreateOptions{ + codeOnly: c.Bool("code-only"), + sourceType: strings.TrimSpace(c.String("source-type")), + sourceFile: strings.TrimSpace(c.String("source-file")), + bucketName: strings.TrimSpace(c.String("bucket-name")), + namespace: strings.TrimSpace(c.String("namespace")), + objectName: strings.TrimSpace(c.String("object-name")), + objectVersionID: strings.TrimSpace(c.String("object-version-id")), + runtimeConfigType: strings.TrimSpace(c.String("runtime-config-type")), + runtimeName: strings.TrimSpace(c.String("runtime-name")), + runtimeVersionID: strings.TrimSpace(c.String("runtime-version-id")), + handler: strings.TrimSpace(c.String("handler")), + } +} + +func (o codeOnlyCreateOptions) enabled() bool { + return o.codeOnly || o.sourceType != "" || o.sourceFile != "" || o.bucketName != "" || o.namespace != "" || o.objectName != "" || o.objectVersionID != "" || o.runtimeConfigType != "" || o.runtimeName != "" || o.runtimeVersionID != "" || o.handler != "" +} + +func readCodeOnlyUpdateOptions(c *cli.Context) codeOnlyUpdateOptions { + return codeOnlyUpdateOptions{ + codeOnly: c.Bool("code-only"), + sourceType: strings.TrimSpace(c.String("source-type")), + sourceFile: strings.TrimSpace(c.String("source-file")), + bucketName: strings.TrimSpace(c.String("bucket-name")), + namespace: strings.TrimSpace(c.String("namespace")), + objectName: strings.TrimSpace(c.String("object-name")), + objectVersionID: strings.TrimSpace(c.String("object-version-id")), + runtimeConfigType: strings.TrimSpace(c.String("runtime-config-type")), + runtimeName: strings.TrimSpace(c.String("runtime-name")), + runtimeVersionID: strings.TrimSpace(c.String("runtime-version-id")), + handler: strings.TrimSpace(c.String("handler")), + } +} + +func (o codeOnlyUpdateOptions) enabled() bool { + return o.codeOnly || o.sourceType != "" || o.sourceFile != "" || o.bucketName != "" || o.namespace != "" || o.objectName != "" || o.objectVersionID != "" || o.runtimeConfigType != "" || o.runtimeName != "" || o.runtimeVersionID != "" || o.handler != "" +} + type clearDestinationRequest struct { Success bool Failure bool @@ -940,6 +1052,7 @@ func (f *fnsCmd) create(c *cli.Context) error { common.WarnUnsupportedOCIRequestControl(f.provider, control) appName := c.Args().Get(0) fnName := c.Args().Get(1) + codeOnly := readCodeOnlyCreateOptions(c) pbfListingID := strings.TrimSpace(c.String("pbf")) pcConfig, err := common.ParseProvisionedConcurrencySpec(c.String("provisioned-concurrency")) if err != nil { @@ -992,13 +1105,18 @@ func (f *fnsCmd) create(c *cli.Context) error { if fn.Name == "" { return errors.New("fnName path is missing") } - if fn.Image != "" && pbfListingID != "" { + if codeOnly.enabled() { + if pbfListingID != "" || pcConfig != nil || detachedSeconds > 0 || onSuccess != nil || onFailure != nil || clearReq.Success || clearReq.Failure { + return errors.New("code-only options cannot be combined with --pbf or OCI managed-function flags") + } + if err := applyCodeOnlyCreateOptions(f.provider, fn, codeOnly); err != nil { + return err + } + } else if fn.Image != "" && pbfListingID != "" { return errors.New("--image and --pbf cannot be used together") - } - if fn.Image == "" && pbfListingID == "" { + } else if fn.Image == "" && pbfListingID == "" { return errors.New("no image specified") - } - if pbfListingID != "" { + } else if pbfListingID != "" { if !common.IsOracleProvider(f.provider) { return errors.New("--pbf is only supported with an oracle provider") } @@ -1009,21 +1127,21 @@ func (f *fnsCmd) create(c *cli.Context) error { return err } } - if pcConfig != nil { + if !codeOnly.enabled() && pcConfig != nil { if !common.IsOracleProvider(f.provider) { warnUnsupportedProvisionedConcurrency() } else if err := SetProvisionedConcurrencyAnnotations(fn, pcConfig); err != nil { return err } } - if detachedSeconds > 0 { + if !codeOnly.enabled() && detachedSeconds > 0 { if !common.IsOracleProvider(f.provider) { warnUnsupportedDetachedTimeout() } else { SetDetachedTimeoutAnnotation(fn, detachedSeconds) } } - if onSuccess != nil || onFailure != nil { + if !codeOnly.enabled() && (onSuccess != nil || onFailure != nil) { if !common.IsOracleProvider(f.provider) { if onSuccess != nil { warnUnsupportedDestination("--on-success") @@ -1035,7 +1153,7 @@ func (f *fnsCmd) create(c *cli.Context) error { SetDestinationAnnotations(fn, onSuccess, onFailure) } } - if clearReq.Success || clearReq.Failure { + if !codeOnly.enabled() && (clearReq.Success || clearReq.Failure) { if !common.IsOracleProvider(f.provider) { if clearReq.Success { warnUnsupportedDestination("--clear-on-success") @@ -1173,6 +1291,7 @@ func (f *fnsCmd) update(c *cli.Context) error { common.WarnUnsupportedOCIRequestControl(f.provider, control) appName := c.Args().Get(0) fnName := c.Args().Get(1) + codeOnly := readCodeOnlyUpdateOptions(c) if strings.TrimSpace(c.String("pbf")) != "" { return errors.New("--pbf is only supported when creating a function") } @@ -1219,6 +1338,14 @@ func (f *fnsCmd) update(c *cli.Context) error { if err := ApplyGeneratedOCIParityFnFlags(c, fn); err != nil { return err } + if codeOnly.enabled() { + if pcConfig != nil || detachedSeconds > 0 || onSuccess != nil || onFailure != nil || clearReq.Success || clearReq.Failure { + return errors.New("code-only update options cannot be combined with OCI managed-function flags") + } + if err := applyCodeOnlyUpdateOptions(f.provider, fn, codeOnly); err != nil { + return err + } + } annotations, err := common.ApplyOCIResourceTagFlagsToAnnotations( fn.Annotations, c.StringSlice("tag"), @@ -1233,14 +1360,14 @@ func (f *fnsCmd) update(c *cli.Context) error { } fn.Annotations = annotations - if detachedSeconds > 0 { + if !codeOnly.enabled() && detachedSeconds > 0 { if !common.IsOracleProvider(f.provider) { warnUnsupportedDetachedTimeout() } else { SetDetachedTimeoutAnnotation(fn, detachedSeconds) } } - if onSuccess != nil || onFailure != nil { + if !codeOnly.enabled() && (onSuccess != nil || onFailure != nil) { if !common.IsOracleProvider(f.provider) { if onSuccess != nil { warnUnsupportedDestination("--on-success") @@ -1252,7 +1379,7 @@ func (f *fnsCmd) update(c *cli.Context) error { SetDestinationAnnotations(fn, onSuccess, onFailure) } } - if clearReq.Success || clearReq.Failure { + if !codeOnly.enabled() && (clearReq.Success || clearReq.Failure) { if !common.IsOracleProvider(f.provider) { if clearReq.Success { warnUnsupportedDestination("--clear-on-success") @@ -1269,7 +1396,7 @@ func (f *fnsCmd) update(c *cli.Context) error { if err != nil { return err } - if pcConfig != nil { + if !codeOnly.enabled() && pcConfig != nil { if !common.IsOracleProvider(f.provider) { warnUnsupportedProvisionedConcurrency() } else if err := ApplyProvisionedConcurrency(f.provider, fn.ID, pcConfig); err != nil { @@ -1284,6 +1411,263 @@ func (f *fnsCmd) update(c *cli.Context) error { return nil } +func applyCodeOnlyCreateOptions(p provider.Provider, fn *models.Fn, opts codeOnlyCreateOptions) error { + if fn.Image != "" { + return fmt.Errorf("Specify either an image or --code-only options, not both") + } + if !opts.codeOnly { + return fmt.Errorf("--code-only is required when specifying code-only source or runtime flags") + } + sourceType, err := normalizeSourceType(opts.sourceType) + if err != nil { + return err + } + mode, err := normalizeRuntimeConfigType(opts.runtimeConfigType) + if err != nil { + return err + } + if sourceType == "" { + return fmt.Errorf("--source-type is required for code-only create") + } + if mode == "" { + return fmt.Errorf("--runtime-config-type is required for code-only create") + } + if opts.runtimeName == "" { + return fmt.Errorf("--runtime-name is required for code-only create") + } + if requiresHandlerForRuntime(opts.runtimeName) && opts.handler == "" { + return fmt.Errorf("--handler is required for runtime %s", opts.runtimeName) + } + if err := validateCodeOnlySourceOptions(sourceType, opts); err != nil { + return err + } + if err := validateRuntimeConfig(p, mode, opts.runtimeName, opts.runtimeVersionID); err != nil { + return err + } + + fn.CodeOnly = true + fn.Image = "" + fn.SourceType = sourceType + fn.SourceFile = opts.sourceFile + fn.SourceBucketName = opts.bucketName + fn.SourceNamespace = opts.namespace + fn.SourceObjectName = opts.objectName + fn.SourceObjectVersion = opts.objectVersionID + fn.RuntimeConfigType = mode + fn.RuntimeName = opts.runtimeName + fn.RuntimeVersionID = opts.runtimeVersionID + fn.Handler = opts.handler + + if sourceType == "direct" { + archive, err := os.ReadFile(opts.sourceFile) + if err != nil { + return fmt.Errorf("failed to read --source-file %s: %w", opts.sourceFile, err) + } + fn.SourceArchive = archive + } + + return nil +} + +func normalizeSourceType(value string) (string, error) { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "": + return "", nil + case "direct": + return "direct", nil + case "object-storage", "object_storage", "objectstorage": + return "object-storage", nil + default: + return "", fmt.Errorf("unsupported --source-type %q. Supported values are direct and object-storage", value) + } +} + +func normalizeRuntimeConfigType(value string) (string, error) { + v := strings.ToLower(strings.TrimSpace(value)) + switch v { + case "": + return "", nil + case "function-update", "function_update": + return "FUNCTION_UPDATE", nil + case "manual": + return "MANUAL", nil + default: + return "", fmt.Errorf("unsupported --runtime-config-type %q. Supported values are function-update and manual", value) + } +} + +func validateCodeOnlySourceOptions(sourceType string, opts codeOnlyCreateOptions) error { + switch sourceType { + case "direct": + if opts.sourceFile == "" { + return fmt.Errorf("--source-file is required when --source-type=direct") + } + if opts.bucketName != "" || opts.namespace != "" || opts.objectName != "" || opts.objectVersionID != "" { + return fmt.Errorf("Object Storage flags cannot be used when --source-type=direct") + } + case "object-storage": + if opts.bucketName == "" || opts.namespace == "" || opts.objectName == "" { + return fmt.Errorf("--bucket-name, --namespace, and --object-name are required when --source-type=object-storage") + } + if opts.sourceFile != "" { + return fmt.Errorf("--source-file cannot be used when --source-type=object-storage") + } + } + return nil +} + +func validateRuntimeConfig(p provider.Provider, mode, runtimeName, runtimeVersionID string) error { + switch mode { + case "FUNCTION_UPDATE": + if runtimeVersionID != "" { + return fmt.Errorf("--runtime-version-id is only valid for manual runtime configuration") + } + case "MANUAL": + if runtimeVersionID == "" { + return fmt.Errorf("--runtime-version-id is required when --runtime-config-type=manual") + } + if err := validateRuntimeVersionMatchesRuntime(p, runtimeName, runtimeVersionID); err != nil { + return err + } + } + return nil +} + +func validateRuntimeVersionMatchesRuntime(p provider.Provider, runtimeName, runtimeVersionID string) error { + ociProvider, ok := p.(*fnprovideroracle.OracleProvider) + if !ok || ociProvider == nil { + return fmt.Errorf("runtime version validation requires an oracle provider") + } + client, err := ocifunctions.NewFunctionsManagementClientWithConfigurationProvider(ociProvider.ConfigurationProvider) + if err != nil { + return err + } + if ociProvider.FnApiUrl != nil { + client.Host = ociProvider.FnApiUrl.String() + } else { + region, err := ociProvider.ConfigurationProvider.Region() + if err != nil { + return err + } + client.SetRegion(region) + } + limit := 1 + request := ocifunctions.ListFunctionsRuntimeVersionsRequest{ + FunctionsRuntimeName: &runtimeName, + FunctionsRuntimeVersionId: &runtimeVersionID, + Limit: &limit, + } + response, err := client.ListFunctionsRuntimeVersions(context.Background(), request) + if err != nil { + return err + } + if len(response.Items) == 0 { + return fmt.Errorf("runtime version %s does not belong to runtime %s", runtimeVersionID, runtimeName) + } + return nil +} + +func requiresHandlerForRuntime(runtimeName string) bool { + baseRuntime := strings.ToLower(strings.TrimSpace(runtimeName)) + for _, sep := range []string{".", "-"} { + if idx := strings.Index(baseRuntime, sep); idx != -1 { + baseRuntime = baseRuntime[:idx] + break + } + } + return strings.HasPrefix(baseRuntime, "java") || strings.HasPrefix(baseRuntime, "python") || strings.HasPrefix(baseRuntime, "node") || strings.HasPrefix(baseRuntime, "javascript") +} + +func applyCodeOnlyUpdateOptions(p provider.Provider, fn *models.Fn, opts codeOnlyUpdateOptions) error { + if fn.Image != "" { + return fmt.Errorf("Specify either an image update or code-only update flags, not both") + } + if !opts.codeOnly { + return fmt.Errorf("--code-only is required when specifying code-only update flags") + } + + sourceType, err := normalizeSourceType(opts.sourceType) + if err != nil { + return err + } + mode, err := normalizeRuntimeConfigType(opts.runtimeConfigType) + if err != nil { + return err + } + + if sourceType != "" { + if err := validateCodeOnlySourceOptions(sourceType, codeOnlyCreateOptions{ + codeOnly: true, + sourceType: sourceType, + sourceFile: opts.sourceFile, + bucketName: opts.bucketName, + namespace: opts.namespace, + objectName: opts.objectName, + objectVersionID: opts.objectVersionID, + }); err != nil { + return err + } + } + + if mode != "" { + if opts.runtimeName == "" { + return fmt.Errorf("--runtime-name is required when changing --runtime-config-type") + } + if err := validateRuntimeConfig(p, mode, opts.runtimeName, opts.runtimeVersionID); err != nil { + return err + } + } + + effectiveRuntimeName := opts.runtimeName + if effectiveRuntimeName == "" { + effectiveRuntimeName = fn.RuntimeName + } + if effectiveRuntimeName != "" && requiresHandlerForRuntime(effectiveRuntimeName) { + effectiveHandler := strings.TrimSpace(opts.handler) + if effectiveHandler == "" { + effectiveHandler = strings.TrimSpace(fn.Handler) + } + if effectiveHandler == "" && (sourceType != "" || mode != "") { + return fmt.Errorf("--handler is required for runtime %s", effectiveRuntimeName) + } + } + + fn.CodeOnly = true + fn.Image = "" + if sourceType != "" { + fn.SourceType = sourceType + fn.SourceFile = opts.sourceFile + fn.SourceBucketName = opts.bucketName + fn.SourceNamespace = opts.namespace + fn.SourceObjectName = opts.objectName + fn.SourceObjectVersion = opts.objectVersionID + if sourceType == "direct" { + archive, err := os.ReadFile(opts.sourceFile) + if err != nil { + return fmt.Errorf("failed to read --source-file %s: %w", opts.sourceFile, err) + } + fn.SourceArchive = archive + } else { + fn.SourceArchive = nil + } + } + if mode != "" { + fn.RuntimeConfigType = mode + fn.RuntimeName = opts.runtimeName + fn.RuntimeVersionID = opts.runtimeVersionID + } + if opts.handler != "" { + fn.Handler = opts.handler + } + + if sourceType == "" && mode == "" && opts.handler == "" { + return fmt.Errorf("no code-only update fields were provided") + } + + return nil +} + func (f *fnsCmd) setConfig(c *cli.Context) error { appName := c.Args().Get(0) fnName := WithoutSlash(c.Args().Get(1)) diff --git a/objects/runtime/commands.go b/objects/runtime/commands.go new file mode 100644 index 00000000..e5c72785 --- /dev/null +++ b/objects/runtime/commands.go @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +import ( + "github.com/fnproject/cli/client" + "github.com/fnproject/fn_go/provider" + "github.com/fnproject/fn_go/provider/oracle" + "github.com/urfave/cli" +) + +// ListRuntimes returns the runtimes listing command. +func ListRuntimes() cli.Command { + cmd := runtimeCmd{} + return cli.Command{ + Name: "runtimes", + Usage: "List supported runtimes", + Category: "MANAGEMENT COMMAND", + Description: "This command lists all supported runtimes.", + Before: func(c *cli.Context) error { + var err error + cmd.provider, err = client.CurrentProvider() + if err != nil { + return err + } + cmd.providerName = c.String("provider") + return nil + }, + Action: cmd.listRuntimes, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "output", + Usage: "Output format (json)", + }, + cli.StringFlag{ + Name: "provider", + Usage: "Override provider name", + }, + }, + BashComplete: func(c *cli.Context) { + provider, err := client.CurrentProvider() + if err != nil { + return + } + if _, ok := provider.(*oracle.OracleProvider); !ok { + return + } + }, + } +} + +// ListRuntimeVersions returns the runtime versions listing command. +func ListRuntimeVersions() cli.Command { + cmd := runtimeCmd{} + return cli.Command{ + Name: "runtime-versions", + Usage: "List runtime versions for a runtime", + Category: "MANAGEMENT COMMAND", + Description: "This command lists runtime versions for a runtime name.", + Before: func(c *cli.Context) error { + var err error + cmd.provider, err = client.CurrentProvider() + if err != nil { + return err + } + cmd.providerName = c.String("provider") + return nil + }, + Action: cmd.listRuntimeVersions, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "runtime-name", + Usage: "Runtime name (e.g. java21.ol10)", + }, + cli.StringFlag{ + Name: "output", + Usage: "Output format (json)", + }, + cli.StringFlag{ + Name: "provider", + Usage: "Override provider name", + }, + }, + BashComplete: func(c *cli.Context) { + if c.String("runtime-name") != "" { + return + } + suggestRuntimeNames(c) + }, + } +} + +// GetLatestRuntimeVersion returns the latest runtime version lookup command. +func GetLatestRuntimeVersion() cli.Command { + cmd := runtimeCmd{} + return cli.Command{ + Name: "latest-runtime-version", + Usage: "Get the latest runtime version for a runtime", + Category: "MANAGEMENT COMMAND", + Description: "This command gets the latest runtime version for a runtime name.", + Before: func(c *cli.Context) error { + var err error + cmd.provider, err = client.CurrentProvider() + if err != nil { + return err + } + cmd.providerName = c.String("provider") + return nil + }, + Action: cmd.getLatestRuntimeVersion, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "runtime-name", + Usage: "Runtime name (e.g. java21.ol10)", + }, + cli.StringFlag{ + Name: "output", + Usage: "Output format (json)", + }, + cli.StringFlag{ + Name: "provider", + Usage: "Override provider name", + }, + }, + BashComplete: func(c *cli.Context) { + if c.String("runtime-name") != "" { + return + } + suggestRuntimeNames(c) + }, + } +} + +type runtimeCmd struct { + provider provider.Provider + providerName string +} \ No newline at end of file diff --git a/objects/runtime/runtime.go b/objects/runtime/runtime.go new file mode 100644 index 00000000..01b79633 --- /dev/null +++ b/objects/runtime/runtime.go @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/fnproject/cli/client" + "github.com/fnproject/fn_go/provider/oracle" + ociCommon "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/functions" + "github.com/urfave/cli" +) + +func (c *runtimeCmd) ensureOracleProvider() (*oracle.OracleProvider, error) { + ociProvider, ok := c.provider.(*oracle.OracleProvider) + if !ok || ociProvider == nil { + return nil, fmt.Errorf("runtime discovery requires an oracle provider") + } + return ociProvider, nil +} + +func (c *runtimeCmd) listRuntimes(cliCtx *cli.Context) error { + ociProvider, err := c.ensureOracleProvider() + if err != nil { + return err + } + + client, err := newFunctionsClient(ociProvider) + if err != nil { + return err + } + + request := functions.ListFunctionsRuntimesRequest{} + var items []functions.FunctionsRuntimeSummary + for { + response, err := client.ListFunctionsRuntimes(context.Background(), request) + if err != nil { + return err + } + items = append(items, response.Items...) + if response.OpcNextPage == nil { + break + } + request.Page = response.OpcNextPage + } + + return printRuntimes(cliCtx, items) +} + +func (c *runtimeCmd) listRuntimeVersions(cliCtx *cli.Context) error { + runtimeName := strings.TrimSpace(cliCtx.String("runtime-name")) + if runtimeName == "" { + return fmt.Errorf("--runtime-name is required") + } + + ociProvider, err := c.ensureOracleProvider() + if err != nil { + return err + } + + client, err := newFunctionsClient(ociProvider) + if err != nil { + return err + } + + request := functions.ListFunctionsRuntimeVersionsRequest{ + FunctionsRuntimeName: &runtimeName, + } + var items []functions.FunctionsRuntimeVersionSummary + for { + response, err := client.ListFunctionsRuntimeVersions(context.Background(), request) + if err != nil { + return err + } + items = append(items, response.Items...) + if response.OpcNextPage == nil { + break + } + request.Page = response.OpcNextPage + } + + return printRuntimeVersions(cliCtx, items) +} + +func (c *runtimeCmd) getLatestRuntimeVersion(cliCtx *cli.Context) error { + runtimeName := strings.TrimSpace(cliCtx.String("runtime-name")) + if runtimeName == "" { + return fmt.Errorf("--runtime-name is required") + } + + ociProvider, err := c.ensureOracleProvider() + if err != nil { + return err + } + + client, err := newFunctionsClient(ociProvider) + if err != nil { + return err + } + + request := functions.ListFunctionsRuntimeVersionsRequest{ + FunctionsRuntimeName: &runtimeName, + IsCurrentVersion: ociCommon.Bool(true), + Limit: ociCommon.Int(1), + } + response, err := client.ListFunctionsRuntimeVersions(context.Background(), request) + if err != nil { + return err + } + if len(response.Items) == 0 { + return fmt.Errorf("no runtime versions found for runtime %s", runtimeName) + } + return printLatestRuntimeVersion(cliCtx, response.Items[0]) +} + +func printRuntimes(cliCtx *cli.Context, items []functions.FunctionsRuntimeSummary) error { + outputFormat := strings.ToLower(cliCtx.String("output")) + if outputFormat == "json" { + b, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + fmt.Fprint(os.Stdout, string(b)) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) + fmt.Fprint(w, "NAME", "\t", "LANGUAGE", "\t", "OS", "\t", "STATE", "\t", "CURRENT_VERSION_ID", "\n") + for _, item := range items { + fmt.Fprint(w, + stringValue(item.Name), "\t", + stringValue(item.Language), "\t", + stringValue(item.Os), "\t", + item.LifecycleState, "\t", + stringValue(item.CurrentFunctionsRuntimeVersionId), "\t", + "\n", + ) + } + return w.Flush() +} + +func printRuntimeVersions(cliCtx *cli.Context, items []functions.FunctionsRuntimeVersionSummary) error { + outputFormat := strings.ToLower(cliCtx.String("output")) + if outputFormat == "json" { + b, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + fmt.Fprint(os.Stdout, string(b)) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) + fmt.Fprint(w, "DISPLAY_NAME", "\t", "LANGUAGE_VERSION", "\t", "OS_VERSION", "\t", "STATE", "\t", "ID", "\n") + for _, item := range items { + fmt.Fprint(w, + stringValue(item.DisplayName), "\t", + stringValue(item.LanguageVersion), "\t", + stringValue(item.OsVersion), "\t", + item.LifecycleState, "\t", + stringValue(item.Id), "\t", + "\n", + ) + } + return w.Flush() +} + +func printLatestRuntimeVersion(cliCtx *cli.Context, item functions.FunctionsRuntimeVersionSummary) error { + outputFormat := strings.ToLower(cliCtx.String("output")) + if outputFormat == "json" { + b, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Fprint(os.Stdout, string(b)) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) + fmt.Fprint(w, "DISPLAY_NAME", "\t", "LANGUAGE_VERSION", "\t", "OS_VERSION", "\t", "STATE", "\t", "ID", "\n") + fmt.Fprint(w, + stringValue(item.DisplayName), "\t", + stringValue(item.LanguageVersion), "\t", + stringValue(item.OsVersion), "\t", + item.LifecycleState, "\t", + stringValue(item.Id), "\t", + "\n", + ) + return w.Flush() +} + +func suggestRuntimeNames(cliCtx *cli.Context) { + provider, err := client.CurrentProvider() + if err != nil { + return + } + ociProvider, ok := provider.(*oracle.OracleProvider) + if !ok || ociProvider == nil { + return + } + client, err := newFunctionsClient(ociProvider) + if err != nil { + return + } + + request := functions.ListFunctionsRuntimesRequest{} + for { + response, err := client.ListFunctionsRuntimes(context.Background(), request) + if err != nil { + return + } + for _, item := range response.Items { + name := stringValue(item.Name) + if name != "" { + fmt.Println(name) + } + } + if response.OpcNextPage == nil { + break + } + request.Page = response.OpcNextPage + } +} + +func getRegion(oracleProvider *oracle.OracleProvider) string { + if oracleProvider.FnApiUrl != nil { + parts := strings.Split(oracleProvider.FnApiUrl.Host, ".") + if len(parts) >= 4 { + return parts[1] + } + } + region, _ := oracleProvider.ConfigurationProvider.Region() + return region +} + +func newFunctionsClient(oracleProvider *oracle.OracleProvider) (functions.FunctionsManagementClient, error) { + client, err := functions.NewFunctionsManagementClientWithConfigurationProvider(oracleProvider.ConfigurationProvider) + if err != nil { + return client, err + } + if oracleProvider.FnApiUrl != nil { + client.Host = oracleProvider.FnApiUrl.String() + return client, nil + } + client.SetRegion(getRegion(oracleProvider)) + return client, nil +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} \ No newline at end of file diff --git a/src/main/java/com/example/fn/HelloFunction.java b/src/main/java/com/example/fn/HelloFunction.java new file mode 100644 index 00000000..7b2e6a85 --- /dev/null +++ b/src/main/java/com/example/fn/HelloFunction.java @@ -0,0 +1,12 @@ +package com.example.fn; + +public class HelloFunction { + + public String handleRequest(String input) { + String name = (input == null || input.isEmpty()) ? "world" : input; + + System.out.println("Inside Java Hello World function"); + return "Hello, " + name + "!"; + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/fn/HelloFunctionTest.java b/src/test/java/com/example/fn/HelloFunctionTest.java new file mode 100644 index 00000000..e6b7a5e3 --- /dev/null +++ b/src/test/java/com/example/fn/HelloFunctionTest.java @@ -0,0 +1,22 @@ +package com.example.fn; + +import com.fnproject.fn.testing.*; +import org.junit.*; + +import static org.junit.Assert.*; + +public class HelloFunctionTest { + + @Rule + public final FnTestingRule testing = FnTestingRule.createDefault(); + + @Test + public void shouldReturnGreeting() { + testing.givenEvent().enqueue(); + testing.thenRun(HelloFunction.class, "handleRequest"); + + FnResult result = testing.getOnlyResult(); + assertEquals("Hello, world!", result.getBodyAsString()); + } + +} \ No newline at end of file diff --git a/test/cli_code_only_build_test.go b/test/cli_code_only_build_test.go new file mode 100644 index 00000000..6fabeceb --- /dev/null +++ b/test/cli_code_only_build_test.go @@ -0,0 +1,523 @@ +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("code-only build should fail when --app is missing", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + h.MkDir("missing-app") + h.Cd("missing-app") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-python +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: python311.ol9 +handler: hello_world.handler +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/hello_world.py", "def handler(ctx, data=None):\n return 'ok'\n", 0644) + + h.Fn("build").AssertFailed().AssertStderrContains("code-only build requires --app") + }) + + 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("python code-only build should include python dependencies at archive root when present", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-python-deps") + h.Cd("hello-python-deps") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-python +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: python311.ol9 +handler: hello_world.handler +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/hello_world.py", "def handler(ctx, data=None):\n return 'ok'\n", 0644) + h.MkDir("python") + h.WithFile("python/dependency.py", "VALUE = 1\n", 0644) + + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + 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, "python/dependency.py") { + t.Fatalf("expected python/dependency.py in archive, got entries: %v", entries) + } + }) + + t.Run("python code-only build should include resources at archive root when present", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-python-resources") + h.Cd("hello-python-resources") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-python +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: python311.ol9 +handler: hello_world.handler +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/hello_world.py", "def handler(ctx, data=None):\n return 'ok'\n", 0644) + h.MkDir("resources") + h.WithFile("resources/config.json", "{}", 0644) + + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "resources/config.json") { + t.Fatalf("expected resources/config.json in archive, got entries: %v", entries) + } + }) + + t.Run("python code-only build should reject native directories for single-architecture builds", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-python-native") + h.Cd("hello-python-native") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-python +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: python311.ol9 +handler: hello_world.handler +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/hello_world.py", "def handler(ctx, data=None):\n return 'ok'\n", 0644) + h.MkDir("native") + h.MkDir("native/fn-arch-x86") + h.WithFile("native/fn-arch-x86/libexample.so", "x86", 0644) + h.MkDir("native/fn-arch-arm") + h.WithFile("native/fn-arch-arm/libexample.so", "arm", 0644) + + h.Fn("build").AssertFailed().AssertStderrContains("native/ is not allowed for single-architecture Python functions") + }) + + t.Run("node.js code-only build should package function/func.js and exclude func.yaml", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-node") + h.Cd("hello-node") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-node +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: node +handler: func.js +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/func.js", "module.exports = async function (context, input) { return 'ok'; };\n", 0644) + + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "function/func.js") { + t.Fatalf("expected function/func.js 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("node.js code-only build should include node_modules and resources at archive root", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-node-deps") + h.Cd("hello-node-deps") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-node +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: node +handler: func.js +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/func.js", "module.exports = async function (context, input) { return 'ok'; };\n", 0644) + h.MkDir("node_modules") + h.MkDir("node_modules/lodash") + h.WithFile("node_modules/lodash/index.js", "module.exports = {};\n", 0644) + h.MkDir("resources") + h.WithFile("resources/config.json", "{}", 0644) + + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "node_modules/lodash/index.js") { + t.Fatalf("expected node_modules/lodash/index.js in archive, got entries: %v", entries) + } + if !containsEntry(entries, "resources/config.json") { + t.Fatalf("expected resources/config.json in archive, got entries: %v", entries) + } + }) + + t.Run("node.js code-only build should reject native directories for single-architecture builds", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-node-native") + h.Cd("hello-node-native") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello-node +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: node +handler: func.js +`, common.LatestYamlVersion), 0644) + h.MkDir("function") + h.WithFile("function/func.js", "module.exports = async function (context, input) { return 'ok'; };\n", 0644) + h.MkDir("native") + h.MkDir("native/fn-arch-x86") + h.WithFile("native/fn-arch-x86/addon.node", "x86", 0644) + h.MkDir("native/fn-arch-arm") + h.WithFile("native/fn-arch-arm/addon.node", "arm", 0644) + + h.Fn("build").AssertFailed().AssertStderrContains("native/ is not allowed for single-architecture Node.js functions") + }) + + t.Run("go code-only build should create a single-arch versioned archive with func binary at root", 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") { + t.Fatalf("expected func binary at archive root, got entries: %v", entries) + } + if containsEntry(entries, "func.go") { + t.Fatalf("func.go should not be included in Go code-only archive, 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 remain single-arch by default", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + funcName := "multigo" + h.MkDir(funcName) + h.Cd(funcName) + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: %s +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: go +shape: GENERIC_X86_ARM +`, common.LatestYamlVersion, funcName), 0644) + h.WithFile("func.go", `package main + +import "fmt" + +func main() { + fmt.Println("hello") +} +`, 0644) + h.WithFile("go.mod", "module example.com/multigo\n\ngo 1.24.0\n", 0644) + + 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) + } + + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "func") { + t.Fatalf("expected single-arch func binary at archive root, got entries: %v", entries) + } + if containsEntry(entries, "fn-arch-x86/func") || containsEntry(entries, "fn-arch-arm/func") { + t.Fatalf("plain fn build should not create multi-arch Go archive entries, got entries: %v", entries) + } + }) + + t.Run("java code-only build should package exactly one root jar as main.jar", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + 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.MkDir("target") + h.WithFile("target/my-function.jar", "fake-jar-content", 0644) + + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "main.jar") { + t.Fatalf("expected main.jar at archive root, got entries: %v", entries) + } + if containsEntry(entries, "target/my-function.jar") { + t.Fatalf("build output jar should be repackaged as main.jar, got entries: %v", entries) + } + }) + + t.Run("java code-only build should include resources at archive root when present", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-java-resources") + h.Cd("hello-java-resources") + 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.MkDir("target") + h.WithFile("target/my-function.jar", "fake-jar-content", 0644) + h.MkDir("resources") + h.WithFile("resources/config.json", "{}", 0644) + + res := h.Fn("build").AssertSuccess().AssertStdoutContains("Code-only function packaged successfully:") + archivePath := archivePathFromBuildOutput(t, res.Stdout) + entries := zipEntryNames(t, archivePath) + if !containsEntry(entries, "main.jar") { + t.Fatalf("expected main.jar at archive root, got entries: %v", entries) + } + if !containsEntry(entries, "resources/config.json") { + t.Fatalf("expected resources/config.json in archive, got entries: %v", entries) + } + }) + + t.Run("java code-only build should fail when multiple jars are present", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-java-multi") + h.Cd("hello-java-multi") + 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.WithFile("one.jar", "jar1", 0644) + h.WithFile("two.JAR", "jar2", 0644) + + h.Fn("build").AssertFailed().AssertStderrContains("java code-only build requires exactly one .jar file") + }) + + t.Run("java code-only build should fail when no jar is present", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + h.MkDir("hello-java-nojar") + h.Cd("hello-java-nojar") + 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.Fn("build").AssertFailed().AssertStderrContains("java code-only build requires exactly one .jar file") + }) + +t.Run("java code-only build should require Maven", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + appName := h.NewAppName() + # app creation skipped in offline tests + + 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 diff --git a/test/cli_code_only_create_update_test.go b/test/cli_code_only_create_update_test.go new file mode 100644 index 00000000..3c387b73 --- /dev/null +++ b/test/cli_code_only_create_update_test.go @@ -0,0 +1,187 @@ +package test + +import ( + "testing" + + "github.com/fnproject/cli/testharness" +) + +func requireTestServer(t *testing.T, h *testharness.CLIHarness) { + t.Helper() + if res := h.Fn("list", "apps"); !res.Success { + t.Skipf("skipping because test server is not reachable: %s", res.Stderr) + } +} + +func TestCodeOnlyCreateValidation(t *testing.T) { + t.Run("code-only create should reject image and code-only flags together", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.Fn( + "create", "function", appName, "mixed-mode", "some/image:1.0.0", + "--code-only", + "--source-type", "direct", + "--source-file", "/tmp/archive.zip", + "--runtime-config-type", "function-update", + "--runtime-name", "python311.ol9", + "--handler", "hello_world.handler", + ).AssertFailed().AssertStderrContains("Specify either an image or --code-only options, not both") + }) + + t.Run("code-only create should require source-file for direct source", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.Fn( + "create", "function", appName, "missing-source", + "--code-only", + "--source-type", "direct", + "--runtime-config-type", "function-update", + "--runtime-name", "python311.ol9", + "--handler", "hello_world.handler", + ).AssertFailed().AssertStderrContains("--source-file is required when --source-type=direct") + }) + + t.Run("code-only create should require bucket namespace and object name for object-storage source", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.Fn( + "create", "function", appName, "missing-object-fields", + "--code-only", + "--source-type", "object-storage", + "--runtime-config-type", "function-update", + "--runtime-name", "python311.ol9", + "--handler", "hello_world.handler", + ).AssertFailed().AssertStderrContains("--bucket-name, --namespace, and --object-name are required when --source-type=object-storage") + }) + + t.Run("code-only create should require runtime-version-id in manual mode", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.Fn( + "create", "function", appName, "missing-version", + "--code-only", + "--source-type", "direct", + "--source-file", "/tmp/archive.zip", + "--runtime-config-type", "manual", + "--runtime-name", "python311.ol9", + "--handler", "hello_world.handler", + ).AssertFailed().AssertStderrContains("--runtime-version-id is required when --runtime-config-type=manual") + }) + + t.Run("code-only create should reject runtime-version-id in function-update mode", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.Fn( + "create", "function", appName, "bad-function-update", + "--code-only", + "--source-type", "direct", + "--source-file", "/tmp/archive.zip", + "--runtime-config-type", "function-update", + "--runtime-name", "python311.ol9", + "--runtime-version-id", "ocid1.functionsruntimeversion.oc1..example", + "--handler", "hello_world.handler", + ).AssertFailed().AssertStderrContains("--runtime-version-id is only valid for manual runtime configuration") + }) + + t.Run("code-only create should reject invalid python handler format", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.Fn( + "create", "function", appName, "bad-handler", + "--code-only", + "--source-type", "direct", + "--source-file", "/tmp/archive.zip", + "--runtime-config-type", "function-update", + "--runtime-name", "python311.ol9", + "--handler", "hello_world:handler", + ).AssertFailed().AssertStderrContains("handler for runtime python311.ol9 must be in the format .") + }) + + t.Run("code-only create should reject invalid java handler format", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + h.Fn("create", "app", appName).AssertSuccess() + h.WithEnv("PATH", "/usr/bin:/bin") + h.Fn( + "create", "function", appName, "bad-java-handler", + "--code-only", + "--source-type", "direct", + "--source-file", "/tmp/archive.zip", + "--runtime-config-type", "function-update", + "--runtime-name", "java21.ol10", + "--handler", "hello.handler", + ).AssertFailed().AssertStderrContains("handler for runtime java21.ol10 must be in the format ::") + }) +} + +func TestCodeOnlyUpdateValidation(t *testing.T) { + t.Run("code-only update should reject image and code-only flags together", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + funcName := h.NewFuncName(appName) + h.Fn("create", "app", appName).AssertSuccess() + h.Fn("create", "function", appName, funcName, "foo/someimage:0.0.1").AssertSuccess() + + h.Fn( + "update", "function", appName, funcName, "some/image:1.0.0", + "--code-only", + "--source-type", "direct", + "--source-file", "/tmp/archive.zip", + "--runtime-config-type", "function-update", + "--runtime-name", "python311.ol9", + "--handler", "hello_world.handler", + ).AssertFailed().AssertStderrContains("Specify either an image update or code-only update flags, not both") + }) + + t.Run("code-only update should fail when no code-only update fields are provided", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + requireTestServer(t, h) + + appName := h.NewAppName() + funcName := h.NewFuncName(appName) + h.Fn("create", "app", appName).AssertSuccess() + h.Fn("create", "function", appName, funcName, "foo/someimage:0.0.1").AssertSuccess() + + h.Fn("update", "function", appName, funcName, "--code-only").AssertFailed().AssertStderrContains("no code-only update fields were provided") + }) +} \ No newline at end of file diff --git a/test/cli_code_only_init_test.go b/test/cli_code_only_init_test.go new file mode 100644 index 00000000..793da049 --- /dev/null +++ b/test/cli_code_only_init_test.go @@ -0,0 +1,109 @@ +package test + +import ( + "testing" + + "github.com/fnproject/cli/common" + "github.com/fnproject/cli/testharness" +) + +func TestCodeOnlyInit(t *testing.T) { + t.Run("`fn init --code-only --runtime-name python311.ol9` should generate code-only func.yaml and python boilerplate", 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) + yamlFile := h.GetYamlFile("func.yaml") + + if yamlFile.Schema_version != common.LatestYamlVersion { + t.Fatalf("schema_version was %d, expected %d", yamlFile.Schema_version, common.LatestYamlVersion) + } + if !yamlFile.Code_only { + t.Fatal("code_only was not set in func.yaml") + } + if yamlFile.Runtime_config == nil { + t.Fatal("runtime_config was not set in func.yaml") + } + if yamlFile.Runtime_config.Type != "function-update" { + t.Fatalf("runtime_config.type was %q, expected function-update", yamlFile.Runtime_config.Type) + } + if yamlFile.Runtime_config.Runtime_name != "python311.ol9" { + t.Fatalf("runtime_config.runtime_name was %q, expected python311.ol9", yamlFile.Runtime_config.Runtime_name) + } + if yamlFile.Handler != "hello_world.handler" { + t.Fatalf("handler was %q, expected hello_world.handler", yamlFile.Handler) + } + if yamlFile.Build_image != "" || yamlFile.Run_image != "" { + t.Fatal("code-only func.yaml should not contain build_image or run_image") + } + if yamlFile.Runtime != "" { + t.Fatal("code-only func.yaml should not contain runtime") + } + if h.GetFile("hello_world.py") == "" { + t.Fatal("expected hello_world.py boilerplate to be generated") + } + }) + + t.Run("`fn init --code-only --runtime go` should generate code-only func.yaml and go boilerplate", 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) + yamlFile := h.GetYamlFile("func.yaml") + + if !yamlFile.Code_only { + t.Fatal("code_only was not set in func.yaml") + } + if yamlFile.Runtime_config == nil { + t.Fatal("runtime_config was not set in func.yaml") + } + if yamlFile.Runtime_config.Runtime_name != "go" { + t.Fatalf("runtime_config.runtime_name was %q, expected go", yamlFile.Runtime_config.Runtime_name) + } + if yamlFile.Handler != "" { + t.Fatalf("handler was %q, expected empty for go", yamlFile.Handler) + } + if h.GetFile("func.go") == "" { + t.Fatal("expected func.go boilerplate to be generated") + } + }) + + t.Run("`fn init --code-only --runtime java` should require Maven", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + h.WithEnv("PATH", "/usr/bin:/bin") + h.Fn("init", "--code-only", "--runtime", "java", "--runtime-config-type", "function-update", "hello-java").AssertFailed().AssertStderrContains("Maven was not found in PATH") + }) +} + +func TestRuntimeDiscoveryArgValidation(t *testing.T) { + t.Run("`fn list runtime-versions` should require runtime-name", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + h.Fn("list", "runtime-versions").AssertFailed().AssertStderrContains("--runtime-name is required") + }) + + t.Run("`fn get latest-runtime-version` should require runtime-name", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + h.Fn("get", "latest-runtime-version").AssertFailed().AssertStderrContains("--runtime-name is required") + }) +} \ No newline at end of file diff --git a/test/cli_code_only_push_test.go b/test/cli_code_only_push_test.go new file mode 100644 index 00000000..2415e8a1 --- /dev/null +++ b/test/cli_code_only_push_test.go @@ -0,0 +1,52 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/fnproject/cli/common" + "github.com/fnproject/cli/testharness" +) + +func TestCodeOnlyPushValidation(t *testing.T) { + t.Run("code-only push should fail when Object Storage target is not configured in current context", 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) + h.Fn("build").AssertSuccess() + h.Fn("push").AssertFailed().AssertStderrContains("code-only Object Storage target is not configured in the current context") + }) + + t.Run("code-only push should fail when built archive is missing even if context has bucket and namespace", func(t *testing.T) { + t.Parallel() + h := testharness.Create(t) + defer h.Cleanup() + + contextName := h.NewContextName() + h.Fn("create", "context", "--api-url", "http://localhost:8080", contextName).AssertSuccess() + h.Fn("use", "context", contextName).AssertSuccess() + h.Fn("update", "context", "object_storage_bucket_name", "code-only-test-files").AssertSuccess() + h.Fn("update", "context", "namespace", "oraclefunctionsdevelopm").AssertSuccess() + + h.MkDir("hello") + h.Cd("hello") + h.WithFile("func.yaml", fmt.Sprintf(`schema_version: %d +name: hello +version: 0.0.1 +code_only: true +runtime_config: + type: function-update + runtime_name: python311.ol9 +handler: hello_world.handler +`, common.LatestYamlVersion), 0644) + + h.Fn("push").AssertFailed().AssertStderrContains("built archive not found at hello.0.0.1.zip") + }) +} \ No newline at end of file