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.
- ansible_vars/__init__.py +0 -0
- ansible_vars/cli.py +983 -0
- ansible_vars/constants.py +51 -0
- ansible_vars/errors.py +28 -0
- ansible_vars/util.py +387 -0
- ansible_vars/vault.py +830 -0
- ansible_vars/vault_crypt.py +181 -0
- ansible_vars-1.0.0.dist-info/METADATA +254 -0
- ansible_vars-1.0.0.dist-info/RECORD +12 -0
- ansible_vars-1.0.0.dist-info/WHEEL +4 -0
- ansible_vars-1.0.0.dist-info/entry_points.txt +2 -0
- ansible_vars-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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 })"
|