Merge lp:~doanac/ubuntu-ci-services-itself/ppa-cleaner into lp:ubuntu-ci-services-itself
- ppa-cleaner
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Andy Doan |
Approved revision: | no longer in the source branch. |
Merged at revision: | 47 |
Proposed branch: | lp:~doanac/ubuntu-ci-services-itself/ppa-cleaner |
Merge into: | lp:ubuntu-ci-services-itself |
Diff against target: |
680 lines (+349/-129) 8 files modified
ppa-assigner/local_settings.py.example (+5/-0) ppa-assigner/ppa_assigner/api.py (+21/-7) ppa-assigner/ppa_assigner/launchpad.py (+87/-0) ppa-assigner/ppa_assigner/management/commands/clean_ppas.py (+79/-0) ppa-assigner/ppa_assigner/models.py (+34/-51) ppa-assigner/ppa_assigner/settings.py (+6/-0) ppa-assigner/ppa_assigner/tests.py (+116/-71) ppa-assigner/setup.py (+1/-0) |
To merge this branch: | bzr merge lp:~doanac/ubuntu-ci-services-itself/ppa-cleaner |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Andy Doan (community) | Approve | ||
Vincent Ladeuil (community) | Needs Fixing | ||
Review via email: mp+198637@code.launchpad.net |
Commit message
Add ppa-cleaning management command to the ppa-assigner
Description of the change
This adds ppa-cleaning logic to the PPA-assigner via a django management command. In the future, we'll update our charm so that this command is run via upstart.
The main work was done in ppa-assigner/
Andy Doan (doanac) wrote : | # |
On 12/16/2013 07:34 AM, Vincent Ladeuil wrote:
> Review: Needs Fixing
>
> 21 +# Read for details: https:/
> 22 +OAUTH_CONSUMER_KEY = 'TODO'
> 23 +OAUTH_TOKEN = 'TODO'
> 24 +OAUTH_TOKEN_SECRET = 'TODO'
> 25 +
>
> Not sure how this relates to your other MP, I'll consider you've addressed the TODO there for this review.
TODO's are filler. Our juju-deployer configs will override this during
deployment.
> 60 - lockname = bundle.
>
> \o/ no more lock ;)
Good catch, revno 25
> 79 + if type(state) != int:
>
> O_o can you add a comment to explain ? As I read it it means we'll be ignoring some requests. I'd rather error out unless you have a strong reason to do so... which will make a perfect comment ;)
good catch, revno 26
> 118 +def lp_collection(url):
>
> You probably want to talk to wgrant about that. If I remember correctly, if the collection is big/huge, you'll run into timeouts and will need to make several requests to acquire parts of the collection.
>
> So better check with wgrant first if you're under a reasonable size for the collection or if you already need to handle chunks.
No issue here. Launchpad returns these back in chunks of items. This
function deal with grabbing those chunks and returning them back
one-by-one so that we don't get overloaded.
> 138 + 'OAuth realm="https:/
> 139 + 'oauth_
> 140 + 'oauth_
> 141 + 'oauth_token="%s"' % settings.
> 142 + 'oauth_
> 143 + 'oauth_
> 144 + 'oauth_
> 145 + 'oauth_nonce="%s"' % oauth.generate_
>
> Great, we already have 3 settings parametrized, I think we also want OAuth realm so we can test against staging or a local launchpad.
revno 27
> 161 + url = 'https:/
>
> That one probably needs to be parametrized in the same way.
revno: 28
>
> 249 + logging.
>
> Better add the exception encountered if only to start learning about which ones exist.
>
> 266 + logging.
>
good catch - revno 29
>
> 354 - # TODO test if this is needed or if there is some type
> 355 - # of looping logic needed
>
> Yeah, see above, not sure you're talking about the same looping logic but that matches what I'm talking about ;)
as noted. this MP actually makes this stuff work properly.
> 364 logging.
>
> Again, mention the exception, the logs are for us and we want precise info ;)
no - good catch. i was thinking logging.exception automatically included
the stack trace.
However - in this case i'm re-raising the exception. So I thought the
caller should log the exception. I was concerned both people would log
the stack trace and it would make it 2x longer to read than needed.
I'll go ahead and log it here, because I don't think my rationale is
strong enough :)
> By just reading the proposal, this seems better than the previous versi...
Chris Johnston (cjohnston) wrote : | # |
Attempt to merge into lp:ubuntu-ci-services-itself failed due to conflicts:
text conflict in juju-deployer/
Chris Johnston (cjohnston) wrote : | # |
Voting does not meet specified criteria. Required: Approve >= 1. Got: 1 Needs Fixing.
Andy Doan (doanac) wrote : | # |
+1 based on vila's review comment
Preview Diff
1 | === modified file 'ppa-assigner/local_settings.py.example' |
2 | --- ppa-assigner/local_settings.py.example 2013-11-26 20:55:48 +0000 |
3 | +++ ppa-assigner/local_settings.py.example 2013-12-18 18:24:41 +0000 |
4 | @@ -19,6 +19,11 @@ |
5 | |
6 | LAUNCHPAD_PPA_USER = 'TODO' |
7 | |
8 | +# Read for details: https://help.launchpad.net/API/SigningRequests |
9 | +OAUTH_CONSUMER_KEY = 'TODO' |
10 | +OAUTH_TOKEN = 'TODO' |
11 | +OAUTH_TOKEN_SECRET = 'TODO' |
12 | + |
13 | BASEDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) |
14 | |
15 | DATABASES = { |
16 | |
17 | === modified file 'ppa-assigner/ppa_assigner/api.py' |
18 | --- ppa-assigner/ppa_assigner/api.py 2013-11-22 17:47:12 +0000 |
19 | +++ ppa-assigner/ppa_assigner/api.py 2013-12-18 18:24:41 +0000 |
20 | @@ -13,11 +13,13 @@ |
21 | # You should have received a copy of the GNU Affero General Public License |
22 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
23 | |
24 | +import urllib2 |
25 | + |
26 | from tastypie.api import Api |
27 | from tastypie.http import HttpAccepted, HttpApplicationError |
28 | from tastypie.resources import ALL |
29 | |
30 | -from ppa_assigner.models import PPA, LaunchpadError |
31 | +from ppa_assigner.models import PPA |
32 | |
33 | from ci_utils.tastypie.resource import AuthorizedResource |
34 | |
35 | @@ -28,15 +30,15 @@ |
36 | resource_name = 'ppa' |
37 | |
38 | filtering = { |
39 | - 'reserved': ALL, |
40 | - 'lockname': ALL, |
41 | + 'state': ALL, |
42 | + 'ticket_id': ALL, |
43 | + 'timestamp': ALL, |
44 | } |
45 | |
46 | def obj_create(self, bundle, **kwargs): |
47 | '''We don't really create an object, so override tastypie.''' |
48 | - lockname = bundle.data.get('lockname') |
49 | - clean = bundle.data.get('clean') |
50 | - bundle.obj = PPA.reserve(lockname, clean) |
51 | + ticket_id = bundle.data.get('ticket_id') |
52 | + bundle.obj = PPA.reserve(ticket_id) |
53 | return bundle |
54 | |
55 | def patch_list(self, request, **kwargs): |
56 | @@ -45,10 +47,22 @@ |
57 | # populate is a bit different than normal "patch" operations |
58 | PPA.populate_from_launchpad() |
59 | return HttpAccepted() |
60 | - except LaunchpadError as e: |
61 | + except urllib2.URLError as e: |
62 | return HttpApplicationError(e) |
63 | return super(PPAResource, self).patch_list(request, **kwargs) |
64 | |
65 | + def hydrate_state(self, bundle): |
66 | + state = bundle.data['state'] |
67 | + # tastypie by default will only let us refer to the state by their |
68 | + # numeric value. This is awkward for users, so this lets a user |
69 | + # specify something like "dirty" rather than 20. |
70 | + if type(state) != int: |
71 | + for val, label in PPA.STATES: |
72 | + if state == label: |
73 | + bundle.data['state'] = val |
74 | + break |
75 | + return bundle |
76 | + |
77 | |
78 | v1_api = Api(api_name='v1') |
79 | v1_api.register(PPAResource()) |
80 | |
81 | === added file 'ppa-assigner/ppa_assigner/launchpad.py' |
82 | --- ppa-assigner/ppa_assigner/launchpad.py 1970-01-01 00:00:00 +0000 |
83 | +++ ppa-assigner/ppa_assigner/launchpad.py 2013-12-18 18:24:41 +0000 |
84 | @@ -0,0 +1,87 @@ |
85 | +# Ubuntu CI Services |
86 | +# Copyright 2012-2013 Canonical Ltd. |
87 | + |
88 | +# This program is free software: you can redistribute it and/or modify it |
89 | +# under the terms of the GNU Affero General Public License version 3, as |
90 | +# published by the Free Software Foundation. |
91 | + |
92 | +# This program is distributed in the hope that it will be useful, but |
93 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
94 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
95 | +# PURPOSE. See the GNU Affero General Public License for more details. |
96 | + |
97 | +# You should have received a copy of the GNU Affero General Public License |
98 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
99 | + |
100 | +import json |
101 | +import logging |
102 | +import urllib |
103 | +import urllib2 |
104 | + |
105 | +from django.conf import settings |
106 | +from oauth import oauth |
107 | + |
108 | + |
109 | +def lp_collection(url): |
110 | + '''iterate through each item returned in an lp_collection''' |
111 | + resp = urllib2.urlopen(url) |
112 | + resp = resp.read() |
113 | + data = json.loads(resp) |
114 | + |
115 | + for d in data['entries']: |
116 | + yield d |
117 | + |
118 | + if 'next_collection_link' in data: |
119 | + for d in lp_collection(data['next_collection_link']): |
120 | + yield d |
121 | + |
122 | + |
123 | +def _oauth_headers(): |
124 | + '''oauth header required for a launchpad api post |
125 | + |
126 | + https://help.launchpad.net/API/SigningRequests |
127 | + ''' |
128 | + values = [ |
129 | + 'OAuth realm="%s"' % settings.OAUTH_REALM, |
130 | + 'oauth_version="1.0"', |
131 | + 'oauth_consumer_key="%s"' % settings.OAUTH_CONSUMER_KEY, |
132 | + 'oauth_token="%s"' % settings.OAUTH_TOKEN, |
133 | + 'oauth_signature_method="PLAINTEXT"', |
134 | + 'oauth_signature="%%26%s"' % settings.OAUTH_TOKEN_SECRET, |
135 | + 'oauth_timestamp="%d"' % int(oauth.time.time()), |
136 | + 'oauth_nonce="%s"' % oauth.generate_nonce(), |
137 | + ] |
138 | + return { |
139 | + 'Authorization': ', '.join(values), |
140 | + 'Accept': 'application/json', |
141 | + 'Content-Type': 'application/x-www-form-urlencoded' |
142 | + } |
143 | + |
144 | + |
145 | +def _lp_post(url, data): |
146 | + req = urllib2.Request(url, urllib.urlencode(data), _oauth_headers()) |
147 | + return urllib2.urlopen(req) |
148 | + |
149 | + |
150 | +def get_publishing_history(ppa): |
151 | + '''A generator for the source and binary publish history of a ppa.''' |
152 | + url = settings.LAUNCHPAD_API_BASE + '/~%s/+archive/%s/?ws.op=' |
153 | + url = url % (settings.LAUNCHPAD_PPA_USER, ppa.split('/')[-1]) |
154 | + |
155 | + for x in lp_collection(url + 'getPublishedSources'): |
156 | + yield x |
157 | + for x in lp_collection(url + 'getPublishedBinaries'): |
158 | + yield x |
159 | + |
160 | + |
161 | +def request_ppa_clean(ppa): |
162 | + '''call requestDeletion operation on ppa's source and binary pub history''' |
163 | + for x in get_publishing_history(ppa): |
164 | + if x['date_removed']: |
165 | + continue |
166 | + logging.debug('requesting deletion of: %s', x['self_link']) |
167 | + resp = _lp_post(x['self_link'], {'ws.op': 'requestDeletion'}) |
168 | + code = resp.getcode() |
169 | + if code != 200: |
170 | + raise urllib2.HTTPError( |
171 | + x['self_link'], code, resp.read(), resp.headers.headers, None) |
172 | |
173 | === added directory 'ppa-assigner/ppa_assigner/management' |
174 | === added file 'ppa-assigner/ppa_assigner/management/__init__.py' |
175 | === added directory 'ppa-assigner/ppa_assigner/management/commands' |
176 | === added file 'ppa-assigner/ppa_assigner/management/commands/__init__.py' |
177 | === added file 'ppa-assigner/ppa_assigner/management/commands/clean_ppas.py' |
178 | --- ppa-assigner/ppa_assigner/management/commands/clean_ppas.py 1970-01-01 00:00:00 +0000 |
179 | +++ ppa-assigner/ppa_assigner/management/commands/clean_ppas.py 2013-12-18 18:24:41 +0000 |
180 | @@ -0,0 +1,79 @@ |
181 | +# Ubuntu CI Services |
182 | +# Copyright 2012-2013 Canonical Ltd. |
183 | + |
184 | +# This program is free software: you can redistribute it and/or modify it |
185 | +# under the terms of the GNU Affero General Public License version 3, as |
186 | +# published by the Free Software Foundation. |
187 | + |
188 | +# This program is distributed in the hope that it will be useful, but |
189 | +# WITHOUT ANY WARRANTY; without even the implied warranties of |
190 | +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR |
191 | +# PURPOSE. See the GNU Affero General Public License for more details. |
192 | + |
193 | +# You should have received a copy of the GNU Affero General Public License |
194 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
195 | + |
196 | +import logging |
197 | +import time |
198 | + |
199 | +from optparse import make_option |
200 | + |
201 | +from django.core.management.base import BaseCommand |
202 | + |
203 | +from ppa_assigner import launchpad |
204 | +from ppa_assigner.models import PPA |
205 | + |
206 | + |
207 | +class Command(BaseCommand): |
208 | + help = 'Runs forever to take dirty PPAs and get them ready for re-use.' |
209 | + option_list = BaseCommand.option_list + ( |
210 | + make_option('--interval', dest='interval', default=10, type=int, |
211 | + help='Interval to sleep between checks.'), |
212 | + ) |
213 | + |
214 | + # TODO check for oauth before launching |
215 | + |
216 | + def handle(self, *args, **options): |
217 | + level = logging.INFO |
218 | + if int(options['verbosity']) > 1: |
219 | + level = logging.DEBUG |
220 | + logging.basicConfig(level=level) |
221 | + |
222 | + interval = options['interval'] |
223 | + try: |
224 | + while True: |
225 | + self.dirty_to_cleaning() |
226 | + self.cleaning_to_available() |
227 | + time.sleep(interval) |
228 | + except KeyboardInterrupt: |
229 | + print('command cancelled by user') |
230 | + |
231 | + def dirty_to_cleaning(self): |
232 | + logging.debug('Check for PPAs that need to be cleaned...') |
233 | + qs = PPA.objects.select_for_update().filter(state=PPA.DIRTY) |
234 | + if qs.count(): |
235 | + logging.info('Requesting launchpad clean: %s', qs[0].name) |
236 | + try: |
237 | + launchpad.request_ppa_clean(qs[0].name) |
238 | + qs.filter(pk__in=(qs[0].pk,)).update(state=PPA.CLEANING) |
239 | + except Exception as e: |
240 | + logging.exception('unable to request clean ppa, will retry', e) |
241 | + |
242 | + def cleaning_to_available(self): |
243 | + logging.debug('Check for PPAs that are avialable...') |
244 | + qs = PPA.objects.select_for_update().filter(state=PPA.CLEANING) |
245 | + if qs.count(): |
246 | + if self._lp_is_clean(qs[0]): |
247 | + logging.info('ppa is available for use: %s', qs[0].name) |
248 | + qs.filter(pk__in=(qs[0].pk,)).update(state=PPA.AVAILABLE) |
249 | + |
250 | + def _lp_is_clean(self, ppa): |
251 | + logging.debug('Checking if ppa is ready: %s', ppa.name) |
252 | + try: |
253 | + for x in launchpad.get_publishing_history(ppa.name): |
254 | + if not x['date_removed']: |
255 | + return False |
256 | + except Exception as e: |
257 | + logging.exception('error checking if ppa is clean', e) |
258 | + return False |
259 | + return True |
260 | |
261 | === modified file 'ppa-assigner/ppa_assigner/models.py' |
262 | --- ppa-assigner/ppa_assigner/models.py 2013-11-26 20:55:48 +0000 |
263 | +++ ppa-assigner/ppa_assigner/models.py 2013-12-18 18:24:41 +0000 |
264 | @@ -14,36 +14,43 @@ |
265 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
266 | |
267 | import datetime |
268 | -import json |
269 | import logging |
270 | import urllib2 |
271 | |
272 | from django.conf import settings |
273 | from django.db import models |
274 | |
275 | - |
276 | -class LaunchpadError(Exception): |
277 | - '''Raised when an error occurrs interacting with Launchpad.''' |
278 | +from ppa_assigner import launchpad |
279 | |
280 | |
281 | class PPA(models.Model): |
282 | + AVAILABLE = 0 |
283 | + RESERVED = 10 |
284 | + DIRTY = 20 |
285 | + CLEANING = 30 |
286 | + STATES = ( |
287 | + (AVAILABLE, 'available'), |
288 | + (RESERVED, 'reserved'), |
289 | + (DIRTY, 'dirty'), |
290 | + (CLEANING, 'cleaning'), |
291 | + ) |
292 | name = models.CharField(max_length=4096, unique=True) |
293 | |
294 | - reserved = models.BooleanField(default=False) |
295 | - lockname = models.CharField(max_length=4096, blank=True) |
296 | + state = models.PositiveIntegerField(choices=STATES, default=AVAILABLE) |
297 | + ticket_id = models.PositiveIntegerField() |
298 | timestamp = models.DateTimeField(auto_now_add=True) |
299 | |
300 | def __unicode__(self): |
301 | - reserved = '' |
302 | - if self.reserved: |
303 | - reserved = ' (reserved)' |
304 | - if self.lockname: |
305 | - reserved = '%s - %s' % (reserved, self.lockname) |
306 | - return '%s %s' % (self.name, reserved) |
307 | + val = '%s (%s)' % (self.name, self._get_state_str()) |
308 | + if self.state == PPA.RESERVED: |
309 | + val += ' ticket=%s' % self.ticket_id |
310 | + return val |
311 | |
312 | - def clean_artifacts(self): |
313 | - """Cleans up all the artifacts residing in the ppa's.""" |
314 | - raise LaunchpadError('TODO how to clean via LP') |
315 | + def _get_state_str(self): |
316 | + for state, label in PPA.STATES: |
317 | + if self.state == state: |
318 | + return label |
319 | + raise RuntimeError('Unsupported state: %s' % self.state) |
320 | |
321 | @staticmethod |
322 | def _merge_ppas(lp_ppas): |
323 | @@ -53,12 +60,12 @@ |
324 | |
325 | logging.info('adding new ppas: %r', add) |
326 | for x in add: |
327 | - PPA.objects.create(name=x) |
328 | + PPA.objects.create(name=x, ticket_id=0) |
329 | |
330 | logging.info('removing ppas: %r', add) |
331 | for x in remove: |
332 | ppa = PPA.objects.get(name=x) |
333 | - if ppa.reserved: |
334 | + if ppa.state == PPA.RESERVED: |
335 | # log something, but we may as well delete. because if the |
336 | # ppa doesn't exist in LP we are already in a catastrophic |
337 | # situation and shouldn't let any other user re-use this |
338 | @@ -68,51 +75,27 @@ |
339 | @staticmethod |
340 | def populate_from_launchpad(): |
341 | '''Get list of PPAs from launchpad and ensure they exist here.''' |
342 | - url = 'https://api.launchpad.net/1.0/~%s/ppas' |
343 | + url = settings.LAUNCHPAD_API_BASE + '/~%s/ppas' |
344 | url = url % settings.LAUNCHPAD_PPA_USER |
345 | logging.info('populating ppa model from: %s', url) |
346 | try: |
347 | - resp = urllib2.urlopen(url) |
348 | - resp = resp.read() |
349 | - data = json.loads(resp) |
350 | - # TODO test if this is needed or if there is some type |
351 | - # of looping logic needed |
352 | - assert data['total_size'] == len(data['entries']) |
353 | ppas = ['ppa:%s/%s' % (settings.LAUNCHPAD_PPA_USER, x['name']) |
354 | - for x in data['entries']] |
355 | + for x in launchpad.lp_collection(url)] |
356 | PPA._merge_ppas(set(ppas)) |
357 | - |
358 | except urllib2.URLError as e: |
359 | - logging.exception('Could not populate PPAs from launchpad') |
360 | - raise LaunchpadError(e) |
361 | + logging.exception('Could not populate PPAs from launchpad', e) |
362 | + raise |
363 | |
364 | @staticmethod |
365 | - def reserve(lockname='', clean=False): |
366 | + def reserve(ticket_id): |
367 | # "select_for_update" provides proper db-level locking so we |
368 | # don't accidentally have 2 concurrent requests find and reserve the |
369 | # same PPA |
370 | - ppa = None |
371 | - qs = PPA.objects.select_for_update().filter(reserved=False) |
372 | + qs = PPA.objects.select_for_update().filter(state=PPA.AVAILABLE) |
373 | if qs.count() == 0: |
374 | raise PPA.DoesNotExist('No free PPAs exist in pool') |
375 | pk = qs[0].pk |
376 | - qs.filter(pk__in=(pk,)).update(reserved=True, lockname=lockname) |
377 | - |
378 | - # we now hold a lock, be careful |
379 | - try: |
380 | - ppa = PPA.objects.get(pk=pk) |
381 | - ppa.timestamp = datetime.datetime.now() |
382 | - if clean: |
383 | - ppa.clean_artifacts() |
384 | - |
385 | - ppa.save() |
386 | - return ppa |
387 | - except Exception as e: |
388 | - if ppa: |
389 | - ppa.reserved = False |
390 | - ppa.lockname = '' |
391 | - ppa.save() |
392 | - if not isinstance(e, LaunchpadError): |
393 | - logging.exception('Unexpected exception while cleaning') |
394 | - e = LaunchpadError(e) |
395 | - raise e |
396 | + now = datetime.datetime.utcnow() |
397 | + qs.filter(pk__in=(pk,)).update( |
398 | + state=PPA.RESERVED, ticket_id=ticket_id, timestamp=now) |
399 | + return PPA.objects.get(id=pk) |
400 | |
401 | === modified file 'ppa-assigner/ppa_assigner/settings.py' |
402 | --- ppa-assigner/ppa_assigner/settings.py 2013-12-12 23:22:27 +0000 |
403 | +++ ppa-assigner/ppa_assigner/settings.py 2013-12-18 18:24:41 +0000 |
404 | @@ -25,6 +25,12 @@ |
405 | MANAGERS = ADMINS |
406 | |
407 | LAUNCHPAD_PPA_USER = 'TODO' |
408 | +LAUNCHPAD_API_BASE = 'https://api.launchpad.net/1.0' |
409 | + |
410 | +OAUTH_REALM = 'https://api.launchpad.net/' |
411 | +OAUTH_CONSUMER_KEY = 'TODO' |
412 | +OAUTH_TOKEN = 'TODO' |
413 | +OAUTH_TOKEN_SECRET = 'TODO' |
414 | |
415 | DATABASES = { |
416 | 'default': { |
417 | |
418 | === modified file 'ppa-assigner/ppa_assigner/tests.py' |
419 | --- ppa-assigner/ppa_assigner/tests.py 2013-12-10 18:05:51 +0000 |
420 | +++ ppa-assigner/ppa_assigner/tests.py 2013-12-18 18:24:41 +0000 |
421 | @@ -28,7 +28,8 @@ |
422 | from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature |
423 | |
424 | from ci_utils.tastypie.test import TastypieTestCase |
425 | -from ppa_assigner.models import PPA, LaunchpadError |
426 | +from ppa_assigner.models import PPA |
427 | +from ppa_assigner.management.commands.clean_ppas import Command |
428 | |
429 | |
430 | class TestModel(TestCase): |
431 | @@ -36,53 +37,22 @@ |
432 | def setUp(self): |
433 | super(TestModel, self).setUp() |
434 | for n in range(5): |
435 | - PPA.objects.create(name='ci_pool-%s' % str(n).zfill(3)) |
436 | - |
437 | - @mock.patch('ppa_assigner.models.PPA.clean_artifacts') |
438 | - def testReserveNoClean(self, clean): |
439 | - p = PPA.reserve('testlockname') |
440 | - self.assertIsNotNone(p) |
441 | - self.assertFalse(clean.called) |
442 | - self.assertEqual('testlockname', p.lockname) |
443 | - |
444 | - reserved = PPA.objects.filter(reserved=True).count() |
445 | - self.assertEqual(1, reserved) |
446 | + name = 'ci_pool-%s' % str(n).zfill(3) |
447 | + PPA.objects.create(name=name, ticket_id=0) |
448 | |
449 | def testReserveTimestamp(self): |
450 | - p = PPA.reserve() |
451 | - delta = datetime.datetime.now() - p.timestamp |
452 | + ticket = 1234 |
453 | + p = PPA.reserve(ticket) |
454 | + delta = datetime.datetime.now(p.timestamp.tzinfo) - p.timestamp |
455 | # the lock should have happened in less than a second |
456 | + self.assertEqual(PPA.RESERVED, p.state) |
457 | + self.assertEqual(ticket, p.ticket_id) |
458 | self.assertLess(delta.seconds, 1) |
459 | |
460 | - @mock.patch('ppa_assigner.models.PPA.clean_artifacts') |
461 | - def testReserveClean(self, clean): |
462 | - p = PPA.reserve('testlockname', True) |
463 | - self.assertIsNotNone(p) |
464 | - self.assertTrue(clean.called) |
465 | - self.assertEqual('testlockname', p.lockname) |
466 | - |
467 | - reserved = PPA.objects.filter(reserved=True).count() |
468 | - self.assertEqual(1, reserved) |
469 | - |
470 | - @mock.patch('ppa_assigner.models.PPA.clean_artifacts') |
471 | - def testReserveCleanFail(self, clean): |
472 | - '''ensure ppa isn't locked if clean operation fails.''' |
473 | - clean.side_effect = RuntimeError |
474 | - locked_before = PPA.objects.filter(reserved=True).count() |
475 | - with self.assertRaises(LaunchpadError): |
476 | - PPA.reserve(clean=True) |
477 | - |
478 | - locked_after = PPA.objects.filter(reserved=True).count() |
479 | - self.assertEqual(locked_before, locked_after) |
480 | - |
481 | def testReserveNoFree(self): |
482 | - PPA.objects.update(reserved=True) |
483 | + PPA.objects.update(state=PPA.RESERVED) |
484 | with self.assertRaises(PPA.DoesNotExist): |
485 | - PPA.reserve() |
486 | - |
487 | - def testCleanOperation(self): |
488 | - # TODO more logic for this |
489 | - PPA.reserve(clean=True) |
490 | + PPA.reserve(1234) |
491 | |
492 | |
493 | class TestConcurrency(TransactionTestCase): |
494 | @@ -93,7 +63,8 @@ |
495 | super(TestConcurrency, self).setUp() |
496 | transaction.enter_transaction_management() |
497 | for n in range(5): |
498 | - PPA.objects.create(name='ci_pool-%s' % str(n).zfill(3)) |
499 | + name = 'ci_pool-%s' % str(n).zfill(3) |
500 | + PPA.objects.create(name=name, ticket_id=0) |
501 | transaction.commit() |
502 | self.addCleanup(transaction.abort) |
503 | |
504 | @@ -119,7 +90,7 @@ |
505 | def _reserve(self, params): |
506 | params['signal'].set() |
507 | start = time.time() |
508 | - PPA.reserve() |
509 | + PPA.reserve(1234) |
510 | params['time'] = time.time() - start |
511 | # connections are per-thread, so close this |
512 | connection.close() |
513 | @@ -144,49 +115,47 @@ |
514 | def setUp(self): |
515 | super(TestApi, self).setUp('/api/v1') |
516 | for n in range(5): |
517 | - PPA.objects.create(name='ci_pool-%s' % str(n).zfill(3)) |
518 | + name = 'ci_pool-%s' % str(n).zfill(3) |
519 | + PPA.objects.create(name=name, ticket_id=0) |
520 | |
521 | def testGetAll(self): |
522 | obj = self.get('ppa/') |
523 | self.assertEqual(PPA.objects.all().count(), len(obj['objects'])) |
524 | |
525 | def testGetQuery(self): |
526 | - p = PPA.reserve() |
527 | - obj = self.get('ppa/', {'reserved': False}) |
528 | - count = PPA.objects.filter(reserved=False).count() |
529 | + p = PPA.reserve(1234) |
530 | + obj = self.get('ppa/', {'state': PPA.AVAILABLE}) |
531 | + count = PPA.objects.filter(state=PPA.AVAILABLE).count() |
532 | self.assertEqual(count, len(obj['objects'])) |
533 | |
534 | - obj = self.getResource('ppa/', {'reserved': True}) |
535 | + obj = self.getResource('ppa/', {'state': PPA.RESERVED}) |
536 | self.assertEqual(p.name, obj['name']) |
537 | - |
538 | - p.lockname = 'foo' |
539 | - p.save() |
540 | - obj = self.getResource('ppa/', {'reserved': True}) |
541 | - self.assertEqual(p.lockname, obj['lockname']) |
542 | - |
543 | - def testReserveNoClean(self): |
544 | - params = {'lockname': 'foo'} |
545 | + self.assertEqual(p.ticket_id, obj['ticket_id']) |
546 | + |
547 | + def testReserve(self): |
548 | + params = {'ticket_id': 1234} |
549 | obj = self.createResource('ppa/', params) |
550 | - self.assertEqual(obj['lockname'], params['lockname']) |
551 | - self.assertTrue(obj['reserved']) |
552 | - |
553 | - @mock.patch('ppa_assigner.models.PPA.clean_artifacts') |
554 | - def testReserveClean(self, clean): |
555 | - obj = self.createResource('ppa/', {'lockname': 'foo', 'clean': True}) |
556 | - self.assertTrue(obj['reserved']) |
557 | - self.assertTrue(clean.called) |
558 | + self.assertEqual(obj['ticket_id'], params['ticket_id']) |
559 | |
560 | def testReserveNoFree(self): |
561 | - PPA.objects.update(reserved=True) |
562 | - params = {'lockname': 'foo'} |
563 | + PPA.objects.update(state=PPA.DIRTY) |
564 | + params = {'ticket_id': 1234} |
565 | with self.assertRaises(PPA.DoesNotExist): |
566 | self.post('ppa/', params) |
567 | |
568 | - def testFree(self): |
569 | - p = PPA.reserve() |
570 | - self.patch('ppa/%d/' % p.id, {'reserved': False}) |
571 | - p = PPA.objects.get(pk=p.id) |
572 | - self.assertFalse(p.reserved) |
573 | + def testFreeNumeric(self): |
574 | + '''check we can free a ppa with the numeric value in the db''' |
575 | + p = PPA.reserve(1234) |
576 | + self.patch('ppa/%d/' % p.id, {'state': PPA.DIRTY}) |
577 | + p = PPA.objects.get(pk=p.id) |
578 | + self.assertEqual(p.state, PPA.DIRTY) |
579 | + |
580 | + def testFreeString(self): |
581 | + '''check we can free a ppa with a string value''' |
582 | + p = PPA.reserve(1234) |
583 | + self.patch('ppa/%d/' % p.id, {'state': 'dirty'}) |
584 | + p = PPA.objects.get(pk=p.id) |
585 | + self.assertEqual(p.state, PPA.DIRTY) |
586 | |
587 | @mock.patch('urllib2.urlopen') |
588 | def testPopulateFail(self, urlopen): |
589 | @@ -219,3 +188,79 @@ |
590 | expected = ['ppa:%s/%s' % (user, x['name']) for x in data['entries']] |
591 | found = [x.name for x in PPA.objects.all().order_by('name')] |
592 | self.assertListEqual(expected, found) |
593 | + |
594 | + |
595 | +class TestCleaner(TestCase): |
596 | + |
597 | + def setUp(self): |
598 | + super(TestCleaner, self).setUp() |
599 | + for n in range(5): |
600 | + name = 'ci_pool-%s' % str(n).zfill(3) |
601 | + PPA.objects.create(name=name, ticket_id=0) |
602 | + |
603 | + @mock.patch('ppa_assigner.launchpad._lp_post') |
604 | + @mock.patch('ppa_assigner.launchpad.get_publishing_history') |
605 | + def testDirtyToClean(self, get_publishing_history, lp_post): |
606 | + p = PPA.objects.get(name='ci_pool-002') |
607 | + p.state = PPA.DIRTY |
608 | + p.save() |
609 | + |
610 | + # verify we make one _lp_post call |
611 | + get_publishing_history.return_value = [ |
612 | + {'self_link': 'foo', 'date_removed': None}, |
613 | + {'self_link': 'foo', 'date_removed': '123'}, |
614 | + ] |
615 | + resp_200 = mock.Mock() |
616 | + resp_200.getcode.return_value = 200 |
617 | + lp_post.return_value = resp_200 |
618 | + Command().dirty_to_cleaning() |
619 | + p = PPA.objects.get(name='ci_pool-002') |
620 | + self.assertEqual(PPA.CLEANING, p.state) |
621 | + lp_post.assert_called_once() |
622 | + |
623 | + @mock.patch('ppa_assigner.launchpad.request_ppa_clean') |
624 | + def testDirtyToCleanFailure(self, request_clean): |
625 | + '''make sure state only changes if the call passes.''' |
626 | + p = PPA.objects.get(name='ci_pool-002') |
627 | + p.state = PPA.DIRTY |
628 | + p.save() |
629 | + request_clean.side_effect = RuntimeError('foo bar mocked error') |
630 | + |
631 | + Command().dirty_to_cleaning() |
632 | + p = PPA.objects.get(name='ci_pool-002') |
633 | + self.assertEqual(PPA.DIRTY, p.state) |
634 | + |
635 | + @mock.patch('ppa_assigner.launchpad.get_publishing_history') |
636 | + def testCleaning(self, get_publishing_history): |
637 | + p = PPA.objects.get(name='ci_pool-002') |
638 | + p.state = PPA.CLEANING |
639 | + p.save() |
640 | + |
641 | + # first pass we still have an item, so the state shouldn't change |
642 | + get_publishing_history.return_value = [ |
643 | + {'date_removed': None}, |
644 | + {'date_removed': '123'}, |
645 | + ] |
646 | + Command().cleaning_to_available() |
647 | + p = PPA.objects.get(name='ci_pool-002') |
648 | + self.assertEqual(PPA.CLEANING, p.state) |
649 | + |
650 | + get_publishing_history.return_value = [ |
651 | + {'date_removed': '123'}, |
652 | + {'date_removed': '123'}, |
653 | + ] |
654 | + Command().cleaning_to_available() |
655 | + p = PPA.objects.get(name='ci_pool-002') |
656 | + self.assertEqual(PPA.AVAILABLE, p.state) |
657 | + |
658 | + @mock.patch('ppa_assigner.launchpad.get_publishing_history') |
659 | + def testCleaningFailure(self, get_publishing_history): |
660 | + '''make sure it handles an exception from launchpad gracefully.''' |
661 | + p = PPA.objects.get(name='ci_pool-002') |
662 | + p.state = PPA.CLEANING |
663 | + p.save() |
664 | + get_publishing_history.side_effect = RuntimeError('foo bar mocked') |
665 | + |
666 | + Command().cleaning_to_available() |
667 | + p = PPA.objects.get(name='ci_pool-002') |
668 | + self.assertEqual(PPA.CLEANING, p.state) |
669 | |
670 | === modified file 'ppa-assigner/setup.py' |
671 | --- ppa-assigner/setup.py 2013-11-24 18:27:59 +0000 |
672 | +++ ppa-assigner/setup.py 2013-12-18 18:24:41 +0000 |
673 | @@ -37,6 +37,7 @@ |
674 | 'django-tastypie==0.9.15', |
675 | 'South==0.7.5', |
676 | 'mock==1.0.1', |
677 | + 'oauth==1.0.1' |
678 | ] |
679 | |
680 | setup( |
21 +# Read for details: https:/ /help.launchpad .net/API/ SigningRequests
22 +OAUTH_CONSUMER_KEY = 'TODO'
23 +OAUTH_TOKEN = 'TODO'
24 +OAUTH_TOKEN_SECRET = 'TODO'
25 +
Not sure how this relates to your other MP, I'll consider you've addressed the TODO there for this review.
60 - lockname = bundle. data.get( 'lockname' )
\o/ no more lock ;)
79 + if type(state) != int:
O_o can you add a comment to explain ? As I read it it means we'll be ignoring some requests. I'd rather error out unless you have a strong reason to do so... which will make a perfect comment ;)
118 +def lp_collection(url):
You probably want to talk to wgrant about that. If I remember correctly, if the collection is big/huge, you'll run into timeouts and will need to make several requests to acquire parts of the collection.
So better check with wgrant first if you're under a reasonable size for the collection or if you already need to handle chunks.
138 + 'OAuth realm="https:/ /api.launchpad. net/"', version= "1.0"', consumer_ key="%s" ' % settings. OAUTH_CONSUMER_ KEY, OAUTH_TOKEN, signature_ method= "PLAINTEXT" ', signature= "%%26%s" ' % settings. OAUTH_TOKEN_ SECRET, timestamp= "%d"' % int(oauth. time.time( )), nonce() ,
139 + 'oauth_
140 + 'oauth_
141 + 'oauth_token="%s"' % settings.
142 + 'oauth_
143 + 'oauth_
144 + 'oauth_
145 + 'oauth_nonce="%s"' % oauth.generate_
Great, we already have 3 settings parametrized, I think we also want OAuth realm so we can test against staging or a local launchpad.
161 + url = 'https:/ /api.launchpad. net/1.0/ ~%s/+archive/ %s/?ws. op='
That one probably needs to be parametrized in the same way.
223 + # TODO check for oauth before launching
Sounds like a good test to add ;)
249 + logging. exception( 'unable to request clean ppa, will retry')
Better add the exception encountered if only to start learning about which ones exist.
266 + logging. exception( 'error checking if ppa is clean')
idem
354 - # TODO test if this is needed or if there is some type
355 - # of looping logic needed
Yeah, see above, not sure you're talking about the same looping logic but that matches what I'm talking about ;)
364 logging. exception( 'Could not populate PPAs from launchpad')
Again, mention the exception, the logs are for us and we want precise info ;)
By just reading the proposal, this seems better than the previous version, so +once the tweaks above are addressed ;)