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:
Tim Bentley 2023-07-19 06:50:21 +00:00
commit ceda811e70
6 changed files with 311 additions and 2 deletions

View File

@ -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()

View File

@ -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': '',

156
openlp/core/lib/filelock.py Normal file
View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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()