Merge autopkgtest-cloud:sil2100/private-testing into autopkgtest-cloud:master

Proposed by Łukasz Zemczak
Status: Merged
Merged at revision: 86628ad96f6f0c47933ec4de9e0689e57b2869da
Proposed branch: autopkgtest-cloud:sil2100/private-testing
Merge into: autopkgtest-cloud:master
Diff against target: 725 lines (+386/-52)
14 files modified
charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest (+19/-0)
charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker (+89/-35)
charms/focal/autopkgtest-web/config.yaml (+4/-0)
charms/focal/autopkgtest-web/layer.yaml (+1/-0)
charms/focal/autopkgtest-web/reactive/autopkgtest_web.py (+34/-0)
charms/focal/autopkgtest-web/webcontrol/cache-amqp (+12/-1)
charms/focal/autopkgtest-web/webcontrol/download-all-results (+1/-1)
charms/focal/autopkgtest-web/webcontrol/download-results (+1/-1)
charms/focal/autopkgtest-web/webcontrol/helpers/utils.py (+14/-0)
charms/focal/autopkgtest-web/webcontrol/private-results.cgi (+11/-0)
charms/focal/autopkgtest-web/webcontrol/private_results/app.py (+190/-0)
charms/focal/autopkgtest-web/webcontrol/request/app.py (+3/-14)
charms/focal/autopkgtest-web/webcontrol/request/submit.py (+6/-0)
mojo/service-bundle (+1/-0)
Reviewer Review Type Date Requested Status
Iain Lane Approve
Steve Langasek (community) Needs Fixing
Review via email: mp+399668@code.launchpad.net

Commit message

Add support for running private tests and using private PPAs.

Description of the change

Add support for running private tests and using private PPAs.

To post a comment you must log in.
Revision history for this message
Steve Langasek (vorlon) wrote :

Looks sane in general. A few comments inline. I have not reviewed to verify there are no other places in the code that we could have information leaks.

review: Needs Fixing
Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Ok, addressed your comments, this should be good for a final re-review.

