diff --git a/modelsv2/fn.go b/modelsv2/fn.go index 13de45c..de30665 100644 --- a/modelsv2/fn.go +++ b/modelsv2/fn.go @@ -28,6 +28,9 @@ type Fn struct { // Function configuration key values. Config map[string]string `json:"config,omitempty"` + // Code-only workflow flag. + CodeOnly bool `json:"code_only,omitempty"` + // Time when function was created. Always in UTC RFC3339. // Read Only: true // Format: date-time @@ -43,6 +46,21 @@ type Fn struct { // Full container image name, e.g. hub.docker.com/fnproject/yo or fnproject/yo (default registry: hub.docker.com) Image string `json:"image,omitempty"` + // Managed runtime configuration fields for code-only functions. + RuntimeConfigType string `json:"runtime_config_type,omitempty"` + RuntimeName string `json:"runtime_name,omitempty"` + RuntimeVersionID string `json:"runtime_version_id,omitempty"` + Handler string `json:"handler,omitempty"` + + // Archive source input for code-only functions. + SourceType string `json:"source_type,omitempty"` + SourceFile string `json:"source_file,omitempty"` + SourceBucketName string `json:"source_bucket_name,omitempty"` + SourceNamespace string `json:"source_namespace,omitempty"` + SourceObjectName string `json:"source_object_name,omitempty"` + SourceObjectVersion string `json:"source_object_version_id,omitempty"` + SourceArchive []byte `json:"source_archive,omitempty"` + // Maximum usable memory given to function (MiB). Memory uint64 `json:"memory,omitempty"` diff --git a/provider/oracle/shim/fns.go b/provider/oracle/shim/fns.go index 850475e..33eff69 100644 --- a/provider/oracle/shim/fns.go +++ b/provider/oracle/shim/fns.go @@ -1,12 +1,17 @@ package shim import ( + "context" "fmt" + "os" + "strings" + "time" "github.com/fnproject/fn_go/clientv2/fns" "github.com/fnproject/fn_go/modelsv2" "github.com/fnproject/fn_go/provider/oracle/shim/client" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" + ociCommon "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/functions" ) @@ -40,14 +45,21 @@ func (s *fnsShim) CreateFn(params *fns.CreateFnParams) (*fns.CreateFnOK, error) return nil, err } + sourceDetails, err := createFunctionSourceDetails(params.Body.Image, digest) + if params.Body.CodeOnly { + sourceDetails, err = createCodeOnlyFunctionSourceDetails(params.Body) + } + if err != nil { + return nil, err + } + details := functions.CreateFunctionDetails{ DisplayName: ¶ms.Body.Name, ApplicationId: ¶ms.Body.AppID, - Image: ¶ms.Body.Image, MemoryInMBs: &memory, - ImageDigest: digest, Config: params.Body.Config, TimeoutInSeconds: parseTimeout(params.Body.Timeout), + SourceDetails: sourceDetails, } req := functions.CreateFunctionRequest{CreateFunctionDetails: details} @@ -56,6 +68,9 @@ func (s *fnsShim) CreateFn(params *fns.CreateFnParams) (*fns.CreateFnOK, error) if err != nil { return nil, err } + if err := s.waitForFunctionLifecycleWorkRequest(ctxOrBackground(params.Context), res.OpcWorkRequestId); err != nil { + return nil, err + } return &fns.CreateFnOK{ Payload: ociFnToV2(res.Function), @@ -65,10 +80,13 @@ func (s *fnsShim) CreateFn(params *fns.CreateFnParams) (*fns.CreateFnOK, error) func (s *fnsShim) DeleteFn(params *fns.DeleteFnParams) (*fns.DeleteFnNoContent, error) { req := functions.DeleteFunctionRequest{FunctionId: ¶ms.FnID} - _, err := s.ociClient.DeleteFunction(ctxOrBackground(params.Context), req) + res, err := s.ociClient.DeleteFunction(ctxOrBackground(params.Context), req) if err != nil { return nil, err } + if err := s.waitForFunctionLifecycleWorkRequest(ctxOrBackground(params.Context), res.OpcWorkRequestId); err != nil { + return nil, err + } return &fns.DeleteFnNoContent{}, nil } @@ -179,12 +197,24 @@ func (s *fnsShim) UpdateFn(params *fns.UpdateFnParams) (*fns.UpdateFnOK, error) return nil, err } + var updateSourceDetails functions.UpdateFunctionSourceDetails + if params.Body.CodeOnly { + updateSourceDetails, err = createCodeOnlyUpdateSourceDetails(params.Body) + if err != nil { + return nil, err + } + } else if imagePtr != nil || digest != nil { + updateSourceDetails = functions.UpdateContainerImageFunctionSourceDetails{ + Image: imagePtr, + ImageDigest: digest, + } + } + details := functions.UpdateFunctionDetails{ - Image: imagePtr, - ImageDigest: digest, MemoryInMBs: memoryPtr, Config: params.Body.Config, TimeoutInSeconds: parseTimeout(params.Body.Timeout), + SourceDetails: updateSourceDetails, } req := functions.UpdateFunctionRequest{ @@ -193,13 +223,20 @@ func (s *fnsShim) UpdateFn(params *fns.UpdateFnParams) (*fns.UpdateFnOK, error) IfMatch: etag, } - res, err := s.ociClient.UpdateFunction(ctxOrBackground(params.Context), req) + updateRes, err := s.ociClient.UpdateFunction(ctxOrBackground(params.Context), req) + if err != nil { + return nil, err + } + if err := s.waitForFunctionLifecycleWorkRequest(ctxOrBackground(params.Context), updateRes.OpcWorkRequestId); err != nil { + return nil, err + } + getRes, err := s.ociClient.GetFunction(ctxOrBackground(params.Context), functions.GetFunctionRequest{FunctionId: ¶ms.FnID}) if err != nil { return nil, err } return &fns.UpdateFnOK{ - Payload: ociFnToV2(res.Function), + Payload: ociFnToV2(getRes.Function), }, nil } @@ -242,16 +279,7 @@ func ociFnToV2(ociFn functions.Function) *modelsv2.Fn { invokeEndpoint := fmt.Sprintf(invokeEndpointFmtString, *ociFn.InvokeEndpoint, *ociFn.Id) annotations[annotationCompartmentId] = *ociFn.CompartmentId - // For pbf functions image and its digest will be always empty - imageDigest := "" - if ociFn.ImageDigest != nil { - imageDigest = *ociFn.ImageDigest - } - - image := "" - if ociFn.Image != nil { - image = *ociFn.Image - } + image, imageDigest := imageFromSourceDetails(ociFn.SourceDetails) annotations[annotationImageDigest] = imageDigest annotations[annotationInvokeEndpoint] = invokeEndpoint @@ -282,16 +310,7 @@ func ociFnSummaryToV2(ociFnSummary functions.FunctionSummary) *modelsv2.Fn { invokeEndpoint := fmt.Sprintf(invokeEndpointFmtString, *ociFnSummary.InvokeEndpoint, *ociFnSummary.Id) annotations[annotationCompartmentId] = *ociFnSummary.CompartmentId - // For pbf functions image and its digest will be always empty - imageDigest := "" - if ociFnSummary.ImageDigest != nil { - imageDigest = *ociFnSummary.ImageDigest - } - - image := "" - if ociFnSummary.Image != nil { - image = *ociFnSummary.Image - } + image, imageDigest := imageFromSourceDetails(ociFnSummary.SourceDetails) annotations[annotationImageDigest] = imageDigest annotations[annotationInvokeEndpoint] = invokeEndpoint @@ -315,3 +334,257 @@ func ociFnSummaryToV2(ociFnSummary functions.FunctionSummary) *modelsv2.Fn { UpdatedAt: strfmt.DateTime(ociFnSummary.TimeUpdated.Time), } } + +func createFunctionSourceDetails(image string, digest *string) (functions.CreateFunctionSourceDetails, error) { + if image == "" { + return functions.CreateContainerImageFunctionSourceDetails{}, nil + } + return functions.CreateContainerImageFunctionSourceDetails{Image: &image, ImageDigest: digest}, nil +} + +func createCodeOnlyFunctionSourceDetails(fn *modelsv2.Fn) (functions.CreateFunctionSourceDetails, error) { + archiveSource, err := createArchiveSourceDetails(fn) + if err != nil { + return nil, err + } + runtimeConfig, err := createRuntimeConfig(fn) + if err != nil { + return nil, err + } + var handler *string + if strings.TrimSpace(fn.Handler) != "" { + h := strings.TrimSpace(fn.Handler) + handler = &h + } + return functions.CreateArchiveFunctionSourceDetails{ + ArchiveSourceDetails: archiveSource, + RuntimeConfig: runtimeConfig, + Handler: handler, + }, nil +} + +func createArchiveSourceDetails(fn *modelsv2.Fn) (functions.CreateArchiveSourceDetails, error) { + switch strings.ToLower(strings.TrimSpace(fn.SourceType)) { + case "direct": + if len(fn.SourceArchive) == 0 { + return nil, fmt.Errorf("direct source requires archive bytes") + } + return functions.CreateDirectArchiveSourceDetails{ArchiveFile: fn.SourceArchive}, nil + case "object-storage", "object_storage": + bucket := strings.TrimSpace(fn.SourceBucketName) + namespace := strings.TrimSpace(fn.SourceNamespace) + objectName := strings.TrimSpace(fn.SourceObjectName) + if bucket == "" || namespace == "" || objectName == "" { + return nil, fmt.Errorf("object-storage source requires bucket, namespace, and object name") + } + details := functions.CreateObjectStorageArchiveSourceDetails{ + BucketName: &bucket, + Namespace: &namespace, + ObjectName: &objectName, + } + if version := strings.TrimSpace(fn.SourceObjectVersion); version != "" { + details.ObjectVersionId = &version + } + return details, nil + default: + return nil, fmt.Errorf("unsupported code-only source type %q", fn.SourceType) + } +} + +func createRuntimeConfig(fn *modelsv2.Fn) (functions.CreateRuntimeConfig, error) { + switch strings.ToUpper(strings.TrimSpace(fn.RuntimeConfigType)) { + case "FUNCTION_UPDATE": + runtimeName := strings.TrimSpace(fn.RuntimeName) + return functions.CreateFunctionUpdateRuntimeConfig{FunctionsRuntimeName: &runtimeName}, nil + case "MANUAL": + runtimeName := strings.TrimSpace(fn.RuntimeName) + runtimeVersionID := strings.TrimSpace(fn.RuntimeVersionID) + return functions.CreateManualRuntimeConfig{ + FunctionsRuntimeName: &runtimeName, + FunctionsRuntimeVersionId: &runtimeVersionID, + }, nil + default: + return nil, fmt.Errorf("unsupported runtime config type %q", fn.RuntimeConfigType) + } +} + +func createCodeOnlyUpdateSourceDetails(fn *modelsv2.Fn) (functions.UpdateFunctionSourceDetails, error) { + var archiveSource functions.UpdateArchiveSourceDetails + var err error + if strings.TrimSpace(fn.SourceType) != "" { + archiveSource, err = createUpdateArchiveSourceDetails(fn) + if err != nil { + return nil, err + } + } + var runtimeConfig functions.UpdateRuntimeConfig + if strings.TrimSpace(fn.RuntimeConfigType) != "" { + runtimeConfig, err = createUpdateRuntimeConfig(fn) + if err != nil { + return nil, err + } + } + var handler *string + if strings.TrimSpace(fn.Handler) != "" { + h := strings.TrimSpace(fn.Handler) + handler = &h + } + return functions.UpdateArchiveFunctionSourceDetails{ + ArchiveSourceDetails: archiveSource, + Handler: handler, + RuntimeConfig: runtimeConfig, + }, nil +} + +func createUpdateArchiveSourceDetails(fn *modelsv2.Fn) (functions.UpdateArchiveSourceDetails, error) { + switch strings.ToLower(strings.TrimSpace(fn.SourceType)) { + case "direct": + if len(fn.SourceArchive) == 0 { + return nil, fmt.Errorf("direct source requires archive bytes") + } + return functions.UpdateDirectArchiveSourceDetails{ArchiveFile: fn.SourceArchive}, nil + case "object-storage", "object_storage": + details := functions.UpdateObjectStorageArchiveSourceDetails{} + if value := strings.TrimSpace(fn.SourceBucketName); value != "" { + details.BucketName = &value + } + if value := strings.TrimSpace(fn.SourceNamespace); value != "" { + details.Namespace = &value + } + if value := strings.TrimSpace(fn.SourceObjectName); value != "" { + details.ObjectName = &value + } + if value := strings.TrimSpace(fn.SourceObjectVersion); value != "" { + details.ObjectVersionId = &value + } + return details, nil + default: + return nil, fmt.Errorf("unsupported code-only source type %q", fn.SourceType) + } +} + +func createUpdateRuntimeConfig(fn *modelsv2.Fn) (functions.UpdateRuntimeConfig, error) { + switch strings.ToUpper(strings.TrimSpace(fn.RuntimeConfigType)) { + case "FUNCTION_UPDATE": + runtimeName := strings.TrimSpace(fn.RuntimeName) + return functions.UpdateFunctionUpdateRuntimeConfig{FunctionsRuntimeName: &runtimeName}, nil + case "MANUAL": + runtimeName := strings.TrimSpace(fn.RuntimeName) + runtimeVersionID := strings.TrimSpace(fn.RuntimeVersionID) + cfg := functions.UpdateManualRuntimeConfig{FunctionsRuntimeVersionId: &runtimeVersionID} + if runtimeName != "" { + cfg.FunctionsRuntimeName = &runtimeName + } + return cfg, nil + default: + return nil, fmt.Errorf("unsupported runtime config type %q", fn.RuntimeConfigType) + } +} + +func imageFromSourceDetails(details functions.FunctionSourceDetails) (string, string) { + if details == nil { + return "", "" + } + if container, ok := details.(functions.ContainerImageFunctionSourceDetails); ok { + image := "" + if container.Image != nil { + image = *container.Image + } + digest := "" + if container.ImageDigest != nil { + digest = *container.ImageDigest + } + return image, digest + } + return "", "" +} + +func (s *fnsShim) waitForFunctionLifecycleWorkRequest(ctx context.Context, workRequestID *string) error { + if workRequestID == nil || strings.TrimSpace(*workRequestID) == "" { + return nil + } + wrClient, err := s.newWorkRequestManagementClient() + if err != nil || wrClient == nil { + return err + } + fmt.Fprintf(os.Stderr, "Tracking work request %s\n", *workRequestID) + lastStatus := functions.OperationStatusEnum("") + lastPercent := float32(-1) + + for { + resp, err := wrClient.GetWorkRequest(ctx, functions.GetWorkRequestRequest{WorkRequestId: workRequestID}) + if err != nil { + return err + } + if resp.Status != lastStatus || (resp.PercentComplete != nil && *resp.PercentComplete != lastPercent) { + percent := float32(0) + if resp.PercentComplete != nil { + percent = *resp.PercentComplete + } + fmt.Fprintf(os.Stderr, "Work request %s status: %s (%.0f%%)\n", *workRequestID, resp.Status, percent) + lastStatus = resp.Status + lastPercent = percent + } + + switch resp.Status { + case functions.OperationStatusAccepted, + functions.OperationStatusInProgress, + functions.OperationStatusWaiting, + functions.OperationStatusCanceling: + wait := 2 * time.Second + if resp.RetryAfter != nil && *resp.RetryAfter > 0 { + wait = time.Duration(*resp.RetryAfter) * time.Second + } + time.Sleep(wait) + continue + case functions.OperationStatusSucceeded: + fmt.Fprintf(os.Stderr, "Work request %s completed successfully\n", *workRequestID) + return nil + case functions.OperationStatusFailed, + functions.OperationStatusCanceled, + functions.OperationStatusNeedsAttention: + return s.workRequestFailure(ctx, wrClient, workRequestID, resp.Status) + default: + return fmt.Errorf("work request %s ended in unexpected status %s", *workRequestID, resp.Status) + } + } +} + +func (s *fnsShim) newWorkRequestManagementClient() (*functions.WorkRequestManagementClient, error) { + switch c := s.ociClient.(type) { + case functions.FunctionsManagementClient: + return buildWorkRequestManagementClient(c.ConfigurationProvider(), c.Host) + case *functions.FunctionsManagementClient: + return buildWorkRequestManagementClient(c.ConfigurationProvider(), c.Host) + case interface{ ConfigurationProvider() *ociCommon.ConfigurationProvider }: + return buildWorkRequestManagementClient(c.ConfigurationProvider(), "") + default: + return nil, nil + } +} + +func buildWorkRequestManagementClient(configProvider *ociCommon.ConfigurationProvider, host string) (*functions.WorkRequestManagementClient, error) { + if configProvider == nil { + return nil, nil + } + wrClient, err := functions.NewWorkRequestManagementClientWithConfigurationProvider(*configProvider) + if err != nil { + return nil, err + } + if strings.TrimSpace(host) != "" { + wrClient.Host = host + } + return &wrClient, nil +} + +func (s *fnsShim) workRequestFailure(ctx context.Context, wrClient *functions.WorkRequestManagementClient, workRequestID *string, status functions.OperationStatusEnum) error { + message := fmt.Sprintf("work request %s ended with status %s", *workRequestID, status) + if wrClient == nil { + return fmt.Errorf("%s", message) + } + errResp, err := wrClient.ListWorkRequestErrors(ctx, functions.ListWorkRequestErrorsRequest{WorkRequestId: workRequestID}) + if err != nil || len(errResp.Items) == 0 || errResp.Items[0].Message == nil { + return fmt.Errorf("%s", message) + } + return fmt.Errorf("%s: %s", message, *errResp.Items[0].Message) +} diff --git a/provider/oracle/shim/fns_test.go b/provider/oracle/shim/fns_test.go index 6fd1a0a..0f773f0 100644 --- a/provider/oracle/shim/fns_test.go +++ b/provider/oracle/shim/fns_test.go @@ -6,10 +6,147 @@ import ( "github.com/fnproject/fn_go/modelsv2" "github.com/fnproject/fn_go/provider/oracle/shim/client" "github.com/golang/mock/gomock" + "github.com/oracle/oci-go-sdk/v65/functions" "github.com/stretchr/testify/assert" "testing" ) +func TestCreateCodeOnlyFunctionSourceDetailsDirect(t *testing.T) { + archiveBytes := []byte("zip-bytes") + fn := &modelsv2.Fn{ + CodeOnly: true, + SourceType: "direct", + SourceArchive: archiveBytes, + RuntimeConfigType: "FUNCTION_UPDATE", + RuntimeName: "python311.ol9", + Handler: "hello_world.handler", + } + + details, err := createCodeOnlyFunctionSourceDetails(fn) + assert.NoError(t, err) + + archiveDetails, ok := details.(functions.CreateArchiveFunctionSourceDetails) + assert.True(t, ok) + + directDetails, ok := archiveDetails.ArchiveSourceDetails.(functions.CreateDirectArchiveSourceDetails) + assert.True(t, ok) + assert.Equal(t, archiveBytes, directDetails.ArchiveFile) + + runtimeConfig, ok := archiveDetails.RuntimeConfig.(functions.CreateFunctionUpdateRuntimeConfig) + assert.True(t, ok) + if assert.NotNil(t, runtimeConfig.FunctionsRuntimeName) { + assert.Equal(t, "python311.ol9", *runtimeConfig.FunctionsRuntimeName) + } + if assert.NotNil(t, archiveDetails.Handler) { + assert.Equal(t, "hello_world.handler", *archiveDetails.Handler) + } +} + +func TestCreateCodeOnlyFunctionSourceDetailsObjectStorageManual(t *testing.T) { + fn := &modelsv2.Fn{ + CodeOnly: true, + SourceType: "object-storage", + SourceBucketName: "code-only-test-files", + SourceNamespace: "oraclefunctionsdevelopm", + SourceObjectName: "hello.0.0.1.zip", + SourceObjectVersion: "object-version-id", + RuntimeConfigType: "MANUAL", + RuntimeName: "python311.ol9", + RuntimeVersionID: "ocid1.functionsruntimeversion.oc1..exampleuniqueID", + Handler: "hello_world.handler", + } + + details, err := createCodeOnlyFunctionSourceDetails(fn) + assert.NoError(t, err) + + archiveDetails, ok := details.(functions.CreateArchiveFunctionSourceDetails) + assert.True(t, ok) + + objectStorageDetails, ok := archiveDetails.ArchiveSourceDetails.(functions.CreateObjectStorageArchiveSourceDetails) + assert.True(t, ok) + if assert.NotNil(t, objectStorageDetails.BucketName) { + assert.Equal(t, "code-only-test-files", *objectStorageDetails.BucketName) + } + if assert.NotNil(t, objectStorageDetails.Namespace) { + assert.Equal(t, "oraclefunctionsdevelopm", *objectStorageDetails.Namespace) + } + if assert.NotNil(t, objectStorageDetails.ObjectName) { + assert.Equal(t, "hello.0.0.1.zip", *objectStorageDetails.ObjectName) + } + if assert.NotNil(t, objectStorageDetails.ObjectVersionId) { + assert.Equal(t, "object-version-id", *objectStorageDetails.ObjectVersionId) + } + + runtimeConfig, ok := archiveDetails.RuntimeConfig.(functions.CreateManualRuntimeConfig) + assert.True(t, ok) + if assert.NotNil(t, runtimeConfig.FunctionsRuntimeName) { + assert.Equal(t, "python311.ol9", *runtimeConfig.FunctionsRuntimeName) + } + if assert.NotNil(t, runtimeConfig.FunctionsRuntimeVersionId) { + assert.Equal(t, "ocid1.functionsruntimeversion.oc1..exampleuniqueID", *runtimeConfig.FunctionsRuntimeVersionId) + } + if assert.NotNil(t, archiveDetails.Handler) { + assert.Equal(t, "hello_world.handler", *archiveDetails.Handler) + } +} + +func TestCreateArchiveSourceDetailsValidation(t *testing.T) { + _, err := createArchiveSourceDetails(&modelsv2.Fn{SourceType: "direct"}) + assert.EqualError(t, err, "direct source requires archive bytes") + + _, err = createArchiveSourceDetails(&modelsv2.Fn{SourceType: "object-storage", SourceBucketName: "bucket"}) + assert.EqualError(t, err, "object-storage source requires bucket, namespace, and object name") + + _, err = createArchiveSourceDetails(&modelsv2.Fn{SourceType: "something-else"}) + assert.EqualError(t, err, `unsupported code-only source type "something-else"`) +} + +func TestCreateCodeOnlyUpdateSourceDetailsDirect(t *testing.T) { + archiveBytes := []byte("updated-zip-bytes") + fn := &modelsv2.Fn{ + CodeOnly: true, + SourceType: "direct", + SourceArchive: archiveBytes, + RuntimeConfigType: "FUNCTION_UPDATE", + RuntimeName: "python311.ol9", + Handler: "hello_world.handler", + } + + details, err := createCodeOnlyUpdateSourceDetails(fn) + assert.NoError(t, err) + + archiveDetails, ok := details.(functions.UpdateArchiveFunctionSourceDetails) + assert.True(t, ok) + + directDetails, ok := archiveDetails.ArchiveSourceDetails.(functions.UpdateDirectArchiveSourceDetails) + assert.True(t, ok) + assert.Equal(t, archiveBytes, directDetails.ArchiveFile) + + runtimeConfig, ok := archiveDetails.RuntimeConfig.(functions.UpdateFunctionUpdateRuntimeConfig) + assert.True(t, ok) + if assert.NotNil(t, runtimeConfig.FunctionsRuntimeName) { + assert.Equal(t, "python311.ol9", *runtimeConfig.FunctionsRuntimeName) + } + if assert.NotNil(t, archiveDetails.Handler) { + assert.Equal(t, "hello_world.handler", *archiveDetails.Handler) + } +} + +func TestCreateUpdateRuntimeConfigManualAllowsOmittedRuntimeName(t *testing.T) { + config, err := createUpdateRuntimeConfig(&modelsv2.Fn{ + RuntimeConfigType: "MANUAL", + RuntimeVersionID: "ocid1.functionsruntimeversion.oc1..exampleuniqueID", + }) + assert.NoError(t, err) + + manualConfig, ok := config.(functions.UpdateManualRuntimeConfig) + assert.True(t, ok) + assert.Nil(t, manualConfig.FunctionsRuntimeName) + if assert.NotNil(t, manualConfig.FunctionsRuntimeVersionId) { + assert.Equal(t, "ocid1.functionsruntimeversion.oc1..exampleuniqueID", *manualConfig.FunctionsRuntimeVersionId) + } +} + func TestCreateFn(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish()