Skip to content

Commit 0beba1d

Browse files
authored
Merge pull request #15 from azavea/tt/14/boto3
Upgrade to Boto3
2 parents d40bf0c + 1fdba58 commit 0beba1d

4 files changed

Lines changed: 94 additions & 28 deletions

File tree

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,31 @@
22

33
Puts CloudFormation stacks into motion.
44

5+
This project uses `boto3` for AWS CloudFormation.
6+
57
## Testing
68

79
There are two ways to run the built-in test suite. One is intended to be run locally, while the other is setup to run within a Docker container.
810

911
### Local
1012

11-
The local tests require a working installation of Python 3:
13+
The local tests require a working installation of Python 3.
14+
15+
Install dependencies (including test extras) and run the tests:
16+
17+
```bash
18+
$ python3 -m pip install -e '.[test]'
19+
$ python3 -m unittest
20+
```
21+
22+
If you prefer `uv`:
23+
24+
```bash
25+
$ uv pip install -e '.[test]'
26+
$ uv run python -m unittest
27+
```
28+
29+
The legacy setuptools test runner is still supported:
1230

1331
```bash
1432
$ python3 setup.py test
@@ -20,5 +38,12 @@ The Docker setup builds an image with Python 3 installed, along with all of this
2038

2139
```bash
2240
$ docker-compose build majorkirby
41+
$ docker-compose run majorkirby python -m pip install -e '.[test]'
42+
$ docker-compose run majorkirby python -m unittest
43+
```
44+
45+
If you prefer to keep using the legacy setuptools test runner:
46+
47+
```bash
2348
$ docker-compose run majorkirby python setup.py test
2449
```

majorkirby/majorkirby.py

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import json
1010
import logging
1111

12-
from boto import cloudformation
13-
from boto.exception import BotoServerError
12+
import boto3
13+
from botocore.exceptions import ClientError
1414

1515
from troposphere import Template, Ref, Output, Tags
1616

@@ -114,14 +114,49 @@ def __init__(self, **kwargs):
114114
self.should_run = False
115115
self.input_wiring = {}
116116
self.last_heartbeat_id = None
117-
self.boto_conn = None
117+
self._cfn_client = None
118118
self.stack_outputs = {}
119119
self.extra_outputs = {}
120120
self.stack_name = self.get_stack_name()
121121
self.aws_region = kwargs.get("aws_region", "us-east-1")
122122
self.aws_profile = kwargs.get("aws_profile", None)
123123
self.stack = None
124124

125+
def _get_cfn_client(self):
126+
if self._cfn_client is not None:
127+
return self._cfn_client
128+
129+
session = boto3.session.Session(
130+
profile_name=self.aws_profile, region_name=self.aws_region
131+
)
132+
self._cfn_client = session.client("cloudformation")
133+
return self._cfn_client
134+
135+
@staticmethod
136+
def _to_cfn_parameters(parameters):
137+
return [
138+
{"ParameterKey": key, "ParameterValue": str(value)}
139+
for key, value in parameters
140+
]
141+
142+
@staticmethod
143+
def _to_cfn_tags(tags):
144+
return [{"Key": key, "Value": str(value)} for key, value in tags.items()]
145+
146+
def _describe_stack(self):
147+
cfn = self._get_cfn_client()
148+
try:
149+
response = cfn.describe_stacks(StackName=self.stack_name)
150+
return response["Stacks"][0]
151+
except ClientError as e:
152+
error = e.response.get("Error", {})
153+
if error.get("Code") != "ValidationError":
154+
raise
155+
message = (error.get("Message") or "").lower()
156+
if "does not exist" not in message:
157+
raise
158+
return None
159+
125160
def connect_from(self, stack, name=None):
126161
"""
127162
Connects a node's outputs to this node's inputs.
@@ -227,7 +262,10 @@ def set_up_stack(self):
227262
"""
228263
This method should be overridden to set up the stack.
229264
"""
230-
self.add_version("2010-09-09")
265+
if hasattr(self, "set_version"):
266+
self.set_version("2010-09-09")
267+
else:
268+
self.add_version("2010-09-09")
231269

232270
def add_parameter(self, parameter, source=None):
233271
"""
@@ -316,25 +354,25 @@ def _launch_cfn(self):
316354
Sets up stack and launches it.
317355
"""
318356
self.set_up_stack()
319-
self.boto_conn = cloudformation.connect_to_region(
320-
region_name=self.aws_region, profile_name=self.aws_profile
321-
)
357+
cfn = self._get_cfn_client()
322358
parameters = []
323359
for param, input_name in list(self.input_wiring.items()):
324360
try:
325361
parameters.append((param, self.get_input(input_name)))
326362
except MKInputError:
327363
pass
328-
# check to see if stack exists
329-
try:
330-
self.stack = self.boto_conn.describe_stacks(self.stack_name)[0]
331-
except BotoServerError:
332-
# it would be great if we could more granularly check the error
333-
self.boto_conn.create_stack(
334-
self.stack_name,
335-
tags=self.get_raw_tags(),
336-
template_body=self.to_json(),
337-
parameters=parameters,
364+
365+
cfn_parameters = self._to_cfn_parameters(parameters)
366+
cfn_tags = self._to_cfn_tags(self.get_raw_tags())
367+
368+
# Check to see if stack exists.
369+
self.stack = self._describe_stack()
370+
if self.stack is None:
371+
cfn.create_stack(
372+
StackName=self.stack_name,
373+
Tags=cfn_tags,
374+
TemplateBody=self.to_json(),
375+
Parameters=cfn_parameters,
338376
)
339377
self.logger.info("Stack %s created", self.stack_name)
340378

@@ -343,17 +381,20 @@ def _check_cfn(self):
343381
Checks the status of the stack
344382
"""
345383

346-
self.stack = self.boto_conn.describe_stacks(self.stack_name)[0]
347-
self.logger.debug("%s %s", self.stack_name, self.stack.stack_status)
348-
return self.stack.stack_status
384+
self.stack = self._describe_stack()
385+
if self.stack is None:
386+
raise MKNoSuchStackError
387+
status = self.stack.get("StackStatus")
388+
self.logger.debug("%s %s", self.stack_name, status)
389+
return status
349390

350391
def _assign_outputs(self):
351392
"""
352-
Moves keys and values from boto output objects into dict.
393+
Moves keys and values from CloudFormation outputs into a dict.
353394
"""
354395
self.stack_outputs = {}
355-
for output in self.stack.outputs:
356-
self.stack_outputs[output.key] = output.value
396+
for output in self.stack.get("Outputs", []) or []:
397+
self.stack_outputs[output["OutputKey"]] = output["OutputValue"]
357398
self._custom_output_transform()
358399

359400
def _custom_output_transform(self):

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
) as f:
1111
long_description = f.read()
1212

13-
tests_require = ["moto >=0.4.1"]
13+
tests_require = ["moto[cloudformation] >=4.0.0"]
1414

1515
setup(
1616
name="majorkirby",
@@ -20,7 +20,7 @@
2020
author_email="shall@azavea.com",
2121
keywords="aws cloudformation",
2222
packages=find_packages(exclude=["tests"]),
23-
install_requires=["troposphere>=0.7.2", "boto>=2.38.0"],
23+
install_requires=["troposphere>=0.7.2", "boto3>=1.9.0"],
2424
extras_require={"dev": [], "test": tests_require},
2525
test_suite="tests",
2626
tests_require=tests_require,

tests/test_stack_node.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from majorkirby import GlobalConfigNode, StackNode
44
from troposphere import Output
5-
from moto import mock_cloudformation_deprecated
5+
from moto import mock_aws
66

77

88
class FirstStackNode(StackNode):
@@ -22,7 +22,7 @@ def set_up_stack(self):
2222

2323

2424
class TestStackNode(unittest.TestCase):
25-
@mock_cloudformation_deprecated
25+
@mock_aws
2626
def test_stack_threading(self):
2727
global_config = GlobalConfigNode(**{"test": "joker"})
2828

0 commit comments

Comments
 (0)