This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new e5eda218ef4 Validate Cloud Function deploy body after template 
rendering (#70531)
e5eda218ef4 is described below

commit e5eda218ef4bad62097e058bfbecf3615262b849
Author: Dr Alex Mitre <[email protected]>
AuthorDate: Wed Sep 23 00:06:10 2026 -0600

    Validate Cloud Function deploy body after template rendering (#70531)
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 .../providers/google/cloud/operators/functions.py  |  19 ++--
 .../unit/google/cloud/operators/test_functions.py  | 121 ++++++++++++++++++---
 .../ci/prek/validate_operators_init_exemptions.txt |   1 -
 3 files changed, 116 insertions(+), 25 deletions(-)

diff --git 
a/providers/google/src/airflow/providers/google/cloud/operators/functions.py 
b/providers/google/src/airflow/providers/google/cloud/operators/functions.py
index 6b29f121c14..bfdc4791168 100644
--- a/providers/google/src/airflow/providers/google/cloud/operators/functions.py
+++ b/providers/google/src/airflow/providers/google/cloud/operators/functions.py
@@ -171,20 +171,18 @@ class 
CloudFunctionDeployFunctionOperator(GoogleCloudBaseOperator):
         self.gcp_conn_id = gcp_conn_id
         self.api_version = api_version
         self.zip_path = zip_path
-        self.zip_path_preprocessor = ZipPathPreprocessor(body, zip_path)
+        self.validate_body = validate_body
+        self.zip_path_preprocessor: ZipPathPreprocessor | None = None
         self._field_validator: GcpBodyFieldValidator | None = None
         self.impersonation_chain = impersonation_chain
-        if validate_body:
-            self._field_validator = 
GcpBodyFieldValidator(CLOUD_FUNCTION_VALIDATION, api_version=api_version)
-        self._validate_inputs()
         super().__init__(**kwargs)
 
-    def _validate_inputs(self) -> None:
+    def _validate_inputs(self, zip_path_preprocessor: ZipPathPreprocessor) -> 
None:
         if not self.location:
             raise AirflowException("The required parameter 'location' is 
missing")
         if not self.body:
             raise AirflowException("The required parameter 'body' is missing")
-        self.zip_path_preprocessor.preprocess_body()
+        zip_path_preprocessor.preprocess_body()
 
     def _validate_all_body_fields(self) -> None:
         if self._field_validator:
@@ -227,12 +225,19 @@ class 
CloudFunctionDeployFunctionOperator(GoogleCloudBaseOperator):
         }
 
     def execute(self, context: Context):
+        zip_path_preprocessor = ZipPathPreprocessor(self.body, self.zip_path)
+        self.zip_path_preprocessor = zip_path_preprocessor
+        if self.validate_body:
+            self._field_validator = GcpBodyFieldValidator(
+                CLOUD_FUNCTION_VALIDATION, api_version=self.api_version
+            )
+        self._validate_inputs(zip_path_preprocessor)
         hook = CloudFunctionsHook(
             gcp_conn_id=self.gcp_conn_id,
             api_version=self.api_version,
             impersonation_chain=self.impersonation_chain,
         )
-        if self.zip_path_preprocessor.should_upload_function():
+        if zip_path_preprocessor.should_upload_function():
             self.body[GCF_SOURCE_UPLOAD_URL] = self._upload_source_code(hook)
         self._validate_all_body_fields()
         self._set_airflow_version_label()
diff --git 
a/providers/google/tests/unit/google/cloud/operators/test_functions.py 
b/providers/google/tests/unit/google/cloud/operators/test_functions.py
index 9a08d695c68..b168721558b 100644
--- a/providers/google/tests/unit/google/cloud/operators/test_functions.py
+++ b/providers/google/tests/unit/google/cloud/operators/test_functions.py
@@ -80,10 +80,94 @@ class TestGcfFunctionDeploy:
             op.execute(None)
 
     def test_body_empty(self):
+        op = CloudFunctionDeployFunctionOperator(
+            project_id="test_project_id", location="test_region", body={}, 
task_id="id"
+        )
         with pytest.raises(AirflowException):
-            CloudFunctionDeployFunctionOperator(
-                project_id="test_project_id", location="test_region", body={}, 
task_id="id"
-            )
+            op.execute(None)
+
+    
@mock.patch("airflow.providers.google.cloud.operators.functions.CloudFunctionsHook")
+    def test_templated_body_with_zip_path_uploads_after_rendering(self, 
mock_hook):
+        """A templated ``body`` combined with ``zip_path`` must be 
preprocessed after rendering.
+
+        Before this rendered-body preprocessing, the pre-render ``body`` was 
still a Jinja
+        string when ``ZipPathPreprocessor`` ran (at ``__init__`` time), so its 
``x in self.body``
+        membership checks silently evaluated as substring tests and always 
returned ``False``.
+        ``upload_function`` was then left unset and defaulted to ``False``, so 
the zip was never
+        uploaded and ``sourceUploadUrl`` was never populated -- a silent wrong 
result, not a
+        crash. Validating after rendering (in ``execute``) makes the 
preprocessor see the
+        rendered dict and actually upload the source.
+        """
+        mock_hook.return_value.get_function.side_effect = mock.Mock(
+            side_effect=HttpError(resp=MOCK_RESP_404, content=b"not found")
+        )
+        mock_hook.return_value.upload_function_zip.return_value = 
"https://uploadUrl";
+        mock_hook.return_value.create_new_function.return_value = True
+        body = deepcopy(VALID_BODY)
+        body.pop("sourceArchiveUrl", None)
+        body["sourceUploadUrl"] = None
+        op = CloudFunctionDeployFunctionOperator(
+            project_id=GCP_PROJECT_ID,
+            location=GCP_LOCATION,
+            body="{{ var.value.body }}",
+            zip_path="/path/to/file.zip",
+            validate_body=False,
+            task_id="id",
+        )
+        # Template rendering replaces the Jinja expression with the resolved 
value before execute.
+        op.body = body
+        op.execute(context=mock.MagicMock())
+        mock_hook.return_value.upload_function_zip.assert_called_once_with(
+            project_id=GCP_PROJECT_ID, location=GCP_LOCATION, 
zip_path="/path/to/file.zip"
+        )
+        assert op.body["sourceUploadUrl"] == "https://uploadUrl";
+
+    
@mock.patch("airflow.providers.google.cloud.operators.functions.CloudFunctionsHook")
+    def test_templated_location_rendered_empty_raises(self, mock_hook):
+        """A templated ``location`` must be validated after rendering, not 
before.
+
+        Before validating after rendering, the truthiness check in 
``_validate_inputs`` ran
+        against the truthy, un-rendered ``"{{ ... }}"`` string at ``__init__`` 
time and passed,
+        so an empty rendered value was never caught and ``execute`` proceeded 
to deploy with an
+        empty location.
+        """
+        op = CloudFunctionDeployFunctionOperator(
+            project_id=GCP_PROJECT_ID,
+            location="{{ var.value.location }}",
+            body=deepcopy(VALID_BODY),
+            task_id="id",
+        )
+        # Template rendering replaces the Jinja expression with the resolved 
value before execute.
+        op.location = ""
+        with pytest.raises(AirflowException) as ctx:
+            op.execute(context=mock.MagicMock())
+        assert "The required parameter 'location' is missing" in str(ctx.value)
+        mock_hook.assert_not_called()
+
+    
@mock.patch("airflow.providers.google.cloud.operators.functions.CloudFunctionsHook")
+    def test_templated_api_version_validates_after_rendering(self, mock_hook):
+        """A templated ``api_version`` must reach ``GcpBodyFieldValidator`` 
rendered.
+
+        ``sourceRepositoryUrl`` is gated on ``api_version == "v1beta2"`` in
+        ``CLOUD_FUNCTION_VALIDATION``. Building the validator in ``__init__`` 
pinned it to the
+        un-rendered ``"{{ ... }}"`` string, which matches no gated spec, so 
every
+        version-specific field was silently skipped and an invalid value 
passed validation.
+        """
+        body = deepcopy(VALID_BODY)
+        body.pop("sourceArchiveUrl", None)
+        body["sourceRepositoryUrl"] = ""
+        op = CloudFunctionDeployFunctionOperator(
+            project_id=GCP_PROJECT_ID,
+            location=GCP_LOCATION,
+            body=body,
+            api_version="{{ var.value.api_version }}",
+            task_id="id",
+        )
+        # Template rendering replaces the Jinja expression with the resolved 
value before execute.
+        op.api_version = "v1beta2"
+        with pytest.raises(AirflowException, match="sourceRepositoryUrl"):
+            op.execute(context=mock.MagicMock())
+        mock_hook.return_value.create_new_function.assert_not_called()
 
     
@mock.patch("airflow.providers.google.cloud.operators.functions.CloudFunctionsHook")
     def test_deploy_execute(self, mock_hook):
@@ -154,19 +238,21 @@ class TestGcfFunctionDeploy:
 
     
@mock.patch("airflow.providers.google.cloud.operators.functions.CloudFunctionsHook")
     def test_empty_location(self, mock_hook):
+        op = CloudFunctionDeployFunctionOperator(
+            project_id="test_project_id", location="", body=None, task_id="id"
+        )
         with pytest.raises(AirflowException) as ctx:
-            CloudFunctionDeployFunctionOperator(
-                project_id="test_project_id", location="", body=None, 
task_id="id"
-            )
+            op.execute(None)
         err = ctx.value
         assert "The required parameter 'location' is missing" in str(err)
 
     
@mock.patch("airflow.providers.google.cloud.operators.functions.CloudFunctionsHook")
     def test_empty_body(self, mock_hook):
+        op = CloudFunctionDeployFunctionOperator(
+            project_id="test_project_id", location="test_region", body=None, 
task_id="id"
+        )
         with pytest.raises(AirflowException) as ctx:
-            CloudFunctionDeployFunctionOperator(
-                project_id="test_project_id", location="test_region", 
body=None, task_id="id"
-            )
+            op.execute(None)
         err = ctx.value
         assert "The required parameter 'body' is missing" in str(err)
 
@@ -381,20 +467,21 @@ class TestGcfFunctionDeploy:
             ),
         ],
     )
