envstack 1.0.3__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.
- envstack/__init__.py +40 -0
- envstack/cli.py +478 -0
- envstack/config.py +108 -0
- envstack/encrypt.py +361 -0
- envstack/env.py +997 -0
- envstack/envshell.py +143 -0
- envstack/exceptions.py +82 -0
- envstack/logger.py +61 -0
- envstack/node.py +410 -0
- envstack/path.py +448 -0
- envstack/util.py +800 -0
- envstack-1.0.3.dist-info/LICENSE +12 -0
- envstack-1.0.3.dist-info/METADATA +177 -0
- envstack-1.0.3.dist-info/RECORD +17 -0
- envstack-1.0.3.dist-info/WHEEL +5 -0
- envstack-1.0.3.dist-info/entry_points.txt +3 -0
- envstack-1.0.3.dist-info/top_level.txt +1 -0
envstack/envshell.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
Contains envshell wrapper class.
|
|
34
|
+
|
|
35
|
+
Goal: drop the user into an interactive shell *without* sourcing their usual
|
|
36
|
+
shell rc files, so prompt vars (PS1/PROMPT) set by envstack can survive.
|
|
37
|
+
|
|
38
|
+
Notes:
|
|
39
|
+
- You can override the detected shell with ENVSTACK_SHELL.
|
|
40
|
+
Examples:
|
|
41
|
+
ENVSTACK_SHELL=/bin/zsh envstack --shell
|
|
42
|
+
ENVSTACK_SHELL=pwsh envstack --shell
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import List
|
|
48
|
+
|
|
49
|
+
from . import config
|
|
50
|
+
from .wrapper import Wrapper
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _basename(p: str) -> str:
|
|
54
|
+
"""Return the lowercase basename of a path, robustly."""
|
|
55
|
+
try:
|
|
56
|
+
return Path(p).name.lower()
|
|
57
|
+
except Exception:
|
|
58
|
+
return os.path.basename(p).lower()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _detect_shell_argv() -> List[str]:
|
|
62
|
+
"""
|
|
63
|
+
Return argv list for a *clean* interactive shell.
|
|
64
|
+
"""
|
|
65
|
+
shell = os.environ.get("ENVSTACK_SHELL", config.SHELL)
|
|
66
|
+
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
# Prefer COMSPEC if it looks like cmd.exe; otherwise allow pwsh/powershell.
|
|
69
|
+
comspec = os.environ.get("COMSPEC", "cmd.exe")
|
|
70
|
+
|
|
71
|
+
# If user explicitly asked for pwsh/powershell, honor it.
|
|
72
|
+
base = _basename(shell)
|
|
73
|
+
if base in ("pwsh", "pwsh.exe"):
|
|
74
|
+
return [shell, "-NoExit", "-NoProfile"]
|
|
75
|
+
if base in ("powershell", "powershell.exe"):
|
|
76
|
+
return [shell, "-NoExit", "-NoProfile"]
|
|
77
|
+
|
|
78
|
+
# Otherwise use cmd.exe, with /K to keep it open.
|
|
79
|
+
# (Even if config.SHELL returned "cmd", use COMSPEC so we get the real path.)
|
|
80
|
+
return [comspec, "/K"]
|
|
81
|
+
|
|
82
|
+
# POSIX shells
|
|
83
|
+
base = _basename(shell)
|
|
84
|
+
|
|
85
|
+
# bash: skip /etc/profile, ~/.bash_profile, ~/.bashrc, but stay interactive
|
|
86
|
+
if base == "bash":
|
|
87
|
+
return [shell, "--noprofile", "--norc", "-i"]
|
|
88
|
+
|
|
89
|
+
# zsh: -f skips zshrcs; -i for interactive
|
|
90
|
+
if base == "zsh":
|
|
91
|
+
return [shell, "-f", "-i"]
|
|
92
|
+
|
|
93
|
+
# tcsh/csh: -f skips rc; interactive by default when attached to a tty
|
|
94
|
+
if base in ("tcsh", "csh"):
|
|
95
|
+
return [shell, "-f"]
|
|
96
|
+
|
|
97
|
+
# fish: --no-config skips config.fish; interactive by default
|
|
98
|
+
if base == "fish":
|
|
99
|
+
return [shell, "--no-config"]
|
|
100
|
+
|
|
101
|
+
# Fallback: try interactive flag if common; otherwise just exec the shell
|
|
102
|
+
# (Most shells become interactive when connected to a tty anyway.)
|
|
103
|
+
return [shell, "-i"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class EnvshellWrapper(Wrapper):
|
|
107
|
+
"""A wrapper that spawns an interactive shell with the environment set."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, *args, **kwargs):
|
|
110
|
+
super(EnvshellWrapper, self).__init__(*args, **kwargs)
|
|
111
|
+
self.shell = False # exec the shell directly
|
|
112
|
+
|
|
113
|
+
def executable(self):
|
|
114
|
+
"""
|
|
115
|
+
Kept for interface compatibility. The actual argv is produced in
|
|
116
|
+
get_subprocess_command().
|
|
117
|
+
"""
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
def get_subprocess_command(self, env):
|
|
121
|
+
"""
|
|
122
|
+
Override to return argv list for subprocess.Popen(..., shell=False).
|
|
123
|
+
"""
|
|
124
|
+
return _detect_shell_argv()
|
|
125
|
+
|
|
126
|
+
def get_shell_prompt(self) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Return the environment variable that controls the shell prompt and its
|
|
129
|
+
desired value.
|
|
130
|
+
"""
|
|
131
|
+
if os.name == "nt":
|
|
132
|
+
return ("PROMPT", "$E[32m(${ENV:=${STACK}})$E[0m $P$G ")
|
|
133
|
+
else:
|
|
134
|
+
return ("PS1", r"\[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$ ")
|
|
135
|
+
|
|
136
|
+
def get_subprocess_env(self):
|
|
137
|
+
"""
|
|
138
|
+
Override to inject PS1/PROMPT if not already set.
|
|
139
|
+
"""
|
|
140
|
+
prompt_env, prompt_value = self.get_shell_prompt()
|
|
141
|
+
if prompt_env not in self.env:
|
|
142
|
+
self.env[prompt_env] = prompt_value
|
|
143
|
+
return super().get_subprocess_env()
|
envstack/exceptions.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
__doc__ = """
|
|
33
|
+
Contains custom exceptions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CyclicalReference(Exception):
|
|
38
|
+
"""Exception for circular references."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DuplicateFieldValues(Exception):
|
|
44
|
+
"""Exception for template paths with duplicate values for the same field."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidPath(Exception):
|
|
50
|
+
"""Exception for paths that do not match a template."""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvalidSource(Exception):
|
|
56
|
+
"""Exception for invalid .env file."""
|
|
57
|
+
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvalidSyntax(Exception):
|
|
62
|
+
"""Exception for invalid .env file syntax."""
|
|
63
|
+
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MissingFieldError(Exception):
|
|
68
|
+
"""Custom exception class for missing required field values."""
|
|
69
|
+
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TemplateNotFound(Exception):
|
|
74
|
+
"""Custom exception class for missing Templates."""
|
|
75
|
+
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class WriteError(Exception):
|
|
80
|
+
"""Custom exception class for errors during export operations."""
|
|
81
|
+
|
|
82
|
+
pass
|
envstack/logger.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
|
|
34
|
+
from envstack.config import LOG_LEVEL
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Logger(logging.Logger):
|
|
38
|
+
"""Custom logger class."""
|
|
39
|
+
|
|
40
|
+
def setLevel(self, level):
|
|
41
|
+
if isinstance(level, str):
|
|
42
|
+
level = getattr(logging, level.upper(), logging.NOTSET)
|
|
43
|
+
super().setLevel(level)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
log = Logger("envstack")
|
|
47
|
+
log.setLevel(LOG_LEVEL)
|
|
48
|
+
log.addHandler(logging.NullHandler())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_stream_handler():
|
|
52
|
+
"""Adds a new stdout stream handler."""
|
|
53
|
+
for h in log.handlers:
|
|
54
|
+
if h.name == log.name and "StreamHandler" in str(h):
|
|
55
|
+
del log.handlers[log.handlers.index(h)]
|
|
56
|
+
stream_hanlder = logging.StreamHandler()
|
|
57
|
+
stream_hanlder.set_name(log.name)
|
|
58
|
+
stream_hanlder.setFormatter(
|
|
59
|
+
logging.Formatter("%(asctime)s:%(name)s:%(levelname)s - %(message)s")
|
|
60
|
+
)
|
|
61
|
+
log.addHandler(stream_hanlder)
|
envstack/node.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
__doc__ = """
|
|
33
|
+
Contains custom yaml constructor classes and functions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import string
|
|
40
|
+
|
|
41
|
+
import yaml
|
|
42
|
+
|
|
43
|
+
from envstack import util
|
|
44
|
+
from envstack.encrypt import AESGCMEncryptor, Base64Encryptor, FernetEncryptor
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Template(string.Template, str):
|
|
48
|
+
def __init__(self, value):
|
|
49
|
+
super().__init__(value)
|
|
50
|
+
self.value = value
|
|
51
|
+
|
|
52
|
+
def __repr__(self):
|
|
53
|
+
return f"Template(value={self.value})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BaseNode(yaml.YAMLObject):
|
|
57
|
+
"""Base class for custom yaml nodes."""
|
|
58
|
+
|
|
59
|
+
yaml_tag = None
|
|
60
|
+
|
|
61
|
+
def __init__(self, value):
|
|
62
|
+
self.value = value
|
|
63
|
+
|
|
64
|
+
def __repr__(self):
|
|
65
|
+
return f"{self.__class__.__name__}('{self.value}')"
|
|
66
|
+
|
|
67
|
+
def __str__(self):
|
|
68
|
+
return str(self.value)
|
|
69
|
+
|
|
70
|
+
def __eq__(self, other):
|
|
71
|
+
if isinstance(other, self.__class__):
|
|
72
|
+
return self.value == other.value
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_yaml(cls, loader: object, node: yaml.Node):
|
|
77
|
+
return cls(node.value)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def to_yaml(cls, dumper: object, node: yaml.Node):
|
|
81
|
+
return dumper.represent_scalar(cls.yaml_tag, node.value)
|
|
82
|
+
|
|
83
|
+
def resolve(self, env: dict = os.environ):
|
|
84
|
+
"""Returns the decoded value."""
|
|
85
|
+
return self.value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Base64Node(BaseNode):
|
|
89
|
+
"""Base64 encoded string node."""
|
|
90
|
+
|
|
91
|
+
yaml_tag = "!base64"
|
|
92
|
+
|
|
93
|
+
def __init__(self, value):
|
|
94
|
+
super().__init__(value)
|
|
95
|
+
self.original_value = None
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_yaml(cls, loader: object, node: yaml.Node):
|
|
99
|
+
"""Returns a new Base64Node instance."""
|
|
100
|
+
node = cls(node.value)
|
|
101
|
+
node.original_value = node.value
|
|
102
|
+
return node
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def to_yaml(cls, dumper: object, node: yaml.Node):
|
|
106
|
+
"""Encrypts the value before writing to yaml."""
|
|
107
|
+
if node.value == node.original_value:
|
|
108
|
+
encrypted = node.value
|
|
109
|
+
elif isinstance(node.value, cls):
|
|
110
|
+
encrypted = str(node.value)
|
|
111
|
+
else:
|
|
112
|
+
encrypted = Base64Encryptor().encrypt(node.value)
|
|
113
|
+
return dumper.represent_scalar(cls.yaml_tag, encrypted)
|
|
114
|
+
|
|
115
|
+
def resolve(self, env: dict = os.environ):
|
|
116
|
+
"""Returns base64 decoded value."""
|
|
117
|
+
return Base64Encryptor().decrypt(self.value)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MD5Node(BaseNode):
|
|
121
|
+
"""MD5 hash node."""
|
|
122
|
+
|
|
123
|
+
yaml_tag = "!md5"
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_yaml(cls, loader: object, node: yaml.Node):
|
|
127
|
+
"""Returns a new MD5Node instance."""
|
|
128
|
+
return cls(node.value)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def to_yaml(cls, dumper: object, node: yaml.Node):
|
|
132
|
+
"""Encrypts the value before writing to yaml."""
|
|
133
|
+
md5_hash = hashlib.md5(node.value.encode()).hexdigest()
|
|
134
|
+
return dumper.represent_scalar(cls.yaml_tag, md5_hash)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class EncryptedNode(BaseNode):
|
|
138
|
+
"""Default encryption node. Supports multiple encryption schemes.
|
|
139
|
+
Favors AES-GCM, then Fernet, then base64."""
|
|
140
|
+
|
|
141
|
+
yaml_tag = "!encrypt"
|
|
142
|
+
|
|
143
|
+
def __init__(self, value):
|
|
144
|
+
super().__init__(value)
|
|
145
|
+
self.original_value = None
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def encryptor(cls, env: dict = os.environ):
|
|
149
|
+
"""Returns the encryptor class based on the environment."""
|
|
150
|
+
if env.get(AESGCMEncryptor.KEY_VAR_NAME):
|
|
151
|
+
return AESGCMEncryptor(env=env)
|
|
152
|
+
elif env.get(FernetEncryptor.KEY_VAR_NAME):
|
|
153
|
+
return FernetEncryptor(env=env)
|
|
154
|
+
else:
|
|
155
|
+
return Base64Encryptor()
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_yaml(cls, loader: object, node: yaml.Node):
|
|
159
|
+
"""Returns a new EncryptedNode instance."""
|
|
160
|
+
node = cls(node.value)
|
|
161
|
+
node.original_value = node.value
|
|
162
|
+
return node
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def to_yaml(cls, dumper: object, node: yaml.Node):
|
|
166
|
+
"""Encrypts the value before writing (do not double encrypt)."""
|
|
167
|
+
if node.value == node.original_value:
|
|
168
|
+
encrypted = node.value
|
|
169
|
+
elif isinstance(node.value, cls):
|
|
170
|
+
encrypted = str(node.value)
|
|
171
|
+
else:
|
|
172
|
+
encrypted = cls.encryptor().encrypt(node.value)
|
|
173
|
+
return dumper.represent_scalar(cls.yaml_tag, encrypted)
|
|
174
|
+
|
|
175
|
+
def resolve(self, env: dict = os.environ):
|
|
176
|
+
"""Returns the decrypted original value, preserving original type.
|
|
177
|
+
|
|
178
|
+
:param env: environment with encryption keys.
|
|
179
|
+
:return: decrypted value.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
value = self.encryptor(env=env).decrypt(self.value)
|
|
183
|
+
except Exception:
|
|
184
|
+
value = self.value
|
|
185
|
+
return util.safe_eval(value)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AESGCMNode(BaseNode):
|
|
189
|
+
"""Default encrypted node using AES-GCM."""
|
|
190
|
+
|
|
191
|
+
yaml_tag = "!aesgcm"
|
|
192
|
+
|
|
193
|
+
def __init__(self, value):
|
|
194
|
+
super().__init__(value)
|
|
195
|
+
self.original_value = None
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def from_yaml(cls, loader: object, node: yaml.Node):
|
|
199
|
+
"""Returns a new AESGCMNode instance."""
|
|
200
|
+
node = cls(node.value)
|
|
201
|
+
node.original_value = node.value
|
|
202
|
+
return node
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def to_yaml(cls, dumper: object, node: yaml.Node):
|
|
206
|
+
"""Encrypts the value before writing to yaml."""
|
|
207
|
+
if node.value == node.original_value:
|
|
208
|
+
encrypted = node.value
|
|
209
|
+
elif isinstance(node.value, cls):
|
|
210
|
+
encrypted = str(node.value)
|
|
211
|
+
else:
|
|
212
|
+
encrypted = AESGCMEncryptor().encrypt(node.value)
|
|
213
|
+
return dumper.represent_scalar(cls.yaml_tag, encrypted)
|
|
214
|
+
|
|
215
|
+
def resolve(self, env: dict = os.environ):
|
|
216
|
+
"""Returns the decrypted value."""
|
|
217
|
+
return AESGCMEncryptor(env=env).decrypt(self.value)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class FernetNode(BaseNode):
|
|
221
|
+
"""Default encrypted node using Fernet."""
|
|
222
|
+
|
|
223
|
+
yaml_tag = "!fernet"
|
|
224
|
+
|
|
225
|
+
def __init__(self, value):
|
|
226
|
+
super().__init__(value)
|
|
227
|
+
self.original_value = None
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_yaml(cls, loader: object, node: yaml.Node):
|
|
231
|
+
"""Returns a new FernetNode instance."""
|
|
232
|
+
node = cls(node.value)
|
|
233
|
+
node.original_value = node.value
|
|
234
|
+
return node
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def to_yaml(cls, dumper: object, node: yaml.Node):
|
|
238
|
+
"""Encrypts the value before writing to yaml."""
|
|
239
|
+
if node.value == node.original_value:
|
|
240
|
+
encrypted = node.value
|
|
241
|
+
elif isinstance(node.value, cls):
|
|
242
|
+
encrypted = str(node.value)
|
|
243
|
+
else:
|
|
244
|
+
encrypted = FernetEncryptor().encrypt(node.value)
|
|
245
|
+
return dumper.represent_scalar(cls.yaml_tag, encrypted)
|
|
246
|
+
|
|
247
|
+
def resolve(self, env: dict = os.environ):
|
|
248
|
+
"""Returns the decrypted value."""
|
|
249
|
+
return FernetEncryptor(env=env).decrypt(self.value)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class CustomLoader(yaml.SafeLoader):
|
|
253
|
+
"""Custom Loader class to preserve order of keys and ensure required
|
|
254
|
+
keys are first in the mapping."""
|
|
255
|
+
|
|
256
|
+
required_keys = {"include", "all", "darwin", "linux", "windows"}
|
|
257
|
+
|
|
258
|
+
def construct_mapping(self, node: yaml.Node, deep: bool = False):
|
|
259
|
+
"""Construct a mapping from a YAML node, preserving the order of keys
|
|
260
|
+
and ensuring"""
|
|
261
|
+
# keep YAML merge keys (<<) working
|
|
262
|
+
self.flatten_mapping(node)
|
|
263
|
+
|
|
264
|
+
mapping = {}
|
|
265
|
+
|
|
266
|
+
for key_node, value_node in node.value:
|
|
267
|
+
# never implicit-resolve scalar KEYS (YES/NO/ON/OFF/null/date/etc)
|
|
268
|
+
if isinstance(key_node, yaml.ScalarNode):
|
|
269
|
+
key = key_node.value
|
|
270
|
+
else:
|
|
271
|
+
key = self.construct_object(key_node, deep=deep)
|
|
272
|
+
|
|
273
|
+
value = self.construct_object(value_node, deep=deep)
|
|
274
|
+
mapping[key] = value
|
|
275
|
+
|
|
276
|
+
# preserve order of keys and ensure required keys are first in the mapping
|
|
277
|
+
for key, value in list(mapping.items()):
|
|
278
|
+
if key in self.required_keys:
|
|
279
|
+
continue
|
|
280
|
+
mapping[key] = value
|
|
281
|
+
|
|
282
|
+
return mapping
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class CustomDumper(yaml.SafeDumper):
|
|
286
|
+
"""
|
|
287
|
+
Custom Dumper class to handle anchors, references and flow style for nested
|
|
288
|
+
mappings.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
_VAR_LIKE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}")
|
|
292
|
+
|
|
293
|
+
def __init__(self, *args, **kwargs):
|
|
294
|
+
super(CustomDumper, self).__init__(*args, **kwargs)
|
|
295
|
+
self.depth = 0
|
|
296
|
+
self.basekey = None
|
|
297
|
+
self.newanchors = {}
|
|
298
|
+
|
|
299
|
+
def anchor_node(self, node: yaml.Node):
|
|
300
|
+
"""Anchor the node and set the basekey for the node.
|
|
301
|
+
|
|
302
|
+
:param node: yaml node to anchor.
|
|
303
|
+
"""
|
|
304
|
+
# increase depth on entering anchor_node
|
|
305
|
+
self.depth += 1
|
|
306
|
+
|
|
307
|
+
# set basekey for the node
|
|
308
|
+
if self.depth == 2:
|
|
309
|
+
assert isinstance(node, yaml.ScalarNode), (
|
|
310
|
+
"yaml node not a string: %s" % node
|
|
311
|
+
)
|
|
312
|
+
self.basekey = str(node.value)
|
|
313
|
+
node.value = self.basekey
|
|
314
|
+
|
|
315
|
+
# set anchor for the node
|
|
316
|
+
if self.depth == 3:
|
|
317
|
+
assert self.basekey, "could not find base key for value: %s" % node
|
|
318
|
+
self.newanchors[node] = self.basekey
|
|
319
|
+
|
|
320
|
+
super(CustomDumper, self).anchor_node(node)
|
|
321
|
+
if self.newanchors:
|
|
322
|
+
self.anchors.update(self.newanchors)
|
|
323
|
+
self.newanchors.clear()
|
|
324
|
+
|
|
325
|
+
def sanitize_value(self, node: yaml.Node):
|
|
326
|
+
"""Sanitize node value by quoting it if it starts with special characters
|
|
327
|
+
or contains a colon followed by a space, e.g. "@var", "|value", "key: value".
|
|
328
|
+
|
|
329
|
+
:param node: yaml node to sanitize.
|
|
330
|
+
"""
|
|
331
|
+
if isinstance(node, yaml.ScalarNode):
|
|
332
|
+
if node.value and node.value[0] in "@|?,%`&":
|
|
333
|
+
node.style = '"%s"' % node.value
|
|
334
|
+
elif node.value and node.value[-1] == ":":
|
|
335
|
+
node.style = '"%s"' % node.value
|
|
336
|
+
elif ": " in str(node.value):
|
|
337
|
+
node.style = '"%s"' % node.value
|
|
338
|
+
|
|
339
|
+
def quote_vars(self, node: yaml.Node):
|
|
340
|
+
"""Quote embedded variables the node value, e.g. LIST: [1, 2, 3, ${VAR}].
|
|
341
|
+
|
|
342
|
+
:param node: yaml node to quote.
|
|
343
|
+
"""
|
|
344
|
+
if isinstance(node, yaml.ScalarNode):
|
|
345
|
+
if self._VAR_LIKE.match(node.value):
|
|
346
|
+
node.style = '"%s"' % node.value
|
|
347
|
+
|
|
348
|
+
def represent_data(self, data: dict):
|
|
349
|
+
"""Represent data and set flow_style for nested mappings.
|
|
350
|
+
|
|
351
|
+
:param data: Data to represent.
|
|
352
|
+
:return: yaml node.
|
|
353
|
+
"""
|
|
354
|
+
# increase depth on entering represent_data
|
|
355
|
+
self.depth += 1
|
|
356
|
+
node = super().represent_data(data)
|
|
357
|
+
self.depth -= 1
|
|
358
|
+
|
|
359
|
+
# use flow style for nested mappings
|
|
360
|
+
if isinstance(node, yaml.MappingNode) and self.depth >= 2:
|
|
361
|
+
node.flow_style = True
|
|
362
|
+
for _, value in node.value:
|
|
363
|
+
self.quote_vars(value)
|
|
364
|
+
elif isinstance(node, yaml.SequenceNode) and self.depth >= 2:
|
|
365
|
+
node.flow_style = True
|
|
366
|
+
for element in node.value:
|
|
367
|
+
self.quote_vars(element)
|
|
368
|
+
elif isinstance(node, yaml.ScalarNode):
|
|
369
|
+
self.sanitize_value(node)
|
|
370
|
+
|
|
371
|
+
return node
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def get_keys_from_env(env: dict = os.environ):
|
|
375
|
+
"""Return encryption keys from the environment.
|
|
376
|
+
|
|
377
|
+
:param env: Environment dictionary.
|
|
378
|
+
"""
|
|
379
|
+
keys = {}
|
|
380
|
+
for key in [AESGCMEncryptor.KEY_VAR_NAME, FernetEncryptor.KEY_VAR_NAME]:
|
|
381
|
+
if key in env:
|
|
382
|
+
keys[key] = env[key]
|
|
383
|
+
return keys
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def add_custom_node_type(node_type: BaseNode):
|
|
387
|
+
"""Add custom node type to yaml. Node type must be a subclass of BaseNode,
|
|
388
|
+
with local implementation of from_yaml and to_yaml methods, and definition
|
|
389
|
+
of yaml_tag.
|
|
390
|
+
|
|
391
|
+
:param node_type: Custom node class.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
yaml.SafeLoader.add_constructor(node_type.yaml_tag, node_type.from_yaml)
|
|
396
|
+
yaml.SafeDumper.add_representer(node_type, node_type.to_yaml)
|
|
397
|
+
except Exception as e:
|
|
398
|
+
print(f"error adding custom node type {node_type}: {e}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# add custom constructors and representers
|
|
402
|
+
custom_node_types = [
|
|
403
|
+
Base64Node,
|
|
404
|
+
EncryptedNode,
|
|
405
|
+
AESGCMNode,
|
|
406
|
+
FernetNode,
|
|
407
|
+
MD5Node,
|
|
408
|
+
]
|
|
409
|
+
for node in custom_node_types:
|
|
410
|
+
add_custom_node_type(node)
|