utils.py 16.6 KB
Newer Older
1
# -*- coding: utf-8 -*-
Marco Mariani's avatar
Marco Mariani committed
2
# vim: set et sts=2:
Łukasz Nowak's avatar
Łukasz Nowak committed
3 4
##############################################################################
#
5 6
# Copyright (c) 2010, 2011, 2012 Vifib SARL and Contributors.
# All Rights Reserved.
Łukasz Nowak's avatar
Łukasz Nowak committed
7 8 9 10 11
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
12
# guarantees and support are strongly advised to contract a Free Software
Łukasz Nowak's avatar
Łukasz Nowak committed
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
# Service Company
#
# 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, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################
30

Marco Mariani's avatar
Marco Mariani committed
31
import grp
Łukasz Nowak's avatar
Łukasz Nowak committed
32 33
import hashlib
import os
34
import pkg_resources
Marco Mariani's avatar
Marco Mariani committed
35
import pwd
Łukasz Nowak's avatar
Łukasz Nowak committed
36 37 38
import stat
import subprocess
import sys
39 40
import logging
import time
41
import psutil
42
from datetime import datetime
Marco Mariani's avatar
Marco Mariani committed
43 44

from slapos.grid.exception import BuildoutFailedError, WrongPermissionError
Łukasz Nowak's avatar
Łukasz Nowak committed
45 46 47

# Such umask by default will create paths with full permission
# for user, non writable by group and not accessible by others
Marco Mariani's avatar
Marco Mariani committed
48
SAFE_UMASK = 0o27
Łukasz Nowak's avatar
Łukasz Nowak committed
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65

PYTHON_ENVIRONMENT_REMOVE_LIST = [
  'PYTHONHOME',
  'PYTHONPATH',
  'PYTHONSTARTUP',
  'PYTHONY2K',
  'PYTHONOPTIMIZE',
  'PYTHONDEBUG',
  'PYTHONDONTWRITEBYTECODE',
  'PYTHONINSPECT',
  'PYTHONNOUSERSITE',
  'PYTHONNOUSERSITE',
  'PYTHONUNBUFFERED',
  'PYTHONVERBOSE',
]

SYSTEM_ENVIRONMENT_REMOVE_LIST = [
66
  'CONFIG_SITE',
Łukasz Nowak's avatar
Łukasz Nowak committed
67 68 69 70 71 72 73 74
  'ENV',
  'LOGNAME',
  'TEMP',
  'TMP',
  'TMPDIR',
  'USER',
]

Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
75
LOCALE_ENVIRONMENT_REMOVE_LIST = [
Łukasz Nowak's avatar
Łukasz Nowak committed
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
  'LANG',
  'LANGUAGE',
  'LC_ADDRESS',
  'LC_COLLATE',
  'LC_CTYPE',
  'LC_IDENTIFICATION',
  'LC_MEASUREMENT',
  'LC_MESSAGES',
  'LC_MONETARY',
  'LC_NAME',
  'LC_NUMERIC',
  'LC_PAPER',
  'LC_SOURCED',
  'LC_TELEPHONE',
  'LC_TIME',
]


94 95 96
class PromiseError(Exception):
  pass

Łukasz Nowak's avatar
Łukasz Nowak committed
97
class SlapPopen(subprocess.Popen):
98 99
  """
  Almost normal subprocess with greedish features and logging.
100 101
  Each line is logged "live", and self.output is a string containing the whole
  log.
102
  """
Łukasz Nowak's avatar
Łukasz Nowak committed
103
  def __init__(self, *args, **kwargs):
104
    logger = kwargs.pop('logger')
Łukasz Nowak's avatar
Łukasz Nowak committed
105
    kwargs.update(stdin=subprocess.PIPE)
Jondy Zhao's avatar
Jondy Zhao committed
106 107
    if sys.platform == 'cygwin' and kwargs.get('env') == {}:
      kwargs['env'] = None
Łukasz Nowak's avatar
Łukasz Nowak committed
108 109 110 111 112
    subprocess.Popen.__init__(self, *args, **kwargs)
    self.stdin.flush()
    self.stdin.close()
    self.stdin = None

113
    # XXX-Cedric: this algorithm looks overkill for simple logging.
Marco Mariani's avatar
Marco Mariani committed
114
    output_lines = []
115 116
    while True:
      line = self.stdout.readline()
Marco Mariani's avatar
Marco Mariani committed
117
      if line == '' and self.poll() is not None:
118
        break
119 120 121
      if line:
        output_lines.append(line)
        logger.info(line.rstrip('\n'))
Marco Mariani's avatar
Marco Mariani committed
122
    self.output = ''.join(output_lines)
