Skip to content

Commit 7eb692e

Browse files
authored
Merge pull request #112 from fabfuel/feature/diffing
New command: diff
2 parents ecdba55 + 7a36cc4 commit 7eb692e

File tree

5 files changed

+178
-4
lines changed

5 files changed

+178
-4
lines changed

ecs_deploy/cli.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
from time import sleep
55

66
import click
7+
import json
78
import getpass
89
from datetime import datetime, timedelta
910

1011
from ecs_deploy import VERSION
11-
from ecs_deploy.ecs import DeployAction, ScaleAction, RunAction, EcsClient, \
12+
from ecs_deploy.ecs import DeployAction, ScaleAction, RunAction, EcsClient, DiffAction, \
1213
TaskPlacementError, EcsError, UpdateAction, LAUNCH_TYPE_EC2, LAUNCH_TYPE_FARGATE
1314
from ecs_deploy.newrelic import Deployment, NewRelicException
1415
from ecs_deploy.slack import SlackNotification
@@ -348,6 +349,51 @@ def run(cluster, task, count, command, env, secret, launchtype, subnet, security
348349
exit(1)
349350

350351

352+
@click.command()
353+
@click.argument('task')
354+
@click.argument('revision_a')
355+
@click.argument('revision_b')
356+
@click.option('--region', help='AWS region (e.g. eu-central-1)')
357+
@click.option('--access-key-id', help='AWS access key id')
358+
@click.option('--secret-access-key', help='AWS secret access key')
359+
@click.option('--profile', help='AWS configuration profile name')
360+
def diff(task, revision_a, revision_b, region, access_key_id, secret_access_key, profile):
361+
"""
362+
Compare two task definition revisions.
363+
364+
\b
365+
TASK is the name of your task definition (e.g. 'my-task') within ECS.
366+
COUNT is the number of tasks your service should run.
367+
"""
368+
369+
try:
370+
client = get_client(access_key_id, secret_access_key, region, profile)
371+
action = DiffAction(client)
372+
373+
td_a = action.get_task_definition('%s:%s' % (task, revision_a))
374+
td_b = action.get_task_definition('%s:%s' % (task, revision_b))
375+
376+
result = td_a.diff_raw(td_b)
377+
for difference in result:
378+
click.secho('%s: %s' % (difference[0], difference[1]))
379+
380+
if difference[0] == 'add':
381+
for added in difference[2]:
382+
click.secho(' + %s: %s' % (added[0], json.dumps(added[1])))
383+
384+
if difference[0] == 'change':
385+
click.secho(' - %s' % json.dumps(difference[2][0]))
386+
click.secho(' + %s' % json.dumps(difference[2][1]))
387+
388+
if difference[0] == 'remove':
389+
for removed in difference[2]:
390+
click.secho(' - %s: %s' % removed)
391+
392+
except EcsError as e:
393+
click.secho('%s\n' % str(e), fg='red', err=True)
394+
exit(1)
395+
396+
351397
def wait_for_finish(action, timeout, title, success_message, failure_message,
352398
ignore_warnings, sleep_time=1):
353399
click.secho(title, nl=False)
@@ -544,6 +590,7 @@ def inspect_errors(service, failure_message, ignore_warnings, since, timeout):
544590
ecs.add_command(run)
545591
ecs.add_command(cron)
546592
ecs.add_command(update)
593+
ecs.add_command(diff)
547594

548595
if __name__ == '__main__': # pragma: no cover
549596
ecs()

ecs_deploy/ecs.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from boto3.session import Session
66
from botocore.exceptions import ClientError, NoCredentialsError
77
from dateutil.tz.tz import tzlocal
8+
from dictdiffer import diff
89

910
JSON_LIST_REGEX = re.compile(r'^\[.*\]$')
1011

@@ -237,6 +238,47 @@ def family_revision(self):
237238
def diff(self):
238239
return self._diff
239240

241+
def diff_raw(self, task_b):
242+
containers_a = {c['name']: c for c in self.containers}
243+
containers_b = {c['name']: c for c in task_b.containers}
244+
245+
requirements_a = sorted([r['name'] for r in self.requires_attributes])
246+
requirements_b = sorted([r['name'] for r in task_b.requires_attributes])
247+
248+
for container in containers_a:
249+
containers_a[container]['environment'] = {e['name']: e['value'] for e in containers_a[container].get('environment', {})}
250+
251+
for container in containers_b:
252+
containers_b[container]['environment'] = {e['name']: e['value'] for e in containers_b[container].get('environment', {})}
253+
254+
for container in containers_a:
255+
containers_a[container]['secrets'] = {e['name']: e['valueFrom'] for e in containers_a[container].get('secrets', {})}
256+
257+
for container in containers_b:
258+
containers_b[container]['secrets'] = {e['name']: e['valueFrom'] for e in containers_b[container].get('secrets', {})}
259+
260+
composite_a = {
261+
'containers': containers_a,
262+
'volumes': self.volumes,
263+
'requires_attributes': requirements_a,
264+
'role_arn': self.role_arn,
265+
'execution_role_arn': self.execution_role_arn,
266+
'compatibilities': self.compatibilities,
267+
'additional_properties': self.additional_properties,
268+
}
269+
270+
composite_b = {
271+
'containers': containers_b,
272+
'volumes': task_b.volumes,
273+
'requires_attributes': requirements_b,
274+
'role_arn': task_b.role_arn,
275+
'execution_role_arn': task_b.execution_role_arn,
276+
'compatibilities': task_b.compatibilities,
277+
'additional_properties': task_b.additional_properties,
278+
}
279+
280+
return list(diff(composite_a, composite_b))
281+
240282
def get_overrides(self):
241283
override = dict()
242284
overrides = []
@@ -670,6 +712,11 @@ def __init__(self, client):
670712
super(UpdateAction, self).__init__(client, None, None)
671713

672714

715+
class DiffAction(EcsAction):
716+
def __init__(self, client):
717+
super(DiffAction, self).__init__(client, None, None)
718+
719+
673720
class EcsError(Exception):
674721
pass
675722

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def readme():
1111
return f.read()
1212

1313

14-
dependencies = ['click<7.0.0', 'botocore>=1.12.0', 'boto3>=1.4.7', 'future', 'requests']
14+
dependencies = ['click<7.0.0', 'botocore>=1.12.0', 'boto3>=1.4.7', 'future', 'requests', 'dictdiffer==0.8.0']
1515

1616
setup(
1717
name='ecs-deploy',

tests/test_cli.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from ecs_deploy.ecs import EcsClient
1010
from ecs_deploy.newrelic import Deployment, NewRelicDeploymentException
1111
from tests.test_ecs import EcsTestClient, CLUSTER_NAME, SERVICE_NAME, \
12-
TASK_DEFINITION_ARN_1, TASK_DEFINITION_ARN_2, TASK_DEFINITION_FAMILY_1
12+
TASK_DEFINITION_ARN_1, TASK_DEFINITION_ARN_2, TASK_DEFINITION_FAMILY_1, \
13+
TASK_DEFINITION_REVISION_2, TASK_DEFINITION_REVISION_1, \
14+
TASK_DEFINITION_REVISION_3
1315

1416

1517
@pytest.fixture
@@ -1017,4 +1019,45 @@ def test_cron(get_client, runner):
10171019
assert u'Successfully deregistered revision: 2' in result.output
10181020

10191021

1020-
print(result.output)
1022+
@patch('ecs_deploy.cli.get_client')
1023+
def test_diff(get_client, runner):
1024+
get_client.return_value = EcsTestClient('acces_key', 'secret_key')
1025+
result = runner.invoke(cli.diff, (TASK_DEFINITION_FAMILY_1, str(TASK_DEFINITION_REVISION_1), str(TASK_DEFINITION_REVISION_3)))
1026+
1027+
assert not result.exception
1028+
assert result.exit_code == 0
1029+
1030+
assert 'change: containers.webserver.image' in result.output
1031+
assert '- "webserver:123"' in result.output
1032+
assert '+ "webserver:456"' in result.output
1033+
assert 'change: containers.webserver.command' in result.output
1034+
assert '- "run"' in result.output
1035+
assert '+ "execute"' in result.output
1036+
assert 'change: containers.webserver.environment.foo' in result.output
1037+
assert '- "bar"' in result.output
1038+
assert '+ "foobar"' in result.output
1039+
assert 'remove: containers.webserver.environment' in result.output
1040+
assert '- empty: ' in result.output
1041+
assert 'change: containers.webserver.secrets.baz' in result.output
1042+
assert '- "qux"' in result.output
1043+
assert '+ "foobaz"' in result.output
1044+
assert 'change: containers.webserver.secrets.dolor' in result.output
1045+
assert '- "sit"' in result.output
1046+
assert '+ "loremdolor"' in result.output
1047+
assert 'change: role_arn' in result.output
1048+
assert '- "arn:test:role:1"' in result.output
1049+
assert '+ "arn:test:another-role:1"' in result.output
1050+
assert 'change: execution_role_arn' in result.output
1051+
assert '- "arn:test:role:1"' in result.output
1052+
assert '+ "arn:test:another-role:1"' in result.output
1053+
assert 'add: containers.webserver.environment' in result.output
1054+
assert '+ newvar: "new value"' in result.output
1055+
1056+
1057+
@patch('ecs_deploy.cli.get_client')
1058+
def test_diff_without_credentials(get_client, runner):
1059+
get_client.return_value = EcsTestClient()
1060+
result = runner.invoke(cli.diff, (TASK_DEFINITION_FAMILY_1, str(TASK_DEFINITION_REVISION_1), str(TASK_DEFINITION_REVISION_3)))
1061+
1062+
assert result.exit_code == 1
1063+
assert u'Unable to locate credentials. Configure credentials by running "aws configure".\n\n' in result.output

tests/test_ecs.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
u'secrets': ({"name": "baz", "valueFrom": "qux"}, {"name": "dolor", "valueFrom": "sit"})},
3131
{u'name': u'application', u'image': u'application:123', u'command': u'run', u'environment': ()}
3232
]
33+
3334
TASK_DEFINITION_FAMILY_2 = u'test-task'
3435
TASK_DEFINITION_REVISION_2 = 2
3536
TASK_DEFINITION_ARN_2 = u'arn:aws:ecs:eu-central-1:123456789012:task-definition/%s:%s' % (TASK_DEFINITION_FAMILY_2,
@@ -42,6 +43,18 @@
4243
{u'name': u'application', u'image': u'application:123', u'command': u'run', u'environment': ()}
4344
]
4445

