Skip to content

Commit 20b01d2

Browse files
committed
Merge pull request #115 from opdemand/70-run-bash
Implement running of one-off admin commands in ephemeral docker containers fixes #70
2 parents b7689b6 + b330e24 commit 20b01d2

8 files changed

Lines changed: 117 additions & 5 deletions

File tree

api/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,13 @@ def logs(self):
447447
data = subprocess.check_output(['tail', '-n', str(settings.LOG_LINES), path])
448448
return data
449449

450+
def run(self, commands):
451+
"""Run a one-off command in an ephemeral container."""
452+
runtime_nodes = self.node_set.filter(layer__id='runtime').order_by('?')
453+
if not runtime_nodes:
454+
raise EnvironmentError('No nodes available')
455+
return runtime_nodes[0].run(commands)
456+
450457
def destroy(self):
451458
"""Create subtasks to terminate all nodes in parallel."""
452459
all_layers = self.layer_set.all()
@@ -625,6 +632,21 @@ def _prepare_terminate_args(self):
625632
args = (self.uuid, creds, params, self.provider_id)
626633
return args
627634

635+
def run(self, *args, **kwargs):
636+
tasks = import_tasks(self.layer.flavor.provider.type)
637+
command = ' '.join(*args)
638+
# prepare app-specific docker arguments
639+
formation_id = self.formation.id
640+
release = self.formation.release_set.order_by('-created')[0]
641+
version = release.version
642+
docker_args = ' '.join(
643+
['-v',
644+
'/opt/deis/runtime/slugs/{formation_id}-{version}/app:/app'.format(**locals()),
645+
release.image])
646+
args = list(self._prepare_converge_args()) + [docker_args] + [command]
647+
task = tasks.run_node.subtask(args)
648+
return task.apply_async().wait()
649+
628650
def destroy(self, async=False):
629651
subtask = self.terminate()
630652
if async:

api/tests/formation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ def test_formation_actions(self):
108108
response = self.client.post(url, json.dumps(body), content_type='application/json')
109109
self.assertEqual(response.status_code, 201)
110110
formation_id = response.data['id'] # noqa
111+
# create & scale a basic formation
112+
url = '/api/formations/{formation_id}/layers'.format(**locals())
113+
body = {'id': 'proxy', 'flavor': 'autotest', 'run_list': 'recipe[deis::proxy]'}
114+
response = self.client.post(url, json.dumps(body), content_type='application/json')
115+
self.assertEqual(response.status_code, 201)
116+
url = '/api/formations/{formation_id}/layers'.format(**locals())
117+
body = {'id': 'runtime', 'flavor': 'autotest', 'run_list': 'recipe[deis::runtime]'}
118+
response = self.client.post(url, json.dumps(body), content_type='application/json')
119+
self.assertEqual(response.status_code, 201)
120+
url = '/api/formations/{formation_id}/scale/layers'.format(**locals())
121+
body = {'proxy': 2, 'runtime': 4}
122+
response = self.client.post(url, json.dumps(body), content_type='application/json')
123+
self.assertEqual(response.status_code, 200)
111124
# test calculate
112125
url = '/api/formations/{formation_id}/calculate'.format(**locals())
113126
response = self.client.post(url)
@@ -150,6 +163,13 @@ def test_formation_actions(self):
150163
response = self.client.post(url)
151164
self.assertEqual(response.status_code, 200)
152165
self.assertEqual(response.data, FAKE_LOG_DATA)
166+
# test run
167+
url = '/api/formations/{formation_id}/run'.format(**locals())
168+
body = {'commands': 'ls -al'}
169+
response = self.client.post(url, json.dumps(body), content_type='application/json')
170+
self.assertEqual(response.status_code, 200)
171+
self.assertIn('.gitignore', response.data[0])
172+
self.assertEqual(response.data[1], 0)
153173

154174

