← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~stub/launchpad/cronscripts into lp:launchpad/devel

 

Stuart Bishop has proposed merging lp:~stub/launchpad/cronscripts into lp:launchpad/devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


Steps towards Bug #607391.

Adds a config file controlling cronscripts. This config file allows them to be selectively or in bulk disabled from running.
-- 
https://code.launchpad.net/~stub/launchpad/cronscripts/+merge/31934
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~stub/launchpad/cronscripts into lp:launchpad/devel.
=== modified file 'configs/testrunner/launchpad-lazr.conf'
--- configs/testrunner/launchpad-lazr.conf	2010-07-16 20:55:29 +0000
+++ configs/testrunner/launchpad-lazr.conf	2010-08-06 09:11:12 +0000
@@ -7,6 +7,7 @@
 
 [canonical]
 chunkydiff: False
+cron_control_file: lib/lp/services/scripts/tests/cronscripts.ini
 
 [archivepublisher]
 base_url: http://ftpmaster.internal/

=== modified file 'lib/canonical/config/schema-lazr.conf'
--- lib/canonical/config/schema-lazr.conf	2010-08-05 06:27:00 +0000
+++ lib/canonical/config/schema-lazr.conf	2010-08-06 09:11:12 +0000
@@ -209,6 +209,10 @@
 # datatype: string
 admin_address: system-error@xxxxxxxxxxxxx
 
+# By default, relative to the root.
+# datatype: filename
+cron_control_file: cronscripts.ini
+
 
 [checkwatches]
 # The database user to run this process as.
@@ -796,7 +800,7 @@
 
 # If true, only the source package names are imported into
 # Launchpad
-# datatype: boolean"
+# datatype: boolean
 sourcepackagenames_only: false
 
 

=== modified file 'lib/lp/services/scripts/base.py'
--- lib/lp/services/scripts/base.py	2010-04-27 06:15:37 +0000
+++ lib/lp/services/scripts/base.py	2010-08-06 09:11:12 +0000
@@ -9,17 +9,19 @@
     'SilentLaunchpadScriptFailure'
     ]
 
+from ConfigParser import SafeConfigParser
 from cProfile import Profile
 import datetime
 import logging
 from optparse import OptionParser
-import os
+import os.path
 import sys
 
 from contrib.glock import GlobalLock, LockAlreadyAcquired
 import pytz
 from zope.component import getUtility
 
+from canonical.config import config
 from canonical.database.sqlbase import ISOLATION_LEVEL_DEFAULT
 from canonical.launchpad import scripts
 from canonical.launchpad.interfaces import IScriptActivitySet
@@ -185,8 +187,8 @@
     def setup_lock(self):
         """Create lockfile.
 
-        Note that this will create a lockfile even if you don't actually use it.
-        GlobalLock.__del__ is meant to clean it up though.
+        Note that this will create a lockfile even if you don't actually
+        use it. GlobalLock.__del__ is meant to clean it up though.
         """
         self.lock = GlobalLock(self.lockfilepath, logger=self.logger)
 
@@ -290,6 +292,22 @@
 class LaunchpadCronScript(LaunchpadScript):
     """Logs successful script runs in the database."""
 
+    def __init__(self, name=None, dbuser=None, test_args=None):
+        """Initialize, and sys.exit() if the cronscript is disabled.
+
+        Rather than hand editing crontab files, cronscripts can be
+        enabled and disabled using a config file.
+
+        The control file location is specified by
+        config.canonical.cron_control_file.
+        """
+        super(LaunchpadCronScript, self).__init__(name, dbuser, test_args)
+
+        enabled = cronscript_enabled(
+            config.canonical.cron_control_file, self.name, self.logger)
+        if not enabled:
+            sys.exit(0)
+
     def record_activity(self, date_started, date_completed):
         """Record the successful completion of the script."""
         self.txn.begin()
@@ -299,3 +317,45 @@
             date_started=date_started,
             date_completed=date_completed)
         self.txn.commit()
+
+
+def cronscript_enabled(control_path, name, log):
+    """Return True if the cronscript is enabled."""
+    if not os.path.isabs(control_path):
+        control_path = os.path.abspath(
+            os.path.join(config.root, control_path))
+
+    cron_config = SafeConfigParser({'enabled': str(True)})
+
+    if not os.path.exists(control_path):
+        # No control file exists. Everything enabled by default.
+        log.debug("Cronscript control file not found at %s", control_path)
+    else:
+        log.debug("Cronscript control file found at %s", control_path)
+
+        # Try reading the config file. If it fails, we log the
+        # traceback and continue on using the defaults.
+        try:
+            cron_config.read(control_path)
+        except:
+            log.exception("Error parsing %s", control_path)
+
+    if cron_config.has_option(name, 'enabled'):
+        section = name
+    else:
+        section = 'DEFAULT'
+
+    try:
+        enabled = cron_config.getboolean(section, 'enabled')
+    except:
+        log.exception(
+            "Failed to load value from %s section of %s",
+            section, control_path)
+        enabled = True
+
+    if enabled:
+        log.debug("Enabled by %s section", section)
+    else:
+        log.info("Disabled by %s section", section)
+
+    return enabled

