Skip to content

Commit 97d5206

Browse files
danielkzaphobologic
authored andcommitted
hooks: keypair: add some features and rewrite tests (#715)
* hooks: keypair: overhaul and rewrite tests - Add support for importing a local public key file - Add support for storing generated private keys in SSM parameter store - Refactor code to be more streamlined and separate interactive input from other work * hooks: keypair: use input helpers from stacker.ui
1 parent 599dd63 commit 97d5206

File tree

6 files changed

+461
-230
lines changed

6 files changed

+461
-230
lines changed

stacker/hooks/keypair.py

Lines changed: 222 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,184 @@
11
from __future__ import print_function
22
from __future__ import division
33
from __future__ import absolute_import
4-
from builtins import input
4+
55
import logging
66
import os
7+
import sys
8+
9+
from botocore.exceptions import ClientError
710

811
from stacker.session_cache import get_session
12+
from stacker.hooks import utils
13+
from stacker.ui import get_raw_input
914

10-
from . import utils
1115

1216
logger = logging.getLogger(__name__)
1317

18+
KEYPAIR_LOG_MESSAGE = "keypair: %s (%s) %s"
19+
20+
21+
def get_existing_key_pair(ec2, keypair_name):
22+
resp = ec2.describe_key_pairs()
23+
keypair = next((kp for kp in resp["KeyPairs"]
24+
if kp["KeyName"] == keypair_name), None)
25+
26+
if keypair:
27+
logger.info(KEYPAIR_LOG_MESSAGE,
28+
keypair["KeyName"],
29+
keypair["KeyFingerprint"],
30+
"exists")
31+
return {
32+
"status": "exists",
33+
"key_name": keypair["KeyName"],
34+
"fingerprint": keypair["KeyFingerprint"],
35+
}
1436

15-
def find(lst, key, value):
16-
for i, dic in enumerate(lst):
17-
if dic[key] == value:
18-
return lst[i]
19-
return False
37+
logger.info("keypair: \"%s\" not found", keypair_name)
38+
return None
39+
40+
41+
def import_key_pair(ec2, keypair_name, public_key_data):
42+
keypair = ec2.import_key_pair(
43+
KeyName=keypair_name,
44+
PublicKeyMaterial=public_key_data.strip(),
45+
DryRun=False)
46+
logger.info(KEYPAIR_LOG_MESSAGE,
47+
keypair["KeyName"],
48+
keypair["KeyFingerprint"],
49+
"imported")
50+
return keypair
51+
52+
53+
def read_public_key_file(path):
54+
try:
55+
with open(utils.full_path(path), 'rb') as f:
56+
data = f.read()
57+
58+
if not data.startswith(b"ssh-rsa"):
59+
raise ValueError(
60+
"Bad public key data, must be an RSA key in SSH authorized "
61+
"keys format (beginning with `ssh-rsa`)")
62+
63+
return data.strip()
64+
except (ValueError, IOError, OSError) as e:
65+
logger.error("Failed to read public key file {}: {}".format(
66+
path, e))
67+
return None
68+
69+
70+
def create_key_pair_from_public_key_file(ec2, keypair_name, public_key_path):
71+
public_key_data = read_public_key_file(public_key_path)
72+
if not public_key_data:
73+
return None
74+
75+
keypair = import_key_pair(ec2, keypair_name, public_key_data)
76+
return {
77+
"status": "imported",
78+
"key_name": keypair["KeyName"],
79+
"fingerprint": keypair["KeyFingerprint"],
80+
}
81+
82+
83+
def create_key_pair_in_ssm(ec2, ssm, keypair_name, parameter_name,
84+
kms_key_id=None):
85+
keypair = create_key_pair(ec2, keypair_name)
86+
try:
87+
kms_key_label = 'default'
88+
kms_args = {}
89+
if kms_key_id:
90+
kms_key_label = kms_key_id
91+
kms_args = {"KeyId": kms_key_id}
92+
93+
logger.info("Storing generated key in SSM parameter \"%s\" "
94+
"using KMS key \"%s\"", parameter_name, kms_key_label)
95+
96+
ssm.put_parameter(
97+
Name=parameter_name,
98+
Description="SSH private key for KeyPair \"{}\" "
99+
"(generated by Stacker)".format(keypair_name),
100+
Value=keypair["KeyMaterial"],
101+
Type="SecureString",
102+
Overwrite=False,
103+
**kms_args)
104+
except ClientError:
105+
# Erase the key pair if we failed to store it in SSM, since the
106+
# private key will be lost anyway
107+
108+
logger.exception("Failed to store generated key in SSM, deleting "
109+
"created key pair as private key will be lost")
110+
ec2.delete_key_pair(KeyName=keypair_name, DryRun=False)
111+
return None
112+
113+
return {
114+
"status": "created",
115+
"key_name": keypair["KeyName"],
116+
"fingerprint": keypair["KeyFingerprint"],
117+
}
118+
119+
120+
def create_key_pair(ec2, keypair_name):
121+
keypair = ec2.create_key_pair(KeyName=keypair_name, DryRun=False)
122+
logger.info(KEYPAIR_LOG_MESSAGE,
123+
keypair["KeyName"],
124+
keypair["KeyFingerprint"],
125+
"created")
126+
return keypair
127+
128+
129+
def create_key_pair_local(ec2, keypair_name, dest_dir):
130+
dest_dir = utils.full_path(dest_dir)
131+
if not os.path.isdir(dest_dir):
132+
logger.error("\"%s\" is not a valid directory", dest_dir)
133+
return None
134+
135+
file_name = "{0}.pem".format(keypair_name)
136+
key_path = os.path.join(dest_dir, file_name)
137+
if os.path.isfile(key_path):
138+
# This mimics the old boto2 keypair.save error
139+
logger.error("\"%s\" already exists in \"%s\" directory",
140+
file_name, dest_dir)
141+
return None
142+
143+
# Open the file before creating the key pair to catch errors early
144+
with open(key_path, "wb") as f:
145+
keypair = create_key_pair(ec2, keypair_name)
146+
f.write(keypair["KeyMaterial"].encode("ascii"))
147+
148+
return {
149+
"status": "created",
150+
"key_name": keypair["KeyName"],
151+
"fingerprint": keypair["KeyFingerprint"],
152+
"file_path": key_path
153+
}
154+
155+
156+
def interactive_prompt(keypair_name, ):
157+
if not sys.stdin.isatty():
158+
return None, None
159+
160+
try:
161+
while True:
162+
action = get_raw_input(
163+
"import or create keypair \"%s\"? (import/create/cancel) " % (
164+
keypair_name,
165+
)
166+
)
167+
168+
if action.lower() == "cancel":
169+
break
170+
171+
if action.lower() in ("i", "import"):
172+
path = get_raw_input("path to keypair file: ")
173+
return "import", path.strip()
174+
175+
if action.lower() == "create":
176+
path = get_raw_input("directory to save keyfile: ")
177+
return "create", path.strip()
178+
except (EOFError, KeyboardInterrupt):
179+
return None, None
180+
181+
return None, None
20182

21183

22184
def ensure_keypair_exists(provider, context, **kwargs):
@@ -28,84 +190,63 @@ def ensure_keypair_exists(provider, context, **kwargs):
28190
provider (:class:`stacker.providers.base.BaseProvider`): provider
29191
instance
30192
context (:class:`stacker.context.Context`): context instance
31-
32-
Returns: boolean for whether or not the hook succeeded.
193+
keypair (str): name of the key pair to create
194+
ssm_parameter_name (str, optional): path to an SSM store parameter to
195+
receive the generated private key, instead of importing it or
196+
storing it locally.
197+
ssm_key_id (str, optional): ID of a KMS key to encrypt the SSM
198+
parameter with. If omitted, the default key will be used.
199+
public_key_path (str, optional): path to a public key file to be
200+
imported instead of generating a new key. Incompatible with the SSM
201+
options, as the private key will not be available for storing.
202+
203+
Returns:
204+
In case of failure ``False``, otherwise a dict containing:
205+
status (str): one of "exists", "imported" or "created"
206+
key_name (str): name of the key pair
207+
fingerprint (str): fingerprint of the key pair
208+
file_path (str, optional): if a new key was created, the path to
209+
the file where the private key was stored
33210
34211
"""
35-
session = get_session(provider.region)
36-
client = session.client("ec2")
37-
keypair_name = kwargs.get("keypair")
38-
resp = client.describe_key_pairs()
39-
keypair = find(resp["KeyPairs"], "KeyName", keypair_name)
40-
message = "keypair: %s (%s) %s"
212+
213+
keypair_name = kwargs["keypair"]
214+
ssm_parameter_name = kwargs.get("ssm_parameter_name")
215+
ssm_key_id = kwargs.get("ssm_key_id")
216+
public_key_path = kwargs.get("public_key_path")
217+
218+
if public_key_path and ssm_parameter_name:
219+
logger.error("public_key_path and ssm_parameter_name cannot be "
220+
"specified at the same time")
221+
return False
222+
223+
session = get_session(region=provider.region,
224+
profile=kwargs.get("profile"))
225+
ec2 = session.client("ec2")
226+
227+
keypair = get_existing_key_pair(ec2, keypair_name)
41228
if keypair:
42-
logger.info(message,
43-
keypair["KeyName"],
44-
keypair["KeyFingerprint"],
45-
"exists")
46-
return {
47-
"status": "exists",
48-
"key_name": keypair["KeyName"],
49-
"fingerprint": keypair["KeyFingerprint"],
50-
}
229+
return keypair
51230

52-
logger.info("keypair: \"%s\" not found", keypair_name)
53-
create_or_upload = input(
54-
"import or create keypair \"%s\"? (import/create/Cancel) " % (
55-
keypair_name,
56-
),
57-
)
58-
if create_or_upload == "import":
59-
path = input("path to keypair file: ")
60-
full_path = utils.full_path(path)
61-
if not os.path.exists(full_path):
62-
logger.error("Failed to find keypair at path: %s", full_path)
63-
return False
64-
65-
with open(full_path) as read_file:
66-
contents = read_file.read()
67-
68-
keypair = client.import_key_pair(KeyName=keypair_name,
69-
PublicKeyMaterial=contents)
70-
logger.info(message,
71-
keypair["KeyName"],
72-
keypair["KeyFingerprint"],
73-
"imported")
74-
return {
75-
"status": "imported",
76-
"key_name": keypair["KeyName"],
77-
"fingerprint": keypair["KeyFingerprint"],
78-
"file_path": full_path,
79-
}
80-
elif create_or_upload == "create":
81-
path = input("directory to save keyfile: ")
82-
full_path = utils.full_path(path)
83-
if not os.path.exists(full_path) and not os.path.isdir(full_path):
84-
logger.error("\"%s\" is not a valid directory", full_path)
85-
return False
86-
87-
file_name = "{0}.pem".format(keypair_name)
88-
if os.path.isfile(os.path.join(full_path, file_name)):
89-
# This mimics the old boto2 keypair.save error
90-
logger.error("\"%s\" already exists in \"%s\" directory",
91-
file_name,
92-
full_path)
93-
return False
94-
95-
keypair = client.create_key_pair(KeyName=keypair_name)
96-
logger.info(message,
97-
keypair["KeyName"],
98-
keypair["KeyFingerprint"],
99-
"created")
100-
with open(os.path.join(full_path, file_name), "w") as f:
101-
f.write(keypair["KeyMaterial"])
231+
if public_key_path:
232+
keypair = create_key_pair_from_public_key_file(
233+
ec2, keypair_name, public_key_path)
102234

103-
return {
104-
"status": "created",
105-
"key_name": keypair["KeyName"],
106-
"fingerprint": keypair["KeyFingerprint"],
107-
"file_path": os.path.join(full_path, file_name)
108-
}
235+
elif ssm_parameter_name:
236+
ssm = session.client('ssm')
237+
keypair = create_key_pair_in_ssm(
238+
ec2, ssm, keypair_name, ssm_parameter_name, ssm_key_id)
109239
else:
110-
logger.warning("no action to find keypair, failing")
240+
action, path = interactive_prompt(keypair_name)
241+
if action == "import":
242+
keypair = create_key_pair_from_public_key_file(
243+
ec2, keypair_name, path)
244+
elif action == "create":
245+
keypair = create_key_pair_local(ec2, keypair_name, path)
246+
else:
247+
logger.warning("no action to find keypair, failing")
248+
249+
if not keypair:
111250
return False
251+
252+
return keypair

stacker/tests/conftest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import os
55

66
import pytest
7-
7+
import py.path
88

99
logger = logging.getLogger(__name__)
1010

@@ -35,3 +35,10 @@ def aws_credentials():
3535
os.environ[key] = value
3636

3737
saved_env.clear()
38+
39+
40+
@pytest.fixture(scope="package")
41+
def stacker_fixture_dir():
42+
path = os.path.join(os.path.dirname(os.path.realpath(__file__)),
43+
'fixtures')
44+
return py.path.local(path)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
d7:50:1f:78:55:5f:22:c1:f6:88:c6:5d:82:4f:94:4f
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
3+
NhAAAAAwEAAQAAAQEA7rF34ExOHgT+dDYJUswkhBpyC+vnK+ptx+nGQDTkPj9aP1uAXbXA
4+
C97KK+Ihou0jniYKPJMHsjEK4a7eh2ihoK6JkYs9+y0MeGCAHAYuGXdNt5jv1e0XNgoYdf
5+
JloC0pgOp4Po9+4qeuOds8bb9IxwM/aSaJWygaSc22ZTzeOWQk5PXJNH0lR0ZelUUkj0HK
6+
aouuV6UX/t+czTghgnNZgDjk5sOfUNmugN7fJi+6/dWjOaukDkJttfZXLRTPDux0SZw4Jo
7+
RqZ40cBNS8ipLVk24BWeEjVlNl6rrFDtO4yrkscz7plwXlPiRLcdCdbamcCZaRrdkftKje
8+
5ypz5dvocQAAA9DJ0TBmydEwZgAAAAdzc2gtcnNhAAABAQDusXfgTE4eBP50NglSzCSEGn
9+
IL6+cr6m3H6cZANOQ+P1o/W4BdtcAL3sor4iGi7SOeJgo8kweyMQrhrt6HaKGgromRiz37
10+
LQx4YIAcBi4Zd023mO/V7Rc2Chh18mWgLSmA6ng+j37ip6452zxtv0jHAz9pJolbKBpJzb
11+
ZlPN45ZCTk9ck0fSVHRl6VRSSPQcpqi65XpRf+35zNOCGCc1mAOOTmw59Q2a6A3t8mL7r9
12+
1aM5q6QOQm219lctFM8O7HRJnDgmhGpnjRwE1LyKktWTbgFZ4SNWU2XqusUO07jKuSxzPu
13+
mXBeU+JEtx0J1tqZwJlpGt2R+0qN7nKnPl2+hxAAAAAwEAAQAAAQAwMUSy1LUw+nElpYNc
14+
ZDs7MNu17HtQMpTXuCt+6y7qIoBmKmNQiFGuE91d3tpLuvVmCOgoMsdrAtvflR741/dKKf
15+
M8n5B0FjReWZ2ECvtjyOK4HvjNiIEXOBKYPcim/ndSwARnHTHRMWnL5KfewLBA/jbfVBiH
16+
fyFPpWkeJ5v2mg3EDCkTCj7mBZwXYkX8uZ1IN6CZJ9kWNaPO3kloTlamgs6pd/5+OmMGWc
17+
/vhfJQppaJjW58y7D7zCpncHg3Yf0HZsgWRTGJO93TxuyzDlAXITVGwqcz7InTVQZS1XTx
18+
3FNmIpb0lDtVrKGxwvR/7gP6DpxMlKkzoCg3j1o8tHvBAAAAgQDuZCVAAqQFrY4ZH2TluP
19+
SFulXuTiT4mgQivAwI6ysMxjpX1IGBTgDvHXJ0xyW4LN7pCvg8hRAhsPlaNBX24nNfOGmn
20+
QMYp/qAZG5JP2vEJmDUKmEJ77Twwmk+k0zXfyZyfo7rgpF4c5W2EFnV7xiMtBTKbAj4HMn
21+
qGPYDPGpySTwAAAIEA+w72mMctM2yd9Sxyg5b7ZlhuNyKW1oHcEvLoEpTtru0f8gh7C3HT
22+
C0SiuTOth2xoHUWnbo4Yv5FV3gSoQ/rd1sWbkpEZMwbaPGsTA8bkCn2eItsjfrQx+6oY1U
23+
HgZDrkjbByB3KQiq+VioKsrUmgfT/UgBq2tSnHqcYB56Eqj0sAAACBAPNkMvCstNJGS4FN
24+
nSCGXghoYqKHivZN/IjWP33t/cr72lGp1yCY5S6FCn+JdNrojKYk2VXOSF5xc3fZllbr7W
25+
hmhXRr/csQkymXMDkJHnsdhpMeoEZm7wBjUx+hE1+QbNF63kZMe9sjm5y/YRu7W7H6ngme
26+
kb5FW97sspLYX8WzAAAAF2RhbmllbGt6YUBkYW5pZWwtcGMubGFuAQID
27+
-----END OPENSSH PRIVATE KEY-----
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q==

0 commit comments

Comments
 (0)