46+
TASK_DEFINITION_REVISION_3 = 3
47+
TASK_DEFINITION_ARN_3 = u'arn:aws:ecs:eu-central-1:123456789012:task-definition/%s:%s' % (TASK_DEFINITION_FAMILY_1,
48+
TASK_DEFINITION_REVISION_3)
49+
TASK_DEFINITION_VOLUMES_3 = []
50+
TASK_DEFINITION_CONTAINERS_3 = [
51+
{u'name': u'webserver', u'image': u'webserver:456', u'command': u'execute',
52+
u'environment': ({"name": "foo", "value": "foobar"}, {"name": "newvar", "value": "new value"}),
53+
u'secrets': ({"name": "baz", "valueFrom": "foobaz"}, {"name": "dolor", "valueFrom": "loremdolor"})},
54+
{u'name': u'application', u'image': u'application:123', u'command': u'run', u'environment': ()}
55+
]
56+
TASK_DEFINITION_ROLE_ARN_3 = u'arn:test:another-role:1'
57+
4558
PAYLOAD_TASK_DEFINITION_1 = {
4659
u'taskDefinitionArn': TASK_DEFINITION_ARN_1,
4760
u'family': TASK_DEFINITION_FAMILY_1,
@@ -69,6 +82,22 @@
6982
u'compatibilities': [u'EC2'],
7083
}
7184

