sovereign 0.19.3__py3-none-any.whl → 1.0.0b148__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sovereign might be problematic. Click here for more details.

Files changed (80) hide show
  1. sovereign/__init__.py +13 -81
  2. sovereign/app.py +59 -48
  3. sovereign/cache/__init__.py +172 -0
  4. sovereign/cache/backends/__init__.py +110 -0
  5. sovereign/cache/backends/s3.py +143 -0
  6. sovereign/cache/filesystem.py +73 -0
  7. sovereign/cache/types.py +15 -0
  8. sovereign/configuration.py +573 -0
  9. sovereign/constants.py +1 -0
  10. sovereign/context.py +271 -104
  11. sovereign/dynamic_config/__init__.py +113 -0
  12. sovereign/dynamic_config/deser.py +78 -0
  13. sovereign/dynamic_config/loaders.py +120 -0
  14. sovereign/events.py +49 -0
  15. sovereign/logging/access_logger.py +85 -0
  16. sovereign/logging/application_logger.py +54 -0
  17. sovereign/logging/base_logger.py +41 -0
  18. sovereign/logging/bootstrapper.py +36 -0
  19. sovereign/logging/types.py +10 -0
  20. sovereign/middlewares.py +8 -7
  21. sovereign/modifiers/lib.py +1 -0
  22. sovereign/rendering.py +192 -0
  23. sovereign/response_class.py +18 -0
  24. sovereign/server.py +93 -35
  25. sovereign/sources/file.py +1 -1
  26. sovereign/sources/inline.py +1 -0
  27. sovereign/sources/lib.py +1 -0
  28. sovereign/sources/poller.py +296 -53
  29. sovereign/statistics.py +17 -20
  30. sovereign/templates/base.html +59 -46
  31. sovereign/templates/resources.html +203 -102
  32. sovereign/testing/loaders.py +8 -0
  33. sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
  34. sovereign/tracing.py +102 -0
  35. sovereign/types.py +299 -0
  36. sovereign/utils/auth.py +26 -13
  37. sovereign/utils/crypto/__init__.py +0 -0
  38. sovereign/utils/crypto/crypto.py +135 -0
  39. sovereign/utils/crypto/suites/__init__.py +21 -0
  40. sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
  41. sovereign/utils/crypto/suites/base_cipher.py +21 -0
  42. sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
  43. sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
  44. sovereign/utils/dictupdate.py +2 -1
  45. sovereign/utils/eds.py +37 -21
  46. sovereign/utils/mock.py +54 -16
  47. sovereign/utils/resources.py +17 -0
  48. sovereign/utils/version_info.py +8 -0
  49. sovereign/views/__init__.py +4 -0
  50. sovereign/views/api.py +61 -0
  51. sovereign/views/crypto.py +46 -15
  52. sovereign/views/discovery.py +37 -116
  53. sovereign/views/healthchecks.py +87 -18
  54. sovereign/views/interface.py +112 -112
  55. sovereign/worker.py +204 -0
  56. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/METADATA +79 -76
  57. sovereign-1.0.0b148.dist-info/RECORD +77 -0
  58. {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/WHEEL +1 -1
  59. sovereign-1.0.0b148.dist-info/entry_points.txt +38 -0
  60. sovereign_files/__init__.py +0 -0
  61. sovereign_files/static/darkmode.js +51 -0
  62. sovereign_files/static/node_expression.js +42 -0
  63. sovereign_files/static/panel.js +76 -0
  64. sovereign_files/static/resources.css +246 -0
  65. sovereign_files/static/resources.js +642 -0
  66. sovereign_files/static/sass/style.scss +33 -0
  67. sovereign_files/static/style.css +16143 -0
  68. sovereign_files/static/style.css.map +1 -0
  69. sovereign/config_loader.py +0 -225
  70. sovereign/discovery.py +0 -175
  71. sovereign/logs.py +0 -131
  72. sovereign/schemas.py +0 -780
  73. sovereign/static/sass/style.scss +0 -27
  74. sovereign/static/style.css +0 -13553
  75. sovereign/templates/ul_filter.html +0 -22
  76. sovereign/utils/crypto.py +0 -103
  77. sovereign/views/admin.py +0 -120
  78. sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
  79. sovereign-0.19.3.dist-info/RECORD +0 -47
  80. sovereign-0.19.3.dist-info/entry_points.txt +0 -10
sovereign/server.py CHANGED
@@ -1,49 +1,107 @@
1
- import gunicorn.app.base
2
- from fastapi import FastAPI
3
- from typing import Optional, Dict, Any, Callable
4
- from sovereign import asgi_config
5
- from sovereign.app import app
6
- from sovereign.utils.entry_point_loader import EntryPointLoader
1
+ import warnings
2
+ import tempfile
3
+ import configparser
4
+ from pathlib import Path
7
5
 
6
+ import uvicorn
8
7
 
9
- class StandaloneApplication(gunicorn.app.base.BaseApplication): # type: ignore
10
- _HOOKS = ["pre_fork", "post_fork"]
8
+ from sovereign import application_logger as log
9
+ from sovereign.configuration import SovereignAsgiConfig, SupervisordConfig
11
10
 
12
- def __init__(
13
- self, application: FastAPI, options: Optional[Dict[str, Any]] = None
14
- ) -> None:
15
- self.loader = EntryPointLoader(*self._HOOKS)
16
- self.options = options or {}
17
- self.application = application
18
- super().__init__()
11
+ asgi_config = SovereignAsgiConfig()
12
+ supervisord_config = SupervisordConfig()
19
13
 
20
- def load_config(self) -> None:
21
- for key, value in self.options.items():
22
- self.cfg.set(key.lower(), value)
23
14
 
24
- for hook in self._HOOKS:
25
- self._install_hooks(hook)
15
+ def web() -> None:
16
+ from sovereign.app import app
26
17
 
27
- def _install_hooks(self, name: str) -> None:
28
- hooks: list[Callable[[Any, Any], None]] = [
29
- ep.load() for ep in self.loader.groups[name]
30
- ]
18
+ log.debug("Starting web server")
19
+ uvicorn.run(
20
+ app,
21
+ fd=0,
22
+ log_level=asgi_config.log_level,
23
+ access_log=False,
24
+ timeout_keep_alive=asgi_config.keepalive,
25
+ host=asgi_config.host,
26
+ port=asgi_config.port,
27
+ workers=1, # per managed supervisor proc
28
+ )
31
29
 
32
- def master_hook(server: Any, worker: Any) -> None:
33
- for hook in hooks:
34
- hook(server, worker)
35
30
 
36
- self.cfg.set(name, master_hook)
31
+ def worker():
32
+ from sovereign.worker import worker as worker_app
37
33
 
38
- def load(self) -> FastAPI:
39
- return self.application
34
+ log.debug("Starting worker")
35
+ uvicorn.run(
36
+ worker_app,
37
+ log_level=asgi_config.log_level,
38
+ access_log=False,
39
+ timeout_keep_alive=asgi_config.keepalive,
40
+ host="127.0.0.1",
41
+ port=9080,
42
+ workers=1, # per managed supervisor proc
43
+ )
40
44
 
41
45
 
42
- def main() -> None:
43
- asgi = StandaloneApplication(
44
- application=app, options=asgi_config.as_gunicorn_conf()
45
- )
46
- asgi.run()
46
+ def write_supervisor_conf() -> Path:
47
+ proc_env = {
48
+ "LANG": "en_US.UTF-8",
49
+ "LC_ALL": "en_US.UTF-8",
50
+ }
51
+ base = {
52
+ "autostart": "true",
53
+ "autorestart": "true",
54
+ "stdout_logfile": "/dev/stdout",
55
+ "stdout_logfile_maxbytes": "0",
56
+ "stderr_logfile": "/dev/stderr",
57
+ "stderr_logfile_maxbytes": "0",
58
+ "stopsignal": "QUIT",
59
+ "environment": ",".join(["=".join((k, v)) for k, v in proc_env.items()]),
60
+ }
61
+
62
+ conf = configparser.RawConfigParser()
63
+ conf["supervisord"] = supervisord = {
64
+ "nodaemon": str(supervisord_config.nodaemon).lower(),
65
+ "loglevel": supervisord_config.loglevel,
66
+ "pidfile": supervisord_config.pidfile,
67
+ "logfile": supervisord_config.logfile,
68
+ "directory": supervisord_config.directory,
69
+ }
70
+
71
+ conf["fcgi-program:web"] = web = {
72
+ **base,
73
+ "socket": f"tcp://{asgi_config.host}:{asgi_config.port}",
74
+ "numprocs": str(asgi_config.workers),
75
+ "process_name": "%(program_name)s-%(process_num)02d",
76
+ "command": "sovereign-web", # default niceness, higher CPU priority
77
+ }
78
+
79
+ conf["program:data"] = worker = {
80
+ **base,
81
+ "numprocs": "1",
82
+ "command": "nice -n 2 sovereign-worker", # run worker with reduced CPU priority (higher niceness value)
83
+ }
84
+
85
+ if user := asgi_config.user:
86
+ supervisord["user"] = user
87
+ web["user"] = user
88
+ worker["user"] = user
89
+
90
+ log.debug("Writing supervisor config")
91
+ with tempfile.NamedTemporaryFile("w", delete=False) as f:
92
+ conf.write(f)
93
+ log.debug("Supervisor config written out")
94
+ return Path(f.name)
95
+
96
+
97
+ def main():
98
+ path = write_supervisor_conf()
99
+ with warnings.catch_warnings():
100
+ warnings.simplefilter("ignore")
101
+ from supervisor import supervisord
102
+
103
+ log.debug("Starting processes")
104
+ supervisord.main(["-c", path])
47
105
 
48
106
 
49
107
  if __name__ == "__main__":
sovereign/sources/file.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from typing import Any, Dict
2
2
  from sovereign.sources.lib import Source
3
- from sovereign.config_loader import Loadable
3
+ from sovereign.dynamic_config import Loadable
4
4
 
5
5
 
6
6
  class File(Source):
@@ -19,6 +19,7 @@ Example configuration (YAML):
19
19
  region: us-east-1
20
20
  plan_id: 7d57270a-0348-58d3-829d-447a98fe98d5
21
21
  """
22
+
22
23
  from typing import Dict, Any, List
23
24
  from sovereign.sources.lib import Source
24
25
 
sovereign/sources/lib.py CHANGED
@@ -6,6 +6,7 @@ used via configuration.
6
6
 
7
7
  `todo entry point install guide`
8
8
  """
9
+
9
10
  import abc
10
11
  from typing import Any, Dict, List
11
12
 
@@ -1,3 +1,5 @@
1
+ import json
2
+ import uuid
1
3
  import asyncio
2
4
  import traceback
3
5
  from copy import deepcopy
@@ -6,15 +8,16 @@ from datetime import timedelta, datetime
6
8
  from typing import Iterable, Any, Dict, List, Union, Type, Optional
7
9
 
8
10
  from glom import glom, PathAccessError
11
+ from sovereign.statistics import StatsDProxy
9
12
 
13
+ from sovereign.types import Node
14
+ from sovereign.configuration import ConfiguredSource, SourceData, config
10
15
  from sovereign.utils.entry_point_loader import EntryPointLoader
11
16
  from sovereign.sources.lib import Source
12
17
  from sovereign.modifiers.lib import Modifier, GlobalModifier
13
- from sovereign.schemas import (
14
- ConfiguredSource,
15
- SourceData,
16
- Node,
17
- )
18
+ from sovereign.events import bus, Topic, Event
19
+
20
+ from structlog.stdlib import BoundLogger
18
21
 
19
22
 
20
23
  def is_debug_request(v: str, debug: bool = False) -> bool:
@@ -33,15 +36,153 @@ Mods = Dict[str, Type[Modifier]]
33
36
  GMods = Dict[str, Type[GlobalModifier]]
34
37
 
35
38
 
39
+ def _deep_diff(old, new, path="") -> list[dict[str, Any]]:
40
+ changes: list[dict[str, Any]] = []
41
+
42
+ # handle add/remove
43
+ if (old, new) == (None, None):
44
+ return changes
45
+ elif old is None:
46
+ changes.append({"op": "add", "path": path, "value": new})
47
+ return changes
48
+ elif new is None:
49
+ changes.append({"op": "remove", "path": path, "old_value": old})
50
+ return changes
51
+
52
+ # handle completely different types
53
+ if type(old) is not type(new):
54
+ changes.append(
55
+ {"op": "change", "path": path, "old_value": old, "new_value": new}
56
+ )
57
+ return changes
58
+
59
+ # handle fields recursively
60
+ if isinstance(old, dict) and isinstance(new, dict):
61
+ all_keys = set(old.keys()) | set(new.keys())
62
+
63
+ for key in sorted(all_keys):
64
+ old_val = old.get(key)
65
+ new_val = new.get(key)
66
+
67
+ current_path = f"{path}.{key}" if path else key
68
+
69
+ if key not in old:
70
+ changes.append({"op": "add", "path": current_path, "value": new_val})
71
+ elif key not in new:
72
+ changes.append(
73
+ {"op": "remove", "path": current_path, "old_value": old_val}
74
+ )
75
+ elif old_val != new_val:
76
+ nested_changes = _deep_diff(old_val, new_val, current_path)
77
+ changes.extend(nested_changes)
78
+
79
+ # handle items recursively
80
+ elif isinstance(old, list) and isinstance(new, list):
81
+ max_len = max(len(old), len(new))
82
+
83
+ for i in range(max_len):
84
+ current_path = f"{path}[{i}]" if path else f"[{i}]"
85
+
86
+ if i >= len(old):
87
+ changes.append({"op": "add", "path": current_path, "value": new[i]})
88
+ elif i >= len(new):
89
+ changes.append(
90
+ {"op": "remove", "path": current_path, "old_value": old[i]}
91
+ )
92
+ elif old[i] != new[i]:
93
+ nested_changes = _deep_diff(old[i], new[i], current_path)
94
+ changes.extend(nested_changes)
95
+
96
+ # handle primitives
97
+ else:
98
+ if old != new:
99
+ changes.append(
100
+ {"op": "change", "path": path, "old_value": old, "new_value": new}
101
+ )
102
+
103
+ return changes
104
+
105
+
106
+ def per_field_diff(old, new) -> list[dict[str, Any]]:
107
+ changes = []
108
+ max_len = max(len(old), len(new))
109
+
110
+ for i in range(max_len):
111
+ old_inst = old[i] if i < len(old) else None
112
+ new_inst = new[i] if i < len(new) else None
113
+
114
+ if old_inst is None:
115
+ changes.append({"op": "add", "path": f"[{i}]", "value": new_inst})
116
+ elif new_inst is None:
117
+ changes.append({"op": "remove", "path": f"[{i}]", "old_value": old_inst})
118
+ elif old_inst != new_inst:
119
+ # Use the deep diff with index prefix
120
+ field_changes = _deep_diff(old_inst, new_inst, f"[{i}]")
121
+ changes.extend(field_changes)
122
+
123
+ return changes
124
+
125
+
126
+ def _gen_uuid(diff_summary: dict[str, Any]) -> str:
127
+ blob = json.dumps(diff_summary, sort_keys=True, separators=("", ""))
128
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, blob))
129
+
130
+
131
+ def source_diff_summary(prev, curr) -> dict[str, Any]:
132
+ if prev is None:
133
+ summary = {
134
+ "type": "initial_load",
135
+ "scopes": {
136
+ scope: {"added": len(instances)}
137
+ for scope, instances in curr.scopes.items()
138
+ if instances
139
+ },
140
+ }
141
+ else:
142
+ summary = {"type": "update", "scopes": {}}
143
+
144
+ all_scopes = set(prev.scopes.keys()) | set(curr.scopes.keys())
145
+
146
+ for scope in sorted(all_scopes):
147
+ old = prev.scopes.get(scope, [])
148
+ new = curr.scopes.get(scope, [])
149
+
150
+ n_old = len(old)
151
+ n_new = len(new)
152
+
153
+ scope_changes: dict[str, Any] = {}
154
+
155
+ if n_old == 0 and n_new > 0:
156
+ scope_changes["added"] = n_new
157
+ elif n_old > 0 and n_new == 0:
158
+ scope_changes["removed"] = n_old
159
+ elif old != new:
160
+ detailed_changes = per_field_diff(old, new)
161
+ if detailed_changes:
162
+ scope_changes["field_changes"] = detailed_changes
163
+ scope_changes["count_change"] = n_new - n_old
164
+
165
+ if scope_changes:
166
+ summary["scopes"][scope] = scope_changes # type: ignore
167
+
168
+ if not summary["scopes"]:
169
+ summary = {"type": "no_changes"}
170
+
171
+ summary["uuid"] = _gen_uuid(summary)
172
+ return summary
173
+
174
+
36
175
  class SourcePoller:
176
+ stats: StatsDProxy
177
+
37
178
  def __init__(
38
179
  self,
39
180
  sources: List[ConfiguredSource],
40
181
  matching_enabled: bool,
41
- node_match_key: str,
42
- source_match_key: str,
182
+ node_match_key: Optional[str],
183
+ source_match_key: Optional[str],
43
184
  source_refresh_rate: int,
44
- logger: Any,
185
+ logger: BoundLogger,
45
186
  stats: Any,
46
187
  ):
47
188
  self.matching_enabled = matching_enabled
@@ -65,9 +206,17 @@ class SourcePoller:
65
206
  self.global_modifiers: GMods = dict()
66
207
 
67
208
  # initially set data and modify
68
- self.source_data = self.refresh()
209
+ self.source_data: SourceData = SourceData()
210
+ self.source_data_modified: SourceData = SourceData()
69
211
  self.last_updated = datetime.now()
70
212
  self.instance_count = 0
213
+ self.initialized = False
214
+
215
+ self.cache: dict[str, dict[str, list[dict[str, Any]]]] = {}
216
+ self.registry: set[Any] = set()
217
+
218
+ # Retry state
219
+ self.retry_count = 0
71
220
 
72
221
  @property
73
222
  def data_is_stale(self) -> bool:
@@ -102,7 +251,7 @@ class SourcePoller:
102
251
  ret = dict()
103
252
  for entry_point in entry_points:
104
253
  if entry_point.name in configured_modifiers:
105
- self.logger(event=f"Loading modifier {entry_point.name}")
254
+ self.logger.debug(f"Loading modifier {entry_point.name}")
106
255
  ret[entry_point.name] = entry_point.load()
107
256
  loaded = len(ret)
108
257
  configured = len(configured_modifiers)
@@ -118,7 +267,7 @@ class SourcePoller:
118
267
  ret = dict()
119
268
  for entry_point in entry_points:
120
269
  if entry_point.name in configured_modifiers:
121
- self.logger(event=f"Loading global modifier {entry_point.name}")
270
+ self.logger.debug(f"Loading global modifier {entry_point.name}")
122
271
  ret[entry_point.name] = entry_point.load()
123
272
 
124
273
  loaded = len(ret)
@@ -130,55 +279,98 @@ class SourcePoller:
130
279
  return ret
131
280
 
132
281
  def apply_modifications(self, data: Optional[SourceData]) -> SourceData:
133
- with self.stats.timed("modifiers.apply_ms"):
134
- if data is None:
135
- data = self.source_data
136
-
137
- source_data = deepcopy(data)
138
- for scope, instances in source_data.scopes.items():
139
- for g in self.global_modifiers.values():
140
- global_modifier = g(instances)
141
- global_modifier.apply()
142
- source_data.scopes[scope] = global_modifier.join()
143
-
144
- for instance in source_data.scopes[scope]:
145
- for m in self.modifiers.values():
146
- modifier = m(instance)
147
- if modifier.match():
148
- # Modifies the instance in-place
149
- modifier.apply()
150
- return source_data
151
-
152
- def refresh(self) -> SourceData:
282
+ if data is None:
283
+ data = self.source_data
284
+ if len(self.modifiers) or len(self.global_modifiers):
285
+ try:
286
+ with self.stats.timed("modifiers.apply_ms"):
287
+ data = deepcopy(data)
288
+ for scope, instances in data.scopes.items():
289
+ for g in self.global_modifiers.values():
290
+ global_modifier = g(instances)
291
+ global_modifier.apply()
292
+ data.scopes[scope] = global_modifier.join()
293
+
294
+ for instance in data.scopes[scope]:
295
+ for m in self.modifiers.values():
296
+ modifier = m(instance)
297
+ if modifier.match():
298
+ # Modifies the instance in-place
299
+ modifier.apply()
300
+ self.stats.increment("modifiers.apply.success")
301
+
302
+ except Exception:
303
+ self.stats.increment("modifiers.apply.failure")
304
+ raise
305
+
306
+ return data
307
+
308
+ def refresh(self) -> bool:
153
309
  self.stats.increment("sources.attempt")
310
+
311
+ # Get retry config from global source config
312
+ max_retries = config.source_config.max_retries
313
+
154
314
  try:
155
315
  new = SourceData()
156
316
  for source in self.sources:
157
- new.scopes[source.scope].extend(source.get())
317
+ scope = source.scope
318
+ if scope not in new.scopes:
319
+ new.scopes[scope] = []
320
+ new.scopes[scope].extend(source.get())
158
321
  except Exception as e:
159
- self.logger(
160
- event="Error while refreshing sources",
322
+ self.retry_count += 1
323
+ self.logger.error(
324
+ event=f"Error while refreshing sources (attempt {self.retry_count}/{max_retries})",
161
325
  traceback=[line for line in traceback.format_exc().split("\n")],
162
326
  error=e.__class__.__name__,
163
327
  detail=getattr(e, "detail", "-"),
328
+ retry_count=self.retry_count,
164
329
  )
165
330
  self.stats.increment("sources.error")
166
- raise
331
+
332
+ if self.retry_count >= max_retries:
333
+ # Reset retry count for next cycle
334
+ self.retry_count = 0
335
+ self.stats.increment("sources.error.final")
336
+ return False
337
+
338
+ # Success - reset retry count
339
+ self.retry_count = 0
167
340
 
168
341
  # Is the new data the same as what we currently have
169
342
  if new == getattr(self, "source_data", None):
170
343
  self.stats.increment("sources.unchanged")
171
344
  self.last_updated = datetime.now()
172
- return self.source_data
345
+ return False
173
346
  else:
174
347
  self.stats.increment("sources.refreshed")
175
348
  self.last_updated = datetime.now()
349
+ old_data = getattr(self, "source_data", None)
176
350
  self.instance_count = len(
177
351
  [instance for scope in new.scopes.values() for instance in scope]
178
352
  )
179
- return new
353
+
354
+ if config.logging.log_source_diffs:
355
+ diff_summary = source_diff_summary(old_data, new)
356
+ # printing json directly because the logger is fucking stupid
357
+ print(
358
+ json.dumps(
359
+ dict(
360
+ event="Sources refreshed with changes",
361
+ level="info",
362
+ diff=diff_summary,
363
+ total_instances=self.instance_count,
364
+ )
365
+ )
366
+ )
367
+
368
+ self.source_data = new
369
+ return True
180
370
 
181
371
  def extract_node_key(self, node: Union[Node, Dict[Any, Any]]) -> Any:
372
+ if self.node_match_key is None:
373
+ return
182
374
  if "." not in self.node_match_key:
183
375
  # key is not nested, don't need glom
184
376
  node_value = getattr(node, self.node_match_key)
@@ -187,13 +379,13 @@ class SourcePoller:
187
379
  node_value = glom(node, self.node_match_key)
188
380
  except PathAccessError:
189
381
  raise RuntimeError(
190
- f'Failed to find key "{self.node_match_key}" in discoveryRequest({node}).\n'
191
- f"See the docs for more info: "
192
- f"https://vsyrakis.bitbucket.io/sovereign/docs/html/guides/node_matching.html"
382
+ f'Failed to find key "{self.node_match_key}" in discoveryRequest({node})'
193
383
  )
194
384
  return node_value
195
385
 
196
386
  def extract_source_key(self, source: Dict[Any, Any]) -> Any:
387
+ if self.source_match_key is None:
388
+ return
197
389
  if "." not in self.source_match_key:
198
390
  # key is not nested, don't need glom
199
391
  source_value = source[self.source_match_key]
@@ -202,9 +394,7 @@ class SourcePoller:
202
394
  source_value = glom(source, self.source_match_key)
203
395
  except PathAccessError:
204
396
  raise RuntimeError(
205
- f'Failed to find key "{self.source_match_key}" in instance({source}).\n'
206
- f"See the docs for more info: "
207
- f"https://vsyrakis.bitbucket.io/sovereign/docs/html/guides/node_matching.html"
397
+ f'Failed to find key "{self.source_match_key}" in instance({source})'
208
398
  )
209
399
  return source_value
210
400
 
@@ -221,18 +411,14 @@ class SourcePoller:
221
411
  if self.data_is_stale:
222
412
  # Log/emit metric and manually refresh sources.
223
413
  self.stats.increment("sources.stale")
224
- self.logger(
225
- event="Sources have not been refreshed in 2 minutes",
414
+ self.logger.debug(
415
+ "Sources have not been refreshed in 2 minutes",
226
416
  last_update=self.last_updated,
227
417
  instance_count=self.instance_count,
228
418
  )
229
- self.poll()
230
419
 
231
420
  ret = SourceData()
232
-
233
421
  if modify:
234
- if not hasattr(self, "source_data_modified"):
235
- self.poll()
236
422
  data = self.source_data_modified
237
423
  else:
238
424
  data = self.source_data
@@ -257,6 +443,8 @@ class SourcePoller:
257
443
  or is_debug_request(node_value)
258
444
  )
259
445
  if match:
446
+ if scope not in ret.scopes:
447
+ ret.scopes[scope] = []
260
448
  ret.scopes[scope].append(instance)
261
449
  return ret
262
450
 
@@ -284,11 +472,66 @@ class SourcePoller:
284
472
  ret[source_value] = None
285
473
  return list(ret.keys())
286
474
 
287
- def poll(self) -> None:
288
- self.source_data = self.refresh()
475
+ def add_to_context(self, request, output):
476
+ """middleware for adding matched instances to context"""
477
+ node_value = self.extract_node_key(request.node)
478
+ self.registry.add(node_value)
479
+
480
+ if instances := self.cache.get(node_value, None):
481
+ output.update(instances)
482
+ return
483
+
484
+ result = self.get_filtered_instances(node_value)
485
+ output.update(result)
486
+
487
+ def get_filtered_instances(self, node_value):
488
+ matches = self.match_node(node_value=node_value)
489
+ result = {}
490
+ for scope, instances in matches.scopes.items():
491
+ if scope in ("default", None):
492
+ result["instances"] = instances
493
+ else:
494
+ result[scope] = instances
495
+ self.cache[node_value] = result
496
+ return result
497
+
498
+ async def poll(self) -> None:
499
+ updated = self.refresh()
289
500
  self.source_data_modified = self.apply_modifications(self.source_data)
501
+ if not self.initialized:
502
+ await bus.publish(
503
+ Topic.CONTEXT,
504
+ Event(
505
+ message="Sources initialized",
506
+ metadata={"name": "sources"},
507
+ ),
508
+ )
509
+ self.initialized = True
510
+ if updated:
511
+ self.cache.clear()
512
+ await bus.publish(
513
+ Topic.CONTEXT,
514
+ Event(
515
+ message="Sources refreshed",
516
+ metadata={"name": "sources"},
517
+ ),
518
+ )
290
519
 
291
520
  async def poll_forever(self) -> None:
292
521
  while True:
293
- self.poll()
294
- await asyncio.sleep(self.source_refresh_rate)
522
+ try:
523
+ await self.poll()
524
+
525
+ # If we have retry count, use exponential backoff for next attempt
526
+ if self.retry_count > 0:
527
+ retry_delay = config.source_config.retry_delay
528
+ delay = min(
529
+ retry_delay * (2 ** (self.retry_count - 1)),
530
+ self.source_refresh_rate, # Cap at normal refresh rate
531
+ )
532
+ await asyncio.sleep(delay)
533
+ else:
534
+ await asyncio.sleep(self.source_refresh_rate)
535
+ except Exception as e:
536
+ self.logger.error(f"Unexpected error in poll loop: {e}")
537
+ await asyncio.sleep(self.source_refresh_rate)