blob: ce85a95713a979a1eecdac6aac44a3d36c810e7f [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import base64
11import json
12import logging
13import os
14import time
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020015from six.moves import urllib
Copybara854996b2021-09-07 19:36:02 +000016import webapp2
17
18from google.appengine.api import app_identity
19from google.appengine.api import urlfetch
20from google.appengine.ext import db
21from google.protobuf import text_format
22
23from infra_libs import ts_mon
24
25import settings
26from framework import framework_constants
27from proto import api_clients_config_pb2
28
29
30CONFIG_FILE_PATH = os.path.join(
31 os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
32 'testing', 'api_clients.cfg')
33LUCI_CONFIG_URL = (
34 'https://luci-config.appspot.com/_ah/api/config/v1/config_sets'
35 '/services/monorail-prod/config/api_clients.cfg')
36
37
38client_config_svc = None
39service_account_map = None
40qpm_dict = None
41allowed_origins_set = None
42
43
44class ClientConfig(db.Model):
45 configs = db.TextProperty()
46
47
48# Note: The cron job must have hit the servlet before this will work.
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020049# when convert to flask replace the webapp2.RequestHandler to Object
Copybara854996b2021-09-07 19:36:02 +000050class LoadApiClientConfigs(webapp2.RequestHandler):
51
52 config_loads = ts_mon.CounterMetric(
53 'monorail/client_config_svc/loads',
54 'Results of fetches from luci-config.',
55 [ts_mon.BooleanField('success'), ts_mon.StringField('type')])
56
57 def get(self):
58 global service_account_map
59 global qpm_dict
60 authorization_token, _ = app_identity.get_access_token(
61 framework_constants.OAUTH_SCOPE)
62 response = urlfetch.fetch(
63 LUCI_CONFIG_URL,
64 method=urlfetch.GET,
65 follow_redirects=False,
66 headers={'Content-Type': 'application/json; charset=UTF-8',
67 'Authorization': 'Bearer ' + authorization_token})
68
69 if response.status_code != 200:
70 logging.error('Invalid response from luci-config: %r', response)
71 self.config_loads.increment({'success': False, 'type': 'luci-cfg-error'})
72 self.abort(500, 'Invalid response from luci-config')
73
74 try:
75 content_text = self._process_response(response)
76 except Exception as e:
77 self.abort(500, str(e))
78
79 logging.info('luci-config content decoded: %r.', content_text)
80 configs = ClientConfig(configs=content_text,
81 key_name='api_client_configs')
82 configs.put()
83 service_account_map = None
84 qpm_dict = None
85 self.config_loads.increment({'success': True, 'type': 'success'})
86
87 def _process_response(self, response):
88 try:
89 content = json.loads(response.content)
90 except ValueError:
91 logging.error('Response was not JSON: %r', response.content)
92 self.config_loads.increment({'success': False, 'type': 'json-load-error'})
93 raise
94
95 try:
96 config_content = content['content']
97 except KeyError:
98 logging.error('JSON contained no content: %r', content)
99 self.config_loads.increment({'success': False, 'type': 'json-key-error'})
100 raise
101
102 try:
103 content_text = base64.b64decode(config_content)
104 except TypeError:
105 logging.error('Content was not b64: %r', config_content)
106 self.config_loads.increment({'success': False,
107 'type': 'b64-decode-error'})
108 raise
109
110 try:
111 cfg = api_clients_config_pb2.ClientCfg()
112 text_format.Merge(content_text, cfg)
113 except:
114 logging.error('Content was not a valid ClientCfg proto: %r', content_text)
115 self.config_loads.increment({'success': False,
116 'type': 'proto-load-error'})
117 raise
118
119 return content_text
120
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200121 # def GetLoadApiClientConfigs(self):
122 # global service_account_map
123 # global qpm_dict
124 # authorization_token, _ = app_identity.get_access_token(
125 # framework_constants.OAUTH_SCOPE)
126 # response = urlfetch.fetch(
127 # LUCI_CONFIG_URL,
128 # method=urlfetch.GET,
129 # follow_redirects=False,
130 # headers={'Content-Type': 'application/json; charset=UTF-8',
131 # 'Authorization': 'Bearer ' + authorization_token})
132
133 # if response.status_code != 200:
134 # logging.error('Invalid response from luci-config: %r', response)
135 # self.config_loads.increment({'success': False, 'type': 'luci-cfg-error'})
136 # flask.abort(500, 'Invalid response from luci-config')
137
138 # try:
139 # content_text = self._process_response(response)
140 # except Exception as e:
141 # flask.abort(500, str(e))
142
143 # logging.info('luci-config content decoded: %r.', content_text)
144 # configs = ClientConfig(configs=content_text,
145 # key_name='api_client_configs')
146 # configs.put()
147 # service_account_map = None
148 # qpm_dict = None
149 # self.config_loads.increment({'success': True, 'type': 'success'})
150
Copybara854996b2021-09-07 19:36:02 +0000151
152class ClientConfigService(object):
153 """The persistence layer for client config data."""
154
155 # Reload no more than once every 15 minutes.
156 # Different GAE instances can load it at different times,
157 # so clients may get inconsistence responses shortly after allowlisting.
158 EXPIRES_IN = 15 * framework_constants.SECS_PER_MINUTE
159
160 def __init__(self):
161 self.client_configs = None
162 self.load_time = 0
163
164 def GetConfigs(self, use_cache=True, cur_time=None):
165 """Read client configs."""
166
167 cur_time = cur_time or int(time.time())
168 force_load = False
169 if not self.client_configs:
170 force_load = True
171 elif not use_cache:
172 force_load = True
173 elif cur_time - self.load_time > self.EXPIRES_IN:
174 force_load = True
175
176 if force_load:
177 if settings.local_mode or settings.unit_test_mode:
178 self._ReadFromFilesystem()
179 else:
180 self._ReadFromDatastore()
181
182 return self.client_configs
183
184 def _ReadFromFilesystem(self):
185 try:
186 with open(CONFIG_FILE_PATH, 'r') as f:
187 content_text = f.read()
188 logging.info('Read client configs from local file.')
189 cfg = api_clients_config_pb2.ClientCfg()
190 text_format.Merge(content_text, cfg)
191 self.client_configs = cfg
192 self.load_time = int(time.time())
193 except Exception as e:
194 logging.exception('Failed to read client configs: %s', e)
195
196 def _ReadFromDatastore(self):
197 entity = ClientConfig.get_by_key_name('api_client_configs')
198 if entity:
199 cfg = api_clients_config_pb2.ClientCfg()
200 text_format.Merge(entity.configs, cfg)
201 self.client_configs = cfg
202 self.load_time = int(time.time())
203 else:
204 logging.error('Failed to get api client configs from datastore.')
205
206 def GetClientIDEmails(self):
207 """Get client IDs and Emails."""
208 self.GetConfigs(use_cache=True)
209 client_ids = [c.client_id for c in self.client_configs.clients]
210 client_emails = [c.client_email for c in self.client_configs.clients]
211 return client_ids, client_emails
212
213 def GetDisplayNames(self):
214 """Get client display names."""
215 self.GetConfigs(use_cache=True)
216 names_dict = {}
217 for client in self.client_configs.clients:
218 if client.display_name:
219 names_dict[client.client_email] = client.display_name
220 return names_dict
221
222 def GetQPM(self):
223 """Get client qpm limit."""
224 self.GetConfigs(use_cache=True)
225 qpm_map = {}
226 for client in self.client_configs.clients:
227 if client.HasField('qpm_limit'):
228 qpm_map[client.client_email] = client.qpm_limit
229 return qpm_map
230
231 def GetAllowedOriginsSet(self):
232 """Get the set of all allowed origins."""
233 self.GetConfigs(use_cache=True)
234 origins = set()
235 for client in self.client_configs.clients:
236 origins.update(client.allowed_origins)
237 return origins
238
239
240def GetClientConfigSvc():
241 global client_config_svc
242 if client_config_svc is None:
243 client_config_svc = ClientConfigService()
244 return client_config_svc
245
246
247def GetServiceAccountMap():
248 # typ: () -> Mapping[str, str]
249 """Returns only service accounts that have specified display_names."""
250 global service_account_map
251 if service_account_map is None:
252 service_account_map = GetClientConfigSvc().GetDisplayNames()
253 return service_account_map
254
255
256def GetQPMDict():
257 global qpm_dict
258 if qpm_dict is None:
259 qpm_dict = GetClientConfigSvc().GetQPM()
260 return qpm_dict
261
262
263def GetAllowedOriginsSet():
264 global allowed_origins_set
265 if allowed_origins_set is None:
266 allowed_origins_set = GetClientConfigSvc().GetAllowedOriginsSet()
267 return allowed_origins_set