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
ansible_vars/vault.py
ADDED
@@ -0,0 +1,830 @@
|
|
1
|
+
# Vault(file) parsing and management for ansible-vars
|
2
|
+
|
3
|
+
# Standard library imports
|
4
|
+
import os, re, json
|
5
|
+
from io import StringIO
|
6
|
+
from functools import reduce
|
7
|
+
from typing import Type, Hashable, Callable, Any
|
8
|
+
from types import MappingProxyType
|
9
|
+
from collections import OrderedDict
|
10
|
+
from difflib import unified_diff
|
11
|
+
|
12
|
+
# External library imports
|
13
|
+
from ruamel.yaml import YAML
|
14
|
+
from ruamel.yaml.nodes import ScalarNode
|
15
|
+
from ruamel.yaml.parser import CommentToken
|
16
|
+
from ruamel.yaml.representer import Representer
|
17
|
+
from ruamel.yaml.constructor import Constructor
|
18
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
19
|
+
|
20
|
+
# Internal module imports
|
21
|
+
from .constants import ThrowError, octal, Indexable, ChangeList, MatchLocation, SENTINEL_KEY, EDIT_MODE_HEADER, ENCRYPTED_VAR_TAG
|
22
|
+
from .vault_crypt import VaultKey, VaultKeyring
|
23
|
+
from .errors import KeyExistsError, NoVaultKeysError, YAMLFormatError
|
24
|
+
|
25
|
+
class EncryptedVar():
|
26
|
+
'''
|
27
|
+
Represents a single encrypted vault variable, initialized with the encrypted content.
|
28
|
+
The content should be a str, as Ansible does not directly support other data types in encrypted variables.
|
29
|
+
As this class has no `VaultKeyring` access, decryption must be performed externally.
|
30
|
+
Note that comparing `EncryptedVar` objects by their `cipher`s usually does not work, as Ansible ciphers contain a random salt.
|
31
|
+
'''
|
32
|
+
|
33
|
+
def __init__(self, cipher: str, name: str | None = None) -> None:
|
34
|
+
'''Initialize an encrypted variable with an optional variable name. The name is only used for internal representation.'''
|
35
|
+
# Encrypted has to hold a string like '$ANSIBLE_VAULT;1.2;AES256;ramiio\n123456<...>' (the newline is important)
|
36
|
+
self.cipher: str = cipher
|
37
|
+
self.name: str | None = name
|
38
|
+
|
39
|
+
def __repr__(self) -> str:
|
40
|
+
return f"EncryptedVar({ self.name or 'unnamed' })"
|
41
|
+
|
42
|
+
# ruamel.yaml dumper/loader converters
|
43
|
+
|
44
|
+
yaml_tag: str = u'!vault'
|
45
|
+
|
46
|
+
@classmethod
|
47
|
+
def to_yaml(EncryptedVar: Type['EncryptedVar'], representer: Representer, var: 'EncryptedVar') -> Any:
|
48
|
+
#return representer.represent_str(var.cipher)
|
49
|
+
return representer.represent_scalar(u'!vault', var.cipher, style='|')
|
50
|
+
|
51
|
+
@classmethod
|
52
|
+
def from_yaml(EncryptedVar: Type['EncryptedVar'], constructor: Constructor, node: ScalarNode) -> 'EncryptedVar':
|
53
|
+
cipher: Any = constructor.construct_scalar(node)
|
54
|
+
if not isinstance(cipher, str):
|
55
|
+
raise TypeError(f"Expected encrypted value to be a str, but got { type(cipher) }")
|
56
|
+
return EncryptedVar(str(cipher), name=node.id)
|
57
|
+
|
58
|
+
class ProtoEncryptedVar():
|
59
|
+
'''A variable marked to be encrypted in a `Vault` editable.'''
|
60
|
+
|
61
|
+
def __init__(self, plaintext: str, name: str) -> None:
|
62
|
+
'''Initialize a plaintext value marked for encryption with a name for internal representation.'''
|
63
|
+
self.plaintext: str = plaintext
|
64
|
+
self.name: str = name
|
65
|
+
|
66
|
+
def __eq__(self, __o: object) -> bool:
|
67
|
+
if type(__o) is not ProtoEncryptedVar:
|
68
|
+
return False
|
69
|
+
return self.plaintext == __o.plaintext
|
70
|
+
|
71
|
+
def __repr__(self) -> str:
|
72
|
+
return f"ProtoEncryptedVar({ self.name })"
|
73
|
+
|
74
|
+
# ruamel.yaml dumper/loader converters
|
75
|
+
|
76
|
+
yaml_tag: str = ENCRYPTED_VAR_TAG
|
77
|
+
|
78
|
+
@classmethod
|
79
|
+
def to_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], representer: Representer, var: 'ProtoEncryptedVar') -> Any:
|
80
|
+
return representer.represent_scalar(ENCRYPTED_VAR_TAG, var.plaintext, style='\'')
|
81
|
+
|
82
|
+
@classmethod
|
83
|
+
def from_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], constructor: Constructor, node: ScalarNode) -> 'ProtoEncryptedVar':
|
84
|
+
plaintext: Any = constructor.construct_scalar(node)
|
85
|
+
if not isinstance(plaintext, str):
|
86
|
+
raise TypeError(f"Expected decrypted value to be a str, but got { type(plaintext) }")
|
87
|
+
return ProtoEncryptedVar(str(plaintext).rstrip('\n'), name=node.id)
|
88
|
+
|
89
|
+
class Vault():
|
90
|
+
'''
|
91
|
+
Represents an Ansible vault's contents, with plain and encrypted variables and potentially full vault encryption on top.
|
92
|
+
Since full vault encryption is detected from the specified yaml content, do not externally decrypt the vault contents first
|
93
|
+
unless you wish to lose full file encryption, or set `Vault.full_encryption` manually after initialization.
|
94
|
+
To create a fresh `Vault`, you may also use `Vault.create` and set your desired encryption settings directly.
|
95
|
+
To load from and save to a vault file directly, use the `VaultFile` wrapper of this module.
|
96
|
+
|
97
|
+
Note that variable values containing Jinja2 code (e.g. `my_var: "{{ other_var }}"`) are stored in an escaped form by ruamel.yaml.jinja2.
|
98
|
+
You should either ignore these values or take care to understand the escape syntax of the plugin before editing them or adding any.
|
99
|
+
They can be freely overwritten with non-Jinja2 values and will lose their special status.
|
100
|
+
'''
|
101
|
+
|
102
|
+
# Initialization/Loading
|
103
|
+
|
104
|
+
def __init__(self, yaml_content: str, keyring: VaultKeyring | None = None) -> None:
|
105
|
+
'''
|
106
|
+
Parses a vault's (potentially encrypted) contents. Automatically detects if the content is wholly encrypted.
|
107
|
+
If no keyring is supplied, only plain vars and content are supported.
|
108
|
+
'''
|
109
|
+
# If no keyring is supplied, create an empty one which will raise an Error if we try to en-/decrypt anything
|
110
|
+
self.keyring: VaultKeyring = keyring or VaultKeyring(keys=None, detect_available_keys=False)
|
111
|
+
# Full vault encryption, may also contain single encrypted variables either way
|
112
|
+
self.full_encryption: bool
|
113
|
+
# Internal variable tree
|
114
|
+
# Contains a sentinel key (constants.SENTINEL_KEY)
|
115
|
+
self._data: CommentedMap
|
116
|
+
# YAML parser, contains YAML metadata needed for exporting the data
|
117
|
+
self._parser: YAML
|
118
|
+
# Parse the given Jinja2 yaml data and set the relevant variables
|
119
|
+
self._parser, self._data, self.full_encryption = self._parse(yaml_content, self.keyring)
|
120
|
+
|
121
|
+
@classmethod
|
122
|
+
def create(Vault: Type['Vault'], content: str = '', full_encryption: bool = True, keyring: VaultKeyring | None = None) -> 'Vault':
|
123
|
+
'''
|
124
|
+
Creates a new vault with desired encryption settings.
|
125
|
+
If `content` is set, the vault will be loaded with this Jinja2 yaml text instead of an empty string.
|
126
|
+
If `full_encryption` is set to True, the vault will be wholly encrypted in addition to any encrypted variables.
|
127
|
+
If no keyring is supplied, only plain vars are supported and enabling `full_encryption` will not work.
|
128
|
+
'''
|
129
|
+
vault = Vault(content, keyring=keyring)
|
130
|
+
vault.full_encryption = full_encryption
|
131
|
+
return vault
|
132
|
+
|
133
|
+
@classmethod
|
134
|
+
def from_editable(Vault: Type['Vault'], prev_vault: 'Vault', edited_content: str) -> 'Vault':
|
135
|
+
'''Converts a YAML vault edited from a `Vault.as_editable` template into a new `Vault`.'''
|
136
|
+
# Remove static header (try removing without trailing newline too just in case)
|
137
|
+
edited_content = edited_content.replace(EDIT_MODE_HEADER, '', 1)
|
138
|
+
edited_content = edited_content.replace(EDIT_MODE_HEADER.strip('\n'), '', 1)
|
139
|
+
# Init new vault with edited YAML
|
140
|
+
# ProtoEncryptedVar conversion is done in vault parser, so no need to do it here
|
141
|
+
vault = Vault(edited_content, keyring=prev_vault.keyring)
|
142
|
+
# Copy relevant settings from old vault
|
143
|
+
vault.full_encryption = prev_vault.full_encryption
|
144
|
+
return vault
|
145
|
+
|
146
|
+
@staticmethod
|
147
|
+
def _parse(yaml_content: str, keyring: VaultKeyring) -> tuple[YAML, CommentedMap, bool]:
|
148
|
+
'''
|
149
|
+
Parses the given Jinja2 yaml string into a CommentedMap.
|
150
|
+
Returns the YAML parser holding metadata, the loaded mapping and a flag signifying the string was fully encrypted.
|
151
|
+
'''
|
152
|
+
# Create a special Jinja2 YAML round-trip parser holding the metadata for Jinja2 blocks, comments and (most) formatting
|
153
|
+
parser = YAML(typ='jinja2')
|
154
|
+
parser.allow_unicode = True
|
155
|
+
parser.preserve_quotes = True
|
156
|
+
parser.allow_duplicate_keys = True
|
157
|
+
# mapping = dict offset, sequence = list offset within item, offset = list offset before dash
|
158
|
+
parser.indent(sequence=2, mapping=2, offset=0)
|
159
|
+
# Automatic loading and dumping of EncryptedVars
|
160
|
+
parser.register_class(EncryptedVar)
|
161
|
+
# Automcatic loading and dumping of encryption marks
|
162
|
+
parser.register_class(ProtoEncryptedVar)
|
163
|
+
# Decrypt file if wholly encrypted
|
164
|
+
if (full_encryption := VaultKey.is_encrypted(yaml_content)):
|
165
|
+
yaml_content = keyring.decrypt(yaml_content)
|
166
|
+
# Insert a dummy key because our parser can't save the comments of a file without any keys
|
167
|
+
yaml_content = Vault._insert_sentinel(yaml_content)
|
168
|
+
# Load YAML as CommentedMap
|
169
|
+
try:
|
170
|
+
data: CommentedMap = parser.load(yaml_content)
|
171
|
+
except Exception as e:
|
172
|
+
raise YAMLFormatError('Provided content is not valid Ansible YAML code', parent=e)
|
173
|
+
# Convert ProtoEncryptedVars to EncryptedVars
|
174
|
+
# This is done in parsing instead of in `Vault.from_editable` in case the user creates a vault with protovars
|
175
|
+
def _convert_from_proto(_: tuple[Hashable, ...], value: Any) -> Any:
|
176
|
+
'''Encrypts leaves marked for encryption.'''
|
177
|
+
if type(value) is not ProtoEncryptedVar:
|
178
|
+
return value
|
179
|
+
return EncryptedVar(keyring.encrypt(value.plaintext), name=value.name)
|
180
|
+
Vault._transform_leaves(data, _convert_from_proto, tuple())
|
181
|
+
return parser, data, full_encryption
|
182
|
+
|
183
|
+
@staticmethod
|
184
|
+
def _insert_sentinel(yaml_content: str) -> str:
|
185
|
+
'''Inserts a special key into the given raw YAML string, so the resulting data cannot be empty. Returns the modified YAML.'''
|
186
|
+
lines: list[str] = yaml_content.split('\n')
|
187
|
+
# Insert our sentinel key right before the explicit end line (`...`)
|
188
|
+
for index, line in reversed(list(enumerate(lines))): # look for last occurance
|
189
|
+
if line.strip() == '...':
|
190
|
+
lines.insert(index, f"{ SENTINEL_KEY }:")
|
191
|
+
break
|
192
|
+
# If there is no explicit end, just append it to the text
|
193
|
+
else:
|
194
|
+
lines.append(f"{ SENTINEL_KEY }:")
|
195
|
+
return '\n'.join(lines)
|
196
|
+
|
197
|
+
# Dictionary operations
|
198
|
+
|
199
|
+
# A single dict key or a chain of nested dict keys
|
200
|
+
DictPath = Hashable | tuple[Hashable, ...]
|
201
|
+
|
202
|
+
@property
|
203
|
+
def decrypted_vars(self) -> MappingProxyType:
|
204
|
+
'''
|
205
|
+
A read-only dictionary of the vault's variables, with any EncryptedVars already decrypted.
|
206
|
+
Note that the state is frozen whenever you access this property, and not updated when the vault changes.
|
207
|
+
'''
|
208
|
+
copy: dict = self._data.copy()
|
209
|
+
copy.pop(SENTINEL_KEY, None)
|
210
|
+
def _traverse_and_decrypt(root: Any) -> Any:
|
211
|
+
if isinstance(root, dict):
|
212
|
+
return { k: _traverse_and_decrypt(v) for k, v in root.items() }
|
213
|
+
elif isinstance(root, list):
|
214
|
+
return [ _traverse_and_decrypt(v) for v in root ]
|
215
|
+
else:
|
216
|
+
return self.keyring.decrypt(root.cipher) if type(root) is EncryptedVar else root
|
217
|
+
return MappingProxyType(_traverse_and_decrypt(copy))
|
218
|
+
|
219
|
+
def has(self, path: DictPath) -> bool:
|
220
|
+
'''Checks if the given key path is present in the vault's data.'''
|
221
|
+
try: self._traverse(path, decrypt=False)
|
222
|
+
except KeyError: return False
|
223
|
+
return True
|
224
|
+
|
225
|
+
def get(
|
226
|
+
self, path: DictPath, default: Any | Type[ThrowError] = ThrowError,
|
227
|
+
decrypt: bool = False, with_index: bool = False
|
228
|
+
) -> Any | tuple[int, Any]:
|
229
|
+
'''
|
230
|
+
Retrieves a value from the vault's variables by its key path, optionally decrypting it (but not its children).
|
231
|
+
When `default` is set to `ThrowError`, a `KeyError` will be raised if the path does not exist.
|
232
|
+
Else, the default value is returned if the path does not exist.
|
233
|
+
When `with_index` is set to True, a tuple `(index_in_parent, value)` is returned (-1 if defaulted).
|
234
|
+
'''
|
235
|
+
path = Vault._to_path(path)
|
236
|
+
try:
|
237
|
+
value: Any = self._traverse(path, decrypt=decrypt)
|
238
|
+
if with_index:
|
239
|
+
parent: Indexable = self._traverse(path[:-1], decrypt=False)
|
240
|
+
if isinstance(parent, dict):
|
241
|
+
key_index: int = next(index for index, key in enumerate(parent) if key == path[-1])
|
242
|
+
else:
|
243
|
+
key_index: int = path[-1] # type: ignore
|
244
|
+
return (key_index, value)
|
245
|
+
return value
|
246
|
+
except:
|
247
|
+
if default is ThrowError:
|
248
|
+
raise KeyError(f"Key path { '.'.join(map(str, path)) } could not be found in { self }")
|
249
|
+
return (-1, default) if with_index else default
|
250
|
+
|
251
|
+
def set(
|
252
|
+
self, path: DictPath, value: Any, overwrite: bool | Type[ThrowError] = True,
|
253
|
+
create_parents: bool | Type[ThrowError] = True, encrypt: bool = False
|
254
|
+
) -> bool:
|
255
|
+
'''
|
256
|
+
Creates or updates a value in the vault's variables. The value has to be serializable into YAML.
|
257
|
+
If the last/only key of the key path does not exist yet, it will be created.
|
258
|
+
If the variable is set successfully, `True` is returned. If any of the below checks fail, `False` is returned.
|
259
|
+
Be aware that appending a new entry to a list requires the key to be equal to the length of the list (i.e. largest index + 1).
|
260
|
+
On updated leaf values, comment and Jinja2 metadata is preserved.
|
261
|
+
When appending a new value or editing an indexable item, metadata may get messed up.
|
262
|
+
|
263
|
+
Options:
|
264
|
+
- `overwrite`: Controls what happens if a value is already present at the key path
|
265
|
+
- `True`: Replace the existing value
|
266
|
+
- `False`: Abort silently, returning False
|
267
|
+
- `ThrowError`: Raise a KeyError if the key path already exists
|
268
|
+
- `create_parents`: Controls if the entire key path should be created if it doesn't exist yet
|
269
|
+
- `True`: Create any nested dictionaries needed to traverse to the last key
|
270
|
+
- `False`: Abort silently if any but the last key in the path do not exist, returning False
|
271
|
+
- `ThrowError`: Raise a KeyError if any but the last key in the path do not exist
|
272
|
+
- `encrypt`: Controls if the value should be encrypted before storing it (value has to be a str)
|
273
|
+
- `True`: Attempt to convert the value into an `EncryptedVar` before storing it
|
274
|
+
- `False`: Store the value as-is
|
275
|
+
'''
|
276
|
+
path = Vault._to_path(path)
|
277
|
+
# Convert value to EncryptedVar if neccessary
|
278
|
+
if encrypt and type(value) is not EncryptedVar:
|
279
|
+
if type(value) is not str:
|
280
|
+
raise TypeError(f"Ansible only supports encrypted str values, got { type(value) } for { '.'.join(map(str, path)) }")
|
281
|
+
if VaultKey.is_encrypted(value):
|
282
|
+
value = EncryptedVar(value, name=str(path[-1]))
|
283
|
+
else:
|
284
|
+
value = EncryptedVar(self.keyring.encrypt(value), name=str(path[-1]))
|
285
|
+
# Resolve chain and create parents if necessary, then set value for last item
|
286
|
+
parent: Any = self._data
|
287
|
+
par_path: str = ''
|
288
|
+
for _index, segment in enumerate(path):
|
289
|
+
is_last: bool = (_index + 1) == len(path)
|
290
|
+
# Check if parent is indexable
|
291
|
+
if not isinstance(parent, dict | list):
|
292
|
+
raise TypeError(f"Indexing into a { type(parent) } is not supported ({ par_path })")
|
293
|
+
# Check if index is of correct type
|
294
|
+
if isinstance(parent, list) and type(segment) is not int:
|
295
|
+
raise TypeError(f"Type of list index has to be int, got { type(segment) } ({ par_path }[{ segment }])")
|
296
|
+
# Check if the current segment has to be created in the parent
|
297
|
+
if (isinstance(parent, dict) and segment not in parent) or (isinstance(parent, list) and segment >= len(parent)): # type: ignore
|
298
|
+
if not is_last:
|
299
|
+
if create_parents is ThrowError:
|
300
|
+
raise KeyError(f"Parents of { '.'.join(map(str, path)) } could not be resolved ({ segment } not in { par_path })")
|
301
|
+
if not create_parents:
|
302
|
+
return False
|
303
|
+
# Create nested dictionary as next parent or set value of leaf, depending on index
|
304
|
+
segment_value: Any = value if is_last else CommentedMap()
|
305
|
+
# Check that a new list item has a specified index of (largest list index + 1)
|
306
|
+
# This is done because the method of creating an index of e.g. 7 in a list of length 3 is ambiguous
|
307
|
+
if isinstance(parent, list) and segment != len(parent):
|
308
|
+
raise IndexError(f"Creating new list item, but index { segment } exceeds appendment index { len(parent) } ({ par_path })")
|
309
|
+
# Set value
|
310
|
+
if isinstance(parent, dict):
|
311
|
+
parent[segment] = segment_value # type: ignore
|
312
|
+
else:
|
313
|
+
parent.append(segment_value)
|
314
|
+
# Else, replace the existing value if we are at the end of the path
|
315
|
+
elif is_last:
|
316
|
+
if overwrite is ThrowError:
|
317
|
+
raise KeyExistsError(f"Key { segment } already exists in { par_path }")
|
318
|
+
if not overwrite:
|
319
|
+
return False
|
320
|
+
parent[segment] = value # type: ignore
|
321
|
+
# Advance parent
|
322
|
+
parent = parent[segment] # type: ignore
|
323
|
+
par_path = f"{ par_path }.{ segment }"
|
324
|
+
return True
|
325
|
+
|
326
|
+
def pop(
|
327
|
+
self, path: DictPath, default: Any | Type[ThrowError] = ThrowError,
|
328
|
+
decrypt: bool = False, with_index: bool = False
|
329
|
+
) -> Any | tuple[int, Any]:
|
330
|
+
'''
|
331
|
+
Pops (i.e. removes and returns) a value from the vault's variables, optionally decrypting it.
|
332
|
+
Attempts to preserve metadata like comments and Jinja2 blocks.
|
333
|
+
When `default` is set to `ThrowError`, a `KeyError` will be raised if the path does not exist.
|
334
|
+
Else, the default value is returned if the path does not exist.
|
335
|
+
When `with_index` is set to True, a tuple `(index_in_parent, value)` is returned (-1 if defaulted).
|
336
|
+
Be aware that this method is experimental and may mess up comment and Jinja2 metadata.
|
337
|
+
'''
|
338
|
+
path = Vault._to_path(path)
|
339
|
+
index, value = self.get(path, default=default, decrypt=decrypt, with_index=True)
|
340
|
+
# If we did not receive the default value back, we have to delete the key and move its metadata
|
341
|
+
if index > -1:
|
342
|
+
parent: Indexable = self._traverse(path[:-1], decrypt=False)
|
343
|
+
# XXX Metadata manipulation is broken
|
344
|
+
"""
|
345
|
+
# XXX
|
346
|
+
# When a key is deleted, all following comments (not just EOL) up to the next var will be deleted.
|
347
|
+
# We need to move these comment tokens from key_parent.ca.items[key] to the previous item.
|
348
|
+
# If there is not previous item, we need to merge them into key_parent.ca.comment.
|
349
|
+
# XXX Problem we have not adressed yet:
|
350
|
+
# When deleting a list or dict, the following comments/Jinja2 are actually part of the deepest last element's metadata,
|
351
|
+
# not the root elem
|
352
|
+
if isinstance(value, dict | list):
|
353
|
+
def _find_last_child(_path: tuple[Hashable, ...]) -> tuple[Hashable, ...]:
|
354
|
+
'''Finds the child of an indexable that is printed last.'''
|
355
|
+
_value: Any = self._traverse(_path, decrypt=False)
|
356
|
+
if isinstance(_value, dict) and _value:
|
357
|
+
return _find_last_child(_path + ( list(_value.keys())[-1], ))
|
358
|
+
if isinstance(_value, list) and _value:
|
359
|
+
return _find_last_child(_path + ( len(_value) - 1, ))
|
360
|
+
return _path
|
361
|
+
_last_path: tuple[Hashable, ...] = _find_last_child(path)
|
362
|
+
_last_parent: Indexable = self._traverse(_last_path[:-1], decrypt=False)
|
363
|
+
else:
|
364
|
+
_last_path = path
|
365
|
+
_last_parent = parent
|
366
|
+
# First, we need to check if there is a previous key we can move the metadata to
|
367
|
+
if index > 0:
|
368
|
+
# If the parent can hold metadata, we can assume it is ordered (CommentedMap | CommentedSeq)
|
369
|
+
prev_key = (list(parent.keys())[index - 1]) if isinstance(parent, dict) else (parent[index - 1])
|
370
|
+
self._move_metadata(_last_path, path[:-1] + ( prev_key, ), merge=True)
|
371
|
+
# If there is no previous key and the key holds metadata, merge the metadata with the parent's base comment
|
372
|
+
elif isinstance(_last_parent, CommentedMap | CommentedSeq) and path[-1] in _last_parent.ca.items:
|
373
|
+
# Anchor #2 holds EOL comments, anchor #3 holds next-line comments (usually merged into #2 if both exist)
|
374
|
+
# We can ignore #0 and #1 (I think)
|
375
|
+
if _last_parent.ca.items[_last_path[-1]][2] or _last_parent.ca.items[_last_path[-1]][3]:
|
376
|
+
new_metadata: list = (_last_parent.ca.items[_last_path[-1]][2] or []) + (_last_parent.ca.items[_last_path[-1]][3] or [])
|
377
|
+
parent.ca.comment = [ parent.ca.comment[0], (parent.ca.comment[1] or []) + new_metadata ]
|
378
|
+
"""
|
379
|
+
# Remove value
|
380
|
+
del parent[path[-1]] # type: ignore
|
381
|
+
return (index, value) if with_index else value
|
382
|
+
|
383
|
+
# XXX Deprecated due to parser issues
|
384
|
+
"""
|
385
|
+
def rename_key(self, path: DictPath, rename_to: Hashable, overwrite: bool | Type[ThrowError] = ThrowError) -> bool:
|
386
|
+
'''
|
387
|
+
Renames a key, preserving its position and metadata. Returns True on success. Only works for dict-like parents.
|
388
|
+
|
389
|
+
`overwrite` controls what happens if a value is already present at the new key path:
|
390
|
+
- `True`: Replace the existing key's value
|
391
|
+
- `False`: Abort silently, returning False
|
392
|
+
- `ThrowError`: Raise a KeyError if the new key path already exists
|
393
|
+
'''
|
394
|
+
path = Vault._to_path(path)
|
395
|
+
parent: CommentedMap = self._traverse(path[:-1], decrypt=False)
|
396
|
+
if not isinstance(parent, dict):
|
397
|
+
raise TypeError('Can only rename keys in dict-like structures.')
|
398
|
+
# Check if the old key exists
|
399
|
+
if path[-1] not in parent:
|
400
|
+
raise KeyError(f"Key path { '.'.join(map(str, path)) } could not be found in { self }")
|
401
|
+
# Check if the new key already exists
|
402
|
+
if rename_to in parent:
|
403
|
+
if overwrite is False:
|
404
|
+
return False
|
405
|
+
if overwrite is ThrowError:
|
406
|
+
raise KeyError(f"Rename target { '.'.join(map(str, path[:-1] + ( rename_to, ))) } already exists")
|
407
|
+
# Find index of old key
|
408
|
+
key_index: int = next(index for index, key in enumerate(parent) if key == path[-1])
|
409
|
+
# Rename key and move metadata
|
410
|
+
self._move_metadata(path, path[:-1] + ( rename_to, ))
|
411
|
+
parent.insert(key_index, rename_to, parent.pop(path[-1]))
|
412
|
+
return True
|
413
|
+
|
414
|
+
# XXX This method is pretty much broken, as we would need to perform complex merging
|
415
|
+
# for the multiple comment types and parent comments
|
416
|
+
def _move_metadata(self, old_path: DictPath, new_path: DictPath, merge: bool = False) -> None:
|
417
|
+
'''
|
418
|
+
Moves the metadata (i.e. comments and Jinja2) of an item to another item.
|
419
|
+
If `merge` is set to False, the new item's metadata will be overwritten.
|
420
|
+
Else, it will be merged with the old item's metadata (in order new, old).
|
421
|
+
The parent of the new item has to exist already, the new item itself doesn't have to.
|
422
|
+
'''
|
423
|
+
old_path, new_path = Vault._to_path(old_path), Vault._to_path(new_path)
|
424
|
+
# Get parent dict of old key, which contains the key metadata
|
425
|
+
old_parent: Indexable = self._traverse(old_path[:-1], decrypt=False)
|
426
|
+
# Get parent dict of new key, which will receive the key metadata
|
427
|
+
new_parent: Indexable = self._traverse(new_path[:-1], decrypt=False)
|
428
|
+
# If the old parent has no capability of storing metadata, return
|
429
|
+
if not isinstance(old_parent, CommentedMap | CommentedSeq):
|
430
|
+
return
|
431
|
+
# Check if the old key's parent has any CA metadata for the key, else we're already done
|
432
|
+
if old_path[-1] not in old_parent.ca.items:
|
433
|
+
return
|
434
|
+
# If the new parent has no capability of receiving metadata, raise an error
|
435
|
+
if not isinstance(new_parent, CommentedMap | CommentedSeq):
|
436
|
+
raise TypeError(f"New parent { '.'.join(map(str, new_parent)) } cannot hold any metadata")
|
437
|
+
# Check if we should merge metadata (old is appended to new as we assume the old item comes after the new one)
|
438
|
+
if merge and new_path[-1] in new_parent.ca.items and new_parent.ca.items[new_path[-1]]:
|
439
|
+
# The metadata consists of a list with each item being either None, a CommentToken or a list of CommentTokens
|
440
|
+
old_metadata: list = old_parent.ca.items.pop(old_path[-1])
|
441
|
+
merged_metadata: list = new_parent.ca.items[new_path[-1]]
|
442
|
+
print(merged_metadata, old_metadata)
|
443
|
+
for index, _ in enumerate(merged_metadata):
|
444
|
+
if old_metadata[index]:
|
445
|
+
_merged = merged_metadata[index]
|
446
|
+
_old = old_metadata[index]
|
447
|
+
print(_merged, _old)
|
448
|
+
# Direct overwrite
|
449
|
+
if not _merged:
|
450
|
+
merged_metadata[index] = _old
|
451
|
+
# Direct token merge if both are tokens
|
452
|
+
elif type(_merged) == type(_old) == CommentToken:
|
453
|
+
merged_metadata[index] = CommentToken(
|
454
|
+
value = _merged.value + _old.value,
|
455
|
+
start_mark = _merged.start_mark,
|
456
|
+
end_mark = _merged.end_mark,
|
457
|
+
column = _merged.column
|
458
|
+
)
|
459
|
+
# Convert to lists and merge those
|
460
|
+
else:
|
461
|
+
_merged = _merged or []
|
462
|
+
_merged = _merged if type(_merged) is list else [ _merged ]
|
463
|
+
_old = _old or []
|
464
|
+
_old = _old if type(_old) is list else [ _old ]
|
465
|
+
merged_metadata[index] = _merged + _old
|
466
|
+
print(merged_metadata)
|
467
|
+
else:
|
468
|
+
# Overwrite the CA (comment anchor) metadata, which includes Jinja2 blocks
|
469
|
+
new_parent.ca.items[new_path[-1]] = old_parent.ca.items.pop(old_path[-1])
|
470
|
+
"""
|
471
|
+
|
472
|
+
def _traverse(self, path: DictPath, decrypt: bool = False) -> Any:
|
473
|
+
'''Gets the value of the specified key path from the `Vault`'s `_data`, optionally decrypting it.'''
|
474
|
+
path = Vault._to_path(path)
|
475
|
+
def _get_child(parent: Indexable, index: Hashable) -> Any:
|
476
|
+
if not isinstance(parent, dict | list):
|
477
|
+
raise TypeError(f"Can only index into dict-like and list-like types, got { type(parent) } for index { index }")
|
478
|
+
is_dict: bool = isinstance(parent, dict)
|
479
|
+
if (is_dict and index not in parent) or (not is_dict and index > len(parent)): # type: ignore
|
480
|
+
raise KeyError(f"Key '{ index }' of path '{ '.'.join(map(str, path)) }' could not be resolved")
|
481
|
+
return parent[index] # type: ignore
|
482
|
+
leaf: Any = reduce(_get_child, path, self._data)
|
483
|
+
return self.keyring.decrypt(leaf.cipher) if (decrypt and type(leaf) is EncryptedVar) else leaf
|
484
|
+
|
485
|
+
@staticmethod
|
486
|
+
def _to_path(path: DictPath) -> tuple[Hashable, ...]:
|
487
|
+
'''Create a tuple of dictionary keys for traversing nested dictionaries. Can be initialized with a single key or a tuple.'''
|
488
|
+
return path if isinstance(path, tuple) else ( path, )
|
489
|
+
|
490
|
+
# Output formats
|
491
|
+
|
492
|
+
def as_json(self) -> str:
|
493
|
+
'''Returns the decrypted variables of the vault as a JSON string.'''
|
494
|
+
return json.dumps(dict(self.decrypted_vars), indent=2)
|
495
|
+
|
496
|
+
def as_plain(self) -> str:
|
497
|
+
'''Returns the vault in fully decrypted form as Jinja2 YAML code with the original metadata.'''
|
498
|
+
copy: CommentedMap = self._data.copy()
|
499
|
+
#copy.pop(SENTINEL_KEY, None) # <-- would break a file containing only metadata and the sentinel key
|
500
|
+
def _decrypt_leaf(_: tuple[Hashable, ...], value: Any) -> Any:
|
501
|
+
'''Transforms EncryptedVar leaves into decrypted strings.'''
|
502
|
+
return self.keyring.decrypt(value.cipher) if type(value) is EncryptedVar else value
|
503
|
+
Vault._transform_leaves(copy, _decrypt_leaf, tuple())
|
504
|
+
yaml_content: str = self._dump_to_str(copy)
|
505
|
+
yaml_content = Vault._remove_sentinel(yaml_content)
|
506
|
+
return yaml_content
|
507
|
+
|
508
|
+
def as_editable(self, with_header: bool = True) -> str:
|
509
|
+
'''
|
510
|
+
Returns the vault as Jinja2 YAML code with the original metadata.
|
511
|
+
It is prepared for editing and later re-encryption with YAML tags and a static explanatory header.
|
512
|
+
'''
|
513
|
+
copy: CommentedMap = self._data.copy()
|
514
|
+
def _convert_to_proto(path: tuple[Hashable, ...], value: Any) -> Any:
|
515
|
+
'''Marks encrypted leaves for encryption after editing.'''
|
516
|
+
if type(value) is not EncryptedVar:
|
517
|
+
return value
|
518
|
+
return ProtoEncryptedVar(self.keyring.decrypt(value.cipher), value.name or str(path[-1]))
|
519
|
+
Vault._transform_leaves(copy, _convert_to_proto, tuple())
|
520
|
+
yaml_content: str = self._dump_to_str(copy)
|
521
|
+
yaml_content = Vault._remove_sentinel(yaml_content)
|
522
|
+
# Add static header
|
523
|
+
if with_header:
|
524
|
+
return EDIT_MODE_HEADER + yaml_content
|
525
|
+
return yaml_content
|
526
|
+
|
527
|
+
def as_encrypted(self) -> str:
|
528
|
+
'''Returns the vault as Jinja2 YAML code with the original metadata, with encrypted variables and full encryption if enabled.'''
|
529
|
+
copy: CommentedMap = self._data.copy()
|
530
|
+
yaml_content: str = self._dump_to_str(copy)
|
531
|
+
yaml_content = Vault._remove_sentinel(yaml_content)
|
532
|
+
if self.full_encryption:
|
533
|
+
yaml_content = self.keyring.encrypt(yaml_content)
|
534
|
+
return yaml_content
|
535
|
+
|
536
|
+
@staticmethod
|
537
|
+
def _transform_leaves(indexable: Indexable, transform_fn: Callable[[tuple[Hashable, ...], Any], Any], curr_path: tuple[Hashable, ...]) -> None:
|
538
|
+
'''
|
539
|
+
Runs the transform_fn on all leaves of the indexable object recursively, passing the current path and the leaf object as a tuple.
|
540
|
+
The leaves are replaced by the result of the function call.
|
541
|
+
'''
|
542
|
+
if isinstance(indexable, dict):
|
543
|
+
keys: list[Hashable] = list(indexable.keys())
|
544
|
+
else:
|
545
|
+
keys: list[Hashable] = list(range(len(indexable)))
|
546
|
+
for key in keys:
|
547
|
+
_curr_path = curr_path + ( key, ) # type: ignore
|
548
|
+
if isinstance(indexable[key], dict | list): # type: ignore
|
549
|
+
Vault._transform_leaves(indexable[key], transform_fn, _curr_path) # type: ignore
|
550
|
+
else:
|
551
|
+
indexable[key] = transform_fn(_curr_path, indexable[key]) # type: ignore
|
552
|
+
|
553
|
+
@staticmethod
|
554
|
+
def _remove_sentinel(yaml_content: str) -> str:
|
555
|
+
'''Removes the root sentinel key from the given raw YAML string. Returns the modified YAML.'''
|
556
|
+
# Since the parser should put all root keys on their own line, remove (first) line containing the sentinel key
|
557
|
+
lines: list[str] = yaml_content.split('\n')
|
558
|
+
for index, line in enumerate(lines):
|
559
|
+
if line.strip() == f"{ SENTINEL_KEY }:":
|
560
|
+
lines.pop(index)
|
561
|
+
break
|
562
|
+
return '\n'.join(lines).strip('\n') + '\n'
|
563
|
+
|
564
|
+
def _dump_to_str(self, data: Any) -> str:
|
565
|
+
'''Since the YAML parser requires a stream object to dump to, this method handles text streaming.'''
|
566
|
+
builder = StringIO()
|
567
|
+
self._parser.dump(data, builder)
|
568
|
+
return builder.getvalue().strip('\n') + '\n'
|
569
|
+
|
570
|
+
# Comparing to older versions of this vault
|
571
|
+
|
572
|
+
def diff(self, prev_vault: 'Vault', context_lines: int = 3, show_filenames: bool = True) -> str:
|
573
|
+
'''
|
574
|
+
Generates a diff for the edit mode Jinja2 YAML vault code (from `Vault.as_editable`) of a previous vault to this one's.
|
575
|
+
Set `context_lines` to specify how many lines of context are shown before and after the actual diff lines.
|
576
|
+
If `show_filenames` is set to True and the vaults are `VaultFile` objects,
|
577
|
+
the previous and current filenames will be shown in the diff header.
|
578
|
+
'''
|
579
|
+
# Generate filenames
|
580
|
+
prev_filename: str = 'Previous vault'
|
581
|
+
if show_filenames and isinstance(prev_vault, VaultFile):
|
582
|
+
prev_filename = prev_vault.vault_path
|
583
|
+
curr_filename: str = 'Current vault'
|
584
|
+
if show_filenames and isinstance(self, VaultFile):
|
585
|
+
curr_filename = self.vault_path
|
586
|
+
# Generate diff
|
587
|
+
return '\n'.join(
|
588
|
+
unified_diff(
|
589
|
+
prev_vault.as_editable().split('\n'),
|
590
|
+
self.as_editable().split('\n'),
|
591
|
+
fromfile = prev_filename,
|
592
|
+
tofile = curr_filename,
|
593
|
+
n = context_lines,
|
594
|
+
lineterm = ''
|
595
|
+
)
|
596
|
+
)
|
597
|
+
|
598
|
+
def changes(self, prev_vault: 'Vault') -> tuple[ChangeList, ChangeList, ChangeList, ChangeList]:
|
599
|
+
'''
|
600
|
+
Returns the changes between two (decrypted) vault root data structures.
|
601
|
+
Included changes are added, removed, changed and de-encrypted keys.
|
602
|
+
Changes are modeled as traversal paths and only minimal paths are recorded.
|
603
|
+
Returns a tuple of (decrypted_paths, removed_paths, changed_paths, added_paths)
|
604
|
+
where each element is a list of traversal paths.
|
605
|
+
Note that decrypted variables will be recorded twice: In decrypted_paths and in changed_paths.
|
606
|
+
'''
|
607
|
+
# Prepare input and result data
|
608
|
+
old_root: CommentedMap = prev_vault._data
|
609
|
+
new_root: CommentedMap = self._data
|
610
|
+
decrypted_paths: ChangeList = []
|
611
|
+
removed_paths : ChangeList = []
|
612
|
+
changed_paths : ChangeList = []
|
613
|
+
added_paths : ChangeList = []
|
614
|
+
# Traverse root and find changes
|
615
|
+
def _traverse_and_find_changes(path: tuple[Hashable, ...], old_node: Any, new_node: Any) -> None:
|
616
|
+
'''Traverses the old and new data structures and finds changes, discarding each branch after finding a minimal path.'''
|
617
|
+
# Type changed (possibly even decryption)
|
618
|
+
if type(old_node) is not type(new_node):
|
619
|
+
if type(old_node) is EncryptedVar:
|
620
|
+
decrypted_paths.append(path)
|
621
|
+
changed_paths.append(path)
|
622
|
+
return
|
623
|
+
# Leaf value changed (or abort because this is a leaf and it stayed the same)
|
624
|
+
if not isinstance(old_node, dict | list):
|
625
|
+
# We have to do a special comparison for EncryptedVars as their cipher contains a random salt and can't be compared
|
626
|
+
if type(old_node) is EncryptedVar:
|
627
|
+
if self.keyring.decrypt(old_node.cipher) != self.keyring.decrypt(new_node.cipher):
|
628
|
+
changed_paths.append(path)
|
629
|
+
elif old_node != new_node:
|
630
|
+
changed_paths.append(path)
|
631
|
+
return
|
632
|
+
# Check for added and removed nodes in indexables and recurse
|
633
|
+
if (is_dict := isinstance(old_node, dict)):
|
634
|
+
keys: list[Hashable] = sorted(set(old_node.keys()).union(new_node.keys()))
|
635
|
+
else:
|
636
|
+
keys: list[Hashable] = list(range(max(len(old_node), len(new_node))))
|
637
|
+
for key in keys:
|
638
|
+
_path: tuple[Hashable, ...] = path + ( key, )
|
639
|
+
# Check if a key has been added or removed
|
640
|
+
if (is_dict and key in old_node and key not in new_node) or \
|
641
|
+
(not is_dict and key < len(old_node) and key >= len(new_node)): # type: ignore
|
642
|
+
removed_paths.append(_path)
|
643
|
+
continue
|
644
|
+
if (is_dict and key in new_node and key not in old_node) or \
|
645
|
+
(not is_dict and key < len(new_node) and key >= len(old_node)): # type: ignore
|
646
|
+
added_paths.append(_path)
|
647
|
+
continue
|
648
|
+
# Traverse subtree of node
|
649
|
+
_traverse_and_find_changes(_path, old_node[key], new_node[key]) # type: ignore
|
650
|
+
_traverse_and_find_changes(tuple(), old_root, new_root)
|
651
|
+
return decrypted_paths, removed_paths, changed_paths, added_paths
|
652
|
+
|
653
|
+
def copy(self) -> 'Vault':
|
654
|
+
'''Create a copy of this `Vault` instance.'''
|
655
|
+
copy = Vault('', self.keyring)
|
656
|
+
copy._data = self._data.copy()
|
657
|
+
copy.full_encryption = self.full_encryption
|
658
|
+
copy.keyring = self.keyring
|
659
|
+
copy._parser = self._parser
|
660
|
+
return copy
|
661
|
+
|
662
|
+
# Search functions
|
663
|
+
|
664
|
+
def search_keys(self, query: str, is_regex: bool = False, as_bool: bool = False) -> bool | list[tuple[Hashable, ...]]:
|
665
|
+
'''
|
666
|
+
Matches query on all key names in the vault's data and returns matches or a flag signifying some matches were found.
|
667
|
+
Non-regex queries are case-insensitive.
|
668
|
+
'''
|
669
|
+
matches: list[tuple[Hashable, ...]] = []
|
670
|
+
query = str(query)
|
671
|
+
# Match on all keys by traversing through each leaf's path and looking for matches
|
672
|
+
def _match_keys(path: tuple[Hashable, ...], value: Any) -> Any:
|
673
|
+
'''Checks all keys in a leaf's path for query matches.'''
|
674
|
+
for curr_end, segment in enumerate(path, start=1):
|
675
|
+
segment = str(segment)
|
676
|
+
if (is_regex and re.match(query, segment)) or (not is_regex and segment.lower() == query.lower()):
|
677
|
+
matches.append(path[:curr_end])
|
678
|
+
return value
|
679
|
+
Vault._transform_leaves(self._data, _match_keys, tuple())
|
680
|
+
# Return bool or list of matches
|
681
|
+
return bool(matches) if as_bool else matches
|
682
|
+
|
683
|
+
def search_leaf_values(self, query: str, is_regex: bool = False, as_bool: bool = False) -> bool | list[tuple[Hashable, ...]]:
|
684
|
+
'''
|
685
|
+
Matches query on all (decrypted) leaf values in the vault's data and returns matches or a flag signifying some matches were found.
|
686
|
+
Non-regex queries are case-insensitive.
|
687
|
+
'''
|
688
|
+
matches: list[tuple[Hashable, ...]] = []
|
689
|
+
query = str(query)
|
690
|
+
# Match on all decrypted leaf values by traversing through each leaf's path and looking for matches
|
691
|
+
def _match_decrypted_leaves(path: tuple[Hashable, ...], value: Any) -> Any:
|
692
|
+
'''Checks the decrypted value of the current leaf for query matches.'''
|
693
|
+
if type(value) is EncryptedVar:
|
694
|
+
value = self.keyring.decrypt(value.cipher)
|
695
|
+
if value is None:
|
696
|
+
return value
|
697
|
+
value = str(value)
|
698
|
+
if (is_regex and re.match(query, value)) or (not is_regex and value.lower() == query.lower()):
|
699
|
+
matches.append(path)
|
700
|
+
return value
|
701
|
+
Vault._transform_leaves(self._data, _match_decrypted_leaves, tuple())
|
702
|
+
# Return bool or list of matches
|
703
|
+
return bool(matches) if as_bool else matches
|
704
|
+
|
705
|
+
def search_vaulttext(
|
706
|
+
self, query: str, is_regex: bool = False, as_bool: bool = False, from_plain: bool = False, multiline: bool = False
|
707
|
+
) -> bool | list[MatchLocation]:
|
708
|
+
'''
|
709
|
+
Finds matches for the given query in the fully decrypted editable Jinja2 YAML vault code (from `Vault.as_editable`)
|
710
|
+
and returns the match locations as tuples of ((start_line, start_col), (end_line, end_col))
|
711
|
+
or a flag signifying some matches were found.
|
712
|
+
The locations are relative to the `as_editable` unless you set `from_plain`, in which case `as_plain` is used.
|
713
|
+
If `multiline` is set, a regex `.` matches newlines.
|
714
|
+
Non-regex queries are case-insensitive.
|
715
|
+
'''
|
716
|
+
yaml_content: str = self.as_plain() if from_plain else self.as_editable()
|
717
|
+
if not is_regex:
|
718
|
+
yaml_content = yaml_content.lower()
|
719
|
+
matches: list[MatchLocation] = []
|
720
|
+
# Ensure the query is properly escaped if it's plain text
|
721
|
+
pattern: str = query if is_regex else re.escape(query.lower())
|
722
|
+
# Process all matches
|
723
|
+
_matches = re.finditer(pattern, yaml_content, re.DOTALL) if multiline else re.finditer(pattern, yaml_content)
|
724
|
+
for match in _matches:
|
725
|
+
start_idx: int = match.start()
|
726
|
+
end_idx: int = match.end()
|
727
|
+
# Convert match indices to line and column numbers (1-indexed)
|
728
|
+
start_line: int = yaml_content[:start_idx].count('\n') + 1
|
729
|
+
start_col: int = start_idx - yaml_content[:start_idx].rfind('\n')
|
730
|
+
end_line: int = yaml_content[:end_idx].count('\n') + 1
|
731
|
+
end_col: int = end_idx - yaml_content[:end_idx].rfind('\n')
|
732
|
+
# Add tuple of converted indices to matches
|
733
|
+
matches.append(( ( start_line, start_col ), ( end_line, end_col ) ))
|
734
|
+
# Return bool or list of matches
|
735
|
+
return bool(matches) if as_bool else matches
|
736
|
+
|
737
|
+
# Internals
|
738
|
+
|
739
|
+
def __repr__(self) -> str:
|
740
|
+
return f"Vault({ 'un' * (not self.full_encryption) }encrypted)"
|
741
|
+
|
742
|
+
class VaultFile(Vault):
|
743
|
+
'''
|
744
|
+
Wrapper around a `Vault` that handles reading from and writing to a (Jinja2 yaml) vault file on disk.
|
745
|
+
Note that the file has to exist already at init or a `FileNotFoundError` error will be raised on loading and/or saving.
|
746
|
+
Use the `VaultFile.create` method to create a file that doesn't exist yet with your desired encryption settings.
|
747
|
+
'''
|
748
|
+
|
749
|
+
def __init__(self, vault_path: str, keyring: VaultKeyring | None = None) -> None:
|
750
|
+
'''
|
751
|
+
Loads the contents of the specified vault file and initializes a `Vault` with them.
|
752
|
+
If no keyring is supplied, only plain vars and content are supported.
|
753
|
+
'''
|
754
|
+
self.vault_path: str = os.path.abspath(vault_path)
|
755
|
+
yaml_content: str = self._load_file_content(self.vault_path)
|
756
|
+
super().__init__(yaml_content, keyring=keyring)
|
757
|
+
|
758
|
+
@classmethod
|
759
|
+
def create(
|
760
|
+
VaultFile: Type['VaultFile'],
|
761
|
+
path: str,
|
762
|
+
content: str = '',
|
763
|
+
full_encryption: bool = True,
|
764
|
+
permissions: octal | None = None,
|
765
|
+
keyring: VaultKeyring | None = None
|
766
|
+
) -> 'VaultFile':
|
767
|
+
'''
|
768
|
+
Creates a new vault file at the specified path. Raises a `FileExistsError` if the path is already occupied.
|
769
|
+
If `content` is set, the vault will contain this Jinja2 yaml text instead of being empty.
|
770
|
+
If `full_encryption` is set to True, the file will be wholly encrypted, even if empty.
|
771
|
+
If `permissions` are set, the new file's permissions will be modified to that octal.
|
772
|
+
If no keyring is supplied, only plain vars are supported and enabling `full_encryption` will not work.
|
773
|
+
'''
|
774
|
+
if os.path.exists(path):
|
775
|
+
raise FileExistsError(f"Vault file { path } already exists")
|
776
|
+
# Create file first and set permissions before filling in the data
|
777
|
+
with open(path, 'w'): pass
|
778
|
+
if permissions is not None:
|
779
|
+
os.chmod(path, permissions)
|
780
|
+
# Write content to file
|
781
|
+
if full_encryption and not keyring:
|
782
|
+
raise NoVaultKeysError(f"No vault keys available to write encrypted content to { path }")
|
783
|
+
with open(path, 'w') as file:
|
784
|
+
file.write(keyring.encrypt(content) if full_encryption else content) # type: ignore
|
785
|
+
# Create VaultFile and write content to disk
|
786
|
+
vaultfile = VaultFile(path, keyring=keyring)
|
787
|
+
vaultfile.full_encryption = full_encryption
|
788
|
+
vaultfile.save()
|
789
|
+
return vaultfile
|
790
|
+
|
791
|
+
@classmethod
|
792
|
+
def from_editable(VaultFile: Type['VaultFile'], prev_vault_file: 'VaultFile', edited_content: str) -> 'VaultFile':
|
793
|
+
'''Converts a YAML vault edited from a `VaultFile.as_editable` template into a new `VaultFile`. Does not update the file on disk.'''
|
794
|
+
# Create vault from editable, then wrap with our class and copy relevant attributes over
|
795
|
+
vault: Vault = Vault.from_editable(prev_vault_file, edited_content)
|
796
|
+
vault.__class__ = VaultFile
|
797
|
+
vault.vault_path = prev_vault_file.vault_path # type: ignore
|
798
|
+
return vault # type: ignore
|
799
|
+
|
800
|
+
def save(self) -> None:
|
801
|
+
'''Saves the current `Vault` contents to the vault file attached to this `VaultFile`. '''
|
802
|
+
self._save_to_file(self.vault_path, self.as_encrypted())
|
803
|
+
|
804
|
+
def copy(self) -> 'VaultFile':
|
805
|
+
'''Create a copy of this `VaultFile` instance.'''
|
806
|
+
copy: VaultFile = self.from_editable(self, '')
|
807
|
+
copy._data = self._data.copy()
|
808
|
+
copy.full_encryption = self.full_encryption
|
809
|
+
copy.keyring = self.keyring
|
810
|
+
copy._parser = self._parser
|
811
|
+
return copy
|
812
|
+
|
813
|
+
@staticmethod
|
814
|
+
def _load_file_content(path: str) -> str:
|
815
|
+
'''Loads the contents of a file into a string. Throws an error if the file does not exist.'''
|
816
|
+
if not os.path.isfile(path):
|
817
|
+
raise FileNotFoundError(f"Vault file { path } could not be found")
|
818
|
+
with open(path, 'r') as file:
|
819
|
+
return file.read()
|
820
|
+
|
821
|
+
@staticmethod
|
822
|
+
def _save_to_file(path: str, content: str) -> None:
|
823
|
+
'''Saves the given content to a file. Throws an error if the file does not exist.'''
|
824
|
+
if not os.path.isfile(path):
|
825
|
+
raise FileNotFoundError(f"Vault file { path } could not be found")
|
826
|
+
with open(path, 'w') as file:
|
827
|
+
file.write(content)
|
828
|
+
|
829
|
+
def __repr__(self) -> str:
|
830
|
+
return f"VaultFile({ self.vault_path }, { 'un' * (not self.full_encryption) }encrypted)"
|