2 # -*- coding: utf-8 -*-
7 Copyright (c) 2012 Frédéric Massart - FMCorz.net
9 This program is free software: you can redistribute it and/or modify
10 it under the terms of the GNU General Public License as published by
11 the Free Software Foundation, either version 3 of the License, or
12 (at your option) any later version.
14 This program is distributed in the hope that it will be useful,
15 but WITHOUT ANY WARRANTY; without even the implied warranty of
16 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 GNU General Public License for more details.
19 You should have received a copy of the GNU General Public License
20 along with this program. If not, see <http://www.gnu.org/licenses/>.
22 http://github.com/FMCorz/mdk
29 from tempfile
import gettempdir
31 from .tools
import getMDLFromCommitMessage
, mkdir
, process
, parseBranch
33 from .config
import Conf
34 from .git
import Git
, GitException
35 from .exceptions
import InstallException
, UpgradeNotAllowed
36 from .jira
import Jira
, JiraException
37 from .scripts
import Scripts
69 def __init__(self
, path
, identifier
=None):
71 self
.identifier
= identifier
76 def addConfig(self
, name
, value
):
77 """Add a parameter to the config file
78 Will attempt to write them before the inclusion of lib/setup.php"""
79 configFile
= os
.path
.join(self
.path
, 'config.php')
80 if not os
.path
.isfile(configFile
):
83 if name
in self
._reservedKeywords
:
84 raise Exception('Cannot use reserved keywords for settings in config.php')
86 if type(value
) == bool:
87 value
= 'true' if value
else 'false'
88 elif type(value
) != int:
89 value
= str(value
).replace('%instancename%', self
.identifier
)
90 value
= re
.sub(r
'%instancename:(\d*)%',
91 lambda m
: self
.identifier
.split(C
.get('wording.prefixSeparator'))[int(m
.group(1))],
93 value
= "'" + value
+ "'"
97 f
= open(configFile
, 'r')
101 for i
, line
in enumerate(lines
):
102 if re
.search(r
'^// MDK Edit\.$', line
.rstrip()):
104 elif re
.search(r
'require_once.*/lib/setup\.php', line
):
105 lines
.insert(i
, '// MDK Edit.\n')
106 lines
.insert(i
+ 1, '\n')
107 # As we've added lines, let's move the index
113 lines
.insert(i
, '$CFG->%s = %s;\n' %
(name
, value
))
115 f
= open(configFile
, 'w')
119 raise Exception('Error while writing to config file')
123 def branch_compare(self
, branch
, compare
='>='):
124 """Compare the branch of the current instance with the one passed"""
128 raise Exception('Could not convert branch to int, got %s' % branch
)
129 b
= self
.get('branch')
131 raise Exception('Error while reading the branch')
133 b
= C
.get('masterBranch')
139 elif compare
== '=' or compare
== '==':
147 def checkout_stable(self
, checkout
=True):
148 """Checkout the stable branch, do a stash if required. Needs to be called again to pop the stash!"""
150 # Checkout the branch
152 stablebranch
= self
.get('stablebranch')
153 if self
.currentBranch() == stablebranch
:
154 self
._cos_oldbranch
= None
157 self
._cos_oldbranch
= self
.currentBranch()
158 self
._cos_hasstash
= False
161 stash
= self
.git().stash(untracked
=True)
163 raise Exception('Error while stashing your changes')
164 if not stash
[1].startswith('No local changes'):
165 self
._cos_hasstash
= True
168 if not self
.git().checkout(stablebranch
):
169 raise Exception('Could not checkout %s' % stablebranch
)
171 # Checkout the previous branch
172 elif self
._cos_oldbranch
!= None:
173 if not self
.git().checkout(self
._cos_oldbranch
):
174 raise Exception('Could not checkout working branch %s' % self
._cos_oldbranch
)
177 if self
._cos_hasstash
:
178 pop
= self
.git().stash('pop')
180 raise Exception('Error while popping the stash. Probably got conflicts.')
181 self
._cos_hasstash
= False
183 def cli(self
, cli
, args
='', **kwargs
):
184 """Executes a command line tool script"""
185 cli
= os
.path
.join(self
.get('path'), cli
.lstrip('/'))
186 if not os
.path
.isfile(cli
):
187 raise Exception('Could not find script to call')
188 if type(args
) == 'list':
189 args
= ' '.join(args
)
190 cmd
= '%s %s %s' %
(C
.get('php'), cli
, args
)
191 return process(cmd
, cwd
=self
.get('path'), **kwargs
)
193 def currentBranch(self
):
194 """Returns the current branch on the git repository"""
195 return self
.git().currentBranch()
198 """Returns a Database object"""
199 if self
._dbo
== None:
200 engine
= self
.get('dbtype')
201 db
= self
.get('dbname')
202 if engine
!= None and db
!= None:
204 self
._dbo
= DB(engine
, C
.get('db.%s' % engine
))
209 def generateBranchName(self
, issue
, suffix
='', version
=''):
210 """Generates a branch name"""
211 mdl
= re
.sub(r
'(MDL|mdl)(-|_)?', '', issue
)
213 version
= self
.get('branch')
218 branch
= C
.get('wording.branchFormat') % args
219 if suffix
!= None and suffix
!= '':
220 branch
+= C
.get('wording.branchSuffixSeparator') + suffix
223 def get(self
, param
, default
=None):
224 """Returns a property of this instance"""
232 """Returns a Git object"""
233 if self
._git
== None:
234 self
._git
= Git(self
.path
, C
.get('git'))
235 if not self
._git
.isRepository():
236 raise Exception('Could not find the Git repository')
239 def headcommit(self
, branch
=None):
240 """Try to resolve the head commit of branch of this instance"""
243 branch
= self
.currentBranch()
245 raise Exception('Cannot update the tracker when on detached branch')
247 smartSearch
= C
.get('smartHeadCommitSearch')
250 parsedbranch
= parseBranch(branch
)
252 issue
= 'MDL-%s' %
(parsedbranch
['issue'])
254 logging
.debug('Cannot smart resolve using the branch %s' %
(branch
))
259 # Trying to smart guess the last commit needed
261 commits
= self
.git().log(since
=branch
, count
=C
.get('smartHeadCommitLimit'), format
='%s_____%H').split('\n')[:-1]
263 # Looping over the last commits to find the commit messages that match the MDL-12345.
265 for commit
in commits
:
266 match
= getMDLFromCommitMessage(commit
) == issue
267 if not candidate
and not match
:
268 # The first commit does not match a hash, let's ignore this method.
270 candidate
= commit
.split('_____')[-1]
272 # The commit does not match any more, we found it!
273 headcommit
= candidate
276 # We could not smart find the last commit, let's use the default mechanism.
278 upstreamremote
= C
.get('upstreamRemote')
279 stablebranch
= self
.get('stablebranch')
280 headcommit
= self
.git().hashes(ref
='%s/%s' %
(upstreamremote
, stablebranch
), limit
=1, format
='%H')[0]
283 logging
.warning('Could not resolve the head commit')
288 def initPHPUnit(self
, force
=False, prefix
=None):
289 """Initialise the PHPUnit environment"""
290 raise Exception('This method is deprecated, use phpunit.PHPUnit.init() instead.')
292 def initBehat(self
, switchcompletely
=False, force
=False, prefix
=None, faildumppath
=None):
293 """Initialise the Behat environment"""
295 if self
.branch_compare(25, '<'):
296 raise Exception('Behat is only available from Moodle 2.5')
298 # Force switch completely for PHP < 5.4
299 (none
, phpVersion
, none
) = process('%s -r "echo version_compare(phpversion(), \'5.4\');"' %
(C
.get('php')))
300 if int(phpVersion
) <= 0:
301 switchcompletely
= True
303 # Set Behat data root
304 behat_dataroot
= self
.get('dataroot') + '_behat'
305 self
.updateConfig('behat_dataroot', behat_dataroot
)
307 # Set Behat DB prefix
308 currentPrefix
= self
.get('behat_prefix')
309 behat_prefix
= prefix
or 'zbehat_'
311 # Set behat_faildump_path
312 currentFailDumpPath
= self
.get('behat_faildump_path')
313 if faildumppath
and currentFailDumpPath
!= faildumppath
:
314 self
.updateConfig('behat_faildump_path', faildumppath
)
315 elif (not faildumppath
and currentFailDumpPath
):
316 self
.removeConfig('behat_faildump_path')
318 if not currentPrefix
or force
:
319 self
.updateConfig('behat_prefix', behat_prefix
)
320 elif currentPrefix
!= behat_prefix
and self
.get('dbtype') != 'oci':
321 # Warn that a prefix is already set and we did not change it.
322 # No warning for Oracle as we need to set it to something else.
323 logging
.warning('Behat prefix not changed, already set to \'%s\', expected \'%s\'.' %
(currentPrefix
, behat_prefix
))
326 if self
.branch_compare(26, '<'):
328 self
.updateConfig('behat_switchcompletely', switchcompletely
)
329 self
.updateConfig('behat_wwwroot', self
.get('wwwroot'))
331 self
.removeConfig('behat_switchcompletely')
332 self
.removeConfig('behat_wwwroot')
335 wwwroot
= '%s://%s' %
(C
.get('scheme'), C
.get('behat.host'))
336 if C
.get('path') != '' and C
.get('path') != None:
337 wwwroot
= wwwroot
+ C
.get('path')
338 #wwwroot = wwwroot + self.identifier
339 currentWwwroot
= self
.get('behat_wwwroot')
340 if not currentWwwroot
or force
:
341 self
.updateConfig('behat_wwwroot', wwwroot
)
342 elif currentWwwroot
!= wwwroot
:
343 logging
.warning('Behat wwwroot not changed, already set to \'%s\', expected \'%s\'.' %
(currentWwwroot
, wwwroot
))
345 # Force a cache purge
348 # Force dropping the tables if there are any.
350 result
= self
.cli('admin/tool/behat/cli/util.php', args
='--drop', stdout
=None, stderr
=None)
352 raise Exception('Error while initialising Behat. Please try manually.')
354 # Run the init script.
355 result
= self
.cli('admin/tool/behat/cli/init.php', stdout
=None, stderr
=None)
357 raise Exception('Error while initialising Behat. Please try manually.')
359 # Force a cache purge
363 """Returns a dictionary of information about this instance"""
367 'installed': self
.isInstalled(),
368 'identifier': self
.identifier
370 for (k
, v
) in self
.config
.items():
372 for (k
, v
) in self
.version
.items():
376 def install(self
, dbname
=None, engine
=None, dataDir
=None, fullname
=None, dropDb
=False, wwwroot
=None):
377 """Launch the install script of an Instance"""
379 if self
.isInstalled():
380 raise InstallException('Instance already installed!')
383 raise InstallException('Cannot install without a value for wwwroot')
384 if dataDir
== None or not os
.path
.isdir(dataDir
):
385 raise InstallException('Cannot install instance without knowing where the data directory is')
387 dbname
= re
.sub(r
'[^a-zA-Z0-9]', '', self
.identifier
).lower()
388 prefixDbname
= C
.get('db.namePrefix')
390 dbname
= prefixDbname
+ dbname
393 engine
= C
.get('defaultEngine')
395 fullname
= self
.identifier
.replace('-', ' ').replace('_', ' ').title()
396 fullname
= fullname
+ ' ' + C
.get('wording.%s' % engine
)
398 logging
.info('Creating database...')
399 db
= DB(engine
, C
.get('db.%s' % engine
))
400 if db
.dbexists(dbname
):
405 raise InstallException('Cannot install an instance on an existing database (%s)' % dbname
)
410 logging
.info('Installing %s...' % self
.identifier
)
411 cli
= 'admin/cli/install.php'
412 params
= (wwwroot
, dataDir
, engine
, dbname
, C
.get('db.%s.user' % engine
), C
.get('db.%s.passwd' % engine
), C
.get('db.%s.host' % engine
), fullname
, self
.identifier
, C
.get('login'), C
.get('passwd'))
413 args
= '--wwwroot="%s" --dataroot="%s" --dbtype="%s" --dbname="%s" --dbuser="%s" --dbpass="%s" --dbhost="%s" --fullname="%s" --shortname="%s" --adminuser="%s" --adminpass="%s" --allow-unstable --agree-license --non-interactive' % params
414 result
= self
.cli(cli
, args
, stdout
=None, stderr
=None)
416 raise InstallException('Error while running the install, please manually fix the problem.\n- Command was: %s %s %s' %
(C
.get('php'), cli
, args
))
418 configFile
= os
.path
.join(self
.path
, 'config.php')
419 os
.chmod(configFile
, 0666)
421 if C
.get('path') != '' and C
.get('path') != None:
422 self
.addConfig('sessioncookiepath', '/%s/%s/' %
(C
.get('path'), self
.identifier
))
424 self
.addConfig('sessioncookiepath', '/%s/' % self
.identifier
)
426 logging
.warning('Could not append $CFG->sessioncookiepath to config.php')
428 # Add forced $CFG to the config.php if some are globally defined.
429 forceCfg
= C
.get('forceCfg')
430 if isinstance(forceCfg
, dict):
431 for cfgKey
, cfgValue
in forceCfg
.iteritems():
433 if isinstance(cfgValue
, basestring
):
434 cfgValue
= cfgValue
.replace('%instancename%', self
.identifier
)
435 cfgValue
= re
.sub(r
'%instancename:(\d*)%',
436 lambda m
: self
.identifier
.split(C
.get('wording.prefixSeparator'))[int(m
.group(1))],
439 logging
.info('Setting up forced $CFG->%s to \'%s\' in config.php', cfgKey
, cfgValue
)
440 self
.addConfig(cfgKey
, cfgValue
)
442 logging
.warning('Could not append $CFG->%s to config.php', cfgKey
)
446 def isInstalled(self
):
447 """Returns whether this instance is installed or not"""
448 # Reload the configuration if necessary.
450 return self
.installed
== True
453 def isInstance(path
):
454 """Check whether the path is a Moodle web directory"""
455 version
= os
.path
.join(path
, 'version.php')
457 f
= open(version
, 'r')
458 lines
= f
.readlines()
464 if line
.find('MOODLE VERSION INFORMATION') > -1:
472 def isIntegration(self
):
473 """Returns whether an instance is an integration one or not"""
474 r
= C
.get('upstreamRemote') or 'upstream'
475 if not self
.git().getRemote(r
):
477 remote
= self
.git().getConfig('remote.%s.url' % r
)
478 if remote
!= None and remote
.endswith('integration.git'):
483 """Returns whether an instance is a stable one or not"""
484 name
= self
.identifier
.split(C
.get('wording.prefixSeparator'))[0]
485 return name
== C
.get('wording.prefixStable')
488 """Returns whether an instance is a review one or not"""
489 name
= self
.identifier
.split(C
.get('wording.prefixSeparator'))[0]
490 return name
== C
.get('wording.prefixReview')
493 """Loads the information"""
494 if not self
.isInstance(self
.path
):
500 # Extracts information from version.php
502 version
= os
.path
.join(self
.path
, 'version.php')
503 if os
.path
.isfile(version
):
505 reVersion
= re
.compile(r
'^\s*\$version\s*=\s*([0-9.]+)\s*;')
506 reRelease
= re
.compile(r
'^\s*\$release\s*=\s*(?P<brackets>[\'"])?(.+)(?P=brackets)\s*;')
507 reMaturity = re.compile(r'^\s*\$maturity\s*=\s*([a-zA-Z0-9_]+)\s*;')
508 reBranch = re.compile(r'^\s*\$branch\s*=\s*(?P<brackets>[\'"])?
([0-9]+)(?P
=brackets
)\s
*;')
510 f = open(version, 'r
')
512 if reVersion.search(line):
513 self.version['version
'] = reVersion.search(line).group(1)
514 elif reRelease.search(line):
515 self.version['release
'] = reRelease.search(line).group(2)
516 elif reMaturity.search(line):
517 self.version['maturity
'] = reMaturity.search(line).group(1).replace('MATURITY_
', '').lower()
518 elif reBranch.search(line):
519 self.version['branch
'] = reBranch.search(line).group(2)
521 # Several checks about the branch
524 branch = self.version['branch
']
526 self.version['branch
'] = self.version['release
'].replace('.', '')[0:2]
527 branch = self.version['branch
']
528 if int(branch) >= int(C.get('masterBranch
')):
529 self.version['branch
'] = 'master
'
532 if self.version['branch
'] == 'master
':
533 self.version['stablebranch
'] = 'master
'
535 self.version['stablebranch
'] = 'MOODLE_%s_STABLE
' % self.version['branch
']
537 # Integration or stable?
538 self.version['integration
'] = self.isIntegration()
542 # Should never happen
543 raise Exception('This does
not appear to be a Moodle instance
')
545 # Extracts parameters from config.php, does not handle params over multiple lines
547 config = os.path.join(self.path, 'config
.php
')
548 if os.path.isfile(config):
549 self.installed = True
550 prog = re.compile(r'^\s
*\$CFG
->([a
-z_
]+)\s
*=\s
*((?P
<brackets
>[\'"])?(.+)(?P=brackets)|([0-9.]+)|(true|false|null))\s*;$', re.I)
552 f = open(config, 'r')
554 match = prog.search(line)
558 if match.group(5) != None:
560 value = float(match.group(5)) if '.' in str(match.group(5)) else int(match.group(5))
561 elif match.group(6) != None:
563 value = str(match.group(6)).lower()
566 elif value == 'false':
571 # Likely to be a string
572 value = match.group(4)
574 self.config[match.group(1)] = value
579 self.installed = False
580 logging.error('Could not read config file')
583 self.installed = False
588 def purge(self, manual=False):
589 """Purge the cache of an instance"""
590 if not self.isInstalled():
591 raise Exception('Instance not installed, cannot purge.')
592 elif self.branch_compare('22', '<'):
593 raise Exception('Instance does not support cache purging.')
596 dataroot = self.get('dataroot', False)
597 if manual and dataroot != False:
598 logging.debug('Removing directories [dataroot]/cache and [dataroot]/localcache')
599 shutil.rmtree(os.path.join(dataroot, 'cache'), True)
600 shutil.rmtree(os.path.join(dataroot, 'localcache'), True)
602 self.cli('admin/cli/purge_caches.php', stderr=None, stdout=None)
605 raise Exception('Error while purging cache!')
607 def pushPatch(self, branch=None):
608 """Push a patch on the tracker, and remove the previous one"""
611 branch = self.currentBranch()
613 raise Exception('Cannot create a patch from a detached branch')
616 parsedbranch = parseBranch(branch)
618 raise Exception('Could not extract issue number from %s' % branch)
619 issue = 'MDL-%s' % (parsedbranch['issue'])
620 headcommit = self.headcommit(branch)
622 # Creating a patch file.
623 fileName = branch + '.mdk.patch'
624 tmpPatchFile = os.path.join(gettempdir(), fileName)
625 if self.git().createPatch('%s...%s' % (headcommit, branch), saveTo=tmpPatchFile):
629 # Checking if file with same name exists.
630 existingAttachmentId = None
631 existingAttachments = J.getIssue(issue, fields='attachment')
632 for existingAttachment in existingAttachments.get('fields', {}).get('attachment', {}):
633 if existingAttachment.get('filename') == fileName:
634 # Found an existing attachment with the same name, we keep track of it.
635 existingAttachmentId = existingAttachment.get('id')
638 # Pushing patch to the tracker.
640 logging.info('Uploading %s to the tracker' % (fileName))
641 J.upload(issue, tmpPatchFile)
642 except JiraException:
643 logging.error('Error while uploading the patch to the tracker')
646 if existingAttachmentId != None:
647 # On success, deleting file that was there before.
649 logging.info('Deleting older patch...')
650 J.deleteAttachment(existingAttachmentId)
651 except JiraException:
652 logging.info('Could not delete older attachment')
655 logging.error('Could not create a patch file')
661 """Sets the value to be reloaded"""
664 def removeConfig(self, name):
665 """Remove a configuration setting from the config file."""
666 configFile = os.path.join(self.path, 'config.php')
667 if not os.path.isfile(configFile):
671 f = open(configFile, 'r')
672 lines = f.readlines()
676 if re.search(r'\$CFG->%s\s*=.*;' % (name), line):
680 f = open(configFile, 'w')
684 raise Exception('Error while writing to config file')
688 def runScript(self, scriptname, arguments=None, **kwargs):
689 """Runs a script on the instance"""
690 return Scripts.run(scriptname, self.get('path'), arguments=arguments, cmdkwargs=kwargs)
692 def update(self, remote=None):
693 """Update the instance from the remote"""
696 remote = C.get('upstreamRemote')
699 if not self.git().fetch(remote):
700 raise Exception('Could not fetch remote %s' % remote)
703 self.checkout_stable(True)
706 upstream = '%s/%s' % (remote, self.get('stablebranch'))
707 if not self.git().reset(to=upstream, hard=True):
708 raise Exception('Error while executing git reset.')
710 # Return to previous branch
711 self.checkout_stable(False)
713 def updateConfig(self, name, value):
714 """Update a setting in the config file."""
715 self.removeConfig(name)
716 self.addConfig(name, value)
719 """Uninstall the instance"""
721 if not self.isInstalled():
722 raise Exception('The instance is not installed')
724 # Delete the content in moodledata
725 dataroot = self.get('dataroot')
726 if os.path.isdir(dataroot):
727 logging.debug('Deleting dataroot content (%s)' % (dataroot))
728 shutil.rmtree(dataroot)
729 mkdir(dataroot, 0777)
732 dbname = self.get('dbname')
733 if self.dbo().dbexists(dbname):
734 logging.debug('Droping database (%s)' % (dbname))
735 self.dbo().dropdb(dbname)
737 # Remove the config file
738 configFile = os.path.join(self.get('path'), 'config.php')
739 if os.path.isfile(configFile):
740 logging.debug('Deleting config.php')
741 os.remove(configFile)
743 def updateTrackerGitInfo(self, branch=None, ref=None):
744 """Updates the git info on the tracker issue"""
747 branch = self.currentBranch()
749 raise Exception('Cannot update the tracker when on detached branch')
752 parsedbranch = parseBranch(branch)
754 raise Exception('Could not extract issue number from %s' % branch)
755 issue = 'MDL-%s' % (parsedbranch['issue'])
756 version = parsedbranch['version']
758 # Get the jira config
759 repositoryurl = C.get('repositoryUrl')
760 diffurltemplate = C.get('diffUrlTemplate')
761 stablebranch = self.get('stablebranch')
763 # Get the hash of the last upstream commit
765 logging.info('Searching for the head commit...')
768 headcommit = self.git().hashes(ref=ref, limit=1, format='%H')[0]
770 logging.warning('Could not resolve a head commit using the reference: %s' % (ref))
773 # No reference was passed, or it was invalid.
775 headcommit = self.headcommit(branch)
777 # Head commit not resolved
779 logging.error('Head commit not resolved, aborting update of tracker fields')
782 headcommit = headcommit[:10]
783 logging.debug('Head commit resolved to %s' % (headcommit))
786 diffurl = diffurltemplate.replace('%branch%', branch).replace('%stablebranch%', stablebranch).replace('%headcommit%', headcommit)
788 fieldrepositoryurl = C.get('tracker.fieldnames.repositoryurl')
789 fieldbranch = C.get('tracker.fieldnames.%s.branch' % version)
790 fielddiffurl = C.get('tracker.fieldnames.%s.diffurl' % version)
792 if not fieldrepositoryurl or not fieldbranch or not fielddiffurl:
793 logging.error('Cannot set tracker fields for this version (%s). The field names are not set in the config file.', version)
795 logging.info('Setting tracker fields: \n %s: %s \n %s: %s \n %s: %s' %
796 (fieldrepositoryurl, repositoryurl, fieldbranch, branch, fielddiffurl, diffurl))
797 J.setCustomFields(issue, {fieldrepositoryurl: repositoryurl, fieldbranch: branch, fielddiffurl: diffurl})
799 def upgrade(self, nocheckout=False):
800 """Calls the upgrade script"""
801 if not self.isInstalled():
802 raise Exception('Cannot upgrade an instance which is not installed.')
803 elif not self.branch_compare(20):
804 raise Exception('Upgrade command line tool not supported by this version.')
805 elif os.path.isfile(os.path.join(self.get('path'), '.noupgrade')):
806 raise UpgradeNotAllowed('Upgrade not allowed, found .noupgrade.')
810 self.checkout_stable(True)
812 cli = '/admin/cli/upgrade.php'
813 args = '--non-interactive --allow-unstable'
814 result = self.cli(cli, args, stdout=None, stderr=None)
816 raise Exception('Error while running the upgrade.')
818 # Return to previous branch
820 self.checkout_stable(False)