Reorgenized test structure and added validation of inventory before deployment

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-04 01:14:00 +02:00
parent fe04f1955f
commit a9f55579a2
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
37 changed files with 241 additions and 42 deletions

View File

@ -4,8 +4,9 @@ import argparse
import subprocess import subprocess
import os import os
import datetime import datetime
import sys
def run_ansible_playbook(inventory, playbook, modes, limit=None, password_file=None, verbose=0, skip_tests:bool=False): def run_ansible_playbook(inventory, playbook, modes, limit=None, password_file=None, verbose=0, skip_tests=False):
start_time = datetime.datetime.now() start_time = datetime.datetime.now()
print(f"\n▶️ Script started at: {start_time.isoformat()}\n") print(f"\n▶️ Script started at: {start_time.isoformat()}\n")
@ -94,6 +95,10 @@ def main():
"--skip-tests", action="store_true", "--skip-tests", action="store_true",
help="Skip running 'make test' even if tests are normally enabled." help="Skip running 'make test' even if tests are normally enabled."
) )
parser.add_argument(
"--skip-validation", action="store_true",
help="Skip inventory validation before deployment."
)
parser.add_argument( parser.add_argument(
"-v", "--verbose", action="count", default=0, "-v", "--verbose", action="count", default=0,
help="Increase verbosity level. Multiple -v flags increase detail (e.g., -vvv for maximum log output)." help="Increase verbosity level. Multiple -v flags increase detail (e.g., -vvv for maximum log output)."
@ -101,6 +106,17 @@ def main():
args = parser.parse_args() args = parser.parse_args()
if not args.skip_validation:
print("\n🔍 Validating inventory before deployment...\n")
try:
subprocess.run(
[sys.executable, os.path.join(script_dir, "validate_inventory.py"), os.path.dirname(args.inventory)],
check=True
)
except subprocess.CalledProcessError:
print("\n❌ Inventory validation failed. Deployment aborted.\n", file=sys.stderr)
sys.exit(1)
modes = { modes = {
"mode_reset": args.reset, "mode_reset": args.reset,
"mode_test": args.test, "mode_test": args.test,

View File

@ -46,6 +46,22 @@ def compare_application_keys(applications, defaults, source_file):
return errors return errors
def compare_user_keys(users, default_users, source_file):
errors = []
for username, user_conf in users.items():
if username not in default_users:
print(f"Warning: {source_file}: Unknown user '{username}' (not in default_users)", file=sys.stderr)
continue
default_conf = default_users.get(username, {})
for key in user_conf:
if key in ("password", "credentials", "mailu_token"):
continue # ignore credentials/password
if key not in default_conf:
raise Exception(f"{source_file}: Missing default for user '{username}': key '{key}'")
return errors
def load_inventory_files(inventory_dir): def load_inventory_files(inventory_dir):
all_data = {} all_data = {}
inventory_path = Path(inventory_dir) inventory_path = Path(inventory_dir)
@ -69,32 +85,51 @@ def load_inventory_files(inventory_dir):
return all_data return all_data
def find_defaults_applications_file(): def find_single_file(pattern):
candidates = list(Path("group_vars/all").glob("*_applications.yml")) candidates = list(Path("group_vars/all").glob(pattern))
if len(candidates) != 1: if len(candidates) != 1:
raise RuntimeError(f"Expected exactly one *_applications.yml file in group_vars/all, found {len(candidates)}") raise RuntimeError(f"Expected exactly one {pattern} file in group_vars/all, found {len(candidates)}")
return candidates[0] return candidates[0]
def main(): def main():
parser = argparse.ArgumentParser(description="Verify application variable consistency with defaults.") parser = argparse.ArgumentParser(description="Verify application and user variable consistency with defaults.")
parser.add_argument("inventory_dir", help="Path to inventory directory (contains inventory.yml and *_vars/)") parser.add_argument("inventory_dir", help="Path to inventory directory (contains inventory.yml and *_vars/)")
args = parser.parse_args() args = parser.parse_args()
defaults_path = find_defaults_applications_file() defaults_path = find_single_file("*_applications.yml")
users_path = find_single_file("*users.yml")
defaults_data = load_yaml_file(defaults_path) defaults_data = load_yaml_file(defaults_path)
default_users_data = load_yaml_file(users_path)
defaults = defaults_data.get("defaults_applications", {}) if defaults_data else {} defaults = defaults_data.get("defaults_applications", {}) if defaults_data else {}
default_users = default_users_data.get("default_users", {}) if default_users_data else {}
if not defaults: if not defaults:
print(f"Error: No 'defaults_applications' found in {defaults_path}.", file=sys.stderr) print(f"Error: No 'defaults_applications' found in {defaults_path}.", file=sys.stderr)
sys.exit(1) sys.exit(1)
if not default_users:
print(f"Error: No 'default_users' found in {users_path}.", file=sys.stderr)
sys.exit(1)
all_errors = [] all_errors = []
inventory_files = load_inventory_files(args.inventory_dir) inventory_files = load_inventory_files(args.inventory_dir)
for source_path, app_data in inventory_files.items(): for source_path, app_data in inventory_files.items():
errors = compare_application_keys(app_data, defaults, str(source_path)) errors = compare_application_keys(app_data, defaults, str(source_path))
all_errors.extend(errors) all_errors.extend(errors)
# Load all users.yml files from inventory
for path in Path(args.inventory_dir).rglob("*.yml"):
data = load_yaml_file(path)
if isinstance(data, dict) and "users" in data:
try:
compare_user_keys(data["users"], default_users, str(path))
except Exception as e:
print(e, file=sys.stderr)
sys.exit(1)
if all_errors: if all_errors:
print("Validation failed with the following issues:") print("Validation failed with the following issues:")
for err in all_errors: for err in all_errors:

View File

@ -0,0 +1,21 @@
import unittest
from pathlib import Path
class TestInitFiles(unittest.TestCase):
def test_all_test_dirs_have_init(self):
"""
Ensure every subdirectory in the 'tests' folder (excluding '__pycache__') contains an '__init__.py' file.
"""
tests_root = Path(__file__).resolve().parents[2] / "tests"
for path in tests_root.rglob("*"):
if path.is_dir() and "__pycache__" not in path.parts:
init_file = path / "__init__.py"
with self.subTest(directory=str(path.relative_to(tests_root))):
self.assertTrue(
init_file.exists(),
f"Missing __init__.py in directory: {path}"
)
if __name__ == "__main__":
unittest.main()

View File

View File

@ -5,7 +5,7 @@ from unittest import mock
# Ensure cli module is importable # Ensure cli module is importable
dir_path = os.path.abspath( dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../cli') os.path.join(os.path.dirname(__file__), '../../../cli')
) )
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)

View File

@ -43,7 +43,7 @@ class TestGenerateDefaultApplicationsUsers(unittest.TestCase):
When a users.yml exists with defined users, the script should inject a 'users' When a users.yml exists with defined users, the script should inject a 'users'
mapping in the generated YAML, mapping each username to a Jinja2 reference. mapping in the generated YAML, mapping each username to a Jinja2 reference.
""" """
script_path = Path(__file__).resolve().parents[2] / "cli" / "generate_applications.py" script_path = Path(__file__).resolve().parents[3] / "cli" / "generate_applications.py"
result = subprocess.run([ result = subprocess.run([
"python3", str(script_path), "python3", str(script_path),
"--roles-dir", str(self.roles_dir), "--roles-dir", str(self.roles_dir),

View File

@ -29,7 +29,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)
def test_script_generates_expected_yaml(self): def test_script_generates_expected_yaml(self):
script_path = Path(__file__).resolve().parent.parent.parent / "cli" / "generate_applications.py" script_path = Path(__file__).resolve().parent.parent.parent.parent / "cli" / "generate_applications.py"
result = subprocess.run( result = subprocess.run(
[ [

View File

@ -7,7 +7,7 @@ import shutil
import yaml import yaml
# Adjust path to include cli/ folder # Adjust path to include cli/ folder
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "cli"))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "cli")))
from generate_playbook import build_dependency_graph, topological_sort, generate_playbook_entries from generate_playbook import build_dependency_graph, topological_sort, generate_playbook_entries

View File

@ -7,7 +7,7 @@ import yaml
from collections import OrderedDict from collections import OrderedDict
# Add cli/ to import path # Add cli/ to import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "cli"))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "cli")))
import generate_users import generate_users

View File

@ -10,7 +10,7 @@ from unittest.mock import patch
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../cli") os.path.join(os.path.dirname(__file__), "../../../cli")
), ),
) )

