← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-auto-builds into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-auto-builds into launchpad:master.

Commit message:
Automatically request builds for charm recipes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/408995

Unlike snap recipe builds, charm recipe builds must always have a corresponding build request, so we check for recent build requests rather than recent builds, and we allow the normal build request job to handle creating the actual builds.  Otherwise, this is fairly similar to the snap recipe case.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-auto-builds into launchpad:master.
diff --git a/cronscripts/request_daily_builds.py b/cronscripts/request_daily_builds.py
index 5b706c7..8e2b59f 100755
--- a/cronscripts/request_daily_builds.py
+++ b/cronscripts/request_daily_builds.py
@@ -1,6 +1,6 @@
 #!/usr/bin/python3 -S
 #
-# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Request builds for stale daily build recipes and snap packages."""
@@ -12,6 +12,7 @@ import _pythonpath  # noqa: F401
 import transaction
 from zope.component import getUtility
 
+from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
 from lp.code.interfaces.sourcepackagerecipebuild import (
     ISourcePackageRecipeBuildSource,
     )
@@ -40,6 +41,11 @@ class RequestDailyBuilds(LaunchpadCronScript):
         builds = getUtility(ISnapSet).makeAutoBuilds(self.logger)
         self.logger.info(
             'Requested %d automatic snap package builds.' % len(builds))
+        build_requests = getUtility(ICharmRecipeSet).makeAutoBuilds(
+            self.logger)
+        self.logger.info(
+            'Requested %d sets of automatic charm recipe builds.' %
+            len(build_requests))
         transaction.commit()
 
 
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 8567419..8779ef9 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -842,6 +842,7 @@ public.buildqueue                               = SELECT, INSERT, UPDATE
 public.charmrecipe                              = SELECT, UPDATE
 public.charmrecipebuild                         = SELECT, INSERT
 public.charmrecipebuildjob                      = SELECT
+public.charmrecipejob                           = SELECT, INSERT
 public.component                                = SELECT
 public.distribution                             = SELECT
 public.distroarchseries                         = SELECT
diff --git a/lib/lp/charms/interfaces/charmrecipe.py b/lib/lp/charms/interfaces/charmrecipe.py
index 325fe9b..8b7e9d5 100644
--- a/lib/lp/charms/interfaces/charmrecipe.py
+++ b/lib/lp/charms/interfaces/charmrecipe.py
@@ -374,6 +374,13 @@ class ICharmRecipeView(Interface):
         :return: A sequence of `ICharmRecipeBuild` instances.
         """
 
+    def requestAutoBuilds(logger=None):
+        """Request automatic builds for this charm recipe.
+
+        :param logger: An optional logger.
+        :return: A sequence of `ICharmRecipeBuildRequest` instances.
+        """
+
     def getBuildRequest(job_id):
         """Get an asynchronous build request by ID.
 
@@ -673,6 +680,13 @@ class ICharmRecipeSet(Interface):
             cannot be parsed.
         """
 
+    def makeAutoBuilds(logger=None):
+        """Request automatic builds for stale charm recipes.
+
+        :param logger: An optional logger.
+        :return: A sequence of `ICharmRecipeBuildRequest` instances.
+        """
+
     def detachFromGitRepository(repository):
         """Detach all charm recipes from the given Git repository.
 
diff --git a/lib/lp/charms/model/charmrecipe.py b/lib/lp/charms/model/charmrecipe.py
index 7456007..ebd765a 100644
--- a/lib/lp/charms/model/charmrecipe.py
+++ b/lib/lp/charms/model/charmrecipe.py
@@ -10,6 +10,10 @@ __all__ = [
     ]
 
 import base64
