1# Licensed to the Apache Software Foundation (ASF) under one
2# or more contributor license agreements. See the NOTICE file
3# distributed with this work for additional information
4# regarding copyright ownership. The ASF licenses this file
5# to you under the Apache License, Version 2.0 (the
6# "License"); you may not use this file except in compliance
7# with the License. You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing,
12# software distributed under the License is distributed on an
13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14# KIND, either express or implied. See the License for the
15# specific language governing permissions and limitations
16# under the License.
17from __future__ import annotations
18
19import logging
20import os
21import pathlib
22import re
23import shlex
24import stat
25import subprocess
26import sys
27import warnings
28from base64 import b64encode
29from collections.abc import Callable
30from configparser import ConfigParser
31from importlib.util import find_spec
32from inspect import ismodule
33from io import StringIO
34from re import Pattern
35from typing import IO, TYPE_CHECKING, Any, overload
36from urllib.parse import urlsplit
37
38from airflow._shared.configuration.parser import (
39 AirflowConfigParser as _SharedAirflowConfigParser,
40 configure_parser_from_configuration_description,
41)
42from airflow._shared.module_loading import import_string
43from airflow.exceptions import AirflowConfigException, RemovedInAirflow4Warning
44from airflow.secrets import DEFAULT_SECRETS_SEARCH_PATH
45from airflow.task.weight_rule import WeightRule
46from airflow.utils import yaml
47
48if TYPE_CHECKING:
49 from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
50 from airflow.secrets import BaseSecretsBackend
51
52log = logging.getLogger(__name__)
53
54# show Airflow's deprecation warnings
55if not sys.warnoptions:
56 warnings.filterwarnings(action="default", category=DeprecationWarning, module="airflow")
57 warnings.filterwarnings(action="default", category=PendingDeprecationWarning, module="airflow")
58
59ConfigType = str | int | float | bool
60ConfigOptionsDictType = dict[str, ConfigType]
61ConfigSectionSourcesType = dict[str, str | tuple[str, str]]
62ConfigSourcesType = dict[str, ConfigSectionSourcesType]
63
64ENV_VAR_PREFIX = "AIRFLOW__"
65
66_AIRFLOW_V2_SENSITIVE_OPTIONS = {
67 ("core", "dataset_manager_kwargs"),
68 ("core", "internal_api_secret_key"),
69 ("smtp", "smtp_password"),
70}
71
72
73class _SecretKeys:
74 """Holds the secret keys used in Airflow during runtime."""
75
76 fernet_key: str = "" # Set only if needed when generating a new file
77 jwt_secret_key: str = ""
78
79
80class ConfigModifications:
81 """
82 Holds modifications to be applied when writing out the config.
83
84 :param rename: Mapping from (old_section, old_option) to (new_section, new_option)
85 :param remove: Set of (section, option) to remove
86 :param default_updates: Mapping from (section, option) to new default value
87 """
88
89 def __init__(self) -> None:
90 self.rename: dict[tuple[str, str], tuple[str, str]] = {}
91 self.remove: set[tuple[str, str]] = set()
92 self.default_updates: dict[tuple[str, str], str] = {}
93
94 def add_rename(self, old_section: str, old_option: str, new_section: str, new_option: str) -> None:
95 self.rename[(old_section, old_option)] = (new_section, new_option)
96
97 def add_remove(self, section: str, option: str) -> None:
98 self.remove.add((section, option))
99
100 def add_default_update(self, section: str, option: str, new_default: str) -> None:
101 self.default_updates[(section, option)] = new_default
102
103
104@overload
105def expand_env_var(env_var: None) -> None: ...
106
107
108@overload
109def expand_env_var(env_var: str) -> str: ...
110
111
112def expand_env_var(env_var: str | None) -> str | None:
113 """
114 Expand (potentially nested) env vars.
115
116 Repeat and apply `expandvars` and `expanduser` until
117 interpolation stops having any effect.
118 """
119 if not env_var or not isinstance(env_var, str):
120 return env_var
121 while True:
122 interpolated = os.path.expanduser(os.path.expandvars(str(env_var)))
123 if interpolated == env_var:
124 return interpolated
125 env_var = interpolated
126
127
128def run_command(command: str) -> str:
129 """Run command and returns stdout."""
130 process = subprocess.Popen(
131 shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True
132 )
133 output, stderr = (stream.decode(sys.getdefaultencoding(), "ignore") for stream in process.communicate())
134
135 if process.returncode != 0:
136 raise AirflowConfigException(
137 f"Cannot execute {command}. Error code is: {process.returncode}. "
138 f"Output: {output}, Stderr: {stderr}"
139 )
140
141 return output
142
143
144def _default_config_file_path(file_name: str) -> str:
145 templates_dir = os.path.join(os.path.dirname(__file__), "config_templates")
146 return os.path.join(templates_dir, file_name)
147
148
149def retrieve_configuration_description(
150 include_airflow: bool = True,
151 include_providers: bool = True,
152 selected_provider: str | None = None,
153) -> dict[str, dict[str, Any]]:
154 """
155 Read Airflow configuration description from YAML file.
156
157 :param include_airflow: Include Airflow configs
158 :param include_providers: Include provider configs
159 :param selected_provider: If specified, include selected provider only
160 :return: Python dictionary containing configs & their info
161 """
162 base_configuration_description: dict[str, dict[str, Any]] = {}
163 if include_airflow:
164 with open(_default_config_file_path("config.yml")) as config_file:
165 base_configuration_description.update(yaml.safe_load(config_file))
166 if include_providers:
167 from airflow.providers_manager import ProvidersManager
168
169 for provider, config in ProvidersManager().provider_configs:
170 if not selected_provider or provider == selected_provider:
171 base_configuration_description.update(config)
172 return base_configuration_description
173
174
175class AirflowConfigParser(_SharedAirflowConfigParser):
176 """
177 Custom Airflow Configparser supporting defaults and deprecated options.
178
179 This is a subclass of the shared AirflowConfigParser that adds Core-specific initialization
180 and functionality (providers, validation, writing, etc.).
181
182 The defaults are stored in the ``_default_values``. The configuration description keeps
183 description of all the options available in Airflow (description follow config.yaml.schema).
184
185 :param default_config: default configuration (in the form of ini file).
186 :param configuration_description: description of configuration to use
187 """
188
189 def __init__(
190 self,
191 default_config: str | None = None,
192 *args,
193 **kwargs,
194 ):
195 _configuration_description = retrieve_configuration_description(include_providers=False)
196 # For those who would like to use a different data structure to keep defaults:
197 # We have to keep the default values in a ConfigParser rather than in any other
198 # data structure, because the values we have might contain %% which are ConfigParser
199 # interpolation placeholders. The _default_values config parser will interpolate them
200 # properly when we call get() on it.
201 _default_values = create_default_config_parser(_configuration_description)
202 from airflow.providers_manager import ProvidersManager
203
204 super().__init__(
205 _configuration_description,
206 _default_values,
207 ProvidersManager,
208 create_default_config_parser,
209 _default_config_file_path("provider_config_fallback_defaults.cfg"),
210 *args,
211 **kwargs,
212 )
213 self._configuration_description = _configuration_description
214 self._default_values = _default_values
215 if default_config is not None:
216 self._update_defaults_from_string(default_config)
217 self._update_logging_deprecated_template_to_one_from_defaults()
218 self.is_validated = False
219 self._suppress_future_warnings = False
220
221 @property
222 def _validators(self) -> list[Callable[[], None]]:
223 """Overring _validators from shared base class to add core-specific validators."""
224 return [
225 self._validate_sqlite3_version,
226 self._validate_enums,
227 self._validate_deprecated_values,
228 self._upgrade_postgres_metastore_conn,
229 ]
230
231 def _update_logging_deprecated_template_to_one_from_defaults(self):
232 default = self.get_default_value("logging", "log_filename_template")
233 if default:
234 # Tuple does not support item assignment, so we have to create a new tuple and replace it
235 original_replacement = self.deprecated_values["logging"]["log_filename_template"]
236 self.deprecated_values["logging"]["log_filename_template"] = (
237 original_replacement[0],
238 default,
239 )
240
241 # A mapping of old default values that we want to change and warn the user
242 # about. Mapping of section -> setting -> { old, replace }
243 deprecated_values: dict[str, dict[str, tuple[Pattern, str]]] = {
244 "logging": {
245 "log_filename_template": (
246 re.compile(
247 re.escape(
248 "dag_id={{ ti.dag_id }}/run_id={{ ti.run_id }}/task_id={{ ti.task_id }}/{% if ti.map_index >= 0 %}map_index={{ ti.map_index }}/{% endif %}attempt={{ try_number }}.log"
249 )
250 ),
251 # The actual replacement value will be updated after defaults are loaded from config.yml
252 "XX-set-after-default-config-loaded-XX",
253 ),
254 },
255 "core": {
256 "executor": (re.compile(re.escape("SequentialExecutor")), "LocalExecutor"),
257 },
258 }
259
260 _available_logging_levels = ["CRITICAL", "FATAL", "ERROR", "WARN", "WARNING", "INFO", "DEBUG"]
261 enums_options = {
262 ("core", "default_task_weight_rule"): sorted(WeightRule.all_weight_rules()),
263 ("core", "dag_ignore_file_syntax"): ["regexp", "glob"],
264 ("dag_processor", "file_parsing_sort_mode"): [
265 "modified_time",
266 "random_seeded_by_host",
267 "alphabetical",
268 ],
269 ("logging", "logging_level"): _available_logging_levels,
270 ("logging", "fab_logging_level"): _available_logging_levels,
271 # celery_logging_level can be empty, which uses logging_level as fallback
272 ("logging", "celery_logging_level"): [*_available_logging_levels, ""],
273 # uvicorn and gunicorn logging levels for web servers
274 ("logging", "uvicorn_logging_level"): _available_logging_levels,
275 ("logging", "gunicorn_logging_level"): _available_logging_levels,
276 ("webserver", "analytical_tool"): ["google_analytics", "metarouter", "segment", "matomo", ""],
277 ("api", "grid_view_sorting_order"): ["topological", "hierarchical_alphabetical"],
278 ("logging", "dag_processor_log_target"): ["file", "stdout"],
279 }
280
281 upgraded_values: dict[tuple[str, str], str]
282 """Mapping of (section,option) to the old value that was upgraded"""
283
284 def write_custom_config(
285 self,
286 file: IO[str],
287 comment_out_defaults: bool = True,
288 include_descriptions: bool = True,
289 extra_spacing: bool = True,
290 modifications: ConfigModifications | None = None,
291 hide_sensitive: bool = False,
292 ) -> None:
293 """
294 Write a configuration file using a ConfigModifications object.
295
296 This method includes only options from the current airflow.cfg. For each option:
297 - If it's marked for removal, omit it.
298 - If renamed, output it under its new name and add a comment indicating its original location.
299 - If a default update is specified, apply the new default and output the option as a commented line.
300 - Otherwise, if the current value equals the default and comment_out_defaults is True, output it as a comment.
301 Options absent from the current airflow.cfg are omitted.
302
303 :param file: File to write the configuration.
304 :param comment_out_defaults: If True, options whose value equals the default are written as comments.
305 :param include_descriptions: Whether to include section descriptions.
306 :param extra_spacing: Whether to insert an extra blank line after each option.
307 :param modifications: ConfigModifications instance with rename, remove, and default updates.
308 :param hide_sensitive: If True, values of options registered as sensitive are written as
309 ``< hidden >``. Only suitable for preview output, never for a real configuration file.
310 """
311 modifications = modifications or ConfigModifications()
312 output: dict[str, list[tuple[str, str, bool, str]]] = {}
313
314 for section in self._sections: # type: ignore[attr-defined] # accessing _sections from ConfigParser
315 for option, orig_value in self._sections[section].items(): # type: ignore[attr-defined]
316 key = (section.lower(), option.lower())
317 if key in modifications.remove:
318 continue
319
320 mod_comment = ""
321 if key in modifications.rename:
322 new_sec, new_opt = modifications.rename[key]
323 effective_section = new_sec
324 effective_option = new_opt
325 mod_comment += f"# Renamed from {section}.{option}\n"
326 else:
327 effective_section = section
328 effective_option = option
329
330 value = orig_value
331 if key in modifications.default_updates:
332 mod_comment += (
333 f"# Default updated from {orig_value} to {modifications.default_updates[key]}\n"
334 )
335 value = modifications.default_updates[key]
336
337 default_value = self.get_default_value(effective_section, effective_option, fallback="")
338 is_default = str(value) == str(default_value)
339 value_to_write = str(value)
340 if hide_sensitive and (
341 key in _AIRFLOW_V2_SENSITIVE_OPTIONS
342 or self.is_sensitive_option(section, option)
343 or self.is_sensitive_option(effective_section, effective_option)
344 ):
345 value_to_write = "< hidden >"
346 output.setdefault(effective_section.lower(), []).append(
347 (effective_option, value_to_write, is_default, mod_comment)
348 )
349
350 for section, options in output.items():
351 section_buffer = StringIO()
352 section_buffer.write(f"[{section}]\n")
353 if include_descriptions:
354 description = self.configuration_description.get(section, {}).get("description", "")
355 if description:
356 for line in description.splitlines():
357 section_buffer.write(f"# {line}\n")
358 section_buffer.write("\n")
359 for option, value_str, is_default, mod_comment in options:
360 key = (section.lower(), option.lower())
361 if key in modifications.default_updates and comment_out_defaults:
362 section_buffer.write(f"# {option} = {value_str}\n")
363 else:
364 if mod_comment:
365 section_buffer.write(mod_comment)
366 if is_default and comment_out_defaults:
367 section_buffer.write(f"# {option} = {value_str}\n")
368 else:
369 section_buffer.write(f"{option} = {value_str}\n")
370 if extra_spacing:
371 section_buffer.write("\n")
372 content = section_buffer.getvalue().strip()
373 if content:
374 file.write(f"{content}\n\n")
375
376 def _upgrade_postgres_metastore_conn(self):
377 """
378 Normalize the metadata DB URL scheme.
379
380 SQLAlchemy does not accept the schemes `postgres+psycopg2` and `postgres`;
381 they are replaced with `postgresql+psycopg` if the psycopg (v3) driver
382 is installed, or `postgresql+psycopg2` otherwise, and a warning is emitted.
383 The valid bare `postgresql` scheme is rewritten the same way, without a
384 warning, to make the driver explicit.
385 """
386 section, key = "database", "sql_alchemy_conn"
387 old_value = self.get(section, key, _extra_stacklevel=1)
388 bad_schemes = ["postgres+psycopg2", "postgres", "postgresql"]
389 # The provider-side hooks (common.sql / postgres / amazon) also gate psycopg (v3) on an
390 # ``_is_sqlalchemy_2()`` check, because they support Airflow 2.11 on SQLAlchemy 1.4, which has
391 # no native ``postgresql+psycopg`` dialect. airflow-core pins ``sqlalchemy>=2.0``, so that
392 # dialect is always present when the package is importable — ``find_spec`` alone suffices here.
393 good_scheme = "postgresql+psycopg" if find_spec("psycopg") is not None else "postgresql+psycopg2"
394 parsed = urlsplit(old_value)
395 if parsed.scheme in bad_schemes:
396 # The bare ``postgresql`` scheme is valid in SQLAlchemy; we only make the driver explicit,
397 # so don't warn about it. ``postgres`` and ``postgres+psycopg2`` are really unsupported.
398 if parsed.scheme != "postgresql":
399 warnings.warn(
400 f"Unsupported scheme `{parsed.scheme}` in Airflow configuration [database] "
401 "sql_alchemy_conn: SQLAlchemy no longer accepts it. "
402 f"Airflow is using `{good_scheme}` instead for now; change the scheme to "
403 f"`postgresql` (Airflow picks the driver) or `{good_scheme}`.",
404 FutureWarning,
405 stacklevel=1,
406 )
407 self.upgraded_values[(section, key)] = old_value
408 new_value = re.sub("^" + re.escape(f"{parsed.scheme}://"), f"{good_scheme}://", old_value)
409 self._update_env_var(section=section, name=key, new_value=new_value)
410
411 # if the old value is set via env var, we need to wipe it
412 # otherwise, it'll "win" over our adjusted value
413 old_env_var = self._env_var_name("core", key)
414 os.environ.pop(old_env_var, None)
415
416 def _validate_enums(self):
417 """Validate that enum type config has an accepted value."""
418 for (section_key, option_key), enum_options in self.enums_options.items():
419 if self.has_option(section_key, option_key):
420 value = self.get(section_key, option_key, fallback=None)
421 if value and value not in enum_options:
422 raise AirflowConfigException(
423 f"`[{section_key}] {option_key}` should not be "
424 f"{value!r}. Possible values: {', '.join(enum_options)}."
425 )
426
427 def _validate_sqlite3_version(self):
428 """
429 Validate SQLite version.
430
431 Some features in storing rendered fields require SQLite >= 3.15.0.
432 """
433 if "sqlite" not in self.get("database", "sql_alchemy_conn"):
434 return
435
436 import sqlite3
437
438 min_sqlite_version = (3, 15, 0)
439 if sqlite3.sqlite_version_info >= min_sqlite_version:
440 return
441
442 from airflow.utils.docs import get_docs_url
443
444 min_sqlite_version_str = ".".join(str(s) for s in min_sqlite_version)
445 raise AirflowConfigException(
446 f"error: SQLite C library too old (< {min_sqlite_version_str}). "
447 f"See {get_docs_url('howto/set-up-database.html#setting-up-a-sqlite-database')}"
448 )
449
450 def _get_custom_secret_backend(self, worker_mode: bool | None = None) -> Any | None:
451 return super()._get_custom_secret_backend(
452 worker_mode=worker_mode if worker_mode is not None else False
453 )
454
455 def mask_secrets(self):
456 from airflow._shared.configuration.parser import _build_kwarg_env_prefix, _collect_kwarg_env_vars
457 from airflow._shared.secrets_masker import mask_secret as mask_secret_core
458 from airflow.sdk.log import mask_secret as mask_secret_sdk
459
460 for section, key in self.sensitive_config_values:
461 try:
462 with self.suppress_future_warnings():
463 value = self.get(section, key, suppress_warnings=True)
464 except AirflowConfigException:
465 log.debug(
466 "Could not retrieve value from section %s, for key %s. Skipping redaction of this conf.",
467 section,
468 key,
469 )
470 continue
471 mask_secret_core(value)
472 mask_secret_sdk(value)
473
474 # Mask per-key backend kwarg env vars (AIRFLOW__SECRETS__BACKEND_KWARG__* etc.).
475 # These are not in sensitive_config_values but may contain sensitive values.
476 for _section, _kwargs_key in [
477 ("secrets", "backend_kwargs"),
478 ("workers", "secrets_backend_kwargs"),
479 ]:
480 _prefix = _build_kwarg_env_prefix(_section, _kwargs_key)
481 for _value in _collect_kwarg_env_vars(_prefix).values():
482 mask_secret_core(_value)
483 mask_secret_sdk(_value)
484
485 def load_test_config(self):
486 """
487 Use test configuration rather than the configuration coming from airflow defaults.
488
489 When running tests we use special the unit_test configuration to avoid accidental modifications and
490 different behaviours when running the tests. Values for those test configuration are stored in
491 the "unit_tests.cfg" configuration file in the ``airflow/config_templates`` folder
492 and you need to change values there if you want to make some specific configuration to be used
493 """
494 from cryptography.fernet import Fernet
495
496 unit_test_config_file = pathlib.Path(__file__).parent / "config_templates" / "unit_tests.cfg"
497 unit_test_config = unit_test_config_file.read_text()
498 self.remove_all_read_configurations()
499 with StringIO(unit_test_config) as test_config_file:
500 self.read_file(test_config_file)
501
502 # We need those globals before we run "get_all_expansion_variables" because this is where
503 # the variables are expanded from in the configuration - set to random values for tests
504 _SecretKeys.fernet_key = Fernet.generate_key().decode()
505 _SecretKeys.jwt_secret_key = b64encode(os.urandom(16)).decode("utf-8")
506 self.expand_all_configuration_values()
507 log.info("Unit test configuration loaded from 'config_unit_tests.cfg'")
508
509 def expand_all_configuration_values(self):
510 """Expand all configuration values using global and local variables defined in this module."""
511 all_vars = get_all_expansion_variables()
512 for section in self.sections():
513 for key, value in self.items(section):
514 if value is not None:
515 if self.has_option(section, key):
516 self.remove_option(section, key, remove_default=False)
517 if self.is_template(section, key) or not isinstance(value, str):
518 self.set(section, key, value)
519 else:
520 self.set(section, key, value.format(**all_vars))
521
522 def remove_all_read_configurations(self):
523 """Remove all read configurations, leaving only default values in the config."""
524 for section in self.sections():
525 self.remove_section(section)
526
527 def _get_config_value_from_secret_backend(self, config_key: str) -> str | None:
528 """
529 Override to use module-level function that reads from global conf.
530
531 This ensures as_dict() and other methods use the same secrets backend
532 configuration as the global conf instance (set via conf_vars in tests).
533 """
534 secrets_client = get_custom_secret_backend()
535 if not secrets_client:
536 return None
537 try:
538 return secrets_client.get_config(config_key)
539 except Exception as e:
540 raise AirflowConfigException(
541 "Cannot retrieve config from alternative secrets backend. "
542 "Make sure it is configured properly and that the Backend "
543 "is accessible.\n"
544 f"{e}"
545 )
546
547 def __getstate__(self) -> dict[str, Any]:
548 """Return the state of the object as a dictionary for pickling."""
549 return {
550 name: getattr(self, name)
551 for name in [
552 "_sections",
553 "is_validated",
554 "configuration_description",
555 "upgraded_values",
556 "_default_values",
557 ]
558 }
559
560 def __setstate__(self, state) -> None:
561 """Restore the state of the object from a dictionary representation."""
562 self.__init__() # type: ignore[misc]
563 config = state.pop("_sections")
564 self.read_dict(config)
565 self.__dict__.update(state)
566
567
568def get_airflow_home() -> str:
569 """Get path to Airflow Home."""
570 return expand_env_var(os.environ.get("AIRFLOW_HOME", "~/airflow"))
571
572
573def get_airflow_config(airflow_home: str) -> str:
574 """Get Path to airflow.cfg path."""
575 airflow_config_var = os.environ.get("AIRFLOW_CONFIG")
576 if airflow_config_var is None:
577 return os.path.join(airflow_home, "airflow.cfg")
578 return expand_env_var(airflow_config_var)
579
580
581def get_all_expansion_variables() -> dict[str, Any]:
582 return {
583 "FERNET_KEY": _SecretKeys.fernet_key,
584 "JWT_SECRET_KEY": _SecretKeys.jwt_secret_key,
585 **{
586 k: v
587 for k, v in globals().items()
588 if not k.startswith("_") and not callable(v) and not ismodule(v)
589 },
590 }
591
592
593def _generate_fernet_key() -> str:
594 from cryptography.fernet import Fernet
595
596 return Fernet.generate_key().decode()
597
598
599def create_default_config_parser(configuration_description: dict[str, dict[str, Any]]) -> ConfigParser:
600 """
601 Create default config parser based on configuration description.
602
603 It creates ConfigParser with all default values retrieved from the configuration description and
604 expands all the variables from the global and local variables defined in this module.
605
606 :param configuration_description: configuration description - retrieved from config.yaml files
607 following the schema defined in "config.yml.schema.json" in the config_templates folder.
608 :return: Default Config Parser that can be used to read configuration values from.
609 """
610 parser = ConfigParser()
611 all_vars = get_all_expansion_variables()
612 configure_parser_from_configuration_description(parser, configuration_description, all_vars)
613 return parser
614
615
616def write_default_airflow_configuration_if_needed() -> AirflowConfigParser:
617 airflow_config = pathlib.Path(AIRFLOW_CONFIG)
618 if airflow_config.is_dir():
619 msg = (
620 "Airflow config expected to be a path to the configuration file, "
621 f"but got a directory {airflow_config.__fspath__()!r}."
622 )
623 raise IsADirectoryError(msg)
624 if not airflow_config.exists():
625 log.debug("Creating new Airflow config file in: %s", airflow_config.__fspath__())
626 config_directory = airflow_config.parent
627 if not config_directory.exists():
628 if not config_directory.is_relative_to(AIRFLOW_HOME):
629 msg = (
630 f"Config directory {config_directory.__fspath__()!r} not exists "
631 f"and it is not relative to AIRFLOW_HOME {AIRFLOW_HOME!r}. "
632 "Please create this directory first."
633 )
634 raise FileNotFoundError(msg) from None
635 log.debug("Create directory %r for Airflow config", config_directory.__fspath__())
636 config_directory.mkdir(parents=True, exist_ok=True)
637 if not conf.get("core", "fernet_key"):
638 # We know that fernet_key is not set, so we can generate it, set as global key
639 # and also write it to the config file so that same key will be used next time
640 _SecretKeys.fernet_key = _generate_fernet_key()
641 conf._configuration_description["core"]["options"]["fernet_key"]["default"] = (
642 _SecretKeys.fernet_key
643 )
644 conf._default_values.set("core", "fernet_key", _SecretKeys.fernet_key)
645
646 _SecretKeys.jwt_secret_key = b64encode(os.urandom(16)).decode("utf-8")
647 conf._configuration_description["api_auth"]["options"]["jwt_secret"]["default"] = (
648 _SecretKeys.jwt_secret_key
649 )
650 conf._default_values.set("api_auth", "jwt_secret", _SecretKeys.jwt_secret_key)
651 # Invalidate cached configuration_description so it recomputes with the updated base
652 conf.invalidate_cache()
653 pathlib.Path(airflow_config.__fspath__()).touch()
654 make_group_other_inaccessible(airflow_config.__fspath__())
655 with open(airflow_config, "w") as file:
656 conf.write(
657 file,
658 include_sources=False,
659 include_env_vars=True,
660 include_providers=True,
661 extra_spacing=True,
662 only_defaults=True,
663 show_values=True,
664 )
665 return conf
666
667
668def load_standard_airflow_configuration(airflow_config_parser: AirflowConfigParser):
669 """
670 Load standard airflow configuration.
671
672 In case it finds that the configuration file is missing, it will create it and write the default
673 configuration values there, based on defaults passed, and will add the comments and examples
674 from the default configuration.
675
676 :param airflow_config_parser: parser to which the configuration will be loaded
677
678 """
679 global AIRFLOW_HOME # to be cleaned in Airflow 4.0
680 log.info("Reading the config from %s", AIRFLOW_CONFIG)
681 airflow_config_parser.read(AIRFLOW_CONFIG)
682 if airflow_config_parser.has_option("core", "AIRFLOW_HOME"):
683 msg = (
684 "Specifying both AIRFLOW_HOME environment variable and airflow_home "
685 "in the config file is deprecated. Please use only the AIRFLOW_HOME "
686 "environment variable and remove the config file entry."
687 )
688 if "AIRFLOW_HOME" in os.environ:
689 warnings.warn(msg, category=RemovedInAirflow4Warning, stacklevel=1)
690 elif airflow_config_parser.get("core", "airflow_home") == AIRFLOW_HOME:
691 warnings.warn(
692 "Specifying airflow_home in the config file is deprecated. As you "
693 "have left it at the default value you should remove the setting "
694 "from your airflow.cfg and suffer no change in behaviour.",
695 category=RemovedInAirflow4Warning,
696 stacklevel=1,
697 )
698 else:
699 AIRFLOW_HOME = airflow_config_parser.get("core", "airflow_home")
700 warnings.warn(msg, category=RemovedInAirflow4Warning, stacklevel=1)
701
702
703def initialize_config() -> AirflowConfigParser:
704 """
705 Load the Airflow config files.
706
707 Called for you automatically as part of the Airflow boot process.
708 """
709 airflow_config_parser = AirflowConfigParser()
710 if airflow_config_parser.getboolean("core", "unit_test_mode"):
711 airflow_config_parser.load_test_config()
712 else:
713 load_standard_airflow_configuration(airflow_config_parser)
714 # If the user set unit_test_mode in the airflow.cfg, we still
715 # want to respect that and then load the default unit test configuration
716 # file on top of it.
717 if airflow_config_parser.getboolean("core", "unit_test_mode"):
718 airflow_config_parser.load_test_config()
719 return airflow_config_parser
720
721
722def make_group_other_inaccessible(file_path: str):
723 try:
724 permissions = os.stat(file_path)
725 os.chmod(file_path, permissions.st_mode & (stat.S_IRUSR | stat.S_IWUSR))
726 except Exception as e:
727 log.warning(
728 "Could not change permissions of config file to be group/other inaccessible. "
729 "Continuing with original permissions: %s",
730 e,
731 )
732
733
734def ensure_secrets_loaded(
735 default_backends: list[str] = DEFAULT_SECRETS_SEARCH_PATH,
736) -> list[BaseSecretsBackend]:
737 """
738 Ensure that all secrets backends are loaded.
739
740 If the secrets_backend_list contains only 2 default backends, reload it.
741 """
742 # Check if the secrets_backend_list contains only 2 default backends.
743
744 # Check if we are loading the backends for worker too by checking if the default_backends is equal
745 # to DEFAULT_SECRETS_SEARCH_PATH.
746 if len(secrets_backend_list) == 2 or default_backends != DEFAULT_SECRETS_SEARCH_PATH:
747 return initialize_secrets_backends(default_backends=default_backends)
748 return secrets_backend_list
749
750
751def get_custom_secret_backend(worker_mode: bool = False) -> BaseSecretsBackend | None:
752 """
753 Get Secret Backend if defined in airflow.cfg.
754
755 Conditionally selects the section, key and kwargs key based on whether it is called from worker or not.
756
757 This is a convenience function that calls conf._get_custom_secret_backend().
758 """
759 return conf._get_custom_secret_backend(worker_mode=worker_mode)
760
761
762def initialize_secrets_backends(
763 default_backends: list[str] = DEFAULT_SECRETS_SEARCH_PATH,
764) -> list[BaseSecretsBackend]:
765 """
766 Initialize secrets backend.
767
768 * import secrets backend classes
769 * instantiate them and return them in a list
770 """
771 backend_list = []
772 worker_mode = False
773 if default_backends != DEFAULT_SECRETS_SEARCH_PATH:
774 worker_mode = True
775
776 custom_secret_backend = get_custom_secret_backend(worker_mode)
777
778 if custom_secret_backend is not None:
779 from airflow.models import Connection
780
781 custom_secret_backend._set_connection_class(Connection)
782 backend_list.append(custom_secret_backend)
783
784 for class_name in default_backends:
785 from airflow.models import Connection
786
787 secrets_backend_cls = import_string(class_name)
788 backend = secrets_backend_cls()
789 backend._set_connection_class(Connection)
790 backend_list.append(backend)
791
792 return backend_list
793
794
795def initialize_auth_manager() -> BaseAuthManager:
796 """
797 Initialize auth manager.
798
799 * import user manager class
800 * instantiate it and return it
801 """
802 auth_manager_cls = conf.getimport(section="core", key="auth_manager")
803
804 if not auth_manager_cls:
805 raise AirflowConfigException(
806 "No auth manager defined in the config. Please specify one using section/key [core/auth_manager]."
807 )
808
809 return auth_manager_cls()
810
811
812# Setting AIRFLOW_HOME and AIRFLOW_CONFIG from environment variables, using
813# "~/airflow" and "$AIRFLOW_HOME/airflow.cfg" respectively as defaults.
814AIRFLOW_HOME = get_airflow_home()
815AIRFLOW_CONFIG = get_airflow_config(AIRFLOW_HOME)
816
817# Set up dags folder for unit tests
818# this directory won't exist if users install via pip
819_TEST_DAGS_FOLDER = os.path.join(
820 os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "tests", "dags"
821)
822if os.path.exists(_TEST_DAGS_FOLDER):
823 TEST_DAGS_FOLDER = _TEST_DAGS_FOLDER
824else:
825 TEST_DAGS_FOLDER = os.path.join(AIRFLOW_HOME, "dags")
826
827# Set up plugins folder for unit tests
828_TEST_PLUGINS_FOLDER = os.path.join(
829 os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "tests", "plugins"
830)
831if os.path.exists(_TEST_PLUGINS_FOLDER):
832 TEST_PLUGINS_FOLDER = _TEST_PLUGINS_FOLDER
833else:
834 TEST_PLUGINS_FOLDER = os.path.join(AIRFLOW_HOME, "plugins")
835
836SECRET_KEY = b64encode(os.urandom(16)).decode("utf-8")
837
838conf: AirflowConfigParser = initialize_config()
839secrets_backend_list = initialize_secrets_backends()
840conf.validate()