← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:archive-dependency-snap-base into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:archive-dependency-snap-base into launchpad:master.

Commit message:
Add archive dependencies for SnapBase

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This will be used for the "core" snap base once 16.04 enters ESM.

There's no dispatch support in the snap build behaviour yet; that will come in a future branch.

DB MP: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/400347
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:archive-dependency-snap-base into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 6e2cbfc..a61d39d 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Update the interface schema values due to circular imports.
@@ -167,6 +167,7 @@ from lp.services.worlddata.interfaces.language import (
     ILanguage,
     ILanguageSet,
     )
+from lp.snappy.interfaces.snapbase import ISnapBase
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.archivedependency import IArchiveDependency
 from lp.soyuz.interfaces.archivepermission import IArchivePermission
@@ -418,6 +419,9 @@ patch_plain_parameter_type(
 patch_entry_return_type(
     IArchive, '_addArchiveDependency', IArchiveDependency)
 
+# IArchiveDependency
+patch_reference_property(IArchiveDependency, 'snap_base', ISnapBase)
+
 # IBuildFarmJob
 patch_reference_property(IBuildFarmJob, 'buildqueue_record', IBuildQueue)
 
diff --git a/lib/lp/snappy/browser/configure.zcml b/lib/lp/snappy/browser/configure.zcml
index a21c0b4..21d849f 100644
--- a/lib/lp/snappy/browser/configure.zcml
+++ b/lib/lp/snappy/browser/configure.zcml
@@ -198,7 +198,9 @@
             parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
         <browser:navigation
             module="lp.snappy.browser.snapbase"
-            classes="SnapBaseSetNavigation" />
+            classes="
+                SnapBaseNavigation
+                SnapBaseSetNavigation" />
 
         <browser:page
             for="*"
diff --git a/lib/lp/snappy/browser/snapbase.py b/lib/lp/snappy/browser/snapbase.py
index 488aac6..73d1139 100644
--- a/lib/lp/snappy/browser/snapbase.py
+++ b/lib/lp/snappy/browser/snapbase.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Views of bases for snaps."""
@@ -7,11 +7,50 @@ from __future__ import absolute_import, print_function, unicode_literals
 
 __metaclass__ = type
 __all__ = [
+    "SnapBaseNavigation",
     "SnapBaseSetNavigation",
     ]
 
-from lp.services.webapp import GetitemNavigation
-from lp.snappy.interfaces.snapbase import ISnapBaseSet
+from sqlobject import SQLObjectNotFound
+from zope.component import getUtility
+
+from lp.services.webapp import (
+    GetitemNavigation,
+    Navigation,
+    stepthrough,
+    )
+from lp.snappy.interfaces.snapbase import (
+    ISnapBase,
+    ISnapBaseSet,
+    )
+from lp.soyuz.interfaces.archive import IArchiveSet
+
+
+class SnapBaseNavigation(Navigation):
+    """Navigation methods for `ISnapBase`."""
+
+    usedfor = ISnapBase
+
+    @stepthrough("+dependency")
+    def traverse_dependency(self, id):
+        """Traverse to an archive dependency by archive ID.
+
+        We use `IArchive.getArchiveDependency` here, which is protected by
+        `launchpad.View`, so you cannot get to a dependency of a private
+        archive that you can't see.
+        """
+        try:
+            id = int(id)
+        except ValueError:
+            # Not a number.
+            return None
+
+        try:
+            archive = getUtility(IArchiveSet).get(id)
+        except SQLObjectNotFound:
+            return None
+
+        return self.context.getArchiveDependency(archive)
 
 
 class SnapBaseSetNavigation(GetitemNavigation):
diff --git a/lib/lp/snappy/interfaces/snapbase.py b/lib/lp/snappy/interfaces/snapbase.py
index 5a3a97c..a5fdf90 100644
--- a/lib/lp/snappy/interfaces/snapbase.py
+++ b/lib/lp/snappy/interfaces/snapbase.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces for bases for snaps."""
@@ -19,6 +19,7 @@ from lazr.restful.declarations import (
     error_status,
     export_destructor_operation,
     export_factory_operation,
+    export_operation_as,
     export_read_operation,
     export_write_operation,
     exported,
@@ -29,7 +30,12 @@ from lazr.restful.declarations import (
     operation_returns_entry,
     REQUEST_USER,
     )
-from lazr.restful.fields import Reference
+from lazr.restful.fields import (
+    CollectionField,
+    Reference,
+    )
+from lazr.restful.interface import copy_field
+import six
 from six.moves import http_client
 from zope.component import getUtility
 from zope.interface import Interface
@@ -49,6 +55,8 @@ from lp.services.fields import (
     ContentNameField,
     PublicPersonChoice,
     )
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.interfaces.archivedependency import IArchiveDependency
 
 
 class NoSuchSnapBase(NameLookupFailed):
@@ -99,6 +107,24 @@ class ISnapBaseView(Interface):
             "Whether this base is the default for snaps that do not specify a "
             "base.")))
 