Regarding the keying of the piece of code on 'fingerprint' - this is necessary as all of the operations that are being done in that conditional only make sense when we don't provide the PPA fingerprint in the request. Since if the fingerprint is present in the PPA definition, we don't need to query the LP API to get it (and we can't for private PPAs anyway).

Could you take a look now? Thank you!

Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Actually I almost forgot about one thing and I'm working on it right now: since the test results are private, the britney ADT report will have SWIFT urls to the results which will not works (as the container is private) - but we can have a small SSO protected wrapper to fetch the results for us in webcontrol.

Revision history for this message
Iain Lane (laney) wrote :

Thanks, Łukasz, I think this is a great contribution! I've left you a whole bunch of comments inline.

Additonally, could you please write something for the documentation explaining how this side fits together? That's in docs/, and if you don't know then it's worth knowing that VSCode has nice .rst support - live previews. :-)

Have you run this proposal past the people who will be consuming the feature? I'd like to see a signoff from them I think, to make sure what's been designed works, like having to have a public LP team to view the results.

How are the results going to be downloaded by britney (this uses the HTTP API)? Maybe if we do the X-Auth-Token thing britney could be taught to send that in its requests.

review: Needs Fixing
Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Hey! Thank you for the review! I will address some of the immediate issues ASAP, some others I'd still like to discuss. Please see the below inline comment follow ups!

Oh, and +1 on documentation. I'll make sure to work on this this week as a priority. I'll once again run it though the security team, but I already gave them an overview of how things are supposed to work.

Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Ok, so I pushed most of the changes that have been requested. Please see previous diff comments for context as I had some counter-comments to some review items.

That being said, I will now actually look into using X-Auth-Token. At first I was not very fond of this idea, but during my time off I actually started thinking: why not? Less credentials and possibly should be faster as well. So yeah, will push something in a bit.

Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Ok, this should now be back to being reviewable. As said, please check the previous inline comments from the 19th, but I did change to using X-Auth-Token - even makes me think if maybe I should do the same for the britney parts..? Will look into that!

Added some charm parts but uh, since I never worked with this framework before please give me a sign if I forgot something. I edited it once as I forgot that we have SwiftURL in autopkgtest-cloud.conf already so we shouldn't need to include it in the secrets separately (so only token is needed).

Revision history for this message
Iain Lane (laney) wrote :

Really sorry, I think this is my fault for not knowing better. I asked for X-Auth-Token but it seems like those are only valid for 1(?) hour as configured by the cloud. To use these we'd need a way to push new values to the web frontend as necessary and that's probably not feasible really.

I just asked IS if this is possible instead:

  - They generate for us a new user per customer
  - We grant this new user read-only access to private artefacts it should be able to see
  - Creds for this user are the ones used by autopkgtest-web and britney etc

That should at least limit the attack surface to leaking some/all private test results, but not place credentials which have read-write access on the web-accessible frontends.

If you like this, I'll help implement this change, since I feel bad that I led you down an invalid path. :(

Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Hey! So this should be good to go then. No worries. So in the end I really liked the X-Auth-Token idea myself, which is why I switched to using that so eagerly. I saw that the token generated by `openstack token issue` is valid only for 1 hour but I assumed, just like with the tempurl stuff, that we can simply set our own expiration date while generating the token. I did not check that though, and as you said it seems to be impossible (as it's hard coded by the server config).

So yeah, as per your request I have reverted back to using full swift credentials (and swiftclient). I'll go and to the same modification on the britney branch side in a moment. I added charm bits to what we already had and cleaned up some of the naming of the config so that it's clear those are web credentials.

I think in this case one thing we might think about in the next iteration is getting rid of the 'swiftuser' request parameter in favor of hard coding that. But on the other hand having it still gives us some more flexibility. I mean, for safety, we could then have 3 sets of swift credentials: the main ones used on the workers (with write access, sensitive), then a main web-frontend read user to be used here by private-results (with full read access everywhere) and then one that we'd hold in the britney instance that would only have read access to the results that it submitted (so passed via 'swiftuser'). We can of course make the two last ones the same user if we don't feel it's too risky too.

Revision history for this message
Iain Lane (laney) wrote :

Canonical people can see ticket #131365 for creating a new read-only user for the proxy to use.

Once that's completed, assuming it doesn't take too long, I'll build a charm from this branch and deploy it into staging.

Maybe for the next iteration I'd like us to think about

  - Should we have read only creds per-container? Idea is to try to limit the exposure if we mess up somehow, but not sure how much it matters.
  - As Łukasz says, getting rid of the swiftuser parameter; I feel like it should be able to be inferred somehow from the rest of the request (i.e. basically hardcoded, maybe in configuration in some way).

Revision history for this message
Łukasz Zemczak (sil2100) wrote :

Hey! Could someone take a look if this is now good with the new history and get it merged? Thanks!

Revision history for this message
Iain Lane (laney) wrote :

Yep, sorry for the delay!

The only think I'm missing is documentation (inside docs/). I'll merge but could you please add some in the next few days?

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest b/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest
2index 5c2bd67..febd6ea 100755
3--- a/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest
4+++ b/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest
5@@ -82,6 +82,21 @@ def parse_args():
6 "Otherwise this has the same behaviour as test-git.",
7 )
8 parser.add_argument(
9+ "--swiftuser",
10+ help="Swift username for private result handling. "
11+ "The new ADT infrastructure now supports running private tests. "
12+ "Such tests will put their rest results in private containers "
13+ "only readable by the selected swiftuser.",
14+ )
15+ parser.add_argument(
16+ "--readable-by",
17+ action="append",
18+ default=[],
19+ metavar="LP team/username",
20+ help="Make private result readable by the selected LP user/team. "
21+ "Can be specified multiple times.",
22+ )
23+ parser.add_argument(
24 "--all-proposed",
25 action="store_true",
26 help="Disable apt pinning and use all of -proposed",
27@@ -146,6 +161,10 @@ if __name__ == "__main__":
28 if args.test_bzr:
29 params["test-bzr"] = args.test_bzr
30 context = "upstream-"
31+ if args.swiftuser:
32+ params["swiftuser"] = args.swiftuser
33+ if args.readable_by:
34+ params["readable-by"] = args.readable_by
35 if args.all_proposed:
36 params["all-proposed"] = True
37 try:
38diff --git a/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker b/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker
39index e8f85eb..8866f2c 100755
40--- a/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker
41+++ b/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker
42@@ -306,6 +306,10 @@ def process_output_dir(dir, pkgname, code, triggers):
43 subprocess.check_call(['gzip', '-9', os.path.join(dir, 'log')])
44 files.discard('log')
45
46+ # the readable-by file, if present, needs to stay intact and be uploaded
47+ # to the container as is, as it's used for ACL
48+ files.discard('readable-by')
49+
50 if files:
51 # tar up all other artifacts
52 subprocess.check_call(['tar', '-czf', 'artifacts.tar.gz'] + list(files), cwd=dir)
53@@ -360,26 +364,31 @@ def subst(s, big_package, release, architecture, hostarch, pkgname):
54 return s
55
56
57-def send_status_info(queue, release, architecture, pkgname, params, out_dir, running, duration):
58+def send_status_info(queue, release, architecture, pkgname, params, out_dir, running, duration, private=False):
59 '''Send status and logtail to status queue'''
60
61 if not queue:
62 return
63
64- # print('status_info:', release, architecture, pkgname, out_dir, running)
65- try:
66- with open(os.path.join(out_dir, 'log'), 'rb') as f:
67- try:
68- f.seek(-2000, os.SEEK_END)
69- # throw away the first line as we almost surely cut that out in
70- # the middle
71- f.readline()
72- except IOError:
73- # file is smaller than 2000 bytes? okay
74- pass
75- logtail = f.read().decode('UTF-8', errors='replace')
76- except (IOError, OSError) as e:
77- logtail = 'Cannot read log file: %s' % e
78+ if private:
79+ pkgname = 'private-test'
80+ params = {}
81+ logtail = 'Running private test'
82+ else:
83+ # print('status_info:', release, architecture, pkgname, out_dir, running)
84+ try:
85+ with open(os.path.join(out_dir, 'log'), 'rb') as f:
86+ try:
87+ f.seek(-2000, os.SEEK_END)
88+ # throw away the first line as we almost surely cut that out in
89+ # the middle
90+ f.readline()
91+ except IOError:
92+ # file is smaller than 2000 bytes? okay
93+ pass
94+ logtail = f.read().decode('UTF-8', errors='replace')
95+ except (IOError, OSError) as e:
96+ logtail = 'Cannot read log file: %s' % e
97
98 msg = json.dumps({'release': release,
99 'architecture': architecture,
100@@ -390,8 +399,7 @@ def send_status_info(queue, release, architecture, pkgname, params, out_dir, run
101 'logtail': logtail})
102 queue.basic_publish(amqp.Message(msg, delivery_mode=2), status_exchange_name, '')
103
104-
105-def call_autopkgtest(argv, release, architecture, pkgname, params, out_dir, start_time):
106+def call_autopkgtest(argv, release, architecture, pkgname, params, out_dir, start_time, private=False):
107 '''Call autopkgtest and regularly send status/logtail to status_exchange_name
108
109 Return exit code.
110@@ -412,11 +420,13 @@ def call_autopkgtest(argv, release, architecture, pkgname, params, out_dir, star
111 status_update_counter = (status_update_counter + 1) % 10
112 if status_update_counter == 0:
113 send_status_info(status_amqp, release, architecture, pkgname,
114- params, out_dir, True, int(time.time() - start_time))
115+ params, out_dir, True, int(time.time() - start_time),
116+ private)
117
118 ret = autopkgtest.wait()
119 send_status_info(status_amqp, release, architecture, pkgname, params,
120- out_dir, False, int(time.time() - start_time))
121+ out_dir, False, int(time.time() - start_time),
122+ private)
123
124 return ret
125
126@@ -454,6 +464,7 @@ def request(msg):
127 read_per_package_configs(cfg)
128
129 dont_run = False
130+ private = False
131
132 # FIXME: make this more elegant
133 fields = msg.delivery_info['routing_key'].split('-')
134@@ -565,42 +576,60 @@ def request(msg):
135 if 'ppas' in params and params['ppas']:
136 for ppa in params['ppas']:
137 try:
138- (ppauser, ppaname) = ppa.split('/')
139+ (ppacreds, _, ppaurl) = ppa.rpartition('@')
140+ (ppaurl, _, fingerprint) = ppaurl.partition(':')
141+ (ppacreds_user, ppacreds_pass) = ppacreds.split(':') if ppacreds else (None, None)
142+ (ppauser, ppaname) = ppaurl.split('/')
143 except ValueError:
144- logging.error('Invalid PPA specification, must be lpuser/ppa_name')
145+ logging.error('Invalid PPA specification, must be [user:token@]lpuser/ppa_name[:fingerprint]')
146 msg.channel.basic_ack(msg.delivery_tag)
147 return
148- for retry in range(5):
149- try:
150+ if fingerprint:
151+ logging.debug('Request states that PPA user %s, name %s has GPG fingerprint %s' % (ppauser, ppaname, fingerprint))
152+ else:
153+ # Private PPAs require the fingerprint passed through the
154+ # request as we can't use the LP API to fetch it.
155+ if ppacreds_user:
156+ logging.error('Invalid PPA specification, GPG fingerprint required for private PPAs')
157+ msg.channel.basic_ack(msg.delivery_tag)
158+ return
159+ for retry in range(5):
160 try:
161 f = urllib.request.urlopen('https://api.launchpad.net/1.0/~%s/+archive/ubuntu/%s' % (ppauser, ppaname))
162 contents = f.read().decode('UTF-8')
163 f.close()
164 fingerprint = json.loads(contents)['signing_key_fingerprint']
165 logging.debug('PPA user %s, name %s has GPG fingerprint %s' % (ppauser, ppaname, fingerprint))
166+ except (IOError, ValueError, KeyError) as e:
167+ logging.error('Cannot get PPA information: "%s". Consuming the request - it will be left dangling; retry once the problem is resolved.' % e)
168+ msg.channel.basic_ack(msg.delivery_tag)
169+ return
170 except HTTPError as e:
171- # It's quite common to get 503s from LP; retry a few times.
172+ # It's quite common to get 503s from LP; retry a
173+ # few times.
174 if e.code != 503:
175 raise
176 logging.warning('Got error 503 from launchpad API')
177 time.sleep(10)
178 else:
179 break
180- except (IOError, ValueError, KeyError) as e:
181- logging.error('Cannot get PPA information: "%s". Consuming the request - it will be left dangling; retry once the problem is resolved.' % e)
182+ else:
183+ logging.error('Cannot contact Launchpad to get PPA information. Consuming the request - it will be left dangling; retry once the problem is resolved.')
184 msg.channel.basic_ack(msg.delivery_tag)
185 return
186+ if ppacreds_user:
187+ # Any run with at least one private PPA needs to be private.
188+ private = True
189+ ppaprefix = 'https://%s:%s@private-' % (ppacreds_user, ppacreds_pass)
190 else:
191- logging.error('Cannot contact Launchpad to get PPA information. Consuming the request - it will be left dangling; retry once the problem is resolved.')
192- msg.channel.basic_ack(msg.delivery_tag)
193- return
194+ ppaprefix = 'http://'
195 # add GPG key
196 argv += ['--setup-commands', 'apt-key adv --keyserver keyserver.ubuntu.com --recv-key ' + fingerprint]
197 # add apt source
198 argv += ['--setup-commands', 'REL=$(sed -rn "/^(deb|deb-src) .*(ubuntu.com|ftpmaster)/ { s/^[^ ]+ +(\[.*\] *)?[^ ]* +([^ -]+) +.*$/\\2/p; q }" /etc/apt/sources.list); '
199- 'echo "deb http://ppa.launchpad.net/%(u)s/%(p)s/ubuntu $REL main" > /etc/apt/sources.list.d/autopkgtest-%(u)s-%(p)s.list; '
200- 'echo "deb-src http://ppa.launchpad.net/%(u)s/%(p)s/ubuntu $REL main" >> /etc/apt/sources.list.d/autopkgtest-%(u)s-%(p)s.list;' %
201- {'u': ppauser, 'p': ppaname}]
202+ 'echo "deb %(prefix)sppa.launchpad.net/%(u)s/%(p)s/ubuntu $REL main" > /etc/apt/sources.list.d/autopkgtest-%(u)s-%(p)s.list; '
203+ 'echo "deb-src %(prefix)sppa.launchpad.net/%(u)s/%(p)s/ubuntu $REL main" >> /etc/apt/sources.list.d/autopkgtest-%(u)s-%(p)s.list;' %
204+ {'prefix': ppaprefix, 'u': ppauser, 'p': ppaname}]
205
206 # put results into separate container, named by the last PPA
207 container += '-%s-%s' % (ppauser, ppaname)
208@@ -714,6 +743,19 @@ def request(msg):
209 host_arch(release, architecture),
210 pkgname).split()
211
212+ if 'swiftuser' in params:
213+ private = True
214+ elif private:
215+ # Some combination already marked the run as private, but no
216+ # swiftuser user has been specified. This is not valid, as otherwise
217+ # no one would be realistically able to read back the results.
218+ logging.error('Private autopkgtest run detected but no swiftuser identity provided.')
219+ msg.channel.basic_ack(msg.delivery_tag)
220+ return
221+
222+ if private:
223+ container = 'private-{}'.format(container)
224+
225 # run autopkgtest; retry up to three times on tmpfail issues
226 if not dont_run:
227 global running_test
228@@ -723,7 +765,8 @@ def request(msg):
229 for retry in range(3):
230 retry_start_time = time.time()
231 logging.info('Running %s', ' '.join(argv))
232- code = call_autopkgtest(argv, release, architecture, pkgname, params, out_dir, start_time)
233+ code = call_autopkgtest(argv, release, architecture, pkgname, params, out_dir, start_time,
234+ private)
235
236 is_failure = code in FAIL_CODES
237 files = set(os.listdir(out_dir))
238@@ -813,6 +856,13 @@ def request(msg):
239 with open(os.path.join(out_dir, 'requester'), 'w') as f:
240 f.write('%s\n' % params['requester'])
241
242+ if 'readable-by' in params:
243+ with open(os.path.join(out_dir, 'readable-by'), 'w') as f:
244+ if isinstance(params['readable-by'], list):
245+ f.write('\n'.join(params['readable-by']))
246+ else:
247+ f.write('%s\n' % params['readable-by'])
248+
249 (testpkg_version, duration, requester) = process_output_dir(out_dir,
250 pkgname,
251 code,
252@@ -840,8 +890,12 @@ def request(msg):
253 swift_con.get_container(container, limit=1)
254 except swiftclient.exceptions.ClientException:
255 logging.info('container %s does not exist, creating it', container)
256- # make it publicly readable
257- swift_con.put_container(container, headers={'X-Container-Read': '.rlistings,.r:*'})
258+ if private:
259+ # private result, share only with swiftuser
260+ swift_con.put_container(container, headers={'X-Container-Read': '*:%s' % params['swiftuser']})
261+ else:
262+ # make it publicly readable
263+ swift_con.put_container(container, headers={'X-Container-Read': '.rlistings,.r:*'})
264 # wait until it exists
265 timeout = 50
266 while timeout > 0:
267diff --git a/charms/focal/autopkgtest-web/config.yaml b/charms/focal/autopkgtest-web/config.yaml
268index 9786139..5b82f55 100644
269--- a/charms/focal/autopkgtest-web/config.yaml
270+++ b/charms/focal/autopkgtest-web/config.yaml
271@@ -19,6 +19,10 @@ options:
272 type: string
273 default:
274 description: "project:user:token github credentials for POSTing to statuses_url"
275+ swift-web-credentials:
276+ type: string
277+ default:
278+ description: "SWIFT login credentials for the private-result web frontend"
279 https-proxy:
280 type: string
281 default:
282diff --git a/charms/focal/autopkgtest-web/layer.yaml b/charms/focal/autopkgtest-web/layer.yaml
283index 2c93ec9..9e2819d 100644
284--- a/charms/focal/autopkgtest-web/layer.yaml
285+++ b/charms/focal/autopkgtest-web/layer.yaml
286@@ -18,3 +18,4 @@ options:
287 - python3-distro-info
288 - python3-flask
289 - python3-flask-openid
290+ - python3-swiftclient
291diff --git a/charms/focal/autopkgtest-web/reactive/autopkgtest_web.py b/charms/focal/autopkgtest-web/reactive/autopkgtest_web.py
292index 7fd1e6b..118d4ed 100644
293--- a/charms/focal/autopkgtest-web/reactive/autopkgtest_web.py
294+++ b/charms/focal/autopkgtest-web/reactive/autopkgtest_web.py
295@@ -22,6 +22,9 @@ GITHUB_SECRETS_PATH = os.path.expanduser("~ubuntu/github-secrets.json")
296 GITHUB_STATUS_CREDENTIALS_PATH = os.path.expanduser(
297 "~ubuntu/github-status-credentials.txt"
298 )
299+SWIFT_WEB_CREDENTIALS_PATH = os.path.expanduser(
300+ "~ubuntu/swift-web-credentials.conf"
301+)
302
303
304 @when_not("autopkgtest-web.autopkgtest_web_symlinked")
305@@ -193,6 +196,7 @@ def set_up_web_config(apache):
306 ScriptAlias /request.cgi {webcontrol_dir}/request.cgi/
307 ScriptAlias /login {webcontrol_dir}/request.cgi/login
308 ScriptAlias /logout {webcontrol_dir}/request.cgi/logout
309+ ScriptAlias /private-results {webcontrol_dir}/private-results.cgi/
310 ScriptAlias / {webcontrol_dir}/browse.cgi/
311
312 {server_name}
313@@ -236,6 +240,36 @@ def clear_github_secrets():
314 pass
315
316
317+@when_all("config.changed.swift-web-credentials",
318+ "config.set.swift-web-credentials")
319+def write_swift_web_credentials():
320+ swift_credentials = config().get("swift-web-credentials")
321+
322+ with open(SWIFT_WEB_CREDENTIALS_PATH, "w") as f:
323+ f.write(swift_credentials)
324+
325+ try:
326+ os.symlink(
327+ SWIFT_WEB_CREDENTIALS_PATH,
328+ os.path.expanduser("~www-data/swift-web-credentials.conf"),
329+ )
330+ except FileExistsError:
331+ pass
332+
333+
334+@when_not("config.set.swift-web-credentials")
335+def clear_swift_web_credentials():
336+ try:
337+ os.unlink(SWIFT_WEB_CREDENTIALS_PATH)
338+ except FileNotFoundError:
339+ pass
340+
341+ try:
342+ os.unlink(os.path.expanduser("~www-data/swift-web-credentials.conf"))
343+ except FileNotFoundError:
344+ pass
345+
346+
347 @when_all(
348 "config.changed.github-status-credentials",
349 "config.set.github-status-credentials",
350diff --git a/charms/focal/autopkgtest-web/webcontrol/cache-amqp b/charms/focal/autopkgtest-web/webcontrol/cache-amqp
351index bc5df78..0a231e9 100755
352--- a/charms/focal/autopkgtest-web/webcontrol/cache-amqp
353+++ b/charms/focal/autopkgtest-web/webcontrol/cache-amqp
354@@ -110,7 +110,18 @@ class AutopkgtestQueueContents:
355 for r in requests:
356 if isinstance(r, bytes):
357 r = r.decode("UTF-8")
358- res.append(r)
359+ try:
360+ req = r.split(None, 1)
361+ if len(req) > 1:
362+ params = json.loads(req[1])
363+ else:
364+ params = {}
365+ if params.get('private', False):
366+ r = 'private job'
367+ res.append(r)
368+ except (ValueError, IndexError):
369+ logging.error('Received invalid request format "%s"', r)
370+ return
371 return res
372
373 def get_queue_contents(self):
374diff --git a/charms/focal/autopkgtest-web/webcontrol/download-all-results b/charms/focal/autopkgtest-web/webcontrol/download-all-results
375index 9ceb9e5..6696be7 100755
376--- a/charms/focal/autopkgtest-web/webcontrol/download-all-results
377+++ b/charms/focal/autopkgtest-web/webcontrol/download-all-results
378@@ -22,7 +22,7 @@ import urllib.parse
379 import time
380
381 from distro_info import UbuntuDistroInfo
382-from download.utils import get_test_id, init_db
383+from helpers.utils import get_test_id, init_db
384 from urllib.request import urlopen
385
386 LOGGER = logging.getLogger(__name__)
387diff --git a/charms/focal/autopkgtest-web/webcontrol/download-results b/charms/focal/autopkgtest-web/webcontrol/download-results
388index a62babe..b8d4188 100755
389--- a/charms/focal/autopkgtest-web/webcontrol/download-results
390+++ b/charms/focal/autopkgtest-web/webcontrol/download-results
391@@ -8,7 +8,7 @@ import socket
392 import sqlite3
393 import urllib.parse
394
395-from download.utils import get_test_id, init_db
396+from helpers.utils import get_test_id, init_db
397 from urllib.request import urlopen
398
399 import amqplib.client_0_8 as amqp
400diff --git a/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/__init__.cpython-37.pyc b/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/__init__.cpython-37.pyc
401deleted file mode 100644
402index 3998d24..0000000
403Binary files a/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/__init__.cpython-37.pyc and /dev/null differ
404diff --git a/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/utils.cpython-37.pyc b/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/utils.cpython-37.pyc
405deleted file mode 100644
406index 723b94d..0000000
407Binary files a/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/utils.cpython-37.pyc and /dev/null differ
408diff --git a/charms/focal/autopkgtest-web/webcontrol/download/__init__.py b/charms/focal/autopkgtest-web/webcontrol/helpers/__init__.py
409similarity index 100%
410rename from charms/focal/autopkgtest-web/webcontrol/download/__init__.py
411rename to charms/focal/autopkgtest-web/webcontrol/helpers/__init__.py
412diff --git a/charms/focal/autopkgtest-web/webcontrol/download/utils.py b/charms/focal/autopkgtest-web/webcontrol/helpers/utils.py
413similarity index 90%
414rename from charms/focal/autopkgtest-web/webcontrol/download/utils.py
415rename to charms/focal/autopkgtest-web/webcontrol/helpers/utils.py
416index 2069516..36c6c4d 100644
417--- a/charms/focal/autopkgtest-web/webcontrol/download/utils.py
418+++ b/charms/focal/autopkgtest-web/webcontrol/helpers/utils.py
419@@ -1,6 +1,20 @@
420 import logging
421+import os
422 import sqlite3
423
424+
425+def setup_key(app, path):
426+ '''Create or load app.secret_key for cookie encryption.'''
427+ try:
428+ with open(path, 'rb') as f:
429+ app.secret_key = f.read()
430+ except FileNotFoundError:
431+ key = os.urandom(24)
432+ with open(path, 'wb') as f:
433+ os.fchmod(f.fileno(), 0o600)
434+ f.write(key)
435+ app.secret_key = key
436+
437 def init_db(path):
438 '''Create DB if it does not exist, and connect to it'''
439
440diff --git a/charms/focal/autopkgtest-web/webcontrol/private-results.cgi b/charms/focal/autopkgtest-web/webcontrol/private-results.cgi
441new file mode 100755
442index 0000000..9aa5a12
443--- /dev/null
444+++ b/charms/focal/autopkgtest-web/webcontrol/private-results.cgi
445@@ -0,0 +1,11 @@
446+#!/usr/bin/env python3
447+
448+"""Run results app as CGI script """
449+
450+from wsgiref.handlers import CGIHandler
451+
452+from private_results.app import app
453+
454+if __name__ == "__main__":
455+ app.config["DEBUG"] = True
456+ CGIHandler().run(app)
457diff --git a/charms/focal/autopkgtest-web/webcontrol/private_results/app.py b/charms/focal/autopkgtest-web/webcontrol/private_results/app.py
458new file mode 100644
459index 0000000..368d013
460--- /dev/null
461+++ b/charms/focal/autopkgtest-web/webcontrol/private_results/app.py
462@@ -0,0 +1,190 @@
463+"""Test Result Fetcher Flask App"""
464+import os
465+import sys
466+import logging
467+import swiftclient
468+import configparser
469+
470+from html import escape
471+from flask import (Flask, Response, request, session, redirect,
472+ render_template_string)
473+from flask_openid import OpenID
474+
475+sys.path.append('..')
476+
477+from helpers.utils import setup_key
478+from request.submit import Submit
479+
480+
481+HTML = """
482+<!doctype html>
483+<html>
484+<head>
485+<meta charset="utf-8">
486+<title>Autopkgtest Private Test Result Fetcher</title>
487+</head>
488+<body>
489+{{ content }}
490+</body>
491+</html>
492+"""
493+
494+LOGIN = """
495+<form action="/private-results/login" method="post">
496+<input type="submit" value="Log in with Ubuntu SSO">
497+<input type="hidden" name="next" value="{{ next }}">
498+</form>
499+"""
500+
501+DENIED = "Unprivileged or unavailable."
502+
503+
504+def swift_get_object(connection, container, path):
505+ """Fetch an object from swift."""
506+ try:
507+ _, contents = connection.get_object(container, path)
508+ except swiftclient.exceptions.ClientException as e:
509+ logging.error('Failed to fetch %s from container (%s)' %
510+ (path, str(e)))
511+ return None
512+ return contents
513+
514+
515+def validate_user_path(connection, container, nick, path):
516+ """Return true if user is allowed to view files under the given path."""
517+ # First we need to check if this result is actually sharable
518+ allowed_file = swift_get_object(connection, container, path)
519+ if not allowed_file:
520+ return False
521+ allowed = allowed_file.decode('utf-8').splitlines()
522+ # Check if user is allowed
523+ # (separate step not to do unnecessary LP API calls)
524+ if nick in allowed:
525+ return True
526+ # Check if user is allowed via team membership
527+ for entity in allowed:
528+ (code, response) = Submit.lp_request('~%s/participants' % entity, {})
529+ if code != 200:
530+ logging.error('Unable to validate user %s (%s)' % (nick, code))
531+ return False
532+ for e in response.get('entries', []):
533+ if e.get('name') == nick:
534+ return True
535+ return False
536+
537+
538+# Initialize app
539+PATH = os.path.join(os.getenv('TMPDIR', '/tmp'), 'autopkgtest_webcontrol')
540+print(PATH)
541+os.makedirs(PATH, exist_ok=True)
542+app = Flask('private-results')
543+# Keep secret persistent between CGI invocations
544+secret_path = os.path.join(PATH, 'secret_key')
545+setup_key(app, secret_path)
546+oid = OpenID(app, os.path.join(PATH, 'openid'), safe_roots=[])
547+# Load configuration
548+cfg = configparser.ConfigParser()
549+cfg.read(os.path.expanduser('~/swift-web-credentials.conf'))
550+# Build swift credentials
551+auth_url = cfg.get('swift', 'auth_url')
552+if '/v2.0' in auth_url:
553+ swift_creds = {
554+ 'authurl': auth_url,
555+ 'user': cfg.get('swift', 'username'),
556+ 'key': cfg.get('swift', 'password'),
557+ 'tenant_name': cfg.get('swift', 'tenant'),
558+ 'os_options': {
559+ 'region_name': cfg.get('swift', 'region_name')
560+ },
561+ 'auth_version': '2.0'
562+ }
563+else:
564+ swift_creds = {
565+ 'authurl': auth_url,
566+ 'user': cfg.get('swift', 'username'),
567+ 'key': cfg.get('swift', 'password'),
568+ 'os_options': {
569+ 'region_name': cfg.get('swift', 'region_name'),
570+ 'project_domain_name': cfg.get('swift', 'project_domain_name'),
571+ 'project_name': cfg.get('swift', 'project_name'),
572+ 'user_domain_name': cfg.get('swift', 'user_domain_name')
573+ },
574+ 'auth_version': '3'
575+ }
576+# Connect to swift
577+connection = swiftclient.Connection(**swift_creds)
578+
579+
580+#
581+# Flask routes
582+#
583+
584+@app.route('/', methods=['GET'])
585+def index_root():
586+ """Handle the main index root, just pure informational."""
587+ return render_template_string(
588+ HTML, content='Please provide the path to the private result.')
589+
590+
591+@app.route('/<container>/<series>/<arch>/<group>/<src>/<runid>/<file>',
592+ methods=['GET'])
593+def index_result(container, series, arch, group, src, runid, file):
594+ """Handle all GET requests for private tests."""
595+ session.permanent = True
596+ session['next'] = escape(request.url)
597+ if not container.startswith('private-'):
598+ return render_template_string(
599+ HTML, content='Limited to private results only.')
600+ nick = session.get('nickname')
601+ if nick:
602+ # Authenticated via SSO, so that's a start
603+ parent_path = os.path.join(series, arch, group, src, runid)
604+ object_path = os.path.join(parent_path, file)
605+ acl_path = os.path.join(parent_path, 'readable-by')
606+ if not validate_user_path(connection, container, nick, acl_path):
607+ return render_template_string(HTML, content=DENIED), 403
608+ # We can pull the result now
609+ result = swift_get_object(connection, container, object_path)
610+ if result is None:
611+ return render_template_string(HTML, content=DENIED), 403
612+ if file.endswith('.gz'):
613+ content_type = 'text/plain; charset=UTF-8'
614+ headers = {'Content-Encoding': 'gzip'}
615+ return Response(result, content_type=content_type, headers=headers)
616+ else:
617+ return result
618+ else:
619+ # XXX: render_template_string urlencodes its context values, so it's
620+ # not really possible to have 'nested HTML' rendered properly.
621+ return HTML.replace("{{ content }}",
622+ render_template_string(LOGIN, **session))
623+
624+
625+@app.route('/login', methods=['GET', 'POST'])
626+@oid.loginhandler
627+def login():
628+ """Initiate OpenID login."""
629+ if 'nickname' in session:
630+ return redirect(oid.get_next_url())
631+ if 'next' in request.form:
632+ return oid.try_login(
633+ 'https://login.ubuntu.com/',
634+ ask_for=['nickname'])
635+ return redirect('/private-results')
636+
637+
638+@oid.after_login
639+def identify(resp):
640+ """Complete OpenID login."""
641+ session.update(
642+ identity_url=resp.identity_url,
643+ nickname=resp.nickname,
644+ )
645+ return redirect(oid.get_next_url())
646+
647+
648+@app.route('/logout')
649+def logout():
650+ """Clear user session, logging them out."""
651+ session.clear()
652+ return redirect(oid.get_next_url())
653diff --git a/charms/focal/autopkgtest-web/webcontrol/request/app.py b/charms/focal/autopkgtest-web/webcontrol/request/app.py
654index b19945f..66e3539 100644
655--- a/charms/focal/autopkgtest-web/webcontrol/request/app.py
656+++ b/charms/focal/autopkgtest-web/webcontrol/request/app.py
657@@ -9,8 +9,10 @@ from html import escape as _escape
658 from flask import Flask, request, session, redirect
659 from flask_openid import OpenID
660
661+from helpers.utils import setup_key
662 from request.submit import Submit
663
664+
665 # map multiple GET vars to AMQP JSON request parameter list
666 MULTI_ARGS = {'trigger': 'triggers',
667 'ppa': 'ppas',
668@@ -55,19 +57,6 @@ SUCCESS = """
669 """
670
671
672-def setup_key(path):
673- """Create or load app.secret_key for cookie encryption."""
674- try:
675- with open(path, 'rb') as f:
676- app.secret_key = f.read()
677- except FileNotFoundError:
678- key = os.urandom(24)
679- with open(path, 'wb') as f:
680- os.fchmod(f.fileno(), 0o600)
681- f.write(key)
682- app.secret_key = key
683-
684-
685 def check_github_sig(request):
686 """Validate github signature of request.
687
688@@ -115,7 +104,7 @@ os.makedirs(PATH, exist_ok=True)
689 app = Flask('request')
690 # keep secret persistent between CGI invocations
691 secret_path = os.path.join(PATH, 'secret_key')
692-setup_key(secret_path)
693+setup_key(app, secret_path)
694 oid = OpenID(app, os.path.join(PATH, 'openid'), safe_roots=[])
695
696
697diff --git a/charms/focal/autopkgtest-web/webcontrol/request/submit.py b/charms/focal/autopkgtest-web/webcontrol/request/submit.py
698index e1326b1..d48a025 100644
699--- a/charms/focal/autopkgtest-web/webcontrol/request/submit.py
700+++ b/charms/focal/autopkgtest-web/webcontrol/request/submit.py
701@@ -84,6 +84,12 @@ class Submit:
702 del kwargs['all-proposed']
703 except KeyError:
704 pass
705+ try:
706+ if not kwargs['readable-by']:
707+ raise ValueError('Invalid readable-by value')
708+ del kwargs['readable-by']
709+ except KeyError:
710+ pass
711 # no other kwargs supported
712 if kwargs:
713 raise ValueError('Invalid argument %s' % list(kwargs)[0])
714diff --git a/mojo/service-bundle b/mojo/service-bundle
715index 967112c..c0f7dc4 100644
716--- a/mojo/service-bundle
717+++ b/mojo/service-bundle
718@@ -179,6 +179,7 @@ applications:
719 {%- if stage_name == "production" or stage_name == "staging" %}
720 github-secrets: include-file://{{local_dir}}/github-secrets.json
721 github-status-credentials: include-file://{{local_dir}}/github-status-credentials.txt
722+ swift-web-credentials: include-file://{{local_dir}}/swift-web-credentials.conf
723 https-proxy: {{ https_proxy }}
724 no-proxy: {{ no_proxy }}
725 {%- endif %}

Subscribers

People subscribed via source and target branches

to all changes: