1#!/usr/bin/env python3
2import argparse
3import base64
4from concurrent.futures import ThreadPoolExecutor
5import copy
6import http.cookiejar
7import hashlib
8import importlib
9import json
10import math
11from pathlib import Path
12import sqlite3
13import ssl
14import subprocess
15import sys
16import time
17import urllib.error
18import urllib.parse
19import urllib.request
20import uuid
21
22
23class NoRedirect(urllib.request.HTTPRedirectHandler):
24 def redirect_request(self, request, fp, code, message, headers, newurl):
25 return None
26
27
28def main():
29 parser = argparse.ArgumentParser()
30 parser.add_argument('--url', required=True)
31 parser.add_argument('--proof-file', type=Path, required=True)
32 parser.add_argument('--data-dir', type=Path, required=True)
33 parser.add_argument('--restart-unit', required=True)
34 parser.add_argument('--shale-origin', required=True)
35 parser.add_argument('--shale-database', type=Path, required=True)
36 parser.add_argument('--shale-container', required=True)
37 parser.add_argument('--output', type=Path)
38 args = parser.parse_args()
39 if args.output:
40 args.output.unlink(missing_ok=True)
41 repo = Path(__file__).resolve().parent.parent
42 sys.path.insert(0, str(repo / 'service/keycloak'))
43 from api import Keycloak, LoopbackHTTPS
44 Page = importlib.import_module('dashboard-shale-test').Page
45 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
46 context = ssl.create_default_context(cafile='/var/lib/studio/ca-bundle.crt')
47 origin = 'https://globe.studio.test'
48 proof = args.proof_file.read_text().strip()
49 marker = 'shale-link-' + uuid.uuid4().hex
50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')]
51 ids = []
52
53 class TLS(urllib.request.HTTPSHandler):
54 def https_open(self, request):
55 parsed = urllib.parse.urlsplit(request.full_url)
56 assert parsed.netloc in ('keycloak.studio.test', urllib.parse.urlsplit(args.shale_origin).netloc)
57 return self.do_open(LoopbackHTTPS, request, context=context)
58
59 def browser():
60 cookies = http.cookiejar.CookieJar()
61 return urllib.request.build_opener(TLS(), NoRedirect(), urllib.request.HTTPCookieProcessor(cookies)), cookies
62
63 def request(client, url, method='GET', body=None, headers=None, status=None):
64 try:
65 response = client.open(urllib.request.Request(url, method=method, data=body, headers=headers or {}), timeout=45)
66 except urllib.error.HTTPError as error:
67 response = error
68 with response:
69 value = response.read(4 * 1024 * 1024 + 1)
70 assert len(value) <= 4 * 1024 * 1024
71 if status is not None:
72 assert response.status == status, (urllib.parse.urlsplit(url).path, response.status, value[:300])
73 return response.status, response.headers, value.decode()
74
75 def api(actor, method='GET', path='/api/mcp', status=200, body=None):
76 _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method,
77 body=json.dumps(body).encode() if body is not None else None,
78 headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor,
79 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status)
80 return json.loads(body) if body and status < 400 else body or None
81
82 def records(prefix):
83 path = args.data_dir / 'connections.sqlite'
84 with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db:
85 return {key: json.loads(value) for key, value in db.execute('SELECT key,value FROM records WHERE substr(key,1,?)=?', (len(prefix), prefix))}
86
87 def session(index):
88 return records('shale-session:').get('shale-session:' + ids[index])
89
90 def session_count(index):
91 with sqlite3.connect(args.shale_database.as_uri() + '?mode=ro', uri=True) as db:
92 return db.execute('SELECT count(*) FROM sessions s JOIN users u ON s.user=u.id WHERE u.snowflake=?', (ids[index],)).fetchone()[0]
93
94 def start(index, pending=None):
95 target = api(accounts[index][0], 'POST', '/api/mcp/shale', body={'request': pending} if pending else {})['redirect']
96 assert target.startswith(args.shale_origin + '/-/studio-mcp/')
97 client, cookies = browser()
98 _, headers, _ = request(client, target, status=302)
99 link_cookies = [cookie for cookie in cookies if cookie.name == 'studio_mcp_shale_link']
100 assert len(link_cookies) == 1
101 cookie = link_cookies[0]
102 assert not cookie.domain_specified and cookie.secure and cookie.path == '/-/callback'
103 assert cookie.has_nonstandard_attr('HttpOnly') and cookie.get_nonstandard_attr('SameSite') == 'Lax'
104 assert headers.get('Referrer-Policy') == 'no-referrer'
105 assert headers.get('Cache-Control') == 'no-store'
106 request(browser()[0], target, status=410)
107 return client, cookies, headers['Location'], target
108
109 def authorize(client, authorization, index):
110 status, headers, body = request(client, authorization, status=200)
111 form = next(form for form in Page(body).forms if any(field.get('name') == 'password' for field in form['fields']))
112 target = urllib.parse.urljoin(authorization, form['action'])
113 assert urllib.parse.urlsplit(target).hostname == 'keycloak.studio.test'
114 fields = {field['name']: field.get('value', '') for field in form['fields'] if field.get('name')}
115 fields.update(username=accounts[index][0], password=accounts[index][1])
116 status, headers, body = request(client, target, 'POST', urllib.parse.urlencode(fields).encode(),
117 {'Content-Type': 'application/x-www-form-urlencoded', 'Origin': 'https://keycloak.studio.test'})
118 for _ in range(6):
119 assert status in (302, 303), status
120 target = urllib.parse.urljoin(target, headers['Location'])
121 parts = urllib.parse.urlsplit(target)
122 if parts.netloc == urllib.parse.urlsplit(args.shale_origin).netloc:
123 assert parts.path == '/-/callback' and 'code' in dict(urllib.parse.parse_qsl(parts.query))
124 return target
125 assert parts.netloc == 'keycloak.studio.test'
126 status, headers, body = request(client, target)
127 raise AssertionError('too many sign-in redirects')
128
129 def finish(client, target, status=303, pending=None):
130 _, headers, _ = request(client, target, status=status)
131 assert headers.get('Cache-Control') == 'no-store'
132 assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', []))
133 assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', []))
134 if status == 303:
135 assert headers['Location'] == origin + '/mcp' + ('?request=' + pending if pending else '')
136
137 def backend_session(value, status):
138 return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status)
139
140 def link(index, pending=None):
141 client, cookies, authorization, _ = start(index, pending)
142 target = authorize(client, authorization, index)
143 captured = http.cookiejar.CookieJar()
144 for cookie in cookies:
145 captured.set_cookie(copy.copy(cookie))
146 finish(client, target, pending=pending)
147 replay = urllib.request.build_opener(TLS(), NoRedirect(), urllib.request.HTTPCookieProcessor(captured))
148 finish(replay, target, 410)
149 result = session(index)
150 assert result and result['origin'] == args.shale_origin + '/'
151 assert result['linkedAt'] > 0 and len(result['session']) > 20
152 overview = api(accounts[index][0])['shale']
153 assert overview is not None and set(overview) == {'linkedAt'}, overview
154 assert math.isclose(overview['linkedAt'], result['linkedAt'], rel_tol=0, abs_tol=1e-6), overview
155 assert session_count(index) == 1
156 backend_session(result, 200)
157 return result
158
159 def public(path, method='GET', body=None, status=200, token=None, form=False):
160 headers = {'Host': 'globe.studio.test', 'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'}
161 if token:
162 headers.update({'Authorization': 'Bearer ' + token, 'Accept': 'application/json, text/event-stream',
163 'MCP-Protocol-Version': '2025-11-25'})
164 encoded = urllib.parse.urlencode(body).encode() if form else json.dumps(body).encode() if body is not None else None
165 _, headers, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method, encoded, headers, status)
166 return (json.loads(body) if body and headers.get('Content-Type', '').startswith('application/json') else body or None), headers
167
168 def pending_request(client, index, scope):
169 verifier = uuid.uuid4().hex + uuid.uuid4().hex
170 challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')
171 _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code',
172 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256',
173 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302)
174 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers['Location']).query)['request'][0]
175 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
176 assert details['client'] == marker
177 api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403)
178 return pending, verifier, details
179
180 def consent(client, index, repository, scope):
181 pending, verifier, details = pending_request(client, index, scope)
182 available = {r['id'] for r in details['resources']}
183 assert details['linked'] and repository in available, details
184 if index == 0:
185 assert available == {'alpha', 'beta'}, details
186 path = '/api/mcp/consent/' + pending
187 api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']})
188 api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]})
189 result = api(accounts[index][0], 'POST', path, body={'resources': [repository]})
190 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query)
191 assert query['state'] == [marker]
192 tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'],
193 'redirect_uri': client['redirect_uris'][0], 'code_verifier': verifier, 'code': query['code'][0],
194 'resource': origin + '/mcp/shale'}, form=True)
195 return tokens
196
197 def rpc(token, method, params=None, error=False):
198 value, _ = public('/mcp/shale', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': method,
199 **({'params': params} if params is not None else {})}, token=token)
200 assert 'error' not in value, value
201 value = value['result']
202 assert bool(value.get('isError')) == error, value
203 return value if error or 'structuredContent' not in value else value['structuredContent']
204
205 def call(token, tool, fields=None, error=False):
206 return rpc(token, 'tools/call', {'name': tool, 'arguments': fields or {}}, error=error)
207
208 def backend(index, suffix, fields=None):
209 headers = {'Cookie': 'SessionID=' + session(index)['session']}
210 if fields is not None:
211 headers.update({'Origin': args.shale_origin, 'Referer': args.shale_origin + suffix,
212 'Content-Type': 'application/x-www-form-urlencoded'})
213 return request(browser()[0], args.shale_origin + suffix, 'POST' if fields is not None else 'GET',
214 urllib.parse.urlencode(fields).encode() if fields is not None else None, headers)
215
216 def submit_backend(index, path, changes):
217 status, _, body = backend(index, path)
218 assert status == 200, (path, status, body[:700])
219 forms = [form for form in Page(body).forms if changes.keys() <= {field.get('name') for field in form['fields']}
220 and ('t' not in changes or any(field.get('name') == 't' and field.get('value') == changes['t'] for field in form['fields']))]
221 assert len(forms) == 1, path
222 form = forms[0]
223 assert urllib.parse.urljoin(args.shale_origin + path, form.get('action', '')) == args.shale_origin + path
224 fields = {field['name']: field.get('value', '') for field in form['fields']
225 if field.get('name') and field.get('type') == 'hidden'}
226 fields.update(timezone='UTC', tzoffset='+00:00', **changes)
227 return backend(index, path, fields)
228
229 def repository(index, name):
230 status, headers, _ = submit_backend(1, '/-/new', {'name': name, 'description': 'Owned Shale MCP fixture', 'access': 'private'})
231 assert status == 303, status
232 target = urllib.parse.urlsplit(urllib.parse.urljoin(args.shale_origin, headers['Location']))
233 assert target.netloc == urllib.parse.urlsplit(args.shale_origin).netloc
234 assert target.path.rstrip('/') == '/' + name, target.path
235 if index == 0:
236 with sqlite3.connect(args.shale_database) as db:
237 identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0]
238 assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1
239
240 try:
241 for name, credential in accounts:
242 keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name,
243 'lastName': 'Fixture', 'email': name + '@fixture.invalid', 'emailVerified': True, 'enabled': True,
244 'credentials': [{'type': 'password', 'value': credential, 'temporary': False}]})
245 found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true')
246 assert len(found) == 1
247 ids.append(found[0]['id'])
248 assert all(api(name)['shale'] is None for name, _ in accounts)
249 api(accounts[0][0], 'POST', '/api/mcp/shale', status=200)
250 old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
251 latest_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
252 request(browser()[0], old_target, status=410)
253 with sqlite3.connect(args.data_dir / 'connections.sqlite') as db:
254 db.execute("UPDATE records SET expires=1 WHERE substr(key,1,11)='shale-link:'")
255 request(browser()[0], latest_target, status=410)
256 client, cookies, authorization, _ = start(0)
257 request(client, args.shale_origin + '/-/callback?state=other&code=fixture', status=403)
258 request(browser()[0], args.url + '/oauth/shale/link/' + 'a' * 43, headers={'Host': 'globe.studio.test'}, status=403)
259 client, cookies, authorization, _ = start(0)
260 finish(client, authorize(client, authorization, 1), 403)
261 assert session(0) is None and session(1) is None and session_count(1) == 0
262 oauth_client, _ = public('/oauth/register', 'POST', {'client_name': marker,
263 'redirect_uris': ['http://127.0.0.1:29999/shale-callback'], 'token_endpoint_auth_method': 'none'}, status=201)
264 pending, _, details = pending_request(oauth_client, 0, 'shale:read')
265 assert not details['linked'] and not details['resources']
266 api(accounts[1][0], 'POST', '/api/mcp/shale', 403, {'request': pending})
267 first = link(0, pending)
268 api(accounts[0][0], 'POST', '/api/mcp/consent/' + pending, body={'deny': True})
269 assert api(accounts[1][0])['shale'] is None
270 second = link(1)
271 assert first['session'] != second['session']
272 new_first = link(0)
273 assert new_first['session'] != first['session']
274 backend_session(first, 303)
275 backend_session(second, 200)
276 assert session_count(0) == 1
277 for index, name in [(0, 'alpha'), (0, 'beta'), (1, 'foreign')]:
278 repository(index, name)
279 readonly = consent(oauth_client, 0, 'alpha', 'shale:read offline_access')
280 writing = consent(oauth_client, 0, 'alpha', 'shale:read shale:write offline_access')
281 other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write')
282 read = readonly['access_token']
283 write = writing['access_token']
284 assert rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {},
285 'clientInfo': {'name': marker, 'version': '1'}})['capabilities']['tools'] == {}
286 tools = rpc(read, 'tools/list')['tools']
287 assert {tool['name'] for tool in tools} == {'list_repositories', 'list_issues', 'get_issue', 'create_issue',
288 'comment_issue', 'set_issue_status', 'set_issue_title'}
289 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
290 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
291 assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'}
292 assert not call(read, 'list_issues', {'repository': 'alpha'})['issues']
293 for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'),
294 (write, 'alpha/../foreign'), (write, 'https://other.invalid')]:
295 call(token, 'list_issues', {'repository': name}, error=True)
296 call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
297 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True)
298 public('/mcp/observability', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, token=read, status=401)
299 title = 'MCP <angle> & Unicode ☃'
300 created = call(write, 'create_issue', {'repository': 'alpha', 'title': title,
301 'description': 'Owned body <script>fixture</script> & Unicode ☃'})['issue']
302 assert created['title'] == title and created['id'] == 1
303 assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text']
304 assert '<script>' not in json.dumps(created)
305 listed = call(read, 'list_issues', {'repository': 'alpha', 'q': 'is:open'})['issues'][0]
306 assert listed['title'] == title and listed['status'] == created['status']
307 rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True)
308 assert 'filter syntax' in rejected['content'][0]['text'], rejected
309 issue_fields = {'repository': 'alpha', 'id': created['id']}
310 commented = call(write, 'comment_issue', {**issue_fields, 'comment': 'Owned comment & Unicode ☃'})['issue']
311 assert len(commented['comments']) == 2 and commented['comments'][-1]['text'] == 'Owned comment & Unicode ☃'
312 assert call(write, 'set_issue_status', {**issue_fields, 'status': 'done'})['issue']['status'] == 'done'
313 call(write, 'set_issue_status', {**issue_fields, 'status': 'outside-status'}, error=True)
314 changed = call(write, 'set_issue_title', {**issue_fields, 'title': 'Changed ☃'})['issue']
315 assert changed['title'] == 'Changed ☃'
316 assert call(read, 'get_issue', issue_fields)['issue'] == changed
317 status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'})
318 assert status == 303
319 status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'})
320 assert status == 303
321 changed = call(read, 'get_issue', issue_fields)['issue']
322 assert changed['labels'] == ['Owned ☃'], changed
323 with sqlite3.connect(args.shale_database) as db:
324 rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall()
325 assert len(rows) == 1
326 original_owner = db.execute("SELECT owner FROM repositories WHERE name='alpha'").fetchone()[0]
327 db.execute("UPDATE repositories SET owner=? WHERE name='alpha'", (rows[0][0],))
328 try:
329 call(read, 'get_issue', issue_fields, error=True)
330 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
331 finally:
332 with sqlite3.connect(args.shale_database) as db:
333 db.execute("UPDATE repositories SET owner=? WHERE name='alpha'", (original_owner,))
334 with sqlite3.connect(args.shale_database) as db:
335 assert db.execute("SELECT count(*) FROM issues WHERE title='REFUSED'").fetchone()[0] == 0
336 drop = args.shale_database.parent.parent / 'drop-next-write'
337 drop.write_text('/alpha/issues/new')
338 unknown = call(write, 'create_issue', {'repository': 'alpha', 'title': 'Committed with lost response'}, error=True)
339 assert 'outcome is unknown' in unknown['content'][0]['text'], unknown
340 assert not drop.exists(), 'fixture did not drop the committed write response'
341 with sqlite3.connect(args.shale_database) as db:
342 assert db.execute("SELECT count(*) FROM issues WHERE title='Committed with lost response'").fetchone()[0] == 1
343 assert sum(issue['title'] == 'Committed with lost response' for issue in call(read, 'list_issues', {'repository': 'alpha'})['issues']) == 1
344 subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30)
345 assert session(0) == new_first and session(1) == second
346 assert math.isclose(api(accounts[0][0])['shale']['linkedAt'], new_first['linkedAt'], rel_tol=0, abs_tol=1e-6)
347 backend_session(new_first, 200)
348 assert call(read, 'get_issue', issue_fields)['issue'] == changed
349 backend(0, '/-/logout')
350 call(read, 'get_issue', issue_fields, error=True)
351 new_first = link(0)
352 assert call(read, 'get_issue', issue_fields)['issue'] == changed
353 client, cookies, authorization, _ = start(0)
354 target = authorize(client, authorization, 0)
355 subprocess.run(['podman', 'pause', args.shale_container], check=True, capture_output=True)
356 try:
357 with ThreadPoolExecutor(max_workers=2) as pool:
358 callback = pool.submit(finish, client, target, 410)
359 deadline = time.monotonic() + 10
360 while not any(record.get('phase') == 'processing' for record in records('shale-link:').values()):
361 assert time.monotonic() < deadline, 'callback did not reach backend'
362 time.sleep(.05)
363 unlink = pool.submit(api, accounts[0][0], 'DELETE', '/api/mcp/shale', 204)
364 deadline = time.monotonic() + 10
365 while session(0) is not None or records('shale-link:'):
366 assert time.monotonic() < deadline, 'unlink did not cancel the callback'
367 time.sleep(.05)
368 subprocess.run(['podman', 'unpause', args.shale_container], check=True, capture_output=True)
369 callback.result(timeout=30)
370 unlink.result(timeout=30)
371 finally:
372 subprocess.run(['podman', 'unpause', args.shale_container], capture_output=True)
373 assert session(0) is None and session_count(0) == 0
374 assert api(accounts[0][0])['shale'] is None and session(1) == second
375 backend_session(second, 200)
376 public('/mcp/shale', token=read, status=401)
377 public('/mcp/shale', token=write, status=401)
378 public('/oauth/token', 'POST', {'grant_type': 'refresh_token', 'client_id': oauth_client['client_id'],
379 'refresh_token': readonly['refresh_token'], 'resource': origin + '/mcp/shale'}, form=True, status=400)
380 api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204)
381 assert session(1) is None and session_count(1) == 0
382 backend_session(second, 303)
383 assert not records('shale-link:') and not records('shale-session:')
384 result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True,
385 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True,
386 'cross_user_session_isolation': True, 'backend_session_not_exposed_to_browser': True,
387 'relink_revokes_previous_backend_session': True, 'restart_preserves_credentials': True,
388 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True,
389 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True,
390 'cross_repository_and_cross_user_tools_refused': True, 'read_only_and_audience_enforced': True,
391 'native_issue_create_read_comment_status_title': True, 'unicode_and_rendered_text': True,
392 'native_issue_labels_read': True,
393 'committed_write_lost_response_reported_without_replay': True,
394 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
395 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
396 finally:
397 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
398 cleanup_errors = []
399 for name, _ in accounts:
400 try:
401 if keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'):
402 api(name, 'DELETE', '/api/mcp/shale', 204)
403 except Exception as error:
404 cleanup_errors.append(error)
405 try:
406 for user in keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'):
407 assert user['username'] == name
408 keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE')
409 except Exception as error:
410 cleanup_errors.append(error)
411 if cleanup_errors:
412 raise cleanup_errors[0]
413 result['owned_users_and_credentials_removed'] = True
414 if args.output:
415 args.output.write_text(json.dumps(result, indent=2) + '\n')
416 print(json.dumps(result), flush=True)
417
418
419if __name__ == '__main__':
420 main()