155175
FAKE_LOG_DATA = """

api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@
199199
See also
200200
:meth:`FormationViewSet.logs() <api.views.FormationViewSet.logs>`
201201
202+
.. http:post:: /api/formations/(string:id)/run/
203+
204+
See also
205+
:meth:`FormationViewSet.run() <api.views.FormationViewSet.run>`
206+
207+
202208
Auth
203209
====
204210
@@ -293,6 +299,8 @@
293299
views.FormationViewSet.as_view({'post': 'converge'})),
294300
url(r'^formations/(?P<id>[a-z0-9-]+)/logs/?',
295301
views.FormationViewSet.as_view({'post': 'logs'})),
302+
url(r'^formations/(?P<id>[a-z0-9-]+)/run/?',
303+
views.FormationViewSet.as_view({'post': 'run'})),
296304
# formation base endpoint
297305
url(r'^formations/(?P<id>[a-z0-9-]+)/?',
298306
views.FormationViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),

api/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@ def logs(self, request, **kwargs):
234234
return Response(logs, status=status.HTTP_200_OK,
235235
content_type='text/plain')
236236

237+
def run(self, request, **kwargs):
238+
formation = self.get_object()
239+
output_and_rc = formation.run(request.DATA['commands'])
240+
return Response(output_and_rc, status=status.HTTP_200_OK,
241+
content_type='text/plain')
242+
237243
def destroy(self, request, **kwargs):
238244
formation = self.get_object()
239245
formation.destroy()

celerytasks/ec2.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,14 @@ def converge_node(node_id, ssh_username, fqdn, ssh_private_key,
156156
return output, rc
157157

158158

159+
@task(name='ec2.run_node')
160+
def run_node(node_id, ssh_username, fqdn, ssh_private_key, docker_args, command):
161+
ssh = util.connect_ssh(ssh_username, fqdn, 22, ssh_private_key)
162+
command = "sudo docker run {docker_args} {command}".format(**locals())
163+
output, rc = util.exec_ssh(ssh, command, pty=True)
164+
return output, rc
165+
166+
159167
# utility functions
160168

161169
def create_ec2_connection(region, access_key, secret_key):

celerytasks/mock.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,23 @@ def converge_node(node_id, ssh_username, fqdn, ssh_private_key):
4040
output = ""
4141
rc = 0
4242
return output, rc
43+
44+
45+
@task(name='mock.run_node')
46+
def run_node(node_id, ssh_username, fqdn, ssh_private_key, docker_args, command):
47+
output = """\
48+
total 80
49+
drwxr-xr-x 11 matt staff 374 Aug 14 10:57 .
50+
drwxr-xr-x 34 matt staff 1156 Aug 19 12:15 ..
51+
drwxr-xr-x 14 matt staff 476 Aug 20 09:48 .git
52+
-rw-r--r-- 1 matt staff 5 Aug 14 10:57 .gitignore
53+
-rw-r--r-- 1 matt staff 11 Aug 14 10:57 .ruby-version
54+
-rw-r--r-- 1 matt staff 67 Aug 14 10:57 Gemfile
55+
-rw-r--r-- 1 matt staff 277 Aug 14 10:57 Gemfile.lock
56+
-rw-r--r-- 1 matt staff 553 Aug 14 10:57 LICENSE
57+
-rw-r--r-- 1 matt staff 37 Aug 14 10:57 Procfile
58+
-rw-r--r-- 1 matt staff 9165 Aug 14 10:57 README.md
59+
-rw-r--r-- 1 matt staff 127 Aug 14 10:57 web.rb
60+
"""
61+
rc = 0
62+
return output, rc

celerytasks/util.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ def connect_ssh(username, hostname, port, key):
2525
return ssh
2626

2727

28-
def exec_ssh(ssh, command):
28+
def exec_ssh(ssh, command, pty=False):
2929
tran = ssh.get_transport()
3030
chan = tran.open_session()
3131
# NOTE: pty breaks line ordering on commands like apt-get
32-
#chan.get_pty(term='vt100', width=300, height=50)
32+
if pty:
33+
chan.get_pty(term='vt100', width=80, height=24)
3334
chan.exec_command(command)
3435
output = read_from_ssh(chan)
3536
exit_status = chan.recv_exit_status()
@@ -47,12 +48,12 @@ def read_from_ssh(chan):
4748
if data:
4849
got_data = True
4950
output += data
50-
#print("stdout => ", data)
51+
# print("stdout => ", data)
5152
if chan.recv_stderr_ready():
5253
data = r[0].recv_stderr(4096)
5354
if data:
5455
got_data = True
5556
output += data
56-
#print("stderr => ", data)
57+
# print("stderr => ", data)
5758
if not got_data:
5859
return output

client/deis.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
converge force-converge all nodes in the formation
2020
calculate recalculate and update the formation databag
2121
logs view aggregated log info for the formation
22+
run run a command on a remote container
2223
destroy destroy a container formation
2324
2425
Subcommands, use ``deis help [subcommand]`` to learn more::
@@ -711,6 +712,7 @@ def formations(self, args):
711712
formations:converge force-converge all nodes in the formation
712713
formations:calculate recalculate and update the formation databag
713714
formations:logs view aggregated log info for the formation
715+
formations:run run a command on a remote container
714716
formations:destroy destroy a container formation
715717
716718
Use `deis help [command]` to learn more
@@ -910,6 +912,29 @@ def formations_converge(self, args):
910912
else:
911913
print('Error!', response.text)
912914

915+
def formations_run(self, args):
916+
"""
917+
Run a command on a remote node.
918+
919+
Usage: deis formations:run <command>...
920+
"""
921+
formation = args.get('--formation')
922+
if not formation:
923+
formation = self._session.formation
924+
body = {'commands': sys.argv[2:]}
925+
response = self._dispatch('post',
926+
"/api/formations/{}/run".format(formation),
927+
json.dumps(body))
928+
if response.status_code == requests.codes.ok: # @UndefinedVariable
929+
output, rc = json.loads(response.content)
930+
if rc == 0:
931+
sys.stdout.write(output)
932+
sys.stdout.flush()
933+
else:
934+
print('Error!\n{}'.format(output))
935+
else:
936+
print('Error!', response.text)
937+
913938
def keys(self, args):
914939
"""
915940
Valid commands for SSH keys:
@@ -1426,6 +1451,7 @@ def parse_args(cmd):
14261451
'calculate': 'formations:calculate',
14271452
'converge': 'formations:converge',
14281453
'destroy': 'formations:destroy',
1454+
'run': 'formations:run',
14291455
'scale': 'containers:scale',
14301456
'ps': 'containers:list',
14311457
}
@@ -1467,7 +1493,8 @@ def main():
14671493
return
14681494
docopt(__doc__, argv=['--help'])
14691495
# re-parse docopt with the relevant docstring
1470-
if cmd in dir(cli):
1496+
# unless cmd is formations_run, which needs to use sys.argv directly
1497+
if not cmd == 'formations_run' and cmd in dir(cli):
14711498
docstring = trim(getattr(cli, cmd).__doc__)
14721499
if 'Usage: ' in docstring:
14731500
args.update(docopt(docstring))

0 commit comments

Comments
 (0)