Commit 80d56118 authored by Léo-Paul Géneau's avatar Léo-Paul Géneau 👾

Clean up part location if it exists before installation or if installation fails

This makes install() idempotent as it should be.
And by default, the user expects recipes to do their best
to undo any partial change on error.
parent f5597f66
...@@ -6,20 +6,23 @@ import glob ...@@ -6,20 +6,23 @@ import glob
import logging import logging
import os import os
import re import re
import shutil
import stat import stat
import subprocess import subprocess
import zc.buildout import zc.buildout
import six.moves.urllib as urllib import six.moves.urllib as urllib
from slapos.recipe.downloadunpacked import Recipe as Download
from zc.buildout.easy_install import allow_picked_versions from zc.buildout.easy_install import allow_picked_versions
from slapos.recipe import rmtree
from slapos.recipe.downloadunpacked import Recipe as Download
strip = lambda x:x.strip() # noqa strip = lambda x:x.strip() # noqa
is_true = ('false', 'true').index is_true = ('false', 'true').index
RUBYGEMS_URL_BASE = 'https://rubygems.org/rubygems/rubygems'
VERSION_ZIP_RE = '-([0-9.]+).zip'
class Recipe(object): class Recipe(object):
"""zc.buildout recipe for compiling and installing software""" """zc.buildout recipe for compiling and installing software"""
...@@ -42,7 +45,6 @@ class Recipe(object): ...@@ -42,7 +45,6 @@ class Recipe(object):
raise zc.buildout.UserError( raise zc.buildout.UserError(
"Configuration error, 'gems' option is missing") "Configuration error, 'gems' option is missing")
self.version = options.get('version')
self.url = options.get('url') self.url = options.get('url')
# Allow to define specific ruby executable. If not, take just 'ruby' # Allow to define specific ruby executable. If not, take just 'ruby'
self.ruby_executable = options.get('ruby-executable', 'ruby') self.ruby_executable = options.get('ruby-executable', 'ruby')
...@@ -172,36 +174,26 @@ class Recipe(object): ...@@ -172,36 +174,26 @@ class Recipe(object):
gem_dict['version'] = parsed_gem[1].strip() gem_dict['version'] = parsed_gem[1].strip()
return gem_dict return gem_dict
def _get_latest_rubygems(self): def _get_rubygems_url_and_version(self):
if self.url: version = self.options.get('version')
version = self.version if version:
if not version: return '%s-%s.zip' % (RUBYGEMS_URL_BASE, version), version
version = (
re.search(r'rubygems-([0-9.]+).zip$', self.url).group(1)
)
return (self.url, version)
if self.version:
return ('https://rubygems.org/rubygems/'
'rubygems-%s.zip' % self.version, self.version)
url = self.url
if url:
r = re.search(VERSION_ZIP_RE + '$', url)
else:
f = urllib.request.urlopen('https://rubygems.org/pages/download') f = urllib.request.urlopen('https://rubygems.org/pages/download')
s = f.read().decode('utf-8') try:
r = re.search(RUBYGEMS_URL_BASE + VERSION_ZIP_RE,
f.read().decode('utf-8'))
finally:
f.close() f.close()
r = re.search(r'https://rubygems.org/rubygems/'
r'rubygems-([0-9.]+).zip', s)
if r: if r:
url = r.group(0) return url or r.group(0), r.group(1)
version = r.group(1) raise zc.buildout.UserError("Can't find rubygems version.")
return (url, version)
else:
raise zc.buildout.UserError(
'Can\'t find latest rubygems version.')
def _install_rubygems(self): def _install_rubygems(self, url, version):
url, version = self._get_latest_rubygems()
if int(version.split(".")[0]) < 2:
raise zc.buildout.UserError("Rubygems version must be >= 2.0.0")
srcdir = os.path.join(self.buildout['buildout']['parts-directory'], srcdir = os.path.join(self.buildout['buildout']['parts-directory'],
'rubygems-' + version) 'rubygems-' + version)
options = { options = {
...@@ -210,20 +202,8 @@ class Recipe(object): ...@@ -210,20 +202,8 @@ class Recipe(object):
} }
recipe = Download(self.buildout, self.name, options) recipe = Download(self.buildout, self.name, options)
recipe.install() recipe.install()
self.version = version
current_dir = os.getcwd() current_dir = os.getcwd()
try:
os.mkdir(self.options['location'])
except OSError as e:
if e.errno == errno.EEXIST:
pass
else:
raise zc.buildout.UserError(
'IO error while creating %s directory.'
% self.options['location']
)
os.chdir(srcdir) os.chdir(srcdir)
env = self._get_env() env = self._get_env()
...@@ -241,7 +221,7 @@ class Recipe(object): ...@@ -241,7 +221,7 @@ class Recipe(object):
try: try:
self.run(cmd, env) self.run(cmd, env)
finally: finally:
shutil.rmtree(srcdir) rmtree(srcdir)
os.chdir(current_dir) os.chdir(current_dir)
def _install_executable(self, path): def _install_executable(self, path):
...@@ -288,21 +268,9 @@ class Recipe(object): ...@@ -288,21 +268,9 @@ class Recipe(object):
self.run(cmd, self._get_env()) self.run(cmd, self._get_env())
def get_gem_executable(self, bindir): def get_dependency_list(self, gem_dict, gem_executable, version):
gem_executable = os.path.join(bindir, 'gem')
gem_executable = glob.glob(gem_executable + '*')
if gem_executable:
if not self.version:
self.version = self.run([
gem_executable[0],
'--version',
])
return gem_executable[0]
def get_dependency_list(self, gem_dict, gem_executable):
gem_search_pattern = '^' + gem_dict['gemname'].replace('.',r'\.') + '$' gem_search_pattern = '^' + gem_dict['gemname'].replace('.',r'\.') + '$'
if self.version[0] < '3': if version[0] < '3':
gem_search_pattern = '/' + gem_search_pattern + '/' gem_search_pattern = '/' + gem_search_pattern + '/'
cmd = [ cmd = [
...@@ -330,14 +298,21 @@ class Recipe(object): ...@@ -330,14 +298,21 @@ class Recipe(object):
} for match in self.gem_regex.findall(cmd_result)] } for match in self.gem_regex.findall(cmd_result)]
def install(self): def install(self):
parts = [self.options['location']] location = self.options['location']
bindir = os.path.join(self.options['location'], 'bin') bindir = os.path.join(self.options['location'], 'bin')
gem_executable = self.get_gem_executable(bindir) if os.path.lexists(location):
rmtree(location)
try:
os.makedirs(location)
parts = [location]
url, version = self._get_rubygems_url_and_version()
if int(version.split(".")[0]) < 2:
raise zc.buildout.UserError("Rubygems version must be >= 2.0.0")
if not gem_executable: self._install_rubygems(url, version)
self._install_rubygems() gem_executable = glob.glob(os.path.join(bindir, 'gem*'))[0]
gem_executable = self.get_gem_executable(bindir)
gem_dict_list = list(map(self.get_gem_dict, self.gems)) gem_dict_list = list(map(self.get_gem_dict, self.gems))
for gem_dict in gem_dict_list: for gem_dict in gem_dict_list:
...@@ -349,7 +324,8 @@ class Recipe(object): ...@@ -349,7 +324,8 @@ class Recipe(object):
) )
for dep_dict in self.get_dependency_list(gem_dict, for dep_dict in self.get_dependency_list(gem_dict,
gem_executable): gem_executable,
version):
match = [gem_d for gem_d in gem_dict_list match = [gem_d for gem_d in gem_dict_list
if dep_dict['gemname'] == gem_d['gemname']] if dep_dict['gemname'] == gem_d['gemname']]
if not match: if not match:
...@@ -365,6 +341,10 @@ class Recipe(object): ...@@ -365,6 +341,10 @@ class Recipe(object):
self.log.info('installing ruby gem "%s"', gem_dict['gemname']) self.log.info('installing ruby gem "%s"', gem_dict['gemname'])
self._install_gem(gem_dict, gem_executable, bindir) self._install_gem(gem_dict, gem_executable, bindir)
except:
if os.path.lexists(location):
rmtree(location)
raise
for executable in os.listdir(bindir): for executable in os.listdir(bindir):
installed_path = self._install_executable( installed_path = self._install_executable(
......
...@@ -21,6 +21,9 @@ RUBYGEMS_DEFAULT_VERSION = '2.0.0' ...@@ -21,6 +21,9 @@ RUBYGEMS_DEFAULT_VERSION = '2.0.0'
def touch(path): def touch(path):
parent = path.parent
if not parent.exists():
os.makedirs(str(parent))
with path.open('w') as f: with path.open('w') as f:
f.write('') f.write('')
...@@ -41,6 +44,20 @@ class fixture(object): ...@@ -41,6 +44,20 @@ class fixture(object):
self.tear_down() self.tear_down()
return wrapper return wrapper
def install_rubygems(self, buildout, name, options):
tempdir, rubygemsdir = os.path.split(options['destination'])
version = rubygemsdir.split('-')[-1]
dirs = (
buildout['buildout']['bin-directory'],
os.path.join(tempdir, 'ruby-' + version),
options['destination'],
)
for directory in dirs:
os.makedirs(directory)
touch(self.tempdir.joinpath('rubygems', 'bin', 'gem'))
return type(str(), (), {'install': lambda _: None})()
def patch(self, modules): def patch(self, modules):
self.patchers = {} self.patchers = {}
self.patches = {} self.patches = {}
...@@ -48,11 +65,6 @@ class fixture(object): ...@@ -48,11 +65,6 @@ class fixture(object):
self.patchers[name] = mock.patch(module) self.patchers[name] = mock.patch(module)
self.patches[name] = self.patchers[name].start() self.patches[name] = self.patchers[name].start()
def makedirs(self, dirs):
self.tempdir = pathlib.Path(tempfile.mkdtemp())
for directory in dirs:
os.makedirs(str(self.tempdir / directory))
def set_up(self): def set_up(self):
name = 'rubygems' name = 'rubygems'
version = self.options.get('return', {}).get('version', self.version) version = self.options.get('return', {}).get('version', self.version)
...@@ -67,14 +79,9 @@ class fixture(object): ...@@ -67,14 +79,9 @@ class fixture(object):
% RUBYGEMS_DEFAULT_VERSION % RUBYGEMS_DEFAULT_VERSION
).encode('utf-8')) ).encode('utf-8'))
) )
self.patches['download'].side_effect = self.install_rubygems
self.makedirs(( self.tempdir = pathlib.Path(tempfile.mkdtemp())
'bin',
'ruby-' + version,
'rubygems-' + version,
'rubygems/bin',
))
buildout = {'buildout': dict({ buildout = {'buildout': dict({
'parts-directory': str(self.tempdir), 'parts-directory': str(self.tempdir),
'bin-directory': str(self.tempdir / 'bin'), 'bin-directory': str(self.tempdir / 'bin'),
...@@ -133,8 +140,8 @@ class RubyGemsDefaultTestCase(RubyGemsTestCase): ...@@ -133,8 +140,8 @@ class RubyGemsDefaultTestCase(RubyGemsTestCase):
expected_install_arg_list_list = [ expected_install_arg_list_list = [
[ [
'ruby', None, 'install', '--no-document', 'ruby', str(path.joinpath(name, 'bin', 'gem')), 'install',
'--bindir=%s/rubygems/bin' % path, '--no-document', '--bindir=%s/rubygems/bin' % path,
'sass', '--', 'sass', '--',
], ],
] ]
...@@ -215,18 +222,7 @@ class RubyGemsDefaultTestCase(RubyGemsTestCase): ...@@ -215,18 +222,7 @@ class RubyGemsDefaultTestCase(RubyGemsTestCase):
patches['urlopen'].return_value = BytesIO(b'') patches['urlopen'].return_value = BytesIO(b'')
recipe = rubygems.Recipe(buildout, name, options) recipe = rubygems.Recipe(buildout, name, options)
self.assertRaisesRegexp(zc.buildout.UserError, self.assertRaisesRegexp(zc.buildout.UserError,
'Can\'t find latest rubygems version.', "Can't find rubygems version.",
recipe.install
)
@fixture({'recipe': {'gems': 'sass'}})
@mock.patch('rubygems.os.mkdir')
def test_mkdir_error(self, path, patches, buildout, name, options, version, mkdir):
mkdir.side_effect = OSError(errno.EIO)
recipe = rubygems.Recipe(buildout, name, options)
self.assertRaisesRegexp(
zc.buildout.UserError,
'IO error while creating %s/rubygems directory.' % path,
recipe.install recipe.install
) )
...@@ -293,12 +289,13 @@ class RubyGemsDeploymentTestCase(RubyGemsTestCase): ...@@ -293,12 +289,13 @@ class RubyGemsDeploymentTestCase(RubyGemsTestCase):
if version[0] < '3': if version[0] < '3':
gem_search_pattern = '/' + gem_search_pattern + '/' gem_search_pattern = '/' + gem_search_pattern + '/'
gem_executable = str(path.joinpath(name, 'bin', 'gem'))
expected_install_arg_list_list.extend([ expected_install_arg_list_list.extend([
[ [
'ruby', None, 'dependency', '-rv', gem_dict['version'], 'ruby', gem_executable, 'dependency',
gem_search_pattern, '-rv', gem_dict['version'], gem_search_pattern,
], [ ], [
'ruby', None, 'install', '--no-document', 'ruby', gem_executable, 'install', '--no-document',
'--bindir=%s/rubygems/bin' % path, '--bindir=%s/rubygems/bin' % path,
'--ignore-dependencies', gem_dict['gemname'], '--ignore-dependencies', gem_dict['gemname'],
'--version=' + gem_dict['version'], '--', '--version=' + gem_dict['version'], '--',
...@@ -309,13 +306,9 @@ class RubyGemsDeploymentTestCase(RubyGemsTestCase): ...@@ -309,13 +306,9 @@ class RubyGemsDeploymentTestCase(RubyGemsTestCase):
path, patches, expected_install_arg_list_list) path, patches, expected_install_arg_list_list)
@deployment_fixture({'recipe': {'gems': 'hashie==0.3.1'}}) @deployment_fixture({'recipe': {'gems': 'hashie==0.3.1'}})
@mock.patch('rubygems.Recipe.get_gem_executable')
def test_already_installed_rubygems( def test_already_installed_rubygems(
self, path, patches, buildout, name, options, version, get_gem_exe): self, path, patches, buildout, name, options, version):
touch(path / 'rubygems/bin/gem') touch(path / 'rubygems/bin/gem')
def mocked_get_gem_exe(_):
self.version = RUBYGEMS_DEFAULT_VERSION
get_gem_exe.side_effect = mocked_get_gem_exe
self.deployment_install_test( self.deployment_install_test(
buildout, name, path, patches, options, version) buildout, name, path, patches, options, version)
......
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