123

Marco Mariani's avatar
Marco Mariani committed
124

125
def md5digest(url):
Marco Mariani's avatar
Marco Mariani committed
126
  return hashlib.md5(url).hexdigest()
Łukasz Nowak's avatar
Łukasz Nowak committed
127 128


129
def getCleanEnvironment(logger, home_path='/tmp'):
Łukasz Nowak's avatar
Łukasz Nowak committed
130 131 132 133 134
  changed_env = {}
  removed_env = []
  env = os.environ.copy()
  # Clean python related environment variables
  for k in PYTHON_ENVIRONMENT_REMOVE_LIST + SYSTEM_ENVIRONMENT_REMOVE_LIST \
Marco Mariani's avatar
Marco Mariani committed
135
        + LOCALE_ENVIRONMENT_REMOVE_LIST:
Łukasz Nowak's avatar
Łukasz Nowak committed
136 137 138 139 140
    old = env.pop(k, None)
    if old is not None:
      removed_env.append(k)
  changed_env['HOME'] = env['HOME'] = home_path
  for k in sorted(changed_env.iterkeys()):
141
    logger.debug('Overridden %s = %r' % (k, changed_env[k]))
142 143
  if removed_env:
    logger.debug('Removed from environment: %s' % ', '.join(sorted(removed_env)))
Łukasz Nowak's avatar
Łukasz Nowak committed
144 145 146
  return env


147
def setRunning(logger, pidfile):
Łukasz Nowak's avatar
Łukasz Nowak committed
148
  """Creates a pidfile. If a pidfile already exists, we exit"""
149
  # XXX might use http://code.activestate.com/recipes/577911-context-manager-for-a-daemon-pid-file/
150
  if os.path.exists(pidfile):
Łukasz Nowak's avatar
Łukasz Nowak committed
151
    try:
152
      pid = int(open(pidfile, 'r').readline())
Łukasz Nowak's avatar
Łukasz Nowak committed
153 154 155
    except ValueError:
      pid = None
    # XXX This could use psutil library.
Marco Mariani's avatar
Marco Mariani committed
156
    if pid and os.path.exists("/proc/%s" % pid):
157 158
      logger.info('New slapos process started, but another slapos '
                  'process is aleady running with pid %s, exiting.' % pid)
159
      sys.exit(10)
160
    logger.info('Existing pid file %r was stale, overwritten' % pidfile)
Łukasz Nowak's avatar
Łukasz Nowak committed
161
  # Start new process
162
  write_pid(logger, pidfile)
Łukasz Nowak's avatar
Łukasz Nowak committed
163 164


165
def setFinished(pidfile):
Łukasz Nowak's avatar
Łukasz Nowak committed
166
  try:
167
    os.remove(pidfile)
Łukasz Nowak's avatar
Łukasz Nowak committed
168 169 170 171
  except OSError:
    pass


172
def write_pid(logger, pidfile):
Łukasz Nowak's avatar
Łukasz Nowak committed
173
  try:
174
    with open(pidfile, 'w') as fout:
Marco Mariani's avatar
Marco Mariani committed
175
      fout.write('%s' % os.getpid())
Łukasz Nowak's avatar
Łukasz Nowak committed
176
  except (IOError, OSError):
177
    logger.critical('slapgrid could not write pidfile %s' % pidfile)
Łukasz Nowak's avatar
Łukasz Nowak committed
178 179 180
    raise


181
def dropPrivileges(uid, gid, logger):
Łukasz Nowak's avatar
Łukasz Nowak committed
182 183 184 185 186
  """Drop privileges to uid, gid if current uid is 0

  Do tests to check if dropping was successful and that no system call is able
  to re-raise dropped privileges

187
  Does nothing if uid and gid are not 0
Łukasz Nowak's avatar
Łukasz Nowak committed
188
  """
189 190
  # XXX-Cedric: remove format / just do a print, otherwise formatting is done
  # twice
Łukasz Nowak's avatar
Łukasz Nowak committed
191 192
  current_uid, current_gid = os.getuid(), os.getgid()
  if uid == 0 or gid == 0:
Marco Mariani's avatar
Marco Mariani committed
193 194
    raise OSError('Dropping privileges to uid = %r or '
                  'gid = %r is too dangerous' % (uid, gid))
195
  if (current_uid or current_gid):
Marco Mariani's avatar
Marco Mariani committed
196 197
    logger.debug('Running as uid = %r, gid = %r, dropping '
                 'not needed and not possible' % (current_uid, current_gid))
Łukasz Nowak's avatar
Łukasz Nowak committed
198 199 200
    return
  # drop privileges
  user_name = pwd.getpwuid(uid)[0]
