Skip to content

Commit 188ddf9

Browse files
committed
Manually merge PR91 to address probable cause of issue reported by AU about corrupted per-user data transfers file. Likely due to multiple concurrent updates by the same user. Assures complete locking of the pickle during read and write to prevent races in such situations.The transfers of different users are already protected by physical file separation.
git-svn-id: svn+ssh://svn.code.sf.net/p/migrid/code/trunk@6104 b75ad72c-e7d7-11dd-a971-7dbc132099af
1 parent e1a7615 commit 188ddf9

File tree

3 files changed

+398
-37
lines changed

3 files changed

+398
-37
lines changed

mig/shared/transferfunctions.py

Lines changed: 171 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# --- BEGIN_HEADER ---
55
#
66
# transferfunctions - data transfer helper functions
7-
# Copyright (C) 2003-2023 The MiG Project lead by Brian Vinter
7+
# Copyright (C) 2003-2024 The MiG Project lead by Brian Vinter
88
#
99
# This file is part of MiG.
1010
#
@@ -38,14 +38,21 @@
3838
from mig.shared.base import client_id_dir, mask_creds
3939
from mig.shared.defaults import datatransfers_filename, user_keys_dir, \
4040
transfer_output_dir
41-
from mig.shared.fileio import makedirs_rec, delete_file
41+
from mig.shared.fileio import makedirs_rec, delete_file, acquire_file_lock, \
42+
release_file_lock, remove_rec
4243
from mig.shared.safeeval import subprocess_popen, subprocess_pipe
4344
from mig.shared.serial import load, dump
4445

4546
default_key_type = 'rsa'
4647
default_key_bits = 2048
4748

4849