View File

@ -0,0 +1,134 @@
import unittest
import tempfile
import shutil
import os
from pathlib import Path
import subprocess
import sys
import yaml
SCRIPT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../cli/validate_inventory.py"))
class TestValidateInventory(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.group_vars_all = Path(self.temp_dir) / "group_vars" / "all"
self.group_vars_all.mkdir(parents=True)
self.inventory_dir = Path(self.temp_dir) / "inventory"
self.inventory_dir.mkdir()
# Create default applications file
self.default_applications = {
"defaults_applications": {
"app1": {
"port": 8080,
"enabled": True,
"settings": {
"theme": "dark"
}
}
}
}
(self.group_vars_all / "01_applications.yml").write_text(
yaml.dump(self.default_applications), encoding="utf-8"
)
# Create default users file
self.default_users = {
"default_users": {
"alice": {
"email": "alice@example.com",
"role": "admin"
}
}
}
(self.group_vars_all / "01_users.yml").write_text(
yaml.dump(self.default_users), encoding="utf-8"
)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def run_script(self, expected_code=0):
result = subprocess.run(
[sys.executable, SCRIPT_PATH, str(self.inventory_dir)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
cwd=self.temp_dir
)
if result.returncode != expected_code:
print("STDOUT:", result.stdout)
print("STDERR:", result.stderr)
return result
def test_valid_inventory(self):
(self.inventory_dir / "group_vars.yml").write_text(yaml.dump({
"applications": {
"app1": {
"port": 8080,
"enabled": True,
"settings": {
"theme": "dark"
}
}
},
"users": {
"alice": {
"email": "alice@example.com",
"role": "admin",
"password": "secret"
}
}
}), encoding="utf-8")
result = self.run_script(expected_code=0)
self.assertIn("Inventory directory is valid against defaults", result.stdout)
def test_unknown_user_warning(self):
(self.inventory_dir / "invalid_users.yml").write_text(yaml.dump({
"users": {
"bob": {
"email": "bob@example.com",
"role": "user"
}
}
}), encoding="utf-8")
result = self.run_script(expected_code=0)
self.assertIn("Warning", result.stderr)
def test_missing_user_key_fails(self):
(self.inventory_dir / "invalid_key.yml").write_text(yaml.dump({
"users": {
"alice": {
"email": "alice@example.com",
"role": "admin",
"extra": "unexpected"
}
}
}), encoding="utf-8")
result = self.run_script(expected_code=1)
self.assertIn("Missing default for user 'alice': key 'extra'", result.stderr)
def test_missing_application_key_fails(self):
(self.inventory_dir / "missing_key.yml").write_text(yaml.dump({
"applications": {
"app1": {
"port": 8080,
"enabled": True,
"settings": {
"theme": "dark"
},
"extra_setting": True
}
}
}), encoding="utf-8")
result = self.run_script(expected_code=1)
self.assertIn("Missing default for app1: extra_setting", result.stdout)
if __name__ == "__main__":
unittest.main()

View File

View File

@ -6,7 +6,7 @@ from ansible.errors import AnsibleFilterError
# ensure filter_plugins is on the path # ensure filter_plugins is on the path
dir_path = os.path.abspath( dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins') os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
) )
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)

