Skip to content

Commit b9aaa56

Browse files
committed
feat: add generate command
- Add generate command to create values files for deployments - Add pyyaml dependency for YAML file generation - Update CLI with generate command and flags - Add integration tests for generate command - Update test plugin script with generate command tests - Update sequence diagrams with correct command flags The generate command allows users to create values.yaml files for specific deployments, with support for custom output paths. Required values are validated before generation.
1 parent 90cecbd commit b9aaa56

File tree

7 files changed

+898
-14
lines changed

7 files changed

+898
-14
lines changed

helm_values_manager/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from helm_values_manager.commands.add_deployment_command import AddDeploymentCommand
66
from helm_values_manager.commands.add_value_config_command import AddValueConfigCommand
7+
from helm_values_manager.commands.generate_command import GenerateCommand
78
from helm_values_manager.commands.init_command import InitCommand
89
from helm_values_manager.commands.set_value_command import SetValueCommand
910
from helm_values_manager.models.config_metadata import ConfigMetadata
@@ -125,5 +126,24 @@ def set_value(
125126
raise typer.Exit(code=1) from e
126127

127128

129+
@app.command("generate")
130+
def generate(
131+
deployment: str = typer.Option(
132+
..., "--deployment", "-d", help="Deployment to generate values for (e.g., 'dev', 'prod')"
133+
),
134+
output_path: str = typer.Option(
135+
".", "--output", "-o", help="Directory to output the values file to (default: current directory)"
136+
),
137+
):
138+
"""Generate a values file for a specific deployment."""
139+
try:
140+
command = GenerateCommand()
141+
result = command.execute(deployment=deployment, output_path=output_path)
142+
typer.echo(result)
143+
except Exception as e:
144+
HelmLogger.error("Failed to generate values file: %s", str(e))
145+
raise typer.Exit(code=1) from e
146+
147+
128148
if __name__ == "__main__":
129149
app(prog_name=COMMAND_INFO)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Command to generate values file for a specific deployment."""
2+
3+
import os
4+
from typing import Any, Dict, Optional
5+
6+
import yaml
7+
8+
from helm_values_manager.commands.base_command import BaseCommand
9+
from helm_values_manager.models.helm_values_config import HelmValuesConfig
10+
from helm_values_manager.utils.logger import HelmLogger
11+
12+
13+
class GenerateCommand(BaseCommand):
14+
"""Command to generate values file for a specific deployment."""
15+
16+
def run(self, config: Optional[HelmValuesConfig] = None, **kwargs) -> str:
17+
"""
18+
Generate a values file for a specific deployment.
19+
20+
Args:
21+
config: The loaded configuration
22+
**kwargs: Command arguments
23+
- deployment (str): The deployment to generate values for (e.g., 'dev', 'prod')
24+
- output_path (str, optional): Directory to output the values file to
25+
26+
Returns:
27+
str: Success message with the path to the generated file
28+
29+
Raises:
30+
ValueError: If deployment is empty
31+
KeyError: If deployment doesn't exist in the configuration
32+
FileNotFoundError: If the configuration file doesn't exist
33+
"""
34+
if config is None:
35+
raise ValueError("Configuration not loaded")
36+
37+
deployment = kwargs.get("deployment")
38+
if not deployment:
39+
raise ValueError("Deployment cannot be empty")
40+
41+
output_path = kwargs.get("output_path", ".")
42+
43+
# Validate that the deployment exists
44+
if deployment not in config.deployments:
45+
raise KeyError(f"Deployment '{deployment}' not found")
46+
47+
# Create output directory if it doesn't exist
48+
if not os.path.exists(output_path):
49+
os.makedirs(output_path)
50+
HelmLogger.debug("Created output directory: %s", output_path)
51+
52+
# Generate values dictionary from configuration
53+
values_dict = self._generate_values_dict(config, deployment)
54+
55+
# Generate filename based on deployment and release
56+
filename = f"{deployment}.{config.release}.values.yaml"
57+
file_path = os.path.join(output_path, filename)
58+
59+
# Write values to file
60+
with open(file_path, "w", encoding="utf-8") as f:
61+
yaml.dump(values_dict, f, default_flow_style=False)
62+
63+
HelmLogger.debug("Generated values file for deployment '%s' at '%s'", deployment, file_path)
64+
return f"Successfully generated values file for deployment '{deployment}' at '{file_path}'"
65+
66+
def _generate_values_dict(self, config: HelmValuesConfig, deployment: str) -> Dict[str, Any]:
67+
"""
68+
Generate a nested dictionary of values from the configuration.
69+
70+
Args:
71+
config: The loaded configuration
72+
deployment: The deployment to generate values for
73+
74+
Returns:
75+
Dict[str, Any]: Nested dictionary of values
76+
77+
Raises:
78+
ValueError: If a required value is missing for the deployment
79+
"""
80+
values_dict = {}
81+
missing_required_paths = []
82+
83+
# Get all paths from the configuration
84+
for path in config._path_map.keys():
85+
path_data = config._path_map[path]
86+
87+
# Check if this is a required value
88+
is_required = path_data._metadata.required
89+
90+
# Get the value for this path and deployment
91+
value = config.get_value(path, deployment, resolve=True)
92+
93+
# If the value is None and it's required, add to missing list
94+
if value is None and is_required:
95+
missing_required_paths.append(path)
96+
continue
97+
98+
# Skip if no value is set
99+
if value is None:
100+
continue
101+
102+
# Convert dot-separated path to nested dictionary
103+
path_parts = path.split(".")
104+
current_dict = values_dict
105+
106+
# Navigate to the correct nested level
107+
for i, part in enumerate(path_parts):
108+
# If we're at the last part, set the value
109+
if i == len(path_parts) - 1:
110+
current_dict[part] = value
111+
else:
112+
# Create nested dictionary if it doesn't exist
113+
if part not in current_dict:
114+
current_dict[part] = {}
115+
current_dict = current_dict[part]
116+
117+
# If there are missing required values, raise an error
118+
if missing_required_paths:
119+
paths_str = ", ".join(missing_required_paths)
120+
raise ValueError(f"Missing required values for deployment '{deployment}': {paths_str}")
121+
122+
return values_dict

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ urls = { Homepage = "https://github.com/zipstack/helm-values-manager" }
1616
dependencies = [
1717
"typer>=0.15.1,<0.16.0",
1818
"jsonschema>=4.21.1",
19+
"pyyaml>=6.0.2",
1920
]
2021
classifiers = [
2122
"Development Status :: 4 - Beta",

tests/integration/test_cli_integration.py

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77

88
import pytest
9+
import yaml
910

1011

1112
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):
325326
["values-manager", "init", "--release", "test-release"]
326327
)
327328
assert init_returncode == 0
328-
assert Path("helm-values.json").exists()
329+
assert Path(work_dir, "helm-values.json").exists()
329330

330331
# Add a deployment
331332
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):
375376
)
376377
assert returncode == 1
377378
assert "Deployment 'nonexistent' not found" in stderr
379+
380+
381+
def test_generate_help_command(plugin_install):
382+
"""Test that the generate help command works and shows expected output."""
383+
stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--help"])
384+
assert returncode == 0, f"Failed to run help command: {stderr}"
385+
assert "Generate a values file for a specific deployment" in stdout, "Help text should include command description"
386+
assert "--deployment" in stdout, "Help text should include deployment option"
387+
assert "--output" in stdout, "Help text should include output option"
388+
389+
390+
def test_generate_command(plugin_install, tmp_path):
391+
"""Test that the generate command works correctly."""
392+
# Create a test directory
393+
test_dir = tmp_path / "test_generate_command"
394+
test_dir.mkdir()
395+
os.chdir(test_dir)
396+
397+
# Initialize the plugin
398+
stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"])
399+
assert returncode == 0, f"Failed to initialize plugin: {stderr}"
400+
401+
# Add a deployment
402+
stdout, stderr, returncode = run_helm_command(["values-manager", "add-deployment", "dev"])
403+
assert returncode == 0, f"Failed to add deployment: {stderr}"
404+
405+
# Add value configs
406+
stdout, stderr, returncode = run_helm_command(
407+
["values-manager", "add-value-config", "--path", "app.replicas", "--description", "Number of replicas"]
408+
)
409+
assert returncode == 0, f"Failed to add value config: {stderr}"
410+
411+
stdout, stderr, returncode = run_helm_command(
412+
["values-manager", "add-value-config", "--path", "app.image.repository", "--description", "Image repository"]
413+
)
414+
assert returncode == 0, f"Failed to add value config: {stderr}"
415+
416+
stdout, stderr, returncode = run_helm_command(
417+
["values-manager", "add-value-config", "--path", "app.image.tag", "--description", "Image tag"]
418+
)
419+
assert returncode == 0, f"Failed to add value config: {stderr}"
420+
421+
# Set values
422+
stdout, stderr, returncode = run_helm_command(
423+
["values-manager", "set-value", "--path", "app.replicas", "--deployment", "dev", "--value", "3"]
424+
)
425+
assert returncode == 0, f"Failed to set value: {stderr}"
426+
427+
stdout, stderr, returncode = run_helm_command(
428+
["values-manager", "set-value", "--path", "app.image.repository", "--deployment", "dev", "--value", "myapp"]
429+
)
430+
assert returncode == 0, f"Failed to set value: {stderr}"
431+
432+
stdout, stderr, returncode = run_helm_command(
433+
["values-manager", "set-value", "--path", "app.image.tag", "--deployment", "dev", "--value", "latest"]
434+
)
435+
assert returncode == 0, f"Failed to set value: {stderr}"
436+
437+
# Generate values file
438+
stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "dev"])
439+
assert returncode == 0, f"Failed to generate values file: {stderr}"
440+
assert "Successfully generated values file for deployment 'dev'" in stdout, f"Unexpected output: {stdout}"
441+
442+
# Verify the values file exists
443+
values_file = test_dir / "dev.test-release.values.yaml"
444+
assert values_file.exists(), "Values file should exist"
445+
446+
# Verify the content of the values file
447+
with open(values_file, "r") as f:
448+
values = yaml.safe_load(f)
449+
assert values["app"]["replicas"] == "3", "Values file should contain correct replicas value"
450+
assert values["app"]["image"]["repository"] == "myapp", "Values file should contain correct repository value"
451+
assert values["app"]["image"]["tag"] == "latest", "Values file should contain correct tag value"
452+
453+
454+
def test_generate_with_output_path(plugin_install, tmp_path):
455+
"""Test that the generate command works with a custom output path."""
456+
# Create a test directory
457+
test_dir = tmp_path / "test_generate_with_output_path"
458+
test_dir.mkdir()
459+
os.chdir(test_dir)
460+
461+
# Create a custom output directory
462+
output_dir = test_dir / "output"
463+
output_dir.mkdir()
464+
465+
# Initialize the plugin
466+
stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"])
467+
assert returncode == 0, f"Failed to initialize plugin: {stderr}"
468+
469+
# Add a deployment
470+
stdout, stderr, returncode = run_helm_command(["values-manager", "add-deployment", "prod"])
471+
assert returncode == 0, f"Failed to add deployment: {stderr}"
472+
473+
# Add value configs
474+
stdout, stderr, returncode = run_helm_command(
475+
["values-manager", "add-value-config", "--path", "app.replicas", "--description", "Number of replicas"]
476+
)
477+
assert returncode == 0, f"Failed to add value config: {stderr}"
478+
479+
# Set values
480+
stdout, stderr, returncode = run_helm_command(
481+
["values-manager", "set-value", "--path", "app.replicas", "--deployment", "prod", "--value", "5"]
482+
)
483+
assert returncode == 0, f"Failed to set value: {stderr}"
484+
485+
# Generate values file with custom output path
486+
stdout, stderr, returncode = run_helm_command(
487+
["values-manager", "generate", "--deployment", "prod", "--output", str(output_dir)]
488+
)
489+
assert returncode == 0, f"Failed to generate values file: {stderr}"
490+
assert "Successfully generated values file for deployment 'prod'" in stdout, f"Unexpected output: {stdout}"
491+
492+
# Verify the values file exists in the custom output directory
493+
values_file = output_dir / "prod.test-release.values.yaml"
494+
assert values_file.exists(), "Values file should exist in the custom output directory"
495+
496+
# Verify the content of the values file
497+
with open(values_file, "r") as f:
498+
values = yaml.safe_load(f)
499+
assert values["app"]["replicas"] == "5", "Values file should contain correct replicas value"
500+
501+
502+
def test_generate_nonexistent_deployment(plugin_install, tmp_path):
503+
"""Test that generating values for a nonexistent deployment fails with the correct error message."""
504+
# Create a test directory
505+
test_dir = tmp_path / "test_generate_nonexistent_deployment"
506+
test_dir.mkdir()
507+
os.chdir(test_dir)
508+
509+
# Initialize the plugin
510+
stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"])
511+
assert returncode == 0, f"Failed to initialize plugin: {stderr}"
512+
513+
# Try to generate values for a nonexistent deployment
514+
stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "nonexistent"])
515+
assert returncode != 0, "Expected command to fail but it succeeded"
516+
assert "Deployment 'nonexistent' not found" in stderr, f"Unexpected error message: {stderr}"
517+
518+
519+
def test_generate_no_config(plugin_install, tmp_path):
520+
"""Test that generating values without initializing fails with the correct error message."""
521+
# Create a test directory
522+
test_dir = tmp_path / "test_generate_no_config"
523+
test_dir.mkdir()
524+
os.chdir(test_dir)
525+
526+
# Try to generate values without initializing
527+
stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "dev"])
528+
assert returncode != 0, "Expected command to fail but it succeeded"
529+
assert "Configuration file helm-values.json not found" in stderr, f"Unexpected error message: {stderr}"
530+
531+
532+
def test_generate_with_missing_required_value(plugin_install, tmp_path):
533+
"""Test generate command with missing required values."""
534+
# Change to temp directory
535+
os.chdir(tmp_path)
536+
537+
# Initialize the config
538+
stdout, stderr, returncode = run_helm_command(["values-manager", "init", "--release", "test-release"])
539+
assert returncode == 0, f"Failed to initialize config: {stderr}"
540+
541+
# Add a deployment
542+
stdout, stderr, returncode = run_helm_command(["values-manager", "add-deployment", "dev"])
543+
assert returncode == 0, f"Failed to add deployment: {stderr}"
544+
545+
# Add a required value config but don't set a value for it
546+
stdout, stderr, returncode = run_helm_command(
547+
[
548+
"values-manager",
549+
"add-value-config",
550+
"--path",
551+
"app.required",
552+
"--description",
553+
"Required value",
554+
"--required",
555+
]
556+
)
557+
assert returncode == 0, f"Failed to add value config: {stderr}"
558+
559+
# Try to generate values without setting the required value
560+
stdout, stderr, returncode = run_helm_command(["values-manager", "generate", "--deployment", "dev"])
561+
562+
# Verify the command failed
563+
assert returncode != 0, "Expected command to fail but it succeeded"
564+
assert "Missing required values for deployment 'dev'" in stderr
565+
assert "app.required" in stderr

0 commit comments

Comments
 (0)