This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 d53814c1bfa Add `team_id` to variable APIs (#57102)
d53814c1bfa is described below
commit d53814c1bfa9b0bb442a6a523a57fdf68efe67b9
Author: Vincent <[email protected]>
AuthorDate: Mon Nov 24 12:59:31 2025 -0500
Add `team_id` to variable APIs (#57102)
---
.../api_fastapi/core_api/datamodels/variables.py | 3 +
.../core_api/openapi/v2-rest-api-generated.yaml | 13 ++++
airflow-core/src/airflow/models/variable.py | 21 ++++--
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 26 ++++++-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 2 +
.../core_api/routes/public/test_variables.py | 81 +++++++++++++++++++++-
airflow-core/tests/unit/models/test_variable.py | 11 +++
.../src/airflowctl/api/datamodels/generated.py | 2 +
task-sdk/tests/task_sdk/api/test_client.py | 2 +-
9 files changed, 152 insertions(+), 9 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 722be8e4684..ec2292f6ef3 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
@@ -19,6 +19,7 @@ from __future__ import annotations
import json
from collections.abc import Iterable
+from uuid import UUID
from pydantic import Field, JsonValue, model_validator
@@ -35,6 +36,7 @@ class VariableResponse(BaseModel):
val: str = Field(alias="value")
description: str | None
is_encrypted: bool
+ team_id: UUID | None
@model_validator(mode="after")
def redact_val(self) -> Self:
@@ -57,6 +59,7 @@ class VariableBody(StrictBaseModel):
key: str = Field(max_length=ID_LEN)
value: JsonValue = Field(serialization_alias="val")
description: str | None = Field(default=None)
+ team_id: UUID | None = Field(default=None)
class VariableCollectionResponse(BaseModel):
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 1abdc3f6fcb..59b9210a9d3 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
@@ -13053,6 +13053,12 @@ components:
- type: string
- type: 'null'
title: Description
+ team_id:
+ anyOf:
+ - type: string
+ format: uuid
+ - type: 'null'
+ title: Team Id
additionalProperties: false
type: object
required:
@@ -13092,12 +13098,19 @@ components:
is_encrypted:
type: boolean
title: Is Encrypted
+ team_id:
+ anyOf:
+ - type: string
+ format: uuid
+ - type: 'null'
+ title: Team Id
type: object
required:
- key
- value
- description
- is_encrypted
+ - team_id
title: VariableResponse
description: Variable serializer for responses.
VersionInfo:
diff --git a/airflow-core/src/airflow/models/variable.py
b/airflow-core/src/airflow/models/variable.py
index 684da9ac5b8..87d1445a065 100644
--- a/airflow-core/src/airflow/models/variable.py
+++ b/airflow-core/src/airflow/models/variable.py
@@ -30,7 +30,7 @@ from sqlalchemy.orm import Mapped, declared_attr,
reconstructor, synonym
from sqlalchemy_utils import UUIDType
from airflow._shared.secrets_masker import mask_secret
-from airflow.configuration import ensure_secrets_loaded
+from airflow.configuration import conf, ensure_secrets_loaded
from airflow.models.base import ID_LEN, Base
from airflow.models.crypto import get_fernet
from airflow.models.team import Team
@@ -149,7 +149,7 @@ class Variable(Base, LoggingMixin):
# means SQLA etc is loaded, but we can't avoid that unless/until we
add import shims as a big
# back-compat layer
- # If this is set it means are in some kind of execution context (Task,
Dag Parse or Triggerer perhaps)
+ # If this is set it means we are in some kind of execution context
(Task, Dag Parse or Triggerer perhaps)
# and should use the Task SDK API server path
if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"),
"SUPERVISOR_COMMS"):
warnings.warn(
@@ -185,6 +185,7 @@ class Variable(Base, LoggingMixin):
value: Any,
description: str | None = None,
serialize_json: bool = False,
+ team_id: str | None = None,
session: Session | None = None,
) -> None:
"""
@@ -196,13 +197,14 @@ class Variable(Base, LoggingMixin):
:param value: Value to set for the Variable
:param description: Description of the Variable
:param serialize_json: Serialize the value to a JSON string
+ :param team_id: ID of the team associated to the variable (if any)
:param session: optional session, use if provided or create a new one
"""
# TODO: This is not the best way of having compat, but it's "better
than erroring" for now. This still
# means SQLA etc is loaded, but we can't avoid that unless/until we
add import shims as a big
# back-compat layer
- # If this is set it means are in some kind of execution context (Task,
Dag Parse or Triggerer perhaps)
+ # If this is set it means we are in some kind of execution context
(Task, Dag Parse or Triggerer perhaps)
# and should use the Task SDK API server path
if hasattr(sys.modules.get("airflow.sdk.execution_time.task_runner"),
"SUPERVISOR_COMMS"):
warnings.warn(
@@ -221,6 +223,11 @@ class Variable(Base, LoggingMixin):
)
return
+ if team_id and not conf.getboolean("core", "multi_team"):
+ raise ValueError(
+ "Multi-team mode is not configured in the Airflow environment.
To assign a team to a variable, multi-mode must be enabled."
+ )
+
# check if the secret exists in the custom secrets' backend.
Variable.check_for_write_conflict(key=key)
if serialize_json:
@@ -235,7 +242,7 @@ class Variable(Base, LoggingMixin):
ctx = create_session()
with ctx as session:
- new_variable = Variable(key=key, val=stored_value,
description=description)
+ new_variable = Variable(key=key, val=stored_value,
description=description, team_id=team_id)
val = new_variable._val
is_encrypted = new_variable.is_encrypted
@@ -252,6 +259,7 @@ class Variable(Base, LoggingMixin):
val=val,
description=description,
is_encrypted=is_encrypted,
+ team_id=team_id,
)
stmt = pg_stmt.on_conflict_do_update(
index_elements=["key"],
@@ -259,6 +267,7 @@ class Variable(Base, LoggingMixin):
val=val,
description=description,
is_encrypted=is_encrypted,
+ team_id=team_id,
),
)
elif dialect_name == "mysql":
@@ -269,11 +278,13 @@ class Variable(Base, LoggingMixin):
val=val,
description=description,
is_encrypted=is_encrypted,
+ team_id=team_id,
)
stmt = mysql_stmt.on_duplicate_key_update(
val=val,
description=description,
is_encrypted=is_encrypted,
+ team_id=team_id,
)
else:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
@@ -283,6 +294,7 @@ class Variable(Base, LoggingMixin):
val=val,
description=description,
is_encrypted=is_encrypted,
+ team_id=team_id,
)
stmt = sqlite_stmt.on_conflict_do_update(
index_elements=["key"],
@@ -290,6 +302,7 @@ class Variable(Base, LoggingMixin):
val=val,
description=description,
is_encrypted=is_encrypted,
+ team_id=team_id,
),
)
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 b2502da7638..5142b744a77 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
@@ -6559,6 +6559,18 @@ export const $VariableBody = {
}
],
title: 'Description'
+ },
+ team_id: {
+ anyOf: [
+ {
+ type: 'string',
+ format: 'uuid'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Team Id'
}
},
additionalProperties: false,
@@ -6612,10 +6624,22 @@ export const $VariableResponse = {
is_encrypted: {
type: 'boolean',
title: 'Is Encrypted'
+ },
+ team_id: {
+ anyOf: [
+ {
+ type: 'string',
+ format: 'uuid'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Team Id'
}
},
type: 'object',
- required: ['key', 'value', 'description', 'is_encrypted'],
+ required: ['key', 'value', 'description', 'is_encrypted', 'team_id'],
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 0561ca268e1..8fefb28e5bd 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
@@ -1594,6 +1594,7 @@ export type VariableBody = {
key: string;
value: JsonValue;
description?: string | null;
+ team_id?: string | null;
};
/**
@@ -1612,6 +1613,7 @@ export type VariableResponse = {
value: string;
description: string | null;
is_encrypted: boolean;
+ team_id: string | null;
};
/**
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 6e6282508a9..b235db785b3 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
@@ -19,14 +19,17 @@ from __future__ import annotations
import json
from io import BytesIO
from unittest import mock
+from unittest.mock import ANY
import pytest
+from airflow.models.team import Team
from airflow.models.variable import Variable
from airflow.utils.session import provide_session
from tests_common.test_utils.asserts import assert_queries_count
-from tests_common.test_utils.db import clear_db_variables
+from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_teams, clear_db_variables
from tests_common.test_utils.logs import check_last_log
pytestmark = pytest.mark.db_test
@@ -62,6 +65,8 @@ def create_file_upload(content: dict) -> BytesIO:
@provide_session
def _create_variables(session) -> None:
+ team = session.query(Team).where(Team.name == "test").one()
+
Variable.set(
key=TEST_VARIABLE_KEY,
value=TEST_VARIABLE_VALUE,
@@ -87,6 +92,7 @@ def _create_variables(session) -> None:
key=TEST_VARIABLE_KEY4,
value=TEST_VARIABLE_VALUE4,
description=TEST_VARIABLE_DESCRIPTION4,
+ team_id=team.id,
session=session,
)
@@ -98,15 +104,31 @@ def _create_variables(session) -> None:
)
+@provide_session
+def _create_team(session) -> None:
+ session.add(Team(name="test"))
+ session.commit()
+
+
[email protected](scope="session")
+def team_id(session):
+ return str(session.query(Team.id).filter_by(name="test").one()[0])
+
+
class TestVariableEndpoint:
@pytest.fixture(autouse=True)
- def setup(self) -> None:
+ def setup(self):
clear_db_variables()
+ clear_db_teams()
+ with conf_vars({("core", "multi_team"): "True"}):
+ yield
- def teardown_method(self) -> None:
+ def teardown_method(self):
clear_db_variables()
+ clear_db_teams()
def create_variables(self):
+ _create_team()
_create_variables()
@@ -150,6 +172,7 @@ class TestGetVariable(TestVariableEndpoint):
"value": TEST_VARIABLE_VALUE,
"description": TEST_VARIABLE_DESCRIPTION,
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -159,6 +182,7 @@ class TestGetVariable(TestVariableEndpoint):
"value": "***",
"description": TEST_VARIABLE_DESCRIPTION2,
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -168,6 +192,7 @@ class TestGetVariable(TestVariableEndpoint):
"value": '{"password": "***"}',
"description": TEST_VARIABLE_DESCRIPTION3,
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -177,6 +202,7 @@ class TestGetVariable(TestVariableEndpoint):
"value": TEST_VARIABLE_VALUE4,
"description": TEST_VARIABLE_DESCRIPTION4,
"is_encrypted": True,
+ "team_id": ANY,
},
),
(
@@ -186,6 +212,7 @@ class TestGetVariable(TestVariableEndpoint):
"value": TEST_VARIABLE_SEARCH_VALUE,
"description": TEST_VARIABLE_SEARCH_DESCRIPTION,
"is_encrypted": True,
+ "team_id": None,
},
),
],
@@ -343,6 +370,7 @@ class TestPatchVariable(TestVariableEndpoint):
"value": "The new value",
"description": "The new description",
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -351,6 +379,7 @@ class TestPatchVariable(TestVariableEndpoint):
"key": TEST_VARIABLE_KEY,
"value": "The new value",
"description": "The new description",
+ "team_id": None,
},
{"update_mask": ["value"]},
{
@@ -358,6 +387,7 @@ class TestPatchVariable(TestVariableEndpoint):
"value": "The new value",
"description": TEST_VARIABLE_DESCRIPTION,
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -373,6 +403,7 @@ class TestPatchVariable(TestVariableEndpoint):
"value": "The new value",
"description": TEST_VARIABLE_DESCRIPTION4,
"is_encrypted": True,
+ "team_id": ANY,
},
),
(
@@ -388,6 +419,7 @@ class TestPatchVariable(TestVariableEndpoint):
"value": "***",
"description": TEST_VARIABLE_DESCRIPTION2,
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -403,6 +435,7 @@ class TestPatchVariable(TestVariableEndpoint):
"value": '{"password": "***"}',
"description": "new description",
"is_encrypted": True,
+ "team_id": None,
},
),
],
@@ -414,6 +447,25 @@ class TestPatchVariable(TestVariableEndpoint):
assert response.json() == expected_response
check_last_log(session, dag_id=None, event="patch_variable",
logical_date=None)
+ def test_patch_with_team_should_respond_200(self, test_client, session,
testing_team):
+ self.create_variables()
+ body = {
+ "key": TEST_VARIABLE_KEY,
+ "value": "The new value",
+ "description": "The new description",
+ "team_id": str(testing_team.id),
+ }
+ response = test_client.patch(f"/variables/{TEST_VARIABLE_KEY}",
json=body)
+ assert response.status_code == 200
+ assert response.json() == {
+ "key": TEST_VARIABLE_KEY,
+ "value": "The new value",
+ "description": "The new description",
+ "is_encrypted": True,
+ "team_id": str(testing_team.id),
+ }
+ check_last_log(session, dag_id=None, event="patch_variable",
logical_date=None)
+
def test_patch_should_respond_400(self, test_client):
response = test_client.patch(
f"/variables/{TEST_VARIABLE_KEY}",
@@ -463,6 +515,7 @@ class TestPostVariable(TestVariableEndpoint):
"value": "new variable value",
"description": "new variable description",
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -476,6 +529,7 @@ class TestPostVariable(TestVariableEndpoint):
"value": "***",
"description": "another password",
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -489,6 +543,7 @@ class TestPostVariable(TestVariableEndpoint):
"value": '{"password": "***"}',
"description": "some description",
"is_encrypted": True,
+ "team_id": None,
},
),
(
@@ -502,6 +557,7 @@ class TestPostVariable(TestVariableEndpoint):
"value": "",
"description": "some description",
"is_encrypted": True,
+ "team_id": None,
},
),
],
@@ -513,6 +569,25 @@ class TestPostVariable(TestVariableEndpoint):
assert response.json() == expected_response
check_last_log(session, dag_id=None, event="post_variable",
logical_date=None)
+ def test_post_with_team_should_respond_201(self, test_client,
testing_team, session):
+ self.create_variables()
+ body = {
+ "key": "new variable key",
+ "value": "new variable value",
+ "description": "new variable description",
+ "team_id": str(testing_team.id),
+ }
+ response = test_client.post("/variables", json=body)
+ assert response.status_code == 201
+ assert response.json() == {
+ "key": "new variable key",
+ "value": "new variable value",
+ "description": "new variable description",
+ "is_encrypted": True,
+ "team_id": str(testing_team.id),
+ }
+ check_last_log(session, dag_id=None, event="post_variable",
logical_date=None)
+
def test_post_should_respond_401(self, unauthenticated_test_client):
response = unauthenticated_test_client.post(
"/variables",
diff --git a/airflow-core/tests/unit/models/test_variable.py
b/airflow-core/tests/unit/models/test_variable.py
index b02a760a665..722b6a155a3 100644
--- a/airflow-core/tests/unit/models/test_variable.py
+++ b/airflow-core/tests/unit/models/test_variable.py
@@ -198,6 +198,17 @@ class TestVariable:
assert test_var.description == "a test variable"
assert test_var.val == "value"
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_set_variable_sets_team(self, testing_team, session):
+ Variable.set(key="key", value="value", team_id=testing_team.id,
session=session)
+ test_var = session.query(Variable).filter(Variable.key == "key").one()
+ assert test_var.team_id == testing_team.id
+ assert test_var.val == "value"
+
+ def test_set_variable_sets_team_multi_team_off(self, testing_team,
session):
+ with pytest.raises(ValueError, match=r"Multi-team mode is not
configured in the Airflow environment"):
+ Variable.set(key="key", value="value", team_id=testing_team.id,
session=session)
+
def test_variable_set_existing_value_to_blank(self, session):
test_value = "Some value"
test_key = "test_key"
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index 5621a9950cf..1cb5ac77dde 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -925,6 +925,7 @@ class VariableBody(BaseModel):
key: Annotated[str, Field(max_length=250, title="Key")]
value: JsonValue
description: Annotated[str | None, Field(title="Description")] = None
+ team_id: Annotated[UUID | None, Field(title="Team Id")] = None
class VariableResponse(BaseModel):
@@ -936,6 +937,7 @@ class VariableResponse(BaseModel):
value: Annotated[str, Field(title="Value")]
description: Annotated[str | None, Field(title="Description")] = None
is_encrypted: Annotated[bool, Field(title="Is Encrypted")]
+ team_id: Annotated[UUID | None, Field(title="Team Id")] = None
class VersionInfo(BaseModel):
diff --git a/task-sdk/tests/task_sdk/api/test_client.py
b/task-sdk/tests/task_sdk/api/test_client.py
index 0355c3abc87..be75d6ee1cd 100644
--- a/task-sdk/tests/task_sdk/api/test_client.py
+++ b/task-sdk/tests/task_sdk/api/test_client.py
@@ -264,7 +264,7 @@ class TestClient:
class TestTaskInstanceOperations:
"""
- Test that the TestVariableOperations class works as expected. While the
operations are simple, it
+ Test that the TestTaskInstanceOperations class works as expected. While
the operations are simple, it
still catches the basic functionality of the client for task instances
including endpoint and
response parsing.
"""