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/cli.py ADDED
@@ -0,0 +1,983 @@
1
+ #!/usr/bin/env python3
2
+ # PYTHON_ARGCOMPLETE_OK
3
+
4
+ # CLI entry point for ansible-vars
5
+
6
+ # Standard library imports
7
+ import os, re, json, atexit, signal
8
+ from glob import glob
9
+ from time import sleep
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from shutil import rmtree
13
+ from builtins import print as std_print
14
+ from subprocess import run as sys_command
15
+ from tempfile import NamedTemporaryFile, mkdtemp
16
+ from typing import Iterator, Type, Hashable, Callable, Any
17
+ from argparse import ArgumentParser, RawDescriptionHelpFormatter
18
+
19
+ # External library imports
20
+ import yaml
21
+ from argcomplete import autocomplete as shell_completion
22
+ from argcomplete.completers import FilesCompleter
23
+ from termcolor import colored
24
+ from pygments import highlight
25
+ from pygments.style import StyleMeta
26
+ from pygments.styles import get_style_by_name
27
+ from pygments.lexers.data import JsonLexer
28
+ from pygments.lexers.templates import YamlJinjaLexer
29
+ from pygments.formatter import Formatter
30
+ from pygments.formatters import TerminalFormatter, Terminal256Formatter, TerminalTrueColorFormatter
31
+
32
+ # Internal module imports
33
+ from .vault import VaultFile, EncryptedVar, ProtoEncryptedVar
34
+ from .vault_crypt import VaultKey, VaultKeyring
35
+ from .util import DiffFileLogger, VaultDaemon
36
+ from .constants import Unset, MatchLocation, SENTINEL_KEY
37
+ from .errors import YAMLFormatError
38
+
39
+ ## CLI argument parsing
40
+
41
+ HELP: dict[str, str] = {
42
+ 'epilog': '''
43
+ examples:
44
+
45
+ # Create a new encrypted vault in the current directory, using the first auto-detected vault key, and open it for editing
46
+ ansible-vars create my_vault.yml
47
+ # Edit a vault with a custom editor command (so it calls `nano -B <vault path>`)
48
+ ansible-vars edit --editor-command 'nano -B' my_vault.yml
49
+ # Encrypt a vars file in-place using a custom encryption key (by default, the first loaded key is used)
50
+ ansible-vars --add-key my_key '<passphrase>' --add-key other_key '<passphrase>' --encryption-key other_key encrypt my_vars.yml
51
+ # Check if a string value is encrypted (no keys need to be loaded for this)
52
+ ansible-vars is-encrypted string '<value>'
53
+ # Recursively search the directory `./host_vars` of vault files for decrypted text matches on a regex pattern
54
+ ansible-vars grep '# TODO' h:
55
+ # Get the diff of two vaults
56
+ ansible-vars diff my_vault.yml.old my_vault.yml
57
+ # Get the decrypted value of a vault's key path `root['my_key'][0]['other_key']` as JSON
58
+ ansible-vars get my_vault.yml my_key '[0]' other_key
59
+ # Start a daemon which syncs the decrypted contents of all vault files in `./host_vars`, `./group_vars`, and `./vars` to a target directory
60
+ ansible-vars file-daemon /tmp/decrypted/
61
+
62
+ tips:
63
+
64
+ - Some commands allow for a `--json` flag. The individual help messages for these commands will specify the structure of their responses.
65
+ - For brevity, the term 'vault' is used in help messages to denote fully encrypted, partially encrypted and plain vars files.
66
+ - When a command asks for a vault file path it actually accepts multiple kinds of search path:
67
+ - You can specify a full or relative path to a vault file just as usual. This path will always be tried first.
68
+ - If you specify `h:<path>`, `g:<path>`, or `v:<path>`, it looks in `./host_vars`, `./group_vars`, or `./vars`, respectively.
69
+ - If a resolved path is a directory instead of a file, it looks for a `main.yml` in that directory.
70
+ - For example, to open the file `/ansible/host_vars/my_host/main.yml`, you may run the command in `/ansible` and specify `h:my_host`.
71
+ - Data keys are split into segments (e.g. `root['my_key'][0]` would become `'my_key', 0`) for easier parsing.
72
+ - When specifying a key segment which is a number (e.g. a list index), surround it in brackets (`[2]`) to differentiate it from a string.
73
+ - If you need to actually use the string `[2]`, add a set of brackets to escape it (`[[2]]` -> `'[2]'`, `[[[2]]]` -> `'[[2]]'`, ...).
74
+ - Each vault key may hold some metadata about upcoming comments and Jinja2 blocks. Beware, using `set` and `del` may delete this data.
75
+ ''',
76
+ 'key_args': '''
77
+ Specify vault keys to load for en-/decryption. Not required for vars files with no encrypted variables.
78
+ A key is a combination of an identifier (can be a vault ID or anything else, ideally unique) and a passphrase.
79
+ By default, available keys are auto-detected if your current directory contains an Ansible config and appended to the ones you supplied.
80
+ If no explicit encryption key is specified, the first supplied/available key is used.
81
+ ''',
82
+ 'log_args': '''
83
+ Log a diff of any vault changes performed with this program to an encrypted or plain logfile, creating it if necessary.
84
+ If in encrypted mode, the supplied (-K) or inferred encryption key is used by default. It must match the existing logfile's key.
85
+ If a directory is supplied, the logfile name is generated from the encryption key's identifier.
86
+
87
+ Diff logging is supported for the commands `create`, `edit`, `convert`, `set`, and `del`.
88
+
89
+ [!] Beware that logging changes in an encrypted vault to a plain logfile may cause secrets leakage, as diffs are in decrypted form.
90
+ ''',
91
+ 'cmd_keyring': '''
92
+ Show all vault keys that have been loaded by argument or auto-detection, as well as their passphrases.
93
+
94
+ JSON mode formatting:
95
+ - { <vault key identifier>: <passphrase>, ... }
96
+ - `--keys-only`: [ <vault key identifier>, ... ]
97
+ ''',
98
+ 'cmd_create': '''
99
+ Create a new vault file, encrypting it with the encryption key, or a plain vars file. Then open it for editing.
100
+ If you specify a custom edit command, it must block until the editing is done. Note that the passed path is not the original vault path.
101
+ ''',
102
+ 'cmd_edit': '''
103
+ Open a vault file for editing. Encrypted vars are specially marked and can be changed, created or removed.
104
+ If you specify a custom edit command, it must block until the editing is done. Note that the passed path is not the original vault path.
105
+ ''',
106
+ 'cmd_view': '''
107
+ Show a vault's contents with all values in decrypted form, or as a JSON object holding just the data.
108
+
109
+ JSON mode formatting:
110
+ - { <key segment>: <dict|list|value>, ... } (basically YAML -> JSON conversion with node decryption)
111
+ - Could also be a list, but Ansible expects a dictionary as the data's baseline.
112
+ ''',
113
+ 'cmd_info': '''
114
+ Show details about the encryption status of a vault's leaf values.
115
+
116
+ JSON mode formatting:
117
+ - { "full_encryption": <bool>, "encrypted_leaves": [ [ <key segment>, ... ], ... ], "plain_leaves": [ [ <key segment>, ... ], ... ] }
118
+ ''',
119
+ 'cmd_encrypt': '''
120
+ Encrypt a string and return it or fully encrypt a file in-place. This uses the configured encryption key.
121
+ ''',
122
+ 'cmd_decrypt': '''
123
+ Decrypt a string and return it or fully decrypt a file in-place. Uses the first matching one of the loaded keys.
124
+ ''',
125
+ 'cmd_is_enc': '''
126
+ Check if a string or file is vault-encrypted. Works without any loaded keys.
127
+ ''',
128
+ 'cmd_convert': '''
129
+ Switch a file between full outer and full inner encryption for convenient migrating between encryption schemes.
130
+ If the file is already fully encrypted, decrypt it in-place and encrypt all leaf values individually.
131
+ If the file is not fully encrypted, encrypt it in-place and decrypt all leaf nodes individually.
132
+ ''',
133
+ 'cmd_grep': '''
134
+ Search one or multiple decrypted vault file(s) for text matches on a regex pattern. Returns matched text and locations.
135
+ By default, the locations are relative to the line and column numbers of the `edit` command's format, not the `view` one's.
136
+ You can also search only keys or only leaf values, in which case the matching keys and values are returned.
137
+ By default, all specified directory paths are searched recursively and all contained Ansible YAML files are matched with the pattern.
138
+ For non-regex queries, the search is case-insensitive.
139
+
140
+ JSON mode formatting:
141
+ - { <file path>: [ { "value": <matched string>, "context": [ <line>, ...], "start": [ <start line>, <start col> ], "end": [ <end line>, <end col> ] }, ... ], ... }
142
+ - `context` contains the full lines that were matched
143
+ - `--keys-only`, `--values-only`: { <file path>: [ { "key": [ <key segment>, ... ], "value": <value> }, ... ], ... }
144
+ ''',
145
+ 'cmd_diff': '''
146
+ Compare two versions of a vault (or two entirely different ones) and show the diff with some additional context lines.
147
+ The diff is based on the decrypted/editable vault format.
148
+ ''',
149
+ 'cmd_changes': '''
150
+ Compare two versions of a vault (or two entirely different ones) and show the differences in their nodes as a tree structure.
151
+ Removed nodes are colored red and marked with a `(-)`, added nodes green and marked with a `(+)`,
152
+ changed nodes blue and marked with a `(~)`, and nodes which were previously encrypted but aren't anymore orange and marked with a `(!)`.
153
+
154
+ JSON mode formatting:
155
+ - { "added": [ [ <key segment>, ... ], ... ], "removed": <like added>, "changed": <like added>, "decrypted": <like added> }
156
+ - Only minimal paths are included (e.g. after changing `root['my_key']['subkey']` and `root['my_key']['other_subkey']`, only `my_key` is included).
157
+ ''',
158
+ 'cmd_daemon': '''
159
+ Creates a temporary directory at the target root directory path and syncs the selected source vaults and vault directories into the target in decrypted form.
160
+ Each added source file or directory is described by its vault path and a relative target path within the target root directory.
161
+ By default, the `./host_vars`, `./group_vars`, and `./vars` directories are automatically included if they exist.
162
+ Two sources can not have the same relative target path, i.e. directory merging is not supported.
163
+ Changes in the source(s) are synced to the target(s), including creating/removing/editing/moving files, but not the other way around.
164
+ The sync works as long as the command is running, after which the target root directory is deleted.
165
+
166
+ [!] On exit, all files in the target are deleted. They are not synced back to the source, so don't create or modify anything (important) in there.
167
+ [!] Updated file metadata is not copied to the target on modification, only the file contents.
168
+ ''',
169
+ 'cmd_get': '''
170
+ Looks up the value of a key in a vault and displays it if it exists.
171
+ If the key resolves to a leaf value, the value is decrypted and displayed.
172
+ For a list or dictionary, the full YAML code is printed, but child values are not automatically decrypted.
173
+
174
+ JSON mode formatting:
175
+ - [ ... ] or { ... } for lists/dictionaries, "<value>" for strings, <value> for numbers
176
+ ''',
177
+ 'cmd_set': '''
178
+ Creates or updates a node in a vault with a YAML value, optionally encrypting the value first using the configured encryption key.
179
+ For creating a new list entry, the last specified key segment has to equal the largest index of the list plus one (e.g. `[5]` for a list of length 5).
180
+ The value is interpreted as YAML code.
181
+
182
+ [!] Creating new nodes or changing non-leaf nodes may break/remove trailing comments and Jinja2 blocks.
183
+ ''',
184
+ 'cmd_del': '''
185
+ Deletes a node from a vault if it exists.
186
+
187
+ [!] Deleting nodes may break/remove trailing comments and Jinja2 blocks.
188
+ '''
189
+ }
190
+
191
+ DEFAULT_EDITOR: str = os.environ.get('EDITOR', 'notepad.exe' if os.name == 'nt' else 'vi')
192
+
193
+ args: ArgumentParser = ArgumentParser(
194
+ prog = 'ansible-vars',
195
+ epilog = HELP['epilog'],
196
+ formatter_class = RawDescriptionHelpFormatter,
197
+ description = 'View and manipulate Ansible vars and vault files. Use `ansible-vars <command> -h` to get detailed help.'
198
+ )
199
+
200
+ # Custom shell completion for prefixed paths
201
+ def _prefixed_path_completer(prefix: str, **_) -> list[str]:
202
+ has_prefix: bool = len(prefix) > 1 and prefix[:2] in ( 'h:', 'g:', 'v:' )
203
+ resolved_prefix: str | None = { 'h:': 'host_vars', 'g:': 'group_vars', 'v:': 'vars' }[prefix[:2]] if has_prefix else None
204
+ if resolved_prefix and os.path.isdir(os.path.abspath(resolved_prefix + 'x')):
205
+ # Replace prefix with actual path
206
+ path_prefix: str = prefix[:3] if (len(prefix) > 2 and prefix[2] == os.path.sep) else prefix[:2]
207
+ new_prefix: str = os.path.join(resolved_prefix, prefix[len(path_prefix):])
208
+ # Use FilesCompleter to get completions in the resolved directory
209
+ completions: list[str] = FilesCompleter()(new_prefix)
210
+ # Adjust the completions to keep the prefix
211
+ return [
212
+ f"{ path_prefix }{ os.path.relpath(completion, resolved_prefix) }{ os.path.sep * os.path.isdir(completion) }"
213
+ for completion in completions
214
+ ]
215
+ else:
216
+ return FilesCompleter()(prefix)
217
+
218
+ # Base args
219
+
220
+ args.add_argument('--debug', '-d', action='store_true', help='print debug information')
221
+ args.add_argument('--color-mode', '-C', type=str, choices=['none', 'basic', '256', 'truecolor'], default='256', help='set terminal color capability (default: 256)')
222
+
223
+ key_args = args.add_argument_group('vault key management', description=HELP['key_args'])
224
+ # This arg can be repeated (results in [ [id, passphrase], ... ])
225
+ key_args.add_argument(
226
+ '--add-key', '-k', type=str, nargs=2, action='append', dest='keys', default=[], metavar=('<identifier>', '<passphrase>'), help='add a vault key'
227
+ )
228
+ key_args.add_argument('--no-detect-keys', '-D', action='store_false', dest='detect_keys', help='disable automatic key detection')
229
+ key_args.add_argument('--encryption-key', '-K', type=str, metavar='<identifier>', help='which of the loaded keys to use for encryption')
230
+
231
+ log_args = args.add_argument_group('logging vault changes', description=HELP['log_args'])
232
+ log_mutex = log_args.add_mutually_exclusive_group()
233
+ log_mutex.add_argument('--log', '-l', type=str, metavar='<log path>', help='log to an encrypted logfile (uses the encryption key)')
234
+ log_mutex.add_argument('--log-plain', '-L', type=str, metavar='<log path>', help='log to a plain logfile (dangerous!)')
235
+ log_args.add_argument('--logging-key', type=str, metavar='<identifier>', help='use this loaded key for logging instead of the encryption key')
236
+
237
+ # Commands
238
+ commands = args.add_subparsers(dest='command', metavar='<command>', required=True)
239
+
240
+ cmd_keyring = commands.add_parser('keyring', help='show available vault keys and their passphrases', description=HELP['cmd_keyring'])
241
+ cmd_keyring.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the vault keys as JSON and nothing else')
242
+ cmd_keyring.add_argument('--keys-only', '-o', action='store_false', dest='show_passphrases', help='show only the vault keys, not the passphrases')
243
+
244
+ cmd_create = commands.add_parser('create', help='create a new vault', description=HELP['cmd_create'])
245
+ cmd_create.add_argument('vault_path', type=str, metavar='<vault path>', help='path to create a new vault at') \
246
+ .completer = _prefixed_path_completer # type: ignore
247
+ cmd_create.add_argument('--plain', '-p', action='store_false', dest='encrypt_vault', help='create without full file encryption')
248
+ cmd_create.add_argument('--make-parents', '-m', action='store_true', help='create all directories in the given path')
249
+ cmd_mutex = cmd_create.add_mutually_exclusive_group()
250
+ cmd_mutex.add_argument('--no-edit', '-n', action='store_false', dest='open_edit_mode', help='just create the file, don\'t open it for editing')
251
+ cmd_mutex.add_argument(
252
+ '--edit-command', '-e', type=str, default=DEFAULT_EDITOR, help=f"editor command to use (runs as <command> <some path>) (default: { DEFAULT_EDITOR })"
253
+ )
254
+
255
+ cmd_edit = commands.add_parser('edit', help='edit a vault', description=HELP['cmd_edit'])
256
+ cmd_edit.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to edit') \
257
+ .completer = _prefixed_path_completer # type: ignore
258
+ cmd_edit.add_argument(
259
+ '--edit-command', '-e', type=str, default=DEFAULT_EDITOR, help=f"editor command to use (runs as <command> <some path>) (default: { DEFAULT_EDITOR })"
260
+ )
261
+
262
+ cmd_view = commands.add_parser('view', help='show the decrypted contents of a vault')
263
+ cmd_view.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to dump') \
264
+ .completer = _prefixed_path_completer # type: ignore
265
+ cmd_view.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the vault data as JSON and nothing else')
266
+
267
+ cmd_info = commands.add_parser('info', help='show information about a vault\'s variables', description=HELP['cmd_info'])
268
+ cmd_info.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to analyze') \
269
+ .completer = _prefixed_path_completer # type: ignore
270
+ cmd_info.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the information as JSON and nothing else')
271
+
272
+ cmd_encrypt = commands.add_parser('encrypt', help='encrypt a file in-place or a string with the encryption key', description=HELP['cmd_encrypt'])
273
+ cmd_encrypt.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
274
+ cmd_encrypt.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to encrypt') \
275
+ .completer = _prefixed_path_completer # type: ignore
276
+
277
+ cmd_decrypt = commands.add_parser('decrypt', help='decrypt a file in-place or a string', description=HELP['cmd_decrypt'])
278
+ cmd_decrypt.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
279
+ cmd_decrypt.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to decrypt') \
280
+ .completer = _prefixed_path_completer # type: ignore
281
+
282
+ cmd_is_enc = commands.add_parser('is-encrypted', help='check if a file or string is vault-encrypted', description=HELP['cmd_is_enc'])
283
+ cmd_is_enc.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
284
+ cmd_is_enc.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to check') \
285
+ .completer = _prefixed_path_completer # type: ignore
286
+ cmd_is_enc.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if encrypted or 100 if unencrypted')
287
+
288
+ cmd_convert = commands.add_parser('convert', help='switch vault between outer (file) and inner (vars) encryption', description=HELP['cmd_convert'])
289
+ cmd_convert.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to convert') \
290
+ .completer = _prefixed_path_completer # type: ignore
291
+
292
+ cmd_grep = commands.add_parser('grep', help='search a file or folder for a pattern', description=HELP['cmd_grep'])
293
+ cmd_grep.add_argument('query', type=str, metavar='<pattern>', help='regex query to match with targets')
294
+ cmd_grep.add_argument('targets', type=str, nargs='+', metavar='[<target> ...]', help='file(s) or folder(s) to search recursively') \
295
+ .completer = _prefixed_path_completer # type: ignore
296
+ cmd_grep.add_argument('--no-recurse', '-n', action='store_false', dest='recurse', help='don\'t recurse into target folders\' subfolders')
297
+ grep_mutex_limit = cmd_grep.add_mutually_exclusive_group()
298
+ grep_mutex_limit.add_argument('--keys-only', '-o', action='store_const', const='keys', dest='limit_grep', help='only search vault data\'s key names')
299
+ grep_mutex_limit.add_argument('--values-only', '-O', action='store_const', const='values', dest='limit_grep', help='only search vault data\'s leaf values')
300
+ grep_mutex_limit.add_argument('--plain-format', '-p', action='store_true', help='show match locations relative to the `view` command\'s output, not `edit`')
301
+ grep_mutex_type = cmd_grep.add_mutually_exclusive_group()
302
+ grep_mutex_type.add_argument('--simple', '-s', action='store_false', dest='is_regex', help='mark that the query is not a regex, but plain text')
303
+ grep_mutex_type.add_argument('--multiline', '-m', action='store_true', help='make . in a regex pattern match newlines')
304
+ cmd_grep.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the matches as JSON and nothing else')
305
+ cmd_grep.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if any matches found or 100 if none found')
306
+
307
+ cmd_diff = commands.add_parser('diff', help='show line differences between two vaults', description=HELP['cmd_diff'])
308
+ cmd_diff.add_argument('old_vault', type=str, metavar='<old vault vault path>', help='path of "old"/base vault') \
309
+ .completer = _prefixed_path_completer # type: ignore
310
+ cmd_diff.add_argument('new_vault', type=str, metavar='<new vault vault path>', help='path of "new"/changed vault') \
311
+ .completer = _prefixed_path_completer # type: ignore
312
+ cmd_diff.add_argument('--context-lines', '-c', type=int, metavar='<amount>', default=3, help='show <amount> lines of context around changed lines (default: 3)')
313
+
314
+ cmd_changes = commands.add_parser('changes', help='show var changes between vaults', description=HELP['cmd_changes'])
315
+ cmd_changes.add_argument('old_vault', type=str, metavar='<old vault vault path>', help='path of "old"/base vault') \
316
+ .completer = _prefixed_path_completer # type: ignore
317
+ cmd_changes.add_argument('new_vault', type=str, metavar='<new vault vault path>', help='path of "new"/changed vault') \
318
+ .completer = _prefixed_path_completer # type: ignore
319
+ cmd_changes.add_argument('--json', '-j', action='store_true', dest='as_json', help='print added/changed/removed/decrypted vars as JSON and nothing else')
320
+
321
+ cmd_daemon = commands.add_parser('file-daemon', help='create a folder and sync decrypted vaults to it', description=HELP['cmd_daemon'])
322
+ cmd_daemon.add_argument('target_root', type=str, metavar='<target root path>', help='root path the decrypted files and folders should be synced into') \
323
+ .completer = _prefixed_path_completer # type: ignore
324
+ # This arg can be repeated (results in [ [source, rel_target], ... ])
325
+ cmd_daemon.add_argument(
326
+ '--add-source', '-s', type=str, nargs=2, action='append', dest='sources', default=[], metavar=('<source path>', '<target subpath>'),
327
+ help='vault file or folder to sync and rel. path in <target root> to sync into'
328
+ ).completer = _prefixed_path_completer # type: ignore
329
+ cmd_daemon.add_argument('--no-recurse', '-n', action='store_false', dest='recurse', help='don\'t recurse into source folders\' subfolders')
330
+ cmd_daemon.add_argument('--no-default-dirs', '-N', action='store_false', dest='include_default_dirs', help='don\'t include default sync sources')
331
+ cmd_daemon.add_argument('--force', '-f', action='store_true', help='if the target root already exists, delete it')
332
+
333
+ cmd_get = commands.add_parser('get', help='get a key\'s (decrypted) value if it exists', description=HELP['cmd_get'])
334
+ cmd_get.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to get value from') \
335
+ .completer = _prefixed_path_completer # type: ignore
336
+ cmd_get.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
337
+ cmd_get.add_argument('--no-decrypt', '-n', action='store_false', dest='decrypt_value', help='don\'t decrypt the value if it is encrypted')
338
+ cmd_get.add_argument('--json', '-j', action='store_true', dest='as_json', help='print only value as JSON and set the rc to 0 if the key exists or 100 if it doesn\'t')
339
+
340
+ cmd_set = commands.add_parser('set', help='update a key\'s value or add a new key (experimental!)', description=HELP['cmd_set'])
341
+ cmd_set.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to set value in') \
342
+ .completer = _prefixed_path_completer # type: ignore
343
+ cmd_set.add_argument('value', type=str, metavar='<value>', help='value to set (will be loaded as YAML)')
344
+ cmd_set.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
345
+ cmd_set.add_argument('--encrypt', '-e', action='store_true', dest='encrypt_value', help='encrypt the value if it is\'t encrypted yet')
346
+
347
+ cmd_del = commands.add_parser('del', help='delete a key and its value if they exist (experimental!)', description=HELP['cmd_del'])
348
+ cmd_del.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to delete key from') \
349
+ .completer = _prefixed_path_completer # type: ignore
350
+ cmd_del.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
351
+ cmd_del.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if the key exists or 100 if it doesn\'t')
352
+
353
+ shell_completion(args)
354
+ config = args.parse_args()
355
+
356
+ ## CLI helpers
357
+
358
+ # Terminal output
359
+
360
+ class Color(StrEnum):
361
+ '''Available terminal message colors.'''
362
+ # Basic output
363
+ DEBUG = 'blue'
364
+ INFO = 'light_cyan'
365
+ GOOD = 'light_green'
366
+ MEH = 'light_yellow'
367
+ BAD = 'light_red'
368
+ # Changes command tree colors
369
+ TREE_TITLE = 'magenta'
370
+ TREE_ADDED = 'light_green'
371
+ TREE_REMOVED = 'light_red'
372
+ TREE_CHANGED = 'light_cyan'
373
+ TREE_DECRYPTED = 'light_yellow'
374
+ TREE_UNCHANGED = 'white'
375
+
376
+ # Overwrite standard print function with color support
377
+ def print(msg: Any, color: Color = Color.INFO, **print_args) -> None:
378
+ '''Outputs text to the console, coloring it if `color` is set to True in this module.'''
379
+ msg = colored(str(msg), color=color.value) if config.color_mode != 'none' else str(msg) # type: ignore
380
+ std_print(msg, **print_args)
381
+
382
+ def debug(msg: Any, prefix: str = '(debug) ', **print_args) -> None:
383
+ '''Outputs a debug message with a prefix.'''
384
+ if config.debug:
385
+ print(prefix + str(msg), Color.DEBUG, **print_args)
386
+
387
+ # All available color palettes are available in pygments.styles.STYLE_MAP
388
+ # These look nice in 256/truecolor (they're all the same in basic mode):
389
+ # zenburn solarized-light solarized-dark paraiso-dark one-dark nord monokai material lightbulb friendly_grayscale
390
+ # zenburn has the best differentiation between token types while still having good contrast and readability
391
+ highlight_style: StyleMeta = get_style_by_name(os.environ.get('ANSIBLE_VARS_THEME', 'zenburn'))
392
+
393
+ json_highlight_lexer = JsonLexer(stripall=True)
394
+ yaml_highlight_lexer = YamlJinjaLexer(stripall=True)
395
+ if config.color_mode != 'none':
396
+ _formatter: Type[Formatter] = { 'basic': TerminalFormatter, '256': Terminal256Formatter, 'truecolor': TerminalTrueColorFormatter }[config.color_mode]
397
+ highlight_formatter: Formatter = _formatter(linenos=False, cssclass="source", style=highlight_style)
398
+
399
+ def print_json(code: str) -> None:
400
+ '''Print JSON code with syntax highlighting if a `color_mode` is available.'''
401
+ if config.color_mode == 'none':
402
+ return std_print(code)
403
+ std_print(highlight(code, json_highlight_lexer, highlight_formatter).strip('\n'))
404
+
405
+ def print_yaml(code: str) -> None:
406
+ '''Print Jinja2 YAML code with syntax highlighting if a `color_mode` is available.'''
407
+ if config.color_mode == 'none':
408
+ return std_print(code)
409
+ std_print(highlight(code, yaml_highlight_lexer, highlight_formatter).strip('\n'))
410
+
411
+ def print_diff(diff: str) -> None:
412
+ '''Print a diff with highlighting if a `color_mode` is available.'''
413
+ _color_map: dict = { '-': Color.TREE_REMOVED, '+': Color.TREE_ADDED, '@': Color.INFO, '*': Color.TREE_UNCHANGED }
414
+ for line in diff.split('\n'):
415
+ color: Color = _color_map[line[0]] if (len(line) > 0 and line[0] in [ '-', '+', '@' ]) else _color_map['*']
416
+ print(line, color)
417
+
418
+ def resolve_key_path(segments: list[str]) -> tuple[Hashable, ...]:
419
+ '''
420
+ Resolves a list of string key segments into the correct types.
421
+ Numbers should be represented as `[<number>]`, which can be escaped by adding brackets (`[[2]]` -> `'[2]'`, ...).
422
+ '''
423
+ resolved: list[Hashable] = []
424
+ for segment in segments:
425
+ # Check if it's a number or an escaped number-like string (2 -> '2', '[2]' -> 2, '[[2]]' -> '[2]', ...)
426
+ pattern: str = r'^\[+([+-]?((\d+(\.\d*)?)|(\.\d+))([eE][-+]?\d+)?)\]+$'
427
+ match: re.Match[str] | None = re.match(pattern, segment)
428
+ opening_brackets: int = len(segment) - len(segment.lstrip('['))
429
+ closing_brackets: int = len(segment) - len(segment.rstrip(']'))
430
+ if match and opening_brackets == closing_brackets:
431
+ number_str: str = match.group(1)
432
+ # Convert to number if only one pair of brackets
433
+ if opening_brackets == 1:
434
+ number: float | int = float(number_str)
435
+ if number.is_integer():
436
+ number = int(number)
437
+ resolved.append(number)
438
+ # Remove a pair of brackets and keep as string
439
+ else:
440
+ resolved.append(segment[1:-1])
441
+ # Simple string
442
+ else:
443
+ resolved.append(segment)
444
+ return tuple(resolved)
445
+
446
+ def format_key_path(key_path: tuple[Hashable, ...]) -> str:
447
+ '''Formats a traversal path into a string representation.'''
448
+ def _represent(value) -> str:
449
+ return f"'{ value }'" if type(value) is str else str(value)
450
+ return ' -> '.join(map(_represent, key_path))
451
+
452
+ # Vault path loader
453
+
454
+ def resolve_vault_path(search_path: str, create_mode: bool = False, allow_dirs: bool = False) -> str:
455
+ '''
456
+ Resolve the path to a (vault) file or optionally a directory.
457
+ The given search path is tested for these cases in order:
458
+ - As an absolute path or a relative path from the PWD
459
+ - As a relative path with prefix `h:` / `g:` / `v:` to be treated as a subpath into `./host_vars` / `./group_vars` / `./vars`
460
+ - If the path is expected to be a file and the previous steps found a directory, append a `main.yml` to that path
461
+ If `create_mode` is set to True (i.e. the searched file doesn't exist yet), we test for option 2 first, then option 1 and option 3.
462
+ '''
463
+ # Try the path as-is first
464
+ abspath: str = os.path.abspath(search_path)
465
+ if not os.path.exists(abspath) or (not allow_dirs and os.path.isdir(abspath)):
466
+ # Check for prefix search notation
467
+ if len(search_path) > 1 and search_path[1] == ':' and (prefix := search_path[0]) in [ 'h', 'g', 'v' ]:
468
+ resolved: dict = { 'h': 'host_vars', 'g': 'group_vars', 'v': 'vars' }
469
+ abspath = os.path.abspath(resolved[prefix])
470
+ if len(search_path) > 2:
471
+ abspath = os.path.join(abspath, search_path[2:].lstrip(os.path.sep))
472
+ # Check for main.yml in directory
473
+ if not allow_dirs and os.path.isdir(abspath):
474
+ abspath = os.path.join(abspath, 'main.yml')
475
+ # Debug output
476
+ debug(f"Resolved path { search_path } to { abspath }")
477
+ # If we're in creation mode, we can't known if the file exists yet, so we check prefix notation first
478
+ if create_mode:
479
+ return abspath
480
+ if not os.path.exists(abspath):
481
+ raise FileNotFoundError(f"Could not resolve vault path { abspath }")
482
+ return abspath
483
+
484
+ ## CLI logic
485
+
486
+ # Load vault keys
487
+
488
+ _explicit_keys: list[VaultKey] = [ VaultKey(passphrase, vault_id=id) for id, passphrase in config.keys ]
489
+ keyring = VaultKeyring(_explicit_keys.copy(), detect_available_keys=config.detect_keys)
490
+
491
+ if config.encryption_key:
492
+ keyring.default_encryption_key = keyring.key_by_id(config.encryption_key)
493
+
494
+ debug(f"Loaded { len(keyring.keys) } vault key(s): { keyring }")
495
+ try:
496
+ debug(f"Encryption key: { keyring.encryption_key.id }")
497
+ except:
498
+ debug('Encryption key: unavailable')
499
+
500
+ # Set up logging
501
+
502
+ _log_path: str = getattr(config, 'log', None) or getattr(config, 'log_plain', None) or '/dev/null'
503
+ log_enabled: bool = bool((getattr(config, 'log', None) or getattr(config, 'log_plain', None)))
504
+ _log_plain: bool = bool(getattr(config, 'log_plain', None) or not log_enabled)
505
+ _log_key: VaultKey | VaultKeyring | None = None if _log_plain else (keyring.key_by_id(config.logging_key) if config.logging_key else keyring)
506
+ logger = DiffFileLogger(_log_path, _log_key, plain=_log_plain)
507
+
508
+ # Keyring command
509
+
510
+ if config.command == 'keyring':
511
+ # Passphrase helper
512
+ def _passphrase_from_key(key: VaultKey, quote: bool = True) -> str:
513
+ return ("'" * quote + key.passphrase + "'" * quote) if key.passphrase else 'passphrase unknown'
514
+ # Normal output format
515
+ if not config.as_json:
516
+ # Formats a list of keys into a list of entry lines
517
+ def _format_key_list(key_list: list[VaultKey]) -> Iterator[str]:
518
+ for key in key_list:
519
+ yield f"- { key.id }" + (f": { _passphrase_from_key(key) }" * config.show_passphrases)
520
+ # Show keys loaded from args
521
+ print('Explicitly loaded keys:', Color.GOOD)
522
+ if _explicit_keys:
523
+ print('\n'.join(_format_key_list(_explicit_keys)))
524
+ else:
525
+ print('No keys loaded')
526
+ # Show keys loaded by auto-detection
527
+ print('\nAuto-detected keys:', Color.GOOD)
528
+ if config.detect_keys:
529
+ detected_keys: list[VaultKey] = keyring.keys[len(_explicit_keys):]
530
+ if detected_keys:
531
+ print('\n'.join(_format_key_list(detected_keys)))
532
+ else:
533
+ print('No keys detected.')
534
+ else:
535
+ print('Function disabled by flag', Color.MEH)
536
+ # JSON mode with passphrases
537
+ elif config.show_passphrases:
538
+ print_json(json.dumps({ key.id: _passphrase_from_key(key, quote=False) for key in keyring.keys }, indent=2))
539
+ # JSON mode without passphrases
540
+ else:
541
+ print_json(json.dumps([ key.id for key in keyring.keys ]))
542
+
543
+ # Create & Edit commands
544
+
545
+ if config.command in [ 'create', 'edit' ]:
546
+ # Create or load vault file
547
+ vault_path: str = resolve_vault_path(config.vault_path, create_mode=(config.command == 'create'))
548
+ if config.command == 'create':
549
+ if config.make_parents:
550
+ os.makedirs(os.path.dirname(vault_path), mode=0o700, exist_ok=True)
551
+ vault = VaultFile.create(vault_path, full_encryption=config.encrypt_vault, permissions=0o600, keyring=keyring)
552
+ print(f"Created { 'encrypted' if vault.full_encryption else 'plain' } vault at { vault_path }", Color.GOOD)
553
+ else:
554
+ vault = VaultFile(vault_path, keyring=keyring)
555
+ # Open vault for edit mode
556
+ if getattr(config, 'open_edit_mode', True):
557
+ print(f"Editing vault at { vault_path }")
558
+ # Create a secure temporary file to host the editable content
559
+ with NamedTemporaryFile(mode='w+', prefix='vault_', suffix='.yml') as edit_file:
560
+ # Write vault contents to temp file
561
+ editable: str = vault.as_editable()
562
+ edit_file.write(editable)
563
+ while True:
564
+ # Open editor and wait for it to close
565
+ edit_file.seek(0)
566
+ sys_command(f"{ config.edit_command } { edit_file.name }", shell=True)
567
+ # Re-load vault from edited content and save to original location
568
+ edit_file.seek(0)
569
+ new_editable: str = edit_file.read()
570
+ if editable != new_editable:
571
+ try:
572
+ new_vault: VaultFile = VaultFile.from_editable(vault, new_editable)
573
+ except YAMLFormatError as e:
574
+ print('Invalid YAML format:', Color.BAD)
575
+ print(e.parent if e.parent else e, Color.BAD)
576
+ print('Note that Ansible YAML must have a dictionary as a root.', Color.BAD)
577
+ decision: str = input(colored('Continue editing? (discard changes on no) [Yn] > ', Color.MEH.value))
578
+ if decision.strip().lower() not in [ 'n', 'no' ]:
579
+ continue
580
+ else:
581
+ print('Changes discarded.', Color.BAD)
582
+ break
583
+ new_vault.save()
584
+ print(f"Saved changes!", Color.GOOD)
585
+ # Warn about decrypted variables
586
+ if (changes := new_vault.changes(vault))[0]:
587
+ print(f"\n[!] The following vars have been decrypted in this edit:", Color.MEH)
588
+ print('\n'.join([ f"- { format_key_path(path) }" for path in changes[0] ]))
589
+ # Log changes
590
+ if log_enabled:
591
+ logger.add_log_entry(vault, new_vault, comment=f"{ config.command } command via CLI")
592
+ else:
593
+ print(f"File unchanged.")
594
+ break
595
+
596
+ # View command
597
+
598
+ if config.command == 'view':
599
+ vault_path: str = resolve_vault_path(config.vault_path)
600
+ vault = VaultFile(vault_path, keyring=keyring)
601
+ if config.as_json:
602
+ print_json(vault.as_json())
603
+ else:
604
+ print_yaml(vault.as_plain())
605
+
606
+ # Info command
607
+
608
+ if config.command == 'info':
609
+ vault_path: str = resolve_vault_path(config.vault_path)
610
+ vault = VaultFile(vault_path, keyring=keyring)
611
+ # Sort leaf values
612
+ encrypted_leaves: list[tuple[Hashable, ...]] = []
613
+ plain_leaves: list[tuple[Hashable, ...]] = []
614
+ def _sort_leaf(path: tuple[Hashable, ...], value: Any) -> Any:
615
+ if path != ( SENTINEL_KEY, ):
616
+ (encrypted_leaves if type(value) is EncryptedVar else plain_leaves).append(path)
617
+ return value
618
+ vault._transform_leaves(vault._data, _sort_leaf, tuple())
619
+ # Output results
620
+ if config.as_json:
621
+ _data: dict = {
622
+ 'full_encryption': vault.full_encryption,
623
+ 'encrypted_leaves': encrypted_leaves,
624
+ 'plain_leaves': plain_leaves
625
+ }
626
+ print_json(json.dumps(_data, indent=2))
627
+ else:
628
+ print('Encrypted leaf values:', Color.GOOD)
629
+ if encrypted_leaves:
630
+ print('\n'.join([ f"- { format_key_path(key) }" for key in encrypted_leaves ]))
631
+ else:
632
+ print('No encrypted vars')
633
+ print('\nPlain leaf values:', Color.GOOD)
634
+ if encrypted_leaves:
635
+ print('\n'.join([ f"- { format_key_path(key) }" for key in plain_leaves ]))
636
+ else:
637
+ print('No plain vars')
638
+
639
+ # Encrypt & Decrypt & Is-Encrypted commands
640
+
641
+ if config.command in [ 'encrypt', 'decrypt', 'is-encrypted' ]:
642
+ # File target
643
+ if config.target_type == 'file':
644
+ vault_path: str = resolve_vault_path(config.target)
645
+ vault = VaultFile(vault_path, keyring=keyring)
646
+ if config.command in [ 'encrypt', 'decrypt' ]:
647
+ if vault.full_encryption == (config.command == 'encrypt'):
648
+ print(f"Vault is already { 'en' if vault.full_encryption else 'de' }crypted.")
649
+ else:
650
+ vault.full_encryption = (config.command == 'encrypt')
651
+ vault.save()
652
+ print(f"Vault { 'en' if vault.full_encryption else 'de' }crypted.", Color.GOOD)
653
+ else:
654
+ if config.quiet:
655
+ exit(0 if vault.full_encryption else 100)
656
+ else:
657
+ print(f"Vault is { 'encrypted' if vault.full_encryption else 'plain' }.", Color.GOOD if vault.full_encryption else Color.MEH)
658
+ # String target
659
+ else:
660
+ is_encrypted: bool = VaultKey.is_encrypted(config.target)
661
+ if config.command in [ 'encrypt', 'decrypt' ]:
662
+ if is_encrypted == (config.command == 'encrypt'):
663
+ print(f"Value is already { 'en' if is_encrypted else 'de' }crypted.")
664
+ else:
665
+ print(f"{ 'En' if not is_encrypted else 'De' }crypted value:", Color.GOOD)
666
+ print(keyring.encrypt(config.target) if (config.command == 'encrypt') else keyring.decrypt(config.target))
667
+ else:
668
+ if config.quiet:
669
+ exit(0 if is_encrypted else 100)
670
+ else:
671
+ print(f"Value is { 'encrypted' if is_encrypted else 'plain' }.", Color.GOOD if is_encrypted else Color.MEH)
672
+
673
+ # Convert command
674
+
675
+ if config.command == 'convert':
676
+ vault_path: str = resolve_vault_path(config.vault_path)
677
+ vault = VaultFile(vault_path, keyring=keyring)
678
+ @logger.log_changes(vault, comment=f"{ config.command } command via CLI", enable=log_enabled)
679
+ def _convert() -> None:
680
+ vault.full_encryption = not vault.full_encryption
681
+ def _encrypt_decrypt(path: tuple[Hashable, ...], value: Any) -> Any:
682
+ if path == ( SENTINEL_KEY, ):
683
+ return value
684
+ if not vault.full_encryption and type(value) is not EncryptedVar:
685
+ return EncryptedVar(keyring.encrypt(value), name=str(path[-1]))
686
+ if vault.full_encryption and type(value) is EncryptedVar:
687
+ return keyring.decrypt(value.cipher)
688
+ return value
689
+ vault._transform_leaves(vault._data, _encrypt_decrypt, tuple())
690
+ vault.save()
691
+ print(f"Vault converted to { 'outer' if vault.full_encryption else 'inner' } encryption.", Color.GOOD)
692
+ if not vault.full_encryption:
693
+ print('New vault contents:\n')
694
+ print_yaml(vault.as_editable(with_header=False))
695
+ _convert()
696
+
697
+ # Grep command
698
+
699
+ if config.command == 'grep':
700
+ # Resolve files and dirs to all file paths
701
+ raw_target_paths: list[str] = [ resolve_vault_path(target, allow_dirs=True) for target in config.targets ]
702
+ target_files: list[str] = []
703
+ for path in raw_target_paths:
704
+ if os.path.isdir(path):
705
+ _targets: list[str] = glob(os.path.join(path, '**' * config.recurse, '*'), recursive=config.recurse, include_hidden=True)
706
+ target_files += [ _path for _path in _targets if os.path.isfile(_path) ]
707
+ else:
708
+ target_files.append(path)
709
+ # Filter out non-YAML and non-YAML-dict files
710
+ targets: list[VaultFile] = []
711
+ for path in target_files:
712
+ try: targets.append(VaultFile(path, keyring=keyring))
713
+ except: debug(f"Skipping non-YAML file { path }")
714
+ # Search targets
715
+ matches: dict = {}
716
+ for vault in targets: # type: ignore
717
+ matches[vault.vault_path] = []
718
+ # Keys/Values only mode
719
+ if getattr(config, 'limit_grep', None):
720
+ _search_fn: Callable = vault.search_keys if config.limit_grep == 'keys' else vault.search_leaf_values
721
+ _results: list[tuple[Hashable, ...]] = _search_fn(config.query, is_regex=config.is_regex) # type: ignore
722
+ matches[vault.vault_path] += [ { 'key': key, 'value': vault.get(key, decrypt=True) } for key in _results ]
723
+ # Text matching mode
724
+ else:
725
+ _results: list[MatchLocation] = \
726
+ vault.search_vaulttext(config.query, is_regex=config.is_regex, from_plain=config.plain_format, multiline=config.multiline) # type: ignore
727
+ _text: str = vault.as_plain() if config.plain_format else vault.as_editable()
728
+ _lines: list[str] = _text.split('\n')
729
+ for location in _results:
730
+ # Find actual text value (lines and columns are 1-indexed, so we have to subtract 1)
731
+ _first_char: int = sum(map(len, _lines[:(location[0][0] - 1)])) + (location[0][0] - 1) + (location[0][1] - 1)
732
+ _final_char: int = sum(map(len, _lines[:(location[1][0] - 1)])) + (location[1][0] - 1) + (location[1][1] - 1)
733
+ matches[vault.vault_path].append({
734
+ 'value' : _text[_first_char:_final_char],
735
+ 'context': _lines[(location[0][0] - 1):(location[1][0])],
736
+ 'start' : location[0],
737
+ 'end' : location[1]
738
+ })
739
+ # Remove empty results
740
+ if not matches[vault.vault_path]:
741
+ del matches[vault.vault_path]
742
+ if config.quiet and matches:
743
+ exit(0)
744
+ if config.quiet and not matches:
745
+ exit(100)
746
+ # Output JSON
747
+ if config.as_json:
748
+ print_json(json.dumps(matches, indent=2))
749
+ elif not matches:
750
+ print('No matches found.', Color.MEH)
751
+ # Output keys-only/values-only
752
+ elif getattr(config, 'limit_grep', None):
753
+ print(f"Found { 'keys' if config.limit_grep == 'keys' else 'leaf values' } matching query.", Color.GOOD)
754
+ for file_path in matches:
755
+ print(f"In { file_path }:")
756
+ for match in matches[file_path]:
757
+ print('- ', end='')
758
+ print(format_key_path(match['key']), Color.GOOD if config.limit_grep == 'keys' else Color.INFO, end='')
759
+ print(' ==> ', end='')
760
+ print(match['value'], Color.GOOD if config.limit_grep == 'values' else Color.INFO)
761
+ # Output text matches
762
+ else:
763
+ print('Found text matching query.', Color.GOOD)
764
+ for file_path in matches:
765
+ print(f"\nIn { file_path }:")
766
+ for match in matches[file_path]:
767
+ _omission: str = colored('[...]', Color.MEH.value)
768
+ _value: str = f"{ colored(match['value'].split('\n')[0], Color.GOOD.value) }{ _omission if len(match['context']) > 1 else '' }"
769
+ print(f"@L{ match['start'][0] }:{ match['start'][1] } { _value }")
770
+ try:
771
+ print_yaml('\n'.join(match['context']))
772
+ except:
773
+ print('\n'.join(match['context']), Color.DEBUG)
774
+
775
+ # Diff & Changes command
776
+
777
+ if config.command in [ 'diff', 'changes' ]:
778
+ old_vault_path: str = resolve_vault_path(config.old_vault)
779
+ new_vault_path: str = resolve_vault_path(config.new_vault)
780
+ old_vault = VaultFile(old_vault_path, keyring=keyring)
781
+ new_vault = VaultFile(new_vault_path, keyring=keyring)
782
+ # Diff command
783
+ if config.command == 'diff':
784
+ print_diff(new_vault.diff(old_vault, context_lines=config.context_lines, show_filenames=True))
785
+ # Changes command
786
+ else:
787
+ decrypted_vars, removed_vars, changed_vars, added_vars = new_vault.changes(old_vault)
788
+ json_result: dict = { 'added': added_vars, 'removed': removed_vars, 'changed': changed_vars, 'decrypted': decrypted_vars }
789
+ # JSON
790
+ if config.as_json:
791
+ print_json(json.dumps(json_result, indent=2))
792
+ # Tree view
793
+ else:
794
+ old_tree: Any = dict(old_vault.decrypted_vars)
795
+ new_tree: Any = dict(new_vault.decrypted_vars)
796
+ branches: list[tuple[tuple[Hashable, ...], str | None]] = []
797
+ # Build branches by traversing the data
798
+ # XXX This code makes me sad :(
799
+ def _traverse_data(path: tuple[Hashable, ...], value: Any, _change_inheritance=None) -> None:
800
+ for _added_path in added_vars:
801
+ if path == _added_path[:-1]:
802
+ branches.append(( _added_path, 'added' ))
803
+ _traverse_data(_added_path, new_vault.get(_added_path), _change_inheritance='added')
804
+ if isinstance(value, dict | list | tuple):
805
+ # Generate type-appropriate indices
806
+ if isinstance(value, dict):
807
+ keys: list[Hashable] = sorted(value.keys())
808
+ else:
809
+ keys: list[Hashable] = list(range(len(value)))
810
+ # Depth-first traversal into the data while recording branch types
811
+ for key in keys:
812
+ _path = path + ( key, )
813
+ _change_type: str | None = _change_inheritance
814
+ for change_type in json_result:
815
+ if _path in json_result[change_type]:
816
+ if change_type == 'changed' and _path in json_result['decrypted']:
817
+ continue
818
+ _change_type = change_type
819
+ break
820
+ if ( _path, _change_type ) not in branches:
821
+ branches.append(( _path, _change_type ))
822
+ _traverse_data(_path, value[key], _change_inheritance=_change_type) # type: ignore
823
+ if _change_type not in [ 'removed', None ]:
824
+ _traverse_data(_path, new_vault.get(_path), _change_inheritance='added') # type: ignore
825
+ elif value is not None:
826
+ if path:
827
+ _change_type: str | None = _change_inheritance
828
+ for change_type in json_result:
829
+ if path in json_result[change_type]:
830
+ if change_type == 'changed' and path in json_result['decrypted']:
831
+ return
832
+ _change_type = change_type
833
+ break
834
+ if ( path, _change_type ) not in branches:
835
+ branches.append(( path, _change_type ))
836
+ _traverse_data(tuple(), old_tree)
837
+ # Preamble
838
+ print('Branch symbols', Color.TREE_TITLE)
839
+ print('(+) Added node', Color.TREE_ADDED)
840
+ print('(-) Removed node', Color.TREE_REMOVED)
841
+ print('(~) Changed node', Color.TREE_CHANGED)
842
+ print('(!) Decrypted node', Color.TREE_DECRYPTED)
843
+ print('(=) Unchanged node', Color.TREE_UNCHANGED)
844
+ std_print()
845
+ # Branches from depth-first search
846
+ print('Vault changes', Color.TREE_TITLE)
847
+ branches.sort()
848
+ print('│', Color.TREE_UNCHANGED)
849
+ for _index, branch in enumerate(branches):
850
+ # Prepare branch data
851
+ key: tuple[Hashable, ...] = branch[0] # type: ignore
852
+ key_depth: int = len(key) - 1
853
+ change_symbol: str = { 'added': '(+)', 'changed': '(~)', 'removed': '(-)', 'decrypted': '(!)', None: '(=)' }[branch[1]]
854
+ change_color : str = {
855
+ 'added': Color.TREE_ADDED, 'changed': Color.TREE_CHANGED, 'removed': Color.TREE_REMOVED,
856
+ 'decrypted': Color.TREE_DECRYPTED, None: Color.TREE_UNCHANGED
857
+ }[branch[1]]
858
+ # Print with color and symbol
859
+ _next_depth: int = -1 if (_index + 1) == len(branches) else len(branches[_index + 1][0]) - 1
860
+ d0_line: str = (('├' if _index < (len(branches) - 1) else '└' ) + '──') if key_depth == 0 else '│'
861
+ prefix: str = f"{ d0_line }{ ' ' * key_depth }{ '' if key_depth == 0 else ('└─' if _next_depth != key_depth else '├─') } "
862
+ print(prefix, Color.TREE_UNCHANGED, end='')
863
+ print(f"{ change_symbol } { key[-1] }", change_color)
864
+
865
+ # File-Daemon command
866
+
867
+ if config.command == 'file-daemon':
868
+ target_path: str = os.path.abspath(config.target_root)
869
+ if os.path.exists(target_path):
870
+ if config.force:
871
+ rmtree(target_path, ignore_errors=True)
872
+ else:
873
+ raise FileExistsError(f"Cannot mount sync root to { target_path } as the path already exist")
874
+ # Resolve sources
875
+ if config.include_default_dirs:
876
+ for dir_path in ( 'host_vars', 'group_vars', 'vars' ):
877
+ if os.path.isdir(dir_path):
878
+ config.sources.append([ os.path.abspath(dir_path), dir_path ])
879
+ sources: set[tuple[str, str]] = {
880
+ ( resolve_vault_path(path, allow_dirs=True), os.path.abspath(os.path.join(target_path, subtarget)) ) for path, subtarget in config.sources
881
+ }
882
+ # Validity checks
883
+ for path, subtarget in sources:
884
+ if not Path(subtarget).is_relative_to(target_path):
885
+ raise ValueError(f"Target subpath must be a path that is relative or inside the target root path")
886
+ for _path, _target in sources:
887
+ if _target == subtarget and _path != path:
888
+ raise ValueError(f"Sources may not have the same target subpath, found identical target for { _path } and { path }")
889
+ # Create target dir
890
+ _target_tmp_path = mkdtemp()
891
+ os.rename(_target_tmp_path, target_path)
892
+ # Cleanup handler
893
+ def _cleanup_daemons(daemons) -> None:
894
+ print('\nStopping file daemons and cleaning up files...')
895
+ [ daemon.stop(delete=False) for daemon in daemons ] # type: ignore
896
+ rmtree(target_path, ignore_errors=True)
897
+ print('Goodbye.')
898
+ # Create daemons
899
+ def _error_callback(daemon: VaultDaemon, operation: str, err: Exception) -> None:
900
+ print(f"An error occurred in { daemon } during { operation } operation:", Color.BAD)
901
+ print(err, Color.BAD)
902
+ def _debug_out(daemon: VaultDaemon, msg: Any) -> None:
903
+ debug(f"FileDaemon({ daemon.target_file if daemon.target_file else daemon.target_dir }): { msg }")
904
+ daemons: list[VaultDaemon] = [
905
+ VaultDaemon(path, target, keyring, recurse=config.recurse, error_callback=_error_callback, debug_out=_debug_out)
906
+ for path, target in sources
907
+ ]
908
+ atexit.register(_cleanup_daemons, daemons=daemons)
909
+ # Extra interrupt handler to silence error
910
+ signal.signal(signal.SIGINT, lambda *_: exit(0))
911
+ # Start file daemons
912
+ [ daemon.start(stop_on_exit=False) for daemon in daemons ]
913
+ print(f"{ len(daemons) } file daemons are running.", Color.GOOD)
914
+ print('Interrupt the program to stop them and delete the target directory.')
915
+ # Idle
916
+ try:
917
+ while True:
918
+ sleep(1)
919
+ except KeyboardInterrupt:
920
+ exit(0)
921
+
922
+ # Get & Set & Del commands
923
+
924
+ if config.command in [ 'get', 'set', 'del' ]:
925
+ vault_path: str = resolve_vault_path(config.vault_path)
926
+ vault = VaultFile(vault_path, keyring=keyring)
927
+ key: tuple[Hashable] = resolve_key_path(config.key_segments)
928
+ # Get command
929
+ if config.command == 'get':
930
+ value: Any = vault.get(key, default=Unset, decrypt=config.decrypt_value)
931
+ if type(value) is EncryptedVar:
932
+ value = value.cipher
933
+ if type(value) is ProtoEncryptedVar:
934
+ value = value.plaintext
935
+ # Output JSON
936
+ if config.as_json:
937
+ if value is Unset:
938
+ exit(100)
939
+ # Custom decoder for encrypted vars
940
+ def _decode_vars(obj) -> Any:
941
+ if type(obj) is EncryptedVar:
942
+ return obj.cipher
943
+ if type(obj) is ProtoEncryptedVar:
944
+ return obj.plaintext
945
+ raise TypeError(f"{ type(obj) } cannot be serialized into JSON")
946
+ json_code: str = json.dumps(value, default=_decode_vars, indent=2)
947
+ print_json(json_code)
948
+ # Output text
949
+ else:
950
+ print(f"Key: { format_key_path(key) }")
951
+ if value is Unset:
952
+ print('The key could not be found in the vault.', Color.MEH)
953
+ else:
954
+ yaml_code: str = vault._dump_to_str(value).strip('\n') if isinstance(value, dict | list | tuple) else str(value)
955
+ std_print()
956
+ print_yaml(yaml_code)
957
+ # Set & Del command
958
+ else:
959
+ @logger.log_changes(vault, comment=f"{ config.command } command via CLI", enable=log_enabled)
960
+ def _set_del() -> None:
961
+ old_vault: VaultFile = vault.copy()
962
+ if not getattr(config, 'quiet', False):
963
+ print(f"Key: { format_key_path(key) }")
964
+ # Set command
965
+ if config.command == 'set':
966
+ value: Any = yaml.safe_load(config.value)
967
+ vault.set(key, value, overwrite=True, create_parents=True, encrypt=config.encrypt_value)
968
+ vault.save()
969
+ print('Value has been set.\n', Color.GOOD)
970
+ # Del command
971
+ else:
972
+ result: Any = vault.pop(key, default=Unset)
973
+ if config.quiet:
974
+ vault.save()
975
+ exit(100 if result is Unset else 0)
976
+ elif result is Unset:
977
+ print('The key could not be found in the vault.', Color.MEH)
978
+ exit(0)
979
+ else:
980
+ print('The key has been deleted.\n', Color.GOOD)
981
+ vault.save()
982
+ print_diff(vault.diff(old_vault, show_filenames=True))
983
+ _set_del()