diff --git a/commands/deploy.go b/commands/deploy.go index fc5b21ef..ca85fd9c 100644 --- a/commands/deploy.go +++ b/commands/deploy.go @@ -39,6 +39,7 @@ import ( trigger "github.com/fnproject/cli/objects/trigger" v2Client "github.com/fnproject/fn_go/clientv2" models "github.com/fnproject/fn_go/modelsv2" + fnprovider "github.com/fnproject/fn_go/provider" "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" @@ -109,6 +110,7 @@ func DeployCommand() cli.Command { type deploycmd struct { clientV2 *v2Client.Fn + provider fnprovider.Provider appName string createApp bool @@ -349,6 +351,7 @@ 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 } + common.WarnIfOCIManagedFunctionSettingsUnsupported(os.Stderr, p.provider, funcfile.Name, funcfile) oracleProvider, _ := getOracleProvider() if oracleProvider != nil && oracleProvider.ImageCompartmentID != "" { diff --git a/common/funcfile.go b/common/funcfile.go index e6f29994..6644ca32 100644 --- a/common/funcfile.go +++ b/common/funcfile.go @@ -68,6 +68,36 @@ type Expects struct { Config []inputVar `yaml:"config" json:"config"` } +// OCIDestination represents an OCI destination reference stored in func.yaml. +type OCIDestination struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + OCID string `yaml:"ocid,omitempty" json:"ocid,omitempty"` +} + +// OCIDetachedModeConfig stores detached mode settings for OCI Functions. +type OCIDetachedModeConfig struct { + Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` + OnSuccess *OCIDestination `yaml:"on_success,omitempty" json:"on_success,omitempty"` + OnFailure *OCIDestination `yaml:"on_failure,omitempty" json:"on_failure,omitempty"` +} + +// OCIProvisionedConcurrencyConfig stores provisioned concurrency settings for OCI Functions. +type OCIProvisionedConcurrencyConfig struct { + Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` + Count *int `yaml:"count,omitempty" json:"count,omitempty"` +} + +// 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"` +} + +// FuncDeployConfig stores deploy-time configuration sections in func.yaml. +type FuncDeployConfig struct { + OCI *OCIFunctionDeployConfig `yaml:"oci,omitempty" json:"oci,omitempty"` +} + // FuncFile defines the internal structure of a func.yaml/json/yml type FuncFile struct { // just for posterity, this won't be set on old files, but we can check that @@ -96,6 +126,7 @@ type FuncFile struct { Config map[string]string `yaml:"config,omitempty" json:"config,omitempty"` IDLETimeout *int32 `yaml:"idle_timeout,omitempty" json:"idle_timeout,omitempty"` Annotations map[string]interface{} `yaml:"annotations,omitempty" json:"annotations,omitempty"` + Deploy *FuncDeployConfig `yaml:"deploy,omitempty" json:"deploy,omitempty"` // Run/test Expects Expects `yaml:"expects,omitempty" json:"expects,omitempty"` @@ -128,6 +159,7 @@ type FuncFileV20180708 struct { Config map[string]string `yaml:"config,omitempty" json:"config,omitempty"` Annotations map[string]interface{} `yaml:"annotations,omitempty" json:"annotations,omitempty"` + Deploy *FuncDeployConfig `yaml:"deploy,omitempty" json:"deploy,omitempty"` SigningDetails SigningDetails `yaml:"signing_details,omitempty" json:"signing_details,omitempty""` @@ -388,6 +420,43 @@ func (ff *FuncFileV20180708) ImageNameV20180708() string { return fname } +// HasOCIManagedFunctionSettings reports whether the func.yaml contains OCI-specific +// managed function settings that require OCI provider support. +func (ff *FuncFileV20180708) HasOCIManagedFunctionSettings() bool { + if ff == nil || ff.Deploy == nil || ff.Deploy.OCI == nil { + return false + } + + oci := ff.Deploy.OCI + if oci.ProvisionedConcurrency != nil && (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 + } + + return false +} + +// OCIManagedFunctionSettingNames returns the OCI-managed function setting groups +// present in func.yaml in a stable order for warnings and diagnostics. +func (ff *FuncFileV20180708) OCIManagedFunctionSettingNames() []string { + if ff == nil || ff.Deploy == nil || ff.Deploy.OCI == nil { + return nil + } + + var settings []string + oci := ff.Deploy.OCI + if oci.ProvisionedConcurrency != nil && (oci.ProvisionedConcurrency.Strategy != "" || oci.ProvisionedConcurrency.Count != nil) { + settings = append(settings, "provisioned_concurrency") + } + if oci.DetachedMode != nil && (oci.DetachedMode.Timeout != "" || oci.DetachedMode.OnSuccess != nil || oci.DetachedMode.OnFailure != nil) { + settings = append(settings, "detached_mode") + } + + return settings +} + // Merge the func.init.yaml from the initImage with a.ff // // write out the new func file @@ -409,5 +478,8 @@ func MergeFuncFileInitYAML(path string, ff *FuncFileV20180708) error { ff.Expects = initFf.Expects ff.Run_image = initFf.RunImage ff.Runtime = initFf.Runtime + if initFf.Deploy != nil { + ff.Deploy = initFf.Deploy + } return nil } diff --git a/common/funcfile_test.go b/common/funcfile_test.go index 26f9a589..29e9758d 100644 --- a/common/funcfile_test.go +++ b/common/funcfile_test.go @@ -104,6 +104,63 @@ entrypoint: ./func } } +func TestMergeFuncFileInitYAMLCopiesDeploySection(t *testing.T) { + ff := FuncFileV20180708{Name: "hello"} + initYAML := ` +schema_version: 20180708 +runtime: go +deploy: + oci: + provisioned_concurrency: + strategy: CONSTANT + count: 3 + detached_mode: + timeout: 20m + on_success: + type: stream + ocid: ocid1.stream.oc1..example +` + folder, filePath := createInitYAML(initYAML) + defer os.RemoveAll(folder) + + if err := MergeFuncFileInitYAML(filePath, &ff); err != nil { + t.Fatalf("MergeFuncFileInitYAML() error = %v", err) + } + if ff.Deploy == nil || ff.Deploy.OCI == nil || ff.Deploy.OCI.ProvisionedConcurrency == nil { + t.Fatalf("expected deploy.oci.provisioned_concurrency to be copied from init yaml") + } + if ff.Deploy.OCI.ProvisionedConcurrency.Strategy != "CONSTANT" { + t.Fatalf("expected provisioned concurrency strategy CONSTANT, got %q", ff.Deploy.OCI.ProvisionedConcurrency.Strategy) + } + if ff.Deploy.OCI.ProvisionedConcurrency.Count == nil || *ff.Deploy.OCI.ProvisionedConcurrency.Count != 3 { + t.Fatalf("expected provisioned concurrency count 3, got %#v", ff.Deploy.OCI.ProvisionedConcurrency.Count) + } + if ff.Deploy.OCI.DetachedMode == nil || ff.Deploy.OCI.DetachedMode.Timeout != "20m" { + t.Fatalf("expected detached mode timeout to be copied, got %#v", ff.Deploy.OCI.DetachedMode) + } +} + +func TestFuncFileV20180708OCIManagedFunctionSettingsHelpers(t *testing.T) { + count := 5 + ff := &FuncFileV20180708{ + Deploy: &FuncDeployConfig{ + OCI: &OCIFunctionDeployConfig{ + ProvisionedConcurrency: &OCIProvisionedConcurrencyConfig{Strategy: "CONSTANT", Count: &count}, + DetachedMode: &OCIDetachedModeConfig{Timeout: "20m"}, + }, + }, + } + + if !ff.HasOCIManagedFunctionSettings() { + t.Fatal("expected HasOCIManagedFunctionSettings to return true") + } + want := []string{"provisioned_concurrency", "detached_mode"} + got := ff.OCIManagedFunctionSettingNames() + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected OCIManagedFunctionSettingNames %v, got %v", want, got) + } +} + func createInitYAML(contents string) (string, string) { folder, err := ioutil.TempDir(os.TempDir(), "fn-tests") if err != nil { diff --git a/common/oci_provider_support.go b/common/oci_provider_support.go new file mode 100644 index 00000000..2a9a5423 --- /dev/null +++ b/common/oci_provider_support.go @@ -0,0 +1,51 @@ +package common + +import ( + "fmt" + "io" + "reflect" + "strings" + + fnprovider "github.com/fnproject/fn_go/provider" +) + +// IsOracleProvider reports whether the current provider is the OCI Functions provider. +func IsOracleProvider(p fnprovider.Provider) bool { + if p == nil { + return false + } + typ := reflect.TypeOf(p) + if typ == nil { + return false + } + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + return typ.PkgPath() == "github.com/fnproject/fn_go/provider/oracle" && typ.Name() == "OracleProvider" +} + +// WarnIfOCIManagedFunctionSettingsUnsupported emits a warning when func.yaml +// contains OCI-specific managed function settings but the active provider does +// not support OCI-managed function features. +func WarnIfOCIManagedFunctionSettingsUnsupported(w io.Writer, p fnprovider.Provider, fnName string, ff *FuncFileV20180708) bool { + if w == nil || ff == nil || !ff.HasOCIManagedFunctionSettings() || IsOracleProvider(p) { + return false + } + + settings := ff.OCIManagedFunctionSettingNames() + if len(settings) == 0 { + return false + } + + if fnName == "" { + fnName = ff.Name + } + + _, _ = fmt.Fprintf( + w, + "Warning: function %s contains OCI-specific deploy settings (%s), but the current provider does not support OCI managed function features. These settings will be ignored.\n", + fnName, + strings.Join(settings, ", "), + ) + return true +} \ No newline at end of file diff --git a/common/oci_provider_support_test.go b/common/oci_provider_support_test.go new file mode 100644 index 00000000..91c7fca0 --- /dev/null +++ b/common/oci_provider_support_test.go @@ -0,0 +1,35 @@ +package common + +import ( + "bytes" + "strings" + "testing" +) + +func TestWarnIfOCIManagedFunctionSettingsUnsupported(t *testing.T) { + count := 2 + ff := &FuncFileV20180708{ + Name: "hello", + Deploy: &FuncDeployConfig{ + OCI: &OCIFunctionDeployConfig{ + ProvisionedConcurrency: &OCIProvisionedConcurrencyConfig{ + Strategy: "CONSTANT", + Count: &count, + }, + }, + }, + } + + var stderr bytes.Buffer + warned := WarnIfOCIManagedFunctionSettingsUnsupported(&stderr, nil, ff.Name, ff) + if !warned { + t.Fatal("expected warning helper to report that a warning was emitted") + } + output := stderr.String() + if !strings.Contains(output, "OCI-specific deploy settings") { + t.Fatalf("expected warning output to mention OCI-specific deploy settings, got %q", output) + } + if !strings.Contains(output, "provisioned_concurrency") { + t.Fatalf("expected warning output to include setting name, got %q", output) + } +} \ No newline at end of file diff --git a/common/schema.go b/common/schema.go index 02d6f188..38ab1fe8 100644 --- a/common/schema.go +++ b/common/schema.go @@ -72,6 +72,57 @@ const V20180708Schema = `{ "config": { "type": "object" }, + "deploy": { + "type": "object", + "properties": { + "oci": { + "type": "object", + "properties": { + "provisioned_concurrency": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + }, + "detached_mode": { + "type": "object", + "properties": { + "timeout": { + "type": "string" + }, + "on_success": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "ocid": { + "type": "string" + } + } + }, + "on_failure": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "ocid": { + "type": "string" + } + } + } + } + } + } + } + } + }, "triggers": { "type": "array", "properties": { diff --git a/common/schema_test.go b/common/schema_test.go new file mode 100644 index 00000000..2d2b7b12 --- /dev/null +++ b/common/schema_test.go @@ -0,0 +1,50 @@ +package common + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateFileAgainstSchemaAcceptsOCIDeployConfig(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change working directory: %v", err) + } + + jsonFile := filepath.Join(tmpDir, "temp.json") + content := `{ + "schema_version": 20180708, + "name": "hello", + "version": "0.0.1", + "runtime": "go", + "entrypoint": "./func", + "deploy": { + "oci": { + "provisioned_concurrency": { + "strategy": "CONSTANT", + "count": 5 + }, + "detached_mode": { + "timeout": "20m", + "on_success": { + "type": "stream", + "ocid": "ocid1.stream.oc1..example" + } + } + } + } + }` + if err := os.WriteFile(jsonFile, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp schema file: %v", err) + } + + if err := ValidateFileAgainstSchema("temp.json", V20180708Schema); err != nil { + t.Fatalf("ValidateFileAgainstSchema() error = %v", err) + } +} \ No newline at end of file diff --git a/test/cli_misc_test.go b/test/cli_misc_test.go index 7c45b189..36ec28d8 100644 --- a/test/cli_misc_test.go +++ b/test/cli_misc_test.go @@ -24,6 +24,7 @@ import ( "strings" "testing" + "github.com/fnproject/cli/common" "github.com/fnproject/cli/testharness" ) @@ -164,6 +165,51 @@ func TestSettingMemoryWorks(t *testing.T) { h.Fn("invoke", appName, "another").AssertSuccess() } +func TestFuncYamlWithOCIManagedSettingsParsesInHarness(t *testing.T) { + t.Parallel() + + h := testharness.Create(t) + defer h.Cleanup() + h.MkDir("hello") + h.Cd("hello") + withMinimalFunction(h) + count := 1 + h.WriteYamlFile("func.yaml", common.FuncFileV20180708{ + Schema_version: common.LatestYamlVersion, + Name: "hello", + Version: "0.0.1", + Runtime: "go", + Entrypoint: "./func", + Build_image: "fnproject/go:dev", + Run_image: "fnproject/go", + Deploy: &common.FuncDeployConfig{ + OCI: &common.OCIFunctionDeployConfig{ + ProvisionedConcurrency: &common.OCIProvisionedConcurrencyConfig{ + Strategy: "CONSTANT", + Count: &count, + }, + DetachedMode: &common.OCIDetachedModeConfig{ + Timeout: "20m", + }, + }, + }, + }) + + yamlFile := h.GetYamlFile("func.yaml") + if yamlFile.Deploy == nil || yamlFile.Deploy.OCI == nil { + t.Fatal("expected deploy.oci to be parsed from func.yaml") + } + if yamlFile.Deploy.OCI.ProvisionedConcurrency == nil || yamlFile.Deploy.OCI.ProvisionedConcurrency.Strategy != "CONSTANT" { + t.Fatalf("expected provisioned concurrency settings to be parsed, got %#v", yamlFile.Deploy.OCI.ProvisionedConcurrency) + } + if yamlFile.Deploy.OCI.ProvisionedConcurrency.Count == nil || *yamlFile.Deploy.OCI.ProvisionedConcurrency.Count != 1 { + t.Fatalf("expected provisioned concurrency count 1, got %#v", yamlFile.Deploy.OCI.ProvisionedConcurrency.Count) + } + if yamlFile.Deploy.OCI.DetachedMode == nil || yamlFile.Deploy.OCI.DetachedMode.Timeout != "20m" { + t.Fatalf("expected detached mode timeout 20m, got %#v", yamlFile.Deploy.OCI.DetachedMode) + } +} + func TestAllMainCommandsExist(t *testing.T) { t.Parallel()