Marco Mariani's avatar
Marco Mariani committed
201
  group_list = set(x.gr_gid for x in grp.getgrall() if user_name in x.gr_mem)
202
  group_list.add(gid)
Łukasz Nowak's avatar
Łukasz Nowak committed
203 204 205 206 207 208
  os.initgroups(pwd.getpwuid(uid)[0], gid)
  os.setgid(gid)
  os.setuid(uid)

  # assert that privileges are dropped
  message_pre = 'After dropping to uid = %r and gid = %r ' \
Marco Mariani's avatar
Marco Mariani committed
209
                'and group_list = %s' % (uid, gid, group_list)
Łukasz Nowak's avatar
Łukasz Nowak committed
210
  new_uid, new_gid, new_group_list = os.getuid(), os.getgid(), os.getgroups()
211
  if not (new_uid == uid and new_gid == gid and set(new_group_list) == group_list):
Marco Mariani's avatar
Marco Mariani committed
212 213 214 215 216 217
    raise OSError('%s new_uid = %r and new_gid = %r and '
                  'new_group_list = %r which is fatal.'
                    % (message_pre,
                       new_uid,
                       new_gid,
                       new_group_list))
Łukasz Nowak's avatar
Łukasz Nowak committed
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234

  # assert that it is not possible to go back to running one
  try:
    try:
      os.setuid(current_uid)
    except OSError:
      try:
        os.setgid(current_gid)
      except OSError:
        try:
          os.setgroups([current_gid])
        except OSError:
          raise
  except OSError:
    pass
  else:
    raise ValueError('%s it was possible to go back to uid = %r and gid = '
Marco Mariani's avatar
Marco Mariani committed
235
                     '%r which is fatal.' % (message_pre, current_uid, current_gid))
236
  logger.debug('Succesfully dropped privileges to uid=%r gid=%r' % (uid, gid))
Łukasz Nowak's avatar
Łukasz Nowak committed
237 238


239
def bootstrapBuildout(path, logger, buildout=None,
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
240 241 242
                      additional_buildout_parameter_list=None):
  if additional_buildout_parameter_list is None:
    additional_buildout_parameter_list = []
Łukasz Nowak's avatar
Łukasz Nowak committed
243 244 245 246 247 248
  # Reads uid/gid of path, launches buildout with thoses privileges
  stat_info = os.stat(path)
  uid = stat_info.st_uid
  gid = stat_info.st_gid

  invocation_list = [sys.executable, '-S']
249 250
  if buildout is not None:
    invocation_list.append(buildout)
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
251
    invocation_list.extend(additional_buildout_parameter_list)
252
  else:
253
    try:
254
      __import__('zc.buildout')
255 256
    except ImportError:
      logger.warning('Using old style bootstrap of included bootstrap file. '
Łukasz Nowak's avatar
Łukasz Nowak committed
257
        'Consider having zc.buildout available in search path.')
258
      invocation_list.append(pkg_resources.resource_filename(__name__,
259
        'zc.buildout-bootstrap.py'))
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
260
      invocation_list.extend(additional_buildout_parameter_list)
261 262 263
    else:
      # buildout is importable, so use this one
      invocation_list.extend(["-c", "import sys ; sys.path=" + str(sys.path) +
Marco Mariani's avatar
Marco Mariani committed
264
        " ; import zc.buildout.buildout ; sys.argv[1:1]=" +
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
265
        repr(additional_buildout_parameter_list + ['bootstrap']) + " ; "
266 267
        "zc.buildout.buildout.main()"])

268 269
  if buildout is not None:
    invocation_list.append('bootstrap')
Łukasz Nowak's avatar
Łukasz Nowak committed
270 271 272 273 274 275
  try:
    umask = os.umask(SAFE_UMASK)
    logger.debug('Set umask from %03o to %03o' % (umask, SAFE_UMASK))
    logger.debug('Invoking: %r in directory %r' % (' '.join(invocation_list),
      path))
    process_handler = SlapPopen(invocation_list,
276
                                preexec_fn=lambda: dropPrivileges(uid, gid, logger=logger),
277 278
                                cwd=path,
                                stdout=subprocess.PIPE,
279 280
                                stderr=subprocess.STDOUT,
                                logger=logger)
Łukasz Nowak's avatar
Łukasz Nowak committed
281
    if process_handler.returncode is None or process_handler.returncode != 0:
Marco Mariani's avatar
Marco Mariani committed
282
      message = 'Failed to run buildout profile in directory %r' % path
283 284
      logger.error(message)
      raise BuildoutFailedError('%s:\n%s\n' % (message, process_handler.output))
285
  except OSError as exc:
286
    logger.exception(exc)
287
    raise BuildoutFailedError(exc)
Łukasz Nowak's avatar
Łukasz Nowak committed
288 289 290 291 292
  finally:
    old_umask = os.umask(umask)
    logger.debug('Restore umask from %03o to %03o' % (old_umask, umask))


293
def launchBuildout(path, buildout_binary, logger,
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
294
                   additional_buildout_parameter_list=None):
Łukasz Nowak's avatar
Łukasz Nowak committed
295
  """ Launches buildout."""
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
296 297
  if additional_buildout_parameter_list is None:
    additional_buildout_parameter_list = []
Łukasz Nowak's avatar
Łukasz Nowak committed
298 299 300 301 302
  # Reads uid/gid of path, launches buildout with thoses privileges
  stat_info = os.stat(path)
  uid = stat_info.st_uid
  gid = stat_info.st_gid
  # Extract python binary to prevent shebang size limit
Marco Mariani's avatar
Marco Mariani committed
303
  line = open(buildout_binary, 'r').readline()
Łukasz Nowak's avatar
Łukasz Nowak committed
304 305 306 307 308 309 310
  invocation_list = []
  if line.startswith('#!'):
    line = line[2:]
    # Prepares parameters for buildout
    invocation_list = line.split() + [buildout_binary]
  # Run buildout without reading user defaults
  invocation_list.append('-U')
Cédric de Saint Martin's avatar
Typo  
Cédric de Saint Martin committed
311
  invocation_list.extend(additional_buildout_parameter_list)
Łukasz Nowak's avatar
Łukasz Nowak committed
312 313 314 315 316 317
  try:
    umask = os.umask(SAFE_UMASK)
    logger.debug('Set umask from %03o to %03o' % (umask, SAFE_UMASK))
    logger.debug('Invoking: %r in directory %r' % (' '.join(invocation_list),
      path))
    process_handler = SlapPopen(invocation_list,
318
                                preexec_fn=lambda: dropPrivileges(uid, gid, logger=logger),
Marco Mariani's avatar
Marco Mariani committed
319
                                cwd=path,
320
                                env=getCleanEnvironment(logger=logger,
321
                                                        home_path=path),
322
                                stdout=subprocess.PIPE,
323 324
                                stderr=subprocess.STDOUT,
                                logger=logger)
Łukasz Nowak's avatar
Łukasz Nowak committed
325
    if process_handler.returncode is None or process_handler.returncode != 0:
Marco Mariani's avatar
Marco Mariani committed
326
      message = 'Failed to run buildout profile in directory %r' % path
327 328
      logger.error(message)
      raise BuildoutFailedError('%s:\n%s\n' % (message, process_handler.output))
329
  except OSError as exc:
330
    logger.exception(exc)
331
    raise BuildoutFailedError(exc)
Łukasz Nowak's avatar
Łukasz Nowak committed
332 333 334 335 336
  finally:
    old_umask = os.umask(umask)
    logger.debug('Restore umask from %03o to %03o' % (old_umask, umask))


Marco Mariani's avatar
Marco Mariani committed
337
def updateFile(file_path, content, mode=0o600):
338
  """Creates or updates a file with "content" as content."""
Łukasz Nowak's avatar
Łukasz Nowak committed
339 340
  altered = False
  if not (os.path.isfile(file_path)) or \
Marco Mariani's avatar
Marco Mariani committed
341 342
     not (hashlib.md5(open(file_path).read()).digest() ==
          hashlib.md5(content).digest()):
Marco Mariani's avatar
Marco Mariani committed
343 344
    with open(file_path, 'w') as fout:
      fout.write(content)
Cédric de Saint Martin's avatar
Cédric de Saint Martin committed
345
    altered = True
Łukasz Nowak's avatar
Łukasz Nowak committed
346
  os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
Marco Mariani's avatar
Marco Mariani committed
347 348
  if stat.S_IMODE(os.stat(file_path).st_mode) != mode:
    os.chmod(file_path, mode)
Łukasz Nowak's avatar
Łukasz Nowak committed
349 350 351 352 353
    altered = True
  return altered


def updateExecutable(executable_path, content):
354
  """Creates or updates an executable file with "content" as content."""
Marco Mariani's avatar
Marco Mariani committed
355
  return updateFile(executable_path, content, 0o700)
Łukasz Nowak's avatar
Łukasz Nowak committed
356 357 358


def createPrivateDirectory(path):
359
  """Creates a directory belonging to root with umask 077"""