50+
def get_transfers_path(configuration, client_id):
51+
"""Build the default transfers file path for client_id"""
52+
return os.path.join(configuration.user_settings, client_id_dir(client_id),
53+
datatransfers_filename)
54+
55+
4956
def get_status_dir(configuration, client_id, transfer_id=''):
5057
"""Lookup the status directory for transfers on behalf of client_id.
5158
The optional transfer_id is used to get the explicit status dir for that
@@ -65,7 +72,7 @@ def blind_pw(transfer_dict):
6572
for target in ('lftp_src', 'lftp_dst'):
6673
if transfer_dict.get(target, ''):
6774
replace_map[target] = (
68-
r'(.*://[^:]*):[^@]+@(.*)', r'\1:%s@\2' % hide_pw)
75+
r'(.*://[^:]*):[^@]+@(.*)', r'\1:%s@\2' % hide_pw)
6976
blinded = mask_creds(
7077
transfer_dict, masked_value=hide_pw, subst_map=replace_map)
7178
return blinded
@@ -114,32 +121,58 @@ def build_keyitem_object(configuration, key_dict):
114121
return key_obj
115122

116123

117-
def load_data_transfers(configuration, client_id):
118-
"""Find all data transfers owned by user"""
124+
def lock_data_transfers(transfers_path, exclusive=True, blocking=True):
125+
"""Lock per-user transfers index"""
126+
transfers_lock_path = '%s.lock' % transfers_path
127+
return acquire_file_lock(transfers_lock_path, exclusive=exclusive,
128+
blocking=blocking)
129+
130+
131+
def unlock_data_transfers(transfers_lock):
132+
"""Unlock per-user transfers index"""
133+
return release_file_lock(transfers_lock)
134+
135+
136+
def load_data_transfers(configuration, client_id, do_lock=True, blocking=True):
137+
"""Find all data transfers owned by user with optional locking support
138+
for synchronized access.
139+
"""
119140
logger = configuration.logger
120141
logger.debug("load transfers for %s" % client_id)
142+
transfers_path = get_transfers_path(configuration, client_id)
143+
if do_lock:
144+
flock = lock_data_transfers(transfers_path, exclusive=False,
145+
blocking=blocking)
146+
if not blocking and not flock:
147+
return (False, "could not lock+load saved data transfers for %s" %
148+
client_id)
121149
try:
122-
transfers_path = os.path.join(configuration.user_settings,
123-
client_id_dir(client_id),
124-
datatransfers_filename)
125150
logger.debug("load transfers from %s" % transfers_path)
126151
if os.path.isfile(transfers_path):
127152
transfers = load(transfers_path)
128153
else:
129154
transfers = {}
130155
except Exception as exc:
156+
if do_lock:
157+
unlock_data_transfers(flock)
131158
return (False, "could not load saved data transfers: %s" % exc)
159+
if do_lock:
160+
unlock_data_transfers(flock)
132161
return (True, transfers)
133162

134163

135-
def get_data_transfer(configuration, client_id, transfer_id, transfers=None):
164+
def get_data_transfer(configuration, client_id, transfer_id, transfers=None,
165+
do_lock=True, blocking=True):
136166
"""Helper to extract all details for a data transfer. The optional
137167
transfers argument can be used to pass an already loaded dictionary of
138-
saved transfers to avoid reloading.
168+
saved transfers to avoid reloading. In that case the caller might want to
169+
hold the corresponding lock during the handling here to avoid races.
170+
Locking is also generally supported for synchronized access.
139171
"""
140172
if transfers is None:
141173
(load_status, transfers) = load_data_transfers(configuration,
142-
client_id)
174+
client_id, do_lock,
175+
blocking)
143176
if not load_status:
144177
return (load_status, transfers)
145178
transfer_dict = transfers.get(transfer_id, None)
@@ -150,18 +183,34 @@ def get_data_transfer(configuration, client_id, transfer_id, transfers=None):
150183

151184

152185
def modify_data_transfers(configuration, client_id, transfer_dict, action,
153-
transfers=None):
186+
transfers=None, do_lock=True, blocking=True):
154187
"""Modify data transfers with given action and transfer_dict for client_id.
155188
In practice this a shared helper to add or remove transfers from the saved
156189
data transfers. The optional transfers argument can be used to pass an
157-
already loaded dictionary of saved transfers to avoid reloading.
190+
already loaded dictionary of saved transfers to avoid reloading. In that
191+
case the caller might want to hold the corresponding lock during the
192+
handling here to avoid races. Locking is also generally supported for
193+
synchronized access.
158194
"""
159195
logger = configuration.logger
160196
transfer_id = transfer_dict['transfer_id']
197+
transfers_path = get_transfers_path(configuration, client_id)
198+
# Lock during entire load and save
199+
if do_lock:
200+
flock = lock_data_transfers(transfers_path, exclusive=True,
201+
blocking=blocking)
202+
if not blocking and not flock:
203+
return (False, "could not lock+update data transfers for %s" %
204+
client_id)
205+
161206
if transfers is None:
207+
# Load without repeated lock
162208
(load_status, transfers) = load_data_transfers(configuration,
163-
client_id)
209+
client_id,
210+
do_lock=False)
164211
if not load_status:
212+
if do_lock:
213+
unlock_data_transfers(flock)
165214
logger.error("modify_data_transfers failed in load: %s" %
166215
transfers)
167216
return (load_status, transfers)
@@ -180,49 +229,61 @@ def modify_data_transfers(configuration, client_id, transfer_dict, action,
180229
elif action == "delete":
181230
del transfers[transfer_id]
182231
else:
183-
return (False, "Invalid action %s on data transfers" % action)
232+
if do_lock:
233+
unlock_data_transfers(flock)
234+
return (False, "Invalid action %s on data transfer %s" % (action,
235+
transfer_id))
184236

185237
try:
186-
transfers_path = os.path.join(configuration.user_settings,
187-
client_id_dir(client_id),
188-
datatransfers_filename)
189238
dump(transfers, transfers_path)
190239
res_dir = get_status_dir(configuration, client_id, transfer_id)
191240
makedirs_rec(res_dir, configuration)
192241
except Exception as err:
242+
if do_lock:
243+
unlock_data_transfers(flock)
193244
logger.error("modify_data_transfers failed: %s" % err)
194245
return (False, 'Error updating data transfers: %s' % err)
246+
if do_lock:
247+
unlock_data_transfers(flock)
195248
return (True, transfer_id)
196249

197250

198251
def create_data_transfer(configuration, client_id, transfer_dict,
199-
transfers=None):
252+
transfers=None, do_lock=True, blocking=True):
200253
"""Create a new data transfer for client_id. The optional
201254
transfers argument can be used to pass an already loaded dictionary of
202-
saved transfers to avoid reloading.
255+
saved transfers to avoid reloading. In that case the caller might want to
256+
hold the corresponding lock during the handling here to avoid races.
257+
Locking is also generally supported for synchronized access.
203258
"""
204259
return modify_data_transfers(configuration, client_id, transfer_dict,
205-
"create", transfers)
260+
"create", transfers, do_lock, blocking)
206261

207262

208263
def update_data_transfer(configuration, client_id, transfer_dict,
209-
transfers=None):
264+
transfers=None, do_lock=True, blocking=True):
210265
"""Update existing data transfer for client_id. The optional transfers
211266
argument can be used to pass an already loaded dictionary of saved
212-
transfers to avoid reloading.
267+
transfers to avoid reloading. In that case the caller might want to
268+
hold the corresponding lock during the handling here to avoid races.
269+
Locking is also generally supported for synchronized access.
213270
"""
214271
return modify_data_transfers(configuration, client_id, transfer_dict,
215-
"modify", transfers)
272+
"modify", transfers, do_lock, blocking)
216273

217274

218275
def delete_data_transfer(configuration, client_id, transfer_id,
219-
transfers=None):
276+
transfers=None, do_lock=True, blocking=True):
220277
"""Delete an existing data transfer without checking ownership. The
221278
optional transfers argument can be used to pass an already loaded
222-
dictionary of saved transfers to avoid reloading. """
279+
dictionary of saved transfers to avoid reloading. In that case the caller
280+
might want to hold the corresponding lock during the handling here to avoid
281+
races.
282+
Locking is also generally supported for synchronized access.
283+
"""
223284
transfer_dict = {'transfer_id': transfer_id}
224285
return modify_data_transfers(configuration, client_id, transfer_dict,
225-
"delete", transfers)
286+
"delete", transfers, do_lock, blocking)
226287

