mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-07-05 08:23:08 +02:00
Reorgenized test structure and added validation of inventory before deployment
This commit is contained in:
parent
fe04f1955f
commit
a9f55579a2
@ -4,8 +4,9 @@ import argparse
|
||||
import subprocess
|
||||
import os
|
||||
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()
|
||||
print(f"\n▶️ Script started at: {start_time.isoformat()}\n")
|
||||
|
||||
@ -94,6 +95,10 @@ def main():
|
||||
"--skip-tests", action="store_true",
|
||||
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(
|
||||
"-v", "--verbose", action="count", default=0,
|
||||
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()
|
||||
|
||||
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 = {
|
||||
"mode_reset": args.reset,
|
||||
"mode_test": args.test,
|
||||
|
@ -46,6 +46,22 @@ def compare_application_keys(applications, defaults, source_file):
|
||||
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):
|
||||
all_data = {}
|
||||
inventory_path = Path(inventory_dir)
|
||||
@ -69,32 +85,51 @@ def load_inventory_files(inventory_dir):
|
||||
return all_data
|
||||
|
||||
|
||||
def find_defaults_applications_file():
|
||||
candidates = list(Path("group_vars/all").glob("*_applications.yml"))
|
||||
def find_single_file(pattern):
|
||||
candidates = list(Path("group_vars/all").glob(pattern))
|
||||
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]
|
||||
|
||||
|
||||
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/)")
|
||||
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)
|
||||
default_users_data = load_yaml_file(users_path)
|
||||
|
||||
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:
|
||||
print(f"Error: No 'defaults_applications' found in {defaults_path}.", file=sys.stderr)
|
||||
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 = []
|
||||
|
||||
inventory_files = load_inventory_files(args.inventory_dir)
|
||||
for source_path, app_data in inventory_files.items():
|
||||
errors = compare_application_keys(app_data, defaults, str(source_path))
|
||||
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:
|
||||
print("Validation failed with the following issues:")
|
||||
for err in all_errors:
|
21
tests/integration/test_check_init_files.py
Normal file
21
tests/integration/test_check_init_files.py
Normal 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()
|
0
tests/unit/cli/__init__.py
Normal file
0
tests/unit/cli/__init__.py
Normal file
@ -5,7 +5,7 @@ from unittest import mock
|
||||
|
||||
# Ensure cli module is importable
|
||||
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)
|
||||
|
@ -43,7 +43,7 @@ class TestGenerateDefaultApplicationsUsers(unittest.TestCase):
|
||||
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.
|
||||
"""
|
||||
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([
|
||||
"python3", str(script_path),
|
||||
"--roles-dir", str(self.roles_dir),
|
@ -29,7 +29,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
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(
|
||||
[
|
@ -7,7 +7,7 @@ import shutil
|
||||
import yaml
|
||||
|
||||
# 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
|
||||
|
@ -7,7 +7,7 @@ import yaml
|
||||
from collections import OrderedDict
|
||||
|
||||
# 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
|
||||
|
@ -10,7 +10,7 @@ from unittest.mock import patch
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../cli")
|
||||
os.path.join(os.path.dirname(__file__), "../../../cli")
|
||||
),
|
||||
)
|
||||
|
134
tests/unit/cli/test_validate_inventory.py
Normal file
134
tests/unit/cli/test_validate_inventory.py
Normal 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()
|
0
tests/unit/filter_plugins/__init__.py
Normal file
0
tests/unit/filter_plugins/__init__.py
Normal file
@ -6,7 +6,7 @@ from ansible.errors import AnsibleFilterError
|
||||
|
||||
# ensure filter_plugins is on the path
|
||||
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)
|
||||
|
@ -6,7 +6,7 @@ import unittest
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../roles/docker-matrix")
|
||||
os.path.join(os.path.dirname(__file__), "../../../roles/docker-matrix")
|
||||
),
|
||||
)
|
||||
|
@ -7,7 +7,7 @@ import os
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../")
|
||||
os.path.join(os.path.dirname(__file__), "../../../")
|
||||
),
|
||||
)
|
||||
|
@ -7,7 +7,7 @@ import os
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../")
|
||||
os.path.join(os.path.dirname(__file__), "../../../")
|
||||
),
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ import sys
|
||||
import unittest
|
||||
|
||||
# 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
|
||||
|
@ -4,7 +4,7 @@ import unittest
|
||||
|
||||
# Add the filter_plugins directory to the import path
|
||||
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)
|
||||
|
@ -5,7 +5,7 @@ import os
|
||||
# Ensure filter_plugins directory is on the path
|
||||
sys.path.insert(
|
||||
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
|
@ -5,7 +5,7 @@ import os
|
||||
# Ensure filter_plugins directory is on the path
|
||||
sys.path.insert(
|
||||
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
|
@ -4,7 +4,7 @@ import unittest
|
||||
|
||||
# Add the filter_plugins directory to the import path
|
||||
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)
|
||||
|
@ -4,7 +4,7 @@ import unittest
|
||||
|
||||
# Add the filter_plugins directory to the import path
|
||||
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)
|
||||
|
@ -7,7 +7,7 @@ import os
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), '../../filter_plugins')
|
||||
os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
|
||||
)
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ from unittest.mock import patch, mock_open
|
||||
from ansible.errors import AnsibleFilterError
|
||||
|
||||
# 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)
|
||||
|
||||
import load_configuration
|
@ -5,7 +5,7 @@ import unittest
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../")
|
||||
os.path.join(os.path.dirname(__file__), "../../../")
|
||||
),
|
||||
)
|
||||
|
@ -5,7 +5,7 @@ import os
|
||||
# Ensure filter_plugins directory is on the path
|
||||
sys.path.insert(
|
||||
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
|
@ -5,7 +5,7 @@ import os
|
||||
# Ensure filter_plugins directory is on the path
|
||||
sys.path.insert(
|
||||
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
|
@ -6,7 +6,7 @@ from jinja2 import Undefined
|
||||
# Ensure filter_plugins directory is on the path
|
||||
sys.path.insert(
|
||||
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
|
0
tests/unit/lookup_plugins/__init__.py
Normal file
0
tests/unit/lookup_plugins/__init__.py
Normal file
@ -6,7 +6,7 @@ import unittest
|
||||
import yaml
|
||||
|
||||
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)
|
||||
|
@ -5,7 +5,7 @@ import shutil
|
||||
import unittest
|
||||
|
||||
# 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
|
||||
|
0
tests/unit/module_utils/__init__.py
Normal file
0
tests/unit/module_utils/__init__.py
Normal file
@ -4,17 +4,10 @@ import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
# Add module_utils/ to the import path
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.abspath(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../..",
|
||||
"module_utils",
|
||||
)
|
||||
),
|
||||
)
|
||||
# Add the project root/module_utils to the import path
|
||||
CURRENT_DIR = os.path.dirname(__file__)
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "../../.."))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from module_utils.cert_utils import CertUtils
|
||||
|
0
tests/unit/roles/__init__.py
Normal file
0
tests/unit/roles/__init__.py
Normal file
@ -8,7 +8,7 @@ import sys
|
||||
def load_optimizer_module():
|
||||
module_path = os.path.abspath(os.path.join(
|
||||
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)
|
||||
optimizer = importlib.util.module_from_spec(spec)
|
Loading…
x
Reference in New Issue
Block a user