Coverage for /usr/local/lib/python3.10/site-packages/opal_common-0.0.0-py3.10.egg/opal_common/git_utils/bundle_maker.py: 11%
126 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
1from functools import partial
2from pathlib import Path
3from typing import List, Optional, Set
5from ddtrace import tracer
6from git import Repo
7from git.objects import Commit
8from opal_common.engine import get_rego_package, is_data_module, is_policy_module
9from opal_common.git_utils.commit_viewer import (
10 CommitViewer,
11 VersionedDirectory,
12 VersionedFile,
13 find_ignore_match,
14 has_extension,
15 is_under_directories,
16)
17from opal_common.git_utils.diff_viewer import (
18 DiffViewer,
19 diffed_file_has_extension,
20 diffed_file_is_under_directories,
21)
22from opal_common.logger import logger
23from opal_common.paths import PathUtils
24from opal_common.schemas.policy import (
25 DataModule,
26 DeletedFiles,
27 PolicyBundle,
28 RegoModule,
29)
32class BundleMaker:
33 """creates a policy bundle based on:
35 - the current state of the policy git repo
36 - filtering criteria on the policy git repo (specific directories, specific file types, etc)
38 there are two types of bundles:
39 - a full/complete bundle, representing the state of the repo at one commit
40 - a diff bundle, representing only the *changes* made to the policy between two commits (the diff).
41 """
43 def __init__(
44 self,
45 repo: Repo,
46 in_directories: Set[Path],
47 extensions: Optional[List[str]] = None,
48 root_manifest_path: str = ".manifest",
49 bundle_ignore: Optional[List[str]] = None,
50 ):
51 """[summary]
53 Args:
54 repo (Repo): the policy repo
55 in_directories (Set[Path]): the directories in the repo that we want to filter on.
56 if the entire repo is relevant, pass Path(".") as the directory
57 (all paths are relative to the repo root).
58 extensions (Optional[List[str]]): optional filtering on file extensions.
59 bundle_ignore (Optional[List[str]]): optional ignoring of files using glob paths.
60 Note that the std lib's implementation of Path does not support interpreting double asterisks (**)
61 in glob paths as recursive directories so globs will need to explicitly match those directories.
62 Issue: https://github.com/python/cpython/pull/101398
63 """
64 self._repo = repo
65 self._directories = in_directories
66 self._has_extension = partial(has_extension, extensions=extensions)
67 self._is_under_directories = partial(
68 is_under_directories, directories=in_directories
69 )
70 self._diffed_file_has_extension = partial(
71 diffed_file_has_extension, extensions=extensions
72 )
73 self._diffed_file_is_under_directories = partial(
74 diffed_file_is_under_directories, directories=in_directories
75 )
76 self._root_manifest_path = Path(root_manifest_path)
78 self._bundle_ignore = bundle_ignore
79 self._find_ignore_match = partial(
80 find_ignore_match, bundle_ignore=bundle_ignore
81 )
82 self._diffed_file_find_ignore_match = lambda diff: find_ignore_match(
83 diff.b_path, bundle_ignore
84 )
86 def _get_explicit_manifest(self, viewer: CommitViewer) -> Optional[List[str]]:
87 """Rego policies often have dependencies (import statements) between
88 policies. Since the OPAL client is limited by the OPA REST api and this
89 api currently does not allow to load bundles (multiple policies
90 together), OPAL client can only load one policy at a time.
92 If policies with dependencies between them are loaded out-of-
93 order, OPA will throw an exception.
95 To mitigate this, we allow the developer to put a manifest file
96 in the repository (default path: .manifest). This file, if
97 exists, should contain the list of policy files (.rego) in the
98 correct order they should be loaded into OPA.
100 This method searches for an explicit manifest file, reads it and
101 returns the list of paths, or None if not found.
103 The manifest file can include references to other directories
104 containing a ".manifest" file, those would be recursively
105 expanded to compile the final manifest list.
106 """
107 visited_paths = []
109 def _compile_manifest_file(
110 dir: VersionedDirectory,
111 manifest_file_name: str = ".manifest",
112 _branch: List[str] = [],
113 ) -> List[str]:
114 explicit_manifest: List[Path] = []
115 manifest_file_path = dir.path / manifest_file_name
116 _branch.append(str(manifest_file_path))
118 logger.debug(f"Compiling manifest file { ' -> '.join(_branch)}")
119 try:
120 manifest_file = viewer.get_file(dir.path / manifest_file_name)
121 if manifest_file is None:
122 logger.info(
123 f"Manifest file {manifest_file_path} not found, assuming empty"
124 )
125 else:
126 for path_entry in manifest_file.read().splitlines():
127 # Path is relative to current directory, make it absolute
128 path_entry = dir.path / path_entry
130 if (
131 path_entry.is_absolute()
132 or dir.path.resolve() not in path_entry.resolve().parents
133 ):
134 # Block absolute paths or paths with ".." (CommitViewer ignores those anyway, but be explicit)
135 logger.warning(
136 f" Path '{path_entry}' is outside current .manifest directory"
137 )
138 continue
140 if not viewer.exists(path_entry):
141 logger.warning(f" Path '{path_entry}' does not exist")
142 continue
144 ignore_path_match = find_ignore_match(
145 Path(path_entry), self._bundle_ignore
146 )
147 if ignore_path_match != None:
148 logger.warning(
149 f" Path'{path_entry} is ignored by ignore glob '{ignore_path_match}'"
150 )
151 continue
153 if path_entry in visited_paths:
154 logger.warning(
155 f" Path '{path_entry}' has redundant references"
156 )
157 continue
159 visited_paths.append(path_entry)
161 dir_entry = viewer.get_directory(path_entry)
162 if dir_entry is not None:
163 # Reference to another directory, try to recursively load its manifest file
164 explicit_manifest += _compile_manifest_file(
165 dir_entry, _branch=list(_branch)
166 )
167 continue
169 # This is an existing file
170 explicit_manifest.append(str(path_entry))
171 logger.debug(
172 f" Path '{path_entry}' was added to explicit manifest"
173 )
175 except Exception as e:
176 logger.exception(
177 f" Failed to compile manifest file '{manifest_file_path}'"
178 )
179 return []
181 return explicit_manifest
183 root_manifest = viewer.get_node(self._root_manifest_path)
184 if isinstance(root_manifest, VersionedFile):
185 # Root manifest is supplied in old-fashioned way (as file path) - support for backward compatibility
186 logger.info(
187 f"Using root manifest file path (old-fashioned): '{root_manifest.path}'"
188 )
189 return _compile_manifest_file(
190 viewer.get_directory(root_manifest.path.parent),
191 manifest_file_name=root_manifest.path.name,
192 )
194 elif isinstance(root_manifest, VersionedDirectory):
195 # Root manifest is supplied in new-fashioned way (as a directory path containing ".manifest" file)
196 logger.info(
197 f"Using root manifest dir path (new-fashioned): '{root_manifest.path}'"
198 )
199 return _compile_manifest_file(root_manifest)
201 else:
202 logger.info(
203 f"Root manifest path doesn't exist, no explicit order would be imposed on policy bundle"
204 )
205 return list()
207 def _sort_manifest(
208 self, unsorted_manifest: List[str], explicit_sorting: Optional[List[str]]
209 ) -> List[str]:
210 """The way this sorting works, is assuming that explicit_sorting does
211 NOT necessarily contains all the policies found in the manifest, or
212 that even "policies" mentioned in it actually exists in the actual
213 generated manifest.
215 We must ensure that all items in unsorted_manifest must also
216 exist in the output list.
217 """
218 if not explicit_sorting:
219 return unsorted_manifest
221 # casting to Path
222 unsorted_paths = [Path(path) for path in unsorted_manifest]
223 sorting = [Path(path) for path in explicit_sorting]
225 # sorting the list
226 sorted_paths = PathUtils.sort_paths_according_to_explicit_sorting(
227 unsorted_paths, sorting
228 )
230 # cast back to string paths
231 return [str(path) for path in sorted_paths]
233 def make_bundle(self, commit: Commit) -> PolicyBundle:
234 """Creates a *complete* bundle of all the policy and data modules found
235 in the policy repo, when the repo HEAD is at the given `commit`.
237 Args:
238 commit (Commit): the commit the repo should be checked out on to search for policy files.
240 Returns:
241 bundle (PolicyBundle): the bundle of policy modules found in the repo (checked out on `commit`)
242 """
243 data_modules = []
244 policy_modules = []
245 manifest = []
247 with CommitViewer(commit) as viewer:
248 filter = (
249 lambda f: self._has_extension(f)
250 and self._is_under_directories(f)
251 and self._find_ignore_match(f.path) == None
252 )
253 explicit_manifest = self._get_explicit_manifest(viewer)
254 logger.debug(f"Explicit manifest to be used: {explicit_manifest}")
256 for source_file in viewer.files(filter):
257 with tracer.trace(
258 "bundle_maker.git_file_read", resource=str(source_file.path)
259 ):
260 contents = source_file.read()
261 path = source_file.path
263 if is_data_module(path):
264 data_modules.append(
265 DataModule(path=str(path.parent), data=contents)
266 )
267 manifest.append(str(path))
268 elif is_policy_module(path):
269 policy_modules.append(
270 RegoModule(
271 path=str(path),
272 package_name=get_rego_package(contents) or "",
273 rego=contents,
274 )
275 )
276 manifest.append(str(path))
278 return PolicyBundle(
279 manifest=self._sort_manifest(manifest, explicit_manifest),
280 hash=commit.hexsha,
281 data_modules=data_modules,
282 policy_modules=policy_modules,
283 )
285 def make_diff_bundle(self, old_commit: Commit, new_commit: Commit) -> PolicyBundle:
286 """Creates a *diff* bundle of all the policy and data modules that were
287 changed (either added, renamed, modified or deleted) between the
288 `old_commit` and `new_commit`. essentially all the relevant files when
289 running `git diff old_commit..new_commit`.
291 Note that we still filter only directories and file types given in the constructor.
293 Args:
294 old_commit (Commit): represents the previous known state of the repo.
295 The opal client subscribes to the policy state and gets updates from
296 the server via a pubsub channel. When it receives an update that
297 new state is available in the policy repo, the client requests a
298 *diff bundle* from the /policy api route. The client will report
299 its last known commit as the `old_commit`, and only new state
300 (the diff from the client known commit to the server newest commit)
301 will be returned back.
302 commit (Commit): represents the newest known commit in the server (the new state).
304 Returns:
305 bundle (PolicyBundle): a diff bundle containing only the policy modules changed
306 between `old_commit` and `new_commit`.
307 """
308 data_modules = []
309 policy_modules = []
310 deleted_data_modules = []
311 deleted_policy_modules = []
312 manifest = []
313 explicit_manifest = self._get_explicit_manifest(CommitViewer(new_commit))
315 with DiffViewer(old_commit, new_commit) as viewer:
316 filter = lambda diff: (
317 self._diffed_file_has_extension(diff)
318 and self._diffed_file_is_under_directories(diff)
319 and self._diffed_file_find_ignore_match(diff) == None
320 )
321 for source_file in viewer.added_or_modified_files(filter):
322 contents = source_file.read()
323 path = source_file.path
325 if is_data_module(path):
326 data_modules.append(
327 # in OPA, the data module path is the containing directory
328 # i.e: /path/to/data.json will put the json contents under "/path/to" in the opa tree
329 DataModule(path=str(path.parent), data=contents)
330 )
331 manifest.append(str(path))
332 elif is_policy_module(path):
333 policy_modules.append(
334 RegoModule(
335 path=str(path),
336 package_name=get_rego_package(contents) or "",
337 rego=contents,
338 )
339 )
340 manifest.append(str(path))
342 for source_file in viewer.deleted_files(filter):
343 path = source_file.path
345 if is_data_module(path):
346 # in OPA, the data module path is the containing directory (see above)
347 deleted_data_modules.append(str(path.parent))
348 elif is_policy_module(path):
349 deleted_policy_modules.append(path)
351 if deleted_data_modules or deleted_policy_modules:
352 deleted_policies = self._sort_manifest(
353 deleted_policy_modules, explicit_manifest
354 )
355 deleted_policies.reverse() # when removed, dependent policies should be removed first
357 deleted_files = DeletedFiles(
358 data_modules=deleted_data_modules,
359 policy_modules=deleted_policies,
360 )
361 else:
362 deleted_files = None
364 return PolicyBundle(
365 manifest=self._sort_manifest(manifest, explicit_manifest),
366 hash=new_commit.hexsha,
367 old_hash=old_commit.hexsha,
368 data_modules=data_modules,
369 policy_modules=policy_modules,
370 deleted_files=deleted_files,
371 )