227288

228289
def load_user_keys(configuration, client_id):
@@ -434,11 +495,93 @@ def kill_sub_pid(configuration, client_id, transfer_id, sub_pid, sig=9):
434495
from mig.shared.conf import get_configuration_object
435496
conf = get_configuration_object()
436497
print("Unit testing transfer functions")
498+
# NOTE: use /tmp for testing
499+
orig_user_settings = conf.user_settings
500+
client, transfer = "testuser", "testtransfer"
501+
conf.user_settings = '/tmp/transferstest'
502+
dummy_transfers_dir = os.path.join(conf.user_settings, client)
503+
dummy_transfers_file = os.path.join(dummy_transfers_dir,
504+
datatransfers_filename)
505+
makedirs_rec(dummy_transfers_dir, conf)
506+
transfer_dict = {'transfer_id': transfer}
507+
dummypw = 'NotSoSecretDummy'
508+
transfer_dict.update(
509+
{'password': dummypw,
510+
'lftp_src': 'sftp://john.doe:%s@nowhere.org/README' % dummypw,
511+
'lftp_dst': 'https://john.doe:%s@outerspace.org/' % dummypw,
512+
})
513+
print("=== user transfers dict mangling ===")
514+
(status, transfers) = load_data_transfers(conf, client)
515+
print("initial transfers before create : %s" % transfers)
516+
create_data_transfer(conf, client, transfer_dict)
517+
(status, transfers) = load_data_transfers(conf, client)
518+
print("transfers after create: %s" % transfers)
519+
transfer_dict['password'] += "-UPDATED-NOW"
520+
update_data_transfer(conf, client, transfer_dict)
521+
(status, transfers) = load_data_transfers(conf, client)
522+
print("transfers after update: %s" % transfers)
523+
delete_data_transfer(conf, client, transfer)
524+
(status, transfers) = load_data_transfers(conf, client)
525+
print("transfers after delete: %s" % transfers)
526+
527+
print("lock transfers file for testing prevented create access")
528+
dummy_lock = lock_data_transfers(dummy_transfers_file, exclusive=True)
529+
for i in range(3):
530+
print("try creating transfer while locked (%d)" % i)
531+
transfer_dict['transfer_id'] = '%s-%s' % (transfer, i)
532+
(create_status, created_id) = \
533+
create_data_transfer(conf, client, transfer_dict,
534+
blocking=False)
535+
print("create transfer while locked status: %s" % create_status)
536+
time.sleep(1)
537+
print("unlock transfers file for testing restored create access")
538+
unlock_data_transfers(dummy_lock)
539+
(status, transfers) = load_data_transfers(conf, client)
540+
print("transfers after locked create attempts: %s" % transfers)
541+
for i in range(3):
542+
print("try creating transfer while unlocked (%d)" % i)
543+
transfer_dict['transfer_id'] = '%s-%s' % (transfer, i)
544+
(create_status, created_id) = \
545+
create_data_transfer(conf, client, transfer_dict,
546+
blocking=False)
547+
print("create transfer while unlocked status: %s" % create_status)
548+
time.sleep(1)
549+
550+
(status, transfers) = load_data_transfers(conf, client)
551+
print("transfers after unlocked create attempts: %s" % transfers)
552+
print("lock transfers file for testing prevented delete access")
553+
dummy_lock = lock_data_transfers(dummy_transfers_file, exclusive=True)
554+
for i in range(3):
555+
print("try deleting transfer while locked (%d)" % i)
556+
transfer_id = transfer_dict['transfer_id'] = '%s-%s' % (transfer, i)
557+
(delete_status, delete_id) = \
558+
delete_data_transfer(conf, client, transfer_id,
559+
blocking=False)
560+
print("delete transfer while locked status: %s" % delete_status)
561+
time.sleep(1)
562+
563+
print("unlock transfers file for testing restored delete access")
564+
unlock_data_transfers(dummy_lock)
565+
(status, transfers) = load_data_transfers(conf, client)
566+
print("transfers after locked delete attempts: %s" % transfers)
567+
for i in range(3):
568+
print("try deleting transfer while unlocked (%d)" % i)
569+
transfer_id = transfer_dict['transfer_id'] = '%s-%s' % (transfer, i)
570+
(delete_status, delete_id) = \
571+
delete_data_transfer(conf, client, transfer_id,
572+
blocking=False)
573+
print("delete transfer while unlocked status: %s" % delete_status)
574+
time.sleep(1)
575+
576+
(status, transfers) = load_data_transfers(conf, client)
577+
print("transfers after unlocked delete attempts: %s" % transfers)
578+
579+
remove_rec(dummy_transfers_dir, conf)
580+
437581
print("=== sub pid functions ===")
438582
import multiprocessing
439583
manager = multiprocessing.Manager()
440584
sub_procs_map = manager.dict()
441-
client, transfer = "testuser", "testtransfer"
442585
sub_procs = sub_pid_list(conf, sub_procs_map, client, transfer)
443586
print("initial sub pids: %s" % sub_procs)
444587
for pid in xrange(3):
@@ -476,13 +619,6 @@ def kill_sub_pid(configuration, client_id, transfer_id, sub_pid, sig=9):
476619
print("verify transfer worker is no longer found: %s" % verify_worker)
477620
transfer_workers = all_worker_transfers(conf, workers_map)
478621
print("final transfer workers: %s" % transfer_workers)
479-
transfer_dict = {}
480-
dummypw = 'NotSoSecretDummy'
481-
transfer_dict.update(
482-
{'password': dummypw,
483-
'lftp_src': 'sftp://john.doe:%s@nowhere.org/README' % dummypw,
484-
'lftp_dst': 'https://john.doe:%s@outerspace.org/' % dummypw,
485-
})
486622