+    dependencies = exported(CollectionField(
+        title=_("Archive dependencies for this snap base."),
+        value_type=Reference(schema=IArchiveDependency),
+        readonly=True))
+
+    @operation_parameters(dependency=Reference(schema=IArchive))
+    @operation_returns_entry(schema=IArchiveDependency)
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getArchiveDependency(dependency):
+        """Return the `IArchiveDependency` object for the given dependency.
+
+        :param dependency: an `IArchive`.
+
+        :return: an `IArchiveDependency`, or None if a corresponding object
+            could not be found.
+        """
+
 
 class ISnapBaseEditableAttributes(Interface):
     """`ISnapBase` attributes that can be edited.
@@ -128,6 +154,49 @@ class ISnapBaseEditableAttributes(Interface):
 class ISnapBaseEdit(Interface):
     """`ISnapBase` methods that require launchpad.Edit permission."""
 
+    def addArchiveDependency(dependency, pocket, component=None):
+        """Add an archive dependency for this snap base.
+
+        :param dependency: an `IArchive`.
+        :param pocket: a `PackagePublishingPocket`.
+        :param component: an optional `IComponent` object; if not given, the
+            archive dependency will use the component used for dependencies
+            on the primary archive.
+
+        :raise: `ArchiveDependencyError` if the given `dependency` does not
+            fit this snap base.
+        :return: an `IArchiveDependency`.
+        """
+
+    @operation_parameters(
+        component=copy_field(IArchiveDependency["component_name"]))
+    @export_operation_as(six.ensure_str("addArchiveDependency"))
+    @export_factory_operation(IArchiveDependency, ["dependency", "pocket"])
+    @operation_for_version("devel")
+    def _addArchiveDependency(dependency, pocket, component=None):
+        """Add an archive dependency for this snap base.
+
+        :param dependency: an `IArchive`.
+        :param pocket: a `PackagePublishingPocket`.
+        :param component: an optional component name; if not given, the
+            archive dependency will use the component used for dependencies
+            on the primary archive.
+
+        :raise: `ArchiveDependencyError` if the given `dependency` does not
+            fit this snap base.
+        :return: an `IArchiveDependency`.
+        """
+
+    @operation_parameters(
+        dependency=Reference(schema=IArchive, required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def removeArchiveDependency(dependency):
+        """Remove the archive dependency on the given archive.
+
+        :param dependency: an `IArchive`.
+        """
+
     @export_destructor_operation()
     @operation_for_version("devel")
     def destroySelf():
@@ -141,7 +210,7 @@ class ISnapBaseEdit(Interface):
 # generation working.  Individual attributes must set their version to
 # "devel".
 @exported_as_webservice_entry(as_of="beta")
-class ISnapBase(ISnapBaseView, ISnapBaseEditableAttributes):
+class ISnapBase(ISnapBaseView, ISnapBaseEditableAttributes, ISnapBaseEdit):
     """A base for snaps."""
 
 
diff --git a/lib/lp/snappy/model/snapbase.py b/lib/lp/snappy/model/snapbase.py
index d915caa..615c783 100644
--- a/lib/lp/snappy/model/snapbase.py
+++ b/lib/lp/snappy/model/snapbase.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Bases for snaps."""
@@ -11,6 +11,7 @@ __all__ = [
     ]
 
 import pytz