View File

@ -6,7 +6,7 @@ import unittest
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../roles/docker-matrix") os.path.join(os.path.dirname(__file__), "../../../roles/docker-matrix")
), ),
) )

View File

@ -7,7 +7,7 @@ import os
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../") os.path.join(os.path.dirname(__file__), "../../../")
), ),
) )

View File

@ -7,7 +7,7 @@ import os
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../") os.path.join(os.path.dirname(__file__), "../../../")
), ),
) )

View File

@ -5,7 +5,7 @@ import sys
import unittest import unittest
# Add filter_plugins/ to the import path # Add filter_plugins/ to the import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "filter_plugins"))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "filter_plugins")))
from docker_image import FilterModule from docker_image import FilterModule

View File

@ -4,7 +4,7 @@ import unittest
# Add the filter_plugins directory to the import path # Add the filter_plugins directory to the import path
dir_path = os.path.abspath( dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins') os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
) )
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)

View File

@ -5,7 +5,7 @@ import os
# Ensure filter_plugins directory is on the path # Ensure filter_plugins directory is on the path
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins')) os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
) )
from generate_all_domains import FilterModule from generate_all_domains import FilterModule

View File

@ -5,7 +5,7 @@ import os
# Ensure filter_plugins directory is on the path # Ensure filter_plugins directory is on the path
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../filter_plugins')) os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins'))
) )
from generate_base_sld_domains import FilterModule from generate_base_sld_domains import FilterModule

