Skip to content

Commit 67ce622

Browse files
authored
Fix docker compose tests (#49814)
The docker compose tests on linux have been broken by #49681 because of: a) perrmission problems with config folder - airflow.cfg created eventually was owned by root, not airflow user and it was not readable by the airflow user b) the airflow.cfg has not been initialized in init because airflow version command does not create config c) config directory has not been crated in the docker-compose test d) /opts/ directory was used to create dirs instead of /opt/ when changing permissions e) chown -R does not work across the volumes Also it turned out that diagnostic in case of health-check problms has been broken and did not show the actual errors - because while handling exception of health check api calls were made that also raised exception that was subsequently silently swallowed and did not allow the logs and heealth-check information from the test to be printed. This PR fixes those problems: a) creates config folder during tests b) runs "airflow config list" in init that actually creates a default config when no manual configuration is specified c) changes ownership for the internal folders after the config file is created which allows airflow user to read it, also spearately changes ownership for /opt/airflow (volume in image) and all the shared volumes mounted from the host. d) improves diagnostic by switching to rich print and handing the health exceptions during exception handling, allowing to print detailed logs of what happened e) the output of `breeze testing docker-compose-tests` is printed directly to stdout (with pytest `-s` flag) - so that we see the progress of test as it happens - both locally and in CI.
1 parent f71cb98 commit 67ce622

File tree

7 files changed

+118
-49
lines changed

7 files changed

+118
-49
lines changed

airflow-core/docs/howto/docker-compose/docker-compose.yaml

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ services:
217217
echo "For other operating systems you can get rid of the warning with manually created .env file:"
218218
echo " See: https://airflow.apache.org/docs/apache-airflow/stable/howto/docker-compose/index.html#setting-the-right-airflow-user"
219219
echo
220+
export AIRFLOW_UID=$(id -u)
220221
fi
221222
one_meg=1048576
222223
mem_available=$$(($$(getconf _PHYS_PAGES) * $$(getconf PAGE_SIZE) / one_meg))
@@ -251,9 +252,38 @@ services:
251252
echo " https://airflow.apache.org/docs/apache-airflow/stable/howto/docker-compose/index.html#before-you-begin"
252253
echo
253254
fi
254-
mkdir -p /opts/airflow/{logs,dags,plugins,config}
255-
chown -R "${AIRFLOW_UID}:0" /opts/airflow/{logs,dags,plugins,config}
256-
exec /entrypoint airflow version
255+
echo
256+
echo "Creating missing opt dirs if missing:"
257+
echo
258+
mkdir -v -p /opt/airflow/{logs,dags,plugins,config}
259+
echo
260+
echo "Airflow version:"
261+
/entrypoint airflow version
262+
echo
263+
echo "Files in shared volumes:"
264+
echo
265+
ls -la /opt/airflow/{logs,dags,plugins,config}
266+
echo
267+
echo "Running airflow config list to create default config file if missing."
268+
echo
269+
/entrypoint airflow config list >/dev/null
270+
echo
271+
echo "Files in shared volumes:"
272+
echo
273+
ls -la /opt/airflow/{logs,dags,plugins,config}
274+
echo
275+
echo "Change ownership of files in /opt/airflow to ${AIRFLOW_UID}:0"
276+
echo
277+
chown -R "${AIRFLOW_UID}:0" /opt/airflow/
278+
echo
279+
echo "Change ownership of files in shared volumes to ${AIRFLOW_UID}:0"
280+
echo
281+
chown -v -R "${AIRFLOW_UID}:0" /opt/airflow/{logs,dags,plugins,config}
282+
echo
283+
echo "Files in shared volumes:"
284+
echo
285+
ls -la /opt/airflow/{logs,dags,plugins,config}
286+
257287
# yamllint enable rule:line-length
258288
environment:
259289
<<: *airflow-common-env

dev/breeze/doc/images/output_testing_docker-compose-tests.svg

Lines changed: 15 additions & 11 deletions
Loading
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
73b209e41fec5642624d40a92fdeda71
1+
3b806a5bfb9406969251bd457542e40a

dev/breeze/src/airflow_breeze/commands/testing_commands.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def group_for_testing():
129129
is_flag=True,
130130
)
131131
@option_github_repository
132+
@option_include_success_outputs
132133
@option_verbose
133134
@option_dry_run
134135
@click.argument("extra_pytest_args", nargs=-1, type=click.Path(path_type=str))
@@ -137,6 +138,7 @@ def docker_compose_tests(
137138
image_name: str,
138139
skip_docker_compose_deletion: bool,
139140
github_repository: str,
141+
include_success_outputs: str,
140142
extra_pytest_args: tuple,
141143
):
142144
"""Run docker-compose tests."""
@@ -147,6 +149,7 @@ def docker_compose_tests(
147149
get_console().print(f"[info]Running docker-compose with PROD image: {image_name}[/]")
148150
return_code, info = run_docker_compose_tests(
149151
image_name=image_name,
152+
include_success_outputs=include_success_outputs,
150153
extra_pytest_args=extra_pytest_args,
151154
skip_docker_compose_deletion=skip_docker_compose_deletion,
152155
)

dev/breeze/src/airflow_breeze/commands/testing_commands_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@
237237
"--image-name",
238238
"--python",
239239
"--skip-docker-compose-deletion",
240+
"--include-success-outputs",
240241
"--github-repository",
241242
],
242243
}

