diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5990d9c..85664bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "" # See documentation for possible values + - package-ecosystem: "pip" # Python package management directory: "/" # Location of package manifests schedule: interval: "weekly" diff --git a/README.md b/README.md index 1cd51e9..44975f4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ 🚀 A powerful Helm plugin for managing values and secrets across multiple environments. +## The Problem + +Managing Helm values across multiple environments (dev, staging, prod) is challenging: + +- 🔀 **Values Sprawl**: Values spread across multiple files become hard to track +- 🔍 **Configuration Discovery**: Difficult to know what values can be configured +- ❌ **Missing Values**: No validation for required values before deployment +- 🔐 **Secret Management**: Sensitive data mixed with regular values +- 📝 **Documentation**: Values often lack descriptions and context + +Helm Values Manager solves these challenges by providing a structured way to define, validate, and manage values across environments, with built-in support for documentation and secret handling. + ## Features - 🔐 **Secure Secret Management**: Safely handle sensitive data @@ -33,18 +45,52 @@ helm plugin install https://github.com/zipstack/helm-values-manager ## Quick Start -1. Initialize a new configuration: +1. Initialize a new configuration for your Helm release: ```bash -helm values-manager init +helm values-manager init --release my-app ``` -This creates: +2. Add value configurations with descriptions and validation: + +```bash +# Add a required configuration +helm values-manager add-value-config --path app.replicas --description "Number of application replicas" --required -- `values-manager.yaml` configuration file -- `values` directory with environment files (`dev.yaml`, `staging.yaml`, `prod.yaml`) +# Add an optional configuration +helm values-manager add-value-config --path app.logLevel --description "Application log level (debug/info/warn/error)" +``` + +3. Add deployments for different environments: + +```bash +helm values-manager add-deployment dev +helm values-manager add-deployment prod +``` + +4. Set values for each deployment: + +```bash +# Set values for dev +helm values-manager set-value --path app.replicas --value 1 --deployment dev +helm values-manager set-value --path app.logLevel --value debug --deployment dev + +# Set values for prod +helm values-manager set-value --path app.replicas --value 3 --deployment prod +helm values-manager set-value --path app.logLevel --value info --deployment prod +``` + +5. Generate values files for deployments: + +```bash +# Generate dev values +helm values-manager generate --deployment dev --output ./dev + +# Generate prod values +helm values-manager generate --deployment prod --output ./prod +``` -2. View available commands: +6. View available commands and options: ```bash helm values-manager --help diff --git a/docs/Design/sequence-diagrams.md b/docs/Design/sequence-diagrams.md index ba58088..fb78048 100644 --- a/docs/Design/sequence-diagrams.md +++ b/docs/Design/sequence-diagrams.md @@ -10,7 +10,7 @@ sequenceDiagram participant HelmValuesConfig participant FileSystem - User->>CLI: helm values init + User->>CLI: helm values-manager init --release test-release activate CLI CLI->>BaseCommand: execute() @@ -52,7 +52,7 @@ sequenceDiagram participant HelmValuesConfig participant PathValidator - User->>CLI: helm values add-value-config --path=app.replicas --required + User->>CLI: helm values-manager add-value-config --path app.replicas --description "Number of replicas" --required activate CLI CLI->>BaseCommand: execute() @@ -90,7 +90,7 @@ sequenceDiagram participant BaseCommand participant HelmValuesConfig - User->>CLI: helm values add-deployment prod + User->>CLI: helm values-manager add-deployment prod activate CLI CLI->>BaseCommand: execute() @@ -129,7 +129,7 @@ sequenceDiagram participant HelmValuesConfig participant ValueBackend - User->>CLI: helm values add-backend aws --deployment=prod --region=us-west-2 + User->>CLI: helm values-manager add-backend aws --deployment prod --region us-west-2 activate CLI CLI->>BaseCommand: execute() @@ -173,7 +173,7 @@ sequenceDiagram participant HelmValuesConfig participant ValueBackend - User->>CLI: helm values add-auth direct --deployment=prod --credentials='{...}' + User->>CLI: helm values-manager add-auth direct --deployment prod --credentials '{...}' activate CLI CLI->>BaseCommand: execute() @@ -219,7 +219,7 @@ sequenceDiagram participant ValueBackend participant Storage - User->>CLI: helm values set-value path value --env=prod + User->>CLI: helm values-manager set-value --path app.replicas --value 3 --deployment prod activate CLI CLI->>BaseCommand: execute() @@ -279,7 +279,7 @@ sequenceDiagram participant ValueBackend participant Storage - User->>CLI: helm values get-value path --env=prod + User->>CLI: helm values-manager get-value --path app.replicas --deployment prod activate CLI CLI->>BaseCommand: execute() @@ -337,7 +337,7 @@ sequenceDiagram participant ValueBackend participant Validator - User->>CLI: helm values validate + User->>CLI: helm values-manager validate activate CLI CLI->>BaseCommand: execute() @@ -388,7 +388,7 @@ sequenceDiagram participant ValueBackend participant Generator - User->>CLI: helm values generate --env=prod + User->>CLI: helm values-manager generate --deployment prod --output ./ activate CLI CLI->>BaseCommand: execute() @@ -442,7 +442,7 @@ sequenceDiagram participant Storage participant TableFormatter - User->>CLI: helm values list-values --env=prod + User->>CLI: helm values-manager list-values --deployment prod activate CLI CLI->>BaseCommand: execute() @@ -490,7 +490,7 @@ sequenceDiagram participant HelmValuesConfig participant TableFormatter - User->>CLI: helm values list-deployments + User->>CLI: helm values-manager list-deployments activate CLI CLI->>BaseCommand: execute() @@ -526,7 +526,7 @@ sequenceDiagram participant ValueBackend participant Storage - User->>CLI: helm values remove-deployment prod + User->>CLI: helm values-manager remove-deployment prod activate CLI CLI->>BaseCommand: execute() @@ -576,7 +576,7 @@ sequenceDiagram participant ValueBackend participant Storage - User->>CLI: helm values remove-value path --env=prod + User->>CLI: helm values-manager remove-value --path app.replicas --deployment prod activate CLI CLI->>BaseCommand: execute() @@ -624,7 +624,7 @@ sequenceDiagram participant HelmValuesConfig participant Value - User->>CLI: helm values remove-value-config --path=app.replicas + User->>CLI: helm values-manager remove-value-config --path app.replicas activate CLI CLI->>BaseCommand: execute() @@ -659,6 +659,7 @@ sequenceDiagram ``` Each diagram shows: + - The exact CLI command being executed - All components involved in processing the command - The sequence of operations and data flow @@ -679,3 +680,4 @@ Each diagram shows: 10. `remove-deployment` - Remove a deployment configuration 11. `remove-value` - Remove a value for a specific path and environment 12. `remove-value-config` - Remove a value configuration and its associated values +13. `generate` - Generate a values file for a specific deployment diff --git a/helm_values_manager/__init__.py b/helm_values_manager/__init__.py index 0264d4b..6131d2b 100644 --- a/helm_values_manager/__init__.py +++ b/helm_values_manager/__init__.py @@ -1,4 +1,6 @@ -"""A Helm plugin to manage values and secrets across environments.""" +"""Helm Values Manager package.""" -__version__ = "0.1.0" +from importlib.metadata import version + +__version__ = version("helm-values-manager") __description__ = "A Helm plugin to manage values and secrets across environments." diff --git a/helm_values_manager/backends/base.py b/helm_values_manager/backends/base.py index 9327994..019c0e1 100644 --- a/helm_values_manager/backends/base.py +++ b/helm_values_manager/backends/base.py @@ -57,7 +57,7 @@ def _validate_auth_config(self, auth_config: Dict[str, str]) -> None: valid_types = ["env", "file", "direct", "managed_identity"] if auth_config["type"] not in valid_types: - raise ValueError(f"Invalid auth type: {auth_config['type']}. " f"Must be one of: {', '.join(valid_types)}") + raise ValueError(f"Invalid auth type: {auth_config['type']}. Must be one of: {', '.join(valid_types)}") @abstractmethod def get_value(self, path: str, environment: str, resolve: bool = False) -> Union[str, int, float, bool, None]: diff --git a/helm_values_manager/cli.py b/helm_values_manager/cli.py index 8ae493c..7f918ff 100644 --- a/helm_values_manager/cli.py +++ b/helm_values_manager/cli.py @@ -4,6 +4,7 @@ from helm_values_manager.commands.add_deployment_command import AddDeploymentCommand from helm_values_manager.commands.add_value_config_command import AddValueConfigCommand +from helm_values_manager.commands.generate_command import GenerateCommand from helm_values_manager.commands.init_command import InitCommand from helm_values_manager.commands.set_value_command import SetValueCommand from helm_values_manager.models.config_metadata import ConfigMetadata @@ -125,5 +126,24 @@ def set_value( raise typer.Exit(code=1) from e +@app.command("generate") +def generate( + deployment: str = typer.Option( + ..., "--deployment", "-d", help="Deployment to generate values for (e.g., 'dev', 'prod')" + ), + output_path: str = typer.Option( + ".", "--output", "-o", help="Directory to output the values file to (default: current directory)" + ), +): + """Generate a values file for a specific deployment.""" + try: + command = GenerateCommand() + result = command.execute(deployment=deployment, output_path=output_path) + typer.echo(result) + except Exception as e: + HelmLogger.error("Failed to generate values file: %s", str(e)) + raise typer.Exit(code=1) from e + + if __name__ == "__main__": app(prog_name=COMMAND_INFO) diff --git a/helm_values_manager/commands/generate_command.py b/helm_values_manager/commands/generate_command.py new file mode 100644 index 0000000..bebf767 --- /dev/null +++ b/helm_values_manager/commands/generate_command.py @@ -0,0 +1,122 @@ +"""Command to generate values file for a specific deployment.""" + +import os +from typing import Any, Dict, Optional + +import yaml + +from helm_values_manager.commands.base_command import BaseCommand +from helm_values_manager.models.helm_values_config import HelmValuesConfig +from helm_values_manager.utils.logger import HelmLogger + + +class GenerateCommand(BaseCommand): + """Command to generate values file for a specific deployment.""" + + def run(self, config: Optional[HelmValuesConfig] = None, **kwargs) -> str: + """ + Generate a values file for a specific deployment. + + Args: + config: The loaded configuration + **kwargs: Command arguments + - deployment (str): The deployment to generate values for (e.g., 'dev', 'prod') + - output_path (str, optional): Directory to output the values file to + + Returns: + str: Success message with the path to the generated file + + Raises: + ValueError: If deployment is empty + KeyError: If deployment doesn't exist in the configuration + FileNotFoundError: If the configuration file doesn't exist + """ + if config is None: + raise ValueError("Configuration not loaded") + + deployment = kwargs.get("deployment") + if not deployment: + raise ValueError("Deployment cannot be empty") + + output_path = kwargs.get("output_path", ".") + + # Validate that the deployment exists + if deployment not in config.deployments: + raise KeyError(f"Deployment '{deployment}' not found") + + # Create output directory if it doesn't exist + if not os.path.exists(output_path): + os.makedirs(output_path) + HelmLogger.debug("Created output directory: %s", output_path) + + # Generate values dictionary from configuration + values_dict = self._generate_values_dict(config, deployment) + + # Generate filename based on deployment and release + filename = f"{deployment}.{config.release}.values.yaml" + file_path = os.path.join(output_path, filename) + + # Write values to file + with open(file_path, "w", encoding="utf-8") as f: + yaml.dump(values_dict, f, default_flow_style=False) + + HelmLogger.debug("Generated values file for deployment '%s' at '%s'", deployment, file_path) + return f"Successfully generated values file for deployment '{deployment}' at '{file_path}'" + + def _generate_values_dict(self, config: HelmValuesConfig, deployment: str) -> Dict[str, Any]: + """ + Generate a nested dictionary of values from the configuration. + + Args: + config: The loaded configuration + deployment: The deployment to generate values for + + Returns: + Dict[str, Any]: Nested dictionary of values + + Raises: + ValueError: If a required value is missing for the deployment + """ + values_dict = {} + missing_required_paths = [] + + # Get all paths from the configuration + for path in config._path_map.keys(): + path_data = config._path_map[path] + + # Check if this is a required value + is_required = path_data._metadata.required + + # Get the value for this path and deployment + value = config.get_value(path, deployment, resolve=True) + + # If the value is None and it's required, add to missing list + if value is None and is_required: + missing_required_paths.append(path) + continue + + # Skip if no value is set + if value is None: + continue + + # Convert dot-separated path to nested dictionary + path_parts = path.split(".") + current_dict = values_dict + + # Navigate to the correct nested level + for i, part in enumerate(path_parts): + # If we're at the last part, set the value + if i == len(path_parts) - 1: + current_dict[part] = value + else: + # Create nested dictionary if it doesn't exist + if part not in current_dict: + current_dict[part] = {} + current_dict = current_dict[part] + + # If there are missing required values, raise an error + if missing_required_paths: + paths_str = ", ".join(missing_required_paths) + raise ValueError(f"Missing required values for deployment '{deployment}': {paths_str}") + + return values_dict diff --git a/plugin.yaml b/plugin.yaml index 9d4194b..28acb6c 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: "values-manager" -version: "0.1.0rc1" +version: "0.1.0" usage: "Manage Helm values and secrets across environments" description: |- This plugin helps you manage Helm values and secrets across different environments diff --git a/pyproject.toml b/pyproject.toml index df2cfa8..00ed466 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "helm-values-manager" -version = "0.1.0rc1" +version = "0.1.0" description = "A Helm plugin to manage values and secrets across environments" readme = "README.md" requires-python = ">=3.9" @@ -16,6 +16,7 @@ urls = { Homepage = "https://github.com/zipstack/helm-values-manager" } dependencies = [ "typer>=0.15.1,<0.16.0", "jsonschema>=4.21.1", + "pyyaml>=6.0.2", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/tests/integration/test_cli_integration.py b/tests/integration/test_cli_integration.py index f7c46f5..deff06b 100644 --- a/tests/integration/test_cli_integration.py +++ b/tests/integration/test_cli_integration.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +import yaml def run_helm_command(command: list[str]) -> tuple[str, str, int]: @@ -325,7 +326,7 @@ def test_set_value_nonexistent_path(plugin_install, tmp_path): ["values-manager", "init", "--release", "test-release"] ) assert init_returncode == 0 - assert Path("helm-values.json").exists() + assert Path(work_dir, "helm-values.json").exists() # Add a deployment add_deployment_stdout, add_deployment_stderr, add_deployment_returncode = run_helm_command( @@ -375,3 +376,190 @@ def test_set_value_nonexistent_deployment(plugin_install, tmp_path): ) assert returncode == 1 assert "Deployment 'nonexistent' not found" in stderr + + +def test_generate_help_command(plugin_install): + """Test that the generate help command works and shows expected output.""" + stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--help"]) + assert returncode == 0, f"Failed to run help command: {stderr}" + assert "Generate a values file for a specific deployment" in stdout, "Help text should include command description" + assert "--deployment" in stdout, "Help text should include deployment option" + assert "--output" in stdout, "Help text should include output option" + + +def test_generate_command(plugin_install, tmp_path): + """Test that the generate command works correctly.""" + # Create a test directory + test_dir = tmp_path / "test_generate_command" + test_dir.mkdir() + os.chdir(test_dir) + + # Initialize the plugin + stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"]) + assert returncode == 0, f"Failed to initialize plugin: {stderr}" + + # Add a deployment + stdout, stderr, returncode = run_helm_command(["values-manager", "add-deployment", "dev"]) + assert returncode == 0, f"Failed to add deployment: {stderr}" + + # Add value configs + stdout, stderr, returncode = run_helm_command( + ["values-manager", "add-value-config", "--path", "app.replicas", "--description", "Number of replicas"] + ) + assert returncode == 0, f"Failed to add value config: {stderr}" + + stdout, stderr, returncode = run_helm_command( + ["values-manager", "add-value-config", "--path", "app.image.repository", "--description", "Image repository"] + ) + assert returncode == 0, f"Failed to add value config: {stderr}" + + stdout, stderr, returncode = run_helm_command( + ["values-manager", "add-value-config", "--path", "app.image.tag", "--description", "Image tag"] + ) + assert returncode == 0, f"Failed to add value config: {stderr}" + + # Set values + stdout, stderr, returncode = run_helm_command( + ["values-manager", "set-value", "--path", "app.replicas", "--deployment", "dev", "--value", "3"] + ) + assert returncode == 0, f"Failed to set value: {stderr}" + + stdout, stderr, returncode = run_helm_command( + ["values-manager", "set-value", "--path", "app.image.repository", "--deployment", "dev", "--value", "myapp"] + ) + assert returncode == 0, f"Failed to set value: {stderr}" + + stdout, stderr, returncode = run_helm_command( + ["values-manager", "set-value", "--path", "app.image.tag", "--deployment", "dev", "--value", "latest"] + ) + assert returncode == 0, f"Failed to set value: {stderr}" + + # Generate values file + stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "dev"]) + assert returncode == 0, f"Failed to generate values file: {stderr}" + assert "Successfully generated values file for deployment 'dev'" in stdout, f"Unexpected output: {stdout}" + + # Verify the values file exists + values_file = test_dir / "dev.test-release.values.yaml" + assert values_file.exists(), "Values file should exist" + + # Verify the content of the values file + with open(values_file, "r") as f: + values = yaml.safe_load(f) + assert values["app"]["replicas"] == "3", "Values file should contain correct replicas value" + assert values["app"]["image"]["repository"] == "myapp", "Values file should contain correct repository value" + assert values["app"]["image"]["tag"] == "latest", "Values file should contain correct tag value" + + +def test_generate_with_output_path(plugin_install, tmp_path): + """Test that the generate command works with a custom output path.""" + # Create a test directory + test_dir = tmp_path / "test_generate_with_output_path" + test_dir.mkdir() + os.chdir(test_dir) + + # Create a custom output directory + output_dir = test_dir / "output" + output_dir.mkdir() + + # Initialize the plugin + stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"]) + assert returncode == 0, f"Failed to initialize plugin: {stderr}" + + # Add a deployment + stdout, stderr, returncode = run_helm_command(["values-manager", "add-deployment", "prod"]) + assert returncode == 0, f"Failed to add deployment: {stderr}" + + # Add value configs + stdout, stderr, returncode = run_helm_command( + ["values-manager", "add-value-config", "--path", "app.replicas", "--description", "Number of replicas"] + ) + assert returncode == 0, f"Failed to add value config: {stderr}" + + # Set values + stdout, stderr, returncode = run_helm_command( + ["values-manager", "set-value", "--path", "app.replicas", "--deployment", "prod", "--value", "5"] + ) + assert returncode == 0, f"Failed to set value: {stderr}" + + # Generate values file with custom output path + stdout, stderr, returncode = run_helm_command( + ["values-manager", "generate", "--deployment", "prod", "--output", str(output_dir)] + ) + assert returncode == 0, f"Failed to generate values file: {stderr}" + assert "Successfully generated values file for deployment 'prod'" in stdout, f"Unexpected output: {stdout}" + + # Verify the values file exists in the custom output directory + values_file = output_dir / "prod.test-release.values.yaml" + assert values_file.exists(), "Values file should exist in the custom output directory" + + # Verify the content of the values file + with open(values_file, "r") as f: + values = yaml.safe_load(f) + assert values["app"]["replicas"] == "5", "Values file should contain correct replicas value" + + +def test_generate_nonexistent_deployment(plugin_install, tmp_path): + """Test that generating values for a nonexistent deployment fails with the correct error message.""" + # Create a test directory + test_dir = tmp_path / "test_generate_nonexistent_deployment" + test_dir.mkdir() + os.chdir(test_dir) + + # Initialize the plugin + stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"]) + assert returncode == 0, f"Failed to initialize plugin: {stderr}" + + # Try to generate values for a nonexistent deployment + stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "nonexistent"]) + assert returncode != 0, "Expected command to fail but it succeeded" + assert "Deployment 'nonexistent' not found" in stderr, f"Unexpected error message: {stderr}" + + +def test_generate_no_config(plugin_install, tmp_path): + """Test that generating values without initializing fails with the correct error message.""" + # Create a test directory + test_dir = tmp_path / "test_generate_no_config" + test_dir.mkdir() + os.chdir(test_dir) + + # Try to generate values without initializing + stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "dev"]) + assert returncode != 0, "Expected command to fail but it succeeded" + assert "Configuration file helm-values.json not found" in stderr, f"Unexpected error message: {stderr}" + + +def test_generate_with_missing_required_value(plugin_install, tmp_path): + """Test generate command with missing required values.""" + # Change to temp directory + os.chdir(tmp_path) + + # Initialize the config + stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"]) + assert returncode == 0, f"Failed to initialize config: {stderr}" + + # Add a deployment + stdout, stderr, returncode = run_helm_command(["values-manager", "add-deployment", "dev"]) + assert returncode == 0, f"Failed to add deployment: {stderr}" + + # Add a required value config but don't set a value for it + stdout, stderr, returncode = run_helm_command( + [ + "values-manager", + "add-value-config", + "--path", + "app.required", + "--description", + "Required value", + "--required", + ] + ) + assert returncode == 0, f"Failed to add value config: {stderr}" + + # Try to generate values without setting the required value + stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "dev"]) + + # Verify the command failed + assert returncode != 0, "Expected command to fail but it succeeded" + assert "Missing required values for deployment 'dev'" in stderr + assert "app.required" in stderr diff --git a/tests/unit/commands/test_generate_command.py b/tests/unit/commands/test_generate_command.py new file mode 100644 index 0000000..a0032ea --- /dev/null +++ b/tests/unit/commands/test_generate_command.py @@ -0,0 +1,341 @@ +"""Tests for the generate command.""" + +import json +from unittest.mock import MagicMock, mock_open, patch + +import pytest + +from helm_values_manager.commands.generate_command import GenerateCommand +from helm_values_manager.models.constants import NO_AUTH, NO_BACKEND +from helm_values_manager.models.helm_values_config import Deployment, HelmValuesConfig + + +@pytest.fixture +def mock_config_file(): + """Create a mock configuration file with paths and values.""" + config = HelmValuesConfig() + config.version = "1.0" + config.release = "test-release" + + # Add config paths + config.add_config_path(path="app.replicas", description="Number of app replicas", required=True) + config.add_config_path(path="app.image.repository", description="Container image repository", required=True) + config.add_config_path(path="app.image.tag", description="Container image tag", required=True) + config.add_config_path(path="app.resources.limits.cpu", description="CPU limit", required=False) + config.add_config_path(path="app.resources.limits.memory", description="Memory limit", required=False) + + # Add deployments + dev_deployment = Deployment( + name="dev", + backend=NO_BACKEND, + auth={"type": NO_AUTH}, + backend_config={}, + ) + config.deployments["dev"] = dev_deployment + + prod_deployment = Deployment( + name="prod", + backend=NO_BACKEND, + auth={"type": NO_AUTH}, + backend_config={}, + ) + config.deployments["prod"] = prod_deployment + + # Set values for dev environment + config.set_value(path="app.replicas", environment="dev", value="1") + config.set_value(path="app.image.repository", environment="dev", value="myapp") + config.set_value(path="app.image.tag", environment="dev", value="latest") + config.set_value(path="app.resources.limits.cpu", environment="dev", value="100m") + config.set_value(path="app.resources.limits.memory", environment="dev", value="128Mi") + + # Set values for prod environment + config.set_value(path="app.replicas", environment="prod", value="3") + config.set_value(path="app.image.repository", environment="prod", value="myapp") + config.set_value(path="app.image.tag", environment="prod", value="stable") + config.set_value(path="app.resources.limits.cpu", environment="prod", value="500m") + config.set_value(path="app.resources.limits.memory", environment="prod", value="512Mi") + + return json.dumps(config.to_dict()) + + +@pytest.fixture +def command(): + """Create an instance of the GenerateCommand.""" + return GenerateCommand() + + +def test_generate_success(command, mock_config_file, tmp_path): + """Test successful generation of values file.""" + deployment = "dev" + expected_filename = f"{deployment}.test-release.values.yaml" + + # Create a mock for open that tracks file writes + mock_open_instance = mock_open(read_data=mock_config_file) + + # Mock file operations + with ( + patch("builtins.open", mock_open_instance), + patch("os.path.exists", return_value=True), + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Use a real file for the output + with patch("yaml.dump") as mock_yaml_dump: + # Execute the command + result = command.execute(deployment=deployment, output_path=str(tmp_path)) + + # Verify the result message + assert f"Successfully generated values file for deployment '{deployment}'" in result + assert expected_filename in result + + # Verify yaml.dump was called with the correct data and file + mock_yaml_dump.assert_called_once() + dumped_data = mock_yaml_dump.call_args[0][0] + + # Check that the data structure is correct + assert "app" in dumped_data + assert dumped_data["app"]["replicas"] == "1" + assert dumped_data["app"]["image"]["repository"] == "myapp" + assert dumped_data["app"]["image"]["tag"] == "latest" + assert dumped_data["app"]["resources"]["limits"]["cpu"] == "100m" + assert dumped_data["app"]["resources"]["limits"]["memory"] == "128Mi" + + # Verify the file path in the open call + # Find calls to open with write mode + write_calls = [call for call in mock_open_instance.call_args_list if len(call[0]) > 1 and "w" in call[0][1]] + assert len(write_calls) > 0, "No calls to open() with write mode" + + # Verify the filename in the open call + filename_arg = write_calls[-1][0][0] + assert ( + expected_filename in filename_arg + ), f"Expected filename containing {expected_filename}, got {filename_arg}" + assert str(tmp_path) in filename_arg, f"Expected path containing {tmp_path}, got {filename_arg}" + + +def test_generate_with_output_path(command, mock_config_file, tmp_path): + """Test generating values file with a custom output path.""" + deployment = "prod" + output_path = str(tmp_path / "custom-dir") + expected_filename = f"{deployment}.test-release.values.yaml" + + # Create a mock for open that tracks file writes + mock_open_instance = mock_open(read_data=mock_config_file) + + # Mock file operations + with ( + patch("builtins.open", mock_open_instance), + patch("os.path.exists", side_effect=lambda path: path != output_path), # Directory doesn't exist + patch("os.makedirs") as mock_makedirs, + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Mock the yaml.dump to capture the output + with patch("yaml.dump") as mock_yaml_dump: + # Execute the command + result = command.execute(deployment=deployment, output_path=output_path) + + # Verify the result + assert f"Successfully generated values file for deployment '{deployment}'" in result + assert output_path in result + + # Verify directory creation + mock_makedirs.assert_called_once_with(output_path) + + # Verify yaml.dump was called with the correct data + mock_yaml_dump.assert_called_once() + dumped_data = mock_yaml_dump.call_args[0][0] + + # Check that the data structure is correct + assert "app" in dumped_data + assert dumped_data["app"]["replicas"] == "3" + assert dumped_data["app"]["image"]["tag"] == "stable" + + # Verify the file path in the open call + # Find calls to open with write mode + write_calls = [call for call in mock_open_instance.call_args_list if len(call[0]) > 1 and "w" in call[0][1]] + assert len(write_calls) > 0, "No calls to open() with write mode" + + # Verify the filename in the open call + filename_arg = write_calls[-1][0][0] + assert ( + expected_filename in filename_arg + ), f"Expected filename containing {expected_filename}, got {filename_arg}" + assert output_path in filename_arg, f"Expected path containing {output_path}, got {filename_arg}" + + +def test_generate_missing_deployment(command, mock_config_file): + """Test generating values file with a non-existent deployment.""" + deployment = "staging" + + # Mock file operations + with ( + patch("builtins.open", mock_open(read_data=mock_config_file)), + patch("os.path.exists", return_value=True), + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Execute the command and expect an error + with pytest.raises(KeyError, match=f"Deployment '{deployment}' not found"): + command.execute(deployment=deployment) + + +def test_generate_empty_deployment(command, mock_config_file): + """Test generating values file with an empty deployment.""" + deployment = "" + + # Mock file operations + with ( + patch("builtins.open", mock_open(read_data=mock_config_file)), + patch("os.path.exists", return_value=True), + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Execute the command and expect an error + with pytest.raises(ValueError, match="Deployment cannot be empty"): + command.execute(deployment=deployment) + + +def test_generate_no_config(command): + """Test generating values file when config is None.""" + deployment = "dev" + + # Mock file operations to make load_config return None + with ( + patch.object(command, "load_config", side_effect=FileNotFoundError("Config file not found")), + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Execute the command and expect an error + with pytest.raises(FileNotFoundError, match="Config file not found"): + command.execute(deployment=deployment) + + +def test_generate_with_none_config(command): + """Test generating values file when config is None in the run method.""" + deployment = "dev" + + # Mock load_config to return None instead of raising an exception + with ( + patch.object(command, "load_config", return_value=None), + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Execute the command and expect a ValueError + with pytest.raises(ValueError, match="Configuration not loaded"): + command.execute(deployment=deployment) + + +def test_generate_with_none_value(command, mock_config_file): + """Test generating values file when a value is None.""" + deployment = "dev" + expected_filename = f"{deployment}.test-release.values.yaml" + expected_filepath = f"./{expected_filename}" # Account for ./ prefix + + # Create a mock config with a None value + config = HelmValuesConfig() + config.version = "1.0" + config.release = "test-release" + + # Add config paths + config.add_config_path(path="app.replicas", description="Number of app replicas", required=True) + config.add_config_path(path="app.image.repository", description="Container image repository", required=True) + config.add_config_path( + path="app.image.tag", description="Container image tag", required=False + ) # Changed to not required + + # Add deployment + dev_deployment = Deployment( + name="dev", + backend=NO_BACKEND, + auth={"type": NO_AUTH}, + backend_config={}, + ) + config.deployments["dev"] = dev_deployment + + # Set some values, but leave one as None + config.set_value(path="app.replicas", environment="dev", value="1") + config.set_value(path="app.image.repository", environment="dev", value="myapp") + # app.image.tag is intentionally not set, so it will be None + + # Mock file operations + with ( + patch.object(command, "load_config", return_value=config), + patch("os.path.exists", return_value=True), + patch("fcntl.flock"), + patch("os.open"), + patch("os.close"), + ): + # Use a mock for the output file + with patch("builtins.open", mock_open()) as mock_file, patch("yaml.dump") as mock_yaml_dump: + # Execute the command + result = command.execute(deployment=deployment) + + # Verify the result message + assert f"Successfully generated values file for deployment '{deployment}'" in result + assert expected_filename in result + + # Verify file operations + mock_file.assert_called_once_with(expected_filepath, "w", encoding="utf-8") + + # Verify yaml.dump was called with the correct data + mock_yaml_dump.assert_called_once() + + # Verify the data structure is correct and the None value was skipped + dumped_data = mock_yaml_dump.call_args[0][0] + + # Check that the data structure is correct and the None value was skipped + assert "app" in dumped_data + assert dumped_data["app"]["replicas"] == "1" + assert dumped_data["app"]["image"]["repository"] == "myapp" + assert "tag" not in dumped_data["app"]["image"], "None value should be skipped" + + +def test_generate_with_missing_required_value(): + """Test generate command with a missing required value.""" + # Create a mock config + config = MagicMock(spec=HelmValuesConfig) + + # Setup the deployment + deployment = "dev" + config.deployments = {deployment: MagicMock()} + config.release = "test-release" + + # Setup paths with one required and one optional + path_data_required = MagicMock() + path_data_required._metadata = MagicMock() + path_data_required._metadata.required = True + + path_data_optional = MagicMock() + path_data_optional._metadata = MagicMock() + path_data_optional._metadata.required = False + + # Setup the path map + config._path_map = {"app.required": path_data_required, "app.optional": path_data_optional} + + # Setup get_value to return None for required path and a value for optional path + def mock_get_value(path, env, resolve=False): + if path == "app.required": + return None + return "optional-value" + + config.get_value.side_effect = mock_get_value + + # Create command and mock the load_config method to return our mock config + command = GenerateCommand() + command.load_config = MagicMock(return_value=config) + + # Execute command and expect ValueError + with pytest.raises(ValueError) as excinfo: + command.execute(deployment=deployment, output_path=".") + + # Verify the error message + assert "Missing required values" in str(excinfo.value) + assert "app.required" in str(excinfo.value) + assert "app.optional" not in str(excinfo.value) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 56e1912..62db313 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +from unittest.mock import patch from typer.testing import CliRunner @@ -306,3 +307,158 @@ def test_set_value_no_config(tmp_path): result = runner.invoke(app, ["set-value", "--path", "app.replicas", "--deployment", "dev", "--value", "3"]) assert result.exit_code == 1 assert "Configuration file helm-values.json not found" in result.stdout + + +def test_generate_command(tmp_path): + """Test generate command.""" + # Change to temp directory + os.chdir(tmp_path) + + # Initialize the config + runner.invoke(app, ["init", "--release", "test-release"], catch_exceptions=False) + + # Add a deployment + runner.invoke(app, ["add-deployment", "dev"], catch_exceptions=False) + + # Add value configs + runner.invoke( + app, + ["add-value-config", "--path", "app.replicas", "--description", "Number of replicas"], + catch_exceptions=False, + ) + runner.invoke( + app, + ["add-value-config", "--path", "app.image.repository", "--description", "Image repository"], + catch_exceptions=False, + ) + + # Set values + runner.invoke( + app, ["set-value", "--path", "app.replicas", "--deployment", "dev", "--value", "3"], catch_exceptions=False + ) + runner.invoke( + app, + ["set-value", "--path", "app.image.repository", "--deployment", "dev", "--value", "myapp"], + catch_exceptions=False, + ) + + # Generate values file + result = runner.invoke(app, ["generate", "--deployment", "dev"], catch_exceptions=False) + assert result.exit_code == 0 + assert "Successfully generated values file for deployment 'dev'" in result.stdout + + # Verify the values file exists + values_file = Path("dev.test-release.values.yaml") + assert values_file.exists(), "Values file should exist" + + +def test_generate_with_output_path(tmp_path): + """Test generate command with custom output path.""" + # Change to temp directory + os.chdir(tmp_path) + + # Create a custom output directory + output_dir = tmp_path / "output" + output_dir.mkdir() + + # Initialize the config + runner.invoke(app, ["init", "--release", "test-release"], catch_exceptions=False) + + # Add a deployment + runner.invoke(app, ["add-deployment", "prod"], catch_exceptions=False) + + # Add value configs and set values + runner.invoke( + app, + ["add-value-config", "--path", "app.replicas", "--description", "Number of replicas"], + catch_exceptions=False, + ) + runner.invoke( + app, ["set-value", "--path", "app.replicas", "--deployment", "prod", "--value", "5"], catch_exceptions=False + ) + + # Generate values file with custom output path + result = runner.invoke( + app, ["generate", "--deployment", "prod", "--output", str(output_dir)], catch_exceptions=False + ) + assert result.exit_code == 0 + assert "Successfully generated values file for deployment 'prod'" in result.stdout + + # Verify the values file exists in the custom output directory + values_file = output_dir / "prod.test-release.values.yaml" + assert values_file.exists(), "Values file should exist in the custom output directory" + + +def test_generate_nonexistent_deployment(tmp_path): + """Test generate command with a nonexistent deployment.""" + # Change to temp directory + os.chdir(tmp_path) + + # Initialize the config + runner.invoke(app, ["init", "--release", "test-release"], catch_exceptions=False) + + # Try to generate values for a nonexistent deployment + result = runner.invoke(app, ["generate", "--deployment", "nonexistent"]) + assert result.exit_code == 1 + assert "Failed to generate values file" in result.stdout + assert "Deployment 'nonexistent' not found" in result.stdout + + +def test_generate_no_config(tmp_path): + """Test generate command without initializing config first.""" + # Change to temp directory + os.chdir(tmp_path) + + # Try to generate values without initializing + result = runner.invoke(app, ["generate", "--deployment", "dev"]) + assert result.exit_code == 1 + assert "Failed to generate values file" in result.stdout + assert "Configuration file" in result.stdout + + +def test_generate_command_with_missing_required_value(tmp_path): + """Test generate command with missing required values.""" + # Change to temp directory + os.chdir(tmp_path) + + # Initialize the config + runner.invoke(app, ["init", "--release", "test-release"], catch_exceptions=False) + + # Add a deployment + runner.invoke(app, ["add-deployment", "dev"], catch_exceptions=False) + + # Add a required value config but don't set a value for it + runner.invoke( + app, + ["add-value-config", "--path", "app.required", "--description", "Required value", "--required"], + catch_exceptions=False, + ) + + # Try to generate values without setting the required value + result = runner.invoke(app, ["generate", "--deployment", "dev"]) + assert result.exit_code == 1 + assert "Failed to generate values file" in result.stdout + assert "Missing required values" in result.stdout + assert "app.required" in result.stdout + + +def test_generate_command_error_handling(tmp_path): + """Test generate command error handling.""" + # Change to temp directory + os.chdir(tmp_path) + + # Initialize the config + runner.invoke(app, ["init", "--release", "test-release"], catch_exceptions=False) + + # Add a deployment + runner.invoke(app, ["add-deployment", "dev"], catch_exceptions=False) + + # Mock the GenerateCommand to raise an exception + with patch("helm_values_manager.cli.GenerateCommand") as mock_command: + mock_instance = mock_command.return_value + mock_instance.execute.side_effect = ValueError("Test error") + + # Try to generate values + result = runner.invoke(app, ["generate", "--deployment", "dev"]) + assert result.exit_code == 1 + assert "Failed to generate values file: Test error" in result.stdout diff --git a/tools/test-plugin.sh b/tools/test-plugin.sh index 697543b..ed392e3 100755 --- a/tools/test-plugin.sh +++ b/tools/test-plugin.sh @@ -10,6 +10,23 @@ NC='\033[0m' # No Color INSTALL_SOURCE="local" GITHUB_URL="https://github.com/Zipstack/helm-values-manager" # Correct capitalization DEBUG_FLAG="" +CLEANUP_ONLY=false + +# Function to clean up test files and optionally uninstall plugin +cleanup() { + echo -e "\n${GREEN}Cleaning up test files...${NC}" + + # Remove test files + rm -f helm-values.json .helm-values.lock + rm -f *.values.yaml + rm -rf output + + # Always uninstall the plugin + echo "Removing helm-values-manager plugin..." + helm plugin remove values-manager 2>/dev/null || true + + echo -e "${GREEN}Cleanup complete.${NC}" +} # Parse command line arguments while [[ $# -gt 0 ]]; do @@ -31,27 +48,41 @@ while [[ $# -gt 0 ]]; do DEBUG_FLAG="--debug" shift ;; + --cleanup) + CLEANUP_ONLY=true + shift + ;; *) echo "Unknown option: $1" - echo "Usage: $0 [--source local|github] [--github-url URL] [--debug]" - echo "Example:" - echo " $0 # Install from local directory" - echo " $0 --source github # Install from default GitHub repo" - echo " $0 --source github --github-url URL # Install from custom GitHub repo" - echo " $0 --debug # Run with debug output" + echo "Usage: $0 [--source local|github] [--github-url URL] [--debug] [--cleanup]" + echo "Options:" + echo " --source local|github Source to install plugin from (default: local)" + echo " --github-url URL GitHub URL to install plugin from" + echo " --debug Run with debug output" + echo " --cleanup Only clean up test files and uninstall plugin" + echo "Examples:" + echo " $0 # Install from local directory and run tests" + echo " $0 --source github # Install from default GitHub repo and run tests" + echo " $0 --cleanup # Only clean up test files and uninstall plugin" exit 1 ;; esac done +# Clean up any existing test files +cleanup + +# If cleanup only, just exit +if [ "$CLEANUP_ONLY" = true ]; then + exit 0 +fi + # Get the absolute path to the plugin directory if installing locally if [ "$INSTALL_SOURCE" = "local" ]; then PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fi echo "Installing helm-values-manager plugin..." -# Remove existing plugin if it exists -helm plugin remove values-manager 2>/dev/null || true # Install plugin based on source if [ "$INSTALL_SOURCE" = "local" ]; then @@ -64,9 +95,6 @@ fi echo -e "\n${GREEN}Running test sequence...${NC}" -# Clean up any existing test files -rm -f helm-values.json .helm-values.lock - # Initialize with a valid release name echo -e "\n${GREEN}1. Initializing helm values configuration...${NC}" helm values-manager init -r "test-release" $DEBUG_FLAG @@ -80,14 +108,13 @@ helm values-manager add-deployment prod $DEBUG_FLAG # Add value configurations echo -e "\n${GREEN}3. Adding value configurations...${NC}" helm values-manager add-value-config -p "app.config.name" -d "Application name" -r $DEBUG_FLAG -helm values-manager add-value-config -p "app.config.replicas" -d "Number of replicas" $DEBUG_FLAG +helm values-manager add-value-config -p "app.config.replicas" -d "Number of replicas" -r $DEBUG_FLAG # Set values for different environments echo -e "\n${GREEN}4. Setting values...${NC}" helm values-manager set-value -p "app.config.name" -v "my-test-app" -d dev $DEBUG_FLAG helm values-manager set-value -p "app.config.replicas" -v "1" -d dev $DEBUG_FLAG helm values-manager set-value -p "app.config.name" -v "my-prod-app" -d prod $DEBUG_FLAG -helm values-manager set-value -p "app.config.replicas" -v "3" -d prod $DEBUG_FLAG # Verify configurations echo -e "\n${GREEN}5. Verifying configurations...${NC}" @@ -97,4 +124,36 @@ cat helm-values.json echo -e "\n.helm-values.lock contents:" cat .helm-values.lock +# Generate values files +echo -e "\n${GREEN}6. Generating values files...${NC}" +echo -e "\nGenerating values file for dev deployment..." +helm values-manager generate -d dev $DEBUG_FLAG + +# Temporarily disable exit on error for the prod generate command +set +e +echo -e "\nGenerating values file for prod deployment with custom output path..." +mkdir -p output +helm values-manager generate -d prod -o output $DEBUG_FLAG +GENERATE_PROD_STATUS=$? +set -e + +# Check if the prod generate command failed +if [ $GENERATE_PROD_STATUS -ne 0 ]; then + echo -e "\n${RED}Warning: Failed to generate values file for prod deployment.${NC}" + echo -e "${RED}This is expected because we didn't set a required value for app.config.replicas.${NC}" +fi + +# Verify generated files +echo -e "\n${GREEN}7. Verifying generated files...${NC}" +echo -e "\ndev.test-release.values.yaml contents:" +cat dev.test-release.values.yaml + +# Only try to display the prod values file if it was successfully generated +if [ -f "output/prod.test-release.values.yaml" ]; then + echo -e "\noutput/prod.test-release.values.yaml contents:" + cat output/prod.test-release.values.yaml +else + echo -e "\n${RED}Note: output/prod.test-release.values.yaml was not generated due to missing required values.${NC}" +fi + echo -e "\n${GREEN}Test sequence completed successfully!${NC}"