487623
print("raw transfer dict:\n%s\nis blinded into:\n%s" %
488624
(transfer_dict, blind_pw(transfer_dict)))

tests/support.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class FakeLogger:
8585
majority of MiG code by presenting an API compatible interface
8686
with the common logger module.
8787
88-
An instance of this class is made avaiable to test cases which
88+
An instance of this class is made available to test cases which
8989
can pass it down into function calls and subsequenently make
9090
assertions against any output strings hat were recorded during
9191
execution while also avoiding noise hitting the console.
@@ -111,7 +111,7 @@ def check_empty_and_reset(self):
111111
# complain loudly (and in detail) in the case of unclosed files
112112
if len(unclosed_by_file) > 0:
113113
messages = '\n'.join({' --> %s: line=%s, file=%s' % (fname, lineno, outname)
114-
for fname, (lineno, outname) in unclosed_by_file.items()})
114+
for fname, (lineno, outname) in unclosed_by_file.items()})
115115
raise RuntimeError('unclosed files encountered:\n%s' % (messages,))
116116

117117
def debug(self, line):
@@ -155,6 +155,23 @@ def identify_unclosed_file(specifics):
155155
return (relative_testfile, (lineno, relative_outputfile))
156156

157157

158+
class FakeConfiguration:
159+
"""A simple helper to pretend we have a real Configuration object with any
160+
required attributes explicitly passed.
161+
Automatically attaches a FakeLogger instance if no logger is provided in
162+
kwargs.
163+
"""
164+
165+
def __init__(self, **kwargs):
166+
"""Initialise instance attributes to be any named args provided and a
167+
FakeLogger instance attached if not provided.
168+
"""
169+
self.__dict__.update(kwargs)
170+
if not 'logger' in self.__dict__:
171+
dummy_logger = FakeLogger()
172+
self.__dict__.update({'logger': dummy_logger})
173+
174+
158175
class MigTestCase(TestCase):
159176
"""Embellished base class for MiG test cases. Provides additional commonly
160177
used assertions as well as some basics for the standardised and idiomatic

0 commit comments

Comments
 (0)