Skip to content

Commit e80c896

Browse files
committed
fix(ssh): don't raise exception when default ssh keys can't be found
This commit will once again allow for setting ssh keys at runtime using the `Cloud.use_key()` method when default ssh keys dont exist on the system and no ssh key paths are provided in pycloudlib.toml. Further context: PR canonical#406 broke some end consumers/users of pycloudlib on systems where 1) ssh keys are not set in the pycloudlib.toml and the ssh key is later set using the Cloud.use_key() method 2) no ssh keys exist at the default paths of '~/.ssh/id_rsa.pub' or '~/.ssh/id_ed25519.pub'. This commit removes / reverts the error that was introduced in PR canonical#406 which gets raised at the Cloud class instantation when those two cases are true. The biggest issue with this change, is there was no way for an end user to override/prevent pycloudlib from erroring out if those cases are true - besides for creating a fake ssh key at one of the two default paths. Now, a warning is just logged instead, restoring the flexibility pycloudlib preivously provided to end users for setting / using ssh keys. If both scenarios 1 and 2 are true (ssh key is unset/doesn't exist), a new exception type `UnsetSSHKeyError` will be raised with verbose exception message explaining why this happened and how to fix.
1 parent 0f6566f commit e80c896

File tree

5 files changed

+105
-20
lines changed

5 files changed

+105
-20
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1!10.5.0
1+
1!10.6.0

pycloudlib/cloud.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ def __init__(
6666
self.tag = get_timestamped_tag(tag) if timestamp_suffix else tag
6767
self._validate_tag(self.tag)
6868

69-
self.key_pair = self._get_ssh_keys()
69+
self.key_pair = self._get_ssh_keys(
70+
public_key_path=self.config.get("public_key_path", ""),
71+
private_key_path=self.config.get("private_key_path", ""),
72+
name=self.config.get("key_name", getpass.getuser()),
73+
)
7074

7175
def __enter__(self):
7276
"""Enter context manager for this class."""
@@ -251,7 +255,11 @@ def use_key(self, public_key_path, private_key_path=None, name=None):
251255
name: name to reference key by
252256
"""
253257
self._log.debug("using SSH key from %s", public_key_path)
254-
self.key_pair = KeyPair(public_key_path, private_key_path, name)
258+
self.key_pair = self._get_ssh_keys(
259+
public_key_path=public_key_path,
260+
private_key_path=private_key_path,
261+
name=name,
262+
)
255263

256264
def _check_and_set_config(
257265
self,
@@ -310,33 +318,57 @@ def _validate_tag(tag: str):
310318
if rules_failed:
311319
raise InvalidTagNameError(tag=tag, rules_failed=rules_failed)
312320

313-
def _get_ssh_keys(self) -> KeyPair:
314-
user = getpass.getuser()
315-
# check if id_rsa or id_ed25519 keys exist in the user's .ssh directory
321+
def _get_ssh_keys(
322+
self,
323+
public_key_path: Optional[str] = None,
324+
private_key_path: Optional[str] = None,
325+
name: Optional[str] = None,
326+
) -> KeyPair:
327+
"""Retrieve SSH key pair paths.
328+
329+
This method attempts to retrieve the paths to the public and private SSH keys.
330+
If no public key path is provided, it will look for default keys in the user's
331+
`~/.ssh` directory. If no keys are found, it logs a warning and returns a KeyPair
332+
with None values.
333+
334+
Args:
335+
public_key_path (Optional[str]): The path to the public SSH key. If not provided,
336+
the method will search for default keys.
337+
private_key_path (Optional[str]): The path to the private SSH key. Defaults to None.
338+
name (Optional[str]): An optional name for the key pair. Defaults to None.
339+
340+
Returns:
341+
KeyPair: An instance of KeyPair containing the paths to the public and private keys,
342+
and the optional name.
343+
344+
Raises:
345+
PycloudlibError: If the provided public key path does not exist.
346+
"""
316347
possible_default_keys = [
317348
os.path.expanduser("~/.ssh/id_rsa.pub"),
318349
os.path.expanduser("~/.ssh/id_ed25519.pub"),
319350
]
320-
public_key_path: Optional[str] = os.path.expanduser(self.config.get("public_key_path", ""))
351+
public_key_path = os.path.expanduser(public_key_path or "")
321352
if not public_key_path:
322353
for pubkey in possible_default_keys:
323354
if os.path.exists(pubkey):
324355
self._log.info("No public key path provided, using: %s", pubkey)
325356
public_key_path = pubkey
326357
break
327358
if not public_key_path:
328-
raise PycloudlibError(
359+
self._log.warning(
329360
"No public key path provided and no key found in default locations: "
330-
"'~/.ssh/id_rsa.pub' or '~/.ssh/id_ed25519.pub'"
361+
"'~/.ssh/id_rsa.pub' or '~/.ssh/id_ed25519.pub'. SSH key authentication will "
362+
"not be possible unless a key is later provided with the 'use_key' method."
331363
)
364+
return KeyPair(None, None, None)
332365
if not os.path.exists(os.path.expanduser(public_key_path)):
333366
raise PycloudlibError(f"Provided public key path '{public_key_path}' does not exist")
334367
if public_key_path not in possible_default_keys:
335368
self._log.info("Using provided public key path: '%s'", public_key_path)
336-
private_key_path = self.config.get("private_key_path", "")
337369

338370
return KeyPair(
339371
public_key_path=public_key_path,
340372
private_key_path=private_key_path,
341-
name=self.config.get("key_name", user),
373+
name=name,
342374
)

pycloudlib/errors.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,18 @@ def __init__(self, tag: str, rules_failed: List[str]):
156156
def __str__(self) -> str:
157157
"""Return string representation of the error."""
158158
return f"Tag '{self.tag}' failed the following rules: {', '.join(self.rules_failed)}"
159+
160+
161+
class UnsetSSHKeyError(PycloudlibException):
162+
"""Raised when a SSH key is unset and no default key can be found."""
163+
164+
def __str__(self) -> str:
165+
"""Return string representation of the error."""
166+
return (
167+
"No public key content available for unset key pair. This error occurs when no SSH "
168+
"key is provided in the pycloudlib.toml file and no default keys can be found on "
169+
"the system. If you wish to provide custom SSH keys at runtime, you can do so by "
170+
"calling the `use_key` method on the `Cloud` class. If you wish to use default SSH "
171+
"keys, make sure they are present on the system and that they are located in the "
172+
"default locations."
173+
)

pycloudlib/key.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@
22
"""Base Key Class."""
33

44
import os
5+
from typing import Optional
6+
7+
from pycloudlib.errors import UnsetSSHKeyError
58

69

710
class KeyPair:
811
"""Key Class."""
912

10-
def __init__(self, public_key_path, private_key_path=None, name=None):
13+
def __init__(
14+
self,
15+
public_key_path: Optional[str],
16+
private_key_path: Optional[str] = None,
17+
name: Optional[str] = None,
18+
):
1119
"""Initialize key class to generate key or reuse existing key.
1220
1321
The public key path is given then the key is stored and the
@@ -21,11 +29,15 @@ def __init__(self, public_key_path, private_key_path=None, name=None):
2129
"""
2230
self.name = name
2331
self.public_key_path = public_key_path
24-
if private_key_path:
25-
self.private_key_path = private_key_path
26-
else:
27-
self.private_key_path = self.public_key_path.replace(".pub", "")
2832

33+
# don't set private key path if public key path is None (ssh key is unset)
34+
if self.public_key_path is None:
35+
self.private_key_path = None
36+
return
37+
38+
self.private_key_path = private_key_path or self.public_key_path.replace(".pub", "")
39+
40+
# Expand user paths after setting private key path
2941
self.public_key_path = os.path.expanduser(self.public_key_path)
3042
self.private_key_path = os.path.expanduser(self.private_key_path)
3143

@@ -40,7 +52,8 @@ def public_key_content(self):
4052
"""Read the contents of the public key.
4153
4254
Returns:
43-
output of public key
44-
55+
str: The public key content
4556
"""
57+
if self.public_key_path is None:
58+
raise UnsetSSHKeyError()
4659
return open(self.public_key_path, encoding="utf-8").read()

tests/unit_tests/test_cloud.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Tests related to pycloudlib.cloud module."""
22

33
from io import StringIO
4+
import logging
45
from textwrap import dedent
5-
from typing import List
6+
from typing import List, Optional
67

78
import mock
89
import pytest
910

1011
from pycloudlib.cloud import BaseCloud
11-
from pycloudlib.errors import InvalidTagNameError
12+
from pycloudlib.errors import InvalidTagNameError, UnsetSSHKeyError
1213

1314
# mock module path
1415
MPATH = "pycloudlib.cloud."
@@ -181,6 +182,30 @@ def test_missing_private_key_in_ssh_config(self, _m_expanduser, _m_exists):
181182
assert mycloud.key_pair.public_key_path == "/home/asdf/.ssh/id_rsa.pub"
182183
assert mycloud.key_pair.private_key_path == "/home/asdf/.ssh/id_rsa"
183184

185+
@pytest.mark.dont_mock_ssh_keys
186+
@mock.patch("os.path.expanduser", side_effect=lambda x: x.replace("~", "/root"))
187+
@mock.patch("os.path.exists", return_value=False)
188+
def test_init_raises_error_when_no_ssh_keys_found(
189+
self,
190+
_m_expanduser,
191+
_m_exists,
192+
caplog,
193+
):
194+
"""
195+
Test that an error is raised when no SSH keys can be found.
196+
197+
This test verifies that an error is raised when no SSH keys can be found in the default
198+
locations and no public key path is provided in the config file.
199+
"""
200+
# set log level to Warning to ensure warning gets logged
201+
caplog.set_level(logging.WARNING)
202+
with pytest.raises(UnsetSSHKeyError) as exc_info:
203+
cloud = CloudSubclass(tag="tag", timestamp_suffix=False, config_file=StringIO(CONFIG))
204+
# now we try to access the public key content to trigger the exception
205+
cloud.key_pair.public_key_content
206+
assert "No public key path provided and no key found in default locations" in caplog.text
207+
assert "No public key content available for unset key pair." in str(exc_info.value)
208+
184209
rule1 = "All letters must be lowercase"
185210
rule2 = "Must be between 1 and 63 characters long"
186211
rule3 = "Must not start or end with a hyphen"

0 commit comments

Comments
 (0)