dev/breeze/src/airflow_breeze/utils/run_tests.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def run_docker_compose_tests(
9595
image_name: str,
9696
extra_pytest_args: tuple,
9797
skip_docker_compose_deletion: bool,
98+
include_success_outputs: bool,
9899
) -> tuple[int, str]:
99100
command_result = run_command(["docker", "inspect", image_name], check=False, stdout=DEVNULL)
100101
if command_result.returncode != 0:
@@ -106,8 +107,11 @@ def run_docker_compose_tests(
106107
env["DOCKER_IMAGE"] = image_name
107108
if skip_docker_compose_deletion:
108109
env["SKIP_DOCKER_COMPOSE_DELETION"] = "true"
110+
if include_success_outputs:
111+
env["INCLUDE_SUCCESS_OUTPUTS"] = "true"
112+
# since we are only running one test, we can print output directly with pytest -s
109113
command_result = run_command(
110-
["uv", "run", "pytest", str(test_path), *pytest_args, *extra_pytest_args],
114+
["uv", "run", "pytest", str(test_path), "-s", *pytest_args, *extra_pytest_args],
111115
env=env,
112116
check=False,
113117
cwd=DOCKER_TESTS_ROOT_PATH.as_posix(),

docker-tests/tests/docker_tests/test_docker_compose_quick_start.py

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919
import json
2020
import os
2121
import shlex
22-
from pprint import pprint
2322
from shutil import copyfile
2423
from time import sleep
2524

2625
import pytest
2726
import requests
2827
from python_on_whales import DockerClient, docker
2928
from python_on_whales.exceptions import DockerException
29+
from rich.console import Console
3030

3131
# isort:off (needed to workaround isort bug)
3232
from docker_tests.command_utils import run_command
@@ -36,6 +36,8 @@
3636

3737
# isort:on (needed to workaround isort bug)
3838

39+
console = Console(width=400, color_system="standard")
40+
3941
DOCKER_COMPOSE_HOST_PORT = os.environ.get("HOST_PORT", "localhost:8080")
4042
AIRFLOW_WWW_USER_USERNAME = os.environ.get("_AIRFLOW_WWW_USER_USERNAME", "airflow")
4143
AIRFLOW_WWW_USER_PASSWORD = os.environ.get("_AIRFLOW_WWW_USER_PASSWORD", "airflow")
@@ -60,13 +62,13 @@ def api_request(
6062

6163

6264
def wait_for_terminal_dag_state(dag_id, dag_run_id):
63-
print(f" Simplified representation of DAG {dag_id} ".center(72, "="))
64-
pprint(api_request("GET", f"dags/{DAG_ID}/details"))
65+
console.print(f"[bright_blue]Simplified representation of DAG {dag_id} ".center(72, "="))
66+
console.print(api_request("GET", f"dags/{DAG_ID}/details"))
6567

6668
# Wait 400 seconds
6769
for _ in range(400):
6870
dag_state = api_request("GET", f"dags/{dag_id}/dagRuns/{dag_run_id}").get("state")
69-
print(f"Waiting for DAG Run: dag_state={dag_state}")
71+
console.print(f"Waiting for DAG Run: dag_state={dag_state}")
7072
sleep(1)
7173
if dag_state in ("success", "failed"):
7274
break
@@ -76,20 +78,24 @@ def test_trigger_dag_and_wait_for_result(default_docker_image, tmp_path_factory,
7678
"""Simple test which reproduce setup docker-compose environment and trigger example dag."""
7779
tmp_dir = tmp_path_factory.mktemp("airflow-quick-start")
7880
monkeypatch.setenv("AIRFLOW_IMAGE_NAME", default_docker_image)
81+
console.print(f"[yellow]Tests are run in {tmp_dir}")
7982

8083
compose_file_path = (
8184
AIRFLOW_ROOT_PATH / "airflow-core" / "docs" / "howto" / "docker-compose" / "docker-compose.yaml"
8285
)
8386
copyfile(compose_file_path, tmp_dir / "docker-compose.yaml")
8487

88+
subfolders = ("dags", "logs", "plugins", "config")
89+
console.print(f"[yellow]Cleaning subfolders:[/ {subfolders}")
8590
# Create required directories for docker compose quick start howto
86-
for subdir in ("dags", "logs", "plugins"):
91+
for subdir in ("dags", "logs", "plugins", "config"):
8792
(tmp_dir / subdir).mkdir()
8893

8994
dot_env_file = tmp_dir / ".env"
95+
console.print(f"[yellow]Creating .env file :[/ {dot_env_file}")
9096
dot_env_file.write_text(f"AIRFLOW_UID={os.getuid()}\n")
91-
print(" .env file content ".center(72, "="))
92-
print(dot_env_file.read_text())
97+
console.print(" .env file content ".center(72, "="))
98+
console.print(dot_env_file.read_text())
9399

94100
compose_version = None
95101
try:
@@ -101,10 +107,14 @@ def test_trigger_dag_and_wait_for_result(default_docker_image, tmp_path_factory,
101107
except NotImplementedError:
102108
docker_version = run_command(["docker", "version"], return_output=True)
103109

110+
console.print("[yellow] Shutting down previous instances of quick-start docker compose")
104111
compose = DockerClient(compose_project_name="quick-start", compose_project_directory=tmp_dir).compose
105112
compose.down(remove_orphans=True, volumes=True, quiet=True)
106113
try:
114+
console.print("[yellow] Starting docker compose")
107115
compose.up(detach=True, wait=True, color=not os.environ.get("NO_COLOR"))
116+
console.print("[green] Docker compose started")
117+
108118
# Before we proceed, let's make sure our DAG has been parsed
109119
compose.execute(service="airflow-dag-processor", command=["airflow", "dags", "reserialize"])
110120

@@ -118,36 +128,53 @@ def test_trigger_dag_and_wait_for_result(default_docker_image, tmp_path_factory,
118128
wait_for_terminal_dag_state(dag_id=DAG_ID, dag_run_id=DAG_RUN_ID)
119129
dag_state = api_request("GET", f"dags/{DAG_ID}/dagRuns/{DAG_RUN_ID}").get("state")
120130
assert dag_state == "success"
131+
if os.environ.get("INCLUDE_SUCCESS_OUTPUTS", "") == "true":
132+
print_diagnostics(compose, compose_version, docker_version)
121133
except Exception:
122-
print("HTTP: GET health")
123-
pprint(api_request("GET", "monitor/health"))
124-
print(f"HTTP: GET dags/{DAG_ID}/dagRuns")
125-
pprint(api_request("GET", f"dags/{DAG_ID}/dagRuns"))
126-
print(f"HTTP: GET dags/{DAG_ID}/dagRuns/{DAG_RUN_ID}/taskInstances")
127-
pprint(api_request("GET", f"dags/{DAG_ID}/dagRuns/{DAG_RUN_ID}/taskInstances"))
128-
print(" Docker Version ".center(72, "="))
129-
print(docker_version)
130-
print(" Docker Compose Version ".center(72, "="))
131-
print(compose_version)
132-
print(" Compose Config ".center(72, "="))
133-
print(json.dumps(compose.config(return_json=True), indent=4))
134-
135-
for service in compose.ps(all=True):
136-
print(f" Service: {service.name} ".center(72, "-"))
137-
print(" Service State ".center(72, "."))
138-
pprint(service.state)
139-
print(" Service Config ".center(72, "."))
140-
pprint(service.config)
141-
print(" Service Logs ".center(72, "."))
142-
print(service.logs())
134+
print_diagnostics(compose, compose_version, docker_version)
143135
raise
144136
finally:
145137
if not os.environ.get("SKIP_DOCKER_COMPOSE_DELETION"):
138+
console.print(
139+
"[yellow] Deleting docker compose instance (you can avoid that by passing "
140+
"--skip-docker-compose-deletion flag in `breeze testing docker-compose` or "
141+
'by setting SKIP_DOCKER_COMPOSE_DELETION environment variable to "true")'
142+
)
146143
compose.down(remove_orphans=True, volumes=True, quiet=True)
147-
print("Docker compose instance deleted")
144+
console.print("[green]Docker compose instance deleted")
148145
else:
149-
print("Skipping docker-compose deletion")
150-
print()
151-
print("You can run inspect your docker-compose by running commands starting with:")
146+
console.print("[yellow]Skipping docker-compose deletion")
147+
console.print()
148+
console.print(
149+
"[yellow]You can run inspect your docker-compose by running commands starting with:"
150+
)
151+
console.print()
152152
quoted_command = map(shlex.quote, map(str, compose.docker_compose_cmd))
153-
print(" ".join(quoted_command))
153+
console.print(" ".join(quoted_command))
154+
155+
156+
def print_diagnostics(compose: DockerClient, compose_version: str, docker_version: str):
157+
console.print("HTTP: GET health")
158+
try:
159+
console.print(api_request("GET", "monitor/health"))
160+
console.print(f"HTTP: GET dags/{DAG_ID}/dagRuns")
161+
console.print(api_request("GET", f"dags/{DAG_ID}/dagRuns"))
162+
console.print(f"HTTP: GET dags/{DAG_ID}/dagRuns/{DAG_RUN_ID}/taskInstances")
163+
console.print(api_request("GET", f"dags/{DAG_ID}/dagRuns/{DAG_RUN_ID}/taskInstances"))
164+
except Exception as e:
165+
console.print(f"Failed to get health check: {e}")
166+
console.print(" Docker Version ".center(72, "="))
167+
console.print(docker_version)
168+
console.print(" Docker Compose Version ".center(72, "="))
169+
console.print(compose_version)
170+
console.print(" Compose Config ".center(72, "="))
171+
console.print(json.dumps(compose.config(return_json=True), indent=4))
172+
for service in compose.ps(all=True):
173+
console.print(f"Service: {service.name} ".center(72, "="))
174+
console.print(f" Service State {service.name}".center(50, "."))
175+
console.print(service.state)
176+
console.print(f" Service Config {service.name}".center(50, "."))
177+
console.print(service.config)
178+
console.print(f" Service Logs {service.name}".center(50, "."))
179+
console.print(service.logs())
180+
console.print(f"End of service: {service.name} ".center(72, "="))

0 commit comments

Comments
 (0)