blob: 1de3372bad2a1668162ecbdc1b523a78f285f4d3 [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 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()