Commit 8a9e3766 authored by Julien Muchembled's avatar Julien Muchembled

Fix shared=true, other bugs, and inconsistencies between recipes; much cleanup

parent 2044d9e2
This diff is collapsed.
...@@ -5,59 +5,177 @@ except ImportError: ...@@ -5,59 +5,177 @@ except ImportError:
from pkgutil import extend_path from pkgutil import extend_path
__path__ = extend_path(__path__, __name__) __path__ = extend_path(__path__, __name__)
import errno, logging, os, shutil import errno, json, logging, os, shutil, stat
import zc.buildout from hashlib import md5
from zc.buildout import UserError
logger = logging.getLogger(__name__) from zc.buildout.rmtree import rmtree as buildout_rmtree
def generatePassword(length=8): def generatePassword(length=8):
from random import SystemRandom from random import SystemRandom
from string import ascii_lowercase from string import ascii_lowercase
return ''.join(SystemRandom().sample(ascii_lowercase, length)) return ''.join(SystemRandom().sample(ascii_lowercase, length))
def is_true(value, default=False):
return default if value is None else ('false', 'true').index(value)
def make_read_only(path):
if not os.path.islink(path):
os.chmod(path, os.stat(path).st_mode & 0o555)
def make_read_only_recursively(path):
make_read_only(path)
for root, dir_list, file_list in os.walk(path):
for dir_ in dir_list:
make_read_only(os.path.join(root, dir_))
for file_ in file_list:
make_read_only(os.path.join(root, file_))
def rmtree(path): def rmtree(path):
try: try:
os.remove(path) buildout_rmtree(path)
except OSError as e: except OSError as e:
if e.errno != errno.EISDIR: if e.errno == errno.ENOENT:
return
if e.errno != errno.ENOTDIR:
raise raise
shutil.rmtree(path) os.remove(path)
class EnvironMixin: class EnvironMixin(object):
def __init__(self, allow_none=True): def __init__(self, allow_none=True, compat=False):
environment = self.options.get('environment', '').strip() environment = self.options.get('environment')
if environment: if environment:
from os import environ
if '=' in environment: if '=' in environment:
self._environ = env = {} self._environ = env = {}
if compat: # for slapos.recipe.cmmi
environment_section = self.options.get('environment-section')
if environment_section:
env.update(self.buildout[environment_section])
compat = set(env)
else:
compat = ()
for line in environment.splitlines(): for line in environment.splitlines():
line = line.strip() line = line.strip()
if line: if line:
try: try:
k, v = line.split('=', 1) k, v = line.split('=', 1)
except ValueError: except ValueError:
raise zc.buildout.UserError('Line %r in environment is incorrect' % raise UserError('Line %r in environment is incorrect' % line)
line) k = k.rstrip()
k = k.strip()
if k in env: if k in env:
raise zc.buildout.UserError('Key %r is repeated' % k) if k in compat:
env[k] = v.strip() % environ compat.remove(k)
else:
raise UserError('Key %r is repeated' % k)
env[k] = v.lstrip()
else: else:
self._environ = dict((k, v.strip() % environ) self._environ = self.buildout[environment]
for k, v in self.buildout[environment].items())
else: else:
self._environ = None if allow_none else {} self._environ = None if allow_none else {}
@property def __getattr__(self, attr):
def environ(self): if attr == 'logger':
if self._environ is not None: value = logging.getLogger(self.name)
from os import environ elif attr == 'environ':
env = self._environ.copy() env = self._environ
for k, v in env.items(): del self._environ
logger.info( if env is None:
'Environment %r set to %r' if k in environ else value = None
'Environment %r added with %r', k, v) else:
for kw in environ.items(): from os import environ
env.setdefault(*kw) value = environ.copy()
return env for k in sorted(env):
value[k] = v = env[k] % environ
self.logger.info('[ENV] %s = %s', k, v)
else:
return self.__getattribute__(attr)
setattr(self, attr, value)
return value
class Shared(object):
keep_on_error = False
mkdir_location = True
signature = None
def __init__(self, buildout, name, options):
self.maybe_shared = shared = is_true(options.get('shared'))
if shared:
# Trigger computation of part signature for shared signature.
# From now on, we should not pull new dependencies.
# Ignore if buildout is too old.
options.get('__buildout_signature__')
shared = buildout['buildout'].get('shared-part-list')
if shared:
profile_base_location = options.get('_profile_base_location_')
signature = json.dumps({
k: (v.replace(profile_base_location, '${:_profile_base_location_}')
if profile_base_location else v)
for k, v in options.items()
if k != '_profile_base_location_'
}, indent=0, sort_keys=True)
if not isinstance(signature, bytes): # BBB: Python 3
signature = signature.encode()
digest = md5(signature).hexdigest()
location = None
for shared in shared.splitlines():
shared = shared.strip().rstrip('/')
if shared:
location = os.path.join(os.path.join(shared, name), digest)
if os.path.exists(location):
break
if location:
self.logger = logging.getLogger(name)
self.logger.info('shared at %s', location)
self.location = location
self.signature = signature
return
self.location = os.path.join(buildout['buildout']['parts-directory'], name)
def assertNotShared(self, reason):
if self.maybe_shared:
raise UserError("When shared=true, " + reason)
def install(self, install):
signature = self.signature
location = self.location
if signature is not None:
path = os.path.join(location, '.buildout-shared.json')
  • After I released 0.48, I realized that JSON does not support all types our buildout supports (slapos.buildout@4e13dcb9). Currently, we don't combine the 2 features (shared part with non-str value), and we have no plan to do so, but it may be wiser and cleaner to use a format without this limitation.

    I chose to change to JSON because it's so simple and any external non-Python code could easily parse this file: again, no use case for this, now or planned.

    I am considering to switch to a .buildout-shared.signature file that use the same serialization as .installed.cfg.

    Edited by Julien Muchembled
  • Given how slapos node prune is implemented, non-ascii chars must be written as is (utf-8 encoding) rather than being escaped by serialization format.

    • repr (previous format) could not do that with Python 2
    • I can fix .buildout-shared.json by passing ensure_ascii=False to json.dumps
    • .installed.cfg has this issue for non-str types with Python 2 (repr again)
    Edited by Julien Muchembled
  • Do we still rely on that buildout patch (slapos.buildout@4e13dcb9) ? I see we are using more and more explicit json serialization instead. We probably still rely on it for instance buildout, so we are not ready to drop this patch yet, but I think we should not make ourselves more depending on this patch

    Edited by Julien Muchembled
  • (I edited my comment and yours to fix the hash of the commit I referred to)

    I see we are using more and more explicit json serialization instead.

    Where ? We may see more JSON (like _ slapos parameter or slapos@7918c2b2) but internally it uses !py! buildout serialization.

    • .installed.cfg has this issue for non-str types with Python 2 (repr again)

    Note that I don't see this as an issue. I don't see any use of non-str types in near future and by then, we can hope to have switched to Python 3.

    A bigger issue (still theoretical, with the goal to choose good design) is that .installed.cfg is subject to escape other things: we changed buildout to escape ${ into $${.

    I looks like I'm going to keep JSON and update slapos node prune to recognize the new signature file name (so later I can drop the symlink).

    Edited by Julien Muchembled
  • Ah yes, sorry, I think it was about the jsonkey for slapos parameter and maybe something else where my memory failed me.

    In any case, if we rename the signature files, it's better to keep a symlink like it's done here for a transition period, to prevent problems when old slapos.core is used to install new softwares.

Please register or sign in to reply
if os.path.exists(path):
self.logger.info('shared part is already installed')
return ()
rmtree(location)
try:
if self.mkdir_location:
os.makedirs(location)
else:
parent = os.path.dirname(location)
if not os.path.isdir(parent):
os.makedirs(parent)
install()
try:
s = os.stat(location)
except OSError as e:
if e.errno != errno.ENOENT:
raise
raise UserError('%r was not created' % location)
if self.maybe_shared and not stat.S_ISDIR(s.st_mode):
raise UserError('%r is not a directory' % location)
if signature is None:
return [location]
tmp = path + '.tmp'
with open(tmp, 'wb') as f:
f.write(signature)
# XXX: The following symlink is for backward compatibility with old
# 'slapos node prune' (slapos.core).
os.symlink('.buildout-shared.json', os.path.join(location,
'.buildout-shared.signature'))
os.rename(tmp, path)
except:
if not self.keep_on_error:
rmtree(location)
raise
make_read_only_recursively(location)
return ()
...@@ -36,7 +36,7 @@ import subprocess ...@@ -36,7 +36,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import zc.buildout import zc.buildout
from slapos.recipe import rmtree, EnvironMixin from .. import is_true, rmtree, EnvironMixin, Shared
ARCH_MAP = { ARCH_MAP = {
'i386': 'x86', 'i386': 'x86',
...@@ -90,9 +90,7 @@ def guessPlatform(): ...@@ -90,9 +90,7 @@ def guessPlatform():
return ARCH_MAP[uname()[-2]] return ARCH_MAP[uname()[-2]]
GLOBALS = (lambda *x: {x.__name__: x for x in x})( GLOBALS = (lambda *x: {x.__name__: x for x in x})(
call, guessPlatform, guessworkdir) call, guessPlatform, guessworkdir, is_true)
TRUE_LIST = ('y', 'on', 'yes', 'true', '1')
class Script(EnvironMixin): class Script(EnvironMixin):
"""Free script building system""" """Free script building system"""
...@@ -154,10 +152,9 @@ class Script(EnvironMixin): ...@@ -154,10 +152,9 @@ class Script(EnvironMixin):
raise zc.buildout.UserError('Promise not met, found issues:\n %s\n' % raise zc.buildout.UserError('Promise not met, found issues:\n %s\n' %
'\n '.join(promise_problem_list)) '\n '.join(promise_problem_list))
def download(self, url, md5sum=None): def download(self, *args, **kw):
download = zc.buildout.download.Download(self.buildout['buildout'], path, is_temp = zc.buildout.download.Download(self.buildout['buildout'],
hash_name=True, cache=self.buildout['buildout'].get('download-cache')) hash_name=True)(*args, **kw)
path, is_temp = download(url, md5sum=md5sum)
if is_temp: if is_temp:
self.cleanup_list.append(path) self.cleanup_list.append(path)
return path return path
...@@ -227,7 +224,6 @@ class Script(EnvironMixin): ...@@ -227,7 +224,6 @@ class Script(EnvironMixin):
self.options = options self.options = options
self.buildout = buildout self.buildout = buildout
self.name = name self.name = name
self.logger = logging.getLogger('SlapOS build of %s' % self.name)
missing = True missing = True
keys = 'init', 'install', 'update' keys = 'init', 'install', 'update'
for option in keys: for option in keys:
...@@ -238,17 +234,29 @@ class Script(EnvironMixin): ...@@ -238,17 +234,29 @@ class Script(EnvironMixin):
if missing: if missing:
raise zc.buildout.UserError( raise zc.buildout.UserError(
'at least one of the following option is required: ' + ', '.join(keys)) 'at least one of the following option is required: ' + ', '.join(keys))
if self.options.get('keep-on-error', '').strip().lower() in TRUE_LIST: if is_true(self.options.get('keep-on-error')):
self.logger.debug('Keeping directories in case of errors') self.logger.debug('Keeping directories in case of errors')
self.keep_on_error = True self.keep_on_error = True
else: else:
self.keep_on_error = False self.keep_on_error = False
if self._install and 'location' not in options:
options['location'] = os.path.join(
buildout['buildout']['parts-directory'], self.name)
EnvironMixin.__init__(self, False) EnvironMixin.__init__(self, False)
if self._init: if self._init:
self._exec(self._init) self._exec(self._init)
shared = Shared(buildout, name, options)
if self._update:
shared.assertNotShared("option 'update' can't be set")
if self._install:
location = options.get('location')
if location:
shared.assertNotShared("option 'location' can't be set")
shared.location = location
else:
options['location'] = shared.location
shared.keep_on_error = True
shared.mkdir_location = False
self._shared = shared
else:
shared.assertNotShared("option 'install' must be set")
def _exec(self, script): def _exec(self, script):
options = self.options options = self.options
...@@ -268,13 +276,13 @@ class Script(EnvironMixin): ...@@ -268,13 +276,13 @@ class Script(EnvironMixin):
exec(code, g) exec(code, g)
def install(self): def install(self):
if not self._install: if self._install:
self.update() return self._shared.install(self.__install)
return "" self.update()
return ()
def __install(self):
location = self.options['location'] location = self.options['location']
if os.path.lexists(location):
self.logger.warning('Removing already existing path %r', location)
rmtree(location)
self.cleanup_list = [] self.cleanup_list = []
try: try:
self._exec(self._install) self._exec(self._install)
...@@ -290,9 +298,6 @@ class Script(EnvironMixin): ...@@ -290,9 +298,6 @@ class Script(EnvironMixin):
else: else:
self.logger.debug('Removing %r', path) self.logger.debug('Removing %r', path)
rmtree(path) rmtree(path)
if not os.path.exists(location):
raise zc.buildout.UserError('%r was not created' % location)
return location
def update(self): def update(self):
if self._update: if self._update:
......
...@@ -11,9 +11,8 @@ from zc.buildout.testing import buildoutTearDown ...@@ -11,9 +11,8 @@ from zc.buildout.testing import buildoutTearDown
from contextlib import contextmanager from contextlib import contextmanager
from functools import wraps from functools import wraps
from subprocess import check_call, check_output, CalledProcessError, STDOUT from subprocess import check_call, check_output, CalledProcessError, STDOUT
from slapos.recipe.gitclone import GIT_CLONE_ERROR_MESSAGE, \ from ..gitclone import GIT_CLONE_ERROR_MESSAGE, GIT_CLONE_CACHE_ERROR_MESSAGE
GIT_CLONE_CACHE_ERROR_MESSAGE from .. import make_read_only_recursively
from slapos.recipe.downloadunpacked import make_read_only_recursively
optionflags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE) optionflags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE)
...@@ -563,6 +562,26 @@ class MakeReadOnlyTests(unittest.TestCase): ...@@ -563,6 +562,26 @@ class MakeReadOnlyTests(unittest.TestCase):
make_read_only_recursively(self.tmp_dir) make_read_only_recursively(self.tmp_dir)
self.assertRaises(IOError, open, os.path.join(self.tmp_dir, 'folder', 'symlink'), 'w') self.assertRaises(IOError, open, os.path.join(self.tmp_dir, 'folder', 'symlink'), 'w')
MD5SUM = []
def md5sum(m):
x = m.group(0)
try:
i = MD5SUM.index(x)
except ValueError:
i = len(MD5SUM)
MD5SUM.append(x)
return '<MD5SUM:%s>' % i
renormalizing_patters = [
zc.buildout.testing.normalize_path,
zc.buildout.testing.not_found,
(re.compile(
'.*CryptographyDeprecationWarning: Python 2 is no longer supported by the Python core team. '
'Support for it is now deprecated in cryptography, and will be removed in the next release.\n.*'
), ''),
(re.compile('[0-9a-f]{32}'), md5sum),
]
def test_suite(): def test_suite():
suite = unittest.TestSuite(( suite = unittest.TestSuite((
...@@ -573,12 +592,9 @@ def test_suite(): ...@@ -573,12 +592,9 @@ def test_suite():
tearDown=zc.buildout.testing.buildoutTearDown, tearDown=zc.buildout.testing.buildoutTearDown,
optionflags=optionflags, optionflags=optionflags,
checker=renormalizing.RENormalizing([ checker=renormalizing.RENormalizing([
zc.buildout.testing.normalize_path,
(re.compile(r'http://localhost:\d+'), 'http://test.server'), (re.compile(r'http://localhost:\d+'), 'http://test.server'),
# Clean up the variable hashed filenames to avoid spurious ] + renormalizing_patters),
# test failures globs={'MD5SUM': MD5SUM},
(re.compile(r'[a-f0-9]{32}'), ''),
]),
), ),
unittest.makeSuite(GitCloneNonInformativeTests), unittest.makeSuite(GitCloneNonInformativeTests),
unittest.makeSuite(MakeReadOnlyTests), unittest.makeSuite(MakeReadOnlyTests),
......
...@@ -26,91 +26,57 @@ ...@@ -26,91 +26,57 @@
############################################################################## ##############################################################################
import errno import errno
import os import os
import shutil from zc.buildout import download
import zc.buildout from . import Shared
import logging
from hashlib import md5
from .downloadunpacked import make_read_only_recursively, Signature
class Recipe(object): class Recipe(object):
_parts = None
_shared = None
def __init__(self, buildout, name, options): def __init__(self, buildout, name, options):
buildout_section = buildout['buildout'] self._buildout = buildout['buildout']
self._downloader = zc.buildout.download.Download(buildout_section,
hash_name=True)
self._url = options['url'] self._url = options['url']
self._md5sum = options.get('md5sum') self._md5sum = options.get('md5sum') or None
self._name = name self._name = name
mode = options.get('mode') mode = options.get('mode')
log = logging.getLogger(name)
self._shared = shared = ((options.get('shared', '').lower() == 'true') and
buildout['buildout'].get('shared-parts', None))
if mode is not None: if mode is not None:
mode = int(mode, 8) mode = int(mode, 8)
self._mode = mode self._mode = mode
if 'filename' in options and 'destination' in options:
raise zc.buildout.UserError('Parameters filename and destination are '
'exclusive.')
destination = options.get('destination', None) shared = Shared(buildout, name, options)
if destination is None: if not self._md5sum:
if shared: shared.assertNotShared("option 'md5sum' must be set")
shared_part = buildout['buildout'].get('shared-parts', None)
shared = os.path.join(shared_part.strip().rstrip('/'), name)
if not os.path.exists(shared):
os.makedirs(shared)
self._signature = Signature('.slapos.recipe.build.signature')
profile_base_location = options.get('_profile_base_location_', '')
for k, v in sorted(options.items()):
if profile_base_location:
v = v.replace(profile_base_location, '${:_profile_base_location_}')
self._signature.update(k, v)
shared = os.path.join(shared, self._signature.hexdigest())
self._parts = parts = shared
log.info('shared directory %s set for %s', shared, name)
else:
self._parts = parts = os.path.join(buildout_section['parts-directory'],
name)
destination = os.path.join(parts, options.get('filename', name)) destination = options.get('destination')
if destination:
shared.assertNotShared("option 'destination' can't be set")
else:
self._shared = shared
destination = os.path.join(shared.location,
options.get('filename') or name)
# Compatibility with other recipes: expose location # Compatibility with other recipes: expose location
options['location'] = parts options['location'] = shared.location
options['target'] = self._destination = destination options['target'] = self._destination = destination
def install(self): def install(self):
shared = self._shared
if shared:
return shared.install(self._download)
destination = self._destination destination = self._destination
result = [destination] try:
parts = self._parts os.remove(destination)
log = logging.getLogger(self._name) except OSError as e:
if self._shared: if e.errno != errno.ENOENT:
log.info('Checking whether package is installed at shared path: %s', destination) raise
if self._signature.test(self._parts): self._download()
log.info('This shared package has been installed by other package') return [destination]
return []
if parts is not None and not os.path.isdir(parts):
os.mkdir(parts)
result.append(parts)
path, is_temp = self._downloader(self._url, md5sum=self._md5sum)
with open(path, 'rb') as fsrc:
if is_temp:
os.remove(path)
try:
os.remove(destination)
except OSError as e:
if e.errno != errno.ENOENT:
raise
with open(destination, 'wb') as fdst:
if self._mode is not None:
os.fchmod(fdst.fileno(), self._mode)
shutil.copyfileobj(fsrc, fdst)
if self._shared: def _download(self):
self._signature.save(parts) download.Download(self._buildout, hash_name=True)(
make_read_only_recursively(self._parts) self._url, self._md5sum, self._destination)
return result if self._mode is not None:
os.chmod(self._destination, self._mode)
def update(self): def update(self):
if not self._md5sum: if not self._md5sum:
self.install() self._download()
This diff is collapsed.
...@@ -31,16 +31,14 @@ from io import BytesIO ...@@ -31,16 +31,14 @@ from io import BytesIO
from collections import defaultdict from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from os.path import join from os.path import join
from slapos.recipe import EnvironMixin, generatePassword, logger, rmtree
from zc.buildout import UserError from zc.buildout import UserError
from . import EnvironMixin, generatePassword, is_true, rmtree
ARCH = os.uname()[4] ARCH = os.uname()[4]
@contextmanager @contextmanager
def building_directory(directory): def building_directory(directory):
if os.path.lexists(directory): rmtree(directory)
logger.warning('Removing already existing path %r', directory)
rmtree(directory)
os.makedirs(directory) os.makedirs(directory)
try: try:
yield yield
...@@ -48,8 +46,6 @@ def building_directory(directory): ...@@ -48,8 +46,6 @@ def building_directory(directory):
shutil.rmtree(directory) shutil.rmtree(directory)
raise raise
is_true = ('false', 'true').index
class Popen(subprocess.Popen): class Popen(subprocess.Popen):
def stop(self): def stop(self):
...@@ -99,6 +95,7 @@ class BaseRecipe(EnvironMixin): ...@@ -99,6 +95,7 @@ class BaseRecipe(EnvironMixin):
def __init__(self, buildout, name, options, allow_none=True): def __init__(self, buildout, name, options, allow_none=True):
self.buildout = buildout self.buildout = buildout
self.name = name
self.options = options self.options = options
try: try:
options['location'] = options['location'].strip() options['location'] = options['location'].strip()
...@@ -255,7 +252,7 @@ class InstallDebianRecipe(BaseRecipe): ...@@ -255,7 +252,7 @@ class InstallDebianRecipe(BaseRecipe):
raise NotImplementedError raise NotImplementedError
p[k] = v.strip() p[k] = v.strip()
vm_run = is_true(options.get('vm.run', 'true')) vm_run = is_true(options.get('vm.run'), True)
packages = ['ssh', 'sudo'] if vm_run else [] packages = ['ssh', 'sudo'] if vm_run else []
packages += options.get('packages', '').split() packages += options.get('packages', '').split()
if packages: if packages:
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment