diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9ff0e44..e5aed31 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,3 +15,4 @@ jobs: - run: rye fmt -- --check - run: rye check - run: rye run basedpyright + - run: rye run pytest -vvv diff --git a/CHANGELOG.md b/CHANGELOG.md index 4704eed..2bf4d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,7 @@ # saltstack-age change log + +## 0.3.0 + +* fix: add support for nested pillar data +* fix(cli): write results to stdout +* feat(ci): run tests diff --git a/example/pillar/test.sls b/example/pillar/test.sls index 6279d1f..72b1543 100644 --- a/example/pillar/test.sls +++ b/example/pillar/test.sls @@ -1,7 +1,8 @@ #!jinja|yaml|age -prefix: /tmp +test: + prefix: /tmp -private: ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==] + private: ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==] -public: that's not a secret + public: that's not a secret diff --git a/example/states/test.sls b/example/states/test.sls index 39a1a7d..f4cfc3c 100644 --- a/example/states/test.sls +++ b/example/states/test.sls @@ -1,9 +1,9 @@ -{% set prefix = salt.pillar.get('prefix') %} +{% set prefix = salt.pillar.get('test:prefix') %} {{ prefix }}/test-public: file.managed: - - contents_pillar: public + - contents_pillar: test:public {{ prefix }}/test-private: file.managed: - - contents_pillar: private + - contents_pillar: test:private diff --git a/pyproject.toml b/pyproject.toml index 8a16a07..0e38668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "saltstack-age" -version = "0.2.3" +version = "0.3.0" description = "age renderer for Saltstack" authors = [{ name = "Philippe Muller" }] dependencies = [ diff --git a/src/saltstack_age/cli.py b/src/saltstack_age/cli.py index 7ee685b..6865761 100644 --- a/src/saltstack_age/cli.py +++ b/src/saltstack_age/cli.py @@ -144,15 +144,15 @@ def determine_encryption_type( def encrypt(arguments: Namespace) -> None: value = get_value(arguments).encode() + type_ = determine_encryption_type(arguments) - if determine_encryption_type(arguments) == "identity": + if type_ == "identity": recipients = [identity.to_public() for identity in get_identities(arguments)] ciphertext = pyrage.encrypt(value, recipients) - LOGGER.info("ENC[age-identity,%s]", b64encode(ciphertext).decode()) - else: ciphertext = pyrage.passphrase.encrypt(value, get_passphrase(arguments)) - LOGGER.info("ENC[age-passphrase,%s]", b64encode(ciphertext).decode()) + + _ = sys.stdout.write(f"ENC[age-{type_},{b64encode(ciphertext).decode()}]\n") def decrypt(arguments: Namespace) -> None: @@ -172,10 +172,10 @@ def decrypt(arguments: Namespace) -> None: ) raise SystemExit(-1) - LOGGER.info("%s", secure_value.decrypt(arguments.identities[0])) + _ = sys.stdout.write(secure_value.decrypt(arguments.identities[0])) else: # isinstance(secure_value, PassphraseSecureValue) - LOGGER.info("%s", secure_value.decrypt(get_passphrase(arguments))) + _ = sys.stdout.write(secure_value.decrypt(get_passphrase(arguments))) def main(cli_args: Sequence[str] | None = None) -> None: diff --git a/src/saltstack_age/renderers/age.py b/src/saltstack_age/renderers/age.py index 957372b..1ad166f 100644 --- a/src/saltstack_age/renderers/age.py +++ b/src/saltstack_age/renderers/age.py @@ -1,7 +1,7 @@ from collections import OrderedDict from importlib import import_module from pathlib import Path -from typing import Any +from typing import Any, cast import pyrage from salt.exceptions import SaltRenderError @@ -73,13 +73,18 @@ def _decrypt(string: str) -> str: return secure_value.decrypt(_get_passphrase()) +def _render_value(value: Any) -> Any: # noqa: ANN401 + if is_secure_value(value): + return _decrypt(value) + if isinstance(value, OrderedDict): + return render(cast(Data, value)) + return value + + def render( data: Data, _saltenv: str = "base", _sls: str = "", **_kwargs: None, ) -> Data: - return OrderedDict( - (key, _decrypt(value) if is_secure_value(value) else value) - for key, value in data.items() - ) + return OrderedDict((key, _render_value(value)) for key, value in data.items()) diff --git a/src/saltstack_age/secure_value.py b/src/saltstack_age/secure_value.py index 1580a37..f2f2b71 100644 --- a/src/saltstack_age/secure_value.py +++ b/src/saltstack_age/secure_value.py @@ -1,6 +1,7 @@ import re from base64 import b64decode from dataclasses import dataclass +from typing import Any import pyrage @@ -24,8 +25,8 @@ ) -def is_secure_value(string: str) -> bool: - return bool(RE_SECURE_VALUE.match(string)) +def is_secure_value(value: Any) -> bool: # noqa: ANN401 + return bool(RE_SECURE_VALUE.match(value)) if isinstance(value, str) else False @dataclass diff --git a/tests/integration/_test_renderer_identity.py b/tests/integration/_test_renderer_identity.py new file mode 100644 index 0000000..3407a04 --- /dev/null +++ b/tests/integration/_test_renderer_identity.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + +from saltfactories.cli.call import SaltCall + + +def test(salt_call_cli: SaltCall, tmp_path: Path) -> None: + _ = salt_call_cli.run( + "state.apply", + pillar=json.dumps({"test": {"prefix": str(tmp_path)}}), + ) + assert (tmp_path / "test-public").read_text() == "that's not a secret\n" + assert (tmp_path / "test-private").read_text() == "test-secret-value\n" diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 131bd21..bf8c0b1 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -1,4 +1,3 @@ -import logging from collections.abc import Sequence from pathlib import Path @@ -13,13 +12,11 @@ ) -def test_encrypt__passphrase(caplog: pytest.LogCaptureFixture) -> None: - # Only keep INFO log records - caplog.set_level(logging.INFO) +def test_encrypt__passphrase(capsys: pytest.CaptureFixture[str]) -> None: # Run the CLI tool main(["-P", "woah that is so secret", "enc", "another secret"]) # Ensure we get a passphrase secure value string - secure_value_string = caplog.record_tuples[0][2] + secure_value_string = capsys.readouterr().out secure_value = parse_secure_value(secure_value_string) assert isinstance(secure_value, PassphraseSecureValue) # Ensure we can decrypt it @@ -27,15 +24,13 @@ def test_encrypt__passphrase(caplog: pytest.LogCaptureFixture) -> None: def test_encrypt__single_recipient( - caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], example_age_key: str, ) -> None: - # Only keep INFO log records - caplog.set_level(logging.INFO) # Run the CLI tool main(["-i", example_age_key, "enc", "foo"]) # Ensure we get an identity secure value string - secure_value_string = caplog.record_tuples[0][2] + secure_value_string = capsys.readouterr().out secure_value = parse_secure_value(secure_value_string) assert isinstance(secure_value, IdentitySecureValue) # Ensure we can decrypt it using the same identity @@ -43,10 +38,9 @@ def test_encrypt__single_recipient( def test_encrypt__multiple_recipients( - caplog: pytest.LogCaptureFixture, tmp_path: Path + capsys: pytest.CaptureFixture[str], + tmp_path: Path, ) -> None: - # Only keep INFO log records - caplog.set_level(logging.INFO) # Generate identities identity1 = pyrage.x25519.Identity.generate() identity1_path = tmp_path / "identity1" @@ -66,7 +60,7 @@ def test_encrypt__multiple_recipients( ] ) # Ensure we get an identity secure value string - secure_value_string = caplog.record_tuples[0][2] + secure_value_string = capsys.readouterr().out secure_value = parse_secure_value(secure_value_string) assert isinstance(secure_value, IdentitySecureValue) # Ensure we can decrypt it using all the recipient identities @@ -114,15 +108,13 @@ def test_decrypt( environment: None | dict[str, str], args: Sequence[str], result: str, - caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: # Setup environment variables for name, value in (environment or {}).items(): monkeypatch.setenv(name, value) - # Only keep INFO log records - caplog.set_level(logging.INFO) # Run the CLI tool main(args) # Ensure we get the expected result - assert caplog.record_tuples == [("saltstack_age.cli", logging.INFO, result)] + assert capsys.readouterr().out == result diff --git a/tests/integration/test_renderer_identity_from_config.py b/tests/integration/test_renderer_identity_from_config.py index 976c994..84cefd3 100644 --- a/tests/integration/test_renderer_identity_from_config.py +++ b/tests/integration/test_renderer_identity_from_config.py @@ -1,11 +1,9 @@ -from pathlib import Path - import pytest -from saltfactories.cli.call import SaltCall from saltfactories.daemons.minion import SaltMinion from saltfactories.manager import FactoriesManager from saltfactories.utils import random_string +from tests.integration import _test_renderer_identity from tests.integration.conftest import MINION_CONFIG @@ -19,7 +17,4 @@ def minion(salt_factories: FactoriesManager, example_age_key: str) -> SaltMinion ) -def test(salt_call_cli: SaltCall, tmp_path: Path) -> None: - _ = salt_call_cli.run("state.apply", pillar=f'{{"prefix": "{tmp_path}"}}') - assert (tmp_path / "test-public").read_text() == "that's not a secret\n" - assert (tmp_path / "test-private").read_text() == "test-secret-value\n" +test = _test_renderer_identity.test diff --git a/tests/integration/test_renderer_identity_from_environment.py b/tests/integration/test_renderer_identity_from_environment.py index 3fdf9e4..b22dfbd 100644 --- a/tests/integration/test_renderer_identity_from_environment.py +++ b/tests/integration/test_renderer_identity_from_environment.py @@ -1,11 +1,9 @@ -from pathlib import Path - import pytest -from saltfactories.cli.call import SaltCall from saltfactories.daemons.minion import SaltMinion from saltfactories.manager import FactoriesManager from saltfactories.utils import random_string +from tests.integration import _test_renderer_identity from tests.integration.conftest import MINION_CONFIG @@ -22,7 +20,4 @@ def minion( ) -def test(salt_call_cli: SaltCall, tmp_path: Path) -> None: - _ = salt_call_cli.run("state.apply", pillar=f'{{"prefix": "{tmp_path}"}}') - assert (tmp_path / "test-public").read_text() == "that's not a secret\n" - assert (tmp_path / "test-private").read_text() == "test-secret-value\n" +test = _test_renderer_identity.test