From 6dd31aee5b68f5f8623c2deb8af5ab4396e98684 Mon Sep 17 00:00:00 2001 From: jeanluc Date: Wed, 14 Oct 2026 12:46:42 +0200 Subject: [PATCH] Add `deploy_key.actions_present` --- .pylintrc | 1 + .../+deploy_key_actions_present.added.md | 1 + .../resources/github/states/deploy_key.py | 46 ++++++ src/saltext/github/states/github_repo.py | 58 ++++++++ src/saltext/github/utils/gh/deploy_keys.py | 13 ++ .../github/utils/gh/statecores/deploy_keys.py | 135 ++++++++++++++++++ tests/functional/resources/states/conftest.py | 17 ++- .../resources/states/test_deploy_key.py | 58 ++++++++ .../states/github_repo/test_deploy_key.py | 123 ++++++++++++++++ 9 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 changelog/+deploy_key_actions_present.added.md diff --git a/.pylintrc b/.pylintrc index 74655fc2..5a027810 100755 --- a/.pylintrc +++ b/.pylintrc @@ -656,6 +656,7 @@ additional-builtins=__opts__, __executors__, __events__, __resource__, + __minion__, __resource_funcs__ # Tells whether unused global variables should be treated as a violation. diff --git a/changelog/+deploy_key_actions_present.added.md b/changelog/+deploy_key_actions_present.added.md new file mode 100644 index 00000000..0247df15 --- /dev/null +++ b/changelog/+deploy_key_actions_present.added.md @@ -0,0 +1 @@ +Added composite states that generate an SSH keypair in memory via the `ssh_pki` module and distribute the public half as a deploy key and the private half as an Actions secret, with optional rotation ({py:func}`github_repo.deploy_key_actions_present ` and the resource-type {py:func}`deploy_key.actions_present `) diff --git a/src/saltext/github/resources/github/states/deploy_key.py b/src/saltext/github/resources/github/states/deploy_key.py index 805db21e..601507c6 100644 --- a/src/saltext/github/resources/github/states/deploy_key.py +++ b/src/saltext/github/resources/github/states/deploy_key.py @@ -67,6 +67,52 @@ def present(name, key, read_only=False, profile=None): ) +@catch_errors +def actions_present(name, secret_name, read_only=False, rotate=False, profile=None): + """ + Ensure a generated SSH keypair is distributed to the targeted + repository: the public half as a deploy key with this title, the + private half as an Actions secret. The ed25519 keypair is generated + in memory on the managing minion via :py:func:`ssh_pki.create_private_key + ` and never persisted; + only the public key's fingerprint is reported. + + Since secret values are write-only in the GitHub API, an existing + pair cannot be verified to match and is left untouched unless + ``rotate`` is set. A pair with a missing half is regenerated. + + name + Display title of the deploy key. + + secret_name + Name of the Actions secret holding the private key. GitHub + treats secret names case-insensitively. + + read_only + Deny write access to the repository for this key. Defaults to false. + + rotate + Generate and distribute a new keypair even when both halves + are present. Defaults to false. + + profile + Profile configuration name to use. Defaults to the resource + declaration's profile. + """ + conn, owner, repo_name = _core_args(profile) + return sc_deploy_keys.actions_present( + conn, + name, + secret_name, + repo_name, + keygen=lambda: __minion__["ssh_pki.create_private_key"](algo="ed25519"), + read_only=read_only, + rotate=rotate, + owner=owner, + test=__opts__["test"], + ) + + @catch_errors def absent(name, profile=None): """ diff --git a/src/saltext/github/states/github_repo.py b/src/saltext/github/states/github_repo.py index 0dae3e33..14fe2b1d 100644 --- a/src/saltext/github/states/github_repo.py +++ b/src/saltext/github/states/github_repo.py @@ -23,6 +23,8 @@ import logging +from salt.exceptions import CommandExecutionError + from saltext.github.utils.gh import profile as gh_profile from saltext.github.utils.gh.statecores import actions as sc_actions from saltext.github.utils.gh.statecores import catch_errors @@ -319,6 +321,62 @@ def deploy_key_absent(name, repo_name=None, owner=None, profile="github"): ) +@catch_errors +def deploy_key_actions_present( + name, secret_name, read_only=False, rotate=False, repo_name=None, owner=None, profile="github" +): + """ + Ensure a generated SSH keypair is distributed to a repository: + the public half as a deploy key with this title, the private half + as an Actions secret. The ed25519 keypair is generated in memory + via :py:func:`ssh_pki.create_private_key + ` (requires Salt 3008+) + and never persisted; only the public key's fingerprint is reported. + + Since secret values are write-only in the GitHub API, an existing + pair cannot be verified to match and is left untouched unless + ``rotate`` is set. A pair with a missing half is regenerated. + + name + Display title of the deploy key. + + secret_name + Name of the Actions secret holding the private key. GitHub + treats secret names case-insensitively. + + read_only + Deny write access to the repository for this key. Defaults to false. + + rotate + Generate and distribute a new keypair even when both halves + are present. Defaults to false. + + repo_name + Name of the repository. Defaults to the profile's :ghconf:`repo_name`. + + owner + Owner of the repository. Defaults to the profile's :ghconf:`owner`. + + profile + Profile configuration name to use. Defaults to ``github``. + """ + if "ssh_pki.create_private_key" not in __salt__: + raise CommandExecutionError( + "This state requires the ssh_pki execution module, available since Salt 3008" + ) + return sc_deploy_keys.actions_present( + _get_conn(profile), + name, + secret_name, + _resolve_repo_name(repo_name, profile), + keygen=lambda: __salt__["ssh_pki.create_private_key"](algo="ed25519"), + read_only=read_only, + rotate=rotate, + owner=owner, + test=__opts__["test"], + ) + + @catch_errors def actions_secret_present( name, value=None, force=False, repo_name=None, owner=None, profile="github" diff --git a/src/saltext/github/utils/gh/deploy_keys.py b/src/saltext/github/utils/gh/deploy_keys.py index 6a059a61..c3213228 100644 --- a/src/saltext/github/utils/gh/deploy_keys.py +++ b/src/saltext/github/utils/gh/deploy_keys.py @@ -2,11 +2,24 @@ Operations on GitHub repository deploy keys. """ +import base64 +import hashlib import logging log = logging.getLogger(__name__) +def fingerprint(key): + """ + Return the OpenSSH-style SHA256 fingerprint of an SSH public key. + + key + SSH public key. + """ + blob = base64.b64decode(key.split()[1]) + return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=") + + def key_body(key): """ Reduce an SSH public key to its comparable part (type + base64 body), diff --git a/src/saltext/github/utils/gh/statecores/deploy_keys.py b/src/saltext/github/utils/gh/statecores/deploy_keys.py index f62c95dc..fb84722a 100644 --- a/src/saltext/github/utils/gh/statecores/deploy_keys.py +++ b/src/saltext/github/utils/gh/statecores/deploy_keys.py @@ -4,7 +4,11 @@ import logging +from salt.exceptions import CommandExecutionError + +from saltext.github.utils.gh import actions as gh_actions from saltext.github.utils.gh import deploy_keys as gh_deploy_keys +from saltext.github.utils.gh.statecores import StateError log = logging.getLogger(__name__) @@ -68,6 +72,137 @@ def present(conn, name, key, repo_name, *, read_only=False, owner=None, test=Fal return ret +def actions_present( + conn, + name, + secret_name, + repo_name, + *, + keygen, + read_only=False, + rotate=False, + owner=None, + test=False, +): + """ + Ensure a generated SSH keypair is distributed to a repository: + the public half as a deploy key, the private half as an Actions + secret. The keypair is generated in memory and never persisted; + only the public key's fingerprint is reported. + + Since secret values are write-only in the GitHub API, an existing + pair cannot be verified to match and is left untouched unless + ``rotate`` is set. A pair with a missing half is regenerated. + + conn + :class:`~saltext.github.utils.gh.client.GitHubConnection` to use. + + name + Display title of the deploy key. + + secret_name + Name of the Actions secret holding the private key. GitHub + treats secret names case-insensitively. + + repo_name + Name of the repository. + + keygen + Callable generating the keypair, returning a mapping with + ``private_key`` (OpenSSH PEM format) and ``public_key`` + (OpenSSH single-line format), like + :py:func:`ssh_pki.create_private_key + ` does. Only invoked + when a new pair needs to be distributed. + + read_only + Deny write access to the repository for this key. Defaults to false. + + rotate + Generate and distribute a new keypair even when both halves + are present. Defaults to false. + + owner + Owner of the repository. Defaults to the connection's owner. + + test + Only project the changes without applying them (``test`` mode). + """ + ret = {"name": name, "changes": {}, "result": True, "comment": ""} + + keys = gh_deploy_keys.list_all(conn, repo_name, owner=owner) + current = next((cur for cur in keys if cur["title"] == name), None) + secrets = [sec["name"] for sec in gh_actions.list_secrets(conn, repo_name, owner=owner)] + current_secret = next((sec for sec in secrets if sec.lower() == secret_name.lower()), None) + + if current is not None and current_secret is not None and not rotate: + if bool(current["read_only"]) is bool(read_only): + ret["comment"] = ( + f"Deploy key {name} and secret {secret_name} are present. Note that " + "the private key cannot be verified to match the deploy key, " + "set rotate to regenerate the pair." + ) + return ret + # Deploy keys are immutable, but the read_only flag can be + # flipped by re-adding the known public key. The secret is + # left untouched since the pair still matches. + ret["changes"]["old"] = {"read_only": current["read_only"]} + ret["changes"]["new"] = {"read_only": bool(read_only)} + if test: + ret["result"] = None + ret["comment"] = f"Deploy key {name} would have been replaced" + return ret + applied = {} + try: + gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner) + applied["deleted"] = name + gh_deploy_keys.create( + conn, repo_name, name, current["key"], read_only=bool(read_only), owner=owner + ) + except CommandExecutionError as err: + raise StateError(str(err), changes=applied) from err + ret["comment"] = f"Deploy key {name} has been replaced" + return ret + + key_verb = "created" if current is None else "replaced" + secret_verb = "created" if current_secret is None else "updated" + ret["changes"]["deploy_key"] = {key_verb: name} + ret["changes"]["secret"] = {secret_verb: secret_name} + + if test: + ret["result"] = None + ret["comment"] = ( + f"A keypair would have been generated and distributed as " + f"deploy key {name} and secret {secret_name}" + ) + return ret + + pair = keygen() + private, public = pair["private_key"], f"{pair['public_key'].strip()} {name}" + ret["changes"]["deploy_key"]["fingerprint"] = gh_deploy_keys.fingerprint(public) + + # Remove a stale deploy key before pushing the new pair: if a later + # step fails, the missing deploy key makes the next run regenerate + # the pair. Other orderings can leave a mismatched key and secret + # both present, which is undetectable afterwards. + applied = {} + try: + if current is not None: + gh_deploy_keys.delete(conn, repo_name, current["id"], owner=owner) + applied["deploy_key"] = {"deleted": name} + gh_actions.set_secret(conn, repo_name, secret_name, private, owner=owner) + applied["secret"] = ret["changes"]["secret"] + gh_deploy_keys.create(conn, repo_name, name, public, read_only=bool(read_only), owner=owner) + except CommandExecutionError as err: + raise StateError(str(err), changes=applied) from err + + ret["comment"] = ( + f"A keypair has been generated and distributed as " + f"deploy key {name} and secret {secret_name}" + ) + return ret + + def absent(conn, name, repo_name, *, owner=None, test=False): """ Ensure no deploy key with this title is present in a repository. diff --git a/tests/functional/resources/states/conftest.py b/tests/functional/resources/states/conftest.py index 7ae6956e..7a9a176e 100644 --- a/tests/functional/resources/states/conftest.py +++ b/tests/functional/resources/states/conftest.py @@ -14,13 +14,28 @@ def res_state_opts(res_opts): @pytest.fixture(scope="module") -def res_states(res_state_opts, res_modules, utils_loader, loader_context): +def minion_funcs(res_opts, utils_loader, loader_context): + """ + The managing minion's module loader, packed as the ``__minion__`` + escape hatch like the state engine does in resource context. + """ + return salt.loader.minion_mods( + res_opts, + loader_context, + utils=utils_loader, + loaded_base_name=f"{__name__}.minionmods", + ) + + +@pytest.fixture(scope="module") +def res_states(res_state_opts, res_modules, utils_loader, loader_context, minion_funcs): return salt.loader.states( res_state_opts, res_modules, utils_loader, salt.loader.serializers(res_state_opts), context=loader_context, + minion_mods=minion_funcs, loaded_base_name=f"{__name__}.resstates", ) diff --git a/tests/functional/resources/states/test_deploy_key.py b/tests/functional/resources/states/test_deploy_key.py index ea99fa62..28deddf9 100644 --- a/tests/functional/resources/states/test_deploy_key.py +++ b/tests/functional/resources/states/test_deploy_key.py @@ -1,7 +1,15 @@ import pytest +import salt.utils.x509 as x509util +from tests.support.github import DEPLOY_KEY_A from tests.support.github import DEPLOY_KEY_B +sshpki = pytest.importorskip("salt.utils.sshpki") + + +def _matching_pair(private, public): + return x509util.is_pair(sshpki.load_pubkey(public), sshpki.load_privkey(private)) + def test_present_create(res_states, fake_github, fake_org, for_resource, testmode): before = len(fake_github.mutations()) @@ -27,6 +35,56 @@ def test_present_idempotent(res_states, fake_github, for_resource, fake_org): assert not fake_github.mutations() +def test_actions_present_create(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["deploy_key.actions_present"]( + "autorelease", secret_name="AUTORELEASE_SSH_PRIV" + ) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"]["deploy_key"]["created"] == "autorelease" + assert ret["changes"]["secret"]["created"] == "AUTORELEASE_SSH_PRIV" + assert ("fingerprint" in ret["changes"]["deploy_key"]) is not testmode + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + added = [key for key in repo.deploy_keys if key.title == "autorelease"] + assert bool(added) is not testmode + assert ("AUTORELEASE_SSH_PRIV" in repo.secrets) is not testmode + if not testmode: + assert _matching_pair(repo.get_secret("AUTORELEASE_SSH_PRIV").value, added[0].key) + + +@pytest.mark.usefixtures("testmode") +def test_actions_present_idempotent(res_states, fake_github, for_resource): + with for_resource("testorg/repo-a"): + ret = res_states["deploy_key.actions_present"]( + "existing-key", secret_name="EXISTING_SECRET", read_only=True + ) + assert ret["result"] is True + assert not ret["changes"] + assert not fake_github.mutations() + + +def test_actions_present_rotate(res_states, fake_github, fake_org, for_resource, testmode): + before = len(fake_github.mutations()) + with for_resource("testorg/repo-a"): + ret = res_states["deploy_key.actions_present"]( + "existing-key", secret_name="EXISTING_SECRET", read_only=True, rotate=True + ) + assert ret["result"] is not False + assert (ret["result"] is None) is testmode + assert ret["changes"]["deploy_key"]["replaced"] == "existing-key" + assert ret["changes"]["secret"]["updated"] == "EXISTING_SECRET" + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + current = next(key for key in repo.deploy_keys if key.title == "existing-key") + assert (current.key == DEPLOY_KEY_A) is testmode + if not testmode: + assert _matching_pair(repo.get_secret("EXISTING_SECRET").value, current.key) + assert current.read_only is True + + def test_absent(res_states, fake_github, fake_org, for_resource, testmode): before = len(fake_github.mutations()) with for_resource("testorg/repo-a"): diff --git a/tests/functional/states/github_repo/test_deploy_key.py b/tests/functional/states/github_repo/test_deploy_key.py index 78cf5c5d..a94a4a91 100644 --- a/tests/functional/states/github_repo/test_deploy_key.py +++ b/tests/functional/states/github_repo/test_deploy_key.py @@ -1,6 +1,15 @@ +import pytest +import salt.utils.x509 as x509util + from tests.support.github import DEPLOY_KEY_A from tests.support.github import DEPLOY_KEY_B +sshpki = pytest.importorskip("salt.utils.sshpki") + + +def _matching_pair(private, public): + return x509util.is_pair(sshpki.load_pubkey(public), sshpki.load_privkey(private)) + def test_deploy_key_present_create(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) @@ -78,6 +87,120 @@ def test_deploy_key_present_missing_repo(github_repo): assert "Not Found" in ret.comment +def test_deploy_key_actions_present_create(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.deploy_key_actions_present( + name="autorelease", secret_name="AUTORELEASE_SSH_PRIV", test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["deploy_key"]["created"] == "autorelease" + assert ret.changes["secret"]["created"] == "AUTORELEASE_SSH_PRIV" + assert ("fingerprint" in ret.changes["deploy_key"]) is not testmode + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + added = [key for key in repo.deploy_keys if key.title == "autorelease"] + assert bool(added) is not testmode + assert ("AUTORELEASE_SSH_PRIV" in repo.secrets) is not testmode + if not testmode: + assert _matching_pair(repo.get_secret("AUTORELEASE_SSH_PRIV").value, added[0].key) + assert added[0].read_only is False + rerun = github_repo.deploy_key_actions_present( + name="autorelease", secret_name="AUTORELEASE_SSH_PRIV" + ) + assert rerun.result is True + assert not rerun.changes + + +def test_deploy_key_actions_present_idempotent(github_repo, fake_github, testmode): + ret = github_repo.deploy_key_actions_present( + name="existing-key", secret_name="EXISTING_SECRET", read_only=True, test=testmode + ) + assert ret.result is True + assert not ret.changes + assert "cannot be verified" in ret.comment + assert not fake_github.mutations() + + +def test_deploy_key_actions_present_rotate(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.deploy_key_actions_present( + name="existing-key", + secret_name="EXISTING_SECRET", + read_only=True, + rotate=True, + test=testmode, + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["deploy_key"]["replaced"] == "existing-key" + assert ret.changes["secret"]["updated"] == "EXISTING_SECRET" + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + current = next(key for key in repo.deploy_keys if key.title == "existing-key") + assert (current.key == DEPLOY_KEY_A) is testmode + assert (repo.get_secret("EXISTING_SECRET").value == "hunter2") is testmode + if not testmode: + assert _matching_pair(repo.get_secret("EXISTING_SECRET").value, current.key) + assert current.read_only is True + + +def test_deploy_key_actions_present_regenerates_on_missing_secret(github_repo, fake_org, testmode): + ret = github_repo.deploy_key_actions_present( + name="existing-key", secret_name="AUTORELEASE_SSH_PRIV", read_only=True, test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["deploy_key"]["replaced"] == "existing-key" + assert ret.changes["secret"]["created"] == "AUTORELEASE_SSH_PRIV" + repo = fake_org.get_repo("repo-a") + current = next(key for key in repo.deploy_keys if key.title == "existing-key") + assert (current.key == DEPLOY_KEY_A) is testmode + if not testmode: + assert _matching_pair(repo.get_secret("AUTORELEASE_SSH_PRIV").value, current.key) + + +def test_deploy_key_actions_present_regenerates_on_missing_key(github_repo, fake_org, testmode): + ret = github_repo.deploy_key_actions_present( + name="autorelease", secret_name="EXISTING_SECRET", test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes["deploy_key"]["created"] == "autorelease" + assert ret.changes["secret"]["updated"] == "EXISTING_SECRET" + repo = fake_org.get_repo("repo-a") + added = [key for key in repo.deploy_keys if key.title == "autorelease"] + assert bool(added) is not testmode + assert (repo.get_secret("EXISTING_SECRET").value == "hunter2") is testmode + if not testmode: + assert _matching_pair(repo.get_secret("EXISTING_SECRET").value, added[0].key) + + +def test_deploy_key_actions_present_updates_read_only(github_repo, fake_github, fake_org, testmode): + before = len(fake_github.mutations()) + ret = github_repo.deploy_key_actions_present( + name="existing-key", secret_name="EXISTING_SECRET", test=testmode + ) + assert ret.result is not False + assert (ret.result is None) is testmode + assert ret.changes == {"old": {"read_only": True}, "new": {"read_only": False}} + assert (len(fake_github.mutations()) == before) is testmode + repo = fake_org.get_repo("repo-a") + current = next(key for key in repo.deploy_keys if key.title == "existing-key") + # The known public key and the secret are kept, only the flag is flipped + assert current.key == DEPLOY_KEY_A + assert current.read_only is testmode + assert repo.get_secret("EXISTING_SECRET").value == "hunter2" + + +def test_deploy_key_actions_present_missing_repo(github_repo): + ret = github_repo.deploy_key_actions_present( + name="autorelease", secret_name="AUTORELEASE_SSH_PRIV", repo_name="ghost" + ) + assert ret.result is False + assert "Not Found" in ret.comment + + def test_deploy_key_absent(github_repo, fake_github, fake_org, testmode): before = len(fake_github.mutations()) ret = github_repo.deploy_key_absent(name="existing-key", test=testmode)