Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 0.3.0 #24

Merged
merged 9 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ jobs:
- run: rye fmt -- --check
- run: rye check
- run: rye run basedpyright
- run: rye run pytest -vvv
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions example/pillar/test.sls
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions example/states/test.sls
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
12 changes: 6 additions & 6 deletions src/saltstack_age/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
15 changes: 10 additions & 5 deletions src/saltstack_age/renderers/age.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
5 changes: 3 additions & 2 deletions src/saltstack_age/secure_value.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import re
from base64 import b64decode
from dataclasses import dataclass
from typing import Any

import pyrage

Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/_test_renderer_identity.py
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 9 additions & 17 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import logging
from collections.abc import Sequence
from pathlib import Path

Expand All @@ -13,40 +12,35 @@
)


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
assert secure_value.decrypt("woah that is so secret") == "another secret"


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
assert secure_value.decrypt(read_identity_file(example_age_key)) == "foo"


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"
Expand All @@ -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
Expand Down Expand Up @@ -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
9 changes: 2 additions & 7 deletions tests/integration/test_renderer_identity_from_config.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
9 changes: 2 additions & 7 deletions tests/integration/test_renderer_identity_from_environment.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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