blob: d1eb1234605b004c9f18c1761b1aff4d381ca8a9 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5from __future__ import print_function
6from __future__ import division
7from __future__ import absolute_import
8
9import base64
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010010import binascii
Copybara854996b2021-09-07 19:36:02 +000011import json
12import logging
13import os
14import time
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020015import flask
Copybara854996b2021-09-07 19:36:02 +000016
17from google.appengine.api import app_identity
18from google.appengine.api import urlfetch
19from google.appengine.ext import db
20from google.protobuf import text_format
21
22from infra_libs import ts_mon
23
24import settings
25from framework import framework_constants
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010026from mrproto import api_clients_config_pb2
Copybara854996b2021-09-07 19:36:02 +000027
28
29CONFIG_FILE_PATH = os.path.join(
30 os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
31 'testing', 'api_clients.cfg')
32LUCI_CONFIG_URL = (
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010033 'https://config.luci.app/prpc/config.service.v2.Configs/GetConfig')
Copybara854996b2021-09-07 19:36:02 +000034
35
36client_config_svc = None
37service_account_map = None
38qpm_dict = None
39allowed_origins_set = None
40
41
42class ClientConfig(db.Model):
43 configs = db.TextProperty()
44
45
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020046_CONFIG_LOADS = ts_mon.CounterMetric(
47 'monorail/client_config_svc/loads', 'Results of fetches from luci-config.',
48 [ts_mon.BooleanField('success'),
49 ts_mon.StringField('type')])
Copybara854996b2021-09-07 19:36:02 +000050
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020051def _process_response(response):
52 try:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010053 utf8_decoded_content = response.content.decode('utf-8')
54 except AttributeError:
55 logging.error('Response content was not binary: %r', response.content)
56 _CONFIG_LOADS.increment({'success': False, 'type': 'json-load-error'})
57 raise
58
59 try:
60 # Strip the XSSI prefix.
61 stripped_content = utf8_decoded_content[len(")]}'"):].strip()
62 json_config = json.loads(stripped_content)
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020063 except ValueError:
64 logging.error('Response was not JSON: %r', response.content)
65 _CONFIG_LOADS.increment({'success': False, 'type': 'json-load-error'})
66 raise
67
68 try:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010069 config_raw_content = json_config['rawContent']
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020070 except KeyError:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010071 logging.error('JSON missing rawContent: %r', json_config)
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020072 _CONFIG_LOADS.increment({'success': False, 'type': 'json-key-error'})
73 raise
74
75 try:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010076 content_text = base64.b64decode(config_raw_content)
77 except binascii.Error:
78 logging.error('Content was not b64: %r', config_raw_content)
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020079 _CONFIG_LOADS.increment({'success': False, 'type': 'b64-decode-error'})
80 raise
81
82 try:
83 cfg = api_clients_config_pb2.ClientCfg()
84 text_format.Merge(content_text, cfg)
85 except:
86 logging.error('Content was not a valid ClientCfg proto: %r', content_text)
87 _CONFIG_LOADS.increment({'success': False, 'type': 'proto-load-error'})
88 raise
89
90 return content_text
91
92
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010093def _CallLuciConfig() -> urlfetch._URLFetchResult:
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020094 authorization_token, _ = app_identity.get_access_token(
Copybara854996b2021-09-07 19:36:02 +000095 framework_constants.OAUTH_SCOPE)
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +020096 response = urlfetch.fetch(
Copybara854996b2021-09-07 19:36:02 +000097 LUCI_CONFIG_URL,
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010098 method=urlfetch.POST,
Copybara854996b2021-09-07 19:36:02 +000099 follow_redirects=False,
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200100 headers={
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100101 'Content-Type': 'application/json; charset=utf-8',
102 'Authorization': 'Bearer ' + authorization_token,
103 'Accept': 'application/json'
104 },
105 payload=json.dumps(
106 {
107 'configSet': 'services/monorail-prod',
108 'path': 'api_clients.cfg'
109 }),
110 )
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200111 if response.status_code != 200:
112 logging.error('Invalid response from luci-config: %r', response)
113 _CONFIG_LOADS.increment({'success': False, 'type': 'luci-cfg-error'})
114 flask.abort(500, 'Invalid response from luci-config')
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100115 return response
116
117
118def GetLoadApiClientConfigs():
119 global service_account_map
120 global qpm_dict
121 response = _CallLuciConfig()
Copybara854996b2021-09-07 19:36:02 +0000122
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200123 try:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100124 config_content_text = _process_response(response)
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200125 except Exception as e:
126 flask.abort(500, str(e))
Copybara854996b2021-09-07 19:36:02 +0000127
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100128 logging.info('luci-config content decoded: %r.', config_content_text)
129 configs = ClientConfig(
130 configs=config_content_text, key_name='api_client_configs')
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200131 configs.put()
132 service_account_map = None
133 qpm_dict = None
134 _CONFIG_LOADS.increment({'success': True, 'type': 'success'})
Copybara854996b2021-09-07 19:36:02 +0000135
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200136 return ''
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200137
Copybara854996b2021-09-07 19:36:02 +0000138
139class ClientConfigService(object):
140 """The persistence layer for client config data."""
141
142 # Reload no more than once every 15 minutes.
143 # Different GAE instances can load it at different times,
144 # so clients may get inconsistence responses shortly after allowlisting.
145 EXPIRES_IN = 15 * framework_constants.SECS_PER_MINUTE
146
147 def __init__(self):
148 self.client_configs = None
149 self.load_time = 0
150
151 def GetConfigs(self, use_cache=True, cur_time=None):
152 """Read client configs."""
153
154 cur_time = cur_time or int(time.time())
155 force_load = False
156 if not self.client_configs:
157 force_load = True
158 elif not use_cache:
159 force_load = True
160 elif cur_time - self.load_time > self.EXPIRES_IN:
161 force_load = True
162
163 if force_load:
164 if settings.local_mode or settings.unit_test_mode:
165 self._ReadFromFilesystem()
166 else:
167 self._ReadFromDatastore()
168
169 return self.client_configs
170
171 def _ReadFromFilesystem(self):
172 try:
173 with open(CONFIG_FILE_PATH, 'r') as f:
174 content_text = f.read()
175 logging.info('Read client configs from local file.')
176 cfg = api_clients_config_pb2.ClientCfg()
177 text_format.Merge(content_text, cfg)
178 self.client_configs = cfg
179 self.load_time = int(time.time())
180 except Exception as e:
181 logging.exception('Failed to read client configs: %s', e)
182
183 def _ReadFromDatastore(self):
184 entity = ClientConfig.get_by_key_name('api_client_configs')
185 if entity:
186 cfg = api_clients_config_pb2.ClientCfg()
187 text_format.Merge(entity.configs, cfg)
188 self.client_configs = cfg
189 self.load_time = int(time.time())
190 else:
191 logging.error('Failed to get api client configs from datastore.')
192
193 def GetClientIDEmails(self):
194 """Get client IDs and Emails."""
195 self.GetConfigs(use_cache=True)
196 client_ids = [c.client_id for c in self.client_configs.clients]
197 client_emails = [c.client_email for c in self.client_configs.clients]
198 return client_ids, client_emails
199
200 def GetDisplayNames(self):
201 """Get client display names."""
202 self.GetConfigs(use_cache=True)
203 names_dict = {}
204 for client in self.client_configs.clients:
205 if client.display_name:
206 names_dict[client.client_email] = client.display_name
207 return names_dict
208
209 def GetQPM(self):
210 """Get client qpm limit."""
211 self.GetConfigs(use_cache=True)
212 qpm_map = {}
213 for client in self.client_configs.clients:
214 if client.HasField('qpm_limit'):
215 qpm_map[client.client_email] = client.qpm_limit
216 return qpm_map
217
218 def GetAllowedOriginsSet(self):
219 """Get the set of all allowed origins."""
220 self.GetConfigs(use_cache=True)
221 origins = set()
222 for client in self.client_configs.clients:
223 origins.update(client.allowed_origins)
224 return origins
225
226
227def GetClientConfigSvc():
228 global client_config_svc
229 if client_config_svc is None:
230 client_config_svc = ClientConfigService()
231 return client_config_svc
232
233
234def GetServiceAccountMap():
235 # typ: () -> Mapping[str, str]
236 """Returns only service accounts that have specified display_names."""
237 global service_account_map
238 if service_account_map is None:
239 service_account_map = GetClientConfigSvc().GetDisplayNames()
240 return service_account_map
241
242
243def GetQPMDict():
244 global qpm_dict
245 if qpm_dict is None:
246 qpm_dict = GetClientConfigSvc().GetQPM()
247 return qpm_dict
248
249
250def GetAllowedOriginsSet():
251 global allowed_origins_set
252 if allowed_origins_set is None:
253 allowed_origins_set = GetClientConfigSvc().GetAllowedOriginsSet()
254 return allowed_origins_set