summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAlois Mahdal <amahdal@redhat.com>2014-02-14 14:16:21 +0100
committerAlois Mahdal <amahdal@fullmoon.brq.redhat.com>2014-02-27 14:42:23 +0100
commitf74d14f80a890ba2954be1c1ac3af9e1b8a64800 (patch)
tree0995c8d625e5519f088f0d7a3b3c342558ea538c /src
parent15c3b13b92d81dc188e7336901e456323ab91b2a (diff)
downloadopenlmi-providers-f74d14f80a890ba2954be1c1ac3af9e1b8a64800.tar.gz
openlmi-providers-f74d14f80a890ba2954be1c1ac3af9e1b8a64800.tar.xz
openlmi-providers-f74d14f80a890ba2954be1c1ac3af9e1b8a64800.zip
account: Added test for race conditions
Based on bug 1061153, these tests attempt to create, modify or delete users from a number of threads. Clean up is done by reverting backup of /etc/passwd, /etc/groups, /etc/shadow and /etc/gshadow.
Diffstat (limited to 'src')
-rw-r--r--src/account/test/TestAccountRaceConditions.py201
-rw-r--r--src/account/test/common.py164
-rw-r--r--src/account/test/methods.py41
3 files changed, 406 insertions, 0 deletions
diff --git a/src/account/test/TestAccountRaceConditions.py b/src/account/test/TestAccountRaceConditions.py
new file mode 100644
index 0000000..6125bd3
--- /dev/null
+++ b/src/account/test/TestAccountRaceConditions.py
@@ -0,0 +1,201 @@
+# -*- encoding: utf-8 -*-
+# Copyright(C) 2012-2013 Red Hat, Inc. All rights reserved.
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or(at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# Authors: Alois Mahdal <amahdal@redhat.com>
+
+import methods
+from common import AccountBase, BackupStorage, PasswdFile
+from pywbem import CIMError
+
+import threading
+
+
+## ......................................................................... ##
+## Helper methods
+## ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ##
+
+def perform_attempts(tcls, names, args):
+ """
+ Spawn and wait for one thread per username, passing name and args.
+
+ tcls can be threading.Thread subclass or a callable that returns
+ instance of such. Must accept two arguments: username and connection
+ to the CIM. It also must implement attribute "result" to communicate
+ result to parent threads.
+
+ args is an arbitrary dict that should contain at least 'ns' for LMI
+ Namespace object (usually conn.root.cimv2) plus any other objects that
+ particular subclass needs.
+
+ Returns list of results.
+ """
+ # create, start threads and wait for them to finish
+ threads = []
+ for name in names:
+ threads.append(tcls(name, args))
+ [t.start() for t in threads]
+ [t.join() for t in threads]
+ return [t.result for t in threads]
+
+
+## ......................................................................... ##
+## Threading helper classes
+## ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ##
+
+class AccountActionAttempt(threading.Thread):
+ """
+ Base class to perform an action with account in a thread.
+ """
+
+ def __init__(self, username, args):
+ threading.Thread.__init__(self)
+ self.args = args
+ self.username = username
+ self.ns = self.args['ns']
+ self.result = None
+
+ def _chkargs(self, keys):
+ """Check keys in args; raise sensible message"""
+ for key in keys:
+ if not key in self.args:
+ raise ValueError("%s needs %s passed in args"
+ % (self.__class__.__name__, key))
+
+
+class CreationAttempt(AccountActionAttempt):
+ """
+ Try to create user, mute normal errors (error is OK)
+ """
+
+ def run(self):
+ self._chkargs(['system_iname'])
+ system_iname = self.args['system_iname']
+ lams = self.ns.LMI_AccountManagementService.first_instance()
+ try:
+ lams.CreateAccount(Name=self.username, System=system_iname)
+ self.result = self.username
+ except CIMError:
+ # OK, error reported to user
+ self.result = False
+
+
+class ModificationAttempt(AccountActionAttempt):
+ """
+ Try to modify user
+ """
+
+ def run(self):
+ account = self.ns.LMI_Account.first_instance({"Name": self.username})
+ account.LoginShell = methods.random_shell()
+ account.push()
+
+
+class DeletionAttempt(AccountActionAttempt):
+ """
+ Try to delete user
+ """
+
+ def run(self):
+ account = self.ns.LMI_Account.first_instance({"Name": self.username})
+ account.DeleteUser()
+
+
+## ......................................................................... ##
+## Actual test
+## ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ##
+
+class TestAccountRaceConditions(AccountBase):
+
+ def setUp(self):
+ self.user_count = 20 # = thread count
+ self.prefix = "user"
+ self.names = [methods.random_string(size=8, prefix=self.prefix)
+ for i in xrange(self.user_count)]
+
+ self.bs = BackupStorage()
+ self.bs.add_file("/etc/passwd")
+ self.bs.add_file("/etc/shadow")
+ self.bs.add_file("/etc/group")
+ self.bs.add_file("/etc/gshadow")
+ self.args = {'ns': self.ns}
+
+ def tearDown(self):
+ self.bs.restore_all()
+
+ def assertPasswdNotCorrupt(self):
+ """
+ Assert /etc/passwd is not corrupt
+ """
+ pf = PasswdFile()
+ errors = pf.get_errors()
+ msg = ("/etc/passwd corrupt: %s\n\nFull text follows:\n%s\n"
+ % (errors, pf.fulltext))
+ self.assertFalse(errors, msg)
+
+ def assertUserCount(self, count=None):
+ """
+ Assert particular user count in /etc/passwd (filter on prefix)
+ """
+ if count is None:
+ count = self.user_count
+ pf = PasswdFile({'username_prefix': self.prefix})
+ oc = count
+ rc = len(pf.users)
+ self.assertEqual(rc, oc,
+ "wrong user count: %s, expected %s" % (rc, oc))
+
+ def assertUserNameSet(self, nameset=None):
+ """
+ Assert particular username set in /etc/passwd (filter on prefix)
+ """
+ if nameset is None:
+ nameset = self.names
+ pf = PasswdFile({'username_prefix': self.prefix})
+ on = sorted(nameset)
+ rn = sorted(pf.get_names())
+ self.assertEqual(rn, on,
+ "wrong user set: %s, expected %s" % (rn, on))
+
+ def test_create(self):
+ """
+ Account: Test creations from many threads.
+ """
+ self.args['system_iname'] = self.system_iname
+ created_names = filter(
+ lambda r: isinstance(r, basestring),
+ perform_attempts(CreationAttempt, self.names, self.args)
+ )
+ self.assertUserNameSet(created_names)
+ self.assertPasswdNotCorrupt()
+
+ def test_modify(self):
+ """
+ Account: Test modifications from many threads.
+ """
+ [methods.create_account(n) for n in self.names]
+ perform_attempts(ModificationAttempt, self.names, self.args)
+ self.assertUserCount()
+ self.assertPasswdNotCorrupt()
+
+ def test_delete(self):
+ """
+ Account: Test deletions from many threads.
+ """
+ [methods.create_account(n) for n in self.names]
+ perform_attempts(DeletionAttempt, self.names, self.args)
+ self.assertUserCount(0)
+ self.assertPasswdNotCorrupt()
diff --git a/src/account/test/common.py b/src/account/test/common.py
index 938fb11..2b3b423 100644
--- a/src/account/test/common.py
+++ b/src/account/test/common.py
@@ -21,10 +21,17 @@
Base class and utilities for all OpenLMI Account tests.
"""
+import hashlib
import os
+import tempfile
+import subprocess
+from collections import Counter
+from collections import OrderedDict
+import methods
from lmi.test import lmibase
+
class AccountBase(lmibase.LmiTestCase):
"""
Base class for all LMI Account tests.
@@ -38,3 +45,160 @@ class AccountBase(lmibase.LmiTestCase):
cls.user_name = os.environ.get("LMI_ACCOUNT_USER")
cls.group_name = os.environ.get("LMI_ACCOUNT_GROUP")
+
+## ......................................................................... ##
+## Validators
+## ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ##
+
+class PasswdFile():
+ """
+ Parse /etc/passwd and perform basic heuristics to assess validity.
+
+ By heuristics, I mean it's OK to include here what is considered to
+ be "normal", or "expected" rather than strictly vaid/invalid. For
+ example, you can consider "not normal" to have UID!=GID, but depending
+ on what you did, it could be OK. OTOH, keep in mind that more specific
+ things should be in the test itself.
+ """
+
+ DEFAULT_OPTIONS = {
+ 'username_prefix': 'user',
+ 'unique': [
+ "name",
+ "uid",
+ ]
+ }
+
+ def __init__(self, options=None):
+ self.options = self.__class__.DEFAULT_OPTIONS
+ if options is not None:
+ self.options.update(options)
+ self.users = []
+
+ with open('/etc/passwd') as pf:
+ lines = pf.readlines()
+ self.fulltext = "".join(lines)
+
+ for line in lines:
+ fields = line.split(":")
+ user = {
+ "name": fields[0],
+ "password": fields[1],
+ "uid": fields[2],
+ "gid": fields[3],
+ "gecos": fields[4],
+ "directory": fields[5],
+ "shell": fields[6],
+ }
+ if user['name'].startswith(self.options['username_prefix']):
+ self.users.append(user)
+
+ def find_dups(self):
+ """
+ Find dups in fields that should be unique
+ """
+ dups = Counter()
+ for field in self.options['unique']:
+ if not methods.field_is_unique(field, self.users):
+ dups[field] += 1
+ return dict(dups)
+
+ def get_errors(self):
+ """
+ Get hash of errors.
+ """
+ errlist = {}
+ dups = self.find_dups()
+ if dups:
+ errlist['duplicates'] = dups
+ return errlist
+
+ def get_names(self):
+ """
+ Get list of user names
+ """
+ return [u['name'] for u in self.users]
+
+
+## ......................................................................... ##
+## Other helpers
+## ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ##
+
+class BackupStorage():
+ """
+ Simple file backup storage.
+
+ * Only supports files.
+ * Only supports absolute paths.
+ * Consecutive backups rewrite each other.
+ * Does not autodestroy the backup.
+ """
+
+ def __init__(self):
+ self.root = tempfile.mkdtemp(prefix=self.__class__.__name__ + ".")
+ self.backups = OrderedDict()
+ subprocess.check_call(["mkdir", "-p", self.root])
+
+ def _copy(self, src, dest):
+ """
+ Copy src to dst --- force, keep meta, no questions asked
+ """
+ subprocess.check_call(["cp", "-a", "-f", src, dest])
+
+ def _get_bpath(self, path):
+ """
+ Take original path and return path to backup.
+ """
+ if not path.startswith("/"):
+ raise ValueError("only absolute paths are supported")
+ digest = hashlib.sha1(path).hexdigest()
+ return self.root + "/" + digest
+
+ def _update_index(self):
+ """
+ Create/update an index file to help in case of backup investigation
+
+ For convenience, index file is sorted by real path.
+ """
+ paths = sorted(self.backups.keys())
+ with open(self.root + "/index", "w+") as fh:
+ for path in paths:
+ fh.write("%s %s\n" % (self.backups[path], path))
+
+ def add_files(self, paths):
+ """
+ Add list of tiles to backup storage
+ """
+ for path in paths:
+ self.add_file(path)
+
+ def add_file(self, path):
+ """
+ Add a file to backup storage
+ """
+ bpath = self._get_bpath(path)
+ self._copy(path, bpath)
+ self.backups[path] = bpath
+ self._update_index()
+
+ def restore(self, path):
+ """
+ Restore particular path
+ """
+ try:
+ self._copy(self.backups[path], path)
+ except KeyError:
+ raise ValueError("path not stored: %s" % path)
+
+ def restore_all(self):
+ """
+ Restore all stored paths in same order as they were stored
+ """
+ for key in self.backups.keys():
+ self.restore(key)
+
+ def destroy_backup(self):
+ """
+ Destroy the temporary backup
+ """
+ subprocess.call(["rm", "-rf", self.root])
diff --git a/src/account/test/methods.py b/src/account/test/methods.py
index b453c68..9c033c5 100644
--- a/src/account/test/methods.py
+++ b/src/account/test/methods.py
@@ -18,6 +18,8 @@
# Authors: Roman Rakus <rrakus@redhat.com>
#
+import random
+import string
import subprocess
def user_exists(username):
@@ -95,3 +97,42 @@ def create_group(group_name):
if not group_exists(group_name):
subprocess.check_call(["groupadd", group_name])
+def random_string(size=6, chars=None, prefix=""):
+ """
+ Generate a random string, e.g. usable as UID/GID
+ """
+ if chars is None:
+ chars = string.ascii_uppercase + string.digits
+ if len(prefix) > size:
+ raise ValueError("prefix too long: %s > %s" % (len(prefix), size))
+ salt = ''.join([random.choice(chars) for x in range(size - len(prefix))])
+ return prefix + salt
+
+def random_shell():
+ """
+ Make up a funny shell
+ """
+ return random.choice([
+ "/bin/ash",
+ "/bin/cash",
+ "/bin/dash",
+ "/bin/hash",
+ "/bin/nash",
+ "/bin/mash",
+ "/bin/sash",
+ "/bin/stash",
+ "/bin/splash",
+ "/bin/wash",
+ ])
+
+def field_is_unique(fname, records):
+ """
+ True if the field in `records` has unique values.
+ """
+ seen = []
+ for record in records:
+ if record[fname] in seen:
+ return False
+ else:
+ seen.append(record[fname])
+ return True