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

potiuk 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 f549b34c682 Stop variables export/import from silently corrupting 
values (#70944)
f549b34c682 is described below

commit f549b34c682bfead7b611b1b20c5ae734714ed3d
Author: Y-C <[email protected]>
AuthorDate: Wed Aug 19 02:43:39 2026 +0800

    Stop variables export/import from silently corrupting values (#70944)
    
    * Stop variables export/import from silently corrupting values
    
    `airflow variables export` writes a bare value for any variable without a
    description, while `airflow variables import` treats every dict carrying a
    "value" key as a {"value": ..., "description": ...} envelope. A variable 
whose
    own value happens to have that shape is therefore unwrapped on the way back 
in:
    its value is replaced by the inner "value" and a description is invented 
from
    the inner "description". Nothing warns the operator, so a round-trip 
through a
    file - the documented way to migrate variables between environments - 
quietly
    rewrites their data.
    
    Export also decodes the stored JSON before writing it out, which erases the
    difference between a variable holding raw text and one holding a 
JSON-encoded
    string. Re-importing flattens the latter, and any Dag reading it with
    deserialize_json=True starts failing on a value that is no longer valid 
JSON.
    
    Import's reconstruction is deterministic - strings are stored verbatim,
    everything else is JSON-encoded, and one envelope layer is unwrapped - so 
export
    alone can be made lossless. Fixing it there leaves import untouched and 
keeps
    hand-written import files working exactly as before.
    
    * apply suggestion
    
    ---------
    
    Co-authored-by: Eason09053360 
<[email protected]>
---
 .../src/airflow/cli/commands/variable_command.py   |  5 +++-
 .../unit/cli/commands/test_variable_command.py     | 34 ++++++++++++++++++++++
 2 files changed, 38 insertions(+), 1 deletion(-)

diff --git a/airflow-core/src/airflow/cli/commands/variable_command.py 
b/airflow-core/src/airflow/cli/commands/variable_command.py
index 194b02b529a..8cdf5490d3a 100644
--- a/airflow-core/src/airflow/cli/commands/variable_command.py
+++ b/airflow-core/src/airflow/cli/commands/variable_command.py
@@ -186,11 +186,14 @@ def variables_export(args):
 
         data = json.JSONDecoder()
         for var in qry:
+            # Mirror variables_import's reconstruction so export/import 
round-trips.
             try:
                 val = data.decode(var.val)
             except Exception:
                 val = var.val
-            if var.description:
+            if isinstance(val, str):
+                val = var.val
+            if var.description or (isinstance(val, dict) and "value" in val):
                 var_dict[var.key] = {
                     "value": val,
                     "description": var.description,
diff --git a/airflow-core/tests/unit/cli/commands/test_variable_command.py 
b/airflow-core/tests/unit/cli/commands/test_variable_command.py
index a02c95aa317..ac8eb11a3ae 100644
--- a/airflow-core/tests/unit/cli/commands/test_variable_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_variable_command.py
@@ -360,6 +360,40 @@ class TestCliVariables:
         """Test variables_export command"""
         variable_command.variables_export(self.parser.parse_args(["variables", 
"export", os.devnull]))
 
+    @pytest.mark.parametrize(
+        ("stored_value", "expected_export"),
+        [
+            pytest.param(
+                '{"value": "a", "description": "b"}',
+                {"value": {"value": "a", "description": "b"}, "description": 
None},
+                id="envelope_lookalike",
+            ),
+            pytest.param(
+                '{"value": 1, "other": 2}',
+                {"value": {"value": 1, "other": 2}, "description": None},
+                id="envelope_lookalike_with_extra_keys",
+            ),
+            pytest.param('"hello"', '"hello"', id="json_string"),
+        ],
+    )
+    def test_variables_export_survives_reimport(self, tmp_path, stored_value, 
expected_export):
+        """Values that collide with the export format must round-trip through 
export/import."""
+        path = tmp_path / "variables.json"
+        variable_command.variables_set(self.parser.parse_args(["variables", 
"set", "k", stored_value]))
+        variable_command.variables_export(self.parser.parse_args(["variables", 
"export", os.fspath(path)]))
+
+        assert json.loads(path.read_text()) == {"k": expected_export}
+
+        variable_command.variables_delete(self.parser.parse_args(["variables", 
"delete", "k"]))
+        with create_session() as session:
+            variable_command.variables_import(
+                self.parser.parse_args(["variables", "import", 
os.fspath(path)]), session=session
+            )
+
+        assert Variable.get("k", deserialize_json=True) == 
json.loads(stored_value)
+        with create_session() as session:
+            assert 
session.scalar(select(Variable.description).where(Variable.key == "k")) is None
+
     def test_variables_isolation(self, tmp_path):
         """Test isolation of variables"""
         path1 = tmp_path / "testfile1.json"

Reply via email to