diff options
author | Alois Mahdal <amahdal@redhat.com> | 2014-02-14 14:16:21 +0100 |
---|---|---|
committer | Alois Mahdal <amahdal@fullmoon.brq.redhat.com> | 2014-02-27 14:42:23 +0100 |
commit | f74d14f80a890ba2954be1c1ac3af9e1b8a64800 (patch) | |
tree | 0995c8d625e5519f088f0d7a3b3c342558ea538c /src/account | |
parent | 15c3b13b92d81dc188e7336901e456323ab91b2a (diff) | |
download | openlmi-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/account')
-rw-r--r-- | src/account/test/TestAccountRaceConditions.py | 201 | ||||
-rw-r--r-- | src/account/test/common.py | 164 | ||||
-rw-r--r-- | src/account/test/methods.py | 41 |
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 |