| # 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 os | |
| import socket | |
| import stat | |
| import subprocess | |
| from unittest import mock | |
| import psutil | |
| import pytest | |
| from airflow_breeze.global_constants import SIMPLE_AUTH_MANAGER_VITE_DEV_PORT, VITE_DEV_PORT | |
| from airflow_breeze.utils.run_utils import ( | |
| _find_local_port_listeners, | |
| _find_occupied_local_ports, | |
| _format_occupied_ports_error, | |
| change_directory_permission, | |
| change_file_permission, | |
| check_if_buildx_plugin_installed, | |
| run_command, | |
| run_compile_ui_assets, | |
| ) | |
| def test_change_file_permission(tmp_path): | |
| tmpfile = tmp_path / "test.config" | |
| tmpfile.write_text("content") | |
| change_file_permission(tmpfile) | |
| mode = tmpfile.stat().st_mode | |
| assert not (mode & stat.S_IWGRP) | |
| assert not (mode & stat.S_IWOTH) | |
| def test_change_directory_permission(tmp_path): | |
| subdir = tmp_path / "testdir" | |
| subdir.mkdir() | |
| change_directory_permission(subdir) | |
| mode = subdir.stat().st_mode | |
| assert not (mode & stat.S_IWGRP) | |
| assert not (mode & stat.S_IWOTH) | |
| assert mode & stat.S_IXGRP | |
| assert mode & stat.S_IXOTH | |
| @mock.patch("airflow_breeze.utils.run_utils.subprocess.run") | |
| def test_run_command_dry_run_quiet_does_not_execute(mock_subprocess_run): | |
| result = run_command(["echo", "hello"], dry_run_override=True, quiet=True) | |
| mock_subprocess_run.assert_not_called() | |
| assert result.returncode == 0 | |
| assert result.stdout == "" | |
| assert result.stderr == "" | |
| @pytest.mark.parametrize("verbose", [False, True]) | |
| @mock.patch("airflow_breeze.utils.run_utils.subprocess.run") | |
| def test_run_command_forwards_timeout(mock_subprocess_run, verbose): | |
| mock_subprocess_run.side_effect = subprocess.TimeoutExpired(["echo", "hello"], 30) | |
| with pytest.raises(subprocess.TimeoutExpired): | |
| run_command(["echo", "hello"], timeout=30, verbose_override=verbose) | |
| assert mock_subprocess_run.call_args.kwargs["timeout"] == 30 | |
| def test_find_occupied_local_ports(): | |
| with socket.socket() as unused_socket: | |
| unused_socket.bind(("127.0.0.1", 0)) | |
| unoccupied_port = str(unused_socket.getsockname()[1]) | |
| with socket.create_server(("127.0.0.1", 0)) as server: | |
| occupied_port = str(server.getsockname()[1]) | |
| assert _find_occupied_local_ports((unoccupied_port, occupied_port)) == [occupied_port] | |
| @mock.patch("airflow_breeze.utils.run_utils._run_compile_internally") | |
| @mock.patch("airflow_breeze.utils.run_utils._find_occupied_local_ports", return_value=[]) | |
| def test_run_compile_ui_assets_checks_both_dev_ports(mock_find_occupied_ports, mock_run_compile): | |
| result = run_compile_ui_assets( | |
| dev=True, run_in_background=False, force_clean=False, additional_ui_hooks=[] | |
| ) | |
| assert result == mock_run_compile.return_value | |
| mock_find_occupied_ports.assert_called_once_with((VITE_DEV_PORT, SIMPLE_AUTH_MANAGER_VITE_DEV_PORT)) | |
| @pytest.mark.parametrize( | |
| "occupied_ports", | |
| [ | |
| pytest.param([VITE_DEV_PORT], id="airflow-ui"), | |
| pytest.param([SIMPLE_AUTH_MANAGER_VITE_DEV_PORT], id="simple-auth-manager-ui"), | |
| ], | |
| ) | |
| @mock.patch("airflow_breeze.utils.run_utils._clean_ui_assets") | |
| @mock.patch("airflow_breeze.utils.run_utils._find_local_port_listeners", return_value={}) | |
| @mock.patch("airflow_breeze.utils.run_utils._find_occupied_local_ports") | |
| @mock.patch("airflow_breeze.utils.run_utils.console_print") | |
| def test_run_compile_ui_assets_exits_before_cleanup_when_dev_port_is_occupied( | |
| mock_console_print, mock_find_occupied_ports, mock_find_listeners, mock_clean_ui_assets, occupied_ports | |
| ): | |
| mock_find_occupied_ports.return_value = occupied_ports | |
| with pytest.raises(SystemExit) as ctx: | |
| run_compile_ui_assets(dev=True, run_in_background=False, force_clean=True, additional_ui_hooks=[]) | |
| assert ctx.value.code == 1 | |
| mock_find_listeners.assert_called_once_with(occupied_ports) | |
| mock_console_print.assert_called_once_with( | |
| "[error]Cannot start UI development servers because the following local port(s) " | |
| f"are already in use: {', '.join(occupied_ports)}.[/]\n" | |
| "[info]Stop the processes using these ports and try again.[/]" | |
| ) | |
| mock_clean_ui_assets.assert_not_called() | |
| def test_find_local_port_listeners_reports_current_process(): | |
| with socket.create_server(("127.0.0.1", 0)) as server: | |
| occupied_port = str(server.getsockname()[1]) | |
| listeners = _find_local_port_listeners([occupied_port]) | |
| assert list(listeners) == [occupied_port] | |
| assert listeners[occupied_port] == (os.getpid(), " ".join(psutil.Process().cmdline())) | |
| @pytest.mark.parametrize( | |
| "error", | |
| [ | |
| pytest.param(psutil.AccessDenied(pid=1), id="access-denied"), | |
| pytest.param(psutil.NoSuchProcess(pid=1), id="no-such-process"), | |
| ], | |
| ) | |
| @mock.patch("psutil.process_iter") | |
| def test_find_local_port_listeners_skips_processes_that_cannot_be_inspected(mock_process_iter, error): | |
| failing_process = mock.Mock(spec=psutil.Process) | |
| failing_process.net_connections.side_effect = error | |
| listening_process = mock.Mock(spec=psutil.Process, pid=42) | |
| listening_process.net_connections.return_value = [ | |
| mock.Mock(status=psutil.CONN_LISTEN, laddr=mock.Mock(port=5173)), | |
| ] | |
| listening_process.cmdline.return_value = ["node", "vite.js"] | |
| mock_process_iter.return_value = [failing_process, listening_process] | |
| assert _find_local_port_listeners(["5173"]) == {"5173": (42, "node vite.js")} | |
| @pytest.mark.parametrize( | |
| ("listeners", "expected_lines"), | |
| [ | |
| pytest.param( | |
| {}, | |
| ["[info]Stop the processes using these ports and try again.[/]"], | |
| id="no-listener-found", | |
| ), | |
| pytest.param( | |
| {"5173": (42, "node vite.js --port 5173 [x]")}, | |
| [ | |
| "[info]Port 5173 is used by PID 42: node vite.js --port 5173 \\[x][/]", | |
| "[info]Stop the processes using these ports (for example `kill 42`) and try again.[/]", | |
| ], | |
| id="one-listener", | |
| ), | |
| pytest.param( | |
| {"5173": (42, "node"), "5174": (43, "node")}, | |
| [ | |
| "[info]Port 5173 is used by PID 42: node[/]", | |
| "[info]Port 5174 is used by PID 43: node[/]", | |
| "[info]Stop the processes using these ports (for example `kill 42 43`) and try again.[/]", | |
| ], | |
| id="two-listeners", | |
| ), | |
| pytest.param( | |
| {"5174": (42, "node"), "5173": (42, "node")}, | |
| [ | |
| "[info]Port 5173 is used by PID 42: node[/]", | |
| "[info]Port 5174 is used by PID 42: node[/]", | |
| "[info]Stop the processes using these ports (for example `kill 42`) and try again.[/]", | |
| ], | |
| id="same-process-on-both-ports", | |
| ), | |
| ], | |
| ) | |
| def test_format_occupied_ports_error(listeners, expected_lines): | |
| message = _format_occupied_ports_error(["5173", "5174"], listeners) | |
| assert message.split("\n") == [ | |
| "[error]Cannot start UI development servers because the following local port(s) " | |
| "are already in use: 5173, 5174.[/]", | |
| *expected_lines, | |
| ] | |
| @mock.patch("airflow_breeze.utils.run_utils._run_compile_internally") | |
| @mock.patch("airflow_breeze.utils.run_utils._find_occupied_local_ports") | |
| def test_run_compile_ui_assets_does_not_check_dev_ports_for_static_build( | |
| mock_find_occupied_ports, mock_run_compile | |
| ): | |
| result = run_compile_ui_assets( | |
| dev=False, run_in_background=False, force_clean=False, additional_ui_hooks=[] | |
| ) | |
| assert result == mock_run_compile.return_value | |
| mock_find_occupied_ports.assert_not_called() | |
| @mock.patch("airflow_breeze.utils.run_utils.run_command") | |
| @mock.patch("airflow_breeze.utils.run_utils.console_print") | |
| def test_check_buildah_is_installed(mock_console_print, mock_run_command): | |
| mock_run_command.return_value.returncode = 0 | |
| mock_run_command.return_value.stdout = "buildah 1.33.7" | |
| assert check_if_buildx_plugin_installed() is False | |
| mock_run_command.assert_called_with( | |
| ["docker", "buildx", "version"], | |
| no_output_dump_on_exception=True, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| mock_console_print.assert_called_with( | |
| "[warning]Detected buildah installation.[/]\n" | |
| "[warning]The Dockerfiles are only compatible with BuildKit.[/]\n" | |
| "[warning]Please see the syntax declaration at the top of the Dockerfiles for BuildKit version\n" | |
| ) | |
| @mock.patch("airflow_breeze.utils.run_utils.run_command") | |
| @mock.patch("airflow_breeze.utils.run_utils.console_print") | |
| def test_check_buildkit_is_installed(mock_console_print, mock_run_command): | |
| mock_run_command.return_value.returncode = 0 | |
| mock_run_command.return_value.stdout = "github.com/docker/buildx v0.29.1-desktop.1" | |
| assert check_if_buildx_plugin_installed() is True | |
| mock_run_command.assert_called_with( | |
| ["docker", "buildx", "version"], | |
| no_output_dump_on_exception=True, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| mock_console_print.assert_called_with( | |
| "[success]Docker BuildKit is installed and will be used for the image build.[/]\n" | |
| ) | |
| @mock.patch("airflow_breeze.utils.run_utils.run_command") | |
| @mock.patch("airflow_breeze.utils.run_utils.console_print") | |
| def test_check_buildx_not_detected(mock_console_print, mock_run_command): | |
| mock_run_command.return_value.returncode = 1 | |
| assert check_if_buildx_plugin_installed() is False | |
| mock_run_command.assert_called_with( | |
| ["docker", "buildx", "version"], | |
| no_output_dump_on_exception=True, | |
| capture_output=True, | |
| text=True, | |
| check=False, | |
| ) | |
| mock_console_print.assert_not_called() |