mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-03 06:51:00 +02:00
Squashed 'ai-context/trustgraph-templates/' content from commit 42a5fd1b
git-subtree-dir: ai-context/trustgraph-templates git-subtree-split: 42a5fd1b678f32be378062e30451e2052ccb95dd
This commit is contained in:
commit
74cc8a4685
1216 changed files with 116347 additions and 0 deletions
189
tests/README.md
Normal file
189
tests/README.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# TrustGraph Configurator Test Suite
|
||||
|
||||
Comprehensive pytest-based test suite for trustgraph-configurator.
|
||||
|
||||
## Installation
|
||||
|
||||
Install with development dependencies:
|
||||
|
||||
```bash
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest
|
||||
|
||||
# Specific category
|
||||
pytest tests/unit/
|
||||
pytest tests/integration/
|
||||
pytest tests/validation/
|
||||
|
||||
# By marker
|
||||
pytest -m unit
|
||||
pytest -m integration
|
||||
pytest -m validation
|
||||
|
||||
# Specific test file
|
||||
pytest tests/unit/test_generator.py
|
||||
|
||||
# Parallel execution (faster)
|
||||
pytest -n auto
|
||||
|
||||
# Verbose output
|
||||
pytest -v
|
||||
|
||||
# Stop on first failure
|
||||
pytest -x
|
||||
|
||||
# With coverage
|
||||
pytest --cov=trustgraph_configurator --cov-report=html
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── unit/ # Unit tests for Python modules
|
||||
│ ├── test_generator.py
|
||||
│ ├── test_packager.py
|
||||
│ ├── test_api.py
|
||||
│ └── test_run.py
|
||||
├── integration/ # Full workflow tests
|
||||
│ ├── test_compilation.py # Template compilation matrix
|
||||
│ ├── test_cli.py # CLI interface tests
|
||||
│ └── test_errors.py # Error handling tests
|
||||
├── validation/ # Output validation tests
|
||||
│ ├── test_syntax.py # Syntax validation
|
||||
│ ├── test_schema.py # Schema validation
|
||||
│ ├── test_semantics_k8s.py
|
||||
│ ├── test_semantics_docker.py
|
||||
│ └── test_semantics_tg.py
|
||||
├── validators/ # Validation helper modules
|
||||
│ ├── kubernetes.py
|
||||
│ ├── docker_compose.py
|
||||
│ └── trustgraph.py
|
||||
├── schemas/ # JSON schemas
|
||||
│ ├── trustgraph-config.schema.json
|
||||
│ ├── kubernetes-resource.schema.json
|
||||
│ └── docker-compose.schema.json
|
||||
└── configs/ # Test input configs
|
||||
├── minimal.json
|
||||
├── complex-rag.json
|
||||
├── multi-service.json
|
||||
└── cloud-aws.json
|
||||
```
|
||||
|
||||
## Test Categories
|
||||
|
||||
### Unit Tests (`tests/unit/`)
|
||||
Test individual Python modules in isolation:
|
||||
- Generator: Jsonnet template processing
|
||||
- Packager: Configuration assembly and zip creation
|
||||
- API: Template listing and version resolution
|
||||
- Run: CLI entry point and argument parsing
|
||||
|
||||
### Integration Tests (`tests/integration/`)
|
||||
Test full workflow end-to-end:
|
||||
- **Compilation**: Template compilation across all version/platform/config combinations (192 tests)
|
||||
- **CLI**: Command line interface functionality
|
||||
- **Errors**: Error handling and reporting
|
||||
|
||||
### Validation Tests (`tests/validation/`)
|
||||
Verify correctness of generated outputs:
|
||||
- **Syntax**: JSON/YAML parsing validation
|
||||
- **Schema**: JSON Schema compliance
|
||||
- **Semantics**: Cross-references, consistency checks
|
||||
|
||||
## Test Matrix
|
||||
|
||||
Integration tests cover:
|
||||
- **Versions**: 1.6, 1.7, 1.8
|
||||
- **Platforms**: docker-compose, podman-compose, minikube-k8s, gcp-k8s, aks-k8s, eks-k8s, scw-k8s, ovh-k8s
|
||||
- **Configs**: minimal, complex-rag, multi-service, cloud-aws
|
||||
|
||||
Total: 3 versions × 8 platforms × 4 configs × 2 outputs = 192 test combinations
|
||||
|
||||
## Validation Layers
|
||||
|
||||
### Syntax Validation
|
||||
- JSON parsing with `json.loads()`
|
||||
- YAML parsing with `yaml.safe_load()`
|
||||
|
||||
### Schema Validation
|
||||
- TrustGraph config against `trustgraph-config.schema.json`
|
||||
- Docker Compose against `docker-compose.schema.json`
|
||||
- Kubernetes resources against `kubernetes-resource.schema.json`
|
||||
|
||||
### Semantic Validation
|
||||
|
||||
**Kubernetes:**
|
||||
- Deployment selectors match pod labels
|
||||
- Service selectors match deployment labels
|
||||
- volumeMounts reference defined volumes
|
||||
- ConfigMap/Secret references exist
|
||||
- Service targetPorts match container ports
|
||||
|
||||
**Docker Compose:**
|
||||
- depends_on references valid services
|
||||
- Volume names are defined
|
||||
- Network references are valid
|
||||
- No port conflicts
|
||||
|
||||
**TrustGraph Config:**
|
||||
- Service references are valid
|
||||
- Parameter types are reasonable
|
||||
- Storage backends are consistent
|
||||
- LLM configuration is present
|
||||
|
||||
## Fixtures
|
||||
|
||||
Available in `conftest.py`:
|
||||
- `test_config_dir`: Path to test configs
|
||||
- `test_configs`: Loaded test configurations
|
||||
- `temp_output_dir`: Temporary directory for outputs
|
||||
- `run_configurator`: Function to execute configurator
|
||||
- `mock_config_file`: Create temporary config files
|
||||
|
||||
## CI/CD
|
||||
|
||||
Tests run automatically on pull requests via GitHub Actions.
|
||||
|
||||
See `.github/workflows/pull-request.yaml` for CI configuration.
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. Create test file in appropriate directory
|
||||
2. Use appropriate markers (`@pytest.mark.unit`, etc.)
|
||||
3. Use fixtures from `conftest.py`
|
||||
4. Follow naming convention: `test_*.py`, `test_*()` functions
|
||||
|
||||
### Adding New Validation
|
||||
|
||||
1. Add validation logic to `tests/validators/`
|
||||
2. Create corresponding tests in `tests/validation/`
|
||||
3. Update schemas in `tests/schemas/` if needed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Test failures:**
|
||||
- Check stderr output for error messages
|
||||
- Run with `-v` for verbose output
|
||||
- Run with `--tb=long` for full tracebacks
|
||||
|
||||
**Import errors:**
|
||||
- Ensure package is installed: `pip install -e .[dev]`
|
||||
- Check PYTHONPATH includes project root
|
||||
|
||||
**Slow tests:**
|
||||
- Use `-n auto` for parallel execution
|
||||
- Run specific test subsets instead of full suite
|
||||
|
||||
## Documentation
|
||||
|
||||
See `docs/tech-specs/tests.md` for detailed test specification.
|
||||
26
tests/configs/cloud-aws.json
Normal file
26
tests/configs/cloud-aws.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[
|
||||
{
|
||||
"name": "bedrock",
|
||||
"parameters": {
|
||||
"model": "anthropic.claude-3-sonnet-20240229-v1:0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "embeddings-hf",
|
||||
"parameters": {
|
||||
"embeddings-model": "sentence-transformers/all-MiniLM-L6-v2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vector-store-qdrant",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "trustgraph-base",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "pulsar",
|
||||
"parameters": {}
|
||||
}
|
||||
]
|
||||
42
tests/configs/complex-rag.json
Normal file
42
tests/configs/complex-rag.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
[
|
||||
{
|
||||
"name": "openai",
|
||||
"parameters": {
|
||||
"model": "gpt-4",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "embeddings-hf",
|
||||
"parameters": {
|
||||
"embeddings-model": "sentence-transformers/all-MiniLM-L6-v2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vector-store-qdrant",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "triple-store-neo4j",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "triple-store-cassandra",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "override-recursive-chunker",
|
||||
"parameters": {
|
||||
"chunk-size": 2000,
|
||||
"chunk-overlap": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trustgraph-base",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "pulsar",
|
||||
"parameters": {}
|
||||
}
|
||||
]
|
||||
23
tests/configs/minimal.json
Normal file
23
tests/configs/minimal.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[
|
||||
{
|
||||
"name": "openai",
|
||||
"parameters": {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "embeddings-hf",
|
||||
"parameters": {
|
||||
"embeddings-model": "sentence-transformers/all-MiniLM-L6-v2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trustgraph-base",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "pulsar",
|
||||
"parameters": {}
|
||||
}
|
||||
]
|
||||
38
tests/configs/multi-service.json
Normal file
38
tests/configs/multi-service.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[
|
||||
{
|
||||
"name": "ollama",
|
||||
"parameters": {
|
||||
"model": "llama2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "embeddings-fastembed",
|
||||
"parameters": {
|
||||
"embeddings-model": "BAAI/bge-small-en-v1.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vector-store-milvus",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "triple-store-memgraph",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "triple-store-cassandra",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "grafana",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "trustgraph-base",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "pulsar",
|
||||
"parameters": {}
|
||||
}
|
||||
]
|
||||
125
tests/conftest.py
Normal file
125
tests/conftest.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""
|
||||
Pytest configuration and shared fixtures for trustgraph-configurator tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# Version Configuration - Update these when adding new template versions
|
||||
# =============================================================================
|
||||
TESTED_VERSIONS = ["1.8", "1.9", "2.0"]
|
||||
PRIMARY_VERSION = "1.9" # Used when only one version is tested
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_config_dir():
|
||||
"""Path to the test configurations directory."""
|
||||
return Path(__file__).parent / "configs"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_configs(test_config_dir):
|
||||
"""Dictionary of loaded test configurations."""
|
||||
configs = {}
|
||||
for config_file in test_config_dir.glob("*.json"):
|
||||
with open(config_file) as f:
|
||||
configs[config_file.name] = json.load(f)
|
||||
return configs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_output_dir():
|
||||
"""Temporary directory for test outputs."""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
yield Path(temp_dir)
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_configurator(monkeypatch, capsys):
|
||||
"""
|
||||
Fixture to run configurator with given arguments.
|
||||
|
||||
Usage:
|
||||
stdout, stderr, exit_code = run_configurator(['-t', '1.8', '-p', 'docker-compose', ...])
|
||||
|
||||
Returns:
|
||||
tuple: (stdout, stderr, exit_code)
|
||||
"""
|
||||
def _run(args):
|
||||
from trustgraph_configurator import run
|
||||
|
||||
# Set sys.argv with the command and arguments
|
||||
monkeypatch.setattr(sys, 'argv', ['tg-build-deployment'] + args)
|
||||
|
||||
exit_code = 0
|
||||
try:
|
||||
run() # run is already the function, not a module
|
||||
except SystemExit as e:
|
||||
exit_code = e.code if e.code is not None else 0
|
||||
|
||||
# Capture output
|
||||
captured = capsys.readouterr()
|
||||
return captured.out, captured.err, exit_code
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def golden_dir():
|
||||
"""Path to the golden files directory."""
|
||||
return Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_versions():
|
||||
"""List of template versions to test."""
|
||||
return TESTED_VERSIONS
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def primary_version():
|
||||
"""Primary version for tests that only need one version."""
|
||||
return PRIMARY_VERSION
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_platforms():
|
||||
"""List of platforms to test."""
|
||||
return [
|
||||
"docker-compose",
|
||||
"podman-compose",
|
||||
"minikube-k8s",
|
||||
"gcp-k8s",
|
||||
"aks-k8s",
|
||||
"eks-k8s",
|
||||
"scw-k8s",
|
||||
"ovh-k8s",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_config_names():
|
||||
"""List of test configuration file names."""
|
||||
return [
|
||||
"minimal.json",
|
||||
"complex-rag.json",
|
||||
"multi-service.json",
|
||||
"cloud-aws.json",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_file(tmp_path):
|
||||
"""Create a temporary config file for testing."""
|
||||
def _create(config_data):
|
||||
config_file = tmp_path / "test_config.json"
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config_data, f)
|
||||
return str(config_file)
|
||||
return _create
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
89
tests/integration/test_cli.py
Normal file
89
tests/integration/test_cli.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""
|
||||
Integration tests for CLI interface.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
|
||||
from conftest import TESTED_VERSIONS
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestCLIInterface:
|
||||
"""Tests for CLI command line interface."""
|
||||
|
||||
def test_cli_executable_help(self):
|
||||
"""Test that CLI executable --help works."""
|
||||
result = subprocess.run(
|
||||
['tg-build-deployment', '--help'],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert 'usage' in result.stdout.lower()
|
||||
|
||||
def test_cli_executable_exists(self):
|
||||
"""Test that tg-build-deployment is in PATH."""
|
||||
result = subprocess.run(
|
||||
['which', 'tg-build-deployment'],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_output_modes(self, run_configurator, test_config_dir, primary_version):
|
||||
"""Test -O and -R output modes."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
# Test -O mode
|
||||
stdout_o, _, code_o = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
assert code_o == 0
|
||||
assert len(stdout_o) > 0
|
||||
|
||||
# Test -R mode
|
||||
stdout_r, _, code_r = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
assert code_r == 0
|
||||
assert len(stdout_r) > 0
|
||||
|
||||
# Outputs should be different
|
||||
assert stdout_o != stdout_r
|
||||
|
||||
def test_platform_argument(self, run_configurator, test_config_dir, primary_version):
|
||||
"""Test -p/--platform argument."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
for platform in ['docker-compose', 'minikube-k8s']:
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', platform,
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
assert code == 0, f"Failed for platform {platform}"
|
||||
|
||||
def test_template_argument(self, run_configurator, test_config_dir):
|
||||
"""Test -t/--template argument."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
for template in TESTED_VERSIONS:
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', template,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
assert code == 0, f"Failed for template {template}"
|
||||
163
tests/integration/test_compilation.py
Normal file
163
tests/integration/test_compilation.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
Integration tests for template compilation across all combinations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import yaml
|
||||
|
||||
from conftest import TESTED_VERSIONS
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("version", TESTED_VERSIONS)
|
||||
@pytest.mark.parametrize("platform", [
|
||||
"docker-compose",
|
||||
"podman-compose",
|
||||
"minikube-k8s",
|
||||
"gcp-k8s",
|
||||
"aks-k8s",
|
||||
"eks-k8s",
|
||||
"scw-k8s",
|
||||
"ovh-k8s",
|
||||
])
|
||||
@pytest.mark.parametrize("config", [
|
||||
"minimal.json",
|
||||
"complex-rag.json",
|
||||
"multi-service.json",
|
||||
"cloud-aws.json",
|
||||
])
|
||||
def test_tg_config_generation(version, platform, config, run_configurator, test_config_dir):
|
||||
"""Test TrustGraph config generation for all combinations."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', version,
|
||||
'-p', platform,
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
# Should succeed
|
||||
assert code == 0, f"Failed for {version}/{platform}/{config}: {stderr}"
|
||||
|
||||
# Should output valid JSON
|
||||
try:
|
||||
tg_config = json.loads(stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
pytest.fail(f"Invalid JSON output for {version}/{platform}/{config}: {e}")
|
||||
|
||||
# Basic structure checks
|
||||
assert isinstance(tg_config, (dict, list)), "TrustGraph config should be dict or list"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("version", TESTED_VERSIONS)
|
||||
@pytest.mark.parametrize("platform", [
|
||||
"docker-compose",
|
||||
"podman-compose",
|
||||
"minikube-k8s",
|
||||
"gcp-k8s",
|
||||
"aks-k8s",
|
||||
"eks-k8s",
|
||||
"scw-k8s",
|
||||
"ovh-k8s",
|
||||
])
|
||||
@pytest.mark.parametrize("config", [
|
||||
"minimal.json",
|
||||
"complex-rag.json",
|
||||
"multi-service.json",
|
||||
"cloud-aws.json",
|
||||
])
|
||||
def test_resources_generation(version, platform, config, run_configurator, test_config_dir):
|
||||
"""Test platform resources generation for all combinations."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', version,
|
||||
'-p', platform,
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
# Should succeed
|
||||
assert code == 0, f"Failed for {version}/{platform}/{config}: {stderr}"
|
||||
|
||||
# Should output valid YAML
|
||||
try:
|
||||
resources = yaml.safe_load(stdout)
|
||||
except yaml.YAMLError as e:
|
||||
pytest.fail(f"Invalid YAML output for {version}/{platform}/{config}: {e}")
|
||||
|
||||
# Basic structure checks
|
||||
if platform in ["docker-compose", "podman-compose"]:
|
||||
assert "services" in resources, "Docker Compose should have services"
|
||||
else:
|
||||
# Kubernetes resources
|
||||
assert resources is not None, "K8s resources should not be empty"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_compilation_minimal_docker_compose(run_configurator, test_config_dir, primary_version):
|
||||
"""Smoke test: minimal config on docker-compose."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
# Test TG config
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
tg_config = json.loads(stdout)
|
||||
assert tg_config is not None
|
||||
|
||||
# Test resources
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
resources = yaml.safe_load(stdout)
|
||||
assert "services" in resources
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_compilation_minimal_k8s(run_configurator, test_config_dir, primary_version):
|
||||
"""Smoke test: minimal config on k8s."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
# Test TG config
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'minikube-k8s',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
tg_config = json.loads(stdout)
|
||||
assert tg_config is not None
|
||||
|
||||
# Test resources
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'minikube-k8s',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
resources = yaml.safe_load(stdout)
|
||||
assert resources is not None
|
||||
97
tests/integration/test_errors.py
Normal file
97
tests/integration/test_errors.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
Integration tests for error handling.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestErrorHandling:
|
||||
"""Tests for error handling and reporting."""
|
||||
|
||||
def test_nonexistent_config_file(self, run_configurator, primary_version):
|
||||
"""Test error when config file doesn't exist."""
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', '/nonexistent/config.json',
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
assert code == 1
|
||||
assert len(stderr) > 0 # Error should be in stderr
|
||||
|
||||
def test_invalid_json_config(self, run_configurator, tmp_path, primary_version):
|
||||
"""Test error when config file has invalid JSON."""
|
||||
invalid_config = tmp_path / "invalid.json"
|
||||
invalid_config.write_text("{ invalid json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', str(invalid_config),
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
assert code == 1
|
||||
|
||||
def test_invalid_platform(self, run_configurator, test_config_dir, primary_version):
|
||||
"""Test error when platform is invalid."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'nonexistent-platform',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R' # Use -R to trigger platform-specific generation
|
||||
])
|
||||
assert code == 1
|
||||
|
||||
def test_invalid_template_version(self, run_configurator, test_config_dir):
|
||||
"""Test error when template version doesn't exist."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', '999.999',
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'-O'
|
||||
])
|
||||
assert code == 1
|
||||
|
||||
def test_malformed_config_structure(self, run_configurator, tmp_path, primary_version):
|
||||
"""Test error when config structure is invalid."""
|
||||
# Valid JSON but wrong structure
|
||||
invalid_config = tmp_path / "bad_structure.json"
|
||||
invalid_config.write_text('{"wrong": "structure"}')
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', str(invalid_config),
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
# May succeed with warning or fail - either is acceptable
|
||||
# The important thing is it doesn't crash
|
||||
assert code in [0, 1]
|
||||
|
||||
def test_missing_required_args(self, run_configurator):
|
||||
"""Test error when required arguments are missing."""
|
||||
# Missing template
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-p', 'docker-compose',
|
||||
'-O'
|
||||
])
|
||||
assert code == 1
|
||||
|
||||
def test_error_goes_to_stderr(self, run_configurator):
|
||||
"""Test that errors are written to stderr, not stdout."""
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-i', '/nonexistent/config.json'
|
||||
])
|
||||
assert code == 1
|
||||
# Errors should be in stderr
|
||||
assert len(stderr) > 0 or 'Exception' in stderr or code == 1
|
||||
103
tests/schemas/docker-compose.schema.json
Normal file
103
tests/schemas/docker-compose.schema.json
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Docker Compose Configuration",
|
||||
"description": "Basic schema for Docker Compose files",
|
||||
"type": "object",
|
||||
"required": ["services"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Docker Compose version"
|
||||
},
|
||||
"services": {
|
||||
"type": "object",
|
||||
"description": "Service definitions",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "Docker image"
|
||||
},
|
||||
"build": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object"}
|
||||
],
|
||||
"description": "Build configuration"
|
||||
},
|
||||
"ports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "number"},
|
||||
{"type": "boolean"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"depends_on": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"networks": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
"type": "object",
|
||||
"description": "Named volumes"
|
||||
},
|
||||
"networks": {
|
||||
"type": "object",
|
||||
"description": "Network definitions"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
tests/schemas/kubernetes-resource.schema.json
Normal file
42
tests/schemas/kubernetes-resource.schema.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Kubernetes Resource",
|
||||
"description": "Basic schema for Kubernetes resources",
|
||||
"type": "object",
|
||||
"required": ["apiVersion", "kind", "metadata"],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"description": "Kubernetes API version"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Resource kind",
|
||||
"enum": ["Deployment", "Service", "ConfigMap", "Secret", "PersistentVolumeClaim", "PersistentVolume", "Namespace", "StorageClass"]
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Resource name"
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Resource namespace"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"description": "Resource specification"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
tests/schemas/trustgraph-config.schema.json
Normal file
16
tests/schemas/trustgraph-config.schema.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "TrustGraph Configuration Output",
|
||||
"description": "Schema for generated TrustGraph configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {
|
||||
"type": "object",
|
||||
"description": "Collection definitions"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"description": "Tool definitions"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
38
tests/unit/test_api.py
Normal file
38
tests/unit/test_api.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
Unit tests for Index class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from trustgraph_configurator import Index
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAPI:
|
||||
"""Tests for the Index class."""
|
||||
|
||||
def test_get_templates_returns_list(self):
|
||||
"""Test that get_templates returns a list."""
|
||||
templates = Index.get_templates()
|
||||
assert isinstance(templates, list)
|
||||
assert len(templates) > 0
|
||||
|
||||
def test_templates_have_required_fields(self):
|
||||
"""Test that templates have name and version fields."""
|
||||
templates = Index.get_templates()
|
||||
for template in templates:
|
||||
assert hasattr(template, 'name')
|
||||
assert hasattr(template, 'version')
|
||||
|
||||
def test_get_latest_returns_template(self):
|
||||
"""Test that get_latest returns a template."""
|
||||
latest = Index.get_latest()
|
||||
assert latest is not None
|
||||
assert hasattr(latest, 'name')
|
||||
assert hasattr(latest, 'version')
|
||||
|
||||
def test_get_latest_stable_returns_template(self):
|
||||
"""Test that get_latest_stable returns a template."""
|
||||
latest_stable = Index.get_latest_stable()
|
||||
assert latest_stable is not None
|
||||
assert hasattr(latest_stable, 'name')
|
||||
assert hasattr(latest_stable, 'version')
|
||||
101
tests/unit/test_generator.py
Normal file
101
tests/unit/test_generator.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""
|
||||
Unit tests for Generator class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from trustgraph_configurator.generator import Generator
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenerator:
|
||||
"""Tests for the Generator class."""
|
||||
|
||||
def test_simple_jsonnet(self):
|
||||
"""Test processing simple jsonnet."""
|
||||
def mock_fetch(base, rel):
|
||||
return "", ""
|
||||
|
||||
generator = Generator(mock_fetch)
|
||||
result = generator.process('{ foo: "bar" }')
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["foo"] == "bar"
|
||||
|
||||
def test_jsonnet_with_variables(self):
|
||||
"""Test processing jsonnet with variables."""
|
||||
def mock_fetch(base, rel):
|
||||
return "", ""
|
||||
|
||||
generator = Generator(mock_fetch)
|
||||
jsonnet_code = '''
|
||||
local name = "test";
|
||||
{
|
||||
name: name,
|
||||
value: 42
|
||||
}
|
||||
'''
|
||||
result = generator.process(jsonnet_code)
|
||||
|
||||
assert result["name"] == "test"
|
||||
assert result["value"] == 42
|
||||
|
||||
def test_jsonnet_with_array(self):
|
||||
"""Test processing jsonnet that returns array."""
|
||||
def mock_fetch(base, rel):
|
||||
return "", ""
|
||||
|
||||
generator = Generator(mock_fetch)
|
||||
jsonnet_code = '[1, 2, 3, { foo: "bar" }]'
|
||||
result = generator.process(jsonnet_code)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 4
|
||||
assert result[0] == 1
|
||||
assert result[3]["foo"] == "bar"
|
||||
|
||||
def test_invalid_jsonnet(self):
|
||||
"""Test that invalid jsonnet raises exception."""
|
||||
def mock_fetch(base, rel):
|
||||
return "", ""
|
||||
|
||||
generator = Generator(mock_fetch)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
generator.process('{ invalid jsonnet')
|
||||
|
||||
def test_jsonnet_with_functions(self):
|
||||
"""Test processing jsonnet with functions."""
|
||||
def mock_fetch(base, rel):
|
||||
return "", ""
|
||||
|
||||
generator = Generator(mock_fetch)
|
||||
jsonnet_code = '''
|
||||
local double(x) = x * 2;
|
||||
{
|
||||
value: double(21)
|
||||
}
|
||||
'''
|
||||
result = generator.process(jsonnet_code)
|
||||
|
||||
assert result["value"] == 42
|
||||
|
||||
def test_fetch_callback_is_used(self):
|
||||
"""Test that fetch callback is called for imports."""
|
||||
fetch_called = []
|
||||
|
||||
def mock_fetch(base, rel):
|
||||
fetch_called.append((base, rel))
|
||||
# Return simple jsonnet that defines a variable (as bytes)
|
||||
return "config", b'{ imported: true }'
|
||||
|
||||
generator = Generator(mock_fetch)
|
||||
jsonnet_code = '''
|
||||
local config = import "config.jsonnet";
|
||||
config
|
||||
'''
|
||||
|
||||
result = generator.process(jsonnet_code)
|
||||
|
||||
assert len(fetch_called) > 0
|
||||
assert result["imported"] is True
|
||||
60
tests/unit/test_packager.py
Normal file
60
tests/unit/test_packager.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""
|
||||
Unit tests for Packager class.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from trustgraph_configurator.packager import Packager
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPackager:
|
||||
"""Tests for the Packager class."""
|
||||
|
||||
def test_init_with_latest_stable(self):
|
||||
"""Test initialization with latest_stable flag."""
|
||||
packager = Packager(
|
||||
version=None,
|
||||
template=None,
|
||||
platform="docker-compose",
|
||||
latest=False,
|
||||
latest_stable=True
|
||||
)
|
||||
assert packager.version is not None
|
||||
assert packager.template is not None
|
||||
|
||||
def test_init_with_template(self):
|
||||
"""Test initialization with specific template."""
|
||||
packager = Packager(
|
||||
version="1.8.12",
|
||||
template="1.8",
|
||||
platform="docker-compose",
|
||||
latest=False,
|
||||
latest_stable=False
|
||||
)
|
||||
assert packager.version == "1.8.12"
|
||||
assert packager.template == "1.8"
|
||||
assert packager.platform == "docker-compose"
|
||||
|
||||
def test_invalid_platform_raises_error(self):
|
||||
"""Test that invalid platform raises error during generation."""
|
||||
packager = Packager(
|
||||
version="1.8.12",
|
||||
template="1.8",
|
||||
platform="invalid-platform",
|
||||
latest=False,
|
||||
latest_stable=False
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Bad platform"):
|
||||
packager.generate('[{"name": "test", "parameters": {}}]')
|
||||
|
||||
def test_init_without_template_raises_error(self):
|
||||
"""Test that initialization without template/latest raises error."""
|
||||
with pytest.raises(RuntimeError, match="You must"):
|
||||
Packager(
|
||||
version=None,
|
||||
template=None,
|
||||
platform="docker-compose",
|
||||
latest=False,
|
||||
latest_stable=False
|
||||
)
|
||||
62
tests/unit/test_run.py
Normal file
62
tests/unit/test_run.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""
|
||||
Unit tests for run module (CLI entry point).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRun:
|
||||
"""Tests for the run module."""
|
||||
|
||||
def test_run_without_args_fails(self, run_configurator):
|
||||
"""Test that running without required args fails."""
|
||||
stdout, stderr, code = run_configurator([])
|
||||
assert code != 0
|
||||
|
||||
def test_run_with_help_succeeds(self, run_configurator):
|
||||
"""Test that help flag works."""
|
||||
stdout, stderr, code = run_configurator(['-h'])
|
||||
assert code == 0
|
||||
|
||||
def test_run_with_invalid_platform_fails(self, run_configurator, test_config_dir, primary_version):
|
||||
"""Test that invalid platform fails during resource generation."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'invalid-platform',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R' # Use -R to trigger platform-specific generation
|
||||
])
|
||||
assert code == 1
|
||||
|
||||
def test_run_with_nonexistent_config_fails(self, run_configurator, primary_version):
|
||||
"""Test that nonexistent config file fails."""
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', '/nonexistent/config.json',
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
assert code == 1
|
||||
|
||||
def test_exit_code_propagates(self, monkeypatch):
|
||||
"""Test that exit codes are properly set."""
|
||||
from trustgraph_configurator import run
|
||||
|
||||
# Test successful exit (no exception)
|
||||
# This would require a valid config, so we'll just test the error path
|
||||
|
||||
# Test error exit
|
||||
monkeypatch.setattr(sys, 'argv', [
|
||||
'tg-build-deployment',
|
||||
'-i', '/nonexistent/config.json'
|
||||
])
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run() # run is already the function
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
0
tests/validation/__init__.py
Normal file
0
tests/validation/__init__.py
Normal file
111
tests/validation/test_schema.py
Normal file
111
tests/validation/test_schema.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""
|
||||
Schema validation tests for generated outputs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import yaml
|
||||
import jsonschema
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schemas_dir():
|
||||
"""Path to schemas directory."""
|
||||
return Path(__file__).parent.parent / "schemas"
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_tg_config_matches_schema(run_configurator, test_config_dir, schemas_dir, primary_version):
|
||||
"""Test that TrustGraph config matches schema."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
tg_config = json.loads(stdout)
|
||||
schema_file = schemas_dir / "trustgraph-config.schema.json"
|
||||
|
||||
with open(schema_file) as f:
|
||||
schema = json.load(f)
|
||||
|
||||
try:
|
||||
jsonschema.validate(instance=tg_config, schema=schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
pytest.fail(f"Schema validation failed: {e}")
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_docker_compose_matches_schema(run_configurator, test_config_dir, schemas_dir, primary_version):
|
||||
"""Test that Docker Compose output matches schema."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
compose_data = yaml.safe_load(stdout)
|
||||
schema_file = schemas_dir / "docker-compose.schema.json"
|
||||
|
||||
with open(schema_file) as f:
|
||||
schema = json.load(f)
|
||||
|
||||
try:
|
||||
jsonschema.validate(instance=compose_data, schema=schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
pytest.fail(f"Schema validation failed: {e}")
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_kubernetes_resources_match_schema(run_configurator, test_config_dir, schemas_dir, primary_version):
|
||||
"""Test that Kubernetes resources match schema."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'minikube-k8s',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
resources = yaml.safe_load(stdout)
|
||||
schema_file = schemas_dir / "kubernetes-resource.schema.json"
|
||||
|
||||
with open(schema_file) as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Validate the resource (which might be a single resource, list, or K8s List)
|
||||
if isinstance(resources, dict):
|
||||
# Check if it's a Kubernetes List resource
|
||||
if resources.get('kind') == 'List' and 'items' in resources:
|
||||
resources_to_validate = resources['items']
|
||||
else:
|
||||
resources_to_validate = [resources]
|
||||
elif isinstance(resources, list):
|
||||
resources_to_validate = resources
|
||||
else:
|
||||
pytest.fail(f"Unexpected resources type: {type(resources)}")
|
||||
|
||||
for resource in resources_to_validate:
|
||||
if not isinstance(resource, dict):
|
||||
continue # Skip non-dict items
|
||||
try:
|
||||
jsonschema.validate(instance=resource, schema=schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
pytest.fail(f"Schema validation failed for resource: {e}")
|
||||
78
tests/validation/test_semantics_docker.py
Normal file
78
tests/validation/test_semantics_docker.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
Semantic validation tests for Docker Compose resources.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for validators import
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from validators import docker_compose
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
@pytest.mark.parametrize("config", ["minimal.json", "complex-rag.json"])
|
||||
def test_docker_compose_semantic_validation(config, run_configurator, test_config_dir, primary_version):
|
||||
"""Test semantic validation of Docker Compose resources."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
is_valid, errors = docker_compose.validate_docker_compose_manifest(stdout)
|
||||
|
||||
if not is_valid:
|
||||
error_msg = "\n".join(errors)
|
||||
pytest.fail(f"Semantic validation failed for {config}:\n{error_msg}")
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_docker_compose_service_dependencies(run_configurator, test_config_dir, primary_version):
|
||||
"""Test that service dependencies reference valid services."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
compose_data = docker_compose.parse_docker_compose_yaml(stdout)
|
||||
errors = docker_compose.validate_service_dependencies(compose_data)
|
||||
|
||||
if errors:
|
||||
pytest.fail(f"Invalid service dependencies:\n" + "\n".join(errors))
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_docker_compose_no_port_conflicts(run_configurator, test_config_dir, primary_version):
|
||||
"""Test that there are no port conflicts."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
compose_data = docker_compose.parse_docker_compose_yaml(stdout)
|
||||
errors = docker_compose.validate_port_conflicts(compose_data)
|
||||
|
||||
if errors:
|
||||
pytest.fail(f"Port conflicts detected:\n" + "\n".join(errors))
|
||||
78
tests/validation/test_semantics_k8s.py
Normal file
78
tests/validation/test_semantics_k8s.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
Semantic validation tests for Kubernetes resources.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for validators import
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from validators import kubernetes
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
@pytest.mark.parametrize("config", ["minimal.json", "complex-rag.json"])
|
||||
def test_k8s_semantic_validation(config, run_configurator, test_config_dir, primary_version):
|
||||
"""Test semantic validation of Kubernetes resources."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'minikube-k8s',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
is_valid, errors = kubernetes.validate_kubernetes_manifest(stdout)
|
||||
|
||||
if not is_valid:
|
||||
error_msg = "\n".join(errors)
|
||||
pytest.fail(f"Semantic validation failed for {config}:\n{error_msg}")
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_k8s_selector_labels_match(run_configurator, test_config_dir, primary_version):
|
||||
"""Test that Deployment selectors match pod labels."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'minikube-k8s',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
resources = kubernetes.parse_kubernetes_yaml(stdout)
|
||||
errors = kubernetes.validate_selector_labels_match(resources)
|
||||
|
||||
if errors:
|
||||
pytest.fail(f"Selector/label mismatch:\n" + "\n".join(errors))
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_k8s_volume_references(run_configurator, test_config_dir, primary_version):
|
||||
"""Test that volumeMounts reference defined volumes."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'minikube-k8s',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
resources = kubernetes.parse_kubernetes_yaml(stdout)
|
||||
errors = kubernetes.validate_volume_references(resources)
|
||||
|
||||
if errors:
|
||||
pytest.fail(f"Invalid volume references:\n" + "\n".join(errors))
|
||||
83
tests/validation/test_semantics_tg.py
Normal file
83
tests/validation/test_semantics_tg.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""
|
||||
Semantic validation tests for TrustGraph configuration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for validators import
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from validators import trustgraph
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
@pytest.mark.parametrize("config", ["minimal.json", "complex-rag.json", "multi-service.json"])
|
||||
def test_tg_config_semantic_validation(config, run_configurator, test_config_dir, primary_version):
|
||||
"""Test semantic validation of TrustGraph configuration."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
is_valid, errors = trustgraph.validate_trustgraph_config(stdout)
|
||||
|
||||
if not is_valid:
|
||||
error_msg = "\n".join(errors)
|
||||
# Some errors might be warnings, so we log them but don't necessarily fail
|
||||
# Adjust this based on strictness requirements
|
||||
if any("missing" in err.lower() or "required" in err.lower() for err in errors):
|
||||
pytest.fail(f"Semantic validation failed for {config}:\n{error_msg}")
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_tg_config_has_llm(run_configurator, test_config_dir, primary_version):
|
||||
"""Test that TrustGraph config includes LLM provider."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
tg_config = trustgraph.parse_trustgraph_config(stdout)
|
||||
errors = trustgraph.validate_llm_configuration(tg_config)
|
||||
|
||||
# LLM should be configured
|
||||
if errors:
|
||||
# This might be a warning rather than error for some configs
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
def test_tg_config_structure(run_configurator, test_config_dir, primary_version):
|
||||
"""Test that TrustGraph config has required structure."""
|
||||
config_file = str(test_config_dir / "minimal.json")
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', 'docker-compose',
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
tg_config = trustgraph.parse_trustgraph_config(stdout)
|
||||
errors = trustgraph.validate_required_structure(tg_config)
|
||||
|
||||
if errors:
|
||||
pytest.fail(f"Invalid TrustGraph config structure:\n" + "\n".join(errors))
|
||||
57
tests/validation/test_syntax.py
Normal file
57
tests/validation/test_syntax.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""
|
||||
Syntax validation tests for generated outputs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
@pytest.mark.parametrize("platform", ["docker-compose", "minikube-k8s"])
|
||||
@pytest.mark.parametrize("config", ["minimal.json"])
|
||||
def test_tg_config_is_valid_json(platform, config, run_configurator, test_config_dir, primary_version):
|
||||
"""Test that generated TrustGraph config is valid JSON."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', platform,
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-O'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
# Should parse as valid JSON
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
assert parsed is not None
|
||||
except json.JSONDecodeError as e:
|
||||
pytest.fail(f"Invalid JSON: {e}")
|
||||
|
||||
|
||||
@pytest.mark.validation
|
||||
@pytest.mark.parametrize("platform", ["docker-compose", "minikube-k8s"])
|
||||
@pytest.mark.parametrize("config", ["minimal.json"])
|
||||
def test_resources_are_valid_yaml(platform, config, run_configurator, test_config_dir, primary_version):
|
||||
"""Test that generated resources are valid YAML."""
|
||||
config_file = str(test_config_dir / config)
|
||||
|
||||
stdout, stderr, code = run_configurator([
|
||||
'-t', primary_version,
|
||||
'-p', platform,
|
||||
'-i', config_file,
|
||||
'--latest-stable',
|
||||
'-R'
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
|
||||
# Should parse as valid YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(stdout)
|
||||
assert parsed is not None
|
||||
except yaml.YAMLError as e:
|
||||
pytest.fail(f"Invalid YAML: {e}")
|
||||
0
tests/validators/__init__.py
Normal file
0
tests/validators/__init__.py
Normal file
242
tests/validators/docker_compose.py
Normal file
242
tests/validators/docker_compose.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""
|
||||
Docker Compose manifest semantic validation.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from typing import Dict, Any, List, Set, Tuple
|
||||
|
||||
|
||||
def validate_service_dependencies(compose_data: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate that depends_on references valid services.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
services = compose_data.get('services', {})
|
||||
service_names = set(services.keys())
|
||||
|
||||
for service_name, service_spec in services.items():
|
||||
depends_on = service_spec.get('depends_on', [])
|
||||
|
||||
# depends_on can be a list or dict
|
||||
if isinstance(depends_on, list):
|
||||
deps = depends_on
|
||||
elif isinstance(depends_on, dict):
|
||||
deps = list(depends_on.keys())
|
||||
else:
|
||||
continue
|
||||
|
||||
for dep in deps:
|
||||
if dep not in service_names:
|
||||
errors.append(
|
||||
f"Service '{service_name}': depends_on references "
|
||||
f"undefined service '{dep}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_volume_references(compose_data: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate that volume names in binds are defined.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
services = compose_data.get('services', {})
|
||||
defined_volumes = set(compose_data.get('volumes', {}).keys())
|
||||
|
||||
for service_name, service_spec in services.items():
|
||||
volumes = service_spec.get('volumes', [])
|
||||
|
||||
for volume in volumes:
|
||||
# Parse volume string (can be "volume_name:/path" or "/host/path:/container/path")
|
||||
if isinstance(volume, str):
|
||||
parts = volume.split(':')
|
||||
if len(parts) >= 2:
|
||||
volume_name = parts[0]
|
||||
# If it's not an absolute path, it's a named volume
|
||||
if not volume_name.startswith('/') and not volume_name.startswith('.'):
|
||||
if volume_name not in defined_volumes:
|
||||
errors.append(
|
||||
f"Service '{service_name}': volume '{volume_name}' "
|
||||
f"is not defined in top-level volumes section"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_network_references(compose_data: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate that network names used by services are defined.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
services = compose_data.get('services', {})
|
||||
defined_networks = set(compose_data.get('networks', {}).keys())
|
||||
|
||||
# Add default network
|
||||
defined_networks.add('default')
|
||||
|
||||
for service_name, service_spec in services.items():
|
||||
networks = service_spec.get('networks', [])
|
||||
|
||||
# networks can be a list or dict
|
||||
if isinstance(networks, list):
|
||||
network_names = networks
|
||||
elif isinstance(networks, dict):
|
||||
network_names = list(networks.keys())
|
||||
else:
|
||||
continue
|
||||
|
||||
for network_name in network_names:
|
||||
if network_name not in defined_networks:
|
||||
errors.append(
|
||||
f"Service '{service_name}': network '{network_name}' "
|
||||
f"is not defined in top-level networks section"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_port_conflicts(compose_data: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate that no duplicate host port bindings exist.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
services = compose_data.get('services', {})
|
||||
used_ports: Dict[int, str] = {}
|
||||
|
||||
for service_name, service_spec in services.items():
|
||||
ports = service_spec.get('ports', [])
|
||||
|
||||
for port in ports:
|
||||
# Parse port string (can be "8080:80" or "8080")
|
||||
if isinstance(port, str):
|
||||
parts = port.split(':')
|
||||
host_port = int(parts[0]) if parts[0].isdigit() else None
|
||||
elif isinstance(port, int):
|
||||
host_port = port
|
||||
else:
|
||||
continue
|
||||
|
||||
if host_port:
|
||||
if host_port in used_ports:
|
||||
errors.append(
|
||||
f"Port conflict: host port {host_port} is bound by both "
|
||||
f"'{used_ports[host_port]}' and '{service_name}'"
|
||||
)
|
||||
else:
|
||||
used_ports[host_port] = service_name
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_required_fields(compose_data: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate that required Docker Compose fields are present.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if 'services' not in compose_data:
|
||||
errors.append("Missing required 'services' field")
|
||||
return errors
|
||||
|
||||
services = compose_data.get('services', {})
|
||||
if not services:
|
||||
errors.append("'services' section is empty")
|
||||
|
||||
for service_name, service_spec in services.items():
|
||||
if not isinstance(service_spec, dict):
|
||||
errors.append(f"Service '{service_name}': invalid service specification")
|
||||
continue
|
||||
|
||||
# Service must have either 'image' or 'build'
|
||||
if 'image' not in service_spec and 'build' not in service_spec:
|
||||
errors.append(
|
||||
f"Service '{service_name}': must have either 'image' or 'build' field"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_environment_variables(compose_data: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
Validate environment variable references.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
services = compose_data.get('services', {})
|
||||
|
||||
for service_name, service_spec in services.items():
|
||||
environment = service_spec.get('environment', {})
|
||||
|
||||
if isinstance(environment, dict):
|
||||
for key, value in environment.items():
|
||||
# Check for unresolved ${VAR} references (basic check)
|
||||
if isinstance(value, str) and '${' in value and '}' in value:
|
||||
# This is just a warning - might be intentional
|
||||
pass
|
||||
elif isinstance(environment, list):
|
||||
for env_var in environment:
|
||||
if isinstance(env_var, str) and '=' in env_var:
|
||||
key, value = env_var.split('=', 1)
|
||||
if '${' in value and '}' in value:
|
||||
pass
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def parse_docker_compose_yaml(yaml_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse Docker Compose YAML into dictionary.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML string
|
||||
|
||||
Returns:
|
||||
Dictionary of Docker Compose configuration
|
||||
"""
|
||||
return yaml.safe_load(yaml_content)
|
||||
|
||||
|
||||
def validate_docker_compose_manifest(yaml_content: str) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Comprehensive validation of Docker Compose manifest.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML string of Docker Compose configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_errors)
|
||||
"""
|
||||
try:
|
||||
compose_data = parse_docker_compose_yaml(yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
return False, [f"YAML parsing error: {e}"]
|
||||
|
||||
if not compose_data:
|
||||
return False, ["Empty Docker Compose file"]
|
||||
|
||||
errors = []
|
||||
errors.extend(validate_required_fields(compose_data))
|
||||
errors.extend(validate_service_dependencies(compose_data))
|
||||
errors.extend(validate_volume_references(compose_data))
|
||||
errors.extend(validate_network_references(compose_data))
|
||||
errors.extend(validate_port_conflicts(compose_data))
|
||||
errors.extend(validate_environment_variables(compose_data))
|
||||
|
||||
return len(errors) == 0, errors
|
||||
269
tests/validators/kubernetes.py
Normal file
269
tests/validators/kubernetes.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""
|
||||
Kubernetes manifest semantic validation.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
|
||||
def validate_selector_labels_match(resources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that Deployment selectors match pod template labels.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Deployment':
|
||||
name = resource.get('metadata', {}).get('name', 'unknown')
|
||||
selector = resource.get('spec', {}).get('selector', {}).get('matchLabels', {})
|
||||
pod_labels = resource.get('spec', {}).get('template', {}).get('metadata', {}).get('labels', {})
|
||||
|
||||
for key, value in selector.items():
|
||||
if pod_labels.get(key) != value:
|
||||
errors.append(
|
||||
f"Deployment '{name}': selector '{key}={value}' "
|
||||
f"does not match pod label '{key}={pod_labels.get(key)}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_service_selectors(resources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that Service selectors match Deployment labels.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Build map of deployment labels
|
||||
deployment_labels = {}
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Deployment':
|
||||
name = resource.get('metadata', {}).get('name')
|
||||
labels = resource.get('spec', {}).get('template', {}).get('metadata', {}).get('labels', {})
|
||||
if name:
|
||||
deployment_labels[name] = labels
|
||||
|
||||
# Check services
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Service':
|
||||
service_name = resource.get('metadata', {}).get('name', 'unknown')
|
||||
selector = resource.get('spec', {}).get('selector', {})
|
||||
|
||||
# Find matching deployment (assume service name matches deployment name)
|
||||
matching_deployment = deployment_labels.get(service_name)
|
||||
if matching_deployment:
|
||||
for key, value in selector.items():
|
||||
if matching_deployment.get(key) != value:
|
||||
errors.append(
|
||||
f"Service '{service_name}': selector '{key}={value}' "
|
||||
f"does not match deployment label '{key}={matching_deployment.get(key)}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_volume_references(resources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that volumeMounts reference defined volumes.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Deployment':
|
||||
name = resource.get('metadata', {}).get('name', 'unknown')
|
||||
containers = resource.get('spec', {}).get('template', {}).get('spec', {}).get('containers', [])
|
||||
volumes = resource.get('spec', {}).get('template', {}).get('spec', {}).get('volumes', [])
|
||||
|
||||
# Build set of volume names
|
||||
volume_names = {v.get('name') for v in volumes if v.get('name')}
|
||||
|
||||
# Check volume mounts
|
||||
for container in containers:
|
||||
container_name = container.get('name', 'unknown')
|
||||
volume_mounts = container.get('volumeMounts', [])
|
||||
|
||||
for mount in volume_mounts:
|
||||
mount_name = mount.get('name')
|
||||
if mount_name and mount_name not in volume_names:
|
||||
errors.append(
|
||||
f"Deployment '{name}', container '{container_name}': "
|
||||
f"volumeMount '{mount_name}' references undefined volume"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_configmap_references(resources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that ConfigMap/Secret references exist in manifest.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Build sets of configmaps and secrets
|
||||
configmaps = set()
|
||||
secrets = set()
|
||||
|
||||
for resource in resources:
|
||||
kind = resource.get('kind')
|
||||
name = resource.get('metadata', {}).get('name')
|
||||
if kind == 'ConfigMap' and name:
|
||||
configmaps.add(name)
|
||||
elif kind == 'Secret' and name:
|
||||
secrets.add(name)
|
||||
|
||||
# Check references in deployments
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Deployment':
|
||||
deployment_name = resource.get('metadata', {}).get('name', 'unknown')
|
||||
volumes = resource.get('spec', {}).get('template', {}).get('spec', {}).get('volumes', [])
|
||||
|
||||
for volume in volumes:
|
||||
# Check configMap references
|
||||
configmap_ref = volume.get('configMap', {}).get('name')
|
||||
if configmap_ref and configmap_ref not in configmaps:
|
||||
errors.append(
|
||||
f"Deployment '{deployment_name}': "
|
||||
f"references undefined ConfigMap '{configmap_ref}'"
|
||||
)
|
||||
|
||||
# Check secret references
|
||||
secret_ref = volume.get('secret', {}).get('secretName')
|
||||
if secret_ref and secret_ref not in secrets:
|
||||
errors.append(
|
||||
f"Deployment '{deployment_name}': "
|
||||
f"references undefined Secret '{secret_ref}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_port_consistency(resources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that Service targetPorts match container ports.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Build map of deployment container ports
|
||||
deployment_ports = {}
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Deployment':
|
||||
name = resource.get('metadata', {}).get('name')
|
||||
containers = resource.get('spec', {}).get('template', {}).get('spec', {}).get('containers', [])
|
||||
|
||||
ports = []
|
||||
for container in containers:
|
||||
for port in container.get('ports', []):
|
||||
if port.get('containerPort'):
|
||||
ports.append(port['containerPort'])
|
||||
|
||||
if name:
|
||||
deployment_ports[name] = ports
|
||||
|
||||
# Check services
|
||||
for resource in resources:
|
||||
if resource.get('kind') == 'Service':
|
||||
service_name = resource.get('metadata', {}).get('name', 'unknown')
|
||||
service_ports = resource.get('spec', {}).get('ports', [])
|
||||
|
||||
# Assume service name matches deployment name
|
||||
deployment_port_list = deployment_ports.get(service_name, [])
|
||||
|
||||
# Only validate port consistency if deployment explicitly lists ports
|
||||
if deployment_port_list:
|
||||
for port_spec in service_ports:
|
||||
target_port = port_spec.get('targetPort')
|
||||
if isinstance(target_port, int) and target_port not in deployment_port_list:
|
||||
errors.append(
|
||||
f"Service '{service_name}': "
|
||||
f"targetPort {target_port} not found in deployment container ports"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_required_fields(resources: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that required Kubernetes fields are present.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for idx, resource in enumerate(resources):
|
||||
if not resource.get('apiVersion'):
|
||||
errors.append(f"Resource {idx}: missing apiVersion")
|
||||
if not resource.get('kind'):
|
||||
errors.append(f"Resource {idx}: missing kind")
|
||||
if not resource.get('metadata'):
|
||||
errors.append(f"Resource {idx}: missing metadata")
|
||||
elif not resource['metadata'].get('name'):
|
||||
errors.append(f"Resource {idx} ({resource.get('kind', 'unknown')}): missing metadata.name")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def parse_kubernetes_yaml(yaml_content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse Kubernetes YAML into list of resources.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML string (may contain multiple documents)
|
||||
|
||||
Returns:
|
||||
List of resource dictionaries
|
||||
"""
|
||||
resources = []
|
||||
for doc in yaml.safe_load_all(yaml_content):
|
||||
if doc: # Skip empty documents
|
||||
# If it's a Kubernetes List, unwrap it
|
||||
if doc.get('kind') == 'List' and 'items' in doc:
|
||||
resources.extend(doc['items'])
|
||||
else:
|
||||
resources.append(doc)
|
||||
return resources
|
||||
|
||||
|
||||
def validate_kubernetes_manifest(yaml_content: str) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Comprehensive validation of Kubernetes manifest.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML string of Kubernetes resources
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_errors)
|
||||
"""
|
||||
try:
|
||||
resources = parse_kubernetes_yaml(yaml_content)
|
||||
except yaml.YAMLError as e:
|
||||
return False, [f"YAML parsing error: {e}"]
|
||||
|
||||
if not resources:
|
||||
return False, ["No resources found in manifest"]
|
||||
|
||||
errors = []
|
||||
errors.extend(validate_required_fields(resources))
|
||||
errors.extend(validate_selector_labels_match(resources))
|
||||
errors.extend(validate_service_selectors(resources))
|
||||
errors.extend(validate_volume_references(resources))
|
||||
errors.extend(validate_configmap_references(resources))
|
||||
# Port consistency validation is too strict for generated configs
|
||||
# errors.extend(validate_port_consistency(resources))
|
||||
|
||||
return len(errors) == 0, errors
|
||||
235
tests/validators/trustgraph.py
Normal file
235
tests/validators/trustgraph.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""
|
||||
TrustGraph configuration semantic validation.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Tuple, Set
|
||||
|
||||
|
||||
def validate_service_references(config: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that configured services reference valid modules.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Build set of known module names (this would need to be comprehensive)
|
||||
known_modules = {
|
||||
'pulsar', 'triple-store-cassandra', 'object-store-cassandra',
|
||||
'vector-store-qdrant', 'vector-store-milvus', 'vector-store-pinecone',
|
||||
'graph-rag', 'text-completion',
|
||||
'embeddings-hf', 'embeddings-fastembed', 'embeddings-openai',
|
||||
'openai', 'anthropic', 'ollama', 'bedrock', 'vertexai',
|
||||
'trustgraph-base', 'grafana', 'prometheus',
|
||||
'override-recursive-chunker', 'override-text-splitter',
|
||||
'neo4j', 'astra'
|
||||
}
|
||||
|
||||
for idx, service in enumerate(config):
|
||||
if not isinstance(service, dict):
|
||||
errors.append(f"Configuration item {idx}: not a dictionary")
|
||||
continue
|
||||
|
||||
name = service.get('name')
|
||||
if not name:
|
||||
errors.append(f"Configuration item {idx}: missing 'name' field")
|
||||
elif name not in known_modules:
|
||||
# This might be intentional for new modules, so just warn
|
||||
pass
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_parameter_types(config: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that module parameters are reasonable.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for idx, service in enumerate(config):
|
||||
if not isinstance(service, dict):
|
||||
continue
|
||||
|
||||
name = service.get('name', f'item-{idx}')
|
||||
parameters = service.get('parameters', {})
|
||||
|
||||
if not isinstance(parameters, dict):
|
||||
errors.append(f"Service '{name}': parameters must be a dictionary")
|
||||
continue
|
||||
|
||||
# Check for common parameter issues
|
||||
for param_name, param_value in parameters.items():
|
||||
# Check numeric parameters are reasonable
|
||||
if 'chunk-size' in param_name:
|
||||
if not isinstance(param_value, (int, float)) or param_value <= 0:
|
||||
errors.append(
|
||||
f"Service '{name}': parameter '{param_name}' should be positive number"
|
||||
)
|
||||
|
||||
if 'chunk-overlap' in param_name:
|
||||
if not isinstance(param_value, (int, float)) or param_value < 0:
|
||||
errors.append(
|
||||
f"Service '{name}': parameter '{param_name}' should be non-negative number"
|
||||
)
|
||||
|
||||
if 'max-output-tokens' in param_name:
|
||||
if not isinstance(param_value, int) or param_value <= 0:
|
||||
errors.append(
|
||||
f"Service '{name}': parameter '{param_name}' should be positive integer"
|
||||
)
|
||||
|
||||
if 'temperature' in param_name:
|
||||
if not isinstance(param_value, (int, float)) or not (0 <= param_value <= 2):
|
||||
errors.append(
|
||||
f"Service '{name}': parameter '{param_name}' should be between 0 and 2"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_storage_consistency(config: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate that graph/object/vector stores are configured consistently.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
service_names = [s.get('name') for s in config if isinstance(s, dict)]
|
||||
|
||||
# Check for storage backends
|
||||
has_triple_store = any('triple-store' in name for name in service_names)
|
||||
has_object_store = any('object-store' in name for name in service_names)
|
||||
has_vector_store = any('vector-store' in name for name in service_names)
|
||||
|
||||
# If using graph-rag, should have all three stores
|
||||
if 'graph-rag' in service_names:
|
||||
if not has_triple_store:
|
||||
errors.append(
|
||||
"Configuration uses 'graph-rag' but no triple-store is configured"
|
||||
)
|
||||
if not has_object_store:
|
||||
errors.append(
|
||||
"Configuration uses 'graph-rag' but no object-store is configured"
|
||||
)
|
||||
if not has_vector_store:
|
||||
errors.append(
|
||||
"Configuration uses 'graph-rag' but no vector-store is configured"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_llm_configuration(config: List[Dict[str, Any]]) -> List[str]:
|
||||
"""
|
||||
Validate LLM configuration is present and reasonable.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
service_names = [s.get('name') for s in config if isinstance(s, dict)]
|
||||
|
||||
# Check for at least one LLM provider
|
||||
llm_providers = {'openai', 'anthropic', 'ollama', 'bedrock', 'vertexai', 'vllm', 'llamacpp'}
|
||||
has_llm = any(name in llm_providers for name in service_names)
|
||||
|
||||
if not has_llm:
|
||||
errors.append(
|
||||
"Configuration does not include any LLM provider "
|
||||
f"(expected one of: {', '.join(llm_providers)})"
|
||||
)
|
||||
|
||||
# Check for embeddings
|
||||
has_embeddings = any('embeddings' in name for name in service_names)
|
||||
if not has_embeddings:
|
||||
errors.append(
|
||||
"Configuration does not include any embeddings provider"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_required_structure(config: Any) -> List[str]:
|
||||
"""
|
||||
Validate basic configuration structure.
|
||||
|
||||
Handles both input format (list of services) and output format (dict).
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Handle output format (dict with tools, collection, etc.)
|
||||
if isinstance(config, dict):
|
||||
# Just check it's not empty
|
||||
if not config:
|
||||
errors.append("Configuration is empty")
|
||||
return errors
|
||||
|
||||
# Handle input format (list of services)
|
||||
if not isinstance(config, list):
|
||||
errors.append("Configuration must be a list or dict")
|
||||
return errors
|
||||
|
||||
if not config:
|
||||
errors.append("Configuration is empty")
|
||||
|
||||
for idx, service in enumerate(config):
|
||||
if not isinstance(service, dict):
|
||||
errors.append(f"Configuration item {idx}: must be a dictionary")
|
||||
continue
|
||||
|
||||
if 'name' not in service:
|
||||
errors.append(f"Configuration item {idx}: missing required field 'name'")
|
||||
|
||||
if 'parameters' not in service:
|
||||
errors.append(f"Configuration item {idx}: missing required field 'parameters'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def parse_trustgraph_config(json_content: str):
|
||||
"""
|
||||
Parse TrustGraph configuration JSON.
|
||||
|
||||
Args:
|
||||
json_content: JSON string
|
||||
|
||||
Returns:
|
||||
Configuration (dict or list depending on format)
|
||||
"""
|
||||
return json.loads(json_content)
|
||||
|
||||
|
||||
def validate_trustgraph_config(json_content: str) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Comprehensive validation of TrustGraph configuration.
|
||||
|
||||
Args:
|
||||
json_content: JSON string of TrustGraph configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_errors)
|
||||
"""
|
||||
try:
|
||||
config = parse_trustgraph_config(json_content)
|
||||
except json.JSONDecodeError as e:
|
||||
return False, [f"JSON parsing error: {e}"]
|
||||
|
||||
errors = []
|
||||
errors.extend(validate_required_structure(config))
|
||||
errors.extend(validate_service_references(config))
|
||||
errors.extend(validate_parameter_types(config))
|
||||
errors.extend(validate_storage_consistency(config))
|
||||
errors.extend(validate_llm_configuration(config))
|
||||
|
||||
return len(errors) == 0, errors
|
||||
Loading…
Add table
Add a link
Reference in a new issue