Merge autopkgtest-cloud:sil2100/private-testing into autopkgtest-cloud:master
- Git
- lp:autopkgtest-cloud
- sil2100/private-testing
- Merge into master
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) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Iain Lane | Approve | ||
Steve Langasek (community) | Needs Fixing | ||
Review via email:
|
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.

Ł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!

Ł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.

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.

Ł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.

Ł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.

Ł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-

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. :(

Ł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.

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).

Łukasz Zemczak (sil2100) wrote : | # |
Hey! Could someone take a look if this is now good with the new history and get it merged? Thanks!

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?
Preview Diff
1 | diff --git a/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest b/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/tools/run-autopkgtest |
2 | index 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: |
38 | diff --git a/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker b/charms/focal/autopkgtest-cloud-worker/autopkgtest-cloud/worker/worker |
39 | index 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: |
267 | diff --git a/charms/focal/autopkgtest-web/config.yaml b/charms/focal/autopkgtest-web/config.yaml |
268 | index 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: |
282 | diff --git a/charms/focal/autopkgtest-web/layer.yaml b/charms/focal/autopkgtest-web/layer.yaml |
283 | index 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 |
291 | diff --git a/charms/focal/autopkgtest-web/reactive/autopkgtest_web.py b/charms/focal/autopkgtest-web/reactive/autopkgtest_web.py |
292 | index 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", |
350 | diff --git a/charms/focal/autopkgtest-web/webcontrol/cache-amqp b/charms/focal/autopkgtest-web/webcontrol/cache-amqp |
351 | index 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): |
374 | diff --git a/charms/focal/autopkgtest-web/webcontrol/download-all-results b/charms/focal/autopkgtest-web/webcontrol/download-all-results |
375 | index 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__) |
387 | diff --git a/charms/focal/autopkgtest-web/webcontrol/download-results b/charms/focal/autopkgtest-web/webcontrol/download-results |
388 | index 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 |
400 | diff --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 |
401 | deleted file mode 100644 |
402 | index 3998d24..0000000 |
403 | Binary files a/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/__init__.cpython-37.pyc and /dev/null differ |
404 | diff --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 |
405 | deleted file mode 100644 |
406 | index 723b94d..0000000 |
407 | Binary files a/charms/focal/autopkgtest-web/webcontrol/download/__pycache__/utils.cpython-37.pyc and /dev/null differ |
408 | diff --git a/charms/focal/autopkgtest-web/webcontrol/download/__init__.py b/charms/focal/autopkgtest-web/webcontrol/helpers/__init__.py |
409 | similarity index 100% |
410 | rename from charms/focal/autopkgtest-web/webcontrol/download/__init__.py |
411 | rename to charms/focal/autopkgtest-web/webcontrol/helpers/__init__.py |
412 | diff --git a/charms/focal/autopkgtest-web/webcontrol/download/utils.py b/charms/focal/autopkgtest-web/webcontrol/helpers/utils.py |
413 | similarity index 90% |
414 | rename from charms/focal/autopkgtest-web/webcontrol/download/utils.py |
415 | rename to charms/focal/autopkgtest-web/webcontrol/helpers/utils.py |
416 | index 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 | |
440 | diff --git a/charms/focal/autopkgtest-web/webcontrol/private-results.cgi b/charms/focal/autopkgtest-web/webcontrol/private-results.cgi |
441 | new file mode 100755 |
442 | index 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) |
457 | diff --git a/charms/focal/autopkgtest-web/webcontrol/private_results/app.py b/charms/focal/autopkgtest-web/webcontrol/private_results/app.py |
458 | new file mode 100644 |
459 | index 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()) |
653 | diff --git a/charms/focal/autopkgtest-web/webcontrol/request/app.py b/charms/focal/autopkgtest-web/webcontrol/request/app.py |
654 | index 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 | |
697 | diff --git a/charms/focal/autopkgtest-web/webcontrol/request/submit.py b/charms/focal/autopkgtest-web/webcontrol/request/submit.py |
698 | index 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]) |
714 | diff --git a/mojo/service-bundle b/mojo/service-bundle |
715 | index 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 %} |
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.