85+
PAYLOAD_TASK_DEFINITION_3 = {
86+
u'taskDefinitionArn': TASK_DEFINITION_ARN_3,
87+
u'family': TASK_DEFINITION_FAMILY_1,
88+
u'revision': TASK_DEFINITION_REVISION_3,
89+
u'taskRoleArn': TASK_DEFINITION_ROLE_ARN_3,
90+
u'executionRoleArn': TASK_DEFINITION_ROLE_ARN_3,
91+
u'volumes': deepcopy(TASK_DEFINITION_VOLUMES_3),
92+
u'containerDefinitions': deepcopy(TASK_DEFINITION_CONTAINERS_3),
93+
u'status': u'active',
94+
u'requiresAttributes': {},
95+
u'networkMode': u'host',
96+
u'placementConstraints': {},
97+
u'unknownProperty': u'lorem-ipsum',
98+
u'compatibilities': [u'EC2'],
99+
}
100+
72101
TASK_ARN_1 = u'arn:aws:ecs:eu-central-1:123456789012:task/12345678-1234-1234-1234-123456789011'
73102
TASK_ARN_2 = u'arn:aws:ecs:eu-central-1:123456789012:task/12345678-1234-1234-1234-123456789012'
74103

@@ -166,11 +195,17 @@
166195
u"taskDefinition": PAYLOAD_TASK_DEFINITION_2
167196
}
168197

198+
RESPONSE_TASK_DEFINITION_3 = {
199+
u"taskDefinition": PAYLOAD_TASK_DEFINITION_3
200+
}
201+
169202
RESPONSE_TASK_DEFINITIONS = {
170203
TASK_DEFINITION_ARN_1: RESPONSE_TASK_DEFINITION,
171204
TASK_DEFINITION_ARN_2: RESPONSE_TASK_DEFINITION_2,
205+
TASK_DEFINITION_ARN_3: RESPONSE_TASK_DEFINITION_3,
172206
u'test-task:1': RESPONSE_TASK_DEFINITION,
173207
u'test-task:2': RESPONSE_TASK_DEFINITION_2,
208+
u'test-task:3': RESPONSE_TASK_DEFINITION_3,
174209
u'test-task': RESPONSE_TASK_DEFINITION_2,
175210
}
176211

@@ -892,6 +927,8 @@ def describe_services(self, cluster_name, service_name):
892927
}
893928

894929
def describe_task_definition(self, task_definition_arn):
930+
if not self.access_key_id or not self.secret_access_key:
931+
raise EcsConnectionError(u'Unable to locate credentials. Configure credentials by running "aws configure".')
895932
if task_definition_arn in RESPONSE_TASK_DEFINITIONS:
896933
return deepcopy(RESPONSE_TASK_DEFINITIONS[task_definition_arn])
897934
raise UnknownTaskDefinitionError('Unknown task definition arn: %s' % task_definition_arn)

0 commit comments

Comments
 (0)