diff --git a/apis/v1alpha1/ack-generate-metadata.yaml b/apis/v1alpha1/ack-generate-metadata.yaml index a62a6a68..780c5ca5 100755 --- a/apis/v1alpha1/ack-generate-metadata.yaml +++ b/apis/v1alpha1/ack-generate-metadata.yaml @@ -1,9 +1,9 @@ ack_generate_info: - build_date: "2026-08-13T23:38:16Z" - build_hash: 65d45b2e6c9efd6aca20e0d36826d1e18e4ba2b7 + build_date: "2026-08-24T22:55:33Z" + build_hash: d4aaca5e1dd6acfd81094936dc825ad30d2bbb39 go_version: go1.26.5 - version: v0.62.1 -api_directory_checksum: 5868fc26988d19c4bcd0d5b5c8185065082453ba + version: v0.62.1-4-gd4aaca5 +api_directory_checksum: 69462dc651610808a38517afd5425bf346f9cffd api_version: v1alpha1 aws_sdk_go_version: v1.41.5 generator_config_info: diff --git a/apis/v1alpha1/types.go b/apis/v1alpha1/types.go index 2cef5872..260d0e99 100644 --- a/apis/v1alpha1/types.go +++ b/apis/v1alpha1/types.go @@ -17,14 +17,12 @@ package v1alpha1 import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" - "github.com/aws/aws-sdk-go/aws" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Hack to avoid import errors during build... var ( _ = &metav1.Time{} - _ = &aws.JSONValue{} _ = ackv1alpha1.AWSAccountID("") ) diff --git a/pkg/resource/function/hooks.go b/pkg/resource/function/hooks.go index 4ba98db6..464ded5b 100644 --- a/pkg/resource/function/hooks.go +++ b/pkg/resource/function/hooks.go @@ -38,6 +38,7 @@ var ( ErrCannotSetFunctionCSC = errors.New("cannot set function code signing config when package type is Image") ErrCodeSigningNotAvailable = errors.New("code signing is not available in this region") ErrCannotModifyTenancyConfig = errors.New("tenancy config cannot be modified after function creation") + ErrFunctionUpdating = errors.New("function update issued; requeueing to refresh status and apply any deferred change") ) var ( @@ -53,6 +54,10 @@ var ( ErrSourceImageDoesNotExist, 1*time.Minute, ) + requeueAfterFunctionUpdate = ackrequeue.NeededAfter( + ErrFunctionUpdating, + 5*time.Second, + ) ) // isFunctionPending returns true if the supplied Lambda Function is in a pending @@ -127,12 +132,26 @@ func (rm *resourceManager) customUpdateFunction( return updatedStatusResource, ackerr.NewTerminalError(ErrCannotModifyTenancyConfig) } + codeUpdate := delta.DifferentAt("Spec.Code.ImageURI") || + delta.DifferentAt("Spec.Code.SHA256") || + delta.DifferentAt("Spec.Architectures") + + configUpdate := delta.DifferentExcept( + "Spec.Code", + "Spec.Architectures", + "Spec.Tags", + "Spec.ReservedConcurrentExecutions", + "Spec.FunctionEventInvokeConfig", + "Spec.CodeSigningConfigARN", + "Spec.TenancyConfig", + ) + // Only try to update Spec.Code or Spec.Configuration at once. It is // not correct to sequentially call UpdateFunctionConfiguration and // UpdateFunctionCode because both of them can put the function in a // Pending state. switch { - case delta.DifferentAt("Spec.Code.ImageURI") || delta.DifferentAt("Spec.Code.SHA256") || delta.DifferentAt("Spec.Architectures"): + case codeUpdate: err = rm.updateFunctionCode(ctx, desired, delta, latest) if err != nil { if strings.Contains(err.Error(), "Provide a valid source image.") { @@ -141,24 +160,15 @@ func (rm *resourceManager) customUpdateFunction( return updatedStatusResource, err } } - case delta.DifferentExcept( - "Spec.Code", - "Spec.Tags", - "Spec.ReservedConcurrentExecutions", - "Spec.FunctionEventInvokeConfig", - "Spec.CodeSigningConfigARN", - "Spec.TenancyConfig"): + case configUpdate: err = rm.updateFunctionConfiguration(ctx, desired, delta) if err != nil { return updatedStatusResource, err } } - readOneLatest, err := rm.ReadOne(ctx, desired) - if err != nil { - return updatedStatusResource, err - } - return rm.concreteResource(readOneLatest), nil + // Force a requeue to both refresh Function status and apply any deferred field updates. + return updatedStatusResource, requeueAfterFunctionUpdate } // updateFunctionConfiguration calls the UpdateFunctionConfiguration to edit a diff --git a/pkg/version/version.go b/pkg/version/version.go index 11335bc4..9452918a 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -27,6 +27,6 @@ var ( // that generated this controller's code. They are baked in at code-generation // time and are immutable for the life of the generated code. const ( - ACKGenerateVersion = "v0.62.1" - ACKGenerateBuildDate = "2026-08-13T23:38:16Z" + ACKGenerateVersion = "v0.62.1-4-gd4aaca5" + ACKGenerateBuildDate = "2026-08-24T22:55:33Z" ) diff --git a/test/e2e/tests/test_event_source_mapping.py b/test/e2e/tests/test_event_source_mapping.py index 15c3992c..1c188c1e 100644 --- a/test/e2e/tests/test_event_source_mapping.py +++ b/test/e2e/tests/test_event_source_mapping.py @@ -20,7 +20,7 @@ from acktest.resources import random_suffix_name from acktest.aws.identity import get_region -from acktest.k8s import resource as k8s +from acktest.k8s import resource as k8s, condition from e2e import service_marker, CRD_GROUP, CRD_VERSION, load_lambda_resource from e2e.replacement_values import REPLACEMENT_VALUES @@ -220,6 +220,7 @@ def test_smoke_sqs_queue_stream_ref(self, lambda_client, lambda_function): assert k8s.get_resource_exists(ref) time.sleep(CREATE_WAIT_AFTER_SECONDS) + assert k8s.wait_on_condition(ref, condition.CONDITION_TYPE_RESOURCE_SYNCED, "True", wait_periods=5) esm_uuid = cr['status']['uuid'] diff --git a/test/e2e/tests/test_function.py b/test/e2e/tests/test_function.py index 86d602c9..0e90aa63 100644 --- a/test/e2e/tests/test_function.py +++ b/test/e2e/tests/test_function.py @@ -1363,3 +1363,110 @@ def test_function_code_signing_in_unsupported_region(self, lambda_client): assert deleted is True time.sleep(DELETE_WAIT_AFTER_SECONDS) + + def test_function_update_code_and_environment_variable(self, lambda_client): + # Regression test: a single update that changes both the code (sha256) + # and the configuration (environment variables) must apply BOTH changes. + # The controller can only issue UpdateFunctionCode or + # UpdateFunctionConfiguration in one reconcile, so it applies the code + # change first and must requeue to apply the deferred configuration + # change instead of prematurely reporting the resource as synced. + resource_name = random_suffix_name("functionupdatecodeenv", 24) + + resources = get_bootstrap_resources() + logging.debug(resources) + + archive_1 = open(LAMBDA_FUNCTION_FILE_PATH_ZIP, 'rb') + readFile_1 = archive_1.read() + hash_1 = hashlib.sha256(readFile_1) + binary_hash_1 = hash_1.digest() + base64_hash_1 = base64.b64encode(binary_hash_1).decode('utf-8') + + archive_2 = open(LAMBDA_FUNCTION_UPDATED_FILE_PATH_ZIP, 'rb') + readFile_2 = archive_2.read() + hash_2 = hashlib.sha256(readFile_2) + binary_hash_2 = hash_2.digest() + base64_hash_2 = base64.b64encode(binary_hash_2).decode('utf-8') + + replacements = REPLACEMENT_VALUES.copy() + replacements["FUNCTION_NAME"] = resource_name + replacements["BUCKET_NAME"] = resources.FunctionsBucket.name + replacements["LAMBDA_ROLE"] = resources.BasicRole.arn + replacements["LAMBDA_FILE_NAME"] = LAMBDA_FUNCTION_FILE_ZIP + replacements["RESERVED_CONCURRENT_EXECUTIONS"] = "0" + replacements["CODE_SIGNING_CONFIG_ARN"] = "" + replacements["AWS_REGION"] = get_region() + replacements["ARCHITECTURES"] = 'x86_64' + replacements["HASH"] = base64_hash_1 + + # Load Lambda CR + resource_data = load_lambda_resource( + "function_code_s3", + additional_replacements=replacements, + ) + logging.debug(resource_data) + + # Create k8s resource + ref = k8s.CustomResourceReference( + CRD_GROUP, CRD_VERSION, RESOURCE_PLURAL, + resource_name, namespace="default", + ) + k8s.create_custom_resource(ref, resource_data) + cr = k8s.wait_resource_consumed_by_controller(ref, wait_periods=CONTROLLER_WAIT_PERIODS, period_length=CONTROLLER_PERIOD_LENGTH) + + assert cr is not None + assert k8s.get_resource_exists(ref) + + time.sleep(CREATE_WAIT_AFTER_SECONDS) + + cr = k8s.wait_resource_consumed_by_controller(ref, wait_periods=CONTROLLER_WAIT_PERIODS, period_length=CONTROLLER_PERIOD_LENGTH) + + lambda_validator = LambdaValidator(lambda_client) + + # Assert the original code and empty environment are in place + assert cr["spec"]["code"]["s3Bucket"] == resources.FunctionsBucket.name + assert cr["spec"]["code"]["s3Key"] == LAMBDA_FUNCTION_FILE_ZIP + assert cr["spec"].get("environment", {}).get("variables", {}) == {} + + function = lambda_validator.get_function(resource_name) + assert function is not None + assert function["Configuration"]["CodeSha256"] == base64_hash_1 + assert function["Configuration"].get("Environment", {}).get("Variables", {}) == {} + + # Update both the code and the environment variables in a single patch + cr["spec"]["code"]["sha256"] = base64_hash_2 + cr["spec"]["code"]["s3Key"] = LAMBDA_FUNCTION_UPDATED_FILE_ZIP + cr["spec"]["environment"] = {"variables": {"TEST_ENV_VAR": "test_value"}} + + # Patch k8s resource + k8s.patch_custom_resource(ref, cr) + time.sleep(UPDATE_WAIT_AFTER_SECONDS) + + # The code change is applied first, then the environment change is + # applied on a requeued reconcile. Wait for the controller to converge + # (this must exceed the code-update requeue interval; a single + # UPDATE_WAIT_AFTER_SECONDS sleep is not enough to catch a regression). + assert k8s.wait_on_condition( + ref, + "ACK.ResourceSynced", + "True", + wait_periods=CONTROLLER_WAIT_PERIODS, + period_length=CONTROLLER_PERIOD_LENGTH, + ) + + # Both the code and the environment variables must be applied + function = lambda_validator.get_function(resource_name) + assert function is not None + assert function["Configuration"]["CodeSha256"] == base64_hash_2 + assert function["Configuration"].get("Environment", {}).get("Variables", {}) == { + "TEST_ENV_VAR": "test_value" + } + + # Delete k8s resource + _, deleted = k8s.delete_custom_resource(ref, wait_periods=DELETE_WAIT_PERIODS, period_length=DELETE_PERIOD_LENGTH) + assert deleted is True + + time.sleep(DELETE_WAIT_AFTER_SECONDS) + + # Check Lambda function doesn't exist + assert not lambda_validator.function_exists(resource_name)