+import six
 from storm.locals import (
     Bool,
     DateTime,
@@ -21,9 +22,13 @@ from storm.locals import (
     Storm,
     Unicode,
     )
+from zope.component import getUtility
 from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.errors import NotFoundError
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.model.person import Person
 from lp.services.database.constants import DEFAULT
 from lp.services.database.interfaces import (
     IMasterStore,
@@ -35,6 +40,13 @@ from lp.snappy.interfaces.snapbase import (
     ISnapBaseSet,
     NoSuchSnapBase,
     )
+from lp.soyuz.interfaces.archive import (
+    ArchiveDependencyError,
+    ComponentNotFound,
+    )
+from lp.soyuz.interfaces.component import IComponentSet
+from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.archivedependency import ArchiveDependency
 
 
 @implementer(ISnapBase)
@@ -73,6 +85,63 @@ class SnapBase(Storm):
         self.date_created = date_created
         self.is_default = False
 
+    @property
+    def dependencies(self):
+        """See `ISnapBase`."""
+        return IStore(ArchiveDependency).find(
+            ArchiveDependency,
+            ArchiveDependency.dependency == Archive.id,
+            Archive.owner == Person.id,
+            ArchiveDependency.snap_base == self).order_by(Person.display_name)
+
+    def getArchiveDependency(self, dependency):
+        """See `ISnapBase`."""
+        return IStore(ArchiveDependency).find(
+            ArchiveDependency, snap_base=self, dependency=dependency).one()
+
+    def addArchiveDependency(self, dependency, pocket, component=None):
+        """See `ISnapBase`."""
+        archive_dependency = self.getArchiveDependency(dependency)
+        if archive_dependency is not None:
+            raise ArchiveDependencyError(
+                "This dependency is already registered.")
+        # XXX cjwatson 2021-03-19: Relax this once we have a way to dispatch
+        # appropriate tokens for snap builds whose base has dependencies on
+        # private archives.
+        if dependency.private:
+            raise ArchiveDependencyError("This dependency is private.")
+        if not dependency.enabled:
+            raise ArchiveDependencyError("Dependencies must not be disabled.")
+
+        if dependency.is_ppa:
+            if pocket is not PackagePublishingPocket.RELEASE:
+                raise ArchiveDependencyError(
+                    "Non-primary archives only support the RELEASE pocket.")
+            if (component is not None and
+                    component != dependency.default_component):
+                raise ArchiveDependencyError(
+                    "Non-primary archives only support the '%s' component." %
+                    dependency.default_component.name)
+        return ArchiveDependency(
+            parent=self, dependency=dependency, pocket=pocket,
+            component=component)
+
+    def _addArchiveDependency(self, dependency, pocket, component=None):
+        """See `ISnapBase`."""
+        if isinstance(component, six.text_type):
+            try:
+                component = getUtility(IComponentSet)[component]
+            except NotFoundError as e:
+                raise ComponentNotFound(e)
+        return self.addArchiveDependency(dependency, pocket, component)
+
+    def removeArchiveDependency(self, dependency):
+        """See `ISnapBase`."""
+        archive_dependency = self.getArchiveDependency(dependency)
+        if archive_dependency is None:
+            raise ArchiveDependencyError("This dependency does not exist.")
+        archive_dependency.destroySelf()
+
     def destroySelf(self):
         """See `ISnapBase`."""
         # Guard against unfortunate accidents.
diff --git a/lib/lp/snappy/tests/test_snapbase.py b/lib/lp/snappy/tests/test_snapbase.py
index f45feda..67754d2 100644
--- a/lib/lp/snappy/tests/test_snapbase.py
+++ b/lib/lp/snappy/tests/test_snapbase.py
@@ -1,4 +1,4 @@
-# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test bases for snaps."""
@@ -11,6 +11,9 @@ from testtools.matchers import (
     ContainsDict,
     Equals,
     Is,
+    MatchesListwise,
+    MatchesRegex,
+    MatchesStructure,
     )
 from zope.component import (
     getAdapter,
@@ -18,6 +21,7 @@ from zope.component import (
     )
 
 from lp.app.interfaces.security import IAuthorization
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.snappy.interfaces.snapbase import (
     CannotDeleteSnapBase,
@@ -25,6 +29,7 @@ from lp.snappy.interfaces.snapbase import (
     ISnapBaseSet,
     NoSuchSnapBase,
     )
+from lp.soyuz.interfaces.component import IComponentSet
 from lp.testing import (
     api_url,
     celebrity_logged_in,
@@ -257,6 +262,112 @@ class TestSnapBaseWebservice(TestCaseWithFactory):
             self.assertEqual(
                 snap_bases[1], getUtility(ISnapBaseSet).getDefault())
 
+    def test_addArchiveDependency_unpriv(self):
+        # An unprivileged user cannot add an archive dependency.
+        person = self.factory.makePerson()
+        with celebrity_logged_in("registry_experts"):
+            snap_base = self.factory.makeSnapBase()
+            archive = self.factory.makeArchive()
+            snap_base_url = api_url(snap_base)
+            archive_url = api_url(archive)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            snap_base_url, "addArchiveDependency",
+            dependency=archive_url, pocket="Release", component="main")
+        self.assertThat(response, MatchesStructure(
+            status=Equals(401),
+            body=MatchesRegex(br".*addArchiveDependency.*launchpad.Edit.*")))
+
+    def test_addArchiveDependency(self):
+        # A registry expert can add an archive dependency.
+        person = self.factory.makeRegistryExpert()
+        with person_logged_in(person):
+            snap_base = self.factory.makeSnapBase()
+            archive = self.factory.makeArchive()
+            snap_base_url = api_url(snap_base)
+            archive_url = api_url(archive)
+            self.assertEqual([], list(snap_base.dependencies))
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            snap_base_url, "addArchiveDependency",
+            dependency=archive_url, pocket="Release", component="main")
+        self.assertEqual(201, response.status)
+        with person_logged_in(person):
+            self.assertThat(list(snap_base.dependencies), MatchesListwise([
+                MatchesStructure(
+                    archive=Is(None),
+                    snap_base=Equals(snap_base),
+                    dependency=Equals(archive),
+                    pocket=Equals(PackagePublishingPocket.RELEASE),
+                    component=Equals(getUtility(IComponentSet)["main"]),
+                    component_name=Equals("main"),
+                    title=Equals(archive.displayname),
+                    ),
+                ]))
+
+    def test_addArchiveDependency_invalid(self):
+        # Invalid requests generate a BadRequest error.
+        person = self.factory.makeRegistryExpert()
+        with person_logged_in(person):
+            snap_base = self.factory.makeSnapBase()
+            archive = self.factory.makeArchive()
+            snap_base.addArchiveDependency(
+                archive, PackagePublishingPocket.RELEASE)
+            snap_base_url = api_url(snap_base)
+            archive_url = api_url(archive)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            snap_base_url, "addArchiveDependency",
+            dependency=archive_url, pocket="Release", component="main")
+        self.assertThat(response, MatchesStructure.byEquality(
+            status=400, body=b"This dependency is already registered."))
+
+    def test_removeArchiveDependency_unpriv(self):
+        # An unprivileged user cannot remove an archive dependency.
+        person = self.factory.makePerson()
+        with celebrity_logged_in("registry_experts"):
+            snap_base = self.factory.makeSnapBase()
+            archive = self.factory.makeArchive()
+            snap_base.addArchiveDependency(
+                archive, PackagePublishingPocket.RELEASE)
+            snap_base_url = api_url(snap_base)
+            archive_url = api_url(archive)
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            snap_base_url, "removeArchiveDependency", dependency=archive_url)
+        self.assertThat(response, MatchesStructure(
+            status=Equals(401),
+            body=MatchesRegex(
+                br".*removeArchiveDependency.*launchpad.Edit.*")))
+
+    def test_removeArchiveDependency(self):
+        # A registry expert can remove an archive dependency.
+        person = self.factory.makeRegistryExpert()
+        with person_logged_in(person):
+            snap_base = self.factory.makeSnapBase()
+            archive = self.factory.makeArchive()
+            snap_base.addArchiveDependency(
+                archive, PackagePublishingPocket.RELEASE)
+            snap_base_url = api_url(snap_base)
+            archive_url = api_url(archive)
+            self.assertNotEqual([], list(snap_base.dependencies))
+        webservice = webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            snap_base_url, "removeArchiveDependency", dependency=archive_url)
+        self.assertEqual(200, response.status)
+        with person_logged_in(person):
+            self.assertEqual([], list(snap_base.dependencies))
+
     def test_collection(self):
         # lp.snap_bases is a collection of all SnapBases.
         person = self.factory.makePerson()