=== added file 'lib/lp/services/scripts/tests/cronscripts.ini'
--- lib/lp/services/scripts/tests/cronscripts.ini	1970-01-01 00:00:00 +0000
+++ lib/lp/services/scripts/tests/cronscripts.ini	2010-08-06 09:11:12 +0000
@@ -0,0 +1,2 @@
+[example-cronscript-disabled]
+enabled: False

=== added file 'lib/lp/services/scripts/tests/example-cronscript.py'
--- lib/lp/services/scripts/tests/example-cronscript.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/scripts/tests/example-cronscript.py	2010-08-06 09:11:12 +0000
@@ -0,0 +1,30 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""An example cronscript. If it runs, it returns 42 as its return code."""
+
+__metaclass__ = type
+__all__ = []
+
+import sys
+
+from lp.services.scripts.base import (
+    LaunchpadCronScript, SilentLaunchpadScriptFailure)
+
+class Script(LaunchpadCronScript):
+    def main(self):
+        if self.name == 'example-cronscript-enabled':
+            raise SilentLaunchpadScriptFailure(42)
+        else:
+            # Raise a non-standard error code, as if the
+            # script was invoked as disabled the main()
+            # method should never be invoked.
+            raise SilentLaunchpadScriptFailure(999)
+
+if __name__ == '__main__':
+    if sys.argv[-1] == 'enabled':
+        name = 'example-cronscript-enabled'
+    else:
+        name = 'example-cronscript-disabled'
+    script = Script(name)
+    script.lock_and_run()

=== added file 'lib/lp/services/scripts/tests/test_cronscript_enabled.py'
--- lib/lp/services/scripts/tests/test_cronscript_enabled.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/scripts/tests/test_cronscript_enabled.py	2010-08-06 09:11:12 +0000
@@ -0,0 +1,144 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the cronscript_enabled function in scripts/base.py."""
+
+__metaclass__ = type
+
+from cStringIO import StringIO
+from logging import DEBUG
+import os.path
+import subprocess
+import sys
+from tempfile import NamedTemporaryFile
+from textwrap import dedent
+import unittest
+
+from lp.services.scripts.base import cronscript_enabled
+from lp.testing import TestCase
+from lp.testing.logger import MockLogger
+
+
+class TestCronscriptEnabled(TestCase):
+    def setUp(self):
+        super(TestCronscriptEnabled, self).setUp()
+        self.log_output = StringIO()
+        self.log = MockLogger(self.log_output)
+        self.log.setLevel(DEBUG)
+
+    def makeConfig(self, body):
+        tempfile = NamedTemporaryFile(suffix='.ini')
+        tempfile.write(body)
+        tempfile.flush()
+        # Ensure a reference is kept until the test is over.
+        # tempfile will then clean itself up.
+        self.addCleanup(lambda x: None, tempfile)
+        return tempfile.name
+
+    def test_noconfig(self):
+        enabled = cronscript_enabled('/idontexist.ini', 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_emptyconfig(self):
+        config = self.makeConfig('')
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_default_true(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            enabled: True
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_default_false(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            enabled: False
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(False, enabled)
+
+    def test_specific_true(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            enabled: False
+            [foo]
+            enabled: True
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_specific_false(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            enabled: True
+            [foo]
+            enabled: False
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(False, enabled)
+
+    def test_broken_true(self):
+        config = self.makeConfig(dedent("""\
+            # This file is unparsable
+            [DEFAULT
+            enabled: False
+            [foo
+            enabled: False
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_invalid_boolean_true(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            enabled: whoops
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_specific_missing_fallsback(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            enabled: False
+            [foo]
+            # There is a typo in the next line.
+            enobled: True
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(False, enabled)
+
+    def test_default_missing_fallsback(self):
+        config = self.makeConfig(dedent("""\
+            [DEFAULT]
+            # There is a typo in the next line. Fallsback to hardcoded
+            # default.
+            enobled: False
+            [foo]
+            # There is a typo in the next line.
+            enobled: False
+            """))
+        enabled = cronscript_enabled(config, 'foo', self.log)
+        self.assertIs(True, enabled)
+
+    def test_enabled_cronscript(self):
+        cmd = [
+            sys.executable,
+            os.path.join(os.path.dirname(__file__), 'example-cronscript.py'),
+            '-qqqqq', 'enabled',
+            ]
+        self.assertEqual(42, subprocess.call(cmd))
+
+    def test_disabled_cronscript(self):
+        cmd = [
+            sys.executable,
+            os.path.join(os.path.dirname(__file__), 'example-cronscript.py'),
+            '-qqqqq', 'disabled',
+            ]
+        self.assertEqual(0, subprocess.call(cmd))
+
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/testing/tests/test_standard_test_template.py'
--- lib/lp/testing/tests/test_standard_test_template.py	2010-07-17 21:13:21 +0000
+++ lib/lp/testing/tests/test_standard_test_template.py	2010-08-06 09:11:12 +0000
@@ -5,6 +5,8 @@
 
 __metaclass__ = type
 
+import unittest
+
 from canonical.testing import DatabaseFunctionalLayer
 from lp.testing import TestCase
 
@@ -22,3 +24,7 @@
 
         # XXX: Assertions take expected value first, actual value second.
         self.assertEqual(4, 2 + 2)
+
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)