+from datetime import (
+    datetime,
+    timedelta,
+    )
 from operator import (
     attrgetter,
     itemgetter,
@@ -20,6 +24,11 @@ from pymacaroons import Macaroon
 from pymacaroons.serializers import JsonSerializer
 import pytz
 from storm.databases.postgres import JSON
+from storm.expr import (
+    Cast,
+    Coalesce,
+    Except,
+    )
 from storm.locals import (
     And,
     Bool,
@@ -81,7 +90,10 @@ from lp.charms.interfaces.charmrecipejob import (
     ICharmRecipeRequestBuildsJobSource,
     )
 from lp.charms.model.charmrecipebuild import CharmRecipeBuild
-from lp.charms.model.charmrecipejob import CharmRecipeJob
+from lp.charms.model.charmrecipejob import (
+    CharmRecipeJob,
+    CharmRecipeJobType,
+    )
 from lp.code.errors import (
     GitRepositoryBlobNotFound,
     GitRepositoryScanFault,
@@ -124,6 +136,8 @@ from lp.services.database.interfaces import (
 from lp.services.database.stormbase import StormBase
 from lp.services.database.stormexpr import (
     Greatest,
+    IsTrue,
+    JSONExtract,
     NullsLast,
     )
 from lp.services.features import getFeatureFlag
@@ -577,6 +591,16 @@ class CharmRecipe(StormBase, WebhookTargetMixin):
                         das.distroseries.name, das.architecturetag, e)
         return builds
 
+    def requestAutoBuilds(self, logger=None):
+        """See `ICharmRecipe`."""
+        self.is_stale = False
+        if logger is not None:
+            logger.debug(
+                "Scheduling builds of charm recipe %s/%s/%s",
+                self.owner.name, self.project.name, self.name)
+        return self.requestBuilds(
+            self.owner, channels=self.auto_build_channels)
+
     def getBuildRequest(self, job_id):
         """See `ICharmRecipe`."""
         return CharmRecipeBuildRequest(self, job_id)
@@ -973,6 +997,43 @@ class CharmRecipeSet:
 
         return charmcraft_data
 
+    @staticmethod
+    def _findStaleRecipes():
+        """Find recipes that need to be rebuilt."""
+        threshold_date = (
+            datetime.now(pytz.UTC) -
+            timedelta(minutes=config.charms.auto_build_frequency))
+        stale_clauses = [
+            IsTrue(CharmRecipe.is_stale),
+            IsTrue(CharmRecipe.auto_build),
+            ]
+        recent_clauses = [
+            CharmRecipeJob.recipe_id == CharmRecipe.id,
+            CharmRecipeJob.job_type == CharmRecipeJobType.REQUEST_BUILDS,
+            JSONExtract(CharmRecipeJob.metadata, "channels") == Coalesce(
+                CharmRecipe.auto_build_channels, Cast("null", "jsonb")),
+            CharmRecipeJob.job_id == Job.id,
+            # We only want recipes that haven't had an automatic build
+            # requested for them recently.
+            Job.date_created >= threshold_date,
+            ]
+        return IStore(CharmRecipe).find(
+            CharmRecipe,
+            CharmRecipe.id.is_in(Except(
+                Select(CharmRecipe.id, where=And(*stale_clauses)),
+                Select(
+                    CharmRecipe.id,
+                    where=And(*(stale_clauses + recent_clauses))))))
+
+    @classmethod
+    def makeAutoBuilds(cls, logger=None):
+        """See `ICharmRecipeSet`."""
+        recipes = cls._findStaleRecipes()
+        build_requests = []
+        for recipe in recipes:
+            build_requests.append(recipe.requestAutoBuilds(logger=logger))
+        return build_requests
+
     def detachFromGitRepository(self, repository):
         """See `ICharmRecipeSet`."""
         recipes = self.findByGitRepository(repository)
diff --git a/lib/lp/charms/tests/test_charmrecipe.py b/lib/lp/charms/tests/test_charmrecipe.py
index f99f8a8..a399743 100644
--- a/lib/lp/charms/tests/test_charmrecipe.py
+++ b/lib/lp/charms/tests/test_charmrecipe.py
@@ -6,7 +6,10 @@
 __metaclass__ = type
 
 import base64
-from datetime import timedelta
+from datetime import (
+    datetime,
+    timedelta,
+    )
 import json
 from textwrap import dedent
 
@@ -15,6 +18,7 @@ import iso8601
 from nacl.public import PrivateKey
 from pymacaroons import Macaroon
 from pymacaroons.serializers import JsonSerializer
+import pytz
 import responses
 from storm.exceptions import LostObjectError
 from storm.locals import Store
@@ -74,6 +78,7 @@ from lp.charms.interfaces.charmrecipebuild import (
 from lp.charms.interfaces.charmrecipejob import (
     ICharmRecipeRequestBuildsJobSource,
     )
+from lp.charms.model.charmrecipe import CharmRecipeSet
 from lp.charms.model.charmrecipebuild import CharmFile
 from lp.charms.model.charmrecipejob import CharmRecipeJob
 from lp.code.errors import GitRepositoryBlobNotFound
@@ -97,6 +102,7 @@ from lp.services.database.sqlbase import (
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
+from lp.services.log.logger import BufferLogger
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webapp.snapshot import notify_modified
@@ -671,6 +677,54 @@ class TestCharmRecipe(TestCaseWithFactory):
                     (hook, "charm-recipe:build:0.1", payload_matcher)
                     for payload_matcher in payload_matchers]))
 
+    def test_requestAutoBuilds(self):
+        # requestAutoBuilds creates a new build request with appropriate
+        # parameters.
+        recipe = self.factory.makeCharmRecipe()
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestAutoBuilds()
+        self.assertThat(request, MatchesStructure(
+            date_requested=Equals(now),
+            date_finished=Is(None),
+            recipe=Equals(recipe),
+            status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+            error_message=Is(None),
+            channels=Is(None),
+            architectures=Is(None)))
+        [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+        self.assertThat(job, MatchesStructure(
+            job_id=Equals(request.id),
+            job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+            recipe=Equals(recipe),
+            requester=Equals(recipe.owner),
+            channels=Is(None),
+            architectures=Is(None)))
+
+    def test_requestAutoBuilds_channels(self):
+        # requestAutoBuilds honours CharmRecipe.auto_build_channels.
+        recipe = self.factory.makeCharmRecipe(
+            auto_build_channels={"charmcraft": "edge"})
+        now = get_transaction_timestamp(IStore(recipe))
+        with person_logged_in(recipe.owner.teamowner):
+            request = recipe.requestAutoBuilds()
+        self.assertThat(request, MatchesStructure(
+            date_requested=Equals(now),
+            date_finished=Is(None),
+            recipe=Equals(recipe),
+            status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+            error_message=Is(None),
+            channels=Equals({"charmcraft": "edge"}),
+            architectures=Is(None)))
+        [job] = getUtility(ICharmRecipeRequestBuildsJobSource).iterReady()
+        self.assertThat(job, MatchesStructure(
+            job_id=Equals(request.id),
+            job=MatchesStructure.byEquality(status=JobStatus.WAITING),
+            recipe=Equals(recipe),
+            requester=Equals(recipe.owner),
+            channels=Equals({"charmcraft": "edge"}),
+            architectures=Is(None)))
+
     def test_delete_without_builds(self):
         # A charm recipe with no builds can be deleted.
         owner = self.factory.makePerson()
@@ -1118,6 +1172,145 @@ class TestCharmRecipeSet(TestCaseWithFactory):
             BadCharmRecipeSearchContext, recipe_set.findByContext,
             self.factory.makeDistribution())
 
+    def test__findStaleRecipes(self):
+        # Stale; not built automatically.
+        self.factory.makeCharmRecipe(is_stale=True)
+        # Not stale; built automatically.
+        self.factory.makeCharmRecipe(auto_build=True, is_stale=False)
+        # Stale; built automatically.
+        stale_daily = self.factory.makeCharmRecipe(
+            auto_build=True, is_stale=True)
+        self.assertContentEqual(
+            [stale_daily], CharmRecipeSet._findStaleRecipes())
+
+    def test__findStaleRecipes_distinct(self):
+        # If a charm recipe has two build requests, it only returns one
+        # recipe.
+        recipe = self.factory.makeCharmRecipe(auto_build=True, is_stale=True)
+        for _ in range(2):
+            build_request = self.factory.makeCharmRecipeBuildRequest(
+                recipe=recipe)
+            removeSecurityProxy(
+                removeSecurityProxy(build_request)._job).job.date_created = (
+                    datetime.now(pytz.UTC) - timedelta(days=2))
+        self.assertContentEqual([recipe], CharmRecipeSet._findStaleRecipes())
+
+    def test_makeAutoBuilds(self):
+        # ICharmRecipeSet.makeAutoBuilds requests builds of
+        # appropriately-configured recipes where possible.
+        self.assertEqual([], getUtility(ICharmRecipeSet).makeAutoBuilds())
+        recipe = self.factory.makeCharmRecipe(auto_build=True, is_stale=True)
+        logger = BufferLogger()
+        [build_request] = getUtility(ICharmRecipeSet).makeAutoBuilds(
+            logger=logger)
+        self.assertThat(build_request, MatchesStructure(
+            recipe=Equals(recipe),
+            status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+            requester=Equals(recipe.owner), channels=Is(None)))
+        expected_log_entries = [
+            "DEBUG Scheduling builds of charm recipe %s/%s/%s" % (
+                recipe.owner.name, recipe.project.name, recipe.name),
+            ]
+        self.assertEqual(
+            expected_log_entries, logger.getLogBuffer().splitlines())
+        self.assertFalse(recipe.is_stale)
+
+    def test_makeAutoBuilds_skips_if_requested_recently(self):
+        # ICharmRecipeSet.makeAutoBuilds skips recipes that have been built
+        # recently.
+        recipe = self.factory.makeCharmRecipe(auto_build=True, is_stale=True)
+        self.factory.makeCharmRecipeBuildRequest(
+            requester=recipe.owner, recipe=recipe)
+        logger = BufferLogger()
+        build_requests = getUtility(ICharmRecipeSet).makeAutoBuilds(
+            logger=logger)
+        self.assertEqual([], build_requests)
+        self.assertEqual([], logger.getLogBuffer().splitlines())
+
+    def test_makeAutoBuilds_skips_if_requested_recently_matching_channels(
+            self):
+        # ICharmRecipeSet.makeAutoBuilds only considers recent build
+        # requests to match a recipe if they match its auto_build_channels.
+        recipe1 = self.factory.makeCharmRecipe(auto_build=True, is_stale=True)
+        recipe2 = self.factory.makeCharmRecipe(
+            auto_build=True, auto_build_channels={"charmcraft": "edge"},
+            is_stale=True)
+        # Create some build requests with mismatched channels.
+        self.factory.makeCharmRecipeBuildRequest(
+            recipe=recipe1, requester=recipe1.owner,
+            channels={"charmcraft": "edge"})
+        self.factory.makeCharmRecipeBuildRequest(
+            recipe=recipe2, requester=recipe2.owner,
+            channels={"charmcraft": "stable"})
+
+        logger = BufferLogger()
+        build_requests = getUtility(ICharmRecipeSet).makeAutoBuilds(
+            logger=logger)
+        self.assertThat(build_requests, MatchesSetwise(
+            MatchesStructure(
+                recipe=Equals(recipe1),
+                status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+                requester=Equals(recipe1.owner), channels=Is(None)),
+            MatchesStructure(
+                recipe=Equals(recipe2),
+                status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+                requester=Equals(recipe2.owner),
+                channels=Equals({"charmcraft": "edge"}))))
+        log_entries = logger.getLogBuffer().splitlines()
+        self.assertEqual(2, len(log_entries))
+        for recipe in recipe1, recipe2:
+            self.assertIn(
+                "DEBUG Scheduling builds of charm recipe %s/%s/%s" % (
+                    recipe.owner.name, recipe.project.name, recipe.name),
+                log_entries)
+            self.assertFalse(recipe.is_stale)
+
+        # Mark the two recipes stale and try again.  There are now matching
+        # build requests so we don't try to request more.
+        for recipe in recipe1, recipe2:
+            removeSecurityProxy(recipe).is_stale = True
+            IStore(recipe).flush()
+        logger = BufferLogger()
+        build_requests = getUtility(ICharmRecipeSet).makeAutoBuilds(
+            logger=logger)
+        self.assertEqual([], build_requests)
+        self.assertEqual([], logger.getLogBuffer().splitlines())
+
+    def test_makeAutoBuilds_skips_non_stale_recipes(self):
+        # ICharmRecipeSet.makeAutoBuilds skips recipes that are not stale.
+        self.factory.makeCharmRecipe(auto_build=True, is_stale=False)
+        self.assertEqual([], getUtility(ICharmRecipeSet).makeAutoBuilds())
+
+    def test_makeAutoBuilds_with_older_build_request(self):
+        # If a previous build request is not recent and the recipe is stale,
+        # ICharmRecipeSet.makeAutoBuilds requests builds.
+        recipe = self.factory.makeCharmRecipe(auto_build=True, is_stale=True)
+        one_day_ago = datetime.now(pytz.UTC) - timedelta(days=1)
+        build_request = self.factory.makeCharmRecipeBuildRequest(
+            recipe=recipe, requester=recipe.owner)
+        removeSecurityProxy(
+            removeSecurityProxy(build_request)._job).job.date_created = (
+                one_day_ago)
+        [build_request] = getUtility(ICharmRecipeSet).makeAutoBuilds()
+        self.assertThat(build_request, MatchesStructure(
+            recipe=Equals(recipe),
+            status=Equals(CharmRecipeBuildRequestStatus.PENDING),
+            requester=Equals(recipe.owner), channels=Is(None)))
+
+    def test_makeAutoBuilds_with_older_and_newer_build_requests(self):
+        # If builds of a recipe have been requested twice, and the most recent
+        # request is too recent, ICharmRecipeSet.makeAutoBuilds does not
+        # request builds.
+        recipe = self.factory.makeCharmRecipe(auto_build=True, is_stale=True)
+        for timediff in timedelta(days=1), timedelta(minutes=30):
+            date_created = datetime.now(pytz.UTC) - timediff
+            build_request = self.factory.makeCharmRecipeBuildRequest(
+                recipe=recipe, requester=recipe.owner)
+            removeSecurityProxy(
+                removeSecurityProxy(build_request)._job).job.date_created = (
+                    date_created)
+        self.assertEqual([], getUtility(ICharmRecipeSet).makeAutoBuilds())
+
     def test_detachFromGitRepository(self):
         # ICharmRecipeSet.detachFromGitRepository clears the given Git
         # repository from all charm recipes.
diff --git a/lib/lp/code/scripts/tests/test_request_daily_builds.py b/lib/lp/code/scripts/tests/test_request_daily_builds.py
index 0f16bc8..d1ac03a 100644
--- a/lib/lp/code/scripts/tests/test_request_daily_builds.py
+++ b/lib/lp/code/scripts/tests/test_request_daily_builds.py
@@ -1,4 +1,4 @@
-# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test the request_daily_builds script."""
@@ -14,6 +14,10 @@ from wsgiref.simple_server import (
 
 import transaction
 
+from lp.charms.interfaces.charmrecipe import (
+    CHARM_RECIPE_ALLOW_CREATE,
+    ICharmRecipe,
+    )
 from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
 from lp.services.config import config
 from lp.services.config.fixture import (
@@ -183,7 +187,9 @@ class TestRequestDailyBuilds(TestCaseWithFactory):
 
     def setUp(self):
         super(TestRequestDailyBuilds, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+        features = dict(SNAP_TESTING_FLAGS)
+        features[CHARM_RECIPE_ALLOW_CREATE] = "on"
+        self.useFixture(FeatureFixture(features))
 
     def makeLoggerheadServer(self):
         loggerhead_server = FakeLoggerheadServer()
@@ -238,6 +244,8 @@ class TestRequestDailyBuilds(TestCaseWithFactory):
             distroseries=distroarchseries.distroseries,
             processors=[distroarchseries.processor],
             auto_build=True, is_stale=True, git_ref=prod_ref)
+        git_prod_charm_recipe = self.factory.makeCharmRecipe(
+            auto_build=True, is_stale=True, git_ref=prod_ref)
         package = self.factory.makeSourcePackage()
         pack_branch = self.factory.makeBranch(sourcepackage=package)
         [pack_ref] = self.factory.makeGitRefs(
@@ -254,9 +262,12 @@ class TestRequestDailyBuilds(TestCaseWithFactory):
             distroseries=distroarchseries.distroseries,
             processors=[distroarchseries.processor],
             auto_build=True, is_stale=True, git_ref=pack_ref)
+        git_pack_charm_recipe = self.factory.makeCharmRecipe(
+            auto_build=True, is_stale=True, git_ref=pack_ref)
         items = [
             bzr_prod_recipe, git_prod_recipe, bzr_prod_snap, git_prod_snap,
             bzr_pack_recipe, git_pack_recipe, bzr_pack_snap, git_pack_snap,
+            git_prod_charm_recipe, git_pack_charm_recipe,
             ]
         for item in items:
             self.assertEqual(0, item.pending_builds.count())
@@ -277,12 +288,21 @@ class TestRequestDailyBuilds(TestCaseWithFactory):
             prod_ref.repository, 'snap/snapcraft.yaml', b'name: prod-snap')
         turnip_server.addBlob(
             pack_ref.repository, 'snap/snapcraft.yaml', b'name: pack-snap')
+        turnip_server.addBlob(
+            prod_ref.repository, 'charmcraft.yaml', b'name: prod-charm')
+        turnip_server.addBlob(
+            pack_ref.repository, 'charmcraft.yaml', b'name: pack-charm')
         retcode, stdout, stderr = run_script(
             'cronscripts/request_daily_builds.py', [])
         self.assertIn('Requested 4 daily recipe builds.', stderr)
         self.assertIn('Requested 4 automatic snap package builds.', stderr)
+        self.assertIn(
+            'Requested 2 sets of automatic charm recipe builds.', stderr)
         for item in items:
-            self.assertEqual(1, item.pending_builds.count())
+            if ICharmRecipe.providedBy(item):
+                self.assertEqual(1, item.pending_build_requests.count())
+            else:
+                self.assertEqual(1, item.pending_builds.count())
             self.assertFalse(item.is_stale)
 
     def test_request_daily_builds_oops(self):
@@ -296,6 +316,8 @@ class TestRequestDailyBuilds(TestCaseWithFactory):
         self.assertEqual(0, recipe.pending_builds.count())
         self.assertIn('Requested 0 daily recipe builds.', stderr)
         self.assertIn('Requested 0 automatic snap package builds.', stderr)
+        self.assertIn(
+            'Requested 0 sets of automatic charm recipe builds.', stderr)
         self.oops_capture.sync()
         self.assertEqual('NonPPABuildRequest', self.oopses[0]['type'])
         self.assertEqual(
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 18e4ffe..673b9fe 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -151,6 +151,10 @@ cron_control_url: file:cronscripts.ini
 
 
 [charms]
+# Minimum time in minutes between dispatching automatic builds of charm
+# recipes.
+auto_build_frequency: 60
+
 # Charmhub's primary URL endpoint.
 # datatype: urlbase
 charmhub_url: none
diff --git a/lib/lp/services/database/stormexpr.py b/lib/lp/services/database/stormexpr.py
index 648bec2..fd66a6e 100644
--- a/lib/lp/services/database/stormexpr.py
+++ b/lib/lp/services/database/stormexpr.py
@@ -1,4 +1,4 @@
-# Copyright 2011-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -18,6 +18,7 @@ __all__ = [
     'IsDistinctFrom',
     'IsFalse',
     'IsTrue',
+    'JSONExtract',
     'NullCount',
     'NullsFirst',
     'NullsLast',
@@ -257,6 +258,11 @@ class RegexpMatch(BinaryOper):
     oper = " ~ "
 
 
+class JSONExtract(BinaryOper):
+    __slots__ = ()
+    oper = "->"
+
+
 compile.set_precedence(compile.get_precedence(Like), RegexpMatch)