OLD | NEW |
1 # Copyright 2015 The LUCI Authors. All rights reserved. | 1 # Copyright 2015 The LUCI Authors. All rights reserved. |
2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
4 | 4 |
5 """Adapter between config service client and the rest of auth_service. | 5 """Adapter between config service client and the rest of auth_service. |
6 | 6 |
7 Basically a cron job that each minute refetches config files from config service | 7 Basically a cron job that each minute refetches config files from config service |
8 and modifies auth service datastore state if anything changed. | 8 and modifies auth service datastore state if anything changed. |
9 | 9 |
10 Following files are fetched: | 10 Following files are fetched: |
(...skipping 16 matching lines...) Expand all Loading... |
27 from google import protobuf | 27 from google import protobuf |
28 from google.appengine.ext import ndb | 28 from google.appengine.ext import ndb |
29 | 29 |
30 from components import config | 30 from components import config |
31 from components import datastore_utils | 31 from components import datastore_utils |
32 from components import gitiles | 32 from components import gitiles |
33 from components import utils | 33 from components import utils |
34 from components.auth import ipaddr | 34 from components.auth import ipaddr |
35 from components.auth import model | 35 from components.auth import model |
36 from components.config import validation | 36 from components.config import validation |
37 from components.config import validation_context | |
38 | 37 |
39 from proto import config_pb2 | 38 from proto import config_pb2 |
40 import importer | 39 import importer |
41 | 40 |
42 | 41 |
43 # Config file revision number and where it came from. | 42 # Config file revision number and where it came from. |
44 Revision = collections.namedtuple('Revision', ['revision', 'url']) | 43 Revision = collections.namedtuple('Revision', ['revision', 'url']) |
45 | 44 |
46 | 45 |
47 class CannotLoadConfigError(Exception): | 46 class CannotLoadConfigError(Exception): |
(...skipping 13 matching lines...) Expand all Loading... |
61 | 60 |
62 | 61 |
63 def get_remote_url(): | 62 def get_remote_url(): |
64 """Returns URL of a config service if configured, to display in UI.""" | 63 """Returns URL of a config service if configured, to display in UI.""" |
65 settings = config.ConfigSettings.cached() | 64 settings = config.ConfigSettings.cached() |
66 if settings and settings.service_hostname: | 65 if settings and settings.service_hostname: |
67 return 'https://%s' % settings.service_hostname | 66 return 'https://%s' % settings.service_hostname |
68 return None | 67 return None |
69 | 68 |
70 | 69 |
71 def get_config_revision(path): | 70 def get_revisions(): |
| 71 """Returns a mapping {config file name => Revision instance or None}.""" |
| 72 futures = {p: _get_config_revision_async(p) for p in _CONFIG_SCHEMAS} |
| 73 return {p: f.get_result() for p, f in futures.iteritems()} |
| 74 |
| 75 |
| 76 def _get_config_revision_async(path): |
72 """Returns tuple with info about last imported config revision.""" | 77 """Returns tuple with info about last imported config revision.""" |
| 78 assert path in _CONFIG_SCHEMAS, path |
73 schema = _CONFIG_SCHEMAS.get(path) | 79 schema = _CONFIG_SCHEMAS.get(path) |
74 return schema['revision_getter']() if schema else None | 80 return schema['revision_getter']() |
75 | 81 |
76 | 82 |
77 @utils.cache_with_expiration(expiration_sec=60) | 83 @utils.cache_with_expiration(expiration_sec=60) |
78 def get_settings(): | 84 def get_settings(): |
79 """Returns auth service own settings (from settings.cfg) as SettingsCfg proto. | 85 """Returns auth service own settings (from settings.cfg) as SettingsCfg proto. |
80 | 86 |
81 Returns default settings if the ones in the datastore are no longer valid. | 87 Returns default settings if the ones in the datastore are no longer valid. |
82 """ | 88 """ |
83 text = _get_service_config('settings.cfg') | 89 text = _get_service_config('settings.cfg') |
84 if not text: | 90 if not text: |
(...skipping 23 matching lines...) Expand all Loading... |
108 configs = _fetch_configs(_CONFIG_SCHEMAS) | 114 configs = _fetch_configs(_CONFIG_SCHEMAS) |
109 except CannotLoadConfigError as exc: | 115 except CannotLoadConfigError as exc: |
110 logging.error('Failed to fetch configs\n%s', exc) | 116 logging.error('Failed to fetch configs\n%s', exc) |
111 return | 117 return |
112 | 118 |
113 # Figure out what needs to be updated. | 119 # Figure out what needs to be updated. |
114 dirty = {} | 120 dirty = {} |
115 dirty_in_authdb = {} | 121 dirty_in_authdb = {} |
116 for path, (new_rev, conf) in sorted(configs.iteritems()): | 122 for path, (new_rev, conf) in sorted(configs.iteritems()): |
117 assert path in _CONFIG_SCHEMAS, path | 123 assert path in _CONFIG_SCHEMAS, path |
118 cur_rev = get_config_revision(path) | 124 cur_rev = _get_config_revision_async(path).get_result() |
119 if cur_rev != new_rev or force: | 125 if cur_rev != new_rev or force: |
120 if _CONFIG_SCHEMAS[path]['use_authdb_transaction']: | 126 if _CONFIG_SCHEMAS[path]['use_authdb_transaction']: |
121 dirty_in_authdb[path] = (new_rev, conf) | 127 dirty_in_authdb[path] = (new_rev, conf) |
122 else: | 128 else: |
123 dirty[path] = (new_rev, conf) | 129 dirty[path] = (new_rev, conf) |
124 else: | 130 else: |
125 logging.info('Config %s is up-to-date at rev %s', path, cur_rev.revision) | 131 logging.info('Config %s is up-to-date at rev %s', path, cur_rev.revision) |
126 | 132 |
127 # First update configs that do not touch AuthDB, one by one. | 133 # First update configs that do not touch AuthDB, one by one. |
128 for path, (rev, conf) in sorted(dirty.iteritems()): | 134 for path, (rev, conf) in sorted(dirty.iteritems()): |
(...skipping 57 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
186 Root entity. Key ID is config file name. | 192 Root entity. Key ID is config file name. |
187 """ | 193 """ |
188 # The body of the config itself. | 194 # The body of the config itself. |
189 config = ndb.TextProperty() | 195 config = ndb.TextProperty() |
190 # Last imported SHA1 revision of the config. | 196 # Last imported SHA1 revision of the config. |
191 revision = ndb.StringProperty(indexed=False) | 197 revision = ndb.StringProperty(indexed=False) |
192 # URL the config was imported from. | 198 # URL the config was imported from. |
193 url = ndb.StringProperty(indexed=False) | 199 url = ndb.StringProperty(indexed=False) |
194 | 200 |
195 | 201 |
196 def _get_service_config_rev(cfg_name): | 202 @ndb.tasklet |
| 203 def _get_service_config_rev_async(cfg_name): |
197 """Returns last processed Revision of given config.""" | 204 """Returns last processed Revision of given config.""" |
198 e = _AuthServiceConfig.get_by_id(cfg_name) | 205 e = yield _AuthServiceConfig.get_by_id_async(cfg_name) |
199 return Revision(e.revision, e.url) if e else None | 206 raise ndb.Return(Revision(e.revision, e.url) if e else None) |
200 | 207 |
201 | 208 |
202 def _get_service_config(cfg_name): | 209 def _get_service_config(cfg_name): |
203 """Returns text of given config file or None if missing.""" | 210 """Returns text of given config file or None if missing.""" |
204 e = _AuthServiceConfig.get_by_id(cfg_name) | 211 e = _AuthServiceConfig.get_by_id(cfg_name) |
205 return e.config if e else None | 212 return e.config if e else None |
206 | 213 |
207 | 214 |
208 @ndb.transactional | 215 @ndb.transactional |
209 def _update_service_config(cfg_name, rev, conf): | 216 def _update_service_config(cfg_name, rev, conf): |
210 """Stores new config (and its revision). | 217 """Stores new config (and its revision). |
211 | 218 |
212 This function is called only if config has already been validated. | 219 This function is called only if config has already been validated. |
213 """ | 220 """ |
214 assert isinstance(conf, basestring) | 221 assert isinstance(conf, basestring) |
215 e = _AuthServiceConfig.get_by_id(cfg_name) or _AuthServiceConfig(id=cfg_name) | 222 e = _AuthServiceConfig.get_by_id(cfg_name) or _AuthServiceConfig(id=cfg_name) |
216 old = e.config | 223 old = e.config |
217 e.populate(config=conf, revision=rev.revision, url=rev.url) | 224 e.populate(config=conf, revision=rev.revision, url=rev.url) |
218 e.put() | 225 e.put() |
219 return old != conf | 226 return old != conf |
220 | 227 |
221 | 228 |
222 ### Group importer config implementation details. | 229 ### Group importer config implementation details. |
223 | 230 |
224 | 231 |
225 def _get_imports_config_revision(): | 232 @ndb.tasklet |
| 233 def _get_imports_config_revision_async(): |
226 """Returns Revision of last processed imports.cfg config.""" | 234 """Returns Revision of last processed imports.cfg config.""" |
227 e = importer.config_key().get() | 235 e = yield importer.config_key().get_async() |
228 if not e or not isinstance(e.config_revision, dict): | 236 if not e or not isinstance(e.config_revision, dict): |
229 return None | 237 raise ndb.Return(None) |
230 desc = e.config_revision | 238 desc = e.config_revision |
231 return Revision(desc.get('rev'), desc.get('url')) | 239 raise ndb.Return(Revision(desc.get('rev'), desc.get('url'))) |
232 | 240 |
233 | 241 |
234 def _update_imports_config(rev, conf): | 242 def _update_imports_config(rev, conf): |
235 """Applies imports.cfg config.""" | 243 """Applies imports.cfg config.""" |
236 # Rewrite existing config even if it is the same (to update 'rev'). | 244 # Rewrite existing config even if it is the same (to update 'rev'). |
237 cur = importer.read_config() | 245 cur = importer.read_config() |
238 importer.write_config(conf, {'rev': rev.revision, 'url': rev.url}) | 246 importer.write_config(conf, {'rev': rev.revision, 'url': rev.url}) |
239 return cur != conf | 247 return cur != conf |
240 | 248 |
241 | 249 |
242 ### Implementation of configs expanded to AuthDB entities. | 250 ### Implementation of configs expanded to AuthDB entities. |
243 | 251 |
244 | 252 |
245 class _ImportedConfigRevisions(ndb.Model): | 253 class _ImportedConfigRevisions(ndb.Model): |
246 """Stores mapping config path -> {'rev': SHA1, 'url': URL}. | 254 """Stores mapping config path -> {'rev': SHA1, 'url': URL}. |
247 | 255 |
248 Parent entity is AuthDB root (auth.model.root_key()). Updated in a transaction | 256 Parent entity is AuthDB root (auth.model.root_key()). Updated in a transaction |
249 when importing configs. | 257 when importing configs. |
250 """ | 258 """ |
251 revisions = ndb.JsonProperty() | 259 revisions = ndb.JsonProperty() |
252 | 260 |
253 | 261 |
254 def _imported_config_revisions_key(): | 262 def _imported_config_revisions_key(): |
255 return ndb.Key(_ImportedConfigRevisions, 'self', parent=model.root_key()) | 263 return ndb.Key(_ImportedConfigRevisions, 'self', parent=model.root_key()) |
256 | 264 |
257 | 265 |
258 def _get_authdb_config_rev(path): | 266 @ndb.tasklet |
| 267 def _get_authdb_config_rev_async(path): |
259 """Returns Revision of last processed config given its name.""" | 268 """Returns Revision of last processed config given its name.""" |
260 mapping = _imported_config_revisions_key().get() | 269 mapping = yield _imported_config_revisions_key().get_async() |
261 if not mapping or not isinstance(mapping.revisions, dict): | 270 if not mapping or not isinstance(mapping.revisions, dict): |
262 return None | 271 raise ndb.Return(None) |
263 desc = mapping.revisions.get(path) | 272 desc = mapping.revisions.get(path) |
264 if not isinstance(desc, dict): | 273 if not isinstance(desc, dict): |
265 return None | 274 raise ndb.Return(None) |
266 return Revision(desc.get('rev'), desc.get('url')) | 275 raise ndb.Return(Revision(desc.get('rev'), desc.get('url'))) |
267 | 276 |
268 | 277 |
269 @datastore_utils.transactional | 278 @datastore_utils.transactional |
270 def _update_authdb_configs(configs): | 279 def _update_authdb_configs(configs): |
271 """Pushes new configs to AuthDB entity group. | 280 """Pushes new configs to AuthDB entity group. |
272 | 281 |
273 Args: | 282 Args: |
274 configs: dict {config path -> (Revision tuple, <config>)}. | 283 configs: dict {config path -> (Revision tuple, <config>)}. |
275 """ | 284 """ |
276 revs = _imported_config_revisions_key().get() | 285 revs = _imported_config_revisions_key().get() |
(...skipping 186 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
463 modified_ts=utils.utcnow(), | 472 modified_ts=utils.utcnow(), |
464 comment='Importing oauth.cfg at rev %s' % rev.revision) | 473 comment='Importing oauth.cfg at rev %s' % rev.revision) |
465 existing.put() | 474 existing.put() |
466 return True | 475 return True |
467 | 476 |
468 | 477 |
469 ### Description of all known config files: how to validate and import them. | 478 ### Description of all known config files: how to validate and import them. |
470 | 479 |
471 # Config file name -> { | 480 # Config file name -> { |
472 # 'proto_class': protobuf class of the config or None to keep it as text, | 481 # 'proto_class': protobuf class of the config or None to keep it as text, |
473 # 'revision_getter': lambda: <latest imported Revision>, | 482 # 'revision_getter': lambda: ndb.Future with <latest imported Revision> |
474 # 'validator': lambda config: <raises ValueError on invalid format> | 483 # 'validator': lambda config: <raises ValueError on invalid format> |
475 # 'updater': lambda rev, config: True if applied, False if not. | 484 # 'updater': lambda rev, config: True if applied, False if not. |
476 # 'use_authdb_transaction': True to call 'updater' in AuthDB transaction. | 485 # 'use_authdb_transaction': True to call 'updater' in AuthDB transaction. |
477 # 'default': Default config value to use if the config file is missing. | 486 # 'default': Default config value to use if the config file is missing. |
478 # } | 487 # } |
479 _CONFIG_SCHEMAS = { | 488 _CONFIG_SCHEMAS = { |
480 'imports.cfg': { | 489 'imports.cfg': { |
481 'proto_class': None, # importer configs are stored as text | 490 'proto_class': None, # importer configs are stored as text |
482 'revision_getter': _get_imports_config_revision, | 491 'revision_getter': _get_imports_config_revision_async, |
483 'updater': _update_imports_config, | 492 'updater': _update_imports_config, |
484 'use_authdb_transaction': False, | 493 'use_authdb_transaction': False, |
485 }, | 494 }, |
486 'ip_whitelist.cfg': { | 495 'ip_whitelist.cfg': { |
487 'proto_class': config_pb2.IPWhitelistConfig, | 496 'proto_class': config_pb2.IPWhitelistConfig, |
488 'revision_getter': lambda: _get_authdb_config_rev('ip_whitelist.cfg'), | 497 'revision_getter': lambda: _get_authdb_config_rev_async('ip_whitelist.cfg'), |
489 'updater': _update_ip_whitelist_config, | 498 'updater': _update_ip_whitelist_config, |
490 'use_authdb_transaction': True, | 499 'use_authdb_transaction': True, |
491 }, | 500 }, |
492 'oauth.cfg': { | 501 'oauth.cfg': { |
493 'proto_class': config_pb2.OAuthConfig, | 502 'proto_class': config_pb2.OAuthConfig, |
494 'revision_getter': lambda: _get_authdb_config_rev('oauth.cfg'), | 503 'revision_getter': lambda: _get_authdb_config_rev_async('oauth.cfg'), |
495 'updater': _update_oauth_config, | 504 'updater': _update_oauth_config, |
496 'use_authdb_transaction': True, | 505 'use_authdb_transaction': True, |
497 }, | 506 }, |
498 'settings.cfg': { | 507 'settings.cfg': { |
499 'proto_class': None, # settings are stored as text in datastore | 508 'proto_class': None, # settings are stored as text in datastore |
500 'default': '', # it's fine if config file is not there | 509 'default': '', # it's fine if config file is not there |
501 'revision_getter': lambda: _get_service_config_rev('settings.cfg'), | 510 'revision_getter': lambda: _get_service_config_rev_async('settings.cfg'), |
502 'updater': lambda rev, c: _update_service_config('settings.cfg', rev, c), | 511 'updater': lambda rev, c: _update_service_config('settings.cfg', rev, c), |
503 'use_authdb_transaction': False, | 512 'use_authdb_transaction': False, |
504 }, | 513 }, |
505 } | 514 } |
506 | 515 |
507 | 516 |
508 @utils.memcache('auth_service:get_configs_url', time=300) | 517 @utils.memcache('auth_service:get_configs_url', time=300) |
509 def _get_configs_url(): | 518 def _get_configs_url(): |
510 """Returns URL where luci-config fetches configs from.""" | 519 """Returns URL where luci-config fetches configs from.""" |
511 url = config.get_config_set_location(config.self_config_set()) | 520 url = config.get_config_set_location(config.self_config_set()) |
(...skipping 40 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
552 try: | 561 try: |
553 location = gitiles.Location.parse(configs_url) | 562 location = gitiles.Location.parse(configs_url) |
554 return str(gitiles.Location( | 563 return str(gitiles.Location( |
555 hostname=location.hostname, | 564 hostname=location.hostname, |
556 project=location.project, | 565 project=location.project, |
557 treeish=rev, | 566 treeish=rev, |
558 path=posixpath.join(location.path, path))) | 567 path=posixpath.join(location.path, path))) |
559 except ValueError: | 568 except ValueError: |
560 # Not a gitiles URL, return as is. | 569 # Not a gitiles URL, return as is. |
561 return configs_url | 570 return configs_url |
OLD | NEW |