diff --git a/README.md b/README.md index 3cc31ff..7203ce2 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ poetry run ruff format src/ tests/ # Type checking poetry run mypy src/ -Note: The file `py.typed` has added to the package and specified in pyproject.toml to ensure mypy treats +Note: The file `py.typed` has been added to the package and specified in pyproject.toml to ensure mypy treats tkseal as a typed package and avoids "Skipping analyzing 'tkseal': found module but no type hints or library stubs" warnings. ``` @@ -63,8 +63,8 @@ tkseal as a typed package and avoids "Skipping analyzing 'tkseal': found module - ✅ `tkseal ready` - Check dependencies (WIP) - ✅ `tkseal diff PATH` - Show differences between plain_secrets.json and cluster - ✅ `tkseal pull PATH` - Extracting secrets from cluster to plain_secrets.json -- 💻 `tkseal seal PATH` - Convert plain_secrets.json to sealed_secrets.json -- +- ✅ `tkseal seal PATH` - Convert plain_secrets.json to sealed_secrets.json + ## Logic documentation @@ -103,13 +103,18 @@ tkseal as a typed package and avoids "Skipping analyzing 'tkseal': found module `tkseal pull environments/testing/` - This shows how "plain_secrets.json" would change based on what's in the Kubernetes cluster + This shows how "plain_secrets.json" would change based on what's in the Kubernetes cluster, and it will + warn about forbidden secrets: - `⚠ Warning: Found forbidden secrets in namespace that cannot be pulled: - - default-token-abc (type: kubernetes.io/service-account-token) - - helm-release-v1 (type: helm.sh/release.v1)` + ``` + These secrets are system-managed and will not be included in plain_secrets.json: + - oidc-saml-proxy-tls (type: kubernetes.io/tls) + This shows how "plain_secrets.json" would change based on what's in the Kubernetes cluster +--- plain_secrets.json ++++ cluster + ``` -### ready Command +## ready Command **Core Functionality** @@ -156,7 +161,7 @@ which can then be safely stored in version control systems like Git. - Example usage: `printf "secret" | kubeseal --raw --namespace ns --name secret-name --context ctx` -### diff command +## diff command **Core Functionality** The diff command compares the local `plain_secrets.json` file in a specified Tanka environment directory with @@ -175,6 +180,7 @@ Usage Example **Show what would change in cluster** ```tkseal diff /path/to/tanka/environments/production``` +Use `tkseal diff /path/to/env --format yaml` to output plain secrets in YAML format. **If there are differences, shows a unified diff** @@ -202,7 +208,7 @@ No differences Error: Path '/nonexistent/path' does not exist. ``` -### pull command +## pull command **Core Functionality** The pull command extracts existing Kubernetes secrets from the cluster and writes them to a local plain_secrets.json @@ -215,8 +221,11 @@ The flow is: 3. Prompt user for confirmation 4. Write kube secrets to plain_secrets.json +### pull secrets (with confirmation) +`tkseal pull /path/to/tanka/environment` +Use `tkseal pull /path/to/env --format yaml` to output plain secrets in YAML format. -### seal command +## seal command **Core Functionality** The seal command reads the local plain_secrets.json file in a specified Tanka environment directory, @@ -232,11 +241,21 @@ The flow is: 3. Seal each secret using kubeseal 4. Write sealed secrets to sealed_secrets.json -# Seal secrets (with confirmation) +### Seal secrets (with confirmation) `tkseal seal /path/to/tanka/environment` +Use `tkseal seal /path/to/env --format yaml` to output sealed secrets in YAML format. The command will: 1. Show yellow warning about cluster changes 2. Display diff of what would change 3. Ask for confirmation 4. Seal secrets to sealed_secrets.json + +# Example of errors running tkseal commands + +This error means that you probably are not in a Tanka environment directory or the directory structure is incorrect. +Remember that Tanka expects a specific directory structure with `main.jsonnet` file in the environment's base directory. + +```Error: Failed to initialize Tanka environment: Command failed with exit code 1: Error: Unable to identify the environments base directory. +Tried to find 'main.jsonnet' in the parent directories. +Please refer to https://tanka.dev/directory-structure for more information``` diff --git a/src/tkseal/cli.py b/src/tkseal/cli.py index 3f9c2f0..93eb54a 100644 --- a/src/tkseal/cli.py +++ b/src/tkseal/cli.py @@ -49,22 +49,28 @@ def ready() -> None: @cli.command() @click.argument("path", type=click.Path(exists=True)) -def diff(path: str) -> None: - """Show differences between plain_secrets.json and cluster secrets. +@click.option( + "--format", + type=click.Choice(["json", "yaml"], case_sensitive=False), + default="json", + help="Output format for secret files (default: json)", +) +def diff(path: str, format: str) -> None: + """Show differences between plain_secrets file and cluster secrets. PATH: Path to Tanka environment directory or .jsonnet file - This shows what would change in the cluster based on plain_secrets.json + This shows what would change in the cluster based on plain_secrets file """ try: - # Create SecretState from path - secret_state = SecretState.from_path(path) + # Create SecretState from path with specified format + secret_state = SecretState.from_path(path, format=format) # Create a Diff instance and run comparison diff_obj = Diff(secret_state) result = diff_obj.plain() - # Display results + # Display results - Always in JSON format, it is independent of the sealed_secrets format (YAML/JSON) if result.has_differences: click.echo(result.diff_output) else: @@ -77,17 +83,23 @@ def diff(path: str) -> None: @cli.command() @click.argument("path", type=click.Path(exists=True)) -def pull(path: str) -> None: - """Pull secrets from the cluster to plain_secrets.json. +@click.option( + "--format", + type=click.Choice(["json", "yaml"], case_sensitive=False), + default="json", + help="Output format for secret files (default: json)", +) +def pull(path: str, format: str) -> None: + """Pull secrets from the cluster to plain_secrets file. PATH: Path to Tanka environment directory or .jsonnet file This extracts unencrypted secrets from the Kubernetes cluster - and saves them to plain_secrets.json in the environment directory. + and saves them to plain_secrets.json or plain_secrets.yaml in the environment directory. """ try: - # Create SecretState from path - secret_state = SecretState.from_path(path) + # Create SecretState from path with specified format + secret_state = SecretState.from_path(path, format=format) # Create Pull instance and show differences pull_obj = Pull(secret_state) @@ -104,10 +116,16 @@ def pull(path: str) -> None: click.secho(f" - {secret.name} (type: {secret.type})", fg="yellow") # Show informational message + plain_secrets_file = f"plain_secrets.{format}" click.secho( - 'This shows how "plain_secrets.json" would change based on what\'s in the Kubernetes cluster', + f'This shows how "{plain_secrets_file}" would change based on what\'s in the Kubernetes cluster', fg="yellow", ) + + # Create Pull instance and show differences + pull_obj = Pull(secret_state) + result = pull_obj.run() + # Display diff results if result.has_differences: click.echo(result.diff_output) @@ -115,7 +133,7 @@ def pull(path: str) -> None: # Confirm before writing if click.confirm("Are you sure?"): pull_obj.write() - click.echo("Successfully pulled secrets to plain_secrets.json") + click.echo(f"Successfully pulled secrets to {plain_secrets_file}") else: click.echo("No differences") @@ -126,21 +144,29 @@ def pull(path: str) -> None: @cli.command() @click.argument("path", type=click.Path(exists=True)) -def seal(path: str) -> None: - """Seal plain_secrets.json to sealed_secrets.json. +@click.option( + "--format", + type=click.Choice(["json", "yaml"], case_sensitive=False), + default="json", + help="Output format for secret files (default: json)", +) +def seal(path: str, format: str) -> None: + """Seal plain_secrets file to sealed_secrets file. PATH: Path to Tanka environment directory or .jsonnet file - Takes secrets from plain_secrets.json, encrypts them using kubeseal, - and saves the resulting SealedSecret resources to sealed_secrets.json. + Takes secrets from plain_secrets file, encrypts them using kubeseal, + and saves the resulting SealedSecret resources to sealed_secrets file. """ try: - # Create SecretState from path - secret_state = SecretState.from_path(path) + # Create SecretState from path with specified format + secret_state = SecretState.from_path(path, format=format) + + sealed_secrets_file = f"sealed_secrets.{format}" # Show informational message # click.secho( - # 'This shows what would change in the cluster based on "plain_secrets.json"', + # f'This shows what would change in the cluster based on "plain_secrets.{format}"', # fg="yellow", # ) @@ -156,7 +182,7 @@ def seal(path: str) -> None: if click.confirm("Are you sure?"): seal_obj = Seal(secret_state) seal_obj.run() - click.echo("Successfully sealed secrets to sealed_secrets.json") + click.echo(f"Successfully sealed secrets to {sealed_secrets_file}") # else: # click.echo("No differences") diff --git a/src/tkseal/configuration.py b/src/tkseal/configuration.py index cb76517..6ede3b8 100644 --- a/src/tkseal/configuration.py +++ b/src/tkseal/configuration.py @@ -8,11 +8,11 @@ and defined in the configuration module. """ -# File name for plain (unencrypted) secrets JSON file -PLAIN_SECRETS_FILE = "plain_secrets.json" +# File name for plain (unencrypted) secrets JSON/YAML file +PLAIN_SECRETS_FILE = "plain_secrets" # File name for sealed (encrypted) secrets JSON file -SEALED_SECRETS_FILE = "sealed_secrets.json" +SEALED_SECRETS_FILE = "sealed_secrets" # Allowed secret types that tkseal can manage MANAGED_SECRET_TYPES = { diff --git a/src/tkseal/diff.py b/src/tkseal/diff.py index 2ec7a5a..ad62895 100644 --- a/src/tkseal/diff.py +++ b/src/tkseal/diff.py @@ -5,6 +5,7 @@ from tkseal.configuration import PLAIN_SECRETS_FILE from tkseal.secret_state import SecretState +from tkseal.tkseal_utils import normalize_to_json @dataclass @@ -27,11 +28,18 @@ def plain(self) -> DiffResult: kube_secrets = self.secret_state.kube_secrets() plain_secrets = self.secret_state.plain_secrets() + # Normalize both to JSON for comparison + kube_secrets_normalized = normalize_to_json(kube_secrets, "json") + plain_secrets_normalized = normalize_to_json( + plain_secrets, self.secret_state.format + ) + + # return self._generate_diff( - from_text=kube_secrets, - to_text=plain_secrets, + from_text=kube_secrets_normalized, + to_text=plain_secrets_normalized, from_label="cluster", - to_label=PLAIN_SECRETS_FILE, + to_label=f"{PLAIN_SECRETS_FILE}.{self.secret_state.format}", ) def pull(self) -> DiffResult: @@ -41,10 +49,16 @@ def pull(self) -> DiffResult: plain_secrets = self.secret_state.plain_secrets() kube_secrets = self.secret_state.kube_secrets() + # Normalize both to JSON for comparison + plain_secrets_normalized = normalize_to_json( + plain_secrets, self.secret_state.format + ) + kube_secrets_normalized = normalize_to_json(kube_secrets, "json") + return self._generate_diff( - from_text=plain_secrets, - to_text=kube_secrets, - from_label=PLAIN_SECRETS_FILE, + from_text=plain_secrets_normalized, + to_text=kube_secrets_normalized, + from_label=f"{PLAIN_SECRETS_FILE}.{self.secret_state.format}", to_label="cluster", ) diff --git a/src/tkseal/pull.py b/src/tkseal/pull.py index 24b364c..040f0ea 100644 --- a/src/tkseal/pull.py +++ b/src/tkseal/pull.py @@ -1,12 +1,13 @@ from tkseal.diff import Diff, DiffResult from tkseal.secret_state import SecretState +from tkseal.serializers import get_serializer class Pull: """Handles pulling secrets from Kubernetes cluster to local files. This class coordinates the process of retrieving secrets from a Kubernetes - cluster and saving them to the local plain_secrets.json file. + cluster and saving them to the local plain_secrets.json or plain_secrets.yaml file. """ def __init__(self, secret_state: SecretState): @@ -20,8 +21,8 @@ def __init__(self, secret_state: SecretState): def run(self) -> DiffResult: """Show differences between local and cluster secrets. - This method displays what would change in the local plain_secrets.json - file if secrets were pulled from the cluster. + This method displays what would change in the local plain_secrets file + if secrets were pulled from the cluster. Returns: DiffResult: Result containing information about the difference @@ -33,15 +34,27 @@ def run(self) -> DiffResult: return diff.pull() def write(self) -> None: - """Write cluster secrets to the plain_secrets.json file. + """Write cluster secrets to the plain_secrets file in the specified format. - This method retrieves secrets from the Kubernetes cluster and writes - them to the local plain_secrets.json file, overwriting any existing content. + This method retrieves secrets from the Kubernetes cluster (as JSON) and writes + them to the local plain_secrets file, converting to YAML if needed. Raises: TKSealError: If there's an error retrieving secrets from cluster PermissionError: If there's an error writing to the file OSError: If there's an I/O error writing to the file """ - kube_secrets = self.secret_state.kube_secrets() - self.secret_state.plain_secrets_file_path.write_text(kube_secrets) + # Get secrets from cluster as JSON string + kube_secrets_json = self.secret_state.kube_secrets() + + # Convert to the desired format if needed + if self.secret_state.format == "yaml": + # Deserialize JSON and re-serialize to YAML + secret_serializer = get_serializer(self.secret_state.format) + secrets_data = secret_serializer.deserialize_secrets(kube_secrets_json) + output = secret_serializer.serialize_secrets(secrets_data) + else: + # Keep as JSON (no conversion needed) + output = kube_secrets_json + + self.secret_state.plain_secrets_file_path.write_text(output) diff --git a/src/tkseal/seal.py b/src/tkseal/seal.py index 4b9c73b..38eb113 100644 --- a/src/tkseal/seal.py +++ b/src/tkseal/seal.py @@ -6,16 +6,17 @@ from tkseal.configuration import PLAIN_SECRETS_FILE from tkseal.kubeseal import KubeSeal from tkseal.secret_state import SecretState +from tkseal.serializers import get_serializer class Seal: """Handles sealing of plain secrets using kubeseal. - This class converts plain_secrets.json to sealed_secrets.json by: - 1. Reading plain secrets from the environment + This class converts plain_secrets files to sealed_secrets files by: + 1. Reading plain secrets from the environment (JSON or YAML) 2. Encrypting each secret value using kubeseal 3. Creating SealedSecret resources in Kubernetes format - 4. Writing sealed secrets to sealed_secrets.json + 4. Writing sealed secrets in the specified format (JSON or YAML) """ def __init__(self, secret_state: SecretState): @@ -49,12 +50,11 @@ def kubeseal(self, name: str, value: str) -> str: def run(self) -> None: """Convert plain secrets to sealed secrets. - Reads plain_secrets.json, encrypts each secret value using kubeseal, - creates SealedSecret resources, and writes to sealed_secrets.json. + Reads plain_secrets file (JSON or YAML), encrypts each secret value using kubeseal, + creates SealedSecret resources, and writes to sealed_secrets file in the specified format. Raises: TKSealError: If sealing or file operations fail - json.JSONDecodeError: If plain_secrets.json is malformed """ # Read and parse plain secrets plain_secrets_text = self.secret_state.plain_secrets() @@ -62,13 +62,18 @@ def run(self) -> None: # Check if plain_secrets_text is empty or exists if not plain_secrets_text or plain_secrets_text.strip() == "": raise TKSealError( - f"No plain secrets found. Please create {PLAIN_SECRETS_FILE} " + f"No plain secrets found. Please create {PLAIN_SECRETS_FILE}.{self.secret_state.format} " f"or run 'tkseal pull' first." ) + + # Deserialize from the file format (could be JSON or YAML) try: - plain_secrets = json.loads(plain_secrets_text) - except json.decoder.JSONDecodeError as e: - raise TKSealError(f"Invalid JSON in {PLAIN_SECRETS_FILE}: {str(e)}") from e + secret_serializer = get_serializer(self.secret_state.format) + plain_secrets = secret_serializer.deserialize_secrets(plain_secrets_text) + except (json.JSONDecodeError, Exception) as e: + raise TKSealError( + f"Invalid format in plain_secrets file: {str(e)}" + ) from e # Process each secret sealed_secrets = [] @@ -92,7 +97,7 @@ def run(self) -> None: "name": secret["name"], "namespace": self.secret_state.namespace, }, - # Preserve secret type if specified in plain_secrets.json + # Preserve the secret type if specified in the plain_secrets file **({"type": secret["type"]} if "type" in secret else {}), }, "encryptedData": encrypted_data, @@ -100,6 +105,6 @@ def run(self) -> None: } sealed_secrets.append(sealed_secret) - # Write sealed secrets to file - sealed_json = json.dumps(sealed_secrets, indent=2) - self.secret_state.sealed_secrets_file_path.write_text(sealed_json) + # Serialize and write sealed secrets to file in the specifier + sealed_output = secret_serializer.serialize_secrets(sealed_secrets) + self.secret_state.sealed_secrets_file_path.write_text(sealed_output) diff --git a/src/tkseal/secret_state.py b/src/tkseal/secret_state.py index 0dc377b..53834d3 100644 --- a/src/tkseal/secret_state.py +++ b/src/tkseal/secret_state.py @@ -45,6 +45,7 @@ def __init__( plain_secrets_file_path: Path, sealed_secrets_file_path: Path, tk_env: TKEnvironment, + format: str = "json", ): """Initialize SecretState. @@ -55,16 +56,18 @@ def __init__( plain_secrets_file_path: Path to plain_secrets.json file sealed_secrets_file_path: Path to sealed_secrets.json file tk_env: TKEnvironment instance for this environment + format: File format for secrets ('json' or 'yaml'), defaults to 'json' """ self.tk_env_path = tk_env_path self.plain_secrets_file_path = plain_secrets_file_path self.sealed_secrets_file_path = sealed_secrets_file_path self._tk_env = tk_env + self.format = format # Optional[Secrets] signifying the absence of the secrets_cache data until it is needed and loaded self._secrets_cache: Secrets | None = None # Cache for the Secrets object @classmethod - def from_path(cls, path: str) -> "SecretState": + def from_path(cls, path: str, format: str = "json") -> "SecretState": """Create SecretState from a Tanka environment path. This method normalizes the path by removing trailing slashes @@ -72,6 +75,7 @@ def from_path(cls, path: str) -> "SecretState": Args: path: Path to Tanka environment directory or .jsonnet file + format: File format for secrets ('json' or 'yaml'), defaults to 'json' Returns: SecretState: Initialized SecretState instance @@ -91,17 +95,18 @@ def from_path(cls, path: str) -> "SecretState": # Initialize TKEnvironment (will validate path exists) tk_env = TKEnvironment(normalized_path) - # Construct file paths + # Construct file paths with the correct extension based on format base_path = Path(normalized_path) # Using Pathlib we can easily join paths and get file names, parent directories, etc. - plain_secrets_path = base_path / configuration.PLAIN_SECRETS_FILE - sealed_secrets_path = base_path / configuration.SEALED_SECRETS_FILE + plain_secrets_path = base_path / f"plain_secrets.{format}" + sealed_secrets_path = base_path / f"sealed_secrets.{format}" return cls( tk_env_path=normalized_path, plain_secrets_file_path=plain_secrets_path, sealed_secrets_file_path=sealed_secrets_path, tk_env=tk_env, + format=format, ) @property diff --git a/src/tkseal/serializers.py b/src/tkseal/serializers.py new file mode 100644 index 0000000..2752d0c --- /dev/null +++ b/src/tkseal/serializers.py @@ -0,0 +1,125 @@ +"""Serialization helpers for converting secrets between JSON and YAML formats.""" + +import json +from abc import ABC + +import yaml + + +def _str_presenter(dumper, data): + """ + Custom YAML representer for strings that preserves multiline formatting. + + Uses block scalar style (|) for strings containing newlines, + ensuring readable YAML output for config files, certificates, etc. + Implementation inspired by: https://www.hrekov.com/blog/yaml-formatting-custom-representer + """ + if "\n" in data: + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +# Register custom representer for multiline string preservation +yaml.add_representer(str, _str_presenter) + +class Serializer(ABC): + """Abstract base class for serializer secrets.""" + + def serialize_secrets(self, data: list[dict]) -> str: + """Serialize secret data to a string.""" + pass + + def deserialize_secrets(self, content: str) -> list[dict]: + """Deserialize secret data from a string.""" + pass + +class YAMLSerializer(Serializer): + """YAML serializer for secrets.""" + + def serialize_secrets(self, data: list[dict]) -> str: + """ + Serialize secret data to YAML format. + + Args: + data: List of secret dictionaries to serialize + format: Output format 'yaml' + + Returns: + Serialized string in the YAML format + """ + return yaml.dump( + data, + default_flow_style=False, # Controls the output style. + # False means indented block format. + # with each item + # on a new line. + # Preferred output for config files. + sort_keys=False, + allow_unicode=True, + ) + + def deserialize_secrets(self, content: str) -> list[dict]: + """ + Deserialize secret data from YAML format. + + Args: + content: Serialized string to deserialize + format: Input format 'yaml' + + Returns: + List of secret dictionaries + """ + return yaml.safe_load(content) + +class JSONSerializer(Serializer): + """JSON serializer for secrets.""" + + def serialize_secrets(self, data: list[dict]) -> str: + """ + Serialize secret data to JSON. + + Args: + data: List of secret dictionaries to serialize + format: Output format 'json' + + Returns: + Serialized string in the JSON format + """ + return json.dumps(data, indent=2) + + def deserialize_secrets(self, content: str) -> list[dict]: + + """ + Deserialize secret data from JSON format. + + Args: + content: Serialized string to deserialize + format: Input format 'json' + + Returns: + List of secret dictionaries + + """ + + return json.loads(content) + +def get_serializer(format: str) -> Serializer: + """ + Factory function to get the appropriate serializer based on format. + + Args: + format: 'json' or 'yaml' + + Returns: + Serializer instance + + Raises: + ValueError: If the format is not 'json' or 'yaml' + """ + if format == "json": + return JSONSerializer() + elif format == "yaml": + return YAMLSerializer() + else: + raise ValueError(f"Unsupported format: {format}. Use 'json' or 'yaml'.") + diff --git a/src/tkseal/tkseal_utils.py b/src/tkseal/tkseal_utils.py index d253146..63f4984 100644 --- a/src/tkseal/tkseal_utils.py +++ b/src/tkseal/tkseal_utils.py @@ -1,6 +1,7 @@ import subprocess from tkseal import TKSealError +from tkseal.serializers import get_serializer def run_command(cmd: list[str], value: str = "") -> str: @@ -27,3 +28,31 @@ def run_command(cmd: list[str], value: str = "") -> str: ) from e except Exception as e: raise TKSealError(f"Failed to execute command: {str(e)}") from e + + +def normalize_to_json(content: str, source_format: str) -> str: + """Normalize content to JSON format for comparison. + + This function is used to ensure consistent format when comparing secrets, + regardless of whether they are stored as JSON or YAML. + + Args: + content: String content in JSON or YAML format + source_format: Format of the content ('json' or 'yaml') + + Returns: + JSON string with consistent formatting + + Examples: + >>> normalize_to_json('[]', 'json') + '[]' + >>> normalize_to_json('- name: test', 'yaml') + '[{"name": "test"}]' + """ + if not content or content.strip() == "" or content.strip() == "[]": + return "[]" + + # Deserialize from source format, then serialize to JSON + secret_serializer = get_serializer(source_format) + data = secret_serializer.deserialize_secrets(content) + return get_serializer("json").serialize_secrets(data) diff --git a/tests/conftest.py b/tests/conftest.py index a331977..0caa102 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,8 @@ Notes: - All tests use tk_status.txt to ensure consistent context/namespace values. """ + + @pytest.fixture def tk_status_file(tmp_path): """Copy the sample tests/tk_status.txt into a temporary file and return its path.""" @@ -34,7 +36,7 @@ def tk_status_file(tmp_path): @pytest.fixture -def temp_tanka_env(tmp_path): +def temp_tanka_env(tmp_path, format="json"): """Create a temporary Tanka environment directory structure.""" env_path = tmp_path / "environments" / "test-env" env_path.mkdir(parents=True) @@ -43,7 +45,7 @@ def temp_tanka_env(tmp_path): plain_secrets = [ {"name": "test-secret", "data": {"username": "admin", "password": "secret123"}} ] - (env_path / "plain_secrets.json").write_text(json.dumps(plain_secrets, indent=2)) + (env_path / f"plain_secrets.{format}").write_text(json.dumps(plain_secrets, indent=2)) return env_path @@ -84,6 +86,7 @@ def simple_mock_secret_state(mocker): mock_state.plain_secrets.return_value = "[]" mock_state.kube_secrets.return_value = "[]" mock_state.get_forbidden_secrets.return_value = [] # No forbidden secrets by default + mock_state.format = "json" # Default format return mock_state @@ -113,6 +116,7 @@ def mock_secret_state(mocker, tk_status_file, mock_tk_env, temp_tanka_env): mock_secret_state.plain_secrets.return_value = "[]" mock_secret_state.kube_secrets.return_value = "[]" mock_secret_state.get_forbidden_secrets.return_value = [] # No forbidden secrets by default + mock_secret_state.format = "json" # Default format # Patch the factory used by most code paths to create SecretState from a path mocker.patch( @@ -167,10 +171,12 @@ def sample_plain_secrets_multiple(): { "name": "app-secret", "data": {"username": "admin", "password": "secret123"}, + "type": "Opaque", }, { "name": "db-secret", "data": {"db_host": "localhost", "db_password": "dbpass456"}, + "type": "Opaque", }, ], indent=2, diff --git a/tests/test_cli.py b/tests/test_cli.py index 50a846e..944de18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -260,3 +260,47 @@ def test_seal_command_handles_tkseal_error( assert result.exit_code == 1 assert "Error" in result.output assert "kubeseal command failed" in result.output + + +class TestFormatFlag: + """Test cases for the --format flag on pull and seal commands.""" + + def test_pull_command_with_yaml_format_flag( + self, cli_runner, mock_pull_cli, temp_tanka_env, mock_secret_state + ): + """Test pull command with --format yaml creates .yaml file.""" + # Simulate 'y' response to confirmation + result = cli_runner.invoke( + cli, ["pull", str(temp_tanka_env), "--format", "yaml"], input="y\n" + ) + + assert result.exit_code == 0 + assert "plain_secrets.yaml" in result.output + # Verify write was called + mock_pull_cli.write.assert_called_once() + + def test_seal_command_with_yaml_format_flag( + self, cli_runner, temp_tanka_env, mock_secret_state, mock_seal_cli + ): + """Test seal command with --format yaml creates .yaml file.""" + mock_seal, mock_diff = mock_seal_cli + + # Simulate 'y' response to confirmation + result = cli_runner.invoke( + cli, ["seal", str(temp_tanka_env), "--format", "yaml"], input="y\n" + ) + + assert result.exit_code == 0 + assert "sealed_secrets.yaml" in result.output + # Verify Seal.run() was called + mock_seal.run.assert_called_once() + + def test_invalid_format_flag_shows_error(self, cli_runner, temp_tanka_env): + """Test that invalid --format value shows error.""" + result = cli_runner.invoke( + cli, ["pull", str(temp_tanka_env), "--format", "xml"] + ) + + # Should fail with exit code 2 (usage error) + assert result.exit_code == 2 + assert "Invalid value for '--format'" in result.output or "invalid choice" in result.output.lower() diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 58396b8..48930fa 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -11,12 +11,9 @@ def test_configuration_constant(): - Ensure both file names end with .json extension. """ - assert configuration.PLAIN_SECRETS_FILE == "plain_secrets.json" + assert configuration.PLAIN_SECRETS_FILE == "plain_secrets" - assert configuration.SEALED_SECRETS_FILE == "sealed_secrets.json" + assert configuration.SEALED_SECRETS_FILE == "sealed_secrets" assert isinstance(configuration.PLAIN_SECRETS_FILE, str) assert isinstance(configuration.SEALED_SECRETS_FILE, str) - - assert configuration.PLAIN_SECRETS_FILE.endswith(".json") - assert configuration.SEALED_SECRETS_FILE.endswith(".json") diff --git a/tests/test_pull.py b/tests/test_pull.py index c9ecb71..ec2759a 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -41,14 +41,16 @@ def test_write_saves_kube_secrets_to_file( # Should write to the file mock_path.write_text.assert_called_once_with(sample_kube_secrets) + @pytest.mark.parametrize("format", ["json", "yaml"]) def test_write_with_real_temp_file( - self, tmp_path, simple_mock_secret_state, sample_kube_secrets + self, tmp_path, simple_mock_secret_state, sample_kube_secrets, format ): - """Test write() actually writes to a real file.""" - # Setup: Create real temp file path - temp_file = tmp_path / "plain_secrets.json" + """Test write() actually writes to a real file in both JSON and YAML formats.""" + # Setup: Create real temp file path with format-specific extension + temp_file = tmp_path / f"plain_secrets.{format}" simple_mock_secret_state.plain_secrets_file_path = temp_file simple_mock_secret_state.kube_secrets.return_value = sample_kube_secrets + simple_mock_secret_state.format = format pull = Pull(simple_mock_secret_state) pull.write() @@ -56,9 +58,14 @@ def test_write_with_real_temp_file( # Verify file was created assert temp_file.exists() - # Verify file contents + # Verify file contents contain expected data written_content = temp_file.read_text() - assert written_content == sample_kube_secrets + + # For JSON, content should match exactly + if format == "json": + assert written_content == sample_kube_secrets + + # For both formats, verify the data is present assert "app-secret" in written_content assert "newsecret456" in written_content diff --git a/tests/test_seal.py b/tests/test_seal.py index f63196c..3a035aa 100644 --- a/tests/test_seal.py +++ b/tests/test_seal.py @@ -7,6 +7,7 @@ from tkseal.exceptions import TKSealError from tkseal.seal import Seal +from tkseal.serializers import get_serializer @pytest.fixture @@ -60,9 +61,15 @@ def test_kubeseal_calls_wrapper_with_correct_params( class TestSealRun: """Test Seal.run() method.""" - def test_run_seals_and_writes_secrets(self, mock_kubeseal, seal_test_setup): - """Test run() seals all key-value pairs and writes proper SealedSecret JSON.""" - mock_state, sealed_file = seal_test_setup + @pytest.mark.parametrize("format", ["json", "yaml"]) + def test_run_seals_and_writes_secrets(self, mock_kubeseal, seal_test_setup, format): + """Test run() seals all key-value pairs and writes proper SealedSecret in JSON/YAML format.""" + mock_state, sealed_file_json = seal_test_setup + + # Update sealed file path for the format + sealed_file = sealed_file_json.parent / f"sealed_secrets.{format}" + mock_state.sealed_secrets_file_path = sealed_file + mock_state.format = format # Mock KubeSeal.seal to return different values for each key mock_kubeseal.side_effect = ["sealed-username", "sealed-password"] @@ -88,12 +95,12 @@ def test_run_seals_and_writes_secrets(self, mock_kubeseal, seal_test_setup): assert sealed_file.exists() content = sealed_file.read_text() - # Verify it's pretty-printed JSON + # Verify it's pretty-printed (has newlines and indentation) assert "\n" in content - assert " " in content # 2-space indentation - # Parse and verify structure - sealed_secrets = json.loads(content) + # Parse and verify structure (format-agnostic deserialization) + secret_serializer = get_serializer(format) + sealed_secrets = secret_serializer.deserialize_secrets(content) assert isinstance(sealed_secrets, list) assert len(sealed_secrets) == 1 diff --git a/tests/test_secret_state.py b/tests/test_secret_state.py index 8993564..53f8595 100644 --- a/tests/test_secret_state.py +++ b/tests/test_secret_state.py @@ -3,6 +3,8 @@ from pathlib import Path from unittest.mock import Mock +import pytest + from tkseal.secret_state import SecretState, normalize_tk_env_path @@ -67,8 +69,11 @@ def test_normalize_tk_env_path_function(self): assert normalize_tk_env_path("/path/to/env") == "/path/to/env" # No change assert normalize_tk_env_path("/path/to/env.jsonnet") == "/path/to/env.jsonnet" - def test_file_paths_use_configuration_constants( - self, mocker, temp_tanka_env, mock_tk_env + @pytest.mark.parametrize( + "format,expected_ext", [("json", ".json"), ("yaml", ".yaml")] + ) + def test_file_paths_use_format_extension( + self, mocker, temp_tanka_env, mock_tk_env, format, expected_ext ): """Test that file paths use configuration constants.""" mocker.patch("tkseal.secret_state.TKEnvironment", return_value=mock_tk_env) diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 0000000..f4d22fe --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,98 @@ +"""Tests for serialization helpers.""" + +import json + +import pytest + +from tkseal.serializers import ( + get_serializer, + YAMLSerializer, +) + + +@pytest.fixture +def sample_secrets_with_multiline(): + """Sample secrets with multiline values (e.g., config files).""" + return [ + { + "name": "config-secret", + "data": { + "config.yml": "database:\n host: localhost\n port: 5432\n", + "single_line": "simple_value", + }, + "type": "Opaque", + } + ] + + +@pytest.mark.parametrize("format", ["json", "yaml"]) +def test_serialize_deserialize_roundtrip(sample_plain_secrets_multiple, format): + """Test round-trip serialization and deserialization for both formats.""" + + sample_secrets_data_multiple: list[dict] = json.loads(sample_plain_secrets_multiple) + + secret_serializer = get_serializer(format) + + # Serialize to string + serialized = secret_serializer.serialize_secrets(sample_secrets_data_multiple) + + # Verify it's a string + assert isinstance(serialized, str) + assert len(serialized) > 0 + + + + # Deserialize back to Python objects + deserialized = secret_serializer.deserialize_secrets(serialized) + + # Verify the structure is preserved + assert deserialized == sample_secrets_data_multiple + assert len(deserialized) == 2 + assert deserialized[0]["name"] == "app-secret" + assert deserialized[0]["data"]["username"] == "admin" + assert deserialized[1]["name"] == "db-secret" + + +def test_yaml_preserves_multiline_formatting(sample_secrets_with_multiline): + """Test that YAML format preserves multiline strings with block scalar style.""" + + format = "yaml" + secret_serializer = get_serializer("yaml") + + assert isinstance(secret_serializer, YAMLSerializer) + + # Serialize to YAML + yaml_output = secret_serializer.serialize_secrets(sample_secrets_with_multiline) + + # Verify multiline string uses block scalar style (|) + assert "|" in yaml_output or "|-" in yaml_output + # Verify the multiline content is preserved + assert "database:" in yaml_output + assert "host: localhost" in yaml_output + assert "port: 5432" in yaml_output + + # Verify single-line values work normally + assert "simple_value" in yaml_output + + + + # Verify round-trip preserves content + deserialized = secret_serializer.deserialize_secrets(yaml_output) + assert ( + deserialized[0]["data"]["config.yml"] + == sample_secrets_with_multiline[0]["data"]["config.yml"] + ) + assert deserialized[0]["data"]["single_line"] == "simple_value" + + +def test_invalid_format_raises_error(sample_plain_secrets): + """Test that invalid format raises ValueError.""" + + # Test serialize with invalid format + with pytest.raises(ValueError, match="Unsupported format"): + secret_serializer = get_serializer("xml") + #serialize_secrets(json.loads(sample_plain_secrets), format="xml") + + # Test deserialize with invalid format + #with pytest.raises(ValueError, match="Unsupported format"): + # deserialize_secrets('{"test": "data"}', format="xml") \ No newline at end of file