Merge lp:~robru/bileto/publish-job into lp:bileto

Proposed by Robert Bruce Park
Status: Merged
Merged at revision: 680
Proposed branch: lp:~robru/bileto/publish-job
Merge into: lp:bileto
Diff against target: 940 lines (+459/-54)
17 files modified
Makefile (+3/-1)
bileto/actions.py (+6/-2)
bileto/static/index.html (+1/-1)
bileto/streams.py (+3/-0)
bileto/templates/loglist.html (+3/-0)
bileto/worker/manager.py (+33/-7)
bileto/worker/manual.py (+11/-0)
bileto/worker/merge.py (+10/-2)
bileto/worker/package.py (+124/-14)
files/rsyncd.conf (+7/-0)
scripts/vcs.sh (+5/-7)
tests/test_actions.py (+3/-8)
tests/test_streams.py (+18/-0)
tests/test_worker_manager.py (+71/-11)
tests/test_worker_manual.py (+28/-0)
tests/test_worker_merge.py (+11/-1)
tests/test_worker_package.py (+122/-0)
To merge this branch: bzr merge lp:~robru/bileto/publish-job
Reviewer Review Type Date Requested Status
Robert Bruce Park (community) Approve
Martin Pitt Needs Fixing
Review via email: mp+302105@code.launchpad.net

Commit message

Implement publish job in Bileto, obsoleting jenkins entirely.

Description of the change

(note this depends on charm changes and must be deployed with "mojo upgrade-charm")

To post a comment you must log in.
lp:~robru/bileto/publish-job updated
703. By Robert Bruce Park

Temporarily disable some checks for testing.

704. By Robert Bruce Park

Temporarily disable some more checks for testing.

705. By Robert Bruce Park

Temporarily disable even more checks for testing.

706. By Robert Bruce Park

Fix publishing.

707. By Robert Bruce Park

Re-enable all checks.

Revision history for this message
Martin Pitt (pitti) wrote :

Mostly code style issues and nitpicks, but two or three more serious issues.

review: Needs Fixing
Revision history for this message
Robert Bruce Park (robru) wrote :

Replies inline,thanks Martin

Revision history for this message
Martin Pitt (pitti) wrote :

Answered to your replies, thanks!

The flashplugin-nonfree component issue is still a blocker. The rsync race looks like an actual bug, but not that important (so not a blocker). Do with the code style/obfuscation comments what you want.

Revision history for this message
Robert Bruce Park (robru) :
lp:~robru/bileto/publish-job updated
708. By Robert Bruce Park

Rename __getattr__ to call_on_all_packages for pitti.

709. By Robert Bruce Park

Ensure os.rename() stays within filesystem.

710. By Robert Bruce Park

Clarify docstring.

Revision history for this message
Robert Bruce Park (robru) wrote :

Ok I've pushed commits to resolve the issues I agree with, I'm choosing not to block on the two things that are historical cruft that I've inherited (flashplugin-nonfree/universe and '0 means no version'), I'm open to revisiting those later but for now those things work and are already production-proven, and I need to move forward with this rollout. Thanks for the review Martin!

review: Approve
lp:~robru/bileto/publish-job updated
711. By Robert Bruce Park

Fix tests.

Revision history for this message
Martin Pitt (pitti) wrote :

Robert Bruce Park [2016-08-22 16:06 -0000]:
> Can you elaborate what you mean by "recursively call the method on its direct children"?

Well, I don't know what this is doing, but you said that __getattr__()
has some magic to do that kind of iteration.

> The reason that __getattr__ is defined to pass methods down to
> children is that this is called from dozens of places (and many of
> those cases it isn't overloaded at all, it's just a direct pass
> through), so I'm not going to reimplement that in every single place
> that needs to pass through. I'll probably rename __getattr__() to
> call_on_all_packages().

Doing "__getattr__ = call_on_all_packages" and replacing half of the
calls with the other makes things even *more*
confusing/implicit/surprising, so better revert that bit or fix it
consistently.

> There is an rsync server running continuously on the bileto
> instance, and snakefruit polls it every 5 minutes looking for a new
> file to read. Bileto doesn't "start" rsync.

So I suggest using a lock file (in both the rsync process and bileto)
to ensure to not run rsync while bileto is updating that file tree.
Another common pattern is to do something like this:

  cp -al cur_tree new_tree
  <change new_tree>
  mv cur_tree old_tree; mv new_tree cur_tree

You can't atomically rename an existing (non-empty) directory, but at
least this is fairly quick and thus will work in almost every case.
However, lock files are cheaper, simpler, and completely robust.

> As I said this was decided by infinity and I'm not sure what would be correct.

Please ask him again what this should mean and add a big fat comment
about it. The concept of "upload flashplugin-nonfree into universe"
simply does not make sense -- asking questions about it might be some
weird LP quirk, but it should at least be documented. You yourself
don't even know what it does any more :-)

Revision history for this message
Robert Bruce Park (robru) wrote :
Download full text (4.5 KiB)

On Aug 22, 2016 10:15 PM, "Martin Pitt" <email address hidden> wrote:
>
> Robert Bruce Park [2016-08-22 16:06 -0000]:
> > Can you elaborate what you mean by "recursively call the method on its
direct children"?
>
> Well, I don't know what this is doing, but you said that __getattr__()
> has some magic to do that kind of iteration.

So a ticket can have many packages. When you "build a ticket" you want to
build each package. The manager class exists to ensure the classes
representing the packages are correctly instantiated and then passes
messages back and forth. So when you call manager.build() you're really
calling .build() on every package instance. Manager.__gettattr__() contains
the logic for calling the method, in parallel, on each instance.

In some cases there's some "global" logic that operates once on the whole
ticket rather than per package, and that's where you see those manager
methods that call __getattr__, they're processing the return values that
came back from each package, etc. But there are other cases where you might
call "manager.foo()" and foo doesn't exist at all, it goes straight to
package.foo() on each package. The idea was that the manager is a sort of
transparent wrapper around the package instances, you can call package
methods on it and it passes the calls down to each instance. Eg, it's an
explicit design goal that you would often call methods on the manager
instance that do not exist, which is why __getattr__ is defined. It's just
unfortunate that so many methods require a bit of intervention before /
after the __getattr__ logic that you see so many methods calling
__gettattr__ with their own method name.

>
> > The reason that __getattr__ is defined to pass methods down to
> > children is that this is called from dozens of places (and many of
> > those cases it isn't overloaded at all, it's just a direct pass
> > through), so I'm not going to reimplement that in every single place
> > that needs to pass through. I'll probably rename __getattr__() to
> > call_on_all_packages().
>
> Doing "__getattr__ = call_on_all_packages" and replacing half of the
> calls with the other makes things even *more*
> confusing/implicit/surprising, so better revert that bit or fix it
> consistently.

What do you mean replacing half the calls? Nothing is calling
"self.__getattr__()" any longer so it's perfectly consistent. __getattr__
is kept for the cases when a nonexistent method is called on the manager
class. That has to stay because they chain together, eg, you can call
"manager.foo().bar()" and that calls foo and bar on all package instances.
Getting rid of __getattr__ would result in code like
"manager.call_on_all_packages('foo').call_on_all_packages('bar')", eg,
redundant slop.

>
> > There is an rsync server running continuously on the bileto
> > instance, and snakefruit polls it every 5 minutes looking for a new
> > file to read. Bileto doesn't "start" rsync.
>
> So I suggest using a lock file (in both the rsync process and bileto)
> to ensure to not run rsync while bileto is updating that file tree.
> Another common pattern is to do something like this:
>
> cp -al cur_tree new_tree
> <change new_tree>
> mv cur_t...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2016-07-11 21:41:48 +0000
3+++ Makefile 2016-08-22 16:49:27 +0000
4@@ -31,8 +31,10 @@
5
6 install: install-common-deps
7 # Need bzr-builddeb for merging debian/changelogs.
8- $(apt) uuid tree devscripts patchutils diffstat quilt libdistro-info-perl fakeroot dput python3-yaml python3-amqplib python3-psycopg2 python3-gevent gunicorn3 supervisor postgresql-client python-swiftclient bzr-builddeb
9+ $(apt) rsync uuid tree devscripts patchutils diffstat quilt libdistro-info-perl fakeroot dput python3-yaml python3-amqplib python3-psycopg2 python3-gevent gunicorn3 supervisor postgresql-client python-swiftclient bzr-builddeb
10 $(envsubst) <files/supervisor.conf >/etc/supervisor/conf.d/bileto.conf
11+ cp files/rsyncd.conf /etc/rsync-juju.d/020-bileto.conf
12+ service rsync restart
13 # Branch branches if necessary
14 [ -d "$(britney_path)" ] || git clone $(britney_branch) $(britney_path)
15 [ -d "$(britney_hints_path)" ] || bzr branch $(britney_hints) $(britney_hints_path)
16
17=== modified file 'bileto/actions.py'
18--- bileto/actions.py 2016-07-08 17:57:20 +0000
19+++ bileto/actions.py 2016-08-22 16:49:27 +0000
20@@ -15,6 +15,11 @@
21 finalize='Merging to trunk.',
22 abandon='Abandoning ticket.',
23 )
24+ options = dict(
25+ publish=dict(
26+ ACK_PACKAGING='Check this option if you have reviewed and '
27+ 'approved all the packaging diffs for this ticket.'),
28+ )
29
30 @staticmethod
31 def status(manager):
32@@ -39,8 +44,7 @@
33 @staticmethod
34 def publish(manager):
35 """Copy Packages to Destination Archive"""
36- manager.checkstatus().validate().checkupload().ackaging()
37- manager.checkdest().unapproved().unbuilt().publish()
38+ manager.validate().prepublish().publish()
39
40 @staticmethod
41 def finalize(manager):
42
43=== modified file 'bileto/static/index.html'
44--- bileto/static/index.html 2016-08-04 17:07:10 +0000
45+++ bileto/static/index.html 2016-08-22 16:49:27 +0000
46@@ -229,7 +229,7 @@
47 <td ng-if="!editing"><a href="/log/{{req.request_id}}/build" target="_blank">Build</a></td>
48 <td ng-if="req.siloname && !editing"><a href="/log/{{req.request_id}}/diff" target="_blank">Diff</a></td>
49 <td ng-if="!editing"><a href="/log/{{req.request_id}}/revert" target="_blank">Revert</a></td>
50-<td ng-if="req.siloname && !editing"><a href="{{meta.jenkins}}/job/{{req.siloname.replace('/', '-')}}-2-publish/build" target="_blank">Publish</a></td>
51+<td ng-if="req.siloname && !editing"><a href="/log/{{req.request_id}}/publish" target="_blank">Publish</a></td>
52 <td ng-if="!editing"><a href="/log/{{req.request_id}}/finalize" target="_blank">Finalize</a></td>
53 <td ng-if="!editing"><a href="/log/{{req.request_id}}/abandon" target="_blank">Abandon</a></td>
54 <td ng-if="editing"><a ng-click="cancel()">Cancel Edit</a></td>
55
56=== modified file 'bileto/streams.py'
57--- bileto/streams.py 2016-08-16 16:36:52 +0000
58+++ bileto/streams.py 2016-08-22 16:49:27 +0000
59@@ -127,6 +127,7 @@
60 post = join(request.path, 'latest/')
61 packages = list_packages(action, ticket)
62 message = getattr(Actions, action).__doc__
63+ options = Actions.options.get(action, {})
64 leaf = 'success_*False' if failures else 'info'
65 paths = glob(join(Config.HOME, 'log', str(ticket), action, '[0-9]*', leaf))
66 meta = dict()
67@@ -156,6 +157,8 @@
68 REQUESTID=str(ticket),
69 TEAMS=COMMA.join(session.get('teams', [])),
70 )
71+ for key in Actions.options.get(action, {}):
72+ env[key] = str(request.form.get(key))
73 path = join(Config.ROOT, 'bileto', 'worker', 'main.py')
74 Popen((path,), env=env)
75 sleep(5)
76
77=== modified file 'bileto/templates/loglist.html'
78--- bileto/templates/loglist.html 2016-07-30 00:09:36 +0000
79+++ bileto/templates/loglist.html 2016-08-22 16:49:27 +0000
80@@ -8,6 +8,9 @@
81 <div id="trigger">
82 {% if session.nickname %}
83 <form action="{{ post }}" method="post">
84+{% for option, description in options.items() %}
85+<p><label><input type="checkbox" name="{{ option }}" value="{{ option }}"><b>{{ option }}:</b> {{ description }}</label></p>
86+{% endfor %}
87 {% if packages %}
88 <p>Include these packages (leave blank to build all):</p>
89 <p>
90
91=== modified file 'bileto/worker/manager.py'
92--- bileto/worker/manager.py 2016-08-17 20:59:59 +0000
93+++ bileto/worker/manager.py 2016-08-22 16:49:27 +0000
94@@ -12,7 +12,7 @@
95
96 import re
97
98-from os import environ
99+from os import environ, makedirs, rename
100 from os.path import getmtime, join
101
102 from time import time
103@@ -57,6 +57,7 @@
104 SLASH = '/'
105 SPACE = ' '
106 EMPTY = ''
107+DASH = '-'
108 PLUS = '+'
109 N = '\n'
110
111@@ -205,8 +206,8 @@
112 pretty_instances(Package.instances.items())
113 return Package.instances
114
115- def __getattr__(self, attr):
116- """Pass method calls down to all instances."""
117+ def call_on_all_packages(self, attr):
118+ """Pass method calls down to all Package instances."""
119 def passthrough(*args, **kwargs):
120 """Pass arguments down to methods."""
121 log.abort_if_cancelled()
122@@ -216,6 +217,7 @@
123 for name, package in self.packages().items()])
124 return self
125 return passthrough
126+ __getattr__ = call_on_all_packages
127
128 def assign(self):
129 """Assign a PPA to this ticket, if possible."""
130@@ -237,20 +239,20 @@
131 raise BiletoError('Reverting MPs is not supported.')
132 self.manual_class = Revert
133 log.vars(self.types(), 'manager.types()')
134- return self.__getattr__('collect')()
135+ return self.call_on_all_packages('collect')()
136
137 def upload(self):
138 """Prevent cancellation once uploading begins."""
139 log.cancellable = False
140 log.info('Point of no return! Job can no longer be cancelled.')
141- return self.__getattr__('upload')()
142+ return self.call_on_all_packages('upload')()
143
144 def diff(self):
145 """Force diffing of all packages."""
146 environ.update(INCLUDES=self.ticket.sources)
147 for func in (Manager.names, Manager.types, Manager.packages):
148 func.cache_clear()
149- self.__getattr__('diff')()
150+ self.call_on_all_packages('diff')()
151 set_swift_creds()
152 stdout = parallelize([VCS.swift_upload_()])[0]
153 log.info('Uploaded diffs:\n%s', stdout)
154@@ -262,7 +264,7 @@
155 assert_lockfile_age(self.ticket.lockfile)
156 lock = open('{}-status'.format(self.ticket.lockfile), 'w')
157 flock(lock, LOCK_EX | LOCK_NB)
158- self.__getattr__('status')()
159+ self.call_on_all_packages('status')()
160 assert_lockfile_age(self.ticket.lockfile)
161 states = defaultdict(list)
162 versions = defaultdict(dict)
163@@ -284,6 +286,30 @@
164 Actions.finalize(self)
165 return self
166
167+ def prepublish(self):
168+ """Abort publication if any safety checks failed."""
169+ self.call_on_all_packages('prepublish')()
170+ msgs = defaultdict(list)
171+ for source_id, message in sorted(self.retvals):
172+ if message:
173+ msgs[message].append(source_id)
174+ if msgs:
175+ raise BiletoError('Publish failed: {}'.format(pretty_states(msgs)))
176+ return self
177+
178+ def publish(self):
179+ """Write package manifest to disk."""
180+ self.call_on_all_packages('publish')()
181+ filename = 'packagelist_rsync_{}'.format(
182+ self.ticket.siloname.replace(SLASH, DASH))
183+ tmpfile = join(Config.HOME, filename)
184+ lines = sorted(Package.manifest)
185+ log.vars(lines, 'Manifest lines')
186+ with open(tmpfile, 'w', encoding='utf-8') as manifest:
187+ manifest.writelines(lines)
188+ makedirs(join(Config.HOME, 'publish'), exist_ok=True)
189+ rename(filename, join(Config.HOME, 'publish', filename))
190+
191 def nuke(self, status):
192 """Delete PPA contents."""
193 # TODO: Eventually delete ephemeral PPAs here
194
195=== modified file 'bileto/worker/manual.py'
196--- bileto/worker/manual.py 2016-03-10 01:47:08 +0000
197+++ bileto/worker/manual.py 2016-08-22 16:49:27 +0000
198@@ -3,6 +3,8 @@
199 Defines steps unique to manually-built source packages.
200 """
201
202+from os import environ
203+
204 from asyncio import coroutine
205
206 from bileto.worker.package import Package
207@@ -26,3 +28,12 @@
208 @coroutine
209 def expose(self):
210 """NOP; Users will expose the source package themselves."""
211+
212+ @coroutine
213+ def check_package_ack(self, source=None):
214+ """User must **always** have upload rights for manual sources."""
215+ if not self.authorized:
216+ return '{} not authorized to upload {}'.format(
217+ environ['BLAME'], self.name)
218+ result = yield from super().check_package_ack(source=source)
219+ return result
220
221=== modified file 'bileto/worker/merge.py'
222--- bileto/worker/merge.py 2016-07-28 20:10:13 +0000
223+++ bileto/worker/merge.py 2016-08-22 16:49:27 +0000
224@@ -98,6 +98,7 @@
225 """Define the steps unique to packages built from merge proposals."""
226 all_mps = {}
227 target = None
228+ acceptable = BUILDABLE_STATES
229
230 @classmethod
231 def prepare_all(cls, urls):
232@@ -163,14 +164,14 @@
233 return 'PPA/bzr version mismatch'
234
235 @coroutine
236- def check_merge_states(self, source=None, acceptable=BUILDABLE_STATES):
237+ def check_merge_states(self, source=None):
238 """Check that merges are in acceptable states."""
239 # (\/) (^,,,^) (\/)
240 your_merges_are_bad_and_you_should_feel_bad = False
241 for merge in self.merges:
242 url = getattr(merge, 'web_link', str(merge))
243 state = getattr(merge, 'queue_status', 'Not even a merge')
244- if state not in acceptable:
245+ if state not in self.acceptable:
246 log.error('%s: %s', state, url)
247 your_merges_are_bad_and_you_should_feel_bad = True
248 if your_merges_are_bad_and_you_should_feel_bad:
249@@ -268,3 +269,10 @@
250 def push(self):
251 """Push our final release to target trunk."""
252 yield from VCS.push_to_trunk(self.target, **self.env)
253+
254+ @coroutine
255+ def prepublish(self):
256+ """Change check_merge_states to consider what is publishable."""
257+ self.acceptable = PUBLISHABLE_STATES
258+ result = yield from super().prepublish()
259+ return result
260
261=== modified file 'bileto/worker/package.py'
262--- bileto/worker/package.py 2016-08-15 16:38:33 +0000
263+++ bileto/worker/package.py 2016-08-22 16:49:27 +0000
264@@ -3,27 +3,45 @@
265 Defines steps common to all types of packages that we handle.
266 """
267
268+from os import environ
269 from os.path import join
270+
271 from contextlib import suppress
272 from asyncio import coroutine, gather
273
274+from lazr.restfulclient.errors import ClientError
275+
276 from bileto.settings import Config
277 from bileto.models import BUILD_FAILURES, BUILD_STATES
278
279 from bileto.worker.vcs import VCS
280 from bileto.worker.log import log
281+from bileto.worker.version import V
282
283+DIFF = '{}_{}_{{}}.diff'
284 SLASH = '/'
285 EMPTY = ''
286+TAB = '\t'
287 N = '\n'
288
289
290+@coroutine
291+def run_checks(checks, *args, **kwargs):
292+ """Run checks and return first message found."""
293+ for check in checks:
294+ message = yield from check(*args, **kwargs)
295+ if message:
296+ return message
297+
298+
299 # pylint: disable=too-many-instance-attributes
300 class Package:
301 """Represent the steps necessary to build & publish a package."""
302+ authorized = None
303 artifacts = ''
304 published = None
305 instances = {}
306+ manifest = []
307
308 def __init__(self, name, distro, series, dest, ppa):
309 self.id = join(series.name, name)
310@@ -32,9 +50,14 @@
311 self.name = name
312 self.dest = dest
313 self.ppa = ppa
314+ diff = DIFF.format(series.name, name)
315+ self.content_diff = diff.format('content')
316+ self.packaging_diff = diff.format('packaging_changes')
317 self.env = dict(
318 SOURCE=name,
319 SERIES=series.name,
320+ CONTENT_DIFF=self.content_diff,
321+ PACKAGING_DIFF=self.packaging_diff,
322 )
323 self.instances[self.id] = self
324
325@@ -52,10 +75,7 @@
326 self.check_diff_exists,
327 self.check_destination_version,
328 )
329- for check in checks:
330- message = yield from check(source=source)
331- if message:
332- break
333+ message = yield from run_checks(checks, source=source)
334 return (self.id, message or 'Successfully built', self.published)
335
336 @coroutine
337@@ -85,7 +105,7 @@
338 def check_archive_pocket(self, source):
339 """Check if our package is in any archive pocket."""
340 version = source.source_package_version
341- dest = self.get_source(self.dest, version)
342+ dest = self.get_source(self.dest, version=version)
343 if dest:
344 results = yield from gather(
345 self.archive_id(source), self.archive_id(dest))
346@@ -148,8 +168,7 @@
347 @coroutine
348 def check_diff_exists(self, source):
349 """Ensure that this package has a diff in the ticket artifacts."""
350- diff_path = '{}_{}_content.diff'.format(self.series.name, self.name)
351- if diff_path not in self.artifacts:
352+ if self.content_diff not in self.artifacts:
353 return 'Diff missing'
354
355 @coroutine
356@@ -157,6 +176,37 @@
357 """Not built from merges; skipping changelog check."""
358
359 @coroutine
360+ def check_upload_rights(self, source):
361+ """Ensure publisher has permission to upload."""
362+ blame = environ['BLAME']
363+ main = self.get_source(self.series.main_archive, pocket='Release')
364+ # Default to flashplugin-nonfree/universe in case this package is NEW
365+ kwargs = dict(
366+ distroseries=self.series,
367+ sourcepackagename='flashplugin-nonfree',
368+ component='universe',
369+ pocket='Proposed',
370+ person=blame)
371+ if main:
372+ kwargs.update(
373+ component=main.component_name,
374+ sourcepackagename=self.name)
375+ log.vars(kwargs, 'calling checkUpload')
376+ try:
377+ self.series.main_archive.checkUpload(**kwargs)
378+ self.authorized = True
379+ except ClientError:
380+ self.authorized = False
381+ log.debug('Can %s upload %s? %s', blame, self.name, self.authorized)
382+
383+ @coroutine
384+ def check_package_ack(self, source):
385+ """Abort publication if packaging diffs have not been acknowledged."""
386+ if self.packaging_diff in self.artifacts:
387+ if 'ACK_PACKAGING' not in environ or not self.authorized:
388+ return 'Packaging diff requires ACK'
389+
390+ @coroutine
391 def build(self):
392 """Build source package."""
393 yield from VCS.build_(**self.env)
394@@ -177,23 +227,21 @@
395 def expose(self):
396 """NOP; Subclasses may optionally implement this."""
397
398- def get_sources(self, archive, version=None):
399+ def get_sources(self, archive, **kwargs):
400 """Get a list of lplib objects representing versions in archive."""
401- kwargs = dict(
402+ kwargs.update(
403 distro_series=self.series,
404 source_name=self.name,
405 order_by_date=True,
406 exact_match=True)
407- if version:
408- kwargs.update(version=version)
409 return [
410 source for source in archive.getPublishedSources(**kwargs)
411 if source.status != 'Deleted']
412
413- def get_source(self, archive, version=None):
414+ def get_source(self, archive, **kwargs):
415 """Get the lplib object representing this source."""
416 with suppress(IndexError):
417- return self.get_sources(archive, version)[0]
418+ return self.get_sources(archive, **kwargs)[0]
419
420 def find_dsc_url(self, source):
421 """Identify the URL to a DSC file for this package in an archive."""
422@@ -214,11 +262,73 @@
423 COMPONENT=dest_source.component_name if dest_source else 'NEW',
424 )
425 dest_dsc = self.find_dsc_url(dest_source or self.get_source(
426- self.dest.distribution.main_archive))
427+ self.series.main_archive))
428 yield from VCS.diff_dest_(DSC_URL=dest_dsc, **self.env)
429 log.info('Diffed %s.', self.id)
430
431 @coroutine
432+ def prepublish(self):
433+ """Abort publication if problems discovered."""
434+ source = self.get_source(self.ppa)
435+ checks = (
436+ self.check_ppa_version,
437+ self.check_build_status,
438+ self.check_diff_exists,
439+ self.check_upload_rights,
440+ self.check_package_ack,
441+ self.check_destination_version,
442+ self.check_merge_states,
443+ self.check_new_commits,
444+ )
445+ message = yield from run_checks(checks, source=source)
446+ return (self.id, message)
447+
448+ def copy_to_dest(self, source):
449+ """Copy this source package to the destination PPA."""
450+ log.info('Copying %s to %s.', source.display_name, self.dest.web_link)
451+ self.dest.copyPackage(
452+ from_archive=self.ppa,
453+ from_pocket=source.pocket,
454+ from_series=self.series.name,
455+ include_binaries=True,
456+ to_pocket='Release',
457+ to_series=self.series.name,
458+ source_name=self.name,
459+ version=source.source_package_version)
460+
461+ def write_manifest(self, source, dest_version):
462+ """Add a line in the manifest representing this package."""
463+ distro, series = source.distro_series_link.split(SLASH)[-2:]
464+ self.manifest.append(TAB.join([
465+ SLASH.join([Config.ppa_team, distro, self.ppa.name]),
466+ source.pocket,
467+ series,
468+ 'Proposed',
469+ series,
470+ self.name,
471+ source.source_package_version,
472+ dest_version,
473+ environ['BLAME'],
474+ distro,
475+ ]) + N)
476+
477+ @coroutine
478+ def publish(self):
479+ """Publish packages from PPA to dest archive."""
480+ source = self.get_source(self.ppa)
481+ dest = self.get_source(self.dest)
482+ dest_version = dest.source_package_version if dest else '0'
483+ if V(source.source_package_version) > dest_version:
484+ if self.dest.self_link == self.series.main_archive_link:
485+ self.write_manifest(source, dest_version)
486+ else:
487+ self.copy_to_dest(source)
488+ else:
489+ log.warning('Source: %s', source.display_name)
490+ log.warning('Dest: %s', dest.display_name)
491+ log.warning('Source version not higher than dest, skipping!')
492+
493+ @coroutine
494 def merge(self):
495 """NOP; implemented by subclasses."""
496
497
498=== added file 'files/rsyncd.conf'
499--- files/rsyncd.conf 1970-01-01 00:00:00 +0000
500+++ files/rsyncd.conf 2016-08-22 16:49:27 +0000
501@@ -0,0 +1,7 @@
502+[publish]
503+comment = Bileto Publication Data
504+path = /var/www/publish/
505+read only = no
506+list = yes
507+uid = www-data
508+group = www-data
509
510=== modified file 'scripts/vcs.sh'
511--- scripts/vcs.sh 2016-08-17 19:13:08 +0000
512+++ scripts/vcs.sh 2016-08-22 16:49:27 +0000
513@@ -205,20 +205,18 @@
514 cd "$WORKPARENT"
515 our_dsc="$(echo ${SOURCE}_*.dsc)"
516 [ -f "$our_dsc" ] || die_gently "Diff failed: No DSC found, was it ever built?"
517- content="${SERIES}_${SOURCE}_content.diff"
518- packaging="${SERIES}_${SOURCE}_packaging_changes.diff"
519- raw="$packaging.raw"
520- touch "$content"
521+ raw="$PACKAGING_DIFF.raw"
522+ touch "$CONTENT_DIFF"
523 if [ -n "$DSC_URL" ]; then
524 download_from_dsc "$DSC_URL"
525 dest_dsc="$(basename "$DSC_URL")"
526- debdiff_wrapper "$dest_dsc" "$our_dsc" "$content"
527- stdout filterdiff --clean --include '*debian/*' --exclude '*changelog' <"$content" >"$raw" || die "Failed to discover packaging changes."
528+ debdiff_wrapper "$dest_dsc" "$our_dsc" "$CONTENT_DIFF"
529+ stdout filterdiff --clean --include '*debian/*' --exclude '*changelog' <"$CONTENT_DIFF" >"$raw" || die "Failed to discover packaging changes."
530 else
531 echo '---NEW SOURCE PACKAGE---' >"$raw"
532 fi
533 if [ -s "$raw" ]; then
534- _build_packaging_diff "$content" "$raw" >"$packaging"
535+ _build_packaging_diff "$CONTENT_DIFF" "$raw" >"$PACKAGING_DIFF"
536 fi
537 }
538
539
540=== modified file 'tests/test_actions.py'
541--- tests/test_actions.py 2016-06-30 18:01:09 +0000
542+++ tests/test_actions.py 2016-08-22 16:49:27 +0000
543@@ -45,14 +45,9 @@
544 manager = Mock()
545 Actions.publish(manager)
546 self.assertSequenceEqual(manager.mock_calls, [
547- call.checkstatus(),
548- call.checkstatus().validate(),
549- call.checkstatus().validate().checkupload(),
550- call.checkstatus().validate().checkupload().ackaging(),
551- call.checkdest(),
552- call.checkdest().unapproved(),
553- call.checkdest().unapproved().unbuilt(),
554- call.checkdest().unapproved().unbuilt().publish(),
555+ call.validate(),
556+ call.validate().prepublish(),
557+ call.validate().prepublish().publish(),
558 ])
559
560 def test_actions_finalize(self):
561
562=== modified file 'tests/test_streams.py'
563--- tests/test_streams.py 2016-07-29 21:10:56 +0000
564+++ tests/test_streams.py 2016-08-22 16:49:27 +0000
565@@ -178,6 +178,24 @@
566 self.assertEqual(env['ACTION'], 'build')
567 self.assertEqual(env['REQUEST_PATH'], '/log/1/build/latest/')
568
569+ @patch('bileto.streams.Popen')
570+ def test_run_subprocess_form_env(self, popen):
571+ """Ensure forms are converted to env vars safely."""
572+ ret = self.app.post('/log/1/publish/latest/', data=dict(
573+ ACK_PACKAGING='ACK_PACKAGING',
574+ MALICIOUS_VAR='REMOTE CODE EXPLOIT HERE',
575+ ))
576+ self.assertIn(b'Redirecting...', ret.data)
577+ self.assertEqual(ret.status_code, 303)
578+ env = popen.mock_calls[0][2]['env']
579+ self.assertSequenceEqual(popen.mock_calls, [
580+ call((join(Config.ROOT, 'bileto', 'worker', 'main.py'),), env=env),
581+ ])
582+ self.assertEqual(env['ACTION'], 'publish')
583+ self.assertEqual(env['REQUEST_PATH'], '/log/1/publish/latest/')
584+ self.assertEqual(env['ACK_PACKAGING'], 'ACK_PACKAGING')
585+ self.assertNotIn('MALICIOUS_VAR', env)
586+
587 def test_cancel(self):
588 """Test that jobs can be cancelled."""
589 root = join(self.tempdir, 'log')
590
591=== modified file 'tests/test_worker_manager.py'
592--- tests/test_worker_manager.py 2016-08-17 20:59:59 +0000
593+++ tests/test_worker_manager.py 2016-08-22 16:49:27 +0000
594@@ -129,8 +129,8 @@
595
596 @patch('bileto.worker.manager.Manager.names')
597 @patch('bileto.worker.manager.Manager.merges')
598- @patch('bileto.worker.manager.Manager.__getattr__')
599- def test_manager_types_revert(self, attr, merges, names):
600+ @patch('bileto.worker.manager.Manager.call_on_all_packages')
601+ def test_manager_types_revert(self, call_on_all, merges, names):
602 """Correctly choose what revert types to build with."""
603 merges.return_value = dict()
604 names.return_value = {'foo', 'bar'}
605@@ -141,12 +141,12 @@
606 Merge: set(),
607 Revert: {'foo', 'bar'},
608 })
609- attr.assert_called_once_with('collect')
610+ call_on_all.assert_called_once_with('collect')
611
612 @patch('bileto.worker.manager.Manager.names')
613 @patch('bileto.worker.manager.Manager.merges')
614- @patch('bileto.worker.manager.Manager.__getattr__')
615- def test_manager_types_revert_no_MPs(self, attr, merges, names):
616+ @patch('bileto.worker.manager.Manager.call_on_all_packages')
617+ def test_manager_types_revert_no_MPs(self, call_on_all, merges, names):
618 """Do not attempt to revert merges."""
619 merges.return_value = dict(foo=[Mock()])
620 names.return_value = {'foo', 'bar'}
621@@ -257,14 +257,14 @@
622 call.validate(),
623 ])
624
625- @patch('bileto.worker.manager.Manager.__getattr__')
626- def test_manager_upload(self, getattr_mock):
627+ @patch('bileto.worker.manager.Manager.call_on_all_packages')
628+ def test_manager_upload(self, call_on_all_packages):
629 """Prevent cancellation after uploading."""
630 log.cancellable = True
631 ticket = Mock()
632 manager = Manager(ticket)
633 manager.upload()
634- getattr_mock.assert_called_once_with('upload')
635+ call_on_all_packages.assert_called_once_with('upload')
636 assert not log.cancellable
637
638 @patch('bileto.worker.manager.Package')
639@@ -311,7 +311,9 @@
640 ('xenial/media-hub', 'Successfully built', None),
641 ('yakkety/media-hub', 'Successfully built', None),
642 ]
643- ticket = Mock(status='Preparing packages.')
644+ ticket = Mock(
645+ status='Preparing packages.',
646+ lockfile=join(self.tempdir, 'lockfile'))
647 manager = Manager(ticket)
648 manager.status()
649 ticket.set_status.assert_called_once_with(
650@@ -347,7 +349,9 @@
651 ('xenial/media-hub', 'Successfully built', None),
652 ('yakkety/media-hub', 'Successfully built', None),
653 ]
654- ticket = Mock(status='Preparing packages.')
655+ ticket = Mock(
656+ status='Preparing packages.',
657+ lockfile=join(self.tempdir, 'lockfile'))
658 manager = Manager(ticket)
659 manager.status()
660 ticket.set_status.assert_called_once_with(
661@@ -368,7 +372,9 @@
662 ('xenial/media-hub', 'Release pocket', '2'),
663 ('yakkety/media-hub', 'Updates pocket', '3'),
664 ]
665- ticket = Mock(status='Preparing packages.')
666+ ticket = Mock(
667+ status='Preparing packages.',
668+ lockfile=join(self.tempdir, 'lockfile'))
669 manager = Manager(ticket)
670 manager.status()
671 ticket.set_status.assert_called_once_with(
672@@ -378,6 +384,60 @@
673 '"2", "yakkety": "3"}}')
674 Acts.finalize.assert_called_once_with(manager)
675
676+ @patch('bileto.worker.manager.parallelize')
677+ @patch('bileto.worker.manager.Manager.packages')
678+ def test_manager_prepublish(self, packages, parallelize):
679+ """Allow publish to proceed."""
680+ packages.return_value = {}
681+ parallelize.return_value = [
682+ ('vivid/media-hub', None),
683+ ('xenial/media-hub', None),
684+ ('yakkety/media-hub', None),
685+ ]
686+ ticket = Mock()
687+ manager = Manager(ticket)
688+ self.assertEqual(manager.prepublish(), manager) # No exception raised
689+
690+ @patch('bileto.worker.manager.parallelize')
691+ @patch('bileto.worker.manager.Manager.packages')
692+ def test_manager_prepublish_abort(self, packages, parallelize):
693+ """Prevent publication when problems found."""
694+ packages.return_value = {}
695+ parallelize.return_value = [
696+ ('vivid/media-hub', 'Diff missing'),
697+ ('xenial/media-hub', 'Diff missing'),
698+ ('yakkety/media-hub', 'Dependency wait'),
699+ ]
700+ ticket = Mock()
701+ manager = Manager(ticket)
702+ with self.assertRaisesRegex(BiletoError, 'Publish failed: Dep'):
703+ manager.prepublish()
704+
705+ @patch('bileto.worker.manager.rename')
706+ @patch('bileto.worker.manager.makedirs')
707+ @patch('bileto.worker.manager.parallelize')
708+ @patch('bileto.worker.manager.Manager.packages')
709+ @patch('bileto.worker.manager.Package.manifest', ['a\n', 'b\n', 'c\n'])
710+ def test_manager_publish(self, packages, parallelize, makedirs, rename):
711+ """Do publishy things."""
712+ packages.return_value = {}
713+ parallelize.return_value = [
714+ ('vivid/media-hub', None),
715+ ('xenial/media-hub', None),
716+ ('yakkety/media-hub', None),
717+ ]
718+ ticket = Mock(siloname='ubuntu/landing-555')
719+ manager = Manager(ticket)
720+ manager.publish()
721+ makedirs.assert_called_once_with(
722+ join(self.tempdir, 'publish'), exist_ok=True)
723+ filename = 'packagelist_rsync_ubuntu-landing-555'
724+ tmpfile = join(self.tempdir, filename)
725+ path = join(self.tempdir, 'publish', filename)
726+ rename.assert_called_once_with(filename, path)
727+ with open(tmpfile, encoding='utf-8') as data:
728+ self.assertEqual(data.read().strip(), 'a\nb\nc')
729+
730 @patch('bileto.worker.manager.Manager.ppa')
731 @patch('bileto.worker.vcs.create_subprocess_exec', POST_NUKE)
732 def test_manager_nuke(self, ppa):
733
734=== added file 'tests/test_worker_manual.py'
735--- tests/test_worker_manual.py 1970-01-01 00:00:00 +0000
736+++ tests/test_worker_manual.py 2016-08-22 16:49:27 +0000
737@@ -0,0 +1,28 @@
738+"""Bileto Manual Test
739+
740+Test that manual packages behave correctly.
741+"""
742+
743+from os import environ
744+
745+from unittest.mock import patch
746+
747+from tests.tests import WorkerTestCase
748+
749+from bileto.worker.manual import Manual
750+
751+
752+class ManualTestCase(WorkerTestCase):
753+ """Test Manual Packages."""
754+
755+ @patch('bileto.worker.package.Package.check_package_ack')
756+ def test_manual_check_package_ack(self, check_package_ack):
757+ """Require upload rights for all manual sources."""
758+ environ['BLAME'] = 'robru'
759+ manual = Manual('oxide', self.distro, self.series, self.dest, self.ppa)
760+ manual.authorized = True
761+ self.assertEqual(self.run_coro(manual.check_package_ack()), None)
762+ manual.authorized = False
763+ self.assertEqual(
764+ self.run_coro(manual.check_package_ack()),
765+ 'robru not authorized to upload oxide')
766
767=== modified file 'tests/test_worker_merge.py'
768--- tests/test_worker_merge.py 2016-07-21 17:10:54 +0000
769+++ tests/test_worker_merge.py 2016-08-22 16:49:27 +0000
770@@ -11,7 +11,7 @@
771 from tests.tests import WorkerTestCase, fake_subproc
772
773 from bileto.settings import Config
774-from bileto.worker.merge import Merge
775+from bileto.worker.merge import BUILDABLE_STATES, PUBLISHABLE_STATES, Merge
776
777
778 STD = (b'', b'')
779@@ -309,3 +309,13 @@
780 call(join(Config.ROOT, 'scripts', 'vcs.sh'),
781 'lp:ofono', env=env, stdout=PIPE, stderr=PIPE),
782 ])
783+
784+ @patch('bileto.worker.package.Package.prepublish')
785+ def test_merge_prepublish(self, prepublish):
786+ """Set acceptable merge states for publishing."""
787+ Merge.all_mps = dict(ofono=[self.mp])
788+ merge = Merge('ofono', self.distro, self.series, self.dest, self.ppa)
789+ self.assertEqual(merge.acceptable, BUILDABLE_STATES)
790+ self.run_coro(merge.prepublish())
791+ self.assertEqual(merge.acceptable, PUBLISHABLE_STATES)
792+ prepublish.assert_called_once_with()
793
794=== modified file 'tests/test_worker_package.py'
795--- tests/test_worker_package.py 2016-07-21 17:10:54 +0000
796+++ tests/test_worker_package.py 2016-08-22 16:49:27 +0000
797@@ -3,10 +3,14 @@
798 Test the steps for building a package.
799 """
800
801+from os import environ
802 from os.path import join
803+
804 from subprocess import PIPE
805 from unittest.mock import Mock, call, patch
806
807+from lazr.restfulclient.errors import ClientError
808+
809 from tests.tests import WorkerTestCase, fake_subproc
810
811 from bileto.settings import Config
812@@ -37,6 +41,10 @@
813 class PackageTestCase(WorkerTestCase):
814 """Test Packages."""
815
816+ def setUp(self):
817+ super().setUp()
818+ Package.manifest.clear()
819+
820 def test_package_status_ready_to_build(self):
821 """Report Ready to build when no package in PPA."""
822 self.ppa.getPublishedSources.return_value = []
823@@ -243,3 +251,117 @@
824 call(source_name='dif', distro_series=self.series,
825 exact_match=True, order_by_date=True),
826 ])
827+
828+ def test_package_prepublish_ready_to_build(self):
829+ """Report Ready to build when no package in PPA."""
830+ self.ppa.getPublishedSources.return_value = []
831+ package = Package('zap', self.distro, self.series, self.dest, self.ppa)
832+ self.assertEqual(
833+ self.run_coro(package.prepublish()),
834+ ('xenial/zap', 'Ready to build'))
835+
836+ def test_package_prepublish_package_ack_unauthorized(self):
837+ """Report when user not authorized to ack."""
838+ environ['BLAME'] = 'robru'
839+ environ['ACK_PACKAGING'] = 'yes'
840+ self.ppa.getPublishedSources.return_value = [
841+ Mock(source_package_version='42')]
842+ self.dest.getPublishedSources.return_value = []
843+ i386 = Mock(arch_tag='i386', buildstate='Successfully built')
844+ source = Mock(source_package_version='42')
845+ source.getBuilds.return_value = [i386]
846+ self.ppa.getPublishedSources.return_value = [source]
847+ self.series.main_archive.getPublishedSources.return_value = []
848+ self.series.getPackageUploads.return_value = Listish()
849+ package = Package('zap', self.distro, self.series, self.dest, self.ppa)
850+ package.artifacts = (
851+ 'time/xenial_zap_content.diff (1 MILLION LINES)\n'
852+ 'time/xenial_zap_packaging_changes.diff (1 THOUSAND LINES)\n')
853+ package.series.main_archive.checkUpload.side_effect = ClientError(0, 0)
854+ self.assertEqual(
855+ self.run_coro(package.prepublish()),
856+ ('xenial/zap', 'Packaging diff requires ACK'))
857+ self.assertFalse(package.authorized)
858+
859+ def test_package_prepublish_package_ack_authorized(self):
860+ """Report when user not authorized to ack."""
861+ environ['BLAME'] = 'robru'
862+ environ['ACK_PACKAGING'] = 'yes'
863+ self.ppa.getPublishedSources.return_value = [
864+ Mock(source_package_version='42')]
865+ self.dest.getPublishedSources.return_value = []
866+ i386 = Mock(arch_tag='i386', buildstate='Successfully built')
867+ source = Mock(source_package_version='42')
868+ source.getBuilds.return_value = [i386]
869+ source.getPublishedBinaries.return_value = [
870+ Mock(architecture_specific=1, distro_arch_series_link='./i386'),
871+ ]
872+ self.ppa.getPublishedSources.return_value = [source]
873+ self.series.main_archive.getPublishedSources.return_value = [source]
874+ self.series.getPackageUploads.return_value = Listish()
875+ package = Package('zap', self.distro, self.series, self.dest, self.ppa)
876+ package.artifacts = (
877+ 'time/xenial_zap_content.diff (1 MILLION LINES)\n'
878+ 'time/xenial_zap_packaging_changes.diff (1 THOUSAND LINES)\n')
879+ package.series.main_archive.checkUpload.return_value = True
880+ self.assertEqual(
881+ self.run_coro(package.prepublish()),
882+ ('xenial/zap', None))
883+ self.assertTrue(package.authorized)
884+
885+ @patch('bileto.worker.package.Config.ppa_team', 'glort')
886+ def test_package_publish_manifest(self):
887+ """Publish a package to ubuntu archive."""
888+ environ['BLAME'] = 'robobert'
889+ source = Mock(source_package_version='90')
890+ source.distro_series_link = 'foo/bar/ubuntu/yakkety'
891+ source.pocket = 'Release'
892+ self.ppa.getPublishedSources.return_value = [source]
893+ dest = Mock(source_package_version='80')
894+ self.dest.getPublishedSources.return_value = [dest]
895+ self.dest.self_link = self.series.main_archive_link = 'ubuntu archive'
896+ package = Package('zap', self.distro, self.series, self.dest, self.ppa)
897+ self.run_coro(package.publish())
898+ self.assertEqual(package.manifest, [
899+ 'glort/ubuntu/12321\tRelease\tyakkety\tProposed\tyakkety\tzap\t90'
900+ '\t80\trobobert\tubuntu\n'])
901+
902+ @patch('bileto.worker.package.Config.ppa_team', 'glort')
903+ def test_package_publish_copypackage(self):
904+ """Publish a package to overlay ppa."""
905+ environ['BLAME'] = 'robobert'
906+ source = Mock(source_package_version='90')
907+ source.distro_series_link = 'foo/bar/ubuntu/yakkety'
908+ source.pocket = 'Release'
909+ self.ppa.getPublishedSources.return_value = [source]
910+ dest = Mock(source_package_version='80')
911+ self.dest.getPublishedSources.return_value = [dest]
912+ self.series.main_archive_link = 'ubuntu archive'
913+ self.dest.self_link = 'some other place'
914+ package = Package('zap', self.distro, self.series, self.dest, self.ppa)
915+ self.run_coro(package.publish())
916+ self.assertEqual(package.manifest, [])
917+ self.dest.copyPackage.assert_called_once_with(
918+ from_archive=self.ppa,
919+ from_pocket='Release',
920+ from_series='xenial',
921+ include_binaries=True,
922+ source_name='zap',
923+ to_pocket='Release',
924+ to_series='xenial',
925+ version='90')
926+
927+ @patch('bileto.worker.package.Config.ppa_team', 'glort')
928+ def test_package_publish_skipped(self):
929+ """Skip publishing because version is lower than dest."""
930+ environ['BLAME'] = 'robobert'
931+ source = Mock(source_package_version='70')
932+ source.distro_series_link = 'foo/bar/ubuntu/yakkety'
933+ source.pocket = 'Release'
934+ self.ppa.getPublishedSources.return_value = [source]
935+ dest = Mock(source_package_version='80')
936+ self.dest.getPublishedSources.return_value = [dest]
937+ package = Package('zap', self.distro, self.series, self.dest, self.ppa)
938+ self.run_coro(package.publish())
939+ self.assertEqual(package.manifest, [])
940+ self.assertEqual(self.dest.copyPackage.mock_calls, [])

Subscribers

People subscribed via source and target branches