ansible-vars 1.0.0__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.
@@ -0,0 +1,51 @@
1
+ # Constant types and values for ansible-vars
2
+
3
+ # Standard library imports
4
+ from typing import Hashable, Any
5
+
6
+ # Sentinel classes
7
+
8
+ class Unset():
9
+ '''A sentinel marking that an optional argument is not set, used where None is a valid argument value.'''
10
+ pass
11
+
12
+ class ThrowError():
13
+ '''A sentinel marking that an error should be thrown during the operation if a specified condition is met.'''
14
+ pass
15
+
16
+ # Type hints
17
+
18
+ # Specify an octal by writing 0o<number>
19
+ octal = int
20
+
21
+ # YAML Types indexable by Hashables
22
+ Indexable = dict[Hashable, Any] | list[Any]
23
+
24
+ # Type of a list of changed paths
25
+ ChangeList = list[tuple[Hashable, ...]]
26
+
27
+ # ((start_line, start_col), (end_line, end_col)) of a query match on a string
28
+ MatchLocation = tuple[tuple[int, int], tuple[int, int]]
29
+
30
+ # Vault parser and edit mode config
31
+
32
+ # The YAML parser cannot handle empty data, so we insert a fake root key before parsing and remove it before exporting
33
+ SENTINEL_KEY: str = '__parser_root_do_not_remove__'
34
+
35
+ # YAML tag to be applied to variables which should be encrypted in edit mode
36
+ ENCRYPTED_VAR_TAG: str = u'!enc'
37
+
38
+ # Header inserted at the top of a file being edited
39
+ # Will be searched for and removed on re-parsing
40
+ EDIT_MODE_HEADER: str = f"""
41
+ #~ DO NOT EDIT THIS HEADER
42
+ #~ Variables which should be encrypted are formatted like '{ ENCRYPTED_VAR_TAG } <value>'.
43
+ #~ Do not remove this prefix unless you want to convert them to plain variables.
44
+ #~ Add the prefix to any string variable you want to be encrypted.
45
+
46
+ """.lstrip('\n')
47
+
48
+ # Diff log filenames
49
+
50
+ # Default filename for a plaintext vault log
51
+ DEFAULT_PLAIN_LOGNAME: str = 'vault_changelog_plain.log'
ansible_vars/errors.py ADDED
@@ -0,0 +1,28 @@
1
+ # Custom exceptions for ansible-vars
2
+
3
+ # YAML parsing
4
+
5
+ class YAMLFormatError(Exception):
6
+ '''The supplied content is not a valid Ansible YAML file. Supports passing the triggering parent exception.'''
7
+
8
+ def __init__(self, *args: object, parent: Exception | None = None) -> None:
9
+ self.parent: Exception | None = parent
10
+ super().__init__(*args)
11
+
12
+ # VaultKey management
13
+
14
+ class KeyExistsError(KeyError):
15
+ '''The key you wish to create already exists, but overwriting it is disallowed.'''
16
+ pass
17
+
18
+ class NoVaultKeysError(Exception):
19
+ '''No vault keys are available for en-/decryption.'''
20
+ pass
21
+
22
+ class NoMatchingVaultKeyError(Exception):
23
+ '''No vault key matched the ciphertext or no vault key matched the queried ID.'''
24
+ pass
25
+
26
+ class VaultKeyMatchError(Exception):
27
+ '''Vault key did not match the ciphertext.'''
28
+ pass
ansible_vars/util.py ADDED
@@ -0,0 +1,387 @@
1
+ # Helpers for ansible-vars
2
+
3
+ # Standard library imports
4
+ import os, atexit
5
+ from glob import glob
6
+ from pathlib import Path
7
+ from functools import wraps
8
+ from datetime import datetime
9
+ from typing import Callable
10
+ from getpass import getuser as sys_user
11
+ from shutil import copytree, rmtree, copy2, move
12
+
13
+ # External library imports
14
+ from watchdog.observers import Observer
15
+ from watchdog.observers.api import BaseObserver
16
+ from watchdog.events import FileSystemEventHandler, \
17
+ DirCreatedEvent, DirDeletedEvent, DirMovedEvent, DirModifiedEvent, \
18
+ FileCreatedEvent, FileDeletedEvent, FileMovedEvent, FileModifiedEvent
19
+
20
+ # Internal module imports
21
+ from .vault import Vault, VaultFile
22
+ from .vault_crypt import VaultKey, VaultKeyring
23
+ from .constants import DEFAULT_PLAIN_LOGNAME
24
+
25
+ # Diff logging
26
+
27
+ class DiffLogger():
28
+ '''Generates log entries detailing how a vault changed over time.'''
29
+
30
+ def __init__(self) -> None:
31
+ '''Create a new `DiffLogger`.'''
32
+ pass
33
+
34
+ def make_log_entry(self, prev_vault: Vault, curr_vault: Vault, comment: str = '', force: bool = False) -> str | None:
35
+ '''
36
+ Formats a YAML-friendly log entry with data about the current and previous vault and the changes between them, then returns it.
37
+ If no changes happened, None is returned, unless you set `force` to True.
38
+ You can specify an optional comment string which will be included.
39
+ '''
40
+ # Check if any changes happened
41
+ diff: list[str] = curr_vault.diff(prev_vault, context_lines=0, show_filenames=True).split('\n')
42
+ if not force and not diff:
43
+ return None
44
+ # Build entry
45
+ OUTER_SEP: str = '=' * 48
46
+ lines: list[str] = [ OUTER_SEP ]
47
+ # Vault info
48
+ timestamp: datetime = datetime.now().astimezone()
49
+ timezone: str = str(timestamp.tzinfo or 'UTC')
50
+ lines += [ f"OLD VAULT { prev_vault }", f"NEW VAULT { curr_vault }", f"TIMESTAMP { timestamp } ({ timezone })", f"USER { sys_user() }" ]
51
+ # Comment
52
+ if comment:
53
+ lines += [ f"COMMENT {comment}" ]
54
+ lines += [ OUTER_SEP ]
55
+ # Diff
56
+ lines.append('DIFF')
57
+ if diff:
58
+ lines += diff
59
+ else:
60
+ lines.append('No changes.')
61
+ #lines.append(OUTER_SEP)
62
+ return '\n'.join([ f"#│ { line }" for line in lines ])
63
+
64
+ class DiffFileLogger(DiffLogger):
65
+ '''Generates log entries detailing how a vault changed over time and writes them to a vault-encrypted log file.'''
66
+
67
+ def __init__(self, log_path: str, key_or_keyring: VaultKey | VaultKeyring | None, plain: bool = False) -> None:
68
+ '''
69
+ Create a new `DiffLogger` that appends changes to a vault-encrypted file. The file is created if it does not exist.
70
+ The logfile is encrypted using the passed (keyring's) encryption key (note that the content is not in YAML syntax).
71
+ If a directory is passed instead of a file path, the filename will be inferred from the key's vault ID
72
+ using the `DiffFileLogger.generate_filename_from_key` method.
73
+
74
+ If `plain` is set to True, the log will be saved in an unencrypted form.
75
+ Do not mix encrypted and unencrypted logs in the same `log_path`.
76
+ If a filename is generated in plaintext mode, it is set to `constants.DEFAULT_PLAIN_LOGNAME`.
77
+ !!BEWARE!! Information may get leaked if encrypted vault changes are logged in plaintext. Only use this feature
78
+ for plaintext vars/vault files.
79
+ '''
80
+ self.log_path: str = os.path.abspath(log_path)
81
+ # Create keyring if necessary
82
+ if type(key_or_keyring) is VaultKey:
83
+ self.keyring: VaultKeyring = VaultKeyring([ key_or_keyring ], detect_available_keys=False)
84
+ elif type(key_or_keyring) is VaultKeyring:
85
+ self.keyring: VaultKeyring = key_or_keyring
86
+ else:
87
+ self.keyring = VaultKeyring([], detect_available_keys=False)
88
+ # Check if we should write plain data
89
+ self.plain_mode: bool = plain
90
+ # Create logfile if it doesn't exist yet
91
+ self._create_logfile()
92
+ super().__init__()
93
+
94
+ @staticmethod
95
+ def generate_filename_from_key(key_or_keyring: VaultKey | VaultKeyring) -> str:
96
+ '''Generates a logfile name based on the vault ID of the given (keyring's) encryption key.'''
97
+ # Create keyring if necessary
98
+ if type(key_or_keyring) is VaultKey:
99
+ keyring: VaultKeyring = VaultKeyring([ key_or_keyring ], detect_available_keys=False)
100
+ else:
101
+ keyring: VaultKeyring = key_or_keyring # type: ignore
102
+ # Generate default name
103
+ return f"vault_changelog_{ keyring.encryption_key.id }.vault"
104
+
105
+ def log_changes(self, vault: Vault, comment: str = '', force: bool = False, enable: bool = True) -> Callable:
106
+ '''
107
+ Decorator for methods that modify a vault object. Does nothing if `enable` is set to False.
108
+ Creates a log entry with data about the vault and the changes between before and after the call and appends it to the logfile.
109
+ If no changes happened, no entry is written, unless you set `force` to True.
110
+ You can specify an optional comment string which will be included.
111
+ Use the `add_log_entry` method directly if you'd like to compare two different `Vault` objects instead.
112
+ '''
113
+ def decorator(wrapped_function: Callable):
114
+ if not enable:
115
+ return wrapped_function
116
+ @wraps(wrapped_function)
117
+ def wrapper(*args, **kw_args):
118
+ prev_vault: Vault = vault.copy()
119
+ res = wrapped_function(*args, **kw_args)
120
+ self.add_log_entry(prev_vault, vault, comment=comment, force=force)
121
+ return res
122
+ return wrapper
123
+ return decorator
124
+
125
+ def add_log_entry(self, prev_vault: Vault, curr_vault: Vault, comment: str = '', force: bool = False) -> None:
126
+ '''
127
+ Creates a log entry with data about the current and previous vault and the changes between them and appends it to the logfile.
128
+ If no changes happened, no entry is written, unless you set `force` to True.
129
+ You can specify an optional comment string which will be included.
130
+ '''
131
+ entry: str | None = self.make_log_entry(prev_vault, curr_vault, comment=comment, force=force)
132
+ if entry:
133
+ with open(self.log_path, 'r+') as file:
134
+ # Get existing content
135
+ old_content: str = file.read()
136
+ if not self.plain_mode:
137
+ old_content = self.keyring.decrypt(old_content)
138
+ elif VaultKey.is_encrypted(old_content):
139
+ raise ValueError(f"File { self.log_path } is encrypted but we want to write plaintext data")
140
+ # Extend content with new entry
141
+ new_content: str = old_content + ('\n\n\n' * bool(old_content)) + entry
142
+ if not self.plain_mode:
143
+ new_content = self.keyring.encrypt(new_content)
144
+ # Overwrite file
145
+ file.seek(0)
146
+ file.truncate()
147
+ file.write(new_content)
148
+
149
+ def _create_logfile(self) -> None:
150
+ '''Creates the logfile if it does not exist yet. If the current `log_path` is a directory, a file is created in it.'''
151
+ if os.path.isdir(self.log_path):
152
+ filename: str = DEFAULT_PLAIN_LOGNAME if self.plain_mode else DiffFileLogger.generate_filename_from_key(self.keyring)
153
+ self.log_path = os.path.join(self.log_path, filename)
154
+ if os.path.isfile(self.log_path):
155
+ return
156
+ with open(self.log_path, 'w') as file:
157
+ file.write('' if self.plain_mode else self.keyring.encrypt(''))
158
+
159
+ # File daemon
160
+
161
+ class VaultDaemon(FileSystemEventHandler):
162
+ '''Tracks and mirrors (some) filesystem changes from a source file/directory to a target file/directory, decrypting vaults in the target(s).'''
163
+
164
+ def __init__(
165
+ self, source_path: str, target_path: str, keyring: VaultKeyring, recurse: bool = True,
166
+ error_callback: Callable = print, debug_out: Callable = print
167
+ ) -> None:
168
+ '''
169
+ Mirrors (some) filesystem changes from the source to the target, decrypting any encountered vaults in the target.
170
+ The source can either be a single file or a directory. The target must match this.
171
+ Directories are synced recursively when `recurse` is set to True.
172
+ If a source file can't be parsed as a vault, it is copied as-is.
173
+ A keyring is required to decrypt vaults (the keyring may be empty if no fully or partially encrypted vaults are expected).
174
+
175
+ Filesystem event errors are not raised directly, but passed to a callback function (`error_callback`) with the following parameters:
176
+ - `daemon`: This `VaultDaemon` instance
177
+ - `operation`: The performed operation (string, `create` / `delete` / `modify` / `move`)
178
+ - `error`: The caught exception
179
+ Other errors are raised as normal. This is done so possibly irrelevant filesystem errors (failed copy, ...) don't crash the whole daemon.
180
+ By default, the callback function prints the error.
181
+
182
+ Debug output is passed to a debug function (`debug_out`). By default, the function prints the message.
183
+
184
+ Mirrored changes are:
185
+ - Created files/directories in the source
186
+ - Deleted files/directories in the source
187
+ - Modified files in the source (only copies content changes, not metadata)
188
+ - Files/Directories which were moved within the source (moves outside of the scope are handled as creation/deletion)
189
+ '''
190
+ source_path = os.path.abspath(source_path)
191
+ target_path = os.path.abspath(target_path)
192
+ is_file: bool = os.path.isfile(source_path)
193
+ if is_file != os.path.isfile(target_path):
194
+ raise TypeError(f"Source and target must either both be a file or both be a directory: { self }")
195
+ # Prepare paths: We can only observe directories, so we specify a source/target file individually
196
+ self.source_dir: str = os.path.dirname(source_path) if is_file else source_path
197
+ self.target_dir: str = os.path.dirname(target_path) if is_file else target_path
198
+ self.source_file: str | None = source_path if is_file else None
199
+ self.target_file: str | None = target_path if is_file else None
200
+ # Other vars
201
+ self.keyring: VaultKeyring = keyring
202
+ self.error: Callable = lambda op, err: error_callback(self, op, err)
203
+ self.debug: Callable = lambda msg: debug_out(self, msg)
204
+ self.recurse: bool = recurse
205
+ # Schedule one observer for each sync direction
206
+ self.debug(f"Initializing { self }")
207
+ self.observer: BaseObserver = Observer()
208
+ self.observer.schedule(self, self.source_dir, recursive=recurse)
209
+
210
+ def start(self, stop_on_exit: bool = True) -> None:
211
+ '''
212
+ Starts the daemon. This copies the decrypted source file(s) to the target directory, overwriting any existing files.
213
+ The source(s) is/are then watched for changes, which are mirrored to the target.
214
+ When `stop_on_exit` is set to True, the daemon will automatically be stopped when the program exits.
215
+ This function creates a new thread, which runs parallel to the main thread. Use `<this>.observer.join()` to run the thread as blocking.
216
+ '''
217
+ self.debug(f"Starting { self }")
218
+ self.debug(f"Creating target and copying file(s)")
219
+ # Create copy of source in target
220
+ os.makedirs(self.target_dir, exist_ok=True)
221
+ # Only sync one file
222
+ if self.source_file:
223
+ copy2(self.source_file, self.target_file, follow_symlinks=True) # type: ignore
224
+ # Sync dir recursively
225
+ elif self.recurse:
226
+ copytree(self.source_dir, self.target_dir, symlinks=True, dirs_exist_ok=True, ignore_dangling_symlinks=True)
227
+ # Sync flat dir
228
+ else:
229
+ def _ignore_subdirs(parent: str, children: list[str]) -> set:
230
+ return { child for child in children if os.path.isdir(os.path.join(parent, child)) }
231
+ copytree(self.source_dir, self.target_dir, ignore=_ignore_subdirs, symlinks=True, dirs_exist_ok=True, ignore_dangling_symlinks=True)
232
+ # Find and decrypt our copied vault files in target
233
+ def _decrypt_inplace(path: str) -> None:
234
+ '''Decrypts a file in-place if it is a vault.'''
235
+ try:
236
+ vault = VaultFile(path, keyring=self.keyring)
237
+ self.debug(f"Detected vault { os.path.join(self.source_dir, os.path.relpath(path, self.target_dir)) }")
238
+ with open(path, 'w') as file:
239
+ file.write(vault.as_editable(with_header=False))
240
+ except: pass
241
+ if self.target_file:
242
+ _decrypt_inplace(self.target_file)
243
+ else:
244
+ paths: list[str] = glob(os.path.join(self.target_dir, '**' * self.recurse, '*'), recursive=self.recurse, include_hidden=True)
245
+ [ _decrypt_inplace(file) for file in paths if os.path.isfile(file) ]
246
+ # Observe changes in source
247
+ if stop_on_exit:
248
+ atexit.register(self.stop)
249
+ self.debug(f"Watching for changes in { self.source_dir }")
250
+ self.observer.start()
251
+
252
+ def stop(self, delete: bool = True) -> None:
253
+ '''Stops the daemon. If `delete` is set to True, the target file(s) will be deleted.'''
254
+ self.debug(f"Stopping { self }")
255
+ self.observer.stop()
256
+ if delete:
257
+ if self.target_file:
258
+ os.unlink(self.target_file)
259
+ else:
260
+ rmtree(self.target_dir, ignore_errors=True)
261
+
262
+ def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
263
+ '''
264
+ Filesystem hook for a created source path.
265
+ Copies the file/directory to the corresponding target path, in decrypted form if it is a vault-like file.
266
+ '''
267
+ self.debug(f"Captured event: { event }")
268
+ try:
269
+ source_path: str = event.src_path.decode('utf-8') if type(event.src_path) is bytes else event.src_path # type: ignore
270
+ target_path: str = os.path.join(self.target_dir, os.path.relpath(source_path, self.source_dir))
271
+ # Ignore parent modifications if we're watching a single file
272
+ if self.source_file and source_path != self.source_file:
273
+ self.debug('Event ignored (only watching one file, parent updates are irrelevant)')
274
+ return
275
+ # Create directory
276
+ if type(event) is DirCreatedEvent:
277
+ os.makedirs(target_path, mode=0o700, exist_ok=True)
278
+ self.debug(f"Created directory { target_path }")
279
+ # Create decrypted copy of file
280
+ else:
281
+ with open(source_path) as src:
282
+ content: str = src.read()
283
+ with open(target_path, 'w') as tgt:
284
+ try:
285
+ tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
286
+ self.debug(f"Created/Updated file { target_path } from vault contents")
287
+ except:
288
+ tgt.write(content)
289
+ self.debug(f"Created/Updated file { target_path } from plain contents")
290
+ except Exception as e:
291
+ self.error('create', e)
292
+
293
+ def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent) -> None:
294
+ '''Filesystem hook for a deleted source path. Deletes the corresponding target path.'''
295
+ self.debug(f"Captured event: { event }")
296
+ try:
297
+ source_path: str = event.src_path.decode('utf-8') if type(event.src_path) is bytes else event.src_path # type: ignore
298
+ target_path: str = os.path.join(self.target_dir, os.path.relpath(source_path, self.source_dir))
299
+ # Ignore parent modifications if we're watching a single file
300
+ if self.source_file and source_path != self.source_file:
301
+ self.debug('Event ignored (only watching one file, parent updates are irrelevant)')
302
+ return
303
+ # Delete file or directory
304
+ if type(event) is FileDeletedEvent:
305
+ os.unlink(target_path)
306
+ self.debug(f"Deleted file { target_path }")
307
+ else:
308
+ rmtree(target_path, ignore_errors=True)
309
+ self.debug(f"Deleted directory { target_path }")
310
+ except Exception as e:
311
+ self.error('delete', e)
312
+
313
+ def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
314
+ '''
315
+ Filesystem hook for a moved source path. Moves the corresponding target path by the same relative path delta.
316
+ This is only called for a path moved within the observed source directory.
317
+ For out-of-scope moves, `on_created` or `on_deleted` is called.
318
+ '''
319
+ self.debug(f"Captured event: { event }")
320
+ try:
321
+ source_path: str = event.src_path.decode('utf-8') if type(event.src_path) is bytes else event.src_path # type: ignore
322
+ new_source_path: str = event.dest_path.decode('utf-8') if type(event.dest_path) is bytes else event.dest_path # type: ignore
323
+ target_path: str = os.path.join(self.target_dir, os.path.relpath(source_path, self.source_dir))
324
+ new_target_path: str = os.path.join(self.target_dir, os.path.relpath(new_source_path, self.source_dir))
325
+ # Ignore parent modifications if we're watching a single file
326
+ if self.source_file and self.source_file not in ( source_path, new_source_path ):
327
+ self.debug('Event ignored (only watching one file, parent updates are irrelevant)')
328
+ return
329
+ # When watching a single file, the only relevant event is the file being moved to/away, which counts as creation/deletion
330
+ if self.source_file and self.target_file: # testing both even though we know they're both (un)set to make type checker happy
331
+ self.debug(f"Event is move from/to outside of sync scope, treating as creation/deletion")
332
+ if self.source_file == source_path:
333
+ os.unlink(self.target_file)
334
+ self.debug(f"Deleted file { self.target_file }")
335
+ else:
336
+ # Create decrypted copy of file
337
+ with open(self.source_file) as src:
338
+ content: str = src.read()
339
+ with open(self.target_file, 'w') as tgt:
340
+ try:
341
+ tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
342
+ self.debug(f"Created/Updated file { self.target_file } from vault contents")
343
+ except:
344
+ tgt.write(content)
345
+ self.debug(f"Created/Updated file { self.target_file } from plain contents")
346
+ # Move file or directory
347
+ else:
348
+ move(target_path, new_target_path)
349
+ self.debug(f"Moved { target_path } to { new_target_path }")
350
+ except Exception as e:
351
+ self.error('move', e)
352
+
353
+ def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
354
+ '''
355
+ Filesystem hook for a modified source path.
356
+ For files, the modified source content is copied to the corresponding target file, in decrypted form if it is a vault-like file.
357
+ Directory modification events are ignored, as those are usually reundantly emitted for the parent directory of the actual filesystem event.
358
+ '''
359
+ self.debug(f"Captured event: { event }")
360
+ try:
361
+ source_path: str = event.src_path.decode('utf-8') if type(event.src_path) is bytes else event.src_path # type: ignore
362
+ target_path: str = os.path.join(self.target_dir, os.path.relpath(source_path, self.source_dir))
363
+ # Ignore parent modifications if we're watching a single file
364
+ if self.source_file and source_path != self.source_file:
365
+ self.debug('Event ignored (only watching one file, parent updates are irrelevant)')
366
+ return
367
+ # Ignore modified directories (seems to mean that a file in the directory was modified, we get an individual event for that)
368
+ if type(event) is DirModifiedEvent:
369
+ self.debug('Event ignored (already processed related event)')
370
+ return
371
+ # Create decrypted copy of file
372
+ with open(source_path) as src:
373
+ content: str = src.read()
374
+ with open(target_path, 'w') as tgt:
375
+ try:
376
+ tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
377
+ self.debug(f"Created/Updated file { target_path } from vault contents")
378
+ except:
379
+ tgt.write(content)
380
+ self.debug(f"Created/Updated file { target_path } from plain contents")
381
+ except Exception as e:
382
+ self.error('modify', e)
383
+
384
+ def __repr__(self) -> str:
385
+ src: str = self.source_file if self.source_file else self.source_dir
386
+ tgt: str = self.target_file if self.target_file else self.target_dir
387
+ return f"VaultDaemon({ src } -> { tgt })"