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

shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this 
push:
     new 1dda5ef3078 [airflow-ctl/v0-1-test] Handle undecryptable Variable 
values gracefully in Stable REST API (#65452) (#76075)
1dda5ef3078 is described below

commit 1dda5ef3078019460733334ccaa86eaec18b9c8c
Author: Shahar Epstein <[email protected]>
AuthorDate: Fri Oct 30 20:27:17 2026 +0200

    [airflow-ctl/v0-1-test] Handle undecryptable Variable values gracefully in 
Stable REST API (#65452) (#76075)
    
    * Allow null values for Variable value field to handle decryption failures 
gracefully
    
    * Document rationale in
    code, add a regression test, and add newsfragment 65452.bugfix.rst
    
    * Fix CI for #65452: single-line newsfragment, real fernet decrypt mock, 
regenerate openapi spec + UI/airflowctl datamodels
    
    * Fix CI for #65452: handle nullable Variable.value in UI, regenerate 
openapi/airflowctl datamodels, single-line newsfragment, real fernet decrypt 
mock
    
    * Removed all the comments, suggested by the reviewers
    
    ---------
    
    Same change as the v3-2-test backport 84fde7e64b1 (#67828), so airflowctl 
0.1.x can read a variable that Airflow 3.3+ (and the next 3.2.x) returns with a 
null value. The spec, UI openapi-gen and airflowctl datamodels were regenerated 
with this branch's generators. Adds an airflowctl test for a null value.
    
    (cherry picked from commit f4cc43d37cdc74b3cf08b5d5c8eb5e0a5e90e6ec)
    
    Co-authored-by: Md Zeeshan alam 
<[email protected]>
    Co-authored-by: Md Zeeshan Alam <[email protected]>
---
 .../api_fastapi/core_api/datamodels/variables.py   |  2 +-
 .../core_api/openapi/v2-rest-api-generated.yaml    |  5 +++--
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 11 +++++++--
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  2 +-
 .../ManageVariable/EditVariableButton.tsx          |  2 +-
 .../airflow/ui/src/pages/Variables/Variables.tsx   |  4 ++--
 .../core_api/routes/public/test_variables.py       | 26 ++++++++++++++++++++++
 .../src/airflowctl/api/datamodels/generated.py     |  2 +-
 .../tests/airflow_ctl/api/test_operations.py       | 12 ++++++++++
 9 files changed, 56 insertions(+), 10 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
index 9dc7969b69b..a781c79c6a2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
@@ -32,7 +32,7 @@ class VariableResponse(BaseModel):
     """Variable serializer for responses."""
 
     key: str
-    val: str = Field(alias="value")
+    val: str | None = Field(alias="value", default=None)
     description: str | None
     is_encrypted: bool
     team_name: str | None
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 4ddd9d82042..85535aef38f 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -13541,7 +13541,9 @@ components:
           type: string
           title: Key
         value:
-          type: string
+          anyOf:
+          - type: string
+          - type: 'null'
           title: Value
         description:
           anyOf:
@@ -13559,7 +13561,6 @@ components:
       type: object
       required:
       - key
-      - value
       - description
       - is_encrypted
       - team_name
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 63c051cd1b8..61004a8cd3e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -6901,7 +6901,14 @@ export const $VariableResponse = {
             title: 'Key'
         },
         value: {
-            type: 'string',
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
             title: 'Value'
         },
         description: {
@@ -6932,7 +6939,7 @@ export const $VariableResponse = {
         }
     },
     type: 'object',
-    required: ['key', 'value', 'description', 'is_encrypted', 'team_name'],
+    required: ['key', 'description', 'is_encrypted', 'team_name'],
     title: 'VariableResponse',
     description: 'Variable serializer for responses.'
 } as const;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 1ca6bf02034..e84257afabf 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1653,7 +1653,7 @@ export type VariableCollectionResponse = {
  */
 export type VariableResponse = {
     key: string;
-    value: string;
+    value?: string | null;
     description: string | null;
     is_encrypted: boolean;
     team_name: string | null;
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx
index 65d65014c35..264da41bccc 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx
@@ -50,7 +50,7 @@ const EditVariableButton = ({ disabled, variable }: Props) => 
{
     description: variable.description ?? "",
     key: variable.key,
     team_name: variable.team_name ?? "",
-    value: formatValue(variable.value),
+    value: formatValue(variable.value ?? ""),
   };
   const { editVariable, error, isPending, setError } = 
useEditVariable(initialVariableValue, {
     onSuccessConfirm: onClose,
diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx 
b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
index 053b432e106..149795e256f 100644
--- a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
@@ -93,9 +93,9 @@ const getColumns = ({
       cell: ({ row }) => (
         <Box minWidth={0} overflowWrap="anywhere" wordBreak="break-word">
           <TrimText
-            charLimit={open ? row.original.value.length : undefined}
+            charLimit={open ? (row.original.value?.length ?? 0) : undefined}
             showTooltip
-            text={row.original.value}
+            text={row.original.value ?? null}
           />
         </Box>
       ),
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
index 50871879675..583f75a3fae 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
@@ -254,6 +254,32 @@ class TestGetVariable(TestVariableEndpoint):
         body = response.json()
         assert f"The Variable with key: `{TEST_VARIABLE_KEY}` was not found" 
== body["detail"]
 
+    def 
test_get_should_respond_200_with_null_value_when_decryption_fails(self, 
test_client, session):
+        """
+        Regression test for https://github.com/apache/airflow/pull/65452.
+
+        If the stored value cannot be decrypted (for example after a Fernet key
+        rotation) ``Variable.get_val`` returns ``None``. The endpoint must then
+        respond with HTTP 200 and ``"value": null`` instead of failing with an
+        HTTP 500 caused by response-schema validation.
+        """
+        from cryptography.fernet import InvalidToken
+
+        self.create_variables()
+        with mock.patch("airflow.models.variable.get_fernet") as 
mock_get_fernet:
+            mock_get_fernet.return_value.decrypt.side_effect = InvalidToken
+            response = test_client.get(f"/variables/{TEST_VARIABLE_KEY}")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body == {
+            "key": TEST_VARIABLE_KEY,
+            "value": None,
+            "description": TEST_VARIABLE_DESCRIPTION,
+            "is_encrypted": True,
+            "team_name": None,
+        }
+
 
 class TestGetVariables(TestVariableEndpoint):
     @pytest.mark.enable_redact
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 6983b39dc07..f567cb94c37 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -958,7 +958,7 @@ class VariableResponse(BaseModel):
     """
 
     key: Annotated[str, Field(title="Key")]
-    value: Annotated[str, Field(title="Value")]
+    value: Annotated[str | None, Field(title="Value")] = None
     description: Annotated[str | None, Field(title="Description")] = None
     is_encrypted: Annotated[bool, Field(title="Is Encrypted")]
     team_name: Annotated[str | None, Field(title="Team Name")] = None
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 76262b2c38e..15a7a368f91 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -1869,6 +1869,18 @@ class TestVariablesOperations:
         response = client.variables.get(self.key)
         assert response == self.variable_response
 
+    def test_get_accepts_null_value(self):
+        """Servers with #65452 return ``value: null`` for a variable they 
cannot decrypt."""
+        payload = json.loads(self.variable_response.model_dump_json())
+        payload["value"] = None
+
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(200, json=payload)
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        response = client.variables.get(self.key)
+        assert response.value is None
+
     def test_list(self):
         def handle_request(request: httpx.Request) -> httpx.Response:
             assert request.url.path == "/api/v2/variables"

Reply via email to