Łukasz Nowak's avatar
Łukasz Nowak committed
360 361 362
  if not os.path.isdir(path):
    os.mkdir(path)
  os.chmod(path, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
Marco Mariani's avatar
Marco Mariani committed
363 364
  permission = stat.S_IMODE(os.stat(path).st_mode)
  if permission != 0o700:
Marco Mariani's avatar
Marco Mariani committed
365
    raise WrongPermissionError('Wrong permissions in %s: '
Marco Mariani's avatar
Marco Mariani committed
366 367
                               'is 0%o, should be 0700'
                               % (path, permission))
368 369 370 371 372 373

def checkPromiseList(promise_dir, promise_timeout, uid=None, gid=None, cwd=None,
                  logger=None, profile=False, raise_on_failure=True):
  """
    Check a promise list and return the result or raise in case of failure
    if `raise_on_failure` is set to True
374
    When `profile` is set to True, log each promise resource usage.
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418
  """
  if logger is None:
    logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s")
    logger = logging.getLogger()
    logger.setLevel(logging.DEBUG)

  promise_result_list = []
  if os.path.exists(promise_dir) and os.path.isdir(promise_dir):
    kw = {}
    if uid is not None and gid is not None:
      kw["preexec_fn"] = lambda: dropPrivileges(uid, gid, logger=logger)
    if cwd is not None:
      kw["cwd"] = cwd
    for promise in os.listdir(promise_dir):

      command = [os.path.join(promise_dir, promise)]

      promise = os.path.basename(command[0])
      logger.info("Checking promise '%s'.", promise)
      if not os.path.isfile(command[0]) or not os.access(command[0], os.X_OK):
        # Not executable file
        logger.warning("Promise script '%s' is not executable.", promise)
        continue

      result_dict = {
        "returncode": -1,
        "title": promise,
        "start-date" : datetime.utcnow(),
        "execution-time": 0,
        "message": ""
      }
      process_handler = subprocess.Popen(command,
                                         env=None if sys.platform == 'cygwin' else {},
                                         stdout=subprocess.PIPE,
                                         stderr=subprocess.PIPE,
                                         stdin=subprocess.PIPE,
                                         **kw)
      process_handler.stdin.flush()
      process_handler.stdin.close()
      process_handler.stdin = None

      # Check if the promise finished every tenth of second,
      # but timeout after promise_timeout.
      sleep_time = 0.1
419 420 421 422 423 424 425 426
      check_profile = False
      if profile:
        try:
          psutil_process = psutil.Process(process_handler.pid)
          check_profile = True
        except psutil.NoSuchProcess:
          # process is gone
          pass
427 428 429
      increment_limit = int(promise_timeout / sleep_time)
      for current_increment in range(0, increment_limit):
        if process_handler.poll() is None:
430
          if check_profile:
431 432 433 434 435 436 437 438 439 440 441
            try:
              io_counter = psutil_process.io_counters()
              logger.debug(
                "[t=%ss] CPU: %s%%, MEM: %s MB (%s%%), DISK: %s Read - %s Write" % (
                  current_increment * sleep_time,
                  psutil_process.cpu_percent(),
                  psutil_process.memory_info().rss / float(2 ** 20),
                  round(psutil_process.memory_percent(), 4),
                  io_counter.read_count,
                  io_counter.write_count
                )
442
              )
443 444 445
            except (psutil.AccessDenied, psutil.NoSuchProcess):
              # defunct process will raise AccessDenied
              pass
446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
          time.sleep(sleep_time)
          continue
        result_dict["execution-time"] = current_increment * sleep_time
        result_dict["returncode"] = process_handler.poll()
        if result_dict["returncode"] == 0:
          # Success!
          result_dict["message"] = process_handler.communicate()[0]
        else:
          stdout, stderr = process_handler.communicate()
          if raise_on_failure:
            if stderr is None:
              stderr = "No error output from '%s'." % promise
            else:
              stderr = "Promise '%s':" % promise + stderr
            raise PromiseError(stderr)
          if not stderr:
            result_dict["message"] = stdout or ""
          else:
            result_dict["message"] = stderr
        break
      else:
        process_handler.terminate()
        if raise_on_failure:
          raise PromiseError("The promise '%s' timed out" % promise)
        message = process_handler.stderr.read()
        if message is None:
          message = process_handler.stdout.read() or ""
        message += '\nPROMISE TIMED OUT AFTER %s SECONDS' % promise_timeout
        result_dict["message"] = message
        result_dict["execution-time"] = current_increment * sleep_time

      promise_result_list.append(result_dict)
478 479
      logger.info("Finished promise %r in %s second(s)." % (
                  promise, result_dict["execution-time"]))
480 481

  return promise_result_list