From 183d98505b1aaf60b14c861d4dd484894c598cf0 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Thu, 27 Aug 2026 10:03:52 -0400 Subject: [PATCH] fix: reconcile spec.layers when the function has no layers in AWS sdkFind() builds the latest resource from a deep copy of the desired one. When GetFunction returns no layer, only Status.LayerStatuses was cleared, so Spec.Layers kept the desired value. desired and latest were then identical, no delta was computed for Spec.Layers and updateFunctionConfiguration skipped the field, leaving the resource marked as synced while AWS was never updated. Clear Spec.Layers as well, so the latest resource reflects the actual state in AWS and the delta is computed. The existing e2e test creates the function with layers already attached, so it never covered this path. Add a test that starts from a function without layers and then adds them. Issue #, if available: aws-controllers-k8s/community#3010 Signed-off-by: Carlos Gonzalez --- pkg/resource/function/sdk.go | 5 ++ .../sdk_read_one_post_set_output.go.tpl | 5 ++ test/e2e/resources/function_no_layers.yaml | 15 ++++ test/e2e/tests/test_function.py | 72 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 test/e2e/resources/function_no_layers.yaml diff --git a/pkg/resource/function/sdk.go b/pkg/resource/function/sdk.go index 18187af3..106d7c57 100644 --- a/pkg/resource/function/sdk.go +++ b/pkg/resource/function/sdk.go @@ -409,6 +409,11 @@ func (rm *resourceManager) sdkFind( ko.Spec.Layers = layer ko.Status.LayerStatuses = f16 } else { + // ko is a deep copy of the desired resource, so Spec.Layers has to be + // explicitly cleared when the function has no layers in AWS. Leaving the + // desired value in place makes desired and latest identical, no delta is + // computed for Spec.Layers and the layers are never attached. + ko.Spec.Layers = nil ko.Status.LayerStatuses = nil } if resp.Tags != nil { diff --git a/templates/hooks/function/sdk_read_one_post_set_output.go.tpl b/templates/hooks/function/sdk_read_one_post_set_output.go.tpl index 6230c3f9..3591ce85 100644 --- a/templates/hooks/function/sdk_read_one_post_set_output.go.tpl +++ b/templates/hooks/function/sdk_read_one_post_set_output.go.tpl @@ -39,6 +39,11 @@ ko.Spec.Layers = layer ko.Status.LayerStatuses = f16 } else { + // ko is a deep copy of the desired resource, so Spec.Layers has to be + // explicitly cleared when the function has no layers in AWS. Leaving the + // desired value in place makes desired and latest identical, no delta is + // computed for Spec.Layers and the layers are never attached. + ko.Spec.Layers = nil ko.Status.LayerStatuses = nil } if resp.Tags != nil { diff --git a/test/e2e/resources/function_no_layers.yaml b/test/e2e/resources/function_no_layers.yaml new file mode 100644 index 00000000..cf532657 --- /dev/null +++ b/test/e2e/resources/function_no_layers.yaml @@ -0,0 +1,15 @@ +apiVersion: lambda.services.k8s.aws/v1alpha1 +kind: Function +metadata: + name: $FUNCTION_NAME + annotations: + services.k8s.aws/region: $AWS_REGION +spec: + name: $FUNCTION_NAME + code: + s3Bucket: $BUCKET_NAME + s3Key: $LAMBDA_FILE_NAME + role: $LAMBDA_ROLE + runtime: python3.9 + handler: main + description: function created by ACK lambda-controller e2e tests diff --git a/test/e2e/tests/test_function.py b/test/e2e/tests/test_function.py index 0e90aa63..424026bf 100644 --- a/test/e2e/tests/test_function.py +++ b/test/e2e/tests/test_function.py @@ -800,6 +800,78 @@ def test_function_layers(self, lambda_client): # Check Lambda function doesn't exist assert not lambda_validator.function_exists(resource_name) + def test_function_add_layers_to_function_without_layers(self, lambda_client): + resource_name = random_suffix_name("functionnolayers", 24) + + resources = get_bootstrap_resources() + logging.debug(resources) + + replacements = REPLACEMENT_VALUES.copy() + replacements["FUNCTION_NAME"] = resource_name + replacements["BUCKET_NAME"] = resources.FunctionsBucket.name + replacements["LAMBDA_ROLE"] = resources.EICRole.arn + replacements["LAMBDA_FILE_NAME"] = LAMBDA_FUNCTION_FILE_ZIP + replacements["AWS_REGION"] = get_region() + + # Load Lambda CR that does not declare any layer + resource_data = load_lambda_resource( + "function_no_layers", + 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) + + # Check Lambda function exists and has no layer attached + assert lambda_validator.function_exists(resource_name) + function = lambda_validator.get_function(resource_name) + assert "Layers" not in function["Configuration"] + + # Add layers to a function that currently has none. GetFunction returns + # no layer for it, so the controller has to clear Spec.Layers on the + # latest resource for the delta against the desired spec to be computed. + layers_list = ["arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:68", "arn:aws:lambda:us-west-2:580247275435:layer:LambdaInsightsExtension:52"] + cr["spec"]["layers"] = layers_list + + # Patch k8s resource + k8s.patch_custom_resource(ref, cr) + time.sleep(UPDATE_WAIT_AFTER_SECONDS) + + # Check the layers were attached to the function in AWS + function = lambda_validator.get_function(resource_name) + assert len(function["Configuration"]["Layers"]) == len(layers_list) + for i in range(len(layers_list)): + assert function["Configuration"]["Layers"][i]["Arn"] == layers_list[i] + + # Check the layers are also reported back in the CR status + cr = k8s.get_resource(ref) + assert cr["status"]["layerStatuses"] is not None + assert len(cr["status"]["layerStatuses"]) == len(layers_list) + + # 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) + def test_function_event_invoke_config(self, lambda_client): resource_name = random_suffix_name("lambda-function", 24)