Skip to content
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 .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ additional-builtins=__opts__,
__executors__,
__events__,
__resource__,
__minion__,
__resource_funcs__

# Tells whether unused global variables should be treated as a violation.
Expand Down
1 change: 1 addition & 0 deletions changelog/+deploy_key_actions_present.added.md
Original file line number Diff line number Diff line change
@@ -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 <saltext.github.states.github_repo.deploy_key_actions_present>` and the resource-type {py:func}`deploy_key.actions_present <saltext.github.resources.github.states.deploy_key.actions_present>`)
46 changes: 46 additions & 0 deletions src/saltext/github/resources/github/states/deploy_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
<salt.modules.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):
"""
Expand Down
58 changes: 58 additions & 0 deletions src/saltext/github/states/github_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<salt.modules.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"
Expand Down
13 changes: 13 additions & 0 deletions src/saltext/github/utils/gh/deploy_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
135 changes: 135 additions & 0 deletions src/saltext/github/utils/gh/statecores/deploy_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
<salt.modules.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.
Expand Down
17 changes: 16 additions & 1 deletion tests/functional/resources/states/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down
Loading
Loading