infraclass 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: infraclass
3
+ Version: 1.0.0
4
+ Summary: A fast, predictable, top-down configuration inventory compiler.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pyyaml>=6.0
8
+
9
+ # Infraclass
10
+
11
+ A fast, predictable, top-down configuration inventory compiler for infrastructure management workflows.
@@ -0,0 +1,3 @@
1
+ # Infraclass
2
+
3
+ A fast, predictable, top-down configuration inventory compiler for infrastructure management workflows.
@@ -0,0 +1 @@
1
+ from .compiler import compile_node_data
@@ -0,0 +1,97 @@
1
+ # infraclass/cli.py
2
+ import os, sys, yaml, argparse, getpass, secrets, string
3
+ from datetime import datetime
4
+ import infraclass.compiler as xc
5
+
6
+ class NoAliasDumper(yaml.SafeDumper):
7
+ def ignore_aliases(self, data): return True
8
+
9
+ yaml.add_representer(str, lambda d, data: d.represent_scalar('tag:yaml.org,2002:str', data, style='|' if '\n' in data else None), Dumper=NoAliasDumper)
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description="Infraclass: Top-Down Infrastructure Inventory Compiler")
13
+ parser.add_argument("-b", "--base-dir", default="reclass", help="Path to reclass root folder")
14
+ parser.add_argument("node", nargs="?", help="Name of the node to compile")
15
+ parser.add_argument("--add-secret", action="store_true", help="Add a new encrypted value securely")
16
+ parser.add_argument("-f", "--file", help="Optional path to secret file input (used with --add-secret)")
17
+ parser.add_argument("--remove-secret", metavar="ID", help="Remove a tracking key from the vault")
18
+ parser.add_argument("--show-secret", metavar="ID", help="Reveal the plaintext value of a tracking key")
19
+ parser.add_argument("--re-encrypt", action="store_true", help="Re-encrypt the vault using the current recipients file")
20
+ parser.add_argument("--audit-secrets", action="store_true", help="Check for stale or missing configuration secrets")
21
+ args = parser.parse_args()
22
+
23
+ try:
24
+ # ----------------------------------------------------
25
+ # VAULT MANAGEMENT ROUTES
26
+ # ----------------------------------------------------
27
+ if args.add_secret:
28
+ if not sys.stdin.isatty():
29
+ secret_value = sys.stdin.read().strip()
30
+ if not secret_value: raise ValueError("Piped input stream was empty.")
31
+ print("--> Reading secret value directly from piped input.")
32
+ elif args.file:
33
+ with open(args.file, "r") as f: secret_value = f.read().strip()
34
+ else:
35
+ secret_value = getpass.getpass("Enter passphrase (leave empty to autogenerate a secure one): ").strip()
36
+ if not secret_value:
37
+ secret_value = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32))
38
+ print("--> No passphrase provided. Autogenerated a secure 32-character secret.")
39
+
40
+ new_id = xc.add_secret_to_vault(secret_value)
41
+ print(f"\nMaster vault decrypted safely via in-memory stream.\nGenerated unique tracking identifier: {new_id}\nRe-encrypted master vault back to vault/vault.age successfully.\n\nPaste this reference identifier into your reclass configuration:\n !secret {new_id}")
42
+
43
+ elif args.remove_secret:
44
+ xc.remove_secret_from_vault(args.remove_secret)
45
+ print(f"Successfully purged tracker '{args.remove_secret}' from vault/vault.age.")
46
+
47
+ elif args.show_secret:
48
+ print(xc.show_secret_from_vault(args.show_secret))
49
+
50
+ elif args.re_encrypt:
51
+ xc.update_vault_recipients()
52
+ print("Successfully re-encrypted vault/vault.age using the current recipients file.")
53
+
54
+ elif args.audit_secrets:
55
+ stale, missing = xc.find_stale_secrets(base_dir=args.base_dir)
56
+ print(f"Auditing vault references inside '{args.base_dir}' against master vault...\n")
57
+ if stale:
58
+ print("Stale Secrets (Found in vault/vault.age but NEVER referenced in reclass files):")
59
+ for k in stale: print(f" - {k} (Safe to remove via: Infraclass --remove-secret {k})")
60
+ else:
61
+ print("✔ No stale secrets found in master vault.")
62
+ if missing:
63
+ print("\nMissing Secrets (Tagged as !secret in reclass but NOT found in vault/vault.age!):")
64
+ for m in missing: print(f" - {m}")
65
+
66
+ # ----------------------------------------------------
67
+ # COMPILATION ROUTE
68
+ # ----------------------------------------------------
69
+ elif args.node:
70
+ node_data = xc.compile_node_data(args.node, base_dir=args.base_dir)
71
+ hostname = node_data["node_name"]
72
+
73
+ output_blueprint = {
74
+ "__reclass__": {
75
+ "environment": node_data["environment"], "name": hostname, "node": hostname,
76
+ "timestamp": datetime.now().strftime("%a %b %d %H:%M:%S %Y"),
77
+ "uri": f"yaml_fs:///{os.path.abspath(args.base_dir)}/nodes/{hostname}.yml"
78
+ },
79
+ "applications": node_data["applications"], "classes": node_data["classes"],
80
+ "environment": node_data["environment"], "exports": node_data["exports"],
81
+ "parameters": {
82
+ "_reclass_": {"environment": node_data["environment"], "name": {"full": hostname, "short": hostname.split(".")[0]}},
83
+ **{k: node_data["parameters"][k] for k in sorted(node_data["parameters"].keys()) if k != "_reclass_"}
84
+ }
85
+ }
86
+ print(yaml.dump(output_blueprint, default_flow_style=False, sort_keys=False, Dumper=NoAliasDumper))
87
+
88
+ else:
89
+ parser.print_help()
90
+ sys.exit(1)
91
+
92
+ except Exception as e:
93
+ print(f"ERROR: {str(e)}", file=sys.stderr)
94
+ sys.exit(1)
95
+
96
+ if __name__ == "__main__":
97
+ main()
@@ -0,0 +1,203 @@
1
+ # infraclass/compiler.py
2
+ import os, subprocess, sys, yaml, uuid
3
+ from .merging import deep_merge, evaluate_rubouts
4
+
5
+ _COMPILER_CONTEXT = {"root_dir": os.getcwd()}
6
+ _VAULT_CACHE = None
7
+
8
+ # =====================================================================
9
+ # INTERNAL SECRETS ENGINE UTILITIES
10
+ # =====================================================================
11
+
12
+ def _get_vault_path():
13
+ """Traverses upward from the current working directory to discover vault/vault.age."""
14
+ project_root = os.getcwd()
15
+ while project_root and project_root != os.path.dirname(project_root):
16
+ if os.path.exists(os.path.join(project_root, "reclass")) or \
17
+ os.path.exists(os.path.join(project_root, "vault", "vault.age")):
18
+ break
19
+ project_root = os.path.dirname(project_root)
20
+ return os.path.join(project_root, "vault", "vault.age")
21
+
22
+ def _get_age_key_path():
23
+ """Resolves the age encryption key path based on systemic environment tags."""
24
+ creds_dir = os.environ.get("CREDENTIALS_DIRECTORY")
25
+ if creds_dir and os.path.exists(os.path.join(creds_dir, "age-pq-key")):
26
+ return os.path.join(creds_dir, "age-pq-key")
27
+ return os.environ.get("INFRACLASS_AGE_KEY_FILE", os.path.expanduser("~/.config/age/identity.age"))
28
+
29
+ def _crypt_vault(ciphertext=None, plaintext=None, key_path=None):
30
+ """Handles core in-memory age encryption and decryption subroutines."""
31
+ if not os.path.exists(key_path):
32
+ raise FileNotFoundError(f"Missing age identity key/envelope file at {key_path}")
33
+
34
+ if ciphertext is not None:
35
+ proc = subprocess.run(
36
+ ["age", "--decrypt", "-i", key_path],
37
+ input=ciphertext.strip() + "\n", capture_output=True, text=True, check=True
38
+ )
39
+ return yaml.load(proc.stdout, Loader=yaml.SafeLoader) or {}
40
+
41
+ vault_path = _get_vault_path()
42
+ recipients_file = os.path.join(os.path.dirname(vault_path), "recipients")
43
+ encrypt_flags = ["-R", recipients_file] if os.path.exists(recipients_file) else ["-i", key_path]
44
+
45
+ class VaultDumper(yaml.SafeDumper): pass
46
+ yaml.add_representer(str, lambda d, data: d.represent_scalar('tag:yaml.org,2002:str', data, style='|' if '\n' in data else None), Dumper=VaultDumper)
47
+
48
+ proc = subprocess.run(
49
+ ["age", "--encrypt"] + encrypt_flags + ["-a"],
50
+ input=yaml.dump(plaintext, default_flow_style=False, Dumper=VaultDumper),
51
+ capture_output=True, text=True, check=True
52
+ )
53
+ return proc.stdout
54
+
55
+ def _get_cached_vault():
56
+ """Decrypts the vault once and caches it in memory."""
57
+ global _VAULT_CACHE
58
+ if _VAULT_CACHE is not None:
59
+ return _VAULT_CACHE
60
+
61
+ vault_path = _get_vault_path()
62
+ if os.path.exists(vault_path):
63
+ with open(vault_path, "r") as f:
64
+ _VAULT_CACHE = _crypt_vault(ciphertext=f.read(), key_path=_get_age_key_path())
65
+ else:
66
+ _VAULT_CACHE = {}
67
+ return _VAULT_CACHE
68
+
69
+ def _write_vault(vault_dict, error_msg="Failed to write vault"):
70
+ """Helper subroutine to sync memory modifications cleanly out to disk."""
71
+ global _VAULT_CACHE
72
+ vault_path = _get_vault_path()
73
+ os.makedirs(os.path.dirname(vault_path), exist_ok=True)
74
+ try:
75
+ ciphertext = _crypt_vault(plaintext=vault_dict, key_path=_get_age_key_path())
76
+ with open(vault_path, "w") as f:
77
+ f.write(ciphertext)
78
+ _VAULT_CACHE = vault_dict
79
+ except subprocess.CalledProcessError as e:
80
+ raise RuntimeError(f"{error_msg}: {e.stderr.strip()}")
81
+
82
+ # =====================================================================
83
+ # PUBLIC CORE INTERFACE FUNCTIONS
84
+ # =====================================================================
85
+
86
+ def add_secret_to_vault(plaintext_value):
87
+ """Assigns a unique UUID identifier to a secret value, then commits it to the vault."""
88
+ vault_dict = _get_cached_vault()
89
+ while True:
90
+ new_uuid = str(uuid.uuid4())
91
+ if new_uuid not in vault_dict: break
92
+ vault_dict[new_uuid] = plaintext_value
93
+ _write_vault(vault_dict, "Failed to re-encrypt vault using age")
94
+ return new_uuid
95
+
96
+ def remove_secret_from_vault(lookup_key):
97
+ """Pops a specified secret tracking identifier out of the master vault mapping."""
98
+ vault_dict = _get_cached_vault()
99
+ clean_key = str(lookup_key).strip().strip("'\"")
100
+ if clean_key not in vault_dict:
101
+ raise KeyError(f"Secret key '{clean_key}' does not exist inside the vault.")
102
+ vault_dict.pop(clean_key)
103
+ _write_vault(vault_dict, "Failed to re-encrypt vault")
104
+
105
+ def show_secret_from_vault(lookup_key):
106
+ """Returns the plaintext configuration value behind an isolated reference ID."""
107
+ vault_dict = _get_cached_vault()
108
+ clean_key = str(lookup_key).strip().strip("'\"")
109
+ if clean_key not in vault_dict:
110
+ raise KeyError(f"Secret key '{clean_key}' does not exist inside the vault.")
111
+ return vault_dict[clean_key]
112
+
113
+ def update_vault_recipients():
114
+ """Forces an immediate rewrite pass of the vault data block to register updated keys."""
115
+ _write_vault(_get_cached_vault(), "Failed to re-encrypt vault with updated recipients")
116
+
117
+ def find_stale_secrets(base_dir="reclass"):
118
+ """Audits reclass inventory against vault.age to trace unreferenced or broken keys."""
119
+ referenced_keys = set()
120
+ class AuditLoader(yaml.SafeLoader): pass
121
+ AuditLoader.add_constructor('!secret', lambda loader, node: referenced_keys.add(str(loader.construct_scalar(node)).strip().strip("'\"")) or f"!secret {node.value}")
122
+
123
+ for root, _, files in os.walk(base_dir):
124
+ for file in files:
125
+ if file.endswith((".yml", ".yaml")):
126
+ try:
127
+ with open(os.path.join(root, file), "r") as f: yaml.load(f, Loader=AuditLoader)
128
+ except Exception: continue
129
+
130
+ vault_dict = _get_cached_vault()
131
+ vault_keys = set(str(k).strip() for k in vault_dict.keys())
132
+ return sorted(list(vault_keys - referenced_keys)), sorted(list(referenced_keys - vault_keys))
133
+
134
+ # =====================================================================
135
+ # PYYAML INVENTORY COMPILER HOOKS
136
+ # =====================================================================
137
+
138
+ def age_decrypt_constructor(loader, node):
139
+ """Dynamic parser tag constructor used during real-time reclass file loading phases."""
140
+ lookup_key = str(loader.construct_scalar(node)).strip().strip("'\"")
141
+ try:
142
+ vault_dict = _get_cached_vault()
143
+ return str(vault_dict.get(lookup_key, f"ERROR: Secret key '{lookup_key}' not found in vault.")).strip()
144
+ except Exception as e:
145
+ return f"ERROR: Infraclass vault breakdown: {str(e)}"
146
+
147
+ yaml.add_constructor('!secret', age_decrypt_constructor, Loader=yaml.SafeLoader)
148
+
149
+ # =====================================================================
150
+ # RECLASS COMPILATION LOGIC ENGINE
151
+ # =====================================================================
152
+
153
+ def load_yaml_file(filepath):
154
+ if not os.path.exists(filepath): return {}
155
+ with open(filepath, "r") as f: return yaml.load(f, Loader=yaml.SafeLoader) or {}
156
+
157
+ def resolve_class_path(classes_dir, class_name):
158
+ base_path = os.path.join(classes_dir, *class_name.split("."))
159
+ return base_path + ".yml" if os.path.exists(base_path + ".yml") else (os.path.join(base_path, "init.yml") if os.path.exists(os.path.join(base_path, "init.yml")) else None)
160
+
161
+ def compile_node_data(node_name, base_dir="reclass"):
162
+ classes_dir, nodes_dir = os.path.join(base_dir, "classes"), os.path.join(base_dir, "nodes")
163
+ filename = node_name if node_name.endswith((".yml", ".yaml")) else f"{node_name}.yml"
164
+ node_path = os.path.join(nodes_dir, filename)
165
+
166
+ if not os.path.exists(node_path): raise FileNotFoundError(f"Missing node definition file: {node_path}")
167
+
168
+ node_raw = load_yaml_file(node_path)
169
+ merged_parameters, param_origins = {}, {}
170
+ applications, final_ordered_classes, seen_classes = [], [], set()
171
+
172
+ def visit_class(class_name, parent="the Node file"):
173
+ if class_name in seen_classes: return
174
+ class_file = resolve_class_path(classes_dir, class_name)
175
+ if not class_file:
176
+ raise FileNotFoundError(f"\n[Infraclass Class Missing Error]\n➔ Could not resolve class path for: '{class_name}'\n - Requested by: {parent}")
177
+
178
+ class_data = load_yaml_file(class_file)
179
+ current_short_name = os.path.relpath(class_file, base_dir)
180
+
181
+ for sub_class in class_data.get("classes", []): visit_class(sub_class, parent=current_short_name)
182
+ for app in class_data.get("applications", []):
183
+ if app not in applications: applications.append(app)
184
+
185
+ deep_merge(merged_parameters, class_data.get("parameters", {}), current_context="", source_b=current_short_name, origin_map=param_origins)
186
+ if class_name not in final_ordered_classes: final_ordered_classes.append(class_name)
187
+ seen_classes.add(class_name)
188
+
189
+ for starting_class in node_raw.get("classes", []): visit_class(starting_class)
190
+ for app in node_raw.get("applications", []):
191
+ if app not in applications: applications.append(app)
192
+
193
+ short_node_name = os.path.relpath(node_path, base_dir)
194
+ deep_merge(merged_parameters, node_raw.get("parameters", {}), current_context="", source_b=short_node_name, origin_map=param_origins)
195
+
196
+ return {
197
+ "node_name": filename.rsplit(".yml", 1)[0].rsplit(".yaml", 1)[0],
198
+ "classes": final_ordered_classes,
199
+ "applications": applications,
200
+ "environment": node_raw.get("environment", "base"),
201
+ "exports": node_raw.get("exports", {}),
202
+ "parameters": evaluate_rubouts(merged_parameters)
203
+ }
@@ -0,0 +1,54 @@
1
+ def deep_merge(dict_a, dict_b, current_context="", source_b="Incoming", origin_map=None):
2
+ if origin_map is None:
3
+ origin_map = {}
4
+
5
+ for key, value in dict_b.items():
6
+ path = f"{current_context}.{key}" if current_context else str(key)
7
+
8
+ if key in dict_a:
9
+ type_a = type(dict_a[key])
10
+ type_b = type(value)
11
+
12
+ if isinstance(dict_a[key], dict) and isinstance(value, dict):
13
+ deep_merge(dict_a[key], value, current_context=path, source_b=source_b, origin_map=origin_map)
14
+
15
+ elif isinstance(dict_a[key], list) and isinstance(value, list):
16
+ dict_a[key].extend([x for x in value if x not in dict_a[key]])
17
+ origin_map[path] = source_b
18
+
19
+ elif type_a != type_b and dict_a[key] is not None and value is not None:
20
+ original_file = origin_map.get(path, "An earlier class")
21
+ raise TypeError(
22
+ f"\n[Wifty Data Type Mismatch Detected]\n"
23
+ f"➔ Parameter path: '{path}'\n"
24
+ f" - File 1 ({original_file}) defined a [{type_a.__name__}]:\n"
25
+ f" {dict_a[key]}\n"
26
+ f" - File 2 ({source_b}) tried to overwrite it with a [{type_b.__name__}]:\n"
27
+ f" {value}\n\n"
28
+ f"Fix: Check your class ordering or make sure you aren't missing dashes (-) or brackets []!"
29
+ )
30
+ else:
31
+ dict_a[key] = value
32
+ origin_map[path] = source_b
33
+ else:
34
+ dict_a[key] = value
35
+ origin_map[path] = source_b
36
+
37
+ return dict_a
38
+
39
+ def evaluate_rubouts(data):
40
+ if not isinstance(data, dict):
41
+ return data
42
+
43
+ processed = {}
44
+ for key, value in data.items():
45
+ evaluated_value = evaluate_rubouts(value)
46
+ if str(key).startswith('~'):
47
+ clean_key = str(key)[1:]
48
+ processed[clean_key] = evaluated_value
49
+ else:
50
+ if key in processed and isinstance(processed[key], dict) and isinstance(evaluated_value, dict):
51
+ processed[key].update(evaluated_value)
52
+ else:
53
+ processed[key] = evaluated_value
54
+ return processed
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: infraclass
3
+ Version: 1.0.0
4
+ Summary: A fast, predictable, top-down configuration inventory compiler.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pyyaml>=6.0
8
+
9
+ # Infraclass
10
+
11
+ A fast, predictable, top-down configuration inventory compiler for infrastructure management workflows.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ infraclass/__init__.py
4
+ infraclass/cli.py
5
+ infraclass/compiler.py
6
+ infraclass/merging.py
7
+ infraclass.egg-info/PKG-INFO
8
+ infraclass.egg-info/SOURCES.txt
9
+ infraclass.egg-info/dependency_links.txt
10
+ infraclass.egg-info/entry_points.txt
11
+ infraclass.egg-info/requires.txt
12
+ infraclass.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ infraclass = infraclass.cli:main
@@ -0,0 +1 @@
1
+ pyyaml>=6.0
@@ -0,0 +1 @@
1
+ infraclass
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "infraclass"
7
+ version = "1.0.0"
8
+ description = "A fast, predictable, top-down configuration inventory compiler."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "pyyaml>=6.0"
13
+ ]
14
+
15
+ [project.scripts]
16
+ infraclass = "infraclass.cli:main"
17
+
18
+ # Explicitly tell setuptools to look for your flat layout module folder
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["infraclass*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+