blob: 28b8dc60221d9d962988d96faa774a33bc68362f [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import json
import subprocess
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
from unittest.mock import call
import pytest
from airflow_breeze.global_constants import (
ALLOWED_POSTGRES_VERSIONS,
CI_IMAGE_SOURCES_HASH_LABEL,
CURRENT_POSTGRES_VERSIONS,
)
from airflow_breeze.params.build_ci_params import BuildCiParams
from airflow_breeze.params.build_prod_params import BuildProdParams
from airflow_breeze.utils import docker_command_utils
from airflow_breeze.utils.docker_command_utils import (
autodetect_docker_context,
check_docker_compose_version,
check_docker_is_running,
check_docker_permission_denied,
check_docker_version,
enter_shell,
fix_ownership_using_docker,
get_images_to_pull,
prepare_docker_build_command,
pull_images_with_retries,
)
@pytest.mark.parametrize("listing_failed", [False, True])
def test_stale_worktree_cleanup_removes_only_containers_with_missing_absolute_paths(tmp_path, listing_failed):
existing = tmp_path / "existing worktree"
existing.mkdir()
missing = tmp_path / "deleted worktree"
listing = "\n".join(
[f"stale\t{missing}", f"active\t{existing}", "primary\t", "legacy\t<no value>", "relative\trelative"]
)
with mock.patch("airflow_breeze.utils.docker_command_utils.run_command", autospec=True) as run:
run.return_value = subprocess.CompletedProcess([], int(listing_failed), stdout=listing, stderr="")
docker_command_utils.remove_stale_worktree_containers()
assert run.call_args_list[0].args[0] == [
"docker",
"ps",
"--all",
"--filter",
"label=org.apache.airflow.breeze=true",
"--format",
'{{.ID}}\t{{.Label "org.apache.airflow.breeze.worktree"}}',
]
removals = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "rm"]]
assert removals == ([] if listing_failed else [["docker", "rm", "--force", "--volumes", "stale"]])
def test_stale_worktree_cleanup_keeps_containers_when_path_cannot_be_checked(tmp_path):
original_stat = Path.stat
def stat(path, **kwargs):
if path == tmp_path:
raise PermissionError("unreadable")
return original_stat(path, **kwargs)
with (
mock.patch("airflow_breeze.utils.docker_command_utils.run_command", autospec=True) as run,
mock.patch.object(Path, "stat", autospec=True, side_effect=stat),
):
run.return_value = subprocess.CompletedProcess([], 0, stdout=f"container\t{tmp_path}\n", stderr="")
docker_command_utils.remove_stale_worktree_containers()
assert run.call_count == 1
@pytest.mark.parametrize(
("exception", "expected_message"),
[
pytest.param(
subprocess.TimeoutExpired(["docker", "info"], 30),
"[error]Docker did not respond within 30 seconds.[/]\n"
"[warning]Please make sure Docker is running and responsive.[/]",
id="timeout",
),
pytest.param(
FileNotFoundError(2, "No such file or directory", "docker"),
"[error]Docker executable was not found.[/]\n"
"[warning]Please install Docker and ensure `docker` is available on PATH.[/]",
id="missing-executable",
),
],
)
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_check_docker_is_running_reports_unavailable_docker(
mock_run_command, mock_console_print, exception, expected_message
):
mock_run_command.side_effect = exception
with pytest.raises(SystemExit) as error:
check_docker_is_running()
assert error.value.code == 1
mock_run_command.assert_called_once_with(
["docker", "info"],
no_output_dump_on_exception=True,
text=True,
capture_output=True,
check=False,
timeout=30,
)
mock_console_print.assert_called_once_with(expected_message)
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_check_docker_permission_denied_uses_bounded_info_probe(mock_run_command):
mock_run_command.return_value.returncode = 0
assert check_docker_permission_denied() is False
mock_run_command.assert_called_once_with(
["docker", "info"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
timeout=30,
)
@mock.patch("airflow_breeze.utils.docker_command_utils.check_docker_permission_denied")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_version_unknown(
mock_console_print, mock_run_command, mock_check_docker_permission_denied
):
mock_check_docker_permission_denied.return_value = False
with pytest.raises(SystemExit) as e:
check_docker_version()
assert e.value.code == 1
expected_run_command_calls = [
call(
["docker", "version", "--format", "{{.Client.Version}}"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
),
]
mock_run_command.assert_has_calls(expected_run_command_calls)
mock_console_print.assert_called_with(
"""
[warning]Your version of docker is unknown. If the scripts fail, please make sure to[/]
[warning]install docker at least: 25.0.0 version.[/]
"""
)
@mock.patch("airflow_breeze.utils.docker_command_utils.check_docker_permission_denied")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_version_too_low(
mock_console_print, mock_run_command, mock_check_docker_permission_denied
):
mock_check_docker_permission_denied.return_value = False
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = "0.9"
with pytest.raises(SystemExit) as e:
check_docker_version()
assert e.value.code == 1
mock_check_docker_permission_denied.assert_called()
mock_run_command.assert_called_with(
["docker", "version", "--format", "{{.Client.Version}}"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
)
mock_console_print.assert_called_with(
"""
[error]Your version of docker is too old: 0.9.\n[/]\n[warning]Please upgrade to at least 25.0.0.\n[/]\n\
You can find installation instructions here: https://docs.docker.com/engine/install/
"""
)
@mock.patch("airflow_breeze.utils.docker_command_utils.check_docker_permission_denied")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_version_ok(mock_console_print, mock_run_command, mock_check_docker_permission_denied):
mock_check_docker_permission_denied.return_value = False
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = "25.0.0"
check_docker_version()
mock_check_docker_permission_denied.assert_called()
mock_run_command.assert_called_with(
["docker", "version", "--format", "{{.Client.Version}}"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
)
mock_console_print.assert_called_with("[success]Good version of Docker: 25.0.0.[/]")
@mock.patch("airflow_breeze.utils.docker_command_utils.check_docker_permission_denied")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_version_higher(
mock_console_print, mock_run_command, mock_check_docker_permission_denied
):
mock_check_docker_permission_denied.return_value = False
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = "25.0.0"
check_docker_version()
mock_check_docker_permission_denied.assert_called()
mock_run_command.assert_called_with(
["docker", "version", "--format", "{{.Client.Version}}"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
)
mock_console_print.assert_called_with("[success]Good version of Docker: 25.0.0.[/]")
@mock.patch("airflow_breeze.utils.docker_command_utils.check_docker_permission_denied")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_version_higher_rancher_desktop(
mock_console_print, mock_run_command, mock_check_docker_permission_denied
):
mock_check_docker_permission_denied.return_value = False
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = "25.0.0-rd"
check_docker_version()
mock_check_docker_permission_denied.assert_called()
mock_run_command.assert_called_with(
["docker", "version", "--format", "{{.Client.Version}}"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
check=False,
dry_run_override=False,
)
mock_console_print.assert_called_with("[success]Good version of Docker: 25.0.0-r.[/]")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_compose_version_unknown(mock_console_print, mock_run_command):
with pytest.raises(SystemExit) as e:
check_docker_compose_version()
assert e.value.code == 1
expected_run_command_calls = [
call(
["docker", "compose", "version"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
dry_run_override=False,
),
]
mock_run_command.assert_has_calls(expected_run_command_calls)
mock_console_print.assert_called_with(
"""
[error]Unknown docker-compose version.[/]\n[warning]At least 2.20.2 needed! Please upgrade!\n[/]
See https://docs.docker.com/compose/install/ for installation instructions.\n
Make sure docker-compose you install is first on the PATH variable of yours.\n
"""
)
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_compose_version_low(mock_console_print, mock_run_command):
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = "1.28.5"
with pytest.raises(SystemExit) as e:
check_docker_compose_version()
assert e.value.code == 1
mock_run_command.assert_called_with(
["docker", "compose", "version"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
dry_run_override=False,
)
mock_console_print.assert_called_with(
"""
[error]You have too old version of docker-compose: 1.28.5!\n[/]
[warning]At least 2.20.2 needed! Please upgrade!\n[/]
See https://docs.docker.com/compose/install/ for installation instructions.\n
Make sure docker-compose you install is first on the PATH variable of yours.\n
"""
)
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
def test_check_docker_compose_version_ok(mock_console_print, mock_run_command):
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = "2.20.2"
check_docker_compose_version()
mock_run_command.assert_called_with(
["docker", "compose", "version"],
no_output_dump_on_exception=True,
capture_output=True,
text=True,
dry_run_override=False,
)
mock_console_print.assert_called_with("[success]Good version of docker-compose: 2.20.2[/]")
def _fake_ctx_output(*names: str) -> str:
return "\n".join(json.dumps({"Name": name, "DockerEndpoint": f"unix://{name}"}) for name in names)
@pytest.mark.parametrize(
("context_output", "selected_context", "console_output"),
[
(
_fake_ctx_output("default"),
"default",
"[info]Using 'default' as context",
),
("\n", "default", "[warning]Could not detect docker builder"),
(
_fake_ctx_output("a", "b"),
"a",
"[warning]Could not use any of the preferred docker contexts",
),
(
_fake_ctx_output("a", "desktop-linux"),
"desktop-linux",
"[info]Using 'desktop-linux' as context",
),
(
_fake_ctx_output("a", "default"),
"default",
"[info]Using 'default' as context",
),
(
_fake_ctx_output("a", "default", "desktop-linux"),
"desktop-linux",
"[info]Using 'desktop-linux' as context",
),
(
'[{"Name": "desktop-linux", "DockerEndpoint": "unix://desktop-linux"}]',
"desktop-linux",
"[info]Using 'desktop-linux' as context",
),
],
)
def test_autodetect_docker_context(context_output: str, selected_context: str, console_output: str):
with mock.patch("airflow_breeze.utils.docker_command_utils.run_command") as mock_run_command:
mock_run_command.return_value.returncode = 0
mock_run_command.return_value.stdout = context_output
with mock.patch("airflow_breeze.utils.docker_command_utils.console_print") as mock_console_print:
assert autodetect_docker_context() == selected_context
mock_console_print.assert_called_once()
assert console_output in mock_console_print.call_args[0][0]
SOCKET_INFO = json.dumps(
[
{
"Name": "default",
"Metadata": {},
"Endpoints": {"docker": {"Host": "unix:///not-standard/docker.sock", "SkipTLSVerify": False}},
"TLSMaterial": {},
"Storage": {"MetadataPath": "\u003cIN MEMORY\u003e", "TLSPath": "\u003cIN MEMORY\u003e"},
}
]
)
SOCKET_INFO_DESKTOP_LINUX = json.dumps(
[
{
"Name": "desktop-linux",
"Metadata": {},
"Endpoints": {
"docker": {"Host": "unix:///VERY_NON_STANDARD/docker.sock", "SkipTLSVerify": False}
},
"TLSMaterial": {},
"Storage": {"MetadataPath": "\u003cIN MEMORY\u003e", "TLSPath": "\u003cIN MEMORY\u003e"},
}
]
)
@pytest.fixture
def docker_resources():
resources = {"container": [], "network": [], "volume": []}
def docker(cmd, **kwargs):
kind, action = cmd[1:3]
if action == "ls":
assert "label=com.docker.compose.project" in cmd
if kind == "container":
assert "--all" in cmd
output = "\n".join(item.get("Id", item.get("Name")) for item in resources[kind])
elif action == "inspect":
output = json.dumps(resources[kind])
else:
assert action in ("stop", "wait", "rm")
output = ""
return subprocess.CompletedProcess(cmd, 0, stdout=output, stderr="")
with mock.patch(
"airflow_breeze.utils.docker_command_utils.run_command", autospec=True, side_effect=docker
) as run:
yield resources, run
@pytest.mark.parametrize(
("kwargs", "linked", "expected"),
[
({}, False, {"breeze", "breeze-docs", "main-tests", "stale"}),
({}, True, {"breeze", "breeze-docs", "main-tests", "foobar", "foobar-tests", "stale"}),
(
{"all_worktrees": True},
True,
{"breeze", "breeze-docs", "main-tests", "stale", "foobar", "foobar-tests", "relative", "other"},
),
({"only_project": "foobar"}, True, {"foobar"}),
({"only_project": "breeze-thirdparty"}, True, {"breeze-thirdparty"}),
({"only_project": "absent"}, True, set()),
({"stale_only": True}, True, {"stale"}),
],
)
@pytest.mark.parametrize("preserve_volumes", [False, True])
def test_down_selects_checkout_stale_or_explicit_projects(
docker_resources, tmp_path, kwargs, linked, expected, preserve_volumes
):
resources, run = docker_resources
other = tmp_path / "other"
other.mkdir()
projects = {
"breeze": {},
"breeze-docs": {},
"unrelated": {},
"breeze-excluded": {"org.apache.airflow.breeze": "false"},
"main-tests": {"org.apache.airflow.breeze": "true", "org.apache.airflow.breeze.worktree": ""},
"foobar": {"org.apache.airflow.breeze": "true", "org.apache.airflow.breeze.worktree": str(tmp_path)},
"foobar-tests": {
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": str(tmp_path),
},
"other": {"org.apache.airflow.breeze": "true", "org.apache.airflow.breeze.worktree": str(other)},
"thirdparty": {"org.apache.airflow.breeze.worktree": str(tmp_path)},
"stale": {
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": str(tmp_path / "deleted"),
},
"relative": {
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": "relative/path",
},
"breeze-thirdparty": {"org.apache.airflow.breeze.worktree": str(tmp_path / "deleted")},
}
for project, extra_labels in projects.items():
labels = {"com.docker.compose.project": project, **extra_labels}
resources["container"].append({"Id": f"{project}-container", "Config": {"Labels": labels}})
resources["network"].append({"Id": f"{project}-network", "Labels": labels})
resources["volume"].append({"Name": f"{project}-volume", "Labels": labels})
assert docker_command_utils.bring_compose_projects_down(
preserve_volumes=preserve_volumes, current_worktree=str(tmp_path) if linked else "", **kwargs
) == sorted(expected)
commands = [c.args[0] for c in run.call_args_list]
for kind in ("container", "network", "volume"):
removed = {
arg for cmd in commands if cmd[1:3] == [kind, "rm"] for arg in cmd[3:] if not arg.startswith("--")
}
assert removed == (
set() if kind == "volume" and preserve_volumes else {f"{project}-{kind}" for project in expected}
)
if expected:
stops = [cmd for cmd in commands if cmd[1:3] == ["container", "stop"]]
assert {arg for cmd in stops for arg in cmd[3:]} == {f"{project}-container" for project in expected}
first_removal = next(i for i, cmd in enumerate(commands) if cmd[2] == "rm")
assert all(cmd[2] in ("ls", "inspect", "stop", "wait") for cmd in commands[:first_removal])
removal = next(cmd for cmd in commands if cmd[1:3] == ["container", "rm"])
assert ("--volumes" in removal) is not preserve_volumes
@pytest.mark.parametrize("cleanup_stale_worktrees", [False, True])
@pytest.mark.parametrize("docker_available", [False, True])
def test_environment_checks_reap_stale_resources(
docker_resources, tmp_path, cleanup_stale_worktrees, docker_available
):
resources, run = docker_resources
resources["volume"] = [
{
"Name": "stale-db",
"Labels": {
"com.docker.compose.project": "stale",
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": str(tmp_path / "deleted"),
},
},
{"Name": "live-db", "Labels": {"com.docker.compose.project": "breeze"}},
]
with ExitStack() as stack:
for name in (
"check_docker_is_running",
"check_container_engine_is_docker",
"check_docker_version",
"check_docker_compose_version",
"check_windows_filesystem_mount",
"check_executable_entrypoint_permissions",
"check_uv_version",
):
check = stack.enter_context(
mock.patch.object(docker_command_utils, name, autospec=True, return_value=True)
)
if name == "check_docker_is_running" and not docker_available:
check.side_effect = SystemExit(1)
if docker_available:
docker_command_utils.perform_environment_checks.__wrapped__(
cleanup_stale_worktrees=cleanup_stale_worktrees
)
else:
with pytest.raises(SystemExit):
docker_command_utils.perform_environment_checks.__wrapped__(
cleanup_stale_worktrees=cleanup_stale_worktrees
)
if docker_available and cleanup_stale_worktrees:
assert (
call(["docker", "volume", "rm", "stale-db"], check=False, capture_output=True)
in run.call_args_list
)
else:
run.assert_not_called()
@pytest.mark.parametrize("all_worktrees", [False, True])
@pytest.mark.parametrize("preserve_volumes", [False, True])
def test_down_finds_volumes_without_containers(docker_resources, tmp_path, preserve_volumes, all_worktrees):
resources, run = docker_resources
resources["volume"] = [
{
"Name": "foobar-postgres14-db-volume",
"Labels": {
"com.docker.compose.project": "foobar",
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": str(
tmp_path if all_worktrees else tmp_path / "deleted"
),
},
}
]
projects = docker_command_utils.bring_compose_projects_down(
preserve_volumes=preserve_volumes, all_worktrees=all_worktrees
)
removals = [c.args[0] for c in run.call_args_list if c.args[0][2] == "rm"]
assert projects == ([] if preserve_volumes else ["foobar"])
assert removals == (
[] if preserve_volumes else [["docker", "volume", "rm", "foobar-postgres14-db-volume"]]
)
@pytest.mark.parametrize("failed_action", ["ls", "inspect", "stop", "rm"])
def test_startup_cleanup_continues_after_docker_failures(docker_resources, tmp_path, capsys, failed_action):
resources, run = docker_resources
labels = {
"com.docker.compose.project": "stale",
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": str(tmp_path / "deleted"),
}
resources["container"] = [{"Id": "remaining", "Config": {"Labels": labels}}]
resources["volume"] = [{"Name": "database", "Labels": labels}]
docker = run.side_effect
def fail(cmd, **kwargs):
result = docker(cmd, **kwargs)
if cmd[2] == "ls":
assert "label=org.apache.airflow.breeze=true" in cmd
assert "label=org.apache.airflow.breeze.worktree" in cmd
if cmd[1] == "container":
result.stdout += "\nvanished"
if cmd[2] == failed_action:
if kwargs.get("check"):
raise subprocess.CalledProcessError(1, cmd)
result.returncode = 1
result.stderr = "resource disappeared or is still in use"
return result
run.side_effect = fail
stop_failed = failed_action == "stop"
assert docker_command_utils.bring_compose_projects_down(stale_only=True) == (
["stale"] if stop_failed else []
)
assert capsys.readouterr().out.count("Unable to clean up some deleted-worktree resources") == (
0 if stop_failed else 1
)
commands = [c.args[0] for c in run.call_args_list]
if failed_action == "ls":
assert all(cmd[2] == "ls" for cmd in commands)
else:
assert ["docker", "volume", "rm", "database"] in commands
assert ["docker", "container", "rm", "--force", "--volumes", "remaining"] in commands
assert not any("vanished" in cmd for cmd in commands if cmd[2] in ("stop", "rm"))
def test_down_empty_discovery_is_not_an_error(docker_resources, capsys):
assert docker_command_utils.bring_compose_projects_down(all_worktrees=True) == []
assert "error" not in capsys.readouterr().out.lower()
@pytest.mark.parametrize("failed_kind", ["container", "volume"])
def test_down_does_not_remove_resources_when_discovery_fails(docker_resources, failed_kind):
resources, run = docker_resources
resources["container"] = [
{"Id": "container", "Config": {"Labels": {"com.docker.compose.project": "breeze"}}}
]
docker = run.side_effect
def fail(cmd, **kwargs):
if cmd[1:3] == [failed_kind, "ls"]:
if kwargs.get("check"):
raise subprocess.CalledProcessError(1, cmd)
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="unavailable")
return docker(cmd, **kwargs)
run.side_effect = fail
with pytest.raises(subprocess.CalledProcessError):
docker_command_utils.bring_compose_projects_down()
assert all(c.args[0][2] in ("ls", "inspect") for c in run.call_args_list)
def test_down_stops_after_container_removal_failure(docker_resources):
resources, run = docker_resources
labels = {"com.docker.compose.project": "breeze"}
resources["container"] = [{"Id": "container", "Config": {"Labels": labels}}]
resources["volume"] = [{"Name": "database", "Labels": labels}]
docker = run.side_effect
def fail(cmd, **kwargs):
if cmd[1:3] == ["container", "rm"]:
if kwargs.get("check"):
raise subprocess.CalledProcessError(1, cmd)
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="in use")
return docker(cmd, **kwargs)
run.side_effect = fail
with pytest.raises(subprocess.CalledProcessError):
docker_command_utils.bring_compose_projects_down()
assert not any(c.args[0][1:3] == ["volume", "rm"] for c in run.call_args_list)
def test_down_force_removes_containers_that_outlive_stop(docker_resources, capsys):
resources, run = docker_resources
labels = {"com.docker.compose.project": "breeze"}
resources["container"] = [{"Id": "container", "Config": {"Labels": labels}}]
docker = run.side_effect
def fail(cmd, **kwargs):
if cmd[1:3] == ["container", "stop"]:
if kwargs.get("check"):
raise subprocess.CalledProcessError(1, cmd)
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="did not receive an exit event")
return docker(cmd, **kwargs)
run.side_effect = fail
assert docker_command_utils.bring_compose_projects_down() == ["breeze"]
assert "Stopping Breeze containers" in capsys.readouterr().out
assert [c.args[0] for c in run.call_args_list if c.args[0][2] in ("stop", "wait", "rm")] == [
["docker", "container", "stop", "container"],
["docker", "container", "wait", "container"],
["docker", "container", "rm", "--force", "--volumes", "container"],
]
def test_down_skips_removal_of_containers_that_removed_themselves(docker_resources):
resources, run = docker_resources
labels = {"com.docker.compose.project": "breeze"}
resources["container"] = [{"Id": "container", "Config": {"Labels": labels}}]
resources["volume"] = [{"Name": "database", "Labels": labels}]
docker = run.side_effect
def remove_on_stop(cmd, **kwargs):
if cmd[1:3] == ["container", "stop"]:
resources["container"].clear()
return docker(cmd, **kwargs)
run.side_effect = remove_on_stop
assert docker_command_utils.bring_compose_projects_down() == ["breeze"]
assert [c.args[0] for c in run.call_args_list if c.args[0][2] == "rm"] == [
["docker", "volume", "rm", "database"]
]
def test_startup_cleanup_reports_containers_that_cannot_be_listed_after_stop(
docker_resources, tmp_path, capsys
):
resources, run = docker_resources
labels = {
"com.docker.compose.project": "stale",
"org.apache.airflow.breeze": "true",
"org.apache.airflow.breeze.worktree": str(tmp_path / "deleted"),
}
resources["container"] = [{"Id": "remaining", "Config": {"Labels": labels}}]
docker = run.side_effect
stopped = False
def fail_listing_after_stop(cmd, **kwargs):
nonlocal stopped
result = docker(cmd, **kwargs)
if cmd[1:3] == ["container", "stop"]:
stopped = True
if stopped and cmd[1:3] == ["container", "ls"]:
result.returncode = 1
return result
run.side_effect = fail_listing_after_stop
assert docker_command_utils.bring_compose_projects_down(stale_only=True) == []
assert capsys.readouterr().out.count("Unable to clean up some deleted-worktree resources") == 1
def test_down_removes_volumes_even_when_a_shared_network_is_still_in_use(docker_resources):
resources, run = docker_resources
labels = {"com.docker.compose.project": "breeze"}
resources["network"] = [{"Id": "shared-network", "Labels": labels}]
resources["volume"] = [{"Name": "database", "Labels": labels}]
docker = run.side_effect
def fail(cmd, **kwargs):
if cmd[1:3] == ["network", "rm"]:
if kwargs.get("check"):
raise subprocess.CalledProcessError(1, cmd)
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="active endpoints")
return docker(cmd, **kwargs)
run.side_effect = fail
with pytest.raises(subprocess.CalledProcessError) as error:
docker_command_utils.bring_compose_projects_down()
assert error.value.cmd == ["docker", "network", "rm", "shared-network"]
assert any(c.args[0] == ["docker", "volume", "rm", "database"] for c in run.call_args_list)
def test_down_dry_run_reads_metadata_without_removing_resources():
def docker(cmd, **kwargs):
assert cmd[2] in ("ls", "inspect")
if cmd[1:3] == ["volume", "ls"]:
output = "database\n"
elif cmd[2] == "inspect":
output = json.dumps([{"Name": "database", "Labels": {"com.docker.compose.project": "breeze"}}])
else:
output = ""
return subprocess.CompletedProcess(cmd, 0, stdout=output, stderr="")
with (
mock.patch("airflow_breeze.utils.run_utils.subprocess.run", autospec=True, side_effect=docker),
mock.patch(
"airflow_breeze.utils.run_utils.get_dry_run",
autospec=True,
side_effect=lambda override: True if override is None else override,
),
):
assert docker_command_utils.bring_compose_projects_down() == ["breeze"]
def _shell_params_for_openlineage(
backend: str, postgres_version: str, integration: tuple[str, ...] = ("openlineage",)
) -> mock.MagicMock:
shell_params = mock.MagicMock()
shell_params.use_airflow_version = None
shell_params.restart = False
shell_params.include_mypy_volume = False
shell_params.include_pycache_volume = False
shell_params.quiet = True
shell_params.project_name = None
shell_params.tty = "disabled"
shell_params.command_passed = None
shell_params.integration = integration
shell_params.backend = backend
shell_params.postgres_version = postgres_version
return shell_params
@mock.patch("airflow_breeze.utils.docker_command_utils.fix_ownership_using_docker")
@mock.patch("airflow_breeze.utils.docker_command_utils.cleanup_python_generated_files")
@mock.patch("airflow_breeze.utils.docker_command_utils.read_from_cache_file", return_value="1")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@pytest.mark.parametrize("integration", [("openlineage",), ("all",)])
@pytest.mark.parametrize("postgres_version", CURRENT_POSTGRES_VERSIONS)
def test_enter_shell_openlineage_allows_current_postgres_versions(
mock_run_command,
mock_console_print,
_mock_read_cache,
_mock_cleanup,
_mock_fix_ownership,
postgres_version,
integration,
):
mock_run_command.return_value.returncode = 0
shell_params = _shell_params_for_openlineage("postgres", postgres_version, integration)
enter_shell(shell_params)
mock_run_command.assert_called_once()
@mock.patch("airflow_breeze.utils.docker_command_utils.fix_ownership_using_docker")
@mock.patch("airflow_breeze.utils.docker_command_utils.cleanup_python_generated_files")
@mock.patch("airflow_breeze.utils.docker_command_utils.read_from_cache_file", return_value="1")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@pytest.mark.parametrize("integration", [("openlineage",), ("all",)])
@pytest.mark.parametrize("postgres_version", set(ALLOWED_POSTGRES_VERSIONS) - set(CURRENT_POSTGRES_VERSIONS))
def test_enter_shell_openlineage_rejects_stale_postgres_versions(
mock_run_command,
mock_console_print,
_mock_read_cache,
_mock_cleanup,
_mock_fix_ownership,
postgres_version,
integration,
):
shell_params = _shell_params_for_openlineage("postgres", postgres_version, integration)
with pytest.raises(SystemExit) as exc_info:
enter_shell(shell_params)
assert exc_info.value.code == 1
error_message = mock_console_print.call_args[0][0]
assert all(version in error_message for version in CURRENT_POSTGRES_VERSIONS)
mock_run_command.assert_not_called()
@mock.patch("airflow_breeze.utils.docker_command_utils.fix_ownership_using_docker")
@mock.patch("airflow_breeze.utils.docker_command_utils.cleanup_python_generated_files")
@mock.patch("airflow_breeze.utils.docker_command_utils.read_from_cache_file", return_value="1")
@mock.patch("airflow_breeze.utils.docker_command_utils.console_print")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@pytest.mark.parametrize("integration", [("openlineage",), ("all",)])
def test_enter_shell_openlineage_rejects_non_postgres_backend(
mock_run_command, mock_console_print, _mock_read_cache, _mock_cleanup, _mock_fix_ownership, integration
):
shell_params = _shell_params_for_openlineage("mysql", CURRENT_POSTGRES_VERSIONS[0], integration)
with pytest.raises(SystemExit) as exc_info:
enter_shell(shell_params)
assert exc_info.value.code == 1
mock_run_command.assert_not_called()
CI_IMAGE = "ghcr.io/apache/airflow/main/ci/python3.10:latest"
def _fake_docker_calls(present_images: set[str], failing_pulls: dict[str, int]):
"""
Builds a run_command side effect emulating docker for the pull helpers.
:param present_images: images `docker image inspect` reports as already available
:param failing_pulls: how many times `docker pull` fails for a given image before succeeding
"""
remaining_failures = dict(failing_pulls)
def _run_command(cmd, **kwargs):
result = mock.MagicMock()
result.returncode = 0
if cmd[:2] == ["docker", "compose"]:
result.stdout = f"{CI_IMAGE}\npostgres:17\notel/opentelemetry-collector-contrib:0.155.0\n"
elif cmd[:3] == ["docker", "image", "inspect"]:
result.returncode = 0 if cmd[3] in present_images else 1
elif cmd[:2] == ["docker", "pull"]:
if remaining_failures.get(cmd[2], 0) > 0:
remaining_failures[cmd[2]] -= 1
result.returncode = 1
return result
return _run_command
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_get_images_to_pull_skips_present_and_skipped_images(mock_run_command):
mock_run_command.side_effect = _fake_docker_calls(present_images={"postgres:17"}, failing_pulls={})
images = get_images_to_pull("breeze-test", env={}, skip_images={CI_IMAGE})
assert images == ["otel/opentelemetry-collector-contrib:0.155.0"]
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_get_images_to_pull_returns_nothing_when_compose_config_fails(mock_run_command):
mock_run_command.return_value = mock.MagicMock(returncode=1, stdout="")
assert get_images_to_pull("breeze-test", env={}, skip_images=set()) == []
@mock.patch("airflow_breeze.utils.docker_command_utils.time.sleep")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_pull_images_with_retries_recovers_from_transient_failures(mock_run_command, mock_sleep):
otel_image = "otel/opentelemetry-collector-contrib:0.155.0"
mock_run_command.side_effect = _fake_docker_calls(
present_images={"postgres:17"}, failing_pulls={otel_image: 2}
)
assert pull_images_with_retries("breeze-test", env={}, skip_images={CI_IMAGE}) is True
assert mock_sleep.call_args_list == [call(15), call(30)]
@mock.patch("airflow_breeze.utils.docker_command_utils.time.sleep")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_pull_images_with_retries_gives_up_after_all_attempts(mock_run_command, mock_sleep):
otel_image = "otel/opentelemetry-collector-contrib:0.155.0"
mock_run_command.side_effect = _fake_docker_calls(
present_images={"postgres:17"}, failing_pulls={otel_image: 99}
)
assert pull_images_with_retries("breeze-test", env={}, skip_images={CI_IMAGE}, attempts=3) is False
assert mock_sleep.call_args_list == [call(15), call(30)]
@mock.patch("airflow_breeze.utils.docker_command_utils.time.sleep")
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
def test_pull_images_with_retries_does_not_pull_when_all_images_are_present(mock_run_command, mock_sleep):
mock_run_command.side_effect = _fake_docker_calls(
present_images={"postgres:17", "otel/opentelemetry-collector-contrib:0.155.0"}, failing_pulls={}
)
assert pull_images_with_retries("breeze-test", env={}, skip_images={CI_IMAGE}) is True
assert not any(c.args[0][:2] == ["docker", "pull"] for c in mock_run_command.call_args_list)
mock_sleep.assert_not_called()
@mock.patch("airflow_breeze.utils.docker_command_utils.calculate_ci_sources_hash")
@mock.patch("airflow_breeze.utils.docker_command_utils.check_if_buildx_plugin_installed")
def test_prepare_docker_build_command_labels_ci_image_with_sources_hash(
mock_check_if_buildx_plugin_installed, mock_calculate_ci_sources_hash
):
mock_check_if_buildx_plugin_installed.return_value = False
mock_calculate_ci_sources_hash.return_value = "hash-of-sources"
command = prepare_docker_build_command(BuildCiParams())
label_index = command.index("--label")
assert command[label_index + 1] == f"{CI_IMAGE_SOURCES_HASH_LABEL}=hash-of-sources"
@mock.patch("airflow_breeze.utils.docker_command_utils.check_if_buildx_plugin_installed")
def test_prepare_docker_build_command_does_not_add_sources_hash_label_to_prod_image(
mock_check_if_buildx_plugin_installed,
):
mock_check_if_buildx_plugin_installed.return_value = False
command = prepare_docker_build_command(BuildProdParams())
assert not any(flag.startswith(CI_IMAGE_SOURCES_HASH_LABEL) for flag in command)
@mock.patch("airflow_breeze.utils.docker_command_utils.run_command")
@mock.patch("airflow_breeze.utils.docker_command_utils.get_main_git_dir_for_worktree", return_value=None)
@mock.patch("airflow_breeze.utils.docker_command_utils.get_host_group_id", return_value=1000)
@mock.patch("airflow_breeze.utils.docker_command_utils.get_host_user_id", return_value=1000)
@mock.patch("airflow_breeze.utils.docker_command_utils.get_host_os", return_value="linux")
@mock.patch("airflow_breeze.utils.docker_command_utils.is_docker_rootless")
@pytest.mark.parametrize(
("rootless", "expected"),
[(True, "DOCKER_IS_ROOTLESS=true"), (False, "DOCKER_IS_ROOTLESS=false")],
)
def test_fix_ownership_using_docker_passes_lowercase_rootless_flag(
mock_is_docker_rootless,
_mock_get_host_os,
_mock_get_host_user_id,
_mock_get_host_group_id,
_mock_get_main_git_dir,
mock_run_command,
rootless,
expected,
):
"""The in-container script compares the flag with lowercase ``true``, so ``True`` would never skip."""
mock_is_docker_rootless.return_value = rootless
fix_ownership_using_docker()
docker_command = mock_run_command.call_args[0][0]
assert expected in docker_command