-    def test_invalid_source_code_union_field__init(self, source_code, message):
+    def test_invalid_source_code_union_field__preprocess(self, source_code, 
message):
         body = deepcopy(VALID_BODY)
         body.pop("sourceUploadUrl", None)
         body.pop("sourceArchiveUrl", None)
         zip_path = source_code.pop("zip_path", None)
         body.update(source_code)
+        op = CloudFunctionDeployFunctionOperator(
+            project_id="test_project_id",
+            location="test_region",
+            body=body,
+            task_id="id",
+            zip_path=zip_path,
+        )
         with pytest.raises(AirflowException, match=message):
-            CloudFunctionDeployFunctionOperator(
-                project_id="test_project_id",
-                location="test_region",
-                body=body,
-                task_id="id",
-                zip_path=zip_path,
-            )
+            op.execute(None)
 
     @pytest.mark.parametrize(
         ("source_code", "project_id"),
diff --git a/scripts/ci/prek/validate_operators_init_exemptions.txt 
b/scripts/ci/prek/validate_operators_init_exemptions.txt
index 675813b6031..afdc7deba3b 100644
--- a/scripts/ci/prek/validate_operators_init_exemptions.txt
+++ b/scripts/ci/prek/validate_operators_init_exemptions.txt
@@ -10,4 +10,3 @@ 
providers/amazon/src/airflow/providers/amazon/aws/operators/neptune.py::NeptuneS
 
providers/google/src/airflow/providers/google/cloud/operators/cloud_build.py::CloudBuildCreateBuildOperator
 
providers/google/src/airflow/providers/google/cloud/operators/cloud_storage_transfer_service.py::CloudDataTransferServiceCreateJobOperator
 
providers/google/src/airflow/providers/google/cloud/operators/dataproc.py::DataprocCreateClusterOperator
-providers/google/src/airflow/providers/google/cloud/operators/functions.py::CloudFunctionDeployFunctionOperator

Reply via email to