Merge ~cjwatson/launchpad:garbo-archive-tokens into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 98bf457d825b2da96429144a02e5a5f2ae55383b
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:garbo-archive-tokens
Merge into: launchpad:master
Diff against target: 1086 lines (+420/-501)
5 files modified
database/schema/security.cfg (+2/-0)
dev/null (+0/-337)
lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py (+6/-162)
lib/lp/scripts/garbo.py (+170/-1)
lib/lp/scripts/tests/test_garbo.py (+242/-1)
Reviewer Review Type Date Requested Status
Tom Wardill (community) Approve
Review via email: mp+402375@code.launchpad.net

Commit message

Move remaining generate-ppa-htaccess functions to garbo

Description of the change

Now that archive authentication is handled dynamically, we no longer need to deal with expiring archive subscriptions and deactivating archive authorization tokens via the relatively heavyweight mechanism of a standalone script run once per minute on the PPA publisher system: it's really just a particular kind of garbage collection (with the only quirk being that it sends emails), so it can reasonably be run from garbo.

Deactivating tokens depends on expiring subscriptions, but the latter is quite lightweight and so can be run from garbo-frequently, so in practice this won't delay things for long, and it isn't vital for the cancellation emails sent when tokens are deactivated to be sent immediately.

We can remove generate-ppa-htaccess entirely once this commit lands on production and once we've removed it from production crontabs.

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/database/schema/security.cfg b/database/schema/security.cfg
2index 1bbb845..c3aba65 100644
3--- a/database/schema/security.cfg
4+++ b/database/schema/security.cfg
5@@ -2423,6 +2423,8 @@ public.accesspolicyartifact = SELECT, DELETE
6 public.accesspolicygrant = SELECT, DELETE
7 public.account = SELECT, DELETE
8 public.answercontact = SELECT, DELETE
9+public.archiveauthtoken = SELECT, UPDATE
10+public.archivesubscriber = SELECT, UPDATE
11 public.branch = SELECT, UPDATE
12 public.branchjob = SELECT, DELETE
13 public.branchmergeproposal = SELECT, UPDATE, DELETE
14diff --git a/lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py b/lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py
15index 26e8db8..5ddcc0e 100644
16--- a/lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py
17+++ b/lib/lp/archivepublisher/scripts/generate_ppa_htaccess.py
18@@ -3,34 +3,17 @@
19 # Copyright 2009-2011 Canonical Ltd. This software is licensed under the
20 # GNU Affero General Public License version 3 (see the file LICENSE).
21
22-from datetime import datetime
23-
24-import pytz
25-
26-from lp.registry.model.teammembership import TeamParticipation
27-from lp.services.config import config
28-from lp.services.database.interfaces import IStore
29-from lp.services.mail.helpers import get_email_template
30-from lp.services.mail.mailwrapper import MailWrapper
31-from lp.services.mail.sendmail import (
32- format_address,
33- simple_sendmail,
34- )
35 from lp.services.scripts.base import LaunchpadCronScript
36-from lp.services.webapp import canonical_url
37-from lp.soyuz.enums import ArchiveSubscriberStatus
38-from lp.soyuz.model.archiveauthtoken import ArchiveAuthToken
39-from lp.soyuz.model.archivesubscriber import ArchiveSubscriber
40
41
42 class HtaccessTokenGenerator(LaunchpadCronScript):
43 """Expire archive subscriptions and deactivate invalid tokens."""
44
45- # XXX cjwatson 2021-04-21: This script and class are now misnamed, as we
46- # no longer generate .htaccess or .htpasswd files, but instead check
47- # archive authentication dynamically. We can remove this script once we
48- # stop running it on production and move its remaining functions
49- # elsewhere (probably garbo).
50+ # XXX cjwatson 2021-05-06: This script does nothing. We no longer
51+ # generate .htaccess or .htpasswd files, but instead check archive
52+ # authentication dynamically; and garbo now handles expiring
53+ # subscriptions and deactivating tokens. We can remove this script once
54+ # we stop running it on production.
55
56 def add_my_options(self):
57 """Add script command line options."""
58@@ -44,145 +27,6 @@ class HtaccessTokenGenerator(LaunchpadCronScript):
59 dest="no_deactivation", default=False,
60 help="If set, tokens are not deactivated.")
61
62- def sendCancellationEmail(self, token):
63- """Send an email to the person whose subscription was cancelled."""
64- if token.archive.suppress_subscription_notifications:
65- # Don't send an email if they should be suppresed for the
66- # archive
67- return
68- send_to_person = token.person
69- ppa_name = token.archive.displayname
70- ppa_owner_url = canonical_url(token.archive.owner)
71- subject = "PPA access cancelled for %s" % ppa_name
72- template = get_email_template(
73- "ppa-subscription-cancelled.txt", app='soyuz')
74-
75- assert not send_to_person.is_team, (
76- "Token.person is a team, it should always be individuals.")
77-
78- if send_to_person.preferredemail is None:
79- # The person has no preferred email set, so we don't
80- # email them.
81- return
82-
83- to_address = [send_to_person.preferredemail.email]
84- replacements = {
85- 'recipient_name': send_to_person.displayname,
86- 'ppa_name': ppa_name,
87- 'ppa_owner_url': ppa_owner_url,
88- }
89- body = MailWrapper(72).format(
90- template % replacements, force_wrap=True)
91-
92- from_address = format_address(
93- ppa_name,
94- config.canonical.noreply_from_address)
95-
96- headers = {
97- 'Sender': config.canonical.bounce_address,
98- }
99-
100- simple_sendmail(from_address, to_address, subject, body, headers)
101-
102- def _getInvalidTokens(self):
103- """Return all invalid tokens.
104-
105- A token is invalid if it is active and the token owner is *not* a
106- subscriber to the archive that the token is for. The subscription can
107- be either direct or through a team.
108- """
109- # First we grab all the active tokens for which there is a
110- # matching current archive subscription for a team of which the
111- # token owner is a member.
112- store = IStore(ArchiveSubscriber)
113- valid_tokens = store.find(
114- ArchiveAuthToken,
115- ArchiveAuthToken.name == None,
116- ArchiveAuthToken.date_deactivated == None,
117- ArchiveAuthToken.archive_id == ArchiveSubscriber.archive_id,
118- ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT,
119- ArchiveSubscriber.subscriber_id == TeamParticipation.teamID,
120- TeamParticipation.personID == ArchiveAuthToken.person_id)
121-
122- # We can then evaluate the invalid tokens by the difference of
123- # all active tokens and valid tokens.
124- all_active_tokens = store.find(
125- ArchiveAuthToken,
126- ArchiveAuthToken.name == None,
127- ArchiveAuthToken.date_deactivated == None)
128-
129- return all_active_tokens.difference(valid_tokens)
130-
131- def deactivateTokens(self, tokens, send_email=False):
132- """Deactivate the given tokens.
133-
134- :return: A set of PPAs affected by the deactivations.
135- """
136- affected_ppas = set()
137- num_tokens = 0
138- for token in tokens:
139- if send_email:
140- self.sendCancellationEmail(token)
141- # Deactivate tokens one at a time, as 'tokens' is the result of a
142- # set expression and storm does not allow setting on such things.
143- token.deactivate()
144- affected_ppas.add(token.archive)
145- num_tokens += 1
146- self.logger.debug(
147- "Deactivated %s tokens, %s PPAs affected"
148- % (num_tokens, len(affected_ppas)))
149- return affected_ppas
150-
151- def deactivateInvalidTokens(self, send_email=False):
152- """Deactivate tokens as necessary.
153-
154- If an active token for a PPA no longer has any subscribers,
155- we deactivate the token.
156-
157- :param send_email: Whether to send a cancellation email to the owner
158- of the token. This defaults to False to speed up the test
159- suite.
160- :return: the set of ppas affected by token deactivations.
161- """
162- invalid_tokens = self._getInvalidTokens()
163- return self.deactivateTokens(invalid_tokens, send_email=send_email)
164-
165- def expireSubscriptions(self):
166- """Expire subscriptions as necessary.
167-
168- If an `ArchiveSubscriber`'s date_expires has passed, then
169- set its status to EXPIRED.
170- """
171- now = datetime.now(pytz.UTC)
172-
173- store = IStore(ArchiveSubscriber)
174- newly_expired_subscriptions = store.find(
175- ArchiveSubscriber,
176- ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT,
177- ArchiveSubscriber.date_expires != None,
178- ArchiveSubscriber.date_expires <= now)
179-
180- subscription_names = [
181- subs.displayname for subs in newly_expired_subscriptions]
182- if subscription_names:
183- newly_expired_subscriptions.set(
184- status=ArchiveSubscriberStatus.EXPIRED)
185- self.logger.info(
186- "Expired subscriptions: %s" % ", ".join(subscription_names))
187-
188 def main(self):
189 """Script entry point."""
190- self.logger.info('Starting the PPA .htaccess generation')
191- self.expireSubscriptions()
192- affected_ppas = self.deactivateInvalidTokens(send_email=True)
193- self.logger.debug(
194- '%s PPAs with deactivated tokens' % len(affected_ppas))
195-
196- if self.options.no_deactivation or self.options.dryrun:
197- self.logger.info('Dry run, so not committing transaction.')
198- self.txn.abort()
199- else:
200- self.logger.info('Committing transaction...')
201- self.txn.commit()
202-
203- self.logger.info('Finished PPA .htaccess generation')
204+ pass
205diff --git a/lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py b/lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py
206deleted file mode 100644
207index 472b7bf..0000000
208--- a/lib/lp/archivepublisher/tests/test_generate_ppa_htaccess.py
209+++ /dev/null
210@@ -1,337 +0,0 @@
211-# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
212-# GNU Affero General Public License version 3 (see the file LICENSE).
213-
214-"""Test the generate_ppa_htaccess.py script. """
215-
216-from __future__ import absolute_import, print_function, unicode_literals
217-
218-from datetime import (
219- datetime,
220- timedelta,
221- )
222-import os
223-import subprocess
224-import sys
225-
226-import pytz
227-from zope.component import getUtility
228-
229-from lp.archivepublisher.scripts.generate_ppa_htaccess import (
230- HtaccessTokenGenerator,
231- )
232-from lp.registry.interfaces.distribution import IDistributionSet
233-from lp.registry.interfaces.person import IPersonSet
234-from lp.registry.interfaces.teammembership import TeamMembershipStatus
235-from lp.services.config import config
236-from lp.services.features.testing import FeatureFixture
237-from lp.services.log.logger import BufferLogger
238-from lp.soyuz.enums import ArchiveSubscriberStatus
239-from lp.soyuz.interfaces.archive import NAMED_AUTH_TOKEN_FEATURE_FLAG
240-from lp.testing import TestCaseWithFactory
241-from lp.testing.dbuser import (
242- lp_dbuser,
243- switch_dbuser,
244- )
245-from lp.testing.layers import LaunchpadZopelessLayer
246-from lp.testing.mail_helpers import pop_notifications
247-
248-
249-class TestPPAHtaccessTokenGeneration(TestCaseWithFactory):
250- """Test the generate_ppa_htaccess.py script."""
251-
252- layer = LaunchpadZopelessLayer
253- dbuser = config.generateppahtaccess.dbuser
254-
255- SCRIPT_NAME = 'test tokens'
256-
257- def setUp(self):
258- super(TestPPAHtaccessTokenGeneration, self).setUp()
259- self.owner = self.factory.makePerson(
260- name="joe", displayname="Joe Smith")
261- self.ppa = self.factory.makeArchive(
262- owner=self.owner, name="myppa", private=True)
263-
264- # "Ubuntu" doesn't have a proper publisher config but Ubuntutest
265- # does, so override the PPA's distro here.
266- ubuntutest = getUtility(IDistributionSet)['ubuntutest']
267- self.ppa.distribution = ubuntutest
268-
269- # Enable named auth tokens.
270- self.useFixture(FeatureFixture({NAMED_AUTH_TOKEN_FEATURE_FLAG: "on"}))
271-
272- def getScript(self, test_args=None):
273- """Return a HtaccessTokenGenerator instance."""
274- if test_args is None:
275- test_args = []
276- script = HtaccessTokenGenerator(self.SCRIPT_NAME, test_args=test_args)
277- script.logger = BufferLogger()
278- script.txn = self.layer.txn
279- switch_dbuser(self.dbuser)
280- return script
281-
282- def runScript(self):
283- """Run the expiry script.
284-
285- :return: a tuple of return code, stdout and stderr.
286- """
287- script = os.path.join(
288- config.root, "cronscripts", "generate-ppa-htaccess.py")
289- args = [sys.executable, script, "-v"]
290- process = subprocess.Popen(
291- args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
292- stdout, stderr = process.communicate()
293- return process.returncode, stdout, stderr
294-
295- def assertDeactivated(self, token):
296- """Helper function to test token deactivation state."""
297- return self.assertNotEqual(token.date_deactivated, None)
298-
299- def assertNotDeactivated(self, token):
300- """Helper function to test token deactivation state."""
301- self.assertEqual(token.date_deactivated, None)
302-
303- def setupSubscriptionsAndTokens(self):
304- """Set up a few subscriptions and test tokens and return them."""
305- # Set up some teams. We need to test a few scenarios:
306- # - someone in one subscribed team and leaving that team loses
307- # their token.
308- # - someone in two subscribed teams leaving one team does not
309- # lose their token.
310- # - All members of a team lose their tokens when a team of a
311- # subscribed team leaves it.
312-
313- persons1 = []
314- persons2 = []
315- name12 = getUtility(IPersonSet).getByName("name12")
316- team1 = self.factory.makeTeam(owner=name12)
317- team2 = self.factory.makeTeam(owner=name12)
318- for count in range(5):
319- person = self.factory.makePerson()
320- team1.addMember(person, name12)
321- persons1.append(person)
322- person = self.factory.makePerson()
323- team2.addMember(person, name12)
324- persons2.append(person)
325-
326- all_persons = persons1 + persons2
327-
328- parent_team = self.factory.makeTeam(owner=name12)
329- # This needs to be forced or TeamParticipation is not updated.
330- parent_team.addMember(team2, name12, force_team_add=True)
331-
332- promiscuous_person = self.factory.makePerson()
333- team1.addMember(promiscuous_person, name12)
334- team2.addMember(promiscuous_person, name12)
335- all_persons.append(promiscuous_person)
336-
337- lonely_person = self.factory.makePerson()
338- all_persons.append(lonely_person)
339-
340- # At this point we have team1, with 5 people in it, team2 with 5
341- # people in it, team3 with only team2 in it, promiscuous_person
342- # who is in team1 and team2, and lonely_person who is in no
343- # teams.
344-
345- # Ok now do some subscriptions and ensure everyone has a token.
346- self.ppa.newSubscription(team1, self.ppa.owner)
347- self.ppa.newSubscription(parent_team, self.ppa.owner)
348- self.ppa.newSubscription(lonely_person, self.ppa.owner)
349- tokens = {}
350- for person in all_persons:
351- tokens[person] = self.ppa.newAuthToken(person)
352-
353- return (
354- team1, team2, parent_team, lonely_person,
355- promiscuous_person, all_persons, persons1, persons2, tokens)
356-
357- def testDeactivatingTokens(self):
358- """Test that token deactivation happens properly."""
359- data = self.setupSubscriptionsAndTokens()
360- (team1, team2, parent_team, lonely_person, promiscuous_person,
361- all_persons, persons1, persons2, tokens) = data
362- team1_person = persons1[0]
363-
364- # Named tokens should be ignored for deactivation.
365- self.ppa.newNamedAuthToken("tokenname1")
366- named_token = self.ppa.newNamedAuthToken("tokenname2")
367- named_token.deactivate()
368-
369- # Initially, nothing is eligible for deactivation.
370- script = self.getScript()
371- script.deactivateInvalidTokens()
372- for person in tokens:
373- self.assertNotDeactivated(tokens[person])
374-
375- # Now remove someone from team1. They will lose their token but
376- # everyone else keeps theirs.
377- with lp_dbuser():
378- team1_person.leave(team1)
379- # Clear out emails generated when leaving a team.
380- pop_notifications()
381-
382- script.deactivateInvalidTokens(send_email=True)
383- self.assertDeactivated(tokens[team1_person])
384- del tokens[team1_person]
385- for person in tokens:
386- self.assertNotDeactivated(tokens[person])
387-
388- # Ensure that a cancellation email was sent.
389- self.assertEmailQueueLength(1)
390-
391- # Promiscuous_person now leaves team1, but does not lose their
392- # token because they're also in team2. No other tokens are
393- # affected.
394- with lp_dbuser():
395- promiscuous_person.leave(team1)
396- # Clear out emails generated when leaving a team.
397- pop_notifications()
398- script.deactivateInvalidTokens(send_email=True)
399- self.assertNotDeactivated(tokens[promiscuous_person])
400- for person in tokens:
401- self.assertNotDeactivated(tokens[person])
402-
403- # Ensure that a cancellation email was not sent.
404- self.assertEmailQueueLength(0)
405-
406- # Team 2 now leaves parent_team, and all its members lose their
407- # tokens.
408- with lp_dbuser():
409- name12 = getUtility(IPersonSet).getByName("name12")
410- parent_team.setMembershipData(
411- team2, TeamMembershipStatus.APPROVED, name12)
412- parent_team.setMembershipData(
413- team2, TeamMembershipStatus.DEACTIVATED, name12)
414- self.assertFalse(team2.inTeam(parent_team))
415- script.deactivateInvalidTokens()
416- for person in persons2:
417- self.assertDeactivated(tokens[person])
418-
419- # promiscuous_person also loses the token because they're not in
420- # either team now.
421- self.assertDeactivated(tokens[promiscuous_person])
422-
423- # lonely_person still has their token; they're not in any teams.
424- self.assertNotDeactivated(tokens[lonely_person])
425-
426- def setupDummyTokens(self):
427- """Helper function to set up some tokens."""
428- name12 = getUtility(IPersonSet).getByName("name12")
429- name16 = getUtility(IPersonSet).getByName("name16")
430- sub1 = self.ppa.newSubscription(name12, self.ppa.owner)
431- sub2 = self.ppa.newSubscription(name16, self.ppa.owner)
432- token1 = self.ppa.newAuthToken(name12)
433- token2 = self.ppa.newAuthToken(name16)
434- token3 = self.ppa.newNamedAuthToken("tokenname3")
435- self.layer.txn.commit()
436- return (sub1, sub2), (token1, token2, token3)
437-
438- def testSubscriptionExpiry(self):
439- """Ensure subscriptions' statuses are set to EXPIRED properly."""
440- subs, tokens = self.setupDummyTokens()
441- now = datetime.now(pytz.UTC)
442-
443- # Expire the first subscription.
444- subs[0].date_expires = now - timedelta(minutes=3)
445- self.assertEqual(subs[0].status, ArchiveSubscriberStatus.CURRENT)
446-
447- # Set the expiry in the future for the second.
448- subs[1].date_expires = now + timedelta(minutes=3)
449- self.assertEqual(subs[0].status, ArchiveSubscriberStatus.CURRENT)
450-
451- # Run the script and make sure only the first was expired.
452- script = self.getScript()
453- script.main()
454- self.assertEqual(subs[0].status, ArchiveSubscriberStatus.EXPIRED)
455- self.assertEqual(subs[1].status, ArchiveSubscriberStatus.CURRENT)
456-
457- def _setupOptionsData(self):
458- """Setup test data for option testing."""
459- subs, tokens = self.setupDummyTokens()
460-
461- # Cancel the first subscription.
462- subs[0].cancel(self.ppa.owner)
463- self.assertNotDeactivated(tokens[0])
464- return subs, tokens
465-
466- def testDryrunOption(self):
467- """Test that the dryrun and no-deactivation option works."""
468- subs, tokens = self._setupOptionsData()
469-
470- script = self.getScript(test_args=["--dry-run"])
471- script.main()
472-
473- # Assert that the cancelled subscription did not cause the token
474- # to get deactivated.
475- self.assertNotDeactivated(tokens[0])
476-
477- def testNoDeactivationOption(self):
478- """Test that the --no-deactivation option works."""
479- subs, tokens = self._setupOptionsData()
480- script = self.getScript(test_args=["--no-deactivation"])
481- script.main()
482- self.assertNotDeactivated(tokens[0])
483- script = self.getScript()
484- script.main()
485- self.assertDeactivated(tokens[0])
486-
487- def testSendingCancellationEmail(self):
488- """Test that when a token is deactivated, its user gets an email.
489-
490- The email must contain the right headers and text.
491- """
492- subs, tokens = self.setupDummyTokens()
493- script = self.getScript()
494-
495- # Clear out any existing email.
496- pop_notifications()
497-
498- script.sendCancellationEmail(tokens[0])
499-
500- [email] = pop_notifications()
501- self.assertEqual(
502- email['Subject'],
503- "PPA access cancelled for PPA named myppa for Joe Smith")
504- self.assertEqual(email['To'], "test@canonical.com")
505- self.assertEqual(
506- email['From'],
507- "PPA named myppa for Joe Smith <noreply@launchpad.net>")
508- self.assertEqual(email['Sender'], "bounces@canonical.com")
509-
510- body = email.get_payload()
511- self.assertEqual(
512- body,
513- "Hello Sample Person,\n\n"
514- "Launchpad: cancellation of archive access\n"
515- "-----------------------------------------\n\n"
516- "Your access to the private software archive "
517- "\"PPA named myppa for Joe\nSmith\", "
518- "which is hosted by Launchpad, has been "
519- "cancelled.\n\n"
520- "You will now no longer be able to download software from this "
521- "archive.\n"
522- "If you think this cancellation is in error, you should contact "
523- "the owner\n"
524- "of the archive to verify it.\n\n"
525- "You can contact the archive owner by visiting their Launchpad "
526- "page here:\n\n"
527- "<http://launchpad.test/~joe>\n\n"
528- "If you have any concerns you can contact the Launchpad team by "
529- "emailing\n"
530- "feedback@launchpad.net\n\n"
531- "Regards,\n"
532- "The Launchpad team")
533-
534- def testNoEmailOnCancellationForSuppressedArchive(self):
535- """No email should be sent if the archive has
536- suppress_subscription_notifications set."""
537- subs, tokens = self.setupDummyTokens()
538- token = tokens[0]
539- token.archive.suppress_subscription_notifications = True
540- script = self.getScript()
541-
542- # Clear out any existing email.
543- pop_notifications()
544-
545- script.sendCancellationEmail(token)
546-
547- self.assertEmailQueueLength(0)
548diff --git a/lib/lp/scripts/garbo.py b/lib/lp/scripts/garbo.py
549index 4492911..1d1a8da 100644
550--- a/lib/lp/scripts/garbo.py
551+++ b/lib/lp/scripts/garbo.py
552@@ -36,12 +36,14 @@ import six
553 from storm.expr import (
554 And,
555 Cast,
556+ Except,
557 In,
558 Join,
559 Max,
560 Min,
561 Or,
562 Row,
563+ Select,
564 SQL,
565 )
566 from storm.info import ClassAlias
567@@ -74,19 +76,25 @@ from lp.code.model.revision import (
568 RevisionCache,
569 )
570 from lp.oci.model.ocirecipebuild import OCIFile
571+from lp.registry.interfaces.person import IPersonSet
572 from lp.registry.model.person import Person
573 from lp.registry.model.product import Product
574 from lp.registry.model.sourcepackagename import SourcePackageName
575-from lp.registry.model.teammembership import TeamMembership
576+from lp.registry.model.teammembership import (
577+ TeamMembership,
578+ TeamParticipation,
579+ )
580 from lp.services.config import config
581 from lp.services.database import postgresql
582 from lp.services.database.bulk import (
583 create,
584 dbify_value,
585+ load_related,
586 )
587 from lp.services.database.constants import UTC_NOW
588 from lp.services.database.interfaces import IMasterStore
589 from lp.services.database.sqlbase import (
590+ convert_storm_clause_to_string,
591 cursor,
592 session_store,
593 sqlvalues,
594@@ -109,6 +117,13 @@ from lp.services.job.model.job import Job
595 from lp.services.librarian.model import TimeLimitedToken
596 from lp.services.log.logger import PrefixFilter
597 from lp.services.looptuner import TunableLoop
598+from lp.services.mail.helpers import get_email_template
599+from lp.services.mail.mailwrapper import MailWrapper
600+from lp.services.mail.sendmail import (
601+ format_address,
602+ set_immediate_mail_delivery,
603+ simple_sendmail,
604+ )
605 from lp.services.openid.model.openidconsumer import OpenIDConsumerNonce
606 from lp.services.propertycache import cachedproperty
607 from lp.services.scripts.base import (
608@@ -118,12 +133,16 @@ from lp.services.scripts.base import (
609 )
610 from lp.services.session.model import SessionData
611 from lp.services.verification.model.logintoken import LoginToken
612+from lp.services.webapp.publisher import canonical_url
613 from lp.services.webhooks.interfaces import IWebhookJobSource
614 from lp.services.webhooks.model import WebhookJob
615 from lp.snappy.model.snapbuild import SnapFile
616 from lp.snappy.model.snapbuildjob import SnapBuildJobType
617+from lp.soyuz.enums import ArchiveSubscriberStatus
618 from lp.soyuz.interfaces.publishing import active_publishing_status
619 from lp.soyuz.model.archive import Archive
620+from lp.soyuz.model.archiveauthtoken import ArchiveAuthToken
621+from lp.soyuz.model.archivesubscriber import ArchiveSubscriber
622 from lp.soyuz.model.distributionsourcepackagecache import (
623 DistributionSourcePackageCache,
624 )
625@@ -1556,6 +1575,150 @@ class GitRepositoryPruner(TunableLoop):
626 transaction.commit()
627
628
629+class ArchiveSubscriptionExpirer(BulkPruner):
630+ """Expire archive subscriptions as necessary.
631+
632+ If an `ArchiveSubscriber`'s date_expires has passed, then set its status
633+ to EXPIRED.
634+ """
635+ target_table_class = ArchiveSubscriber
636+
637+ ids_to_prune_query = convert_storm_clause_to_string(Select(
638+ ArchiveSubscriber.id,
639+ where=And(
640+ ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT,
641+ ArchiveSubscriber.date_expires != None,
642+ ArchiveSubscriber.date_expires <= UTC_NOW)))
643+
644+ maximum_chunk_size = 1000
645+
646+ _num_removed = None
647+
648+ def __call__(self, chunk_size):
649+ """See `ITunableLoop`."""
650+ chunk_size = int(chunk_size + 0.5)
651+ newly_expired_subscriptions = list(self.store.find(
652+ ArchiveSubscriber,
653+ ArchiveSubscriber.id.is_in(SQL(
654+ "SELECT * FROM cursor_fetch(%s, %s) AS f(id integer)",
655+ params=(self.cursor_name, chunk_size)))))
656+ load_related(Archive, newly_expired_subscriptions, ["archive_id"])
657+ load_related(Person, newly_expired_subscriptions, ["subscriber_id"])
658+ subscription_names = [
659+ sub.displayname for sub in newly_expired_subscriptions]
660+ if subscription_names:
661+ self.store.find(
662+ ArchiveSubscriber,
663+ ArchiveSubscriber.id.is_in(
664+ [sub.id for sub in newly_expired_subscriptions]),
665+ ).set(status=ArchiveSubscriberStatus.EXPIRED)
666+ self.log.info(
667+ "Expired subscriptions: %s" % ", ".join(subscription_names))
668+ self._num_removed = len(subscription_names)
669+ transaction.commit()
670+
671+
672+class ArchiveAuthTokenDeactivator(BulkPruner):
673+ """Deactivate archive auth tokens as necessary.
674+
675+ If an active token for a PPA no longer has any subscribers, we
676+ deactivate the token, and send an email to the person whose subscription
677+ was cancelled.
678+ """
679+ target_table_class = ArchiveAuthToken
680+
681+ # A token is invalid if it is active and the token owner is *not* a
682+ # subscriber to the archive that the token is for. The subscription can
683+ # be either direct or through a team.
684+ ids_to_prune_query = convert_storm_clause_to_string(Except(
685+ # All valid tokens.
686+ Select(
687+ ArchiveAuthToken.id, tables=[ArchiveAuthToken],
688+ where=And(
689+ ArchiveAuthToken.name == None,
690+ ArchiveAuthToken.date_deactivated == None)),
691+ # Active tokens for which there is a matching current archive
692+ # subscription for a team of which the token owner is a member.
693+ # Removing these from the set of all valid tokens leaves only the
694+ # invalid tokens.
695+ Select(
696+ ArchiveAuthToken.id,
697+ tables=[ArchiveAuthToken, ArchiveSubscriber, TeamParticipation],
698+ where=And(
699+ ArchiveAuthToken.name == None,
700+ ArchiveAuthToken.date_deactivated == None,
701+ ArchiveAuthToken.archive_id == ArchiveSubscriber.archive_id,
702+ ArchiveSubscriber.status == ArchiveSubscriberStatus.CURRENT,
703+ ArchiveSubscriber.subscriber_id == TeamParticipation.teamID,
704+ TeamParticipation.personID == ArchiveAuthToken.person_id))))
705+
706+ maximum_chunk_size = 10
707+
708+ def _sendCancellationEmail(self, token):
709+ """Send an email to the person whose subscription was cancelled."""
710+ if token.archive.suppress_subscription_notifications:
711+ # Don't send an email if they should be suppressed for the
712+ # archive.
713+ return
714+ send_to_person = token.person
715+ ppa_name = token.archive.displayname
716+ ppa_owner_url = canonical_url(token.archive.owner)
717+ subject = "PPA access cancelled for %s" % ppa_name
718+ template = get_email_template(
719+ "ppa-subscription-cancelled.txt", app="soyuz")
720+
721+ if send_to_person.is_team:
722+ raise AssertionError(
723+ "Token.person is a team, it should always be individuals.")
724+
725+ if send_to_person.preferredemail is None:
726+ # The person has no preferred email set, so we don't email them.
727+ return
728+
729+ to_address = [send_to_person.preferredemail.email]
730+ replacements = {
731+ "recipient_name": send_to_person.display_name,
732+ "ppa_name": ppa_name,
733+ "ppa_owner_url": ppa_owner_url,
734+ }
735+ body = MailWrapper(72).format(
736+ template % replacements, force_wrap=True)
737+
738+ from_address = format_address(
739+ ppa_name,
740+ config.canonical.noreply_from_address)
741+
742+ headers = {
743+ "Sender": config.canonical.bounce_address,
744+ }
745+
746+ simple_sendmail(from_address, to_address, subject, body, headers)
747+
748+ def __call__(self, chunk_size):
749+ """See `ITunableLoop`."""
750+ chunk_size = int(chunk_size + 0.5)
751+ tokens = list(self.store.find(
752+ ArchiveAuthToken,
753+ ArchiveAuthToken.id.is_in(SQL(
754+ "SELECT * FROM cursor_fetch(%s, %s) AS f(id integer)",
755+ params=(self.cursor_name, chunk_size)))))
756+ affected_ppas = load_related(Archive, tokens, ["archive_id"])
757+ load_related(Person, affected_ppas, ["ownerID"])
758+ getUtility(IPersonSet).getPrecachedPersonsFromIDs(
759+ [token.person_id for token in tokens], need_preferred_email=True)
760+ for token in tokens:
761+ self._sendCancellationEmail(token)
762+ self.store.find(
763+ ArchiveAuthToken,
764+ ArchiveAuthToken.id.is_in([token.id for token in tokens]),
765+ ).set(date_deactivated=UTC_NOW)
766+ self.log.info(
767+ "Deactivated %s tokens, %s PPAs affected" %
768+ (len(tokens), len(affected_ppas)))
769+ self._num_removed = len(tokens)
770+ transaction.commit()
771+
772+
773 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
774 """Abstract base class to run a collection of TunableLoops."""
775 script_name = None # Script name for locking and database user. Override.
776@@ -1600,6 +1763,10 @@ class BaseDatabaseGarbageCollector(LaunchpadCronScript):
777 def main(self):
778 self.start_time = time.time()
779
780+ # Any email we send can safely be queued until the transaction is
781+ # committed.
782+ set_immediate_mail_delivery(False)
783+
784 # Stores the number of failed tasks.
785 self.failure_count = 0
786
787@@ -1783,6 +1950,7 @@ class FrequentDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
788 script_name = 'garbo-frequently'
789 tunable_loops = [
790 AntiqueSessionPruner,
791+ ArchiveSubscriptionExpirer,
792 BugSummaryJournalRollup,
793 BugWatchScheduler,
794 OpenIDConsumerAssociationPruner,
795@@ -1805,6 +1973,7 @@ class HourlyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
796 """
797 script_name = 'garbo-hourly'
798 tunable_loops = [
799+ ArchiveAuthTokenDeactivator,
800 BugHeatUpdater,
801 DuplicateSessionPruner,
802 GitRepositoryPruner,
803diff --git a/lib/lp/scripts/tests/test_garbo.py b/lib/lp/scripts/tests/test_garbo.py
804index 2b5fa34..fc7fb34 100644
805--- a/lib/lp/scripts/tests/test_garbo.py
806+++ b/lib/lp/scripts/tests/test_garbo.py
807@@ -12,8 +12,11 @@ from datetime import (
808 datetime,
809 timedelta,
810 )
811+from functools import partial
812 import hashlib
813 import logging
814+import re
815+from textwrap import dedent
816 import time
817
818 from pytz import UTC
819@@ -33,6 +36,7 @@ from storm.store import Store
820 from testtools.content import text_content
821 from testtools.matchers import (
822 AfterPreprocessing,
823+ ContainsDict,
824 Equals,
825 GreaterThan,
826 Is,
827@@ -133,7 +137,11 @@ from lp.snappy.model.snapbuildjob import (
828 SnapBuildJob,
829 SnapStoreUploadJob,
830 )
831-from lp.soyuz.enums import PackagePublishingStatus
832+from lp.soyuz.enums import (
833+ ArchiveSubscriberStatus,
834+ PackagePublishingStatus,
835+ )
836+from lp.soyuz.interfaces.archive import NAMED_AUTH_TOKEN_FEATURE_FLAG
837 from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG
838 from lp.soyuz.model.distributionsourcepackagecache import (
839 DistributionSourcePackageCache,
840@@ -154,6 +162,7 @@ from lp.testing.layers import (
841 LaunchpadZopelessLayer,
842 ZopelessDatabaseLayer,
843 )
844+from lp.testing.mail_helpers import pop_notifications
845 from lp.translations.model.pofile import POFile
846 from lp.translations.model.potmsgset import POTMsgSet
847 from lp.translations.model.translationtemplateitem import (
848@@ -1739,6 +1748,238 @@ class TestGarbo(FakeAdapterMixin, TestCaseWithFactory):
849 # retained.
850 self._test_SnapFilePruner('foo.snap', None, 7, expected_count=1)
851
852+ def test_ArchiveSubscriptionExpirer_expires_subscriptions(self):
853+ # Archive subscriptions with expiry dates in the past have their
854+ # statuses set to EXPIRED.
855+ switch_dbuser('testadmin')
856+ ppa = self.factory.makeArchive(private=True)
857+ subs = [
858+ ppa.newSubscription(self.factory.makePerson(), ppa.owner)
859+ for _ in range(2)]
860+ now = datetime.now(UTC)
861+ subs[0].date_expires = now - timedelta(minutes=3)
862+ self.assertEqual(ArchiveSubscriberStatus.CURRENT, subs[0].status)
863+ subs[1].date_expires = now + timedelta(minutes=3)
864+ self.assertEqual(ArchiveSubscriberStatus.CURRENT, subs[1].status)
865+ Store.of(subs[0]).flush()
866+
867+ self.runFrequently()
868+
869+ switch_dbuser('testadmin')
870+ self.assertEqual(ArchiveSubscriberStatus.EXPIRED, subs[0].status)
871+ self.assertEqual(ArchiveSubscriberStatus.CURRENT, subs[1].status)
872+
873+ def test_ArchiveAuthTokenDeactivator_ignores_named_tokens(self):
874+ switch_dbuser('testadmin')
875+ self.useFixture(FeatureFixture({NAMED_AUTH_TOKEN_FEATURE_FLAG: 'on'}))
876+ ppa = self.factory.makeArchive(private=True)
877+ named_tokens = [
878+ ppa.newNamedAuthToken(self.factory.getUniqueUnicode())
879+ for _ in range(2)]
880+ named_tokens[1].deactivate()
881+
882+ self.runHourly()
883+
884+ switch_dbuser('testadmin')
885+ self.assertIsNone(named_tokens[0].date_deactivated)
886+
887+ def test_ArchiveAuthTokenDeactivator_leave_subscribed_team(self):
888+ # Somebody who leaves a subscribed team loses their token, but other
889+ # team members keep theirs.
890+ switch_dbuser('testadmin')
891+ ppa = self.factory.makeArchive(private=True)
892+ team = self.factory.makeTeam()
893+ people = []
894+ for _ in range(3):
895+ person = self.factory.makePerson()
896+ team.addMember(person, team.teamowner)
897+ people.append(person)
898+ ppa.newSubscription(team, ppa.owner)
899+ tokens = {person: ppa.newAuthToken(person) for person in people}
900+
901+ self.runHourly()
902+ switch_dbuser('testadmin')
903+ for person in tokens:
904+ self.assertIsNone(tokens[person].date_deactivated)
905+
906+ people[0].leave(team)
907+ # Clear out emails generated when leaving a team.
908+ pop_notifications()
909+
910+ self.runHourly()
911+ switch_dbuser('testadmin')
912+ self.assertIsNotNone(tokens[people[0]].date_deactivated)
913+ del tokens[people[0]]
914+ for person in tokens:
915+ self.assertIsNone(tokens[person].date_deactivated)
916+
917+ # A cancellation email was sent.
918+ self.assertEmailQueueLength(1)
919+
920+ def test_ArchiveAuthTokenDeactivator_leave_only_one_subscribed_team(self):
921+ # Somebody who leaves a subscribed team retains their token if they
922+ # are still subscribed via another team.
923+ switch_dbuser('testadmin')
924+ ppa = self.factory.makeArchive(private=True)
925+ teams = [self.factory.makeTeam() for _ in range(2)]
926+ people = []
927+ for _ in range(3):
928+ for team in teams:
929+ person = self.factory.makePerson()
930+ team.addMember(person, team.teamowner)
931+ people.append(person)
932+ parent_team = self.factory.makeTeam()
933+ parent_team.addMember(
934+ teams[1], parent_team.teamowner, force_team_add=True)
935+ multiple_teams_person = self.factory.makePerson()
936+ for team in teams:
937+ team.addMember(multiple_teams_person, team.teamowner)
938+ people.append(multiple_teams_person)
939+ ppa.newSubscription(teams[0], ppa.owner)
940+ ppa.newSubscription(parent_team, ppa.owner)
941+ tokens = {person: ppa.newAuthToken(person) for person in people}
942+
943+ self.runHourly()
944+ switch_dbuser('testadmin')
945+ for person in tokens:
946+ self.assertIsNone(tokens[person].date_deactivated)
947+
948+ multiple_teams_person.leave(teams[0])
949+ # Clear out emails generated when leaving a team.
950+ pop_notifications()
951+
952+ self.runHourly()
953+ switch_dbuser('testadmin')
954+ self.assertIsNone(tokens[multiple_teams_person].date_deactivated)
955+ for person in tokens:
956+ self.assertIsNone(tokens[person].date_deactivated)
957+
958+ # A cancellation email was not sent.
959+ self.assertEmailQueueLength(0)
960+
961+ def test_ArchiveAuthTokenDeactivator_leave_indirect_subscription(self):
962+ # Members of a team that leaves a subscribed parent team lose their
963+ # tokens.
964+ switch_dbuser('testadmin')
965+ ppa = self.factory.makeArchive(private=True)
966+ child_team = self.factory.makeTeam()
967+ people = []
968+ for _ in range(3):
969+ person = self.factory.makePerson()
970+ child_team.addMember(person, child_team.teamowner)
971+ people.append(person)
972+ parent_team = self.factory.makeTeam()
973+ parent_team.addMember(
974+ child_team, parent_team.teamowner, force_team_add=True)
975+ directly_subscribed_person = self.factory.makePerson()
976+ people.append(directly_subscribed_person)
977+ ppa.newSubscription(parent_team, ppa.owner)
978+ ppa.newSubscription(directly_subscribed_person, ppa.owner)
979+ tokens = {person: ppa.newAuthToken(person) for person in people}
980+
981+ self.runHourly()
982+ switch_dbuser('testadmin')
983+ for person in tokens:
984+ self.assertIsNone(tokens[person].date_deactivated)
985+
986+ # child_team now leaves parent_team, and all its members lose their
987+ # tokens.
988+ parent_team.setMembershipData(
989+ child_team, TeamMembershipStatus.APPROVED, parent_team.teamowner)
990+ parent_team.setMembershipData(
991+ child_team, TeamMembershipStatus.DEACTIVATED,
992+ parent_team.teamowner)
993+ self.assertFalse(child_team.inTeam(parent_team))
994+ self.runHourly()
995+ switch_dbuser('testadmin')
996+ for person in people[:3]:
997+ self.assertIsNotNone(tokens[person].date_deactivated)
998+
999+ # directly_subscribed_person still has their token; they're not in
1000+ # any teams.
1001+ self.assertIsNone(tokens[directly_subscribed_person].date_deactivated)
1002+
1003+ def test_ArchiveAuthTokenDeactivator_cancellation_email(self):
1004+ # When a token is deactivated, its user gets an email, which
1005+ # contains the correct headers and body.
1006+ switch_dbuser('testadmin')
1007+ # Avoid hyphens in owner and archive names; while these work fine,
1008+ # MailWrapper may wrap at hyphens, which makes it inconvenient to
1009+ # write a precise test for the email body text.
1010+ owner = self.factory.makePerson(
1011+ name='someperson', displayname='Some Person')
1012+ ppa = self.factory.makeArchive(
1013+ owner=owner, name='someppa', private=True)
1014+ subscriber = self.factory.makePerson()
1015+ subscription = ppa.newSubscription(subscriber, owner)
1016+ token = ppa.newAuthToken(subscriber)
1017+ subscription.cancel(owner)
1018+ pop_notifications()
1019+
1020+ self.runHourly()
1021+
1022+ switch_dbuser('testadmin')
1023+ self.assertIsNotNone(token.date_deactivated)
1024+ [email] = pop_notifications()
1025+ self.assertThat(dict(email), ContainsDict({
1026+ 'From': AfterPreprocessing(
1027+ partial(re.sub, r'\n[\t ]', ' '),
1028+ Equals('%s <noreply@launchpad.net>' % ppa.displayname)),
1029+ 'To': Equals(subscriber.preferredemail.email),
1030+ 'Subject': AfterPreprocessing(
1031+ partial(re.sub, r'\n[\t ]', ' '),
1032+ Equals('PPA access cancelled for %s' % ppa.displayname)),
1033+ 'Sender': Equals('bounces@canonical.com'),
1034+ }))
1035+ expected_body = dedent("""\
1036+ Hello {subscriber},
1037+
1038+ Launchpad: cancellation of archive access
1039+ -----------------------------------------
1040+
1041+ Your access to the private software archive "{archive}", which
1042+ is hosted by Launchpad, has been cancelled.
1043+
1044+ You will now no longer be able to download software from this
1045+ archive. If you think this cancellation is in error, you should
1046+ contact the owner of the archive to verify it.
1047+
1048+ You can contact the archive owner by visiting their Launchpad
1049+ page here:
1050+
1051+ <http://launchpad\\.test/~{archive_owner_name}>
1052+
1053+ If you have any concerns you can contact the Launchpad team by
1054+ emailing feedback@launchpad\\.net
1055+
1056+
1057+ Regards,
1058+ The Launchpad team
1059+ """).format(
1060+ subscriber=subscriber.display_name,
1061+ archive=ppa.displayname,
1062+ archive_owner_name=owner.name)
1063+ self.assertTextMatchesExpressionIgnoreWhitespace(
1064+ expected_body, email.get_payload())
1065+
1066+ def test_ArchiveAuthTokenDeactivator_suppressed_archive(self):
1067+ # When a token is deactivated for an archive with
1068+ # suppress_subscription_notifications set, no email is sent.
1069+ switch_dbuser('testadmin')
1070+ ppa = self.factory.makeArchive(
1071+ private=True, suppress_subscription_notifications=True)
1072+ subscriber = self.factory.makePerson()
1073+ subscription = ppa.newSubscription(subscriber, ppa.owner)
1074+ token = ppa.newAuthToken(subscriber)
1075+ subscription.cancel(ppa.owner)
1076+ pop_notifications()
1077+
1078+ self.runHourly()
1079+
1080+ switch_dbuser('testadmin')
1081+ self.assertIsNotNone(token.date_deactivated)
1082+ self.assertEmailQueueLength(0)
1083+
1084
1085 class TestGarboTasks(TestCaseWithFactory):
1086 layer = LaunchpadZopelessLayer

Subscribers

People subscribed via source and target branches

to status/vote changes: