Skip to content

Commit 896a604

Browse files
authored
Merge pull request #27 from Zipstack/feature/validate-config-before-save
feat: add config validation and improve documentation
2 parents f109741 + 090f371 commit 896a604

18 files changed

+589
-363
lines changed

docs/Design/low-level-design.md

Lines changed: 212 additions & 294 deletions
Large diffs are not rendered by default.

docs/Design/sequence-diagrams.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -661,19 +661,19 @@ sequenceDiagram
661661
Each diagram shows:
662662
- The exact CLI command being executed
663663
- All components involved in processing the command
664-
- Data flow between components
665-
- Validation steps
666-
- File system operations
667-
- Success/error handling
668-
669-
The main CLI commands covered are:
670-
1. `init` - Initialize new configuration
671-
2. `add-value-config` - Define a new value configuration with metadata
664+
- The sequence of operations and data flow
665+
- Error handling and validation steps
666+
- Lock management for concurrent access
667+
668+
## Commands Covered
669+
670+
1. `init` - Initialize a new helm-values configuration
671+
2. `add-value-config` - Add a new value configuration with metadata
672672
3. `add-deployment` - Add a new deployment configuration
673-
4. `set-value` - Set a value for a specific path and environment
674-
5. `get-value` - Retrieve a value for a specific path and environment
675-
6. `validate` - Validate the entire configuration
676-
7. `generate` - Generate values.yaml for a specific environment
673+
4. `add-backend` - Add a backend to a deployment
674+
5. `add-auth` - Add authentication to a deployment
675+
6. `get-value` - Get a value for a specific path and environment
676+
7. `set-value` - Set a value for a specific path and environment
677677
8. `list-values` - List all values for a specific environment
678678
9. `list-deployments` - List all deployments
679679
10. `remove-deployment` - Remove a deployment configuration

helm_values_manager/cli.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
"""Command line interface for the helm-values-manager plugin."""
22

3-
from typing import Optional
4-
53
import typer
64

75
from helm_values_manager.commands.add_deployment_command import AddDeploymentCommand
86
from helm_values_manager.commands.add_value_config_command import AddValueConfigCommand
97
from helm_values_manager.commands.init_command import InitCommand
108
from helm_values_manager.commands.set_value_command import SetValueCommand
9+
from helm_values_manager.models.config_metadata import ConfigMetadata
1110
from helm_values_manager.utils.logger import HelmLogger
1211

1312
COMMAND_INFO = "helm values-manager"
@@ -50,12 +49,14 @@ def init(
5049
@app.command("add-value-config")
5150
def add_value_config(
5251
path: str = typer.Option(..., "--path", "-p", help="Configuration path (e.g., 'app.replicas')"),
53-
description: Optional[str] = typer.Option(
54-
None, "--description", "-d", help="Description of what this configuration does"
52+
description: str = typer.Option(
53+
ConfigMetadata.DEFAULT_DESCRIPTION, "--description", "-d", help="Description of what this configuration does"
54+
),
55+
required: bool = typer.Option(
56+
ConfigMetadata.DEFAULT_REQUIRED, "--required", "-r", help="Whether this configuration is required"
5557
),
56-
required: bool = typer.Option(False, "--required", "-r", help="Whether this configuration is required"),
5758
sensitive: bool = typer.Option(
58-
False,
59+
ConfigMetadata.DEFAULT_SENSITIVE,
5960
"--sensitive",
6061
"-s",
6162
help="Whether this configuration contains sensitive data (coming in v0.2.0)",

helm_values_manager/commands/add_value_config_command.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Optional
44

55
from helm_values_manager.commands.base_command import BaseCommand
6+
from helm_values_manager.models.config_metadata import ConfigMetadata
67
from helm_values_manager.models.helm_values_config import HelmValuesConfig
78
from helm_values_manager.utils.logger import HelmLogger
89

@@ -35,9 +36,9 @@ def run(self, config: Optional[HelmValuesConfig] = None, **kwargs) -> str:
3536
if not path:
3637
raise ValueError("Path cannot be empty")
3738

38-
description = kwargs.get("description")
39-
required = kwargs.get("required", False)
40-
sensitive = kwargs.get("sensitive", False)
39+
description = kwargs.get("description", ConfigMetadata.DEFAULT_DESCRIPTION)
40+
required = kwargs.get("required", ConfigMetadata.DEFAULT_REQUIRED)
41+
sensitive = kwargs.get("sensitive", ConfigMetadata.DEFAULT_SENSITIVE)
4142

4243
try:
4344
# Add the new configuration path

helm_values_manager/commands/base_command.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ def save_config(self, config: HelmValuesConfig) -> None:
7373
7474
Raises:
7575
IOError: If unable to write to the file.
76+
ValueError: If the configuration is invalid (e.g., missing release name).
77+
ValidationError: If JSON schema validation fails.
7678
"""
79+
# Validate the config before saving
80+
config.validate()
81+
7782
try:
7883
with open(self.config_file, "w", encoding="utf-8") as f:
7984
json.dump(config.to_dict(), f, indent=2)
Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Simple dataclass for configuration metadata."""
22

33
from dataclasses import asdict, dataclass
4-
from typing import Any, Dict, Optional
4+
from typing import Any, ClassVar, Dict
55

66

77
@dataclass
@@ -10,14 +10,19 @@ class ConfigMetadata:
1010
Represents metadata for a configuration path.
1111
1212
Attributes:
13-
description (Optional[str]): Description of the configuration path.
13+
description (str): Description of the configuration path. Defaults to empty string.
1414
required (bool): Whether the configuration path is required. Defaults to False.
1515
sensitive (bool): Whether the configuration path is sensitive. Defaults to False.
1616
"""
1717

18-
description: Optional[str] = None
19-
required: bool = False
20-
sensitive: bool = False
18+
# Default values as class variables for reference elsewhere
19+
DEFAULT_DESCRIPTION: ClassVar[str] = ""
20+
DEFAULT_REQUIRED: ClassVar[bool] = False
21+
DEFAULT_SENSITIVE: ClassVar[bool] = False
22+
23+
description: str = DEFAULT_DESCRIPTION
24+
required: bool = DEFAULT_REQUIRED
25+
sensitive: bool = DEFAULT_SENSITIVE
2126

2227
def to_dict(self) -> Dict[str, Any]:
2328
"""Convert metadata to dictionary."""
@@ -27,7 +32,7 @@ def to_dict(self) -> Dict[str, Any]:
2732
def from_dict(cls, data: Dict[str, Any]) -> "ConfigMetadata":
2833
"""Create a ConfigMetadata instance from a dictionary."""
2934
return cls(
30-
description=data.get("description"),
31-
required=data.get("required", False),
32-
sensitive=data.get("sensitive", False),
35+
description=data.get("description", cls.DEFAULT_DESCRIPTION),
36+
required=data.get("required", cls.DEFAULT_REQUIRED),
37+
sensitive=data.get("sensitive", cls.DEFAULT_SENSITIVE),
3338
)

helm_values_manager/models/helm_values_config.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from jsonschema.exceptions import ValidationError
1010

1111
from helm_values_manager.backends.simple import SimpleValueBackend
12+
from helm_values_manager.models.config_metadata import ConfigMetadata
1213
from helm_values_manager.models.path_data import PathData
1314
from helm_values_manager.models.value import Value
1415
from helm_values_manager.utils.logger import HelmLogger
@@ -65,7 +66,11 @@ def _validate_schema(cls, data: dict) -> None:
6566
raise
6667

6768
def add_config_path(
68-
self, path: str, description: Optional[str] = None, required: bool = False, sensitive: bool = False
69+
self,
70+
path: str,
71+
description: str = ConfigMetadata.DEFAULT_DESCRIPTION,
72+
required: bool = ConfigMetadata.DEFAULT_REQUIRED,
73+
sensitive: bool = ConfigMetadata.DEFAULT_SENSITIVE,
6974
) -> None:
7075
"""
7176
Add a new configuration path.
@@ -135,7 +140,8 @@ def validate(self) -> None:
135140
Validate the configuration.
136141
137142
Raises:
138-
ValueError: If validation fails.
143+
ValueError: If validation fails (e.g., missing release name)
144+
ValidationError: If JSON schema validation fails
139145
"""
140146
if not self.release:
141147
raise ValueError("Release name is required")
@@ -191,9 +197,9 @@ def from_dict(cls, data: dict) -> "HelmValuesConfig":
191197
for config_item in data.get("config", []):
192198
path = config_item["path"]
193199
metadata = {
194-
"description": config_item.get("description"),
195-
"required": config_item.get("required", False),
196-
"sensitive": config_item.get("sensitive", False),
200+
"description": config_item.get("description", ConfigMetadata.DEFAULT_DESCRIPTION),
201+
"required": config_item.get("required", ConfigMetadata.DEFAULT_REQUIRED),
202+
"sensitive": config_item.get("sensitive", ConfigMetadata.DEFAULT_SENSITIVE),
197203
}
198204
path_data = PathData(path, metadata)
199205
config._path_map[path] = path_data

helm_values_manager/models/path_data.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
class PathData:
1616
"""Manages metadata and values for a configuration path."""
1717

18-
def __init__(self, path: str, metadata: Dict[str, Any]):
18+
def __init__(self, path: str, metadata: Optional[Dict[str, Any]] = None):
1919
"""
2020
Initialize PathData with a path and metadata.
2121
@@ -24,10 +24,12 @@ def __init__(self, path: str, metadata: Dict[str, Any]):
2424
metadata: Dictionary containing metadata for the path
2525
"""
2626
self.path = path
27+
if metadata is None:
28+
metadata = {}
2729
self._metadata = ConfigMetadata(
28-
description=metadata.get("description"),
29-
required=metadata.get("required", False),
30-
sensitive=metadata.get("sensitive", False),
30+
description=metadata.get("description", ConfigMetadata.DEFAULT_DESCRIPTION),
31+
required=metadata.get("required", ConfigMetadata.DEFAULT_REQUIRED),
32+
sensitive=metadata.get("sensitive", ConfigMetadata.DEFAULT_SENSITIVE),
3133
)
3234
self._values: Dict[str, Value] = {}
3335
HelmLogger.debug("Created PathData instance for path %s", path)
@@ -160,9 +162,9 @@ def from_dict(
160162
raise ValueError(f"Missing required keys: {missing}")
161163

162164
metadata = {
163-
"description": data.get("description"),
164-
"required": data.get("required", False),
165-
"sensitive": data.get("sensitive", False),
165+
"description": data.get("description", ConfigMetadata.DEFAULT_DESCRIPTION),
166+
"required": data.get("required", ConfigMetadata.DEFAULT_REQUIRED),
167+
"sensitive": data.get("sensitive", ConfigMetadata.DEFAULT_SENSITIVE),
166168
}
167169
path_data = cls(path=data["path"], metadata=metadata)
168170

helm_values_manager/schemas/v1.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
},
1111
"release": {
1212
"type": "string",
13+
"pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
14+
"maxLength": 53,
1315
"description": "Name of the Helm release"
1416
},
1517
"deployments": {
1618
"type": "object",
1719
"description": "Map of deployment names to their configurations",
20+
"propertyNames": {
21+
"pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
22+
"description": "Deployment names must be lowercase alphanumeric with hyphens, cannot start/end with hyphen"
23+
},
1824
"additionalProperties": {
1925
"type": "object",
2026
"required": ["backend", "auth"],

helm_values_manager/utils/logger.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class HelmLogger:
1515
Logger class that follows Helm plugin conventions.
1616
1717
This logger:
18-
1. Writes debug messages only when HELM_DEBUG is set
18+
1. Writes debug messages only when HELM_DEBUG is set and not "0" or "false"
1919
2. Writes all messages to stderr (Helm convention)
2020
3. Uses string formatting for better performance
2121
4. Provides consistent error and debug message formatting
@@ -24,16 +24,18 @@ class HelmLogger:
2424
@staticmethod
2525
def debug(msg: str, *args: Any) -> None:
2626
"""
27-
Print debug message if HELM_DEBUG is set.
27+
Print debug message if HELM_DEBUG is set and not "0" or "false".
2828
2929
Args:
3030
msg: Message with optional string format placeholders
3131
args: Values to substitute in the message
3232
"""
33-
if os.environ.get("HELM_DEBUG"):
34-
if args:
35-
msg = msg % args
36-
print("[debug] %s" % msg, file=sys.stderr)
33+
debug_val = os.environ.get("HELM_DEBUG", "false").lower()
34+
if debug_val in ("0", "false"):
35+
return
36+
if args:
37+
msg = msg % args
38+
print("[debug] %s" % msg, file=sys.stderr)
3739

3840
@staticmethod
3941
def error(msg: str, *args: Any) -> None:

tests/unit/commands/test_base_command.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,29 +159,89 @@ def test_execute_ensures_lock_release_on_error(base_command, valid_config):
159159

160160

161161
def test_save_config_success(base_command):
162-
"""Test successful config saving."""
162+
"""Test successful config save."""
163163
config = HelmValuesConfig()
164164
config.version = "1.0"
165165
config.release = "test"
166166

167+
# Mock the validate method
168+
config.validate = MagicMock()
169+
167170
with patch("builtins.open", mock_open()) as mock_file:
168171
base_command.save_config(config)
169172

173+
# Verify validate was called
174+
config.validate.assert_called_once()
175+
170176
mock_file.assert_called_once_with(base_command.config_file, "w", encoding="utf-8")
171177
handle = mock_file()
172178

179+
# Verify the written data
173180
written_json = "".join(call.args[0] for call in handle.write.call_args_list)
174181
written_data = json.loads(written_json)
175-
assert written_data["version"] == config.version
176-
assert written_data["release"] == config.release
182+
assert written_data["version"] == "1.0"
183+
assert written_data["release"] == "test"
177184

178185

179186
def test_save_config_io_error(base_command):
180-
"""Test save_config when IO error occurs."""
187+
"""Test IO error handling when saving config."""
181188
config = HelmValuesConfig()
182-
error_message = "Test error"
189+
config.version = "1.0"
190+
config.release = "test"
183191

184-
with patch("builtins.open", mock_open()) as mock_file:
185-
mock_file.return_value.write.side_effect = IOError(error_message)
186-
with pytest.raises(IOError, match=error_message):
192+
# Mock the validate method
193+
config.validate = MagicMock()
194+
195+
# Simulate an IO error
196+
mock_file = mock_open()
197+
mock_file.side_effect = IOError("Test IO Error")
198+
199+
with patch("builtins.open", mock_file):
200+
with pytest.raises(IOError) as excinfo:
187201
base_command.save_config(config)
202+
203+
assert "Test IO Error" in str(excinfo.value)
204+
205+
# Verify validate was called
206+
config.validate.assert_called_once()
207+
208+
209+
def test_save_config_validates_schema(base_command):
210+
"""Test that save_config validates the schema before saving."""
211+
# Create a mock config
212+
config = MagicMock(spec=HelmValuesConfig)
213+
214+
# Make validate method raise an error
215+
config.validate.side_effect = ValueError("Schema validation failed")
216+
217+
# Try to save the config
218+
with pytest.raises(ValueError, match="Schema validation failed"):
219+
base_command.save_config(config)
220+
221+
# Verify that validate was called
222+
config.validate.assert_called_once()
223+
224+
225+
def test_save_config_with_empty_description(base_command, tmp_path):
226+
"""Test that save_config handles empty description correctly."""
227+
# Create a real config
228+
config = HelmValuesConfig()
229+
config.release = "test"
230+
231+
# Add a config path with empty description (default)
232+
config.add_config_path("test.path")
233+
234+
# Set a temporary config file
235+
temp_file = tmp_path / "test_config.json"
236+
base_command.config_file = str(temp_file)
237+
238+
# Save the config
239+
base_command.save_config(config)
240+
241+
# Read the saved file
242+
with open(temp_file, "r") as f:
243+
data = json.load(f)
244+
245+
# Verify that description is an empty string
246+
assert data["config"][0]["description"] == ""
247+
assert isinstance(data["config"][0]["description"], str)

0 commit comments

Comments
 (0)