diff --git a/lib/lp/soyuz/browser/configure.zcml b/lib/lp/soyuz/browser/configure.zcml
index 5aea5bd..ea111dd 100644
--- a/lib/lp/soyuz/browser/configure.zcml
+++ b/lib/lp/soyuz/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -676,7 +676,7 @@
     <browser:url
         for="lp.soyuz.interfaces.archivedependency.IArchiveDependency"
         path_expression="string:+dependency/${dependency/id}"
-        attribute_to_parent="archive"
+        attribute_to_parent="parent"
         />
     <browser:url
         for="lp.soyuz.interfaces.binarypackagerelease.IBinaryPackageReleaseDownloadCount"
diff --git a/lib/lp/soyuz/interfaces/archivedependency.py b/lib/lp/soyuz/interfaces/archivedependency.py
index b6f7092..a3a1525 100644
--- a/lib/lp/soyuz/interfaces/archivedependency.py
+++ b/lib/lp/soyuz/interfaces/archivedependency.py
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """ArchiveDependency interface."""
@@ -14,7 +14,10 @@ from lazr.restful.declarations import (
     exported_as_webservice_entry,
     )
 from lazr.restful.fields import Reference
-from zope.interface import Interface
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
 from zope.schema import (
     Choice,
     Datetime,
@@ -38,11 +41,23 @@ class IArchiveDependency(Interface):
             title=_("Instant when the dependency was created."),
             required=False, readonly=True))
 
+    # The object that has the dependency: exactly one of archive or
+    # snap_base is required (enforced by DB constraints).
+
     archive = exported(
         Reference(
-            schema=IArchive, required=True, readonly=True,
+            schema=IArchive, required=False, readonly=True,
             title=_('Target archive'),
-            description=_("The archive affected by this dependecy.")))
+            description=_("The archive that has this dependency.")))
+
+    snap_base = exported(
+        Reference(
+            # Really ISnapBase, patched in _schema_circular_imports.py.
+            schema=Interface, required=False, readonly=True,
+            title=_('Target snap base'),
+            description=_("The snap base that has this dependency.")))
+
+    parent = Attribute("The object that has this dependency.")
 
     dependency = exported(
         Reference(
@@ -55,14 +70,14 @@ class IArchiveDependency(Interface):
             vocabulary=PackagePublishingPocket))
 
     component = Choice(
-        title=_("Component"), required=True, readonly=True,
+        title=_("Component"), required=False, readonly=True,
         vocabulary='Component')
 
     # We don't want to export IComponent, so the name is exported specially.
     component_name = exported(
         TextLine(
             title=_("Component name"),
-            required=True, readonly=True))
+            required=False, readonly=True))
 
     title = exported(
         TextLine(title=_("Archive dependency title."), readonly=True))
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index 9696b8f..e51898b 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Database class for table Archive."""
@@ -1167,7 +1167,7 @@ class Archive(SQLBase):
                     "Non-primary archives only support the '%s' component." %
                     dependency.default_component.name)
         return ArchiveDependency(
-            archive=self, dependency=dependency, pocket=pocket,
+            parent=self, dependency=dependency, pocket=pocket,
             component=component)
 
     def _addArchiveDependency(self, dependency, pocket, component=None):
diff --git a/lib/lp/soyuz/model/archivedependency.py b/lib/lp/soyuz/model/archivedependency.py
index 8fcd899..49621bf 100644
--- a/lib/lp/soyuz/model/archivedependency.py
+++ b/lib/lp/soyuz/model/archivedependency.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Database class for ArchiveDependency."""
@@ -20,7 +20,9 @@ from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.stormbase import StormBase
+from lp.snappy.interfaces.snapbase import ISnapBase
 from lp.soyuz.adapters.archivedependencies import get_components_for_context
+from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.archivedependency import IArchiveDependency
 
 
@@ -37,9 +39,12 @@ class ArchiveDependency(StormBase):
         name='date_created', tzinfo=pytz.UTC, allow_none=False,
         default=UTC_NOW)
 
-    archive_id = Int(name='archive', allow_none=False)
+    archive_id = Int(name='archive', allow_none=True)
     archive = Reference(archive_id, 'Archive.id')
 
+    snap_base_id = Int(name='snap_base', allow_none=True)
+    snap_base = Reference(snap_base_id, 'SnapBase.id')
+
     dependency_id = Int(name='dependency', allow_none=False)
     dependency = Reference(dependency_id, 'Archive.id')
 
@@ -49,14 +54,33 @@ class ArchiveDependency(StormBase):
     component_id = Int(name='component', allow_none=True)
     component = Reference(component_id, 'Component.id')
 
-    def __init__(self, archive, dependency, pocket, component=None):
+    def __init__(self, parent, dependency, pocket, component=None):
         super(ArchiveDependency, self).__init__()
-        self.archive = archive
+        self.parent = parent
         self.dependency = dependency
         self.pocket = pocket
         self.component = component
 
     @property
+    def parent(self):
+        if self.archive is not None:
+            return self.archive
+        else:
+            return self.snap_base
+
+    @parent.setter
+    def parent(self, value):
+        if IArchive.providedBy(value):
+            self.archive = value
+            self.snap_base = None
+        elif ISnapBase.providedBy(value):
+            self.archive = None
+            self.snap_base = value
+        else:
+            raise AssertionError(
+                "Unknown archive dependency parent %s" % value)
+
+    @property
     def component_name(self):
         """See `IArchiveDependency`"""
         if self.component:
@@ -76,11 +100,14 @@ class ArchiveDependency(StormBase):
         if self.component is None:
             return pocket_title
 
-        # XXX cjwatson 2016-03-31: This may be inaccurate, but we can't do
-        # much better since this ArchiveDependency applies to multiple
-        # series which may each resolve component dependencies in different
-        # ways.
-        distroseries = self.archive.distribution.currentseries
+        if self.archive is not None:
+            # XXX cjwatson 2016-03-31: This may be inaccurate, but we can't
+            # do much better since this ArchiveDependency applies to
+            # multiple series which may each resolve component dependencies
+            # in different ways.
+            distroseries = self.archive.distribution.currentseries
+        else:
+            distroseries = self.snap_base.distro_series
 
         component_part = ", ".join(get_components_for_context(
             self.component, distroseries, self.pocket))