Abort gclient-new-workdir on Btrfs snapshot failure
When attempting to create a Btrfs subvolume snapshot fails, the script previously fell back to a slow file-by-file copy. This change modifies the behavior to instead abort early if the source is detected to be a Btrfs subvolume. If the snapshot fails for a subvolume, the script now prints diagnostic information to help the user troubleshoot and exits, saving the user from an unexpectedly long copy operation. The fallback behavior is preserved for non-subvolumes where snapshots are not supported. This also adds unit tests for the new abort and diagnostic behaviors. Change-Id: Ieee9fec08d9664d3bdffc255b6993fa2f6d8ad70 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/7713376 Commit-Queue: Josiah Kiehl <kiehl@google.com> Reviewed-by: Erik Staab <estaab@google.com>changes/76/7713376/16
parent
5e98835131
commit
c6f8c88b92
@ -0,0 +1,216 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Load the script with hyphens in name as a module
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
script_path = os.path.abspath(
|
||||
os.path.join(test_dir, "..", "gclient-new-workdir.py"))
|
||||
|
||||
# Ensure depot_tools root is in sys.path so gclient_utils can be imported
|
||||
depot_tools_dir = os.path.dirname(script_path)
|
||||
if depot_tools_dir not in sys.path:
|
||||
sys.path.insert(0, depot_tools_dir)
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"gclient_new_workdir",
|
||||
script_path,
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
gclient_new_workdir = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(gclient_new_workdir)
|
||||
|
||||
|
||||
class TestGclientNewWorkdir(unittest.TestCase):
|
||||
|
||||
@patch("subprocess.check_output")
|
||||
@patch("os.stat")
|
||||
@patch("os.access")
|
||||
@patch("subprocess.check_call")
|
||||
@patch("os.makedirs")
|
||||
@patch("sys.exit")
|
||||
def test_abort_on_btrfs_fail(self, mock_exit, mock_makedirs,
|
||||
mock_check_call, mock_os_access, mock_os_stat,
|
||||
mock_check_output):
|
||||
# Setup mocks
|
||||
mock_check_output.return_value = b'btrfs'
|
||||
mock_stat_res = MagicMock()
|
||||
mock_stat_res.st_ino = 256
|
||||
mock_os_stat.return_value = mock_stat_res
|
||||
|
||||
def mock_cc(args, **kwargs):
|
||||
_ = kwargs
|
||||
if args[2] == 'snapshot':
|
||||
raise OSError("Failed")
|
||||
|
||||
mock_check_call.side_effect = mock_cc
|
||||
|
||||
# Make mock_exit raise SystemExit to stop execution
|
||||
mock_exit.side_effect = SystemExit(1)
|
||||
|
||||
# Mock os.access to return True for diagnostics
|
||||
mock_os_access.return_value = True
|
||||
|
||||
# Mock parse_options
|
||||
mock_args = MagicMock()
|
||||
mock_args.repository = "repo"
|
||||
mock_args.new_workdir = "dest"
|
||||
|
||||
with patch.object(gclient_new_workdir,
|
||||
"parse_options",
|
||||
return_value=mock_args):
|
||||
try:
|
||||
gclient_new_workdir.main()
|
||||
except SystemExit:
|
||||
pass # Expected
|
||||
|
||||
# Assertions
|
||||
mock_exit.assert_called_with(1)
|
||||
mock_makedirs.assert_not_called()
|
||||
|
||||
@patch("subprocess.check_output")
|
||||
@patch("os.stat")
|
||||
@patch("os.access")
|
||||
@patch("os.makedirs")
|
||||
@patch("sys.exit")
|
||||
@patch.object(gclient_new_workdir, "support_copy_on_write")
|
||||
def test_fallback_on_non_subvolume(self, mock_support_cow, mock_exit,
|
||||
mock_makedirs, mock_os_access,
|
||||
mock_os_stat, mock_check_output):
|
||||
# Setup mocks
|
||||
mock_check_output.return_value = b'btrfs'
|
||||
mock_stat_res = MagicMock()
|
||||
mock_stat_res.st_ino = 123 # Not subvolume!
|
||||
mock_os_stat.return_value = mock_stat_res
|
||||
|
||||
# Mock os.access to return True for diagnostics
|
||||
mock_os_access.return_value = True
|
||||
|
||||
# Mock support_copy_on_write to stop execution after os.makedirs
|
||||
mock_support_cow.side_effect = SystemExit(0)
|
||||
|
||||
# Mock parse_options
|
||||
mock_args = MagicMock()
|
||||
mock_args.repository = "repo"
|
||||
mock_args.new_workdir = "dest"
|
||||
|
||||
with patch.object(gclient_new_workdir,
|
||||
"parse_options",
|
||||
return_value=mock_args):
|
||||
try:
|
||||
gclient_new_workdir.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
# Assertions
|
||||
mock_exit.assert_not_called()
|
||||
# It should proceed to os.makedirs with resolved path
|
||||
mock_makedirs.assert_called_with(os.path.realpath("dest"))
|
||||
|
||||
@patch("subprocess.check_output")
|
||||
@patch("os.stat")
|
||||
@patch("os.access")
|
||||
@patch("subprocess.check_call")
|
||||
@patch("os.makedirs")
|
||||
@patch("sys.exit")
|
||||
def test_diagnostics_repo_not_readable(self, mock_exit, mock_makedirs,
|
||||
mock_check_call, mock_os_access,
|
||||
mock_os_stat, mock_check_output):
|
||||
# Setup mocks
|
||||
mock_check_output.return_value = b'btrfs'
|
||||
mock_stat_res = MagicMock()
|
||||
mock_stat_res.st_ino = 256
|
||||
mock_os_stat.return_value = mock_stat_res
|
||||
|
||||
def mock_cc(args, **kwargs):
|
||||
_ = kwargs
|
||||
if args[2] == 'snapshot':
|
||||
raise OSError("Failed")
|
||||
|
||||
mock_check_call.side_effect = mock_cc
|
||||
|
||||
# Make mock_exit raise SystemExit to stop execution
|
||||
mock_exit.side_effect = SystemExit(1)
|
||||
|
||||
# Mock os.access: False for repo (not readable)
|
||||
def side_effect(path, mode):
|
||||
if path == "repo" and mode == os.R_OK:
|
||||
return False
|
||||
return True
|
||||
|
||||
mock_os_access.side_effect = side_effect
|
||||
|
||||
# Mock parse_options
|
||||
mock_args = MagicMock()
|
||||
mock_args.repository = "repo"
|
||||
mock_args.new_workdir = "dest"
|
||||
|
||||
with patch.object(gclient_new_workdir,
|
||||
"parse_options",
|
||||
return_value=mock_args):
|
||||
try:
|
||||
gclient_new_workdir.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
# Assertions
|
||||
mock_exit.assert_called_with(1)
|
||||
mock_makedirs.assert_not_called()
|
||||
|
||||
@patch("subprocess.check_output")
|
||||
@patch("os.stat")
|
||||
@patch("os.access")
|
||||
@patch("subprocess.check_call")
|
||||
@patch("os.makedirs")
|
||||
@patch("sys.exit")
|
||||
def test_diagnostics_dest_not_writable(self, mock_exit, mock_makedirs,
|
||||
mock_check_call, mock_os_access,
|
||||
mock_os_stat, mock_check_output):
|
||||
# Setup mocks
|
||||
mock_check_output.return_value = b'btrfs'
|
||||
mock_stat_res = MagicMock()
|
||||
mock_stat_res.st_ino = 256
|
||||
mock_os_stat.return_value = mock_stat_res
|
||||
|
||||
def mock_cc(args, **kwargs):
|
||||
_ = kwargs
|
||||
if args[2] == 'snapshot':
|
||||
raise OSError("Failed")
|
||||
|
||||
mock_check_call.side_effect = mock_cc
|
||||
|
||||
# Make mock_exit raise SystemExit to stop execution
|
||||
mock_exit.side_effect = SystemExit(1)
|
||||
|
||||
# Mock os.access: False for dest parent (not writable)
|
||||
def side_effect(path, mode):
|
||||
if path == "dir" and mode == os.W_OK:
|
||||
return False
|
||||
return True
|
||||
|
||||
mock_os_access.side_effect = side_effect
|
||||
|
||||
# Mock parse_options
|
||||
mock_args = MagicMock()
|
||||
mock_args.repository = "repo"
|
||||
mock_args.new_workdir = "dir/dest"
|
||||
|
||||
with patch.object(gclient_new_workdir,
|
||||
"parse_options",
|
||||
return_value=mock_args):
|
||||
try:
|
||||
gclient_new_workdir.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
# Assertions
|
||||
mock_exit.assert_called_with(1)
|
||||
mock_makedirs.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue