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
) + "'"
93 f
= open(configFile
, 'r')
97 for i
, line
in enumerate(lines
):
98 if re
.search(r
'^// MDK Edit\.$', line
.rstrip()):
100 elif re
.search(r
'require_once.*/lib/setup\.php', line
):
101 lines
.insert(i
, '// MDK Edit.\n')
102 lines
.insert(i
+ 1, '\n')
103 # As we've added lines, let's move the index
109 lines
.insert(i
, '$CFG->%s = %s;\n' %
(name
, value
))
111 f
= open(configFile
, 'w')
115 raise Exception('Error while writing to config file')
119 def branch_compare(self
, branch
, compare
='>='):
120 """Compare the branch of the current instance with the one passed"""
124 raise Exception('Could not convert branch to int, got %s' % branch
)
125 b
= self
.get('branch')
127 raise Exception('Error while reading the branch')
129 b
= C
.get('masterBranch')
135 elif compare
== '=' or compare
== '==':
143 def checkout_stable(self
, checkout
=True):
144 """Checkout the stable branch, do a stash if required. Needs to be called again to pop the stash!"""
146 # Checkout the branch
148 stablebranch
= self
.get('stablebranch')
149 if self
.currentBranch() == stablebranch
:
150 self
._cos_oldbranch
= None
153 self
._cos_oldbranch
= self
.currentBranch()
154 self
._cos_hasstash
= False
157 stash
= self
.git().stash(untracked
=True)
159 raise Exception('Error while stashing your changes')
160 if not stash
[1].startswith('No local changes'):
161 self
._cos_hasstash
= True
164 if not self
.git().checkout(stablebranch
):
165 raise Exception('Could not checkout %s' % stablebranch
)
167 # Checkout the previous branch
168 elif self
._cos_oldbranch
!= None:
169 if not self
.git().checkout(self
._cos_oldbranch
):
170 raise Exception('Could not checkout working branch %s' % self
._cos_oldbranch
)
173 if self
._cos_hasstash
:
174 pop
= self
.git().stash('pop')
176 raise Exception('Error while popping the stash. Probably got conflicts.')
177 self
._cos_hasstash
= False
179 def cli(self
, cli
, args
='', **kwargs
):
180 """Executes a command line tool script"""
181 cli
= os
.path
.join(self
.get('path'), cli
.lstrip('/'))
182 if not os
.path
.isfile(cli
):
183 raise Exception('Could not find script to call')
184 if type(args
) == 'list':
185 args
= ' '.join(args
)
186 cmd
= '%s %s %s' %
(C
.get('php'), cli
, args
)
187 return process(cmd
, cwd
=self
.get('path'), **kwargs
)
189 def currentBranch(self
):
190 """Returns the current branch on the git repository"""
191 return self
.git().currentBranch()
194 """Returns a Database object"""
195 if self
._dbo
== None:
196 engine
= self
.get('dbtype')
197 db
= self
.get('dbname')
198 if engine
!= None and db
!= None:
200 self
._dbo
= DB(engine
, C
.get('db.%s' % engine
))
205 def generateBranchName(self
, issue
, suffix
='', version
=''):
206 """Generates a branch name"""
207 mdl
= re
.sub(r
'(MDL|mdl)(-|_)?', '', issue
)
209 version
= self
.get('branch')
214 branch
= C
.get('wording.branchFormat') % args
215 if suffix
!= None and suffix
!= '':
216 branch
+= C
.get('wording.branchSuffixSeparator') + suffix
219 def get(self
, param
, default
=None):
220 """Returns a property of this instance"""
228 """Returns a Git object"""
229 if self
._git
== None:
230 self
._git
= Git(self
.path
, C
.get('git'))
231 if not self
._git
.isRepository():
232 raise Exception('Could not find the Git repository')
235 def headcommit(self
, branch
=None):
236 """Try to resolve the head commit of branch of this instance"""
239 branch
= self
.currentBranch()
241 raise Exception('Cannot update the tracker when on detached branch')
243 smartSearch
= C
.get('smartHeadCommitSearch')
246 parsedbranch
= parseBranch(branch
)
248 issue
= 'MDL-%s' %
(parsedbranch
['issue'])
250 logging
.debug('Cannot smart resolve using the branch %s' %
(branch
))
255 # Trying to smart guess the last commit needed
257 commits
= self
.git().log(since
=branch
, count
=C
.get('smartHeadCommitLimit'), format
='%s_____%h').split('\n')[:-1]
259 # Looping over the last commits to find the commit messages that match the MDL-12345.
261 for commit
in commits
:
262 match
= getMDLFromCommitMessage(commit
) == issue
263 if not candidate
and not match
:
264 # The first commit does not match a hash, let's ignore this method.
266 candidate
= commit
.split('_____')[-1]
268 # The commit does not match any more, we found it!
269 headcommit
= candidate
272 # We could not smart find the last commit, let's use the default mechanism.
274 upstreamremote
= C
.get('upstreamRemote')
275 stablebranch
= self
.get('stablebranch')
276 headcommit
= self
.git().hashes(ref
='%s/%s' %
(upstreamremote
, stablebranch
), limit
=1, format
='%h')[0]
279 logging
.warning('Could not resolve the head commit')
284 def initPHPUnit(self
, force
=False, prefix
=None):
285 """Initialise the PHPUnit environment"""
286 raise Exception('This method is deprecated, use phpunit.PHPUnit.init() instead.')
288 def initBehat(self
, switchcompletely
=False, force
=False, prefix
=None, faildumppath
=None):
289 """Initialise the Behat environment"""
291 if self
.branch_compare(25, '<'):
292 raise Exception('Behat is only available from Moodle 2.5')
294 # Force switch completely for PHP < 5.4
295 (none
, phpVersion
, none
) = process('%s -r "echo version_compare(phpversion(), \'5.4\');"' %
(C
.get('php')))
296 if int(phpVersion
) <= 0:
297 switchcompletely
= True
299 # Set Behat data root
300 behat_dataroot
= self
.get('dataroot') + '_behat'
301 self
.updateConfig('behat_dataroot', behat_dataroot
)
303 # Set Behat DB prefix
304 currentPrefix
= self
.get('behat_prefix')
305 behat_prefix
= prefix
or 'zbehat_'
307 # Set behat_faildump_path
308 currentFailDumpPath
= self
.get('behat_faildump_path')
309 if faildumppath
and currentFailDumpPath
!= faildumppath
:
310 self
.updateConfig('behat_faildump_path', faildumppath
)
311 elif (not faildumppath
and currentFailDumpPath
):
312 self
.removeConfig('behat_faildump_path')
314 if not currentPrefix
or force
:
315 self
.updateConfig('behat_prefix', behat_prefix
)
316 elif currentPrefix
!= behat_prefix
and self
.get('dbtype') != 'oci':
317 # Warn that a prefix is already set and we did not change it.
318 # No warning for Oracle as we need to set it to something else.
319 logging
.warning('Behat prefix not changed, already set to \'%s\', expected \'%s\'.' %
(currentPrefix
, behat_prefix
))
322 if self
.branch_compare(26, '<'):
324 self
.updateConfig('behat_switchcompletely', switchcompletely
)
325 self
.updateConfig('behat_wwwroot', self
.get('wwwroot'))
327 self
.removeConfig('behat_switchcompletely')
328 self
.removeConfig('behat_wwwroot')
331 wwwroot
= '%s://%s/' %
(C
.get('scheme'), C
.get('behat.host'))
332 if C
.get('path') != '' and C
.get('path') != None:
333 wwwroot
= wwwroot
+ C
.get('path') + '/'
334 wwwroot
= wwwroot
+ self
.identifier
335 currentWwwroot
= self
.get('behat_wwwroot')
336 if not currentWwwroot
or force
:
337 self
.updateConfig('behat_wwwroot', wwwroot
)
338 elif currentWwwroot
!= wwwroot
:
339 logging
.warning('Behat wwwroot not changed, already set to \'%s\', expected \'%s\'.' %
(currentWwwroot
, wwwroot
))
341 # Force a cache purge
344 # Force dropping the tables if there are any.
346 result
= self
.cli('admin/tool/behat/cli/util.php', args
='--drop', stdout
=None, stderr
=None)
348 raise Exception('Error while initialising Behat. Please try manually.')
350 # Run the init script.
351 result
= self
.cli('admin/tool/behat/cli/init.php', stdout
=None, stderr
=None)
353 raise Exception('Error while initialising Behat. Please try manually.')
355 # Force a cache purge
359 """Returns a dictionary of information about this instance"""
363 'installed': self
.isInstalled(),
364 'identifier': self
.identifier
366 for (k
, v
) in self
.config
.items():
368 for (k
, v
) in self
.version
.items():
372 def install(self
, dbname
=None, engine
=None, dataDir
=None, fullname
=None, dropDb
=False, wwwroot
=None):
373 """Launch the install script of an Instance"""
375 if self
.isInstalled():
376 raise InstallException('Instance already installed!')
379 raise InstallException('Cannot install without a value for wwwroot')
380 if dataDir
== None or not os
.path
.isdir(dataDir
):
381 raise InstallException('Cannot install instance without knowing where the data directory is')
383 dbname
= re
.sub(r
'[^a-zA-Z0-9]', '', self
.identifier
).lower()
384 prefixDbname
= C
.get('db.namePrefix')
386 dbname
= prefixDbname
+ dbname
389 engine
= C
.get('defaultEngine')
391 fullname
= self
.identifier
.replace('-', ' ').replace('_', ' ').title()
392 fullname
= fullname
+ ' ' + C
.get('wording.%s' % engine
)
394 logging
.info('Creating database...')
395 db
= DB(engine
, C
.get('db.%s' % engine
))
396 if db
.dbexists(dbname
):
401 raise InstallException('Cannot install an instance on an existing database (%s)' % dbname
)
406 logging
.info('Installing %s...' % self
.identifier
)
407 cli
= 'admin/cli/install.php'
408 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'))
409 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
410 result
= self
.cli(cli
, args
, stdout
=None, stderr
=None)
412 raise InstallException('Error while running the install, please manually fix the problem.\n- Command was: %s %s %s' %
(C
.get('php'), cli
, args
))
414 configFile
= os
.path
.join(self
.path
, 'config.php')
415 os
.chmod(configFile
, 0666)
417 if C
.get('path') != '' and C
.get('path') != None:
418 self
.addConfig('sessioncookiepath', '/%s/%s/' %
(C
.get('path'), self
.identifier
))
420 self
.addConfig('sessioncookiepath', '/%s/' % self
.identifier
)
422 logging
.warning('Could not append $CFG->sessioncookiepath to config.php')
424 # Add forced $CFG to the config.php if some are globally defined.
425 forceCfg
= C
.get('forceCfg')
426 if isinstance(forceCfg
, dict):
427 for cfgKey
, cfgValue
in forceCfg
.iteritems():
429 logging
.info('Setting up forced $CFG->%s to \'%s\' in config.php', cfgKey
, cfgValue
)
430 self
.addConfig(cfgKey
, cfgValue
)
432 logging
.warning('Could not append $CFG->%s to config.php', cfgKey
)
436 def isInstalled(self
):
437 """Returns whether this instance is installed or not"""
438 # Reload the configuration if necessary.
440 return self
.installed
== True
443 def isInstance(path
):
444 """Check whether the path is a Moodle web directory"""
445 version
= os
.path
.join(path
, 'version.php')
447 f
= open(version
, 'r')
448 lines
= f
.readlines()
454 if line
.find('MOODLE VERSION INFORMATION') > -1:
462 def isIntegration(self
):
463 """Returns whether an instance is an integration one or not"""
464 r
= C
.get('upstreamRemote') or 'upstream'
465 if not self
.git().getRemote(r
):
467 remote
= self
.git().getConfig('remote.%s.url' % r
)
468 if remote
!= None and remote
.endswith('integration.git'):
473 """Assume an instance is stable if not integration"""
474 return not self
.isIntegration()
477 """Loads the information"""
478 if not self
.isInstance(self
.path
):
484 # Extracts information from version.php
486 version
= os
.path
.join(self
.path
, 'version.php')
487 if os
.path
.isfile(version
):
489 reVersion
= re
.compile(r
'^\s*\$version\s*=\s*([0-9.]+)\s*;')
490 reRelease
= re
.compile(r
'^\s*\$release\s*=\s*(?P<brackets>[\'"])?(.+)(?P=brackets)\s*;')
491 reMaturity = re.compile(r'^\s*\$maturity\s*=\s*([a-zA-Z0-9_]+)\s*;')
492 reBranch = re.compile(r'^\s*\$branch\s*=\s*(?P<brackets>[\'"])?
([0-9]+)(?P
=brackets
)\s
*;')
494 f = open(version, 'r
')
496 if reVersion.search(line):
497 self.version['version
'] = reVersion.search(line).group(1)
498 elif reRelease.search(line):
499 self.version['release
'] = reRelease.search(line).group(2)
500 elif reMaturity.search(line):
501 self.version['maturity
'] = reMaturity.search(line).group(1).replace('MATURITY_
', '').lower()
502 elif reBranch.search(line):
503 self.version['branch
'] = reBranch.search(line).group(2)
505 # Several checks about the branch
508 branch = self.version['branch
']
510 self.version['branch
'] = self.version['release
'].replace('.', '')[0:2]
511 branch = self.version['branch
']
512 if int(branch) >= int(C.get('masterBranch
')):
513 self.version['branch
'] = 'master
'
516 if self.version['branch
'] == 'master
':
517 self.version['stablebranch
'] = 'master
'
519 self.version['stablebranch
'] = 'MOODLE_%s_STABLE
' % self.version['branch
']
521 # Integration or stable?
522 self.version['integration
'] = self.isIntegration()
526 # Should never happen
527 raise Exception('This does
not appear to be a Moodle instance
')
529 # Extracts parameters from config.php, does not handle params over multiple lines
531 config = os.path.join(self.path, 'config
.php
')
532 if os.path.isfile(config):
533 self.installed = True
534 prog = re.compile(r'^\s
*\$CFG
->([a
-z_
]+)\s
*=\s
*((?P
<brackets
>[\'"])?(.+)(?P=brackets)|([0-9.]+)|(true|false|null))\s*;$', re.I)
536 f = open(config, 'r')
538 match = prog.search(line)
542 if match.group(5) != None:
544 value = float(match.group(5)) if '.' in str(match.group(5)) else int(match.group(5))
545 elif match.group(6) != None:
547 value = str(match.group(6)).lower()
550 elif value == 'false':
555 # Likely to be a string
556 value = match.group(4)
558 self.config[match.group(1)] = value
563 self.installed = False
564 logging.error('Could not read config file')
567 self.installed = False
572 def purge(self, manual=False):
573 """Purge the cache of an instance"""
574 if not self.isInstalled():
575 raise Exception('Instance not installed, cannot purge.')
576 elif self.branch_compare('22', '<'):
577 raise Exception('Instance does not support cache purging.')
580 dataroot = self.get('dataroot', False)
581 if manual and dataroot != False:
582 logging.debug('Removing directories [dataroot]/cache and [dataroot]/localcache')
583 shutil.rmtree(os.path.join(dataroot, 'cache'), True)
584 shutil.rmtree(os.path.join(dataroot, 'localcache'), True)
586 self.cli('admin/cli/purge_caches.php', stderr=None, stdout=None)
589 raise Exception('Error while purging cache!')
591 def pushPatch(self, branch=None):
592 """Push a patch on the tracker, and remove the previous one"""
595 branch = self.currentBranch()
597 raise Exception('Cannot create a patch from a detached branch')
600 parsedbranch = parseBranch(branch)
602 raise Exception('Could not extract issue number from %s' % branch)
603 issue = 'MDL-%s' % (parsedbranch['issue'])
604 headcommit = self.headcommit(branch)
606 # Creating a patch file.
607 fileName = branch + '.mdk.patch'
608 tmpPatchFile = os.path.join(gettempdir(), fileName)
609 if self.git().createPatch('%s...%s' % (headcommit, branch), saveTo=tmpPatchFile):
613 # Checking if file with same name exists.
614 existingAttachmentId = None
615 existingAttachments = J.getIssue(issue, fields='attachment')
616 for existingAttachment in existingAttachments.get('fields', {}).get('attachment', {}):
617 if existingAttachment.get('filename') == fileName:
618 # Found an existing attachment with the same name, we keep track of it.
619 existingAttachmentId = existingAttachment.get('id')
622 # Pushing patch to the tracker.
624 logging.info('Uploading %s to the tracker' % (fileName))
625 J.upload(issue, tmpPatchFile)
626 except JiraException:
627 logging.error('Error while uploading the patch to the tracker')
630 if existingAttachmentId != None:
631 # On success, deleting file that was there before.
633 logging.info('Deleting older patch...')
634 J.deleteAttachment(existingAttachmentId)
635 except JiraException:
636 logging.info('Could not delete older attachment')
639 logging.error('Could not create a patch file')
645 """Sets the value to be reloaded"""
648 def removeConfig(self, name):
649 """Remove a configuration setting from the config file."""
650 configFile = os.path.join(self.path, 'config.php')
651 if not os.path.isfile(configFile):
655 f = open(configFile, 'r')
656 lines = f.readlines()
660 if re.search(r'\$CFG->%s\s*=.*;' % (name), line):
664 f = open(configFile, 'w')
668 raise Exception('Error while writing to config file')
672 def runScript(self, scriptname, arguments=None, **kwargs):
673 """Runs a script on the instance"""
674 return Scripts.run(scriptname, self.get('path'), arguments=arguments, cmdkwargs=kwargs)
676 def update(self, remote=None):
677 """Update the instance from the remote"""
680 remote = C.get('upstreamRemote')
683 if not self.git().fetch(remote):
684 raise Exception('Could not fetch remote %s' % remote)
687 self.checkout_stable(True)
690 upstream = '%s/%s' % (remote, self.get('stablebranch'))
691 if not self.git().reset(to=upstream, hard=True):
692 raise Exception('Error while executing git reset.')
694 # Return to previous branch
695 self.checkout_stable(False)
697 def updateConfig(self, name, value):
698 """Update a setting in the config file."""
699 self.removeConfig(name)
700 self.addConfig(name, value)
703 """Uninstall the instance"""
705 if not self.isInstalled():
706 raise Exception('The instance is not installed')
708 # Delete the content in moodledata
709 dataroot = self.get('dataroot')
710 if os.path.isdir(dataroot):
711 logging.debug('Deleting dataroot content (%s)' % (dataroot))
712 shutil.rmtree(dataroot)
713 mkdir(dataroot, 0777)
716 dbname = self.get('dbname')
717 if self.dbo().dbexists(dbname):
718 logging.debug('Droping database (%s)' % (dbname))
719 self.dbo().dropdb(dbname)
721 # Remove the config file
722 configFile = os.path.join(self.get('path'), 'config.php')
723 if os.path.isfile(configFile):
724 logging.debug('Deleting config.php')
725 os.remove(configFile)
727 def updateTrackerGitInfo(self, branch=None, ref=None):
728 """Updates the git info on the tracker issue"""
731 branch = self.currentBranch()
733 raise Exception('Cannot update the tracker when on detached branch')
736 parsedbranch = parseBranch(branch)
738 raise Exception('Could not extract issue number from %s' % branch)
739 issue = 'MDL-%s' % (parsedbranch['issue'])
740 version = parsedbranch['version']
742 # Get the jira config
743 repositoryurl = C.get('repositoryUrl')
744 diffurltemplate = C.get('diffUrlTemplate')
745 stablebranch = self.get('stablebranch')
747 # Get the hash of the last upstream commit
749 logging.info('Searching for the head commit...')
752 headcommit = self.git().hashes(ref=ref, limit=1, format='%h')[0]
754 logging.warning('Could not resolve a head commit using the reference: %s' % (ref))
757 # No reference was passed, or it was invalid.
759 headcommit = self.headcommit(branch)
761 # Head commit not resolved
763 logging.error('Head commit not resolved, aborting update of tracker fields')
766 logging.debug('Head commit resolved to %s' % (headcommit))
769 diffurl = diffurltemplate.replace('%branch%', branch).replace('%stablebranch%', stablebranch).replace('%headcommit%', headcommit)
771 fieldrepositoryurl = C.get('tracker.fieldnames.repositoryurl')
772 fieldbranch = C.get('tracker.fieldnames.%s.branch' % version)
773 fielddiffurl = C.get('tracker.fieldnames.%s.diffurl' % version)
775 if not fieldrepositoryurl or not fieldbranch or not fielddiffurl:
776 logging.error('Cannot set tracker fields for this version (%s). The field names are not set in the config file.', version)
778 logging.info('Setting tracker fields: \n %s: %s \n %s: %s \n %s: %s' %
779 (fieldrepositoryurl, repositoryurl, fieldbranch, branch, fielddiffurl, diffurl))
780 J.setCustomFields(issue, {fieldrepositoryurl: repositoryurl, fieldbranch: branch, fielddiffurl: diffurl})
782 def upgrade(self, nocheckout=False):
783 """Calls the upgrade script"""
784 if not self.isInstalled():
785 raise Exception('Cannot upgrade an instance which is not installed.')
786 elif not self.branch_compare(20):
787 raise Exception('Upgrade command line tool not supported by this version.')
788 elif os.path.isfile(os.path.join(self.get('path'), '.noupgrade')):
789 raise UpgradeNotAllowed('Upgrade not allowed, found .noupgrade.')
793 self.checkout_stable(True)
795 cli = '/admin/cli/upgrade.php'
796 args = '--non-interactive --allow-unstable'
797 result = self.cli(cli, args, stdout=None, stderr=None)
799 raise Exception('Error while running the upgrade.')
801 # Return to previous branch
803 self.checkout_stable(False)