mirror of https://gitlab.com/openlp/openlp.git
Merge branch 'lock-data-dir' into 'master'
Implement a filelock for shared data folder. See merge request openlp/openlp!519
This commit is contained in:
commit
ceda811e70
|
@ -46,6 +46,7 @@ from openlp.core.common.platform import is_macosx, is_win
|
|||
from openlp.core.common.registry import Registry
|
||||
from openlp.core.common.settings import Settings
|
||||
from openlp.core.display.screens import ScreenList
|
||||
from openlp.core.lib.filelock import FileLock
|
||||
from openlp.core.display.webengine import init_webview_custom_schemes
|
||||
from openlp.core.loader import loader
|
||||
from openlp.core.resources import qInitResources
|
||||
|
@ -79,6 +80,8 @@ class OpenLP(QtCore.QObject, LogMixin):
|
|||
"""
|
||||
self.is_event_loop_active = True
|
||||
result = QtWidgets.QApplication.exec()
|
||||
if self.data_dir_lock:
|
||||
self.data_dir_lock.release()
|
||||
if hasattr(self, 'server'):
|
||||
self.server.close_server()
|
||||
return result
|
||||
|
@ -468,6 +471,14 @@ def main():
|
|||
Registry.create()
|
||||
settings = Settings()
|
||||
Registry().register('settings', settings)
|
||||
if settings.value('advanced/protect data directory'):
|
||||
# attempt to create a file lock
|
||||
app.data_dir_lock = FileLock(AppLocation.get_data_path(), get_version()['full'])
|
||||
if not app.data_dir_lock.lock():
|
||||
# not good! A message will have been presented to the user explaining why we're quitting.
|
||||
sys.exit()
|
||||
else:
|
||||
app.data_dir_lock = None
|
||||
log.info(f'Arguments passed {args}')
|
||||
# Need settings object for the threads.
|
||||
settings_thread = Settings()
|
||||
|
|
|
@ -182,6 +182,7 @@ class Settings(QtCore.QSettings):
|
|||
'advanced/print file meta data': False,
|
||||
'advanced/print notes': False,
|
||||
'advanced/print slide text': False,
|
||||
'advanced/protect data directory': False,
|
||||
'advanced/proxy mode': ProxyMode.SYSTEM_PROXY,
|
||||
'advanced/proxy http': '',
|
||||
'advanced/proxy https': '',
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2023 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
|
||||
"""
|
||||
The :mod:`core` module provides state management
|
||||
|
||||
All the core functions of the OpenLP application including the GUI, settings, logging and a plugin framework are
|
||||
contained within the openlp.core module.
|
||||
"""
|
||||
import logging
|
||||
import getpass
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from threading import Timer
|
||||
|
||||
from PyQt5 import QtWidgets # noqa
|
||||
|
||||
from openlp.core.common.i18n import translate
|
||||
|
||||
log = logging.getLogger()
|
||||
|
||||
LOCK_UPDATE_SECS = 5 * 60
|
||||
LOCK_EXPIRE_SECS = 7 * 60
|
||||
|
||||
|
||||
class FileLock():
|
||||
"""
|
||||
Implements a file lock, where the lock file contains the username and computername of the lock owner.
|
||||
When the file lock is released the lock file is deleted.
|
||||
If a lock aleady exists the user is informed who owns the lock.
|
||||
A timer is used to refresh the lockfile every 5th minute. If a lock file is not refreshed for 7 minutes, the lock
|
||||
can be claimed by a new user/instance.
|
||||
If an user/instance believes that it owns the lock, but the lock file content says that the lock has a different
|
||||
owner, the user will be warned and asked to close the instance immediately.
|
||||
"""
|
||||
|
||||
def __init__(self, data_folder, version):
|
||||
"""
|
||||
Initialize lock.
|
||||
|
||||
:param Path data_folder: The folder where the OpenLP data is located and where the lockfile is placed
|
||||
:param string version: The OpenLP version
|
||||
"""
|
||||
self.lock_filepath = data_folder / 'openlp-data.lock'
|
||||
self.timer = None
|
||||
self.version = version
|
||||
self.has_lock = False
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Destructor
|
||||
"""
|
||||
self.release()
|
||||
|
||||
def release(self):
|
||||
"""
|
||||
Release the lock
|
||||
"""
|
||||
if self.timer:
|
||||
self.timer.cancel()
|
||||
if self.has_lock and self.lock_filepath.exists():
|
||||
self.lock_filepath.unlink()
|
||||
|
||||
def parse_lock_file_content(self):
|
||||
"""
|
||||
Parse the content of the lockfile, returns dict with the data from the lock file.
|
||||
"""
|
||||
with self.lock_filepath.open('r') as lock_file:
|
||||
lock_file_data = lock_file.read()
|
||||
data = json.loads(lock_file_data)
|
||||
return data
|
||||
|
||||
def update_lock_file(self):
|
||||
"""
|
||||
Update the content of the lockfile with the current username, hostname, timestamp and openlp version.
|
||||
"""
|
||||
username = getpass.getuser()
|
||||
hostname = socket.gethostname()
|
||||
if self.lock_filepath.exists():
|
||||
# check if the existing lock has been claimed by someone else, and if so stop the locking
|
||||
data = self.parse_lock_file_content()
|
||||
if data['username'] != username or data['hostname'] != hostname:
|
||||
self.has_lock = False
|
||||
log.critical('"{user}" on "{host}" has claimed the Data Directory Lock!'
|
||||
.format(user=data['username'], host=data['hostname']))
|
||||
QtWidgets.QMessageBox.critical(
|
||||
None, translate('OpenLP', 'Data Directory Lock Error'),
|
||||
translate('OpenLP', 'You have lost OpenLPs shared Data Directory Lock, which instead has '
|
||||
'been claimed by "{user}" on "{host}"! You should close OpenLP '
|
||||
'immediately to avoid data corruption! You can try to reclaim the Data '
|
||||
'Directory Lock by restarting OpenLP')
|
||||
.format(user=data['username'], host=data['hostname']),
|
||||
QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok), QtWidgets.QMessageBox.Ok)
|
||||
return
|
||||
self.has_lock = True
|
||||
with self.lock_filepath.open('w') as lock_file:
|
||||
data = {}
|
||||
data['version'] = self.version
|
||||
data['username'] = username
|
||||
data['hostname'] = hostname
|
||||
# the timestamp is mostly there to ensure that the file content changes and thereby the modification time
|
||||
data['timestamp'] = str(datetime.now())
|
||||
lock_file.write(json.dumps(data))
|
||||
# create and start timer for updating the lock file every 5 minutes
|
||||
self.timer = Timer(LOCK_UPDATE_SECS, self.update_lock_file)
|
||||
self.timer.start()
|
||||
|
||||
def lock(self):
|
||||
"""
|
||||
Create a lock if possible.
|
||||
|
||||
:return: If the lock was acquired.
|
||||
:rtype: bool
|
||||
"""
|
||||
if self.lock_filepath.exists():
|
||||
# check if the existing lock has expired
|
||||
stat_result = self.lock_filepath.stat()
|
||||
sec_since_modified = time.time() - stat_result.st_mtime
|
||||
if sec_since_modified > LOCK_EXPIRE_SECS:
|
||||
# the existing lock has expired! Claim it!
|
||||
self.lock_filepath.unlink(missing_ok=True)
|
||||
self.update_lock_file()
|
||||
return True
|
||||
# someone else has already claimed the lock!
|
||||
data = self.parse_lock_file_content()
|
||||
QtWidgets.QMessageBox.critical(
|
||||
None, translate('OpenLP', 'Data Directory Locked'),
|
||||
translate('OpenLP', 'OpenLPs shared Data Directory is being used by "{user}" on "{host}". '
|
||||
'To avoid data corruption only one user can access the data at a time! Please '
|
||||
'wait a few minutes and try again.')
|
||||
.format(user=data['username'], host=data['hostname']),
|
||||
QtWidgets.QMessageBox.StandardButtons(QtWidgets.QMessageBox.Ok), QtWidgets.QMessageBox.Ok)
|
||||
return False
|
||||
else:
|
||||
self.update_lock_file()
|
||||
return self.has_lock
|
|
@ -80,7 +80,13 @@ class AdvancedTab(SettingsTab):
|
|||
self.data_directory_copy_check_layout.addWidget(self.data_directory_copy_check_box)
|
||||
self.data_directory_copy_check_layout.addStretch()
|
||||
self.data_directory_copy_check_layout.addWidget(self.data_directory_cancel_button)
|
||||
self.data_directory_protect_check_layout = QtWidgets.QHBoxLayout()
|
||||
self.data_directory_protect_check_layout.setObjectName('data_directory_copy_check_layout')
|
||||
self.data_directory_protect_check_box = QtWidgets.QCheckBox(self.data_directory_group_box)
|
||||
self.data_directory_protect_check_box.setObjectName('data_directory_protect_check_box')
|
||||
self.data_directory_protect_check_layout.addWidget(self.data_directory_protect_check_box)
|
||||
self.data_directory_layout.addRow(self.data_directory_copy_check_layout)
|
||||
self.data_directory_layout.addRow(self.data_directory_protect_check_layout)
|
||||
self.data_directory_layout.addRow(self.new_data_directory_has_files_label)
|
||||
self.left_layout.addWidget(self.data_directory_group_box)
|
||||
# Display Workarounds
|
||||
|
@ -129,6 +135,13 @@ class AdvancedTab(SettingsTab):
|
|||
self.data_directory_copy_check_box.setText(translate('OpenLP.AdvancedTab', 'Copy data to new location.'))
|
||||
self.data_directory_copy_check_box.setToolTip(translate(
|
||||
'OpenLP.AdvancedTab', 'Copy the OpenLP data files to the new location.'))
|
||||
self.data_directory_protect_check_box.setText(translate(
|
||||
'OpenLP.AdvancedTab', 'Protect the data directory with a locking mechanism.'))
|
||||
self.data_directory_protect_check_box.setToolTip(translate(
|
||||
'OpenLP.AdvancedTab', 'Protect the data directory with a locking mechanism to avoid data corruption if '
|
||||
'multiple users access the data at the same time. Useful for instances shared via '
|
||||
'network. <strong>NOTE:</strong> This will only work if the network sharing is '
|
||||
'available when OpenLP is running.'))
|
||||
self.new_data_directory_has_files_label.setText(
|
||||
translate('OpenLP.AdvancedTab', '<strong>WARNING:</strong> New data directory location contains '
|
||||
'OpenLP data files. These files WILL be replaced during a copy.'))
|
||||
|
@ -162,7 +175,9 @@ class AdvancedTab(SettingsTab):
|
|||
self.data_directory_path_edit.path = AppLocation.get_data_path()
|
||||
# Don't allow data directory move if running portable.
|
||||
if self.settings.value('advanced/is portable'):
|
||||
self.data_directory_group_box.hide()
|
||||
self.data_directory_new_label.hide()
|
||||
self.data_directory_path_edit.hide()
|
||||
self.data_directory_protect_check_box.setChecked(self.settings.value('advanced/protect data directory'))
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
|
@ -177,6 +192,7 @@ class AdvancedTab(SettingsTab):
|
|||
self.settings.setValue('advanced/x11 bypass wm', self.x11_bypass_check_box.isChecked())
|
||||
self.settings_form.register_post_process('config_screen_changed')
|
||||
self.settings.setValue('advanced/alternate rows', self.alternate_rows_check_box.isChecked())
|
||||
self.settings.setValue('advanced/protect data directory', self.data_directory_protect_check_box.isChecked())
|
||||
self.proxy_widget.save()
|
||||
|
||||
def cancel(self):
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
##########################################################################
|
||||
# OpenLP - Open Source Lyrics Projection #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Copyright (c) 2008-2023 OpenLP Developers #
|
||||
# ---------------------------------------------------------------------- #
|
||||
# This program is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# This program is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the GNU General Public License #
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
||||
##########################################################################
|
||||
"""
|
||||
Package to test the openlp.core.lib.filelock package.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from openlp.core.lib.filelock import FileLock, LOCK_EXPIRE_SECS
|
||||
|
||||
|
||||
@patch('openlp.core.lib.filelock.QtWidgets.QMessageBox.critical')
|
||||
def test_file_lock_simple(mocked_critical, temp_folder):
|
||||
"""
|
||||
Test the creation of a FileLock
|
||||
"""
|
||||
# GIVEN: A FileLock
|
||||
file_lock = FileLock(Path(temp_folder), 'openlp-version')
|
||||
|
||||
# WHEN: Creating a lock without any other lock active
|
||||
lock_acquired = file_lock.lock()
|
||||
|
||||
# cleanup before end
|
||||
if lock_acquired:
|
||||
file_lock.release()
|
||||
|
||||
# THEN: Lock should be acquired
|
||||
assert lock_acquired
|
||||
|
||||
|
||||
@patch('openlp.core.lib.filelock.QtWidgets.QMessageBox.critical')
|
||||
def test_file_lock_relock(mocked_critical, temp_folder):
|
||||
"""
|
||||
Test the relocking of a FileLock
|
||||
"""
|
||||
# GIVEN: A FileLock
|
||||
file_lock = FileLock(Path(temp_folder), 'openlp-version')
|
||||
|
||||
# WHEN: Locking and then updating the lock without any other lock active
|
||||
lock_acquired = file_lock.lock()
|
||||
assert lock_acquired
|
||||
file_lock.update_lock_file()
|
||||
relocking_succeded = file_lock.has_lock
|
||||
|
||||
# cleanup before end
|
||||
if lock_acquired:
|
||||
file_lock.release()
|
||||
|
||||
# THEN: The relocking should succeed
|
||||
assert relocking_succeded
|
||||
|
||||
|
||||
@patch('openlp.core.lib.filelock.QtWidgets.QMessageBox.critical')
|
||||
@patch('openlp.core.lib.filelock.getpass.getuser')
|
||||
def test_file_lock_already_locked(mocked_getuser, mocked_critical, temp_folder):
|
||||
"""
|
||||
Test the creation of a FileLock when a lock already exists
|
||||
"""
|
||||
# GIVEN: 2 FileLocks
|
||||
file_lock1 = FileLock(Path(temp_folder), 'openlp-version')
|
||||
file_lock2 = FileLock(Path(temp_folder), 'openlp-version')
|
||||
|
||||
# WHEN: Creating 2 locks with 2 different users
|
||||
mocked_getuser.side_effect = ['user1', 'user2']
|
||||
lock1_acquired = file_lock1.lock()
|
||||
lock2_acquired = file_lock2.lock()
|
||||
|
||||
# cleanup before end
|
||||
if lock1_acquired:
|
||||
file_lock1.release()
|
||||
if lock2_acquired:
|
||||
file_lock2.release()
|
||||
|
||||
# THEN: Only the first should succeed
|
||||
assert lock1_acquired
|
||||
assert not lock2_acquired
|
||||
|
||||
|
||||
@patch('openlp.core.lib.filelock.QtWidgets.QMessageBox.critical')
|
||||
@patch('openlp.core.lib.filelock.getpass.getuser')
|
||||
@patch('openlp.core.lib.filelock.time.time')
|
||||
def test_file_lock_claim_expired_lock(mocked_time, mocked_getuser, mocked_critical, temp_folder):
|
||||
"""
|
||||
Test the claiming of an expired lock
|
||||
"""
|
||||
# GIVEN: 2 FileLocks
|
||||
file_lock1 = FileLock(Path(temp_folder), 'openlp-version')
|
||||
file_lock2 = FileLock(Path(temp_folder), 'openlp-version')
|
||||
|
||||
# WHEN: Creating 2 locks with 2 different users, at 2 different times, 7 minutes apart
|
||||
mocked_getuser.side_effect = ['user1', 'user2']
|
||||
lock_time = time.time_ns() / 1000000000 + LOCK_EXPIRE_SECS + 1
|
||||
mocked_time.return_value = lock_time
|
||||
lock1_acquired = file_lock1.lock()
|
||||
lock2_acquired = file_lock2.lock()
|
||||
|
||||
# cleanup before end
|
||||
if lock1_acquired:
|
||||
file_lock1.release()
|
||||
if lock2_acquired:
|
||||
file_lock2.release()
|
||||
|
||||
# THEN: Both locks should succeed
|
||||
assert lock1_acquired
|
||||
assert lock2_acquired
|
|
@ -48,7 +48,8 @@ def app_main_env():
|
|||
patch('openlp.core.app.QtWidgets.QMessageBox.information'), \
|
||||
patch('openlp.core.app.OpenLP') as mock_openlp, \
|
||||
patch('openlp.core.app.Server') as mock_server, \
|
||||
patch('openlp.core.app.sys'):
|
||||
patch('openlp.core.app.sys'), \
|
||||
patch('openlp.core.app.FileLock'):
|
||||
mock_registry.return_value = MagicMock()
|
||||
mock_settings.return_value = MagicMock()
|
||||
openlp_server = MagicMock()
|
||||
|
|
Loading…
Reference in New Issue