diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19b7511..541e283 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,53 @@ We use GitHub to host code, to track issues and feature requests, as well as acc 5. Make sure your code passes all code quality checks. 6. Issue that pull request! +## Version Control Guidelines + +### Branch Naming +- Feature branches: `feature/short-description` +- Bug fixes: `fix/issue-description` +- Documentation: `docs/what-changed` +- Release branches: `release/version-number` + +### Commit Messages +Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +Types: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation only changes +- `style`: Changes that do not affect the meaning of the code +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `test`: Adding missing tests or correcting existing tests +- `chore`: Changes to the build process or auxiliary tools + +Example: +``` +feat(value): add support for remote storage backends + +- Implement ValueBackend interface +- Add AWS Secrets Manager backend +- Add Azure Key Vault backend + +Closes #123 +``` + +## Important Documentation + +Before contributing, please review these important documents: + +1. [Code Structure](docs/Development/code-structure.md) - Understanding the codebase organization +2. [Testing Guide](docs/Development/testing.md) - Testing standards and practices +3. [Architecture Overview](docs/Design/low-level-design.md) - System architecture and design decisions +4. [ADRs](docs/ADRs/) - Architecture Decision Records + ## Development Setup 1. Clone your fork: @@ -86,12 +133,6 @@ tox -e py39 # For Python 3.9 - Follow Google style for docstrings - Keep the README.md and other documentation up to date -### Documentation Requirements - -- Use docstrings for all public modules, functions, classes, and methods -- Follow Google style for docstrings -- Keep the README.md and other documentation up to date - #### Required Extensions When contributing to documentation, please note that we use the following extensions: @@ -115,7 +156,7 @@ To preview Mermaid diagrams locally, you can: ## Any contributions you make will be under the MIT Software License -In short, when you submit code changes, your submissions are understood to be under the same [MIT License](LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](./LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. ## Report bugs using GitHub's [issue tracker](https://github.com/zipstack/helm-values-manager/issues) @@ -135,4 +176,4 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue] ## License -By contributing, you agree that your contributions will be licensed under its MIT License. +By contributing, you agree that your contributions will be licensed under its [MIT License](./LICENSE). diff --git a/docs/ADRs/001-helm-values-manager.md b/docs/ADRs/001-helm-values-manager.md index 394ac16..7cfe0db 100644 --- a/docs/ADRs/001-helm-values-manager.md +++ b/docs/ADRs/001-helm-values-manager.md @@ -9,7 +9,7 @@ Managing configurations and secrets across multiple Kubernetes deployments is a Key challenges include: - Ensuring configurations remain consistent across different environments (e.g., dev, staging, production). - Managing sensitive values securely using external secret management systems. -- Automating the generation of `values.json` while integrating with GitOps tools like ArgoCD. +- Automating the generation of `values.yaml` while integrating with GitOps tools like ArgoCD. - Providing a user-friendly CLI that integrates well with Helm workflows. ## Decision @@ -22,7 +22,7 @@ We have decided to implement the **Helm Values Manager** as a **Helm plugin writ 4. **Secret Storage Abstraction:** Securely manages sensitive values by integrating with AWS Secrets Manager, Azure Key Vault, and HashiCorp Vault. 5. **CLI-Based Approach:** Interactive commands for managing configurations and secrets. 6. **Autocomplete Support:** Smooth CLI experience. -7. **ArgoCD Compatibility:** Generates `values.json` dynamically for GitOps workflows. +7. **ArgoCD Compatibility:** Generates `values.yaml` dynamically for GitOps workflows. 8. **JSON for Configuration:** Using JSON for configuration files provides better schema validation and consistent parsing across different platforms. ### Value Storage Model diff --git a/docs/ADRs/005-unified-backend-approach.md b/docs/ADRs/005-unified-backend-approach.md new file mode 100644 index 0000000..1305cce --- /dev/null +++ b/docs/ADRs/005-unified-backend-approach.md @@ -0,0 +1,137 @@ +# ADR-005: Unified Backend Approach for Value Storage + +## Status +Proposed + +## Context +Currently, the Value class handles two distinct storage types: local and remote. This creates a split in logic within the Value class, requiring different code paths and validation rules based on the storage type. This complexity makes the code harder to maintain and test. + +## Decision +We will remove the storage_type distinction from the Value class and implement a SimpleValueBackend for non-sensitive data (previously handled as "local" storage). This means: + +1. Value class will: + - Only interact with backends through a unified interface + - Not need to know about storage types + - Have simpler logic and better separation of concerns + +2. Storage backends will: + - Include a new SimpleValueBackend for non-sensitive data + - All implement the same ValueBackend interface + - Be responsible for their specific storage mechanisms + +3. Configuration will: + - Use SimpleValueBackend internally for non-sensitive values + - Use secure backends (AWS/Azure) for sensitive values + - Backend selection handled by the system based on value sensitivity + +## Consequences + +### Positive +1. **Cleaner Value Class** + - Removes storage type logic + - Single consistent interface for all operations + - Better separation of concerns + +2. **Unified Testing** + - All storage types tested through the same interface + - No need for separate local/remote test cases + - Easier to mock and verify behavior + +3. **More Flexible** + - Easy to add new backend types + - Consistent interface for all storage types + - Clear extension points + +4. **Better Security Model** + - Storage backend choice driven by data sensitivity + - Clear separation between sensitive and non-sensitive data + - Explicit in configuration + +### Negative +1. **Slight Performance Impact** + - Additional method calls for simple value operations + - Extra object creation for SimpleValueBackend + +### Neutral +1. **Configuration Changes** + - Backend selection based on value sensitivity + - Transparent to end users + +## Implementation Plan + +1. **Backend Development** + ```python + class SimpleValueBackend(ValueBackend): + def __init__(self): + self._values = {} + + def get_value(self, path: str, environment: str) -> str: + key = self._make_key(path, environment) + return self._values[key] + + def set_value(self, path: str, environment: str, value: str) -> None: + key = self._make_key(path, environment) + self._values[key] = value + ``` + +2. **Value Class Simplification** + ```python + @dataclass + class Value: + path: str + environment: str + _backend: ValueBackend + + def get(self) -> str: + return self._backend.get_value(self.path, self.environment) + + def set(self, value: str) -> None: + if not isinstance(value, str): + raise ValueError("Value must be a string") + self._backend.set_value(self.path, self.environment, value) + ``` + +3. **Configuration Example** + ```json + { + "version": "1.0", + "release": "my-app", + "deployments": { + "prod": { + "backend": "aws", + "auth": { + "type": "managed_identity" + }, + "backend_config": { + "region": "us-west-2" + } + } + }, + "config": [ + { + "path": "app.replicas", + "description": "Number of application replicas", + "required": true, + "sensitive": false, + "values": { + "dev": "3", + "prod": "5" + } + }, + { + "path": "app.secretKey", + "description": "Application secret key", + "required": true, + "sensitive": true, + "values": { + "dev": "dev-key-123", + "prod": "prod-key-456" + } + } + ] + } + ``` + + The system will automatically: + - Use SimpleValueBackend for non-sensitive values (app.replicas) + - Use configured secure backend for sensitive values (app.secretKey) diff --git a/docs/Design/low-level-design.md b/docs/Design/low-level-design.md index 9f16d85..9cf09bc 100644 --- a/docs/Design/low-level-design.md +++ b/docs/Design/low-level-design.md @@ -28,13 +28,11 @@ classDiagram class Value { +str path +str environment - +str storage_type -ValueBackend _backend - -Optional~str~ _value +get() str +set(value: str) None +to_dict() dict - +from_dict(data: dict, path: str, environment: str, backend: ValueBackend) Value + +from_dict(data: dict, backend: ValueBackend) Value } class ConfigValue { @@ -49,6 +47,7 @@ classDiagram +str name +Dict~str,Any~ auth +str backend + +Dict~str,Any~ backend_config } class BaseCommand { @@ -61,22 +60,28 @@ classDiagram class ValueBackend { <> - +get_value(key: str)* str - +set_value(key: str, value: str)* None + +get_value(path: str, environment: str)* str + +set_value(path: str, environment: str, value: str)* None +validate_auth_config(auth_config: dict)* None } + class SimpleValueBackend { + -Dict~str,str~ values + +get_value(path: str, environment: str) str + +set_value(path: str, environment: str, value: str) None + } + class AWSSecretsBackend { -SecretsManagerClient client - +get_value(key: str) str - +set_value(key: str, value: str) None + +get_value(path: str, environment: str) str + +set_value(path: str, environment: str, value: str) None +validate_auth_config(auth_config: dict) None } class AzureKeyVaultBackend { -KeyVaultClient client - +get_value(key: str) str - +set_value(key: str, value: str) None + +get_value(path: str, environment: str) str + +set_value(path: str, environment: str, value: str) None +validate_auth_config(auth_config: dict) None } @@ -84,7 +89,8 @@ classDiagram HelmValuesConfig "1" *-- "*" Deployment HelmValuesConfig "1" *-- "*" PathData PathData "1" *-- "*" Value - Value "1" o-- "0..1" ValueBackend + Value "1" o-- "1" ValueBackend + ValueBackend <|.. SimpleValueBackend ValueBackend <|.. AWSSecretsBackend ValueBackend <|.. AzureKeyVaultBackend BaseCommand <|-- SetValueCommand @@ -98,36 +104,25 @@ The system uses a unified approach to value storage and resolution through the ` ```python class Value: def __init__(self, path: str, environment: str, - storage_type: str = "local", - backend: Optional[ValueBackend] = None): + backend: ValueBackend): self.path = path self.environment = environment - self.storage_type = storage_type self._backend = backend - self._value: Optional[str] = None def get(self) -> str: """Get the resolved value""" - if self.storage_type == "local": - return self._value - return self._backend.get_value( - self._generate_key(self.path, self.environment) - ) + return self._backend.get_value(self.path, self.environment) def set(self, value: str) -> None: """Set the value""" - if self.storage_type == "local": - self._value = value - else: - self._backend.set_value( - self._generate_key(self.path, self.environment), - value - ) + if not isinstance(value, str): + raise ValueError("Value must be a string") + self._backend.set_value(self.path, self.environment, value) ``` Key features: - Encapsulated value resolution logic -- Unified interface for local and remote storage +- Unified interface for all storage backends - Clear separation of concerns - Type-safe value handling @@ -174,13 +169,13 @@ The `ValueBackend` interface defines the contract for value storage: ```python class ValueBackend(ABC): @abstractmethod - def get_value(self, key: str) -> str: - """Get a value from storage using a unique key.""" + def get_value(self, path: str, environment: str) -> str: + """Get a value from storage.""" pass @abstractmethod - def set_value(self, key: str, value: str) -> None: - """Store a value using a unique key.""" + def set_value(self, path: str, environment: str, value: str) -> None: + """Store a value.""" pass @abstractmethod @@ -190,6 +185,7 @@ class ValueBackend(ABC): ``` Implementations: +- SimpleValueBackend (for non-sensitive values) - AWS Secrets Manager Backend - Azure Key Vault Backend - Additional backends can be easily added @@ -198,22 +194,34 @@ Implementations: ### 1. Configuration Structure -The configuration is stored in a hierarchical structure: - -```python +The configuration follows the v1 schema: +```json { - "app.replicas": { - "metadata": { - "description": "Number of replicas", - "required": True, - "sensitive": False - }, - "values": { - "dev": Value(path="app.replicas", environment="dev", storage_type="local"), - "prod": Value(path="app.replicas", environment="prod", - storage_type="aws", backend=aws_backend) + "version": "1.0", + "release": "my-app", + "deployments": { + "prod": { + "backend": "aws", + "auth": { + "type": "managed_identity" + }, + "backend_config": { + "region": "us-west-2" + } } - } + }, + "config": [ + { + "path": "app.replicas", + "description": "Number of application replicas", + "required": true, + "sensitive": false, + "values": { + "dev": "3", + "prod": "5" + } + } + ] } ``` @@ -226,7 +234,7 @@ The configuration is stored in a hierarchical structure: 2. Value Resolution: - Uses `Value` class to handle resolution - - Automatically selects local or remote storage + - Automatically selects storage backend - Handles errors and validation ### 3. Security Features diff --git a/docs/Design/sequence-diagrams.md b/docs/Design/sequence-diagrams.md index a0708b7..ea700c0 100644 --- a/docs/Design/sequence-diagrams.md +++ b/docs/Design/sequence-diagrams.md @@ -147,28 +147,28 @@ sequenceDiagram BaseCommand->>HelmValuesConfig: set_value(path, env, value) activate HelmValuesConfig - alt Value Exists - HelmValuesConfig->>Value: set(value) - activate Value - else Create New Value + HelmValuesConfig->>HelmValuesConfig: find_config_item(path) + HelmValuesConfig->>HelmValuesConfig: get_backend_for_config(config_item, env) + + alt Sensitive Value HelmValuesConfig->>HelmValuesConfig: get_deployment(env) - HelmValuesConfig->>ValueBackend: create_backend(deployment) - HelmValuesConfig->>Value: create(path, env, backend) - HelmValuesConfig->>Value: set(value) - activate Value + HelmValuesConfig->>ValueBackend: create_secure_backend(deployment) + else Non-Sensitive Value + HelmValuesConfig->>ValueBackend: create_simple_backend() end - alt Local Storage - Value->>Value: store_value_locally() - else Remote Storage - Value->>ValueBackend: set_value(key, value) - activate ValueBackend + HelmValuesConfig->>Value: create(path, env, backend) + activate Value + Value->>ValueBackend: set_value(path, env, value) + activate ValueBackend + + alt Secure Backend ValueBackend->>Storage: write(key, value) Storage-->>ValueBackend: success - ValueBackend-->>Value: success - deactivate ValueBackend end + ValueBackend-->>Value: success + deactivate ValueBackend Value-->>HelmValuesConfig: success deactivate Value @@ -202,30 +202,44 @@ sequenceDiagram CLI->>BaseCommand: execute() activate BaseCommand + BaseCommand->>BaseCommand: acquire_lock() BaseCommand->>BaseCommand: load_config() BaseCommand->>HelmValuesConfig: get_value(path, env) activate HelmValuesConfig - HelmValuesConfig->>Value: get() + HelmValuesConfig->>HelmValuesConfig: find_config_item(path) + HelmValuesConfig->>HelmValuesConfig: get_backend_for_config(config_item, env) + + alt Sensitive Value + HelmValuesConfig->>HelmValuesConfig: get_deployment(env) + HelmValuesConfig->>ValueBackend: create_secure_backend(deployment) + else Non-Sensitive Value + HelmValuesConfig->>ValueBackend: create_simple_backend() + end + + HelmValuesConfig->>Value: create(path, env, backend) activate Value + Value->>ValueBackend: get_value(path, env) + activate ValueBackend - alt Local Storage - Value->>Value: return_local_value() - else Remote Storage - Value->>ValueBackend: get_value(key) - ValueBackend-->>Value: value + alt Secure Backend + ValueBackend->>Storage: read(key) + Storage-->>ValueBackend: value end - Value-->>HelmValuesConfig: resolved_value + ValueBackend-->>Value: value + deactivate ValueBackend + Value-->>HelmValuesConfig: value deactivate Value HelmValuesConfig-->>BaseCommand: value deactivate HelmValuesConfig + BaseCommand->>BaseCommand: release_lock() BaseCommand-->>CLI: value deactivate BaseCommand - CLI-->>User: display value + CLI-->>User: value deactivate CLI ``` diff --git a/docs/Development/code-structure.md b/docs/Development/code-structure.md new file mode 100644 index 0000000..5f2834a --- /dev/null +++ b/docs/Development/code-structure.md @@ -0,0 +1,97 @@ +# Code Structure + +This document outlines the organization and structure of the Helm Values Manager codebase. + +## Package Organization + +``` +helm_values_manager/ +├── models/ # Core data models +│ ├── __init__.py +│ └── value.py # Value class for configuration management +├── backends/ # Storage backend implementations +│ ├── __init__.py +│ ├── base.py # Abstract base class for backends +│ ├── aws.py # AWS Secrets Manager backend +│ └── azure.py # Azure Key Vault backend +└── cli.py # CLI implementation +``` + +## Core Components + +### Models Package + +The `models` package contains the core data structures used throughout the application. + +#### Value Class +The `Value` class is a fundamental component that handles configuration values: + +```python +class Value: + """ + Represents a configuration value with storage strategy. + + Attributes: + path (str): Configuration path (e.g., "app.replicas") + environment (str): Environment name (e.g., "dev", "prod") + storage_type (str): Type of storage ("local" or "remote") + """ +``` + +**Key Features:** +- Dual storage support (local/remote) +- Serialization capabilities +- Input validation +- Error handling + +For implementation details, see the [Value Class Implementation Guide](value-class.md). + +### Backends Package + +The `backends` package provides storage implementations for different providers: + +- `base.py`: Abstract base class defining the backend interface +- `aws.py`: AWS Secrets Manager implementation +- `azure.py`: Azure Key Vault implementation + +Each backend must implement: +```python +class ValueBackend(ABC): + @abstractmethod + def get_value(self, key: str) -> str: + """Get a value from storage.""" + pass + + @abstractmethod + def set_value(self, key: str, value: str) -> None: + """Set a value in storage.""" + pass +``` + +## Design Principles + +1. **Clean Architecture** + - Clear separation of concerns + - Dependency inversion for backends + - Model independence from storage + +2. **Error Handling** + - Custom exceptions for domain errors + - Comprehensive error messages + - Graceful error recovery + +3. **Security** + - Secure value storage + - Input validation + - No sensitive data logging + +4. **Testing** + - Unit tests for all components + - Integration tests for backends + - High code coverage requirement + +## Further Reading + +- [Architecture Overview](architecture.md) +- [Testing Guide](testing.md) +- [Security Guidelines](security.md) diff --git a/docs/Development/tasks.md b/docs/Development/tasks.md index d8e326e..809997f 100644 --- a/docs/Development/tasks.md +++ b/docs/Development/tasks.md @@ -3,23 +3,23 @@ ## Core Components ### Value Class Implementation -- [ ] Create basic Value class structure - - [ ] Add path, environment, storage_type, and backend attributes - - [ ] Implement constructor with type hints - - [ ] Add basic validation for attributes -- [ ] Implement value resolution - - [ ] Add get() method with local value support - - [ ] Add set() method with local value support - - [ ] Add remote value support in get() method - - [ ] Add remote value support in set() method -- [ ] Add serialization support - - [ ] Implement to_dict() method - - [ ] Implement from_dict() static method - - [ ] Add tests for serialization/deserialization -- [ ] Add value validation - - [ ] Implement basic type validation - - [ ] Add support for required field validation - - [ ] Add support for sensitive field handling +- [x] Create basic Value class structure + - [x] Add path, environment, storage_type, and backend attributes + - [x] Implement constructor with type hints + - [x] Add basic validation for attributes +- [x] Implement value resolution + - [x] Add get() method with local value support + - [x] Add set() method with local value support + - [x] Add remote value support in get() method + - [x] Add remote value support in set() method +- [x] Add serialization support + - [x] Implement to_dict() method + - [x] Implement from_dict() static method + - [x] Add tests for serialization/deserialization +- [x] Add value validation + - [x] Implement basic type validation + - [x] Add support for required field validation + - [x] Add support for sensitive field handling ### PathData Class Implementation - [ ] Create PathData class diff --git a/docs/Development/testing.md b/docs/Development/testing.md new file mode 100644 index 0000000..30458c8 --- /dev/null +++ b/docs/Development/testing.md @@ -0,0 +1,168 @@ +# Testing Guide + +This document outlines the testing standards and practices for the Helm Values Manager project. + +## Test Organization + +``` +tests/ +├── unit/ # Unit tests +│ ├── models/ # Tests for core models +│ │ └── test_value.py +│ └── backends/ # Tests for backend implementations +├── integration/ # Integration tests +│ └── backends/ # Backend integration tests +└── conftest.py # Shared test fixtures +``` + +## Test Categories + +### Unit Tests + +Unit tests focus on testing individual components in isolation. They should: +- Test a single unit of functionality +- Mock external dependencies +- Be fast and independent +- Cover both success and error cases + +Example from `test_value.py`: +```python +def test_value_init_local(): + """Test Value initialization with local storage.""" + value = Value(path="app.replicas", environment="dev") + assert value.path == "app.replicas" + assert value.environment == "dev" + assert value.storage_type == "local" +``` + +### Integration Tests + +Integration tests verify component interactions, especially with external services: +- Test actual backend implementations +- Verify configuration loading +- Test end-to-end workflows + +## Testing Standards + +### 1. Test Organization +- Group related tests in classes +- Use descriptive test names +- Follow the file structure of the implementation +- Keep test files focused and manageable + +### 2. Test Coverage +- Aim for 100% code coverage +- Test all code paths +- Include edge cases +- Test error conditions + +### 3. Test Quality +- Follow Arrange-Act-Assert pattern +- Keep tests independent +- Use appropriate assertions +- Write clear test descriptions + +Example: +```python +def test_set_invalid_type(): + """Test setting a non-string value.""" + value = Value(path="app.replicas", environment="dev") + with pytest.raises(ValueError, match="Value must be a string"): + value.set(3) +``` + +### 4. Fixtures and Mocks +- Use fixtures for common setup +- Mock external dependencies +- Keep mocks simple and focused +- Use appropriate scoping + +Example: +```python +@pytest.fixture +def mock_backend(): + """Create a mock backend for testing.""" + backend = Mock(spec=ValueBackend) + backend.get_value.return_value = "mock_value" + return backend +``` + +## Running Tests + +### Local Development +```bash +# Run all tests +python -m pytest + +# Run specific test file +python -m pytest tests/unit/models/test_value.py + +# Run with coverage +python -m pytest --cov=helm_values_manager +``` + +### Using Tox +```bash +# Run tests in all environments +tox + +# Run for specific Python version +tox -e py39 +``` + +## Test Documentation + +Each test should have: +1. Clear docstring explaining purpose +2. Well-structured setup and teardown +3. Clear assertions with messages +4. Proper error case handling + +Example: +```python +def test_from_dict_missing_path(): + """Test deserializing with missing path field.""" + data = { + "environment": "dev", + "storage_type": "local", + "value": "3" + } + with pytest.raises(ValueError, match="Missing required field: path"): + Value.from_dict(data) +``` + +## Best Practices + +1. **Test Independence** + - Each test should run in isolation + - Clean up after tests + - Don't rely on test execution order + +2. **Test Readability** + - Use clear, descriptive names + - Document test purpose + - Keep tests simple and focused + +3. **Test Maintenance** + - Update tests when implementation changes + - Remove obsolete tests + - Keep test code as clean as production code + +4. **Performance** + - Keep unit tests fast + - Use appropriate fixtures + - Mock expensive operations + +## Continuous Integration + +Our CI pipeline: +- Runs all tests +- Checks code coverage +- Enforces style guidelines +- Runs security checks + +## Further Reading + +- [Code Structure](code-structure.md) +- [Contributing Guide](../../CONTRIBUTING.md) +- [Pre-commit Hooks](pre-commit-hooks.md) diff --git a/helm_values_manager/backends/base.py b/helm_values_manager/backends/base.py index 22a4c12..9a8d6a0 100644 --- a/helm_values_manager/backends/base.py +++ b/helm_values_manager/backends/base.py @@ -1,7 +1,7 @@ """Base module for value storage backends. This module provides the abstract base class for all value storage backends. -Each backend must implement the key-value interface defined here. +Each backend must implement the path-based interface defined here. """ from abc import ABC, abstractmethod @@ -15,8 +15,8 @@ class ValueBackend(ABC): Each backend is responsible for storing and retrieving values securely using their respective storage systems (e.g., AWS Secrets Manager, Azure Key Vault). - The backend operates on a simple key-value model where: - - Keys are unique identifiers for values + The backend operates on a path-based model where: + - Values are identified by their path and environment - Values are strings that may be encrypted depending on the backend """ @@ -59,49 +59,49 @@ def _validate_auth_config(self, auth_config: Dict[str, str]) -> None: raise ValueError(f"Invalid auth type: {auth_config['type']}. " f"Must be one of: {', '.join(valid_types)}") @abstractmethod - def get_value(self, key: str) -> str: + def get_value(self, path: str, environment: str) -> str: """Get a value from the storage backend. Args: - key: The unique key to retrieve the value for. - This should be a valid key that exists in the backend. + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") Returns: str: The value stored in the backend. Raises: - ValueError: If the key doesn't exist + ValueError: If the value doesn't exist ConnectionError: If there's an error connecting to the backend PermissionError: If there's an authentication or authorization error """ pass @abstractmethod - def set_value(self, key: str, value: str) -> None: + def set_value(self, path: str, environment: str, value: str) -> None: """Set a value in the storage backend. Args: - key: The unique key to store the value under. - This should follow the backend's key naming conventions. + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") value: The value to store. Must be a string. Raises: - ValueError: If the key or value is invalid + ValueError: If the value is invalid ConnectionError: If there's an error connecting to the backend PermissionError: If there's an authentication or authorization error """ pass @abstractmethod - def remove_value(self, key: str) -> None: + def remove_value(self, path: str, environment: str) -> None: """Remove a value from the storage backend. Args: - key: The unique key to remove. - This should be a valid key that exists in the backend. + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") Raises: - ValueError: If the key doesn't exist + ValueError: If the value doesn't exist ConnectionError: If there's an error connecting to the backend PermissionError: If there's an authentication or authorization error """ diff --git a/helm_values_manager/backends/simple.py b/helm_values_manager/backends/simple.py new file mode 100644 index 0000000..48d201a --- /dev/null +++ b/helm_values_manager/backends/simple.py @@ -0,0 +1,78 @@ +""" +Simple value backend implementation for non-sensitive values. + +This module provides a simple in-memory backend for storing non-sensitive values. +""" + +from typing import Dict + +from .base import ValueBackend + + +class SimpleValueBackend(ValueBackend): + """ + A simple in-memory backend for storing non-sensitive values. + + This backend is used by default for any values marked as non-sensitive. + """ + + def __init__(self) -> None: + """Initialize an empty in-memory storage.""" + super().__init__({"type": "direct"}) # Simple backend doesn't need auth + self._storage: Dict[str, str] = {} + + def _get_storage_key(self, path: str, environment: str) -> str: + """Generate a unique storage key.""" + return f"{path}:{environment}" + + def get_value(self, path: str, environment: str) -> str: + """ + Get a value from the in-memory storage. + + Args: + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") + + Returns: + str: The stored value + + Raises: + ValueError: If the value doesn't exist + """ + key = self._get_storage_key(path, environment) + if key not in self._storage: + raise ValueError(f"No value found for {path} in {environment}") + return self._storage[key] + + def set_value(self, path: str, environment: str, value: str) -> None: + """ + Set a value in the in-memory storage. + + Args: + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") + value: The value to store + + Raises: + ValueError: If the value is not a string + """ + if not isinstance(value, str): + raise ValueError("Value must be a string") + key = self._get_storage_key(path, environment) + self._storage[key] = value + + def remove_value(self, path: str, environment: str) -> None: + """ + Remove a value from the in-memory storage. + + Args: + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") + + Raises: + ValueError: If the value doesn't exist + """ + key = self._get_storage_key(path, environment) + if key not in self._storage: + raise ValueError(f"No value found for {path} in {environment}") + del self._storage[key] diff --git a/helm_values_manager/models/__init__.py b/helm_values_manager/models/__init__.py new file mode 100644 index 0000000..ccedfb9 --- /dev/null +++ b/helm_values_manager/models/__init__.py @@ -0,0 +1,5 @@ +"""Models package for helm-values-manager.""" + +from .value import Value + +__all__ = ["Value"] diff --git a/helm_values_manager/models/value.py b/helm_values_manager/models/value.py new file mode 100644 index 0000000..f66820e --- /dev/null +++ b/helm_values_manager/models/value.py @@ -0,0 +1,100 @@ +""" +Value class implementation for Helm Values Manager. + +This module provides the Value class which encapsulates the storage and resolution +of configuration values using the appropriate backend. +""" + +from dataclasses import dataclass +from typing import Any, Dict + +from ..backends.base import ValueBackend + + +@dataclass +class Value: + """ + Represents a configuration value with its storage backend. + + Attributes: + path: The configuration path (e.g., "app.replicas") + environment: The environment name (e.g., "dev", "prod") + _backend: The backend used for storing and retrieving values + """ + + path: str + environment: str + _backend: ValueBackend + + def get(self) -> str: + """ + Get the resolved value. + + Returns: + str: The resolved value + + Raises: + ValueError: If value doesn't exist + RuntimeError: If backend operation fails + """ + return self._backend.get_value(self.path, self.environment) + + def set(self, value: str) -> None: + """ + Set the value using the backend. + + Args: + value: The value to store + + Raises: + ValueError: If value is not a string + RuntimeError: If backend operation fails + """ + if not isinstance(value, str): + raise ValueError("Value must be a string") + self._backend.set_value(self.path, self.environment, value) + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize the value configuration to a dictionary. + + Returns: + dict: Serialized representation of the value containing: + - path: The configuration path + - environment: The environment name + """ + return { + "path": self.path, + "environment": self.environment, + } + + @staticmethod + def from_dict(data: Dict[str, Any], backend: ValueBackend) -> "Value": + """ + Create a Value instance from a dictionary representation. + + Args: + data: Dictionary containing value configuration including: + - path: The configuration path + - environment: The environment name + backend: The backend to use for this value + + Returns: + Value: New Value instance + + Raises: + ValueError: If required data is missing or invalid + """ + if not isinstance(data, dict): + raise ValueError("Data must be a dictionary") + + if "path" not in data: + raise ValueError("Missing required field: path") + if "environment" not in data: + raise ValueError("Missing required field: environment") + + return Value( + path=data["path"], + environment=data["environment"], + _backend=backend, + ) diff --git a/tests/unit/backends/test_base.py b/tests/unit/backends/test_base.py index a10e3dd..8f207a1 100644 --- a/tests/unit/backends/test_base.py +++ b/tests/unit/backends/test_base.py @@ -17,42 +17,48 @@ def __init__(self, auth_config={"type": "direct"}): self.store = {} super().__init__(auth_config) - def get_value(self, key: str) -> str: + def get_value(self, path: str, environment: str) -> str: """Get a value from the store. Args: - key: The key to retrieve + path: The configuration path + environment: The environment name Returns: The stored value Raises: - ValueError: If key not found + ValueError: If value not found """ + key = f"{path}:{environment}" if key not in self.store: - raise ValueError(f"Key not found: {key}") + raise ValueError(f"No value found for {path} in {environment}") return self.store[key] - def set_value(self, key: str, value: str) -> None: + def set_value(self, path: str, environment: str, value: str) -> None: """Set a value in the store. Args: - key: The key to store under + path: The configuration path + environment: The environment name value: The value to store """ + key = f"{path}:{environment}" self.store[key] = value - def remove_value(self, key: str) -> None: + def remove_value(self, path: str, environment: str) -> None: """Remove a value from the store. Args: - key: The key to remove + path: The configuration path + environment: The environment name Raises: - ValueError: If key not found + ValueError: If value not found """ + key = f"{path}:{environment}" if key not in self.store: - raise ValueError(f"Key not found: {key}") + raise ValueError(f"No value found for {path} in {environment}") del self.store[key] @@ -86,38 +92,38 @@ def test_auth_config_validation(): def test_get_value(backend): """Test getting values from the backend.""" # Set test value - backend.set_value("test/key", "test_value") + backend.set_value("app.replicas", "dev", "3") # Test getting existing value - assert backend.get_value("test/key") == "test_value" + assert backend.get_value("app.replicas", "dev") == "3" # Test getting non-existent value - with pytest.raises(ValueError, match="Key not found"): - backend.get_value("non/existent") + with pytest.raises(ValueError, match="No value found"): + backend.get_value("app.replicas", "prod") def test_set_value(backend): """Test setting values in the backend.""" # Test setting new value - backend.set_value("test/key", "test_value") - assert backend.get_value("test/key") == "test_value" + backend.set_value("app.replicas", "dev", "3") + assert backend.get_value("app.replicas", "dev") == "3" # Test overwriting existing value - backend.set_value("test/key", "new_value") - assert backend.get_value("test/key") == "new_value" + backend.set_value("app.replicas", "dev", "5") + assert backend.get_value("app.replicas", "dev") == "5" def test_remove_value(backend): """Test removing values from the backend.""" # Set test value - backend.set_value("test/key", "test_value") - assert backend.get_value("test/key") == "test_value" + backend.set_value("app.replicas", "dev", "3") + assert backend.get_value("app.replicas", "dev") == "3" # Test removing existing value - backend.remove_value("test/key") - with pytest.raises(ValueError, match="Key not found"): - backend.get_value("test/key") + backend.remove_value("app.replicas", "dev") + with pytest.raises(ValueError, match="No value found"): + backend.get_value("app.replicas", "dev") # Test removing non-existent value - with pytest.raises(ValueError, match="Key not found"): - backend.remove_value("non/existent") + with pytest.raises(ValueError, match="No value found"): + backend.remove_value("app.replicas", "prod") diff --git a/tests/unit/backends/test_simple.py b/tests/unit/backends/test_simple.py new file mode 100644 index 0000000..40221ff --- /dev/null +++ b/tests/unit/backends/test_simple.py @@ -0,0 +1,75 @@ +"""Tests for the SimpleValueBackend.""" + +import pytest + +from helm_values_manager.backends.simple import SimpleValueBackend + + +@pytest.fixture +def backend(): + """Create a SimpleValueBackend instance for testing.""" + return SimpleValueBackend() + + +def test_get_value_not_found(backend): + """Test getting a non-existent value.""" + with pytest.raises(ValueError, match="No value found for app.replicas in dev"): + backend.get_value("app.replicas", "dev") + + +def test_set_and_get_value(backend): + """Test setting and getting a value.""" + backend.set_value("app.replicas", "dev", "3") + assert backend.get_value("app.replicas", "dev") == "3" + + +def test_set_invalid_value_type(backend): + """Test setting a non-string value.""" + with pytest.raises(ValueError, match="Value must be a string"): + backend.set_value("app.replicas", "dev", 3) + + +def test_remove_value(backend): + """Test removing a value.""" + # Set and verify value + backend.set_value("app.replicas", "dev", "3") + assert backend.get_value("app.replicas", "dev") == "3" + + # Remove and verify it's gone + backend.remove_value("app.replicas", "dev") + with pytest.raises(ValueError, match="No value found for app.replicas in dev"): + backend.get_value("app.replicas", "dev") + + +def test_remove_non_existent_value(backend): + """Test removing a non-existent value.""" + with pytest.raises(ValueError, match="No value found for app.replicas in dev"): + backend.remove_value("app.replicas", "dev") + + +def test_environment_isolation(backend): + """Test that values are isolated between environments.""" + # Set value in dev + backend.set_value("app.replicas", "dev", "3") + assert backend.get_value("app.replicas", "dev") == "3" + + # Verify it doesn't exist in prod + with pytest.raises(ValueError, match="No value found for app.replicas in prod"): + backend.get_value("app.replicas", "prod") + + # Set different value in prod + backend.set_value("app.replicas", "prod", "5") + assert backend.get_value("app.replicas", "prod") == "5" + assert backend.get_value("app.replicas", "dev") == "3" + + +def test_path_isolation(backend): + """Test that values are isolated between paths.""" + # Set value for app.replicas + backend.set_value("app.replicas", "dev", "3") + assert backend.get_value("app.replicas", "dev") == "3" + + # Set value for app.image + backend.set_value("app.image", "dev", "nginx:latest") + assert backend.get_value("app.image", "dev") == "nginx:latest" + assert backend.get_value("app.replicas", "dev") == "3" diff --git a/tests/unit/models/test_value.py b/tests/unit/models/test_value.py new file mode 100644 index 0000000..271c61a --- /dev/null +++ b/tests/unit/models/test_value.py @@ -0,0 +1,81 @@ +"""Tests for the Value class.""" + +from unittest.mock import Mock + +import pytest + +from helm_values_manager.backends.base import ValueBackend +from helm_values_manager.models.value import Value + + +@pytest.fixture +def mock_backend(): + """Create a mock backend for testing.""" + backend = Mock(spec=ValueBackend) + backend.get_value.return_value = "mock_value" + return backend + + +def test_value_init(mock_backend): + """Test Value initialization.""" + value = Value(path="app.replicas", environment="dev", _backend=mock_backend) + assert value.path == "app.replicas" + assert value.environment == "dev" + assert value._backend == mock_backend + + +def test_get_value(mock_backend): + """Test getting a value.""" + value = Value(path="app.replicas", environment="dev", _backend=mock_backend) + assert value.get() == "mock_value" + mock_backend.get_value.assert_called_once_with("app.replicas", "dev") + + +def test_set_value(mock_backend): + """Test setting a value.""" + value = Value(path="app.replicas", environment="dev", _backend=mock_backend) + value.set("3") + mock_backend.set_value.assert_called_once_with("app.replicas", "dev", "3") + + +def test_set_invalid_type(mock_backend): + """Test setting a non-string value.""" + value = Value(path="app.replicas", environment="dev", _backend=mock_backend) + with pytest.raises(ValueError, match="Value must be a string"): + value.set(3) + + +def test_to_dict(mock_backend): + """Test serializing a value.""" + value = Value(path="app.replicas", environment="dev", _backend=mock_backend) + data = value.to_dict() + assert data == {"path": "app.replicas", "environment": "dev"} + + +def test_from_dict(mock_backend): + """Test deserializing a value.""" + data = {"path": "app.replicas", "environment": "dev"} + value = Value.from_dict(data, mock_backend) + assert value.path == "app.replicas" + assert value.environment == "dev" + assert value._backend == mock_backend + + +def test_from_dict_invalid(): + """Test deserializing with invalid data.""" + with pytest.raises(ValueError, match="Data must be a dictionary"): + Value.from_dict("not a dict", Mock(spec=ValueBackend)) + + +def test_from_dict_missing_path(mock_backend): + """Test deserializing with missing path.""" + data = {"environment": "dev"} + with pytest.raises(ValueError, match="Missing required field: path"): + Value.from_dict(data, mock_backend) + + +def test_from_dict_missing_environment(mock_backend): + """Test deserializing with missing environment.""" + data = {"path": "app.replicas"} + with pytest.raises(ValueError, match="Missing required field: environment"): + Value.from_dict(data, mock_backend)