diff --git a/objects/fn/fns.go b/objects/fn/fns.go index 2221605e..df852b74 100644 --- a/objects/fn/fns.go +++ b/objects/fn/fns.go @@ -34,7 +34,10 @@ import ( "github.com/fnproject/fn_go/modelsv2" models "github.com/fnproject/fn_go/modelsv2" "github.com/fnproject/fn_go/provider" + "github.com/fnproject/fn_go/provider/oracle" "github.com/jmoiron/jsonq" + ociCommon "github.com/oracle/oci-go-sdk/v65/common" + ociFunctions "github.com/oracle/oci-go-sdk/v65/functions" "github.com/urfave/cli" ) @@ -69,9 +72,121 @@ 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", + }, } var updateFnFlags = 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 != "" +} + // WithSlash appends "/" to function path func WithSlash(p string) string { p = path.Clean(p) @@ -261,6 +376,7 @@ func WithFuncFileV20180708(ff *common.FuncFileV20180708, fn *models.Fn) error { func (f *fnsCmd) create(c *cli.Context) error { appName := c.Args().Get(0) fnName := c.Args().Get(1) + codeOnly := readCodeOnlyCreateOptions(c) fn := &models.Fn{} fn.Name = fnName @@ -271,7 +387,11 @@ func (f *fnsCmd) create(c *cli.Context) error { if fn.Name == "" { return errors.New("fnName path is missing") } - if fn.Image == "" { + if codeOnly.enabled() { + if err := applyCodeOnlyCreateOptions(f.provider, fn, codeOnly); err != nil { + return err + } + } else if fn.Image == "" { return errors.New("no image specified") } @@ -287,9 +407,11 @@ func (f *fnsCmd) create(c *cli.Context) error { // CreateFn request func CreateFn(r *fnclient.Fn, appID string, fn *models.Fn) (*models.Fn, error) { fn.AppID = appID - err := common.ValidateTagImageName(fn.Image) - if err != nil { - return nil, err + if fn.Image != "" { + err := common.ValidateTagImageName(fn.Image) + if err != nil { + return nil, err + } } resp, err := r.Fns.CreateFn(&apifns.CreateFnParams{ @@ -307,7 +429,11 @@ func CreateFn(r *fnclient.Fn, appID string, fn *models.Fn) (*models.Fn, error) { return nil, err } - fmt.Println("Successfully created function:", resp.Payload.Name, "with", resp.Payload.Image) + if fn.CodeOnly || resp.Payload.Image == "" { + fmt.Println("Successfully created code-only function:", resp.Payload.Name) + } else { + fmt.Println("Successfully created function:", resp.Payload.Name, "with", resp.Payload.Image) + } return resp.Payload, nil } @@ -375,6 +501,7 @@ func GetFnByName(client *fnclient.Fn, appID, fnName string) (*models.Fn, error) func (f *fnsCmd) update(c *cli.Context) error { appName := c.Args().Get(0) fnName := c.Args().Get(1) + codeOnly := readCodeOnlyUpdateOptions(c) app, err := app.GetAppByName(f.client, appName) if err != nil { @@ -386,6 +513,11 @@ func (f *fnsCmd) update(c *cli.Context) error { } WithFlags(c, fn) + if codeOnly.enabled() { + if err := applyCodeOnlyUpdateOptions(f.provider, fn, codeOnly); err != nil { + return err + } + } err = PutFn(f.client, fn.ID, fn) if err != nil { @@ -603,3 +735,343 @@ func (f *fnsCmd) delete(c *cli.Context) error { fmt.Println("Function", fnName, "deleted") 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 := validateHandlerForRuntime(opts.runtimeName, opts.handler); err != nil { + return err + } + 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 { + if strings.TrimSpace(runtimeName) != "" { + if err := validateRuntimeName(p, runtimeName); err != nil { + return err + } + } + 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 validateRuntimeName(p provider.Provider, runtimeName string) error { + ociProvider, ok := p.(*oracle.OracleProvider) + if !ok || ociProvider == nil { + return fmt.Errorf("runtime 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) + } + request := ociFunctions.ListFunctionsRuntimesRequest{} + for { + response, err := client.ListFunctionsRuntimes(context.Background(), request) + if err != nil { + return err + } + for _, item := range response.Items { + if item.Name == nil || strings.TrimSpace(*item.Name) != runtimeName { + continue + } + if item.LifecycleState != ociFunctions.FunctionsRuntimeLifecycleStateActive { + return fmt.Errorf("runtime %s is not active", runtimeName) + } + return nil + } + if response.OpcNextPage == nil { + break + } + request.Page = response.OpcNextPage + } + return fmt.Errorf("runtime %s does not exist", runtimeName) +} + +func validateRuntimeVersionMatchesRuntime(p provider.Provider, runtimeName, runtimeVersionID string) error { + ociProvider, ok := p.(*oracle.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) + } + request := ociFunctions.ListFunctionsRuntimeVersionsRequest{ + FunctionsRuntimeName: &runtimeName, + FunctionsRuntimeVersionId: &runtimeVersionID, + Limit: ociCommon.Int(1), + } + 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) + } + if response.Items[0].LifecycleState != ociFunctions.FunctionsRuntimeVersionLifecycleStateActive { + return fmt.Errorf("runtime version %s is not active for 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 validateHandlerForRuntime(runtimeName, handler string) error { + baseRuntime := strings.ToLower(strings.TrimSpace(runtimeName)) + for _, sep := range []string{".", "-"} { + if idx := strings.Index(baseRuntime, sep); idx != -1 { + baseRuntime = baseRuntime[:idx] + break + } + } + h := strings.TrimSpace(handler) + if h == "" { + return nil + } + switch { + case strings.HasPrefix(baseRuntime, "python"): + if strings.Contains(h, ":") || strings.Count(h, ".") != 1 { + return fmt.Errorf("handler for runtime %s must be in the format .", runtimeName) + } + case strings.HasPrefix(baseRuntime, "java"): + if !strings.Contains(h, "::") { + return fmt.Errorf("handler for runtime %s must be in the format ::", runtimeName) + } + case strings.HasPrefix(baseRuntime, "node"), strings.HasPrefix(baseRuntime, "javascript"): + if strings.Contains(h, ":") || strings.Count(h, ".") != 1 { + return fmt.Errorf("handler for runtime %s must be in the format .", runtimeName) + } + } + return nil +} + +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) + } + if err := validateHandlerForRuntime(effectiveRuntimeName, effectiveHandler); err != nil { + return err + } + } + + 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 +} 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