View File

@ -4,7 +4,7 @@ import unittest
# Add the filter_plugins directory to the import path # Add the filter_plugins directory to the import path
dir_path = os.path.abspath( dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins') os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
) )
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)

View File

@ -4,7 +4,7 @@ import unittest
# Add the filter_plugins directory to the import path # Add the filter_plugins directory to the import path
dir_path = os.path.abspath( dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins') os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
) )
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)

View File

@ -7,7 +7,7 @@ import os
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins') os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
) )
) )

View File

@ -5,7 +5,7 @@ from unittest.mock import patch, mock_open
from ansible.errors import AnsibleFilterError from ansible.errors import AnsibleFilterError
# make sure our plugin is on PYTHONPATH # make sure our plugin is on PYTHONPATH
root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins')) root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
sys.path.insert(0, root) sys.path.insert(0, root)
import load_configuration import load_configuration

View File

@ -5,7 +5,7 @@ import unittest
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath( os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../") os.path.join(os.path.dirname(__file__), "../../../")
), ),
) )

View File

@ -5,7 +5,7 @@ import os
# Ensure filter_plugins directory is on the path # Ensure filter_plugins directory is on the path
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins')) os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
) )
from safe_join import safe_join from safe_join import safe_join

View File

@ -5,7 +5,7 @@ import os
# Ensure filter_plugins directory is on the path # Ensure filter_plugins directory is on the path
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins')) os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
) )
from safe import safe_placeholders from safe import safe_placeholders

View File

@ -6,7 +6,7 @@ from jinja2 import Undefined
# Ensure filter_plugins directory is on the path # Ensure filter_plugins directory is on the path
sys.path.insert( sys.path.insert(
0, 0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins')) os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
) )
from safe import FilterModule from safe import FilterModule

View File

View File

@ -6,7 +6,7 @@ import unittest
import yaml import yaml
dir_path = os.path.abspath( dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../lookup_plugins') os.path.join(os.path.dirname(__file__), '../../../lookup_plugins')
) )
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)

View File

@ -5,7 +5,7 @@ import shutil
import unittest import unittest
# Adjust the PYTHONPATH to include the lookup_plugins folder from the docker-portfolio role. # Adjust the PYTHONPATH to include the lookup_plugins folder from the docker-portfolio role.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../roles/docker-portfolio/lookup_plugins')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../roles/docker-portfolio/lookup_plugins'))
from docker_cards import LookupModule from docker_cards import LookupModule

View File

View File

@ -4,17 +4,10 @@ import os
import sys import sys
import unittest import unittest
# Add module_utils/ to the import path # Add the project root/module_utils to the import path
sys.path.insert( CURRENT_DIR = os.path.dirname(__file__)
0, PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "../../.."))
os.path.abspath( sys.path.insert(0, PROJECT_ROOT)
os.path.join(
os.path.dirname(__file__),
"../../..",
"module_utils",
)
),
)
from module_utils.cert_utils import CertUtils from module_utils.cert_utils import CertUtils

View File

View File

@ -8,7 +8,7 @@ import sys
def load_optimizer_module(): def load_optimizer_module():
module_path = os.path.abspath(os.path.join( module_path = os.path.abspath(os.path.join(
os.path.dirname(__file__), os.path.dirname(__file__),
'..', "..", 'roles', 'system-storage-optimizer', 'files', 'system-storage-optimizer.py' '..', "..", "..","..",'roles', 'system-storage-optimizer', 'files', 'system-storage-optimizer.py'
)) ))
spec = importlib.util.spec_from_file_location('storage_optimizer', module_path) spec = importlib.util.spec_from_file_location('storage_optimizer', module_path)
optimizer = importlib.util.module_from_spec(spec) optimizer = importlib.util.module_from_spec(spec)