Merge lp:~ensoft-opensource/ensoft-sextant/v1_ssh into lp:ensoft-sextant

Proposed by Patrick Stevens
Status: Merged
Approved by: ChrisD
Approved revision: 42
Merged at revision: 12
Proposed branch: lp:~ensoft-opensource/ensoft-sextant/v1_ssh
Merge into: lp:ensoft-sextant
Diff against target: 985 lines (+524/-233)
9 files modified
bin/sextant (+0/-10)
doc/wiki/Reference (+10/-2)
etc/sextant.conf (+13/-5)
setup.py (+1/-1)
src/sextant/__main__.py (+353/-194)
src/sextant/environment.py (+98/-1)
src/sextant/export.py (+1/-1)
src/sextant/query.py (+40/-17)
src/sextant/update_db.py (+8/-2)
To merge this branch: bzr merge lp:~ensoft-opensource/ensoft-sextant/v1_ssh
Reviewer Review Type Date Requested Status
James Approve
Review via email: mp+232403@code.launchpad.net

Commit message

Add support for SSH-ing into the Neo4j instance, rather than connecting directly.

Description of the change

Add support for SSH-ing into the Neo4j instance, rather than connecting directly.

Updates the documentation to add a new SSH section; adds a section to sextant.__main__ to handle SSHing. This parses the input arguments to Sextant and searches for remote_neo4j, then uses that or whichever configured default exists. It picks a random high-numbered open port, creates a port forward between localhost:highport and remote_neo4j, then passes localhost:highport in to Sextant proper. Once Sextant has finished, closes the port forward.
Adds an option display_neo4j to Sextant's internal functions (hidden to end-users) so that Sextant functions can use remoteneo4j:7474 rather than localhost:highport in messages output to the user.
Adds a use_ssh_tunnel config option (defaulting to True) to make Sextant connect directly rather than by SSH.
Moves any configuration-wrangling into sextant.environment rather than sextant.__main__, because this allows wrappers to Sextant to access configuration.

Also a small bugfix where sextant.export.ProgramConverter.to_dot was ignoring its remove_self_calls input, and some cosmetic changes (primarily "== None" and "!= None" to "is None" and "is not None") in sextant.query. Sorry for having these in this branch.

To post a comment you must log in.
Revision history for this message
Patrick Stevens (patrickas) wrote :

The major qualm I have with this is that the config file is currently parsed twice (because environment.load_config is called once in the wrapper and once in sextant.__main__) - this isn't really a problem, but it seems sub-optimal. Can anyone think of any easy fixes?

Revision history for this message
James (jamesh-f) wrote :

Please see comments on Documentation.

review: Needs Fixing
Revision history for this message
Patrick Stevens (patrickas) :
Revision history for this message
ChrisD (gingerchris) wrote :

Big question (that I should perhaps know the answer to):
Why did we decide to implement this as a wrapper to sextant and not simply make it optional functionality that sextant can provide?

I'm perhaps missing something, but if we integrate this with sextant that removes a lot of faff e.g. wrappers extra odd args...

Note that some of my inline comments were added prior to this realisation.

Revision history for this message
Patrick Stevens (patrickas) wrote :

> Big question (that I should perhaps know the answer to):
> Why did we decide to implement this as a wrapper to sextant and not simply
> make it optional functionality that sextant can provide?
>
> I'm perhaps missing something, but if we integrate this with sextant that
> removes a lot of faff e.g. wrappers extra odd args...
>
> Note that some of my inline comments were added prior to this realisation.

This was after discussion with Rob - it also seems logically sensible to me, to separate the fact that we're SSHing for all of Sextant from Sextant itself. (It doesn't get rid of that many extra args to put it inline - we still want to pass stuff in to sextant.query, sextant.add_program and so forth...)

Revision history for this message
ChrisD (gingerchris) wrote :

But by virtue of having those args and the wrapper script parsing sextants
config, importing its modules and understating its cli there is already no
meaningful separation.

Further, trying to maintain separation is introducing hacks.

Some args may still exist to internal Apis but there won't be any need to
have extra external (e.g. Cli) args.

On Friday, 29 August 2014, Patrick Stevens <email address hidden> wrote:

> > Big question (that I should perhaps know the answer to):
> > Why did we decide to implement this as a wrapper to sextant and not
> simply
> > make it optional functionality that sextant can provide?
> >
> > I'm perhaps missing something, but if we integrate this with sextant that
> > removes a lot of faff e.g. wrappers extra odd args...
> >
> > Note that some of my inline comments were added prior to this
> realisation.
>
> This was after discussion with Rob - it also seems logically sensible to
> me, to separate the fact that we're SSHing for all of Sextant from Sextant
> itself. (It doesn't get rid of that many extra args to put it inline - we
> still want to pass stuff in to sextant.query, sextant.add_program and so
> forth...)
> --
>
> https://code.launchpad.net/~ensoft-opensource/ensoft-sextant/v1_ssh/+merge/232403
> Your team Ensoft Open Source is subscribed to branch
> lp:~ensoft-opensource/ensoft-sextant/v1_ssh.
>

Revision history for this message
Patrick Stevens (patrickas) :
Revision history for this message
Patrick Stevens (patrickas) wrote :

Changes made per Chuck's comments. In particular, elide sextant_wrapper into sextant.__main__.

Revision history for this message
ChrisD (gingerchris) :
Revision history for this message
ChrisD (gingerchris) :
Revision history for this message
Patrick Stevens (patrickas) wrote :

Two comments (I've made Chuck's changes on anything I haven't commented)

Revision history for this message
ChrisD (gingerchris) :
Revision history for this message
Patrick Stevens (patrickas) wrote :

I tried that - replaced the <for key in parsers> loop with:
    argumentparser.add_argument('--remote-neo4j', metavar="URL",
                                  help="URL of neo4j server", type=str,
                                  default=config.remote_neo4j)
    argumentparser.add_argument('--use-ssh-tunnel', metavar="BOOL", type=str,
                                  help="whether to SSH into the remote server,"
                                       "True/False",
                                  default=str(config.use_ssh_tunnel))

and that causes the call <./bin/sextant query programs --remote-neo4j http://localhost:7474> to claim an error in __main__.py:
error: unrecognized arguments: --remote-neo4j http://localhost:7474

According to [1], the correct way is to use parents (so parser.add_subparser('run', parents=[parser]) as the way to add a subparser), but my first attempt at that failed for some reason I don't remember.

[1]: http://stackoverflow.com/questions/7066826/in-python-how-to-get-subparsers-to-read-in-parent-parsers-argument

Revision history for this message
James (jamesh-f) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'bin/sextant'
2--- bin/sextant 1970-01-01 00:00:00 +0000
3+++ bin/sextant 2014-08-29 14:18:31 +0000
4@@ -0,0 +1,12 @@
5+#!/bin/bash
6+
7+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
8+
9+export PYTHONPATH=${PYTHONPATH}:${DIR}/../src
10+
11+# We allow people to specify whether they want to run Sextant under Python 2
12+# or 3, by setting the environment variable SEXTANT_PYTHON to the Python they
13+# want to use.
14+export SEXTANT_PYTHON=${SEXTANT_PYTHON:=python}
15+
16+$SEXTANT_PYTHON -m sextant $@
17
18=== removed file 'bin/sextant'
19--- bin/sextant 2014-08-19 16:04:41 +0000
20+++ bin/sextant 1970-01-01 00:00:00 +0000
21@@ -1,10 +0,0 @@
22-#!/bin/bash
23-
24-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
25-
26-export PYTHONPATH=${PYTHONPATH}:${DIR}/../src
27-
28-export SEXTANT_PYTHON=${SEXTANT_PYTHON:=python}
29-
30-$SEXTANT_PYTHON -m sextant $@
31-
32
33=== modified file 'doc/wiki/Reference'
34--- doc/wiki/Reference 2014-08-26 11:04:04 +0000
35+++ doc/wiki/Reference 2014-08-29 14:18:31 +0000
36@@ -19,6 +19,10 @@
37 {{{
38 sextant delete-program <program name to be deleted as stored in database>
39 }}}
40+
41+== Audit ==
42+To see a detailed view of a list of programs on the server, type {{{ sextant audit }}} into the command line. This will return a list of programs stored on the Neo4j server, along with who uploaded them, that person's username on the computer they used to upload, and the date they were uploaded.
43+
44 == Queries ==
45 === Command Line ===
46 Command line queries produce a text output, either as a list or in GraphML which can be opened in yED.
47@@ -40,9 +44,13 @@
48 {{{
49 sextant query -h
50 }}}
51+
52 === Web ===
53 To run the web server type
54 {{{ sextant web }}}
55 into the command line; then navigate a web browser to the port specified.
56-== Audit ==
57-To see a more detailed view of a list of programs on the server type {{{ sextant audit }}} into the command line. This will return a list of programs on the Neo4j server, specified in either the config file of command line, along with their unique program number, who uploaded them, that persons id and the date they were uploaded.
58\ No newline at end of file
59+
60+== SSH ==
61+Sextant allows you to create an SSH tunnel between your client and the Neo4J server. Sextant picks a random high-numbered port which is not in use, sets up an SSH tunnel between {{{ localhost:{that port} }}} and the specified {{{ remote_neo4j }}} in the configuration file (or as the {{{ --remote-neo4j }}} command-line option), and then uses {{{ localhost:{port} }}} for communications. This happens by default; if you do not wish for this to happen, set the config option {{{ use_ssh_tunnel }}} to {{{ False }}} in the configuration file.
62+
63+Warning: if you have {{{ remote_neo4j }}} set to some variant of {{{ localhost }}}, then SSH may fail (because a machine can't easily SSH to itself as currently implemented). We have some error-catching here - if we detect that we are attempting to SSH to ourself, then we skip using SSH at all - but it can probably be broken by a determined user. If you do encounter an error for this reason, the workaround at present is to set {{{ use_ssh_tunnel }}} to {{{ False }}} in the configuration file.
64
65=== modified file 'etc/sextant.conf'
66--- etc/sextant.conf 2014-08-22 14:49:33 +0000
67+++ etc/sextant.conf 2014-08-29 14:18:31 +0000
68@@ -1,7 +1,15 @@
69 [Neo4j]
70 # URL of the Neo4J server
71-remote_neo4j = http://localhost:7474
72-# port on which to serve Sextant Web
73-port = 2905
74-# number of calls incoming before we consider a function to be 'common'
75-common_function_calls = 10
76+remote_neo4j =
77+# Port on which to serve Sextant Web
78+#port = 2905
79+# Number of calls incoming before we consider a function to be 'common'.
80+# Defaults to 10.
81+#common_function_calls = 10
82+# Whether to connect using SSH to the remote server. Warning: if remote_neo4j
83+# is some kind of localhost, we won't be able to SSH. Default is True (that is,
84+# automatically SSH).
85+#use_ssh_tunnel = True
86+# Which username to use on the remote SSH server. Default, or empty, is your
87+# currently-logged-in username.
88+#ssh_username =
89
90=== modified file 'setup.py'
91--- setup.py 2014-08-18 08:04:35 +0000
92+++ setup.py 2014-08-29 14:18:31 +0000
93@@ -18,7 +18,7 @@
94 packages=['sextant', 'sextant.web', 'resources', 'etc'],
95 package_dir={'sextant': 'src/sextant', 'resources': 'resources', 'etc': 'etc'},
96 scripts=['bin/sextant'],
97- install_requires=['neo4jrestclient', 'twisted'],
98+ install_requires=['twisted'],
99 package_data={'resources': ['sextant/web/*'], 'etc': ['*.conf']},
100 )
101
102
103=== modified file 'src/sextant/__main__.py'
104--- src/sextant/__main__.py 2014-08-26 11:04:04 +0000
105+++ src/sextant/__main__.py 2014-08-29 14:18:31 +0000
106@@ -7,223 +7,123 @@
107
108 from __future__ import absolute_import, print_function
109
110-import argparse
111-try:
112- import ConfigParser
113-except ImportError:
114- import configparser as ConfigParser
115+import sys
116+import random
117+import socket
118 import logging
119 import logging.config
120-import os
121+import argparse
122 import requests
123-import sys
124-
125-from . import update_db
126+import subprocess
127+
128+try:
129+ from urllib import parse
130+except ImportError: # fall back to Python 2
131+ import urlparse as parse
132+
133 from . import query
134 from . import db_api
135+from . import update_db
136 from . import environment
137
138-
139-# @@@ Logging level should be configurable (with some structure to setting up
140-# logging).
141-logging.config.dictConfig({
142- "version": 1,
143- "handlers": {
144- "console": {
145- "class": "logging.StreamHandler",
146- "level": logging.INFO,
147- "stream": "ext://sys.stderr",
148- },
149- },
150- "root": {
151- "level": logging.DEBUG,
152- "handlers": ["console"],
153- },
154-})
155-log = logging.getLogger()
156-
157-
158-def get_config_file():
159- # get the config file option for neo4j server location and web port number
160- _ROOT = os.path.abspath(os.path.dirname(__file__))
161-
162- def get_data(path, file_name):
163- return os.path.join(_ROOT, path, file_name)
164-
165- home_config = environment.HOME_CONFIG_FILE
166- env_config = os.environ.get(environment.ENV_CONFIG_FILE, "")
167- example_config = environment.DEFAULT_CONFIG_FILE
168-
169- try:
170- conffile = next(p
171- for p in (home_config, env_config, example_config)
172- if os.path.exists(p))
173- except StopIteration:
174- #No config files accessable
175- if environment.ENV_CONFIG_FILE in os.environ:
176- #SEXTANT_CONFIG environment variable is set
177- log.error("{} file %r doesn't exist.".format(environment.ENV_CONFIG_FILE), env_config)
178- log.error("Sextant requires a configuration file.")
179- sys.exit(1)
180-
181- log.info("Sextant is using config file {}".format(conffile))
182- return conffile
183-
184-conffile = get_config_file()
185-
186-conf = ConfigParser.ConfigParser()
187-conf.read(conffile)
188-
189-remote_neo4j = 'http://localhost:7474'
190-web_port = 2905
191-common_def = 10 # definition of a 'common' node
192-try:
193- options = conf.options('Neo4j')
194-except ConfigParser.NoSectionError:
195- pass
196-else:
197- try:
198- remote_neo4j = conf.get('Neo4j', 'remote_neo4j')
199- except ConfigParser.NoOptionError:
200- pass
201-
202- try:
203- web_port = conf.get('Neo4j', 'port')
204- except ConfigParser.NoOptionError:
205- pass
206-
207- try:
208- common_def = conf.get('Neo4j', 'common_function_calls')
209- except ConfigParser.NoOptionError:
210- common_def = 10
211-
212-argumentparser = argparse.ArgumentParser(description="Invoke part of the SEXTANT program")
213-subparsers = argumentparser.add_subparsers(title="subcommands")
214-
215-#set what will be defined as a "common function"
216-db_api.set_common_cutoff(common_def)
217-
218-parsers = dict()
219-
220-# add each subparser in turn to the parsers dictionary
221-
222-parsers['add'] = subparsers.add_parser('add-program',
223- help="add a program to the database")
224-parsers['add'].add_argument('input_file', metavar="FILE_NAME",
225- help="name of file to be put into database",
226- type=str)
227-parsers['add'].add_argument('--name-in-db', metavar="PROGRAM_NAME",
228- help="string to store this program under", type=str,
229- nargs=1)
230-parsers['add'].add_argument('--not-object-file',
231- help='default False, if the input file is an '
232- 'object to be disassembled',
233- action='store_true')
234-
235-parsers['delete'] = subparsers.add_parser('delete-program',
236- help="delete a program from the database")
237-parsers['delete'].add_argument('program_name', metavar="PROG_NAME",
238- help="name of program as stored in the database",
239- type=str)
240-
241-parsers['query'] = subparsers.add_parser('query',
242- help="make a query of the database")
243-parsers['query'].add_argument('query', metavar="QUERY",
244- help="functions-calling, functions-called-by, "
245- "all-call-paths, whole-program, "
246- "shortest-call-path, programs or "
247- "functions; if the latter, "
248- "supply argument --program",
249- type=str)
250-parsers['query'].add_argument('--program', metavar="PROG_NAME",
251- help="name of program as stored in the database; "
252- "required for all queries except 'programs'",
253- type=str, nargs=1)
254-parsers['query'].add_argument('--funcs', metavar='FUNCS',
255- help='functions to pass to the query',
256- type=str, nargs='+')
257-parsers['query'].add_argument('--suppress-common', metavar='BOOL',
258- help='suppress commonly called functions (True or False)',
259- type=str, nargs=1)
260-
261-parsers['web'] = subparsers.add_parser('web', help="start the web server")
262-parsers['web'].add_argument('--port', metavar='PORT', type=int,
263- help='port on which to serve Sextant Web',
264- default=web_port)
265-
266-parsers['audit'] = subparsers.add_parser('audit', help='view usage of Sextant')
267-
268-for parser_key in parsers:
269- parsers[parser_key].add_argument('--remote-neo4j', metavar="URL",
270- help="URL of neo4j server", type=str,
271- default=remote_neo4j)
272+config = environment.load_config()
273+
274+
275+def _displayable_url(args):
276+ """
277+ Return the URL specified by the user for Sextant to look at.
278+
279+ This is needed because we may be using SSH (that is, Sextant sees a
280+ localhost port for the Neo4j server, which ssh is forwarding somewhere
281+ else); in this case, we want to display the URL that the user thinks
282+ Sextant is using, rather than the URL it's actually using.
283+
284+ :param args: the argparse object Sextant was invoked with
285+ :return: the URL the Sextant invoker expects Sextant to be using
286+
287+ """
288+ try:
289+ if args.display_neo4j:
290+ return args.display_neo4j
291+ except AttributeError:
292+ return args.remote_neo4j
293+
294+ return args.remote_neo4j
295+
296+
297+# Beginning of functions which handle the actual invocation of Sextant
298+
299+def _start_web(args):
300+ # Don't import at top level - makes twisted dependency semi-optional,
301+ # allowing non-web functionality to work with Python 3.
302+ if sys.version_info[0] == 2:
303+ from .web import server
304+ else: # twisted won't be available - Python 2 required
305+ logging.error('Web server must be run on Python 2.')
306+ return
307+ logging.info("Serving site on port {}".format(args.port))
308+
309+ # server is .web.server, imported a couple of lines ago
310+ server.serve_site(input_database_url=args.remote_neo4j, port=args.port)
311+
312
313 def _audit(args):
314 try:
315 audited = query.audit(args.remote_neo4j)
316 except requests.exceptions.ConnectionError as e:
317- logging.error('Connection error to server {}: {}'.format(args.remote_neo4j, e))
318+ msg = 'Connection error to server {url}: {exception}'
319+ logging.error(msg.format(url=_displayable_url(args)), exception=e)
320
321 if not audited:
322- print('No programs on database at {}.'.format(args.remote_neo4j))
323+ location = _displayable_url(args)
324+ logging.warning('No programs on database at {}.'.format(location))
325 else:
326 for program in audited:
327- st = 'Program {} with {} functions uploaded by {} (id {}) on {}.'
328- print(st.format(program.program_name, program.number,
329- program.uploader, program.uploader_id,
330- program.date))
331-
332-parsers['audit'].set_defaults(func=_audit)
333-
334-def _start_web(args):
335- # Don't import at top level -- this makes twisted dependency semi-optional,
336- # allowing non-web functionality to work with Python 3.
337- try:
338- from .web import server
339- except ImportError as e: # twisted wasn't available - Python 2
340- log.error('Web server must be run on Python 2.')
341- log.error(e)
342- return
343- log.info("Serving site on port %s", args.port)
344- server.serve_site(input_database_url=args.remote_neo4j, port=args.port)
345-
346-parsers['web'].set_defaults(func=_start_web)
347-
348-def add_file(namespace):
349- try:
350- alternative_name = namespace.name_in_db[0]
351+ st = ('Program {progname} with {numfuncs} functions '
352+ 'uploaded by {uploader} (id {uploaderid}) on {date}.')
353+ print(st.format(progname=program.program_name,
354+ numfuncs=program.number,
355+ uploader=program.uploader,
356+ uploaderid=program.uploader_id,
357+ date=program.date))
358+
359+
360+def _add_program(args):
361+ try:
362+ alternative_name = args.name_in_db[0]
363 except TypeError:
364 alternative_name = None
365
366- not_object_file = namespace.not_object_file
367+ not_object_file = args.not_object_file
368 # the default is "yes, this is an object file" if not-object-file was
369- # unsupplied=
370+ # unsupplied
371+
372 try:
373- update_db.upload_program(namespace.input_file[0],
374- namespace.remote_neo4j,
375- alternative_name,
376- not_object_file)
377+ update_db.upload_program(file_path=args.input_file,
378+ db_url=args.remote_neo4j,
379+ alternative_name=alternative_name,
380+ not_object_file=not_object_file,
381+ display_url=_displayable_url(args))
382 except requests.exceptions.ConnectionError as e:
383- logging.error('Connection error to server {}: {}'.format(namespace.remote_neo4j,
384- e))
385+ msg = 'Connection error to server {}: {}'
386+ logging.error(msg.format(_displayable_url(args), e))
387
388 except IOError as e:
389 # in case of Python 2, where FileNotFoundError is undefined
390- logging.error('Input file {} was not found.'.format(namespace.input_file[0]))
391+ # note: ConnectionError subclasses IOError, so must come first
392+ logging.error('Input file {} was not found.'.format(args.input_file[0]))
393 logging.error(e)
394 logging.debug(e, exc_info=True)
395 except ValueError as e:
396 logging.error(e)
397
398-def delete_file(namespace):
399+
400+def _delete_program(namespace):
401 update_db.delete_program(namespace.program_name,
402 namespace.remote_neo4j)
403
404-parsers['add'].set_defaults(func=add_file)
405-parsers['delete'].set_defaults(func=delete_file)
406-
407-
408-def make_query(namespace):
409+
410+def _make_query(namespace):
411 arg1 = None
412 arg2 = None
413 try:
414@@ -245,17 +145,276 @@
415 suppress_common = False
416
417 query.query(remote_neo4j=namespace.remote_neo4j,
418+ display_neo4j=_displayable_url(namespace),
419 input_query=namespace.query,
420- program_name=program_name, argument_1=arg1, argument_2=arg2,
421+ program_name=program_name,
422+ argument_1=arg1, argument_2=arg2,
423 suppress_common=suppress_common)
424
425-parsers['query'].set_defaults(func=make_query)
426-
427-# parse the arguments
428-
429-parsed = argumentparser.parse_args()
430-
431-# call the appropriate function
432-
433-parsed.func(parsed)
434+# End of functions which invoke Sextant
435+
436+def parse_arguments():
437+ """
438+ Parses the command-line arguments to Sextant.
439+
440+ The resulting object, result, has a .func property, which is a method to be
441+ called with result as its only parameter. This .func method runs whichever
442+ of Sextant's functionality is appropriate.
443+ :return: namespace summarising the arguments
444+
445+ """
446+
447+ argumentparser = argparse.ArgumentParser(description="Invoke part of the SEXTANT program")
448+ subparsers = argumentparser.add_subparsers(title="subcommands")
449+
450+ #set what will be defined as a "common function"
451+ db_api.set_common_cutoff(config.common_cutoff)
452+
453+ parsers = dict()
454+
455+ # add each subparser in turn to the parsers dictionary
456+
457+ parsers['add'] = subparsers.add_parser('add-program',
458+ help="add a program to the database")
459+ parsers['add'].add_argument('input_file', metavar="FILE_NAME",
460+ help="name of file to be put into database",
461+ type=str)
462+ parsers['add'].add_argument('--name-in-db', metavar="PROGRAM_NAME",
463+ help="string to store this program under", type=str,
464+ nargs=1)
465+ parsers['add'].add_argument('--not-object-file',
466+ help='default False, if the input file is an '
467+ 'object to be disassembled',
468+ action='store_true')
469+
470+ parsers['delete'] = subparsers.add_parser('delete-program',
471+ help="delete a program from the database")
472+ parsers['delete'].add_argument('program_name', metavar="PROG_NAME",
473+ help="name of program as stored in the database",
474+ type=str)
475+
476+ parsers['query'] = subparsers.add_parser('query',
477+ help="make a query of the database")
478+ parsers['query'].add_argument('query', metavar="QUERY",
479+ help="functions-calling, functions-called-by, "
480+ "all-call-paths, whole-program, "
481+ "shortest-call-path, programs or "
482+ "functions; if the latter, "
483+ "supply argument --program",
484+ type=str)
485+ parsers['query'].add_argument('--program', metavar="PROG_NAME",
486+ help="name of program as stored in the database; "
487+ "required for all queries except 'programs'",
488+ type=str, nargs=1)
489+ parsers['query'].add_argument('--funcs', metavar='FUNCS',
490+ help='functions to pass to the query',
491+ type=str, nargs='+')
492+ parsers['query'].add_argument('--suppress-common', metavar='BOOL',
493+ help='suppress commonly called functions (True or False)',
494+ type=str, nargs=1)
495+
496+ parsers['web'] = subparsers.add_parser('web', help="start the web server")
497+ parsers['web'].add_argument('--port', metavar='PORT', type=int,
498+ help='port on which to serve Sextant Web',
499+ default=config.web_port)
500+
501+ parsers['audit'] = subparsers.add_parser('audit', help='view usage of Sextant')
502+
503+ for key in parsers:
504+ parsers[key].add_argument('--remote-neo4j', metavar="URL",
505+ help="URL of neo4j server", type=str,
506+ default=config.remote_neo4j)
507+ parsers[key].add_argument('--use-ssh-tunnel', metavar="BOOL", type=str,
508+ help="whether to SSH into the remote server,"
509+ "True/False",
510+ default=str(config.use_ssh_tunnel))
511+
512+ parsers['audit'].set_defaults(func=_audit)
513+ parsers['web'].set_defaults(func=_start_web)
514+ parsers['add'].set_defaults(func=_add_program)
515+ parsers['delete'].set_defaults(func=_delete_program)
516+ parsers['query'].set_defaults(func=_make_query)
517+
518+ # parse the arguments
519+
520+ return argumentparser.parse_args()
521+
522+
523+def _start_tunnel(local_port, remote_host, remote_port, ssh_user=''):
524+ """
525+ Creates an SSH port-forward.
526+
527+ This will result in localhost:local_port appearing to be
528+ remote_host:remote_port.
529+
530+ :param local_port: integer port number to open at localhost
531+ :param remote_host: string address of remote host (no port number)
532+ :param remote_port: port to 'open' on the remote host
533+ :param ssh_user: user to log in on the remote_host as
534+
535+ """
536+
537+ if not (isinstance(local_port, int) and local_port > 0):
538+ raise ValueError(
539+ 'Local port {} must be a positive integer.'.format(local_port))
540+ if not (isinstance(remote_port, int) and remote_port > 0):
541+ raise ValueError(
542+ 'Remote port {} must be a positive integer.'.format(remote_port))
543+
544+ logging.debug('Starting SSH tunnel...')
545+
546+ # this cmd string will be .format()ed in a few lines' time
547+ cmd = ['ssh']
548+
549+ if ssh_user:
550+ # ssh -l {user} ... sets the remote login username
551+ cmd += ['-l', ssh_user]
552+
553+ # -L localport:localhost:remoteport forwards the port
554+ # -M makes SSH able to accept slave connections
555+ # -S sets the location of a control socket (in this case, sextant-controller
556+ # with a unique identifier appended, just in case we run sextant twice
557+ # simultaneously), so we know how to close the port again
558+ # -f goes into background; -N does not execute a remote command;
559+ # -T says to remote host that we don't want a text shell.
560+ cmd += ['-M',
561+ '-S', 'sextantcontroller{tunnel_id}'.format(tunnel_id=local_port),
562+ '-fNT',
563+ '-L', '{0}:localhost:{1}'.format(local_port, remote_port),
564+ remote_host]
565+
566+ logging.debug('Running {}'.format(' '.join(cmd)))
567+
568+ exit_code = subprocess.call(cmd)
569+ if exit_code:
570+ raise OSError('SSH setup failed with error {}'.format(exit_code))
571+
572+ logging.debug('SSH tunnel created.')
573+
574+
575+def _stop_tunnel(local_port, remote_host):
576+ """
577+ Tear down an SSH port-forward which was previously set up with start_tunnel.
578+
579+ We use local_port as an identifier.
580+ :param local_port: the port on localhost we are using as the entrypoint
581+ :param remote_host: remote host we tunnelled into
582+
583+ """
584+
585+ logging.debug('Shutting down SSH tunnel...')
586+
587+ # ssh -O sends a command to the slave specified in -S
588+ cmd = ['ssh',
589+ '-S', 'sextantcontroller{}'.format(local_port),
590+ '-O', 'exit',
591+ remote_host]
592+
593+ return_code = subprocess.call(cmd)
594+ if return_code == 0:
595+ logging.debug('Shut down successfully.')
596+ else:
597+ logging.warning(
598+ 'SSH tunnel shutdown returned error code {}'.format(return_code))
599+ logging.warning(
600+ 'You may need to close sextantcontroller{} manually.'.format(local_port))
601+
602+
603+def _is_port_used(port):
604+ """
605+ Checks with the OS to see whether a port is open.
606+
607+ Beware: port is passed directly to the shell. Make sure it is an integer.
608+ We raise ValueError if it is not.
609+ :param port: integer port to check for openness
610+ :return: bool(port is in use)
611+
612+ """
613+
614+ # we follow http://stackoverflow.com/questions/2838244/get-open-tcp-port-in-python
615+ if not (isinstance(port, int) and port > 0):
616+ raise ValueError('port {} must be a positive integer.'.format(port))
617+
618+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
619+ try:
620+ sock.bind(('127.0.0.1', port))
621+ except socket.error as e:
622+ if e.errno == 98: # Address already in use
623+ return True
624+ raise
625+
626+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
627+
628+ return False # that is, the port is not used
629+
630+
631+def _get_unused_port():
632+ """
633+ Returns a port number between 10000 and 50000 which is not currently open.
634+
635+ """
636+
637+ keep_going = True
638+ while keep_going:
639+ portnum = random.randint(10000, 50000)
640+ keep_going = _is_port_used(portnum)
641+ return portnum
642+
643+
644+def _get_host_and_port(url):
645+ """Given a URL as http://host:port, returns (host, port)."""
646+ parsed = parse.urlparse(url)
647+ return (parsed.hostname, parsed.port)
648+
649+
650+def _is_localhost(host, port):
651+ """Checks whether a host is an alias to localhost."""
652+ return socket.getaddrinfo(host, port)[0][4][0] in ('127.0.0.1', '::1')
653+
654+
655+def main():
656+ args = parse_arguments()
657+
658+ if args.use_ssh_tunnel.lower() == 'true':
659+ localport = _get_unused_port()
660+
661+ remotehost, remoteport = _get_host_and_port(args.remote_neo4j)
662+
663+ if _is_localhost(remotehost, remoteport):
664+ # we are attempting to connect to localhost anyway, so we won't
665+ # bother to SSH to it.
666+ # There may be some ways the user can trick us into trying to SSH
667+ # to localhost anyway, but this will do as a first pass.
668+ # SSHing to localhost is undesirable because on my test computer,
669+ # we get 'connection refused' if we try.
670+ args.func(args)
671+
672+ else: # we need to SSH
673+ try:
674+ _start_tunnel(localport, remotehost, remoteport,
675+ ssh_user=config.ssh_user)
676+ except OSError as e:
677+ logging.error(str(e))
678+ return
679+
680+ try:
681+ args.display_neo4j = args.remote_neo4j
682+ args.remote_neo4j = 'http://localhost:{}'.format(localport)
683+ args.func(args)
684+ except KeyboardInterrupt:
685+ # this probably happened because we were running Sextant Web
686+ # and Ctrl-C'ed out of it
687+ logging.info('Keyboard interrupt detected. Halting.')
688+ pass
689+
690+ finally:
691+ _stop_tunnel(localport, remotehost)
692+
693+ else: # no need to set up the ssh, just run sextant
694+ args.func(args)
695+
696+
697+if __name__ == '__main__':
698+ main()
699+
700
701
702=== modified file 'src/sextant/environment.py'
703--- src/sextant/environment.py 2014-08-21 13:03:24 +0000
704+++ src/sextant/environment.py 2014-08-29 14:18:31 +0000
705@@ -10,9 +10,17 @@
706 """
707
708 __all__ = ("RESOURCES_DIR", "DEFAULT_CONFIG_FILE", "ENV_CONFIG_FILE",
709- "HOME_CONFIG_FILE")
710+ "HOME_CONFIG_FILE", "load_config")
711
712 import os
713+import logging
714+import logging.config
715+import collections
716+
717+try:
718+ import ConfigParser
719+except ImportError:
720+ import configparser as ConfigParser
721
722
723 RESOURCES_DIR = "" # where the resources folder is located
724@@ -41,3 +49,92 @@
725 RESOURCES_DIR = _find_folder('resources')
726 _conf_path = _find_folder('etc')
727 DEFAULT_CONFIG_FILE = os.path.join(_conf_path, 'sextant.conf')
728+
729+
730+Config = collections.namedtuple('Config', ['web_port', 'remote_neo4j',
731+ 'common_cutoff', 'use_ssh_tunnel',
732+ 'ssh_user'])
733+
734+
735+def load_config(check_here=''):
736+ """
737+ Loads in the config file (checking in the various locations).
738+
739+ If check_here is supplied as a location for the config file, it is used
740+ preferentially. Then, in order, we use HOME_CONFIG_FILE, the contents of
741+ the SEXTANT_CONFIG environment variable, and finally the bundled
742+ etc/sextant.conf if no other config file was found.
743+
744+ :return: configuration object with properties for each config option
745+ """
746+
747+ # @@@ Logging level should be configurable (with some structure to setting up
748+ # logging).
749+ logging.config.dictConfig({
750+ "version": 1,
751+ "handlers": {
752+ "console": {
753+ "class": "logging.StreamHandler",
754+ "level": logging.WARNING,
755+ "stream": "ext://sys.stderr",
756+ },
757+ },
758+ "root": {
759+ "level": logging.DEBUG,
760+ "handlers": ["console"],
761+ },
762+ })
763+ log = logging.getLogger()
764+
765+ def get_config_file(check=''):
766+ env_config = os.environ.get(ENV_CONFIG_FILE, "")
767+ try:
768+ conffile = next(p
769+ for p in (check, HOME_CONFIG_FILE,
770+ env_config, DEFAULT_CONFIG_FILE)
771+ if os.path.exists(p))
772+ except StopIteration:
773+ # No config files accessible
774+ if ENV_CONFIG_FILE in os.environ:
775+ # SEXTANT_CONFIG environment variable is set
776+ log.error("{} file %r doesn't exist.".format(ENV_CONFIG_FILE), env_config)
777+ raise Exception("Sextant requires a configuration file.")
778+
779+ log.info("Sextant is using config file {}".format(conffile))
780+ return conffile
781+
782+ conf_file = get_config_file(check_here)
783+
784+ conf = ConfigParser.ConfigParser({'remote_neo4j': 'http://localhost:7474',
785+ 'port': '2905',
786+ 'common_function_calls': '10',
787+ 'use_ssh_tunnel': 'True',
788+ 'ssh_username': ''})
789+ # It turns out that getint, getboolean etc want the defaults to be strings.
790+ # When getboolean is called and the default is not a string, and the
791+ # default is used, it complains that it can't work with non-strings.
792+ # Ultimately, however, we output an object with int common_function_calls
793+ # and port.
794+
795+ conf.read(conf_file)
796+
797+ remote_neo4j = ''
798+ web_port = 0
799+ common_def = 0 # definition of a 'common' node
800+ use_ssh_tunnel = True
801+ ssh_user = ''
802+ try:
803+ conf.options('Neo4j')
804+ except ConfigParser.NoSectionError:
805+ pass
806+ else:
807+ remote_neo4j = conf.get('Neo4j', 'remote_neo4j')
808+ web_port = conf.getint('Neo4j', 'port')
809+ common_def = conf.getint('Neo4j', 'common_function_calls')
810+ use_ssh_tunnel = conf.getboolean('Neo4j', 'use_ssh_tunnel')
811+ ssh_user = conf.get('Neo4j', 'ssh_username')
812+
813+ return Config(remote_neo4j=remote_neo4j, web_port=web_port,
814+ common_cutoff=common_def, use_ssh_tunnel=use_ssh_tunnel,
815+ ssh_user=ssh_user)
816+
817
818=== modified file 'src/sextant/export.py'
819--- src/sextant/export.py 2014-08-26 11:04:04 +0000
820+++ src/sextant/export.py 2014-08-29 14:18:31 +0000
821@@ -33,7 +33,7 @@
822 return name
823
824 @staticmethod
825- def to_dot(program, suppress_common_nodes=False):
826+ def to_dot(program, suppress_common_nodes=False, remove_self_calls=False):
827 """
828 Convert the program to DOT output format.
829 program: Nodes and edges to be outputted
830
831=== modified file 'src/sextant/query.py'
832--- src/sextant/query.py 2014-08-26 11:04:04 +0000
833+++ src/sextant/query.py 2014-08-29 14:18:31 +0000
834@@ -14,12 +14,37 @@
835 from .export import ProgramConverter
836
837
838-def query(remote_neo4j, input_query, program_name=None, argument_1=None, argument_2=None, suppress_common=False):
839+def query(remote_neo4j, input_query, display_neo4j='', program_name=None,
840+ argument_1=None, argument_2=None, suppress_common=False):
841+ """
842+ Run a query against the database at remote_neo4j.
843+
844+ input_query may be in ('functions-calling', 'functions-called-by',
845+ 'all-call-paths', 'whole-program', 'shortest-call-path', 'functions',
846+ 'programs').
847+
848+ If we wish to display a different URL to the user than remote_neo4j,
849+ display_neo4j should be used for that different URL.
850+
851+ :param remote_neo4j: location to sent HTTP requests to
852+ :param input_query: string as detailed above
853+ :param display_neo4j: Neo4j URL to display in messages to the user
854+ :param program_name: string name of program
855+ :param argument_1: string argument 1 to the query
856+ :param argument_2: string argument 2 to the query
857+ :param suppress_common: whether or not to suppress links to common functions
858+
859+ """
860+
861+ if display_neo4j:
862+ display_url = display_neo4j
863+ else:
864+ display_url = remote_neo4j
865
866 try:
867 db = db_api.SextantConnection(remote_neo4j)
868 except requests.exceptions.ConnectionError as err:
869- logging.error("Could not connect to Neo4J server {}. Are you sure it is running?".format(remote_neo4j))
870+ logging.error("Could not connect to Neo4J server {}. Are you sure it is running?".format(display_url))
871 logging.error(str(err))
872 return 2
873 #Not supported in python 2
874@@ -38,50 +63,51 @@
875 remove_self_calls = False
876
877 if input_query == 'functions-calling':
878- if argument_1 == None:
879+ if argument_1 is None:
880 print('Supply one function name to functions-calling.')
881 return 1
882 prog = db.get_all_functions_calling(program_name, argument_1)
883 elif input_query == 'functions-called-by':
884- if argument_1 == None:
885+ if argument_1 is None:
886 print('Supply one function name to functions-called-by.')
887 return 1
888 prog = db.get_all_functions_called(program_name, argument_1)
889 elif input_query == 'all-call-paths':
890- if (argument_1 == None and argument_2 == None):
891+ if argument_1 is None and argument_2 is None:
892 print('Supply two function names to calls-between.')
893 return 1
894 prog = db.get_call_paths(program_name, argument_1, argument_2)
895 elif input_query == 'whole-program':
896 prog = db.get_whole_program(program_name)
897 elif input_query == 'shortest-call-path':
898- if argument_1 == None and argument_2 == None:
899+ if argument_1 is None and argument_2 is None:
900 print('Supply two function names to shortest-path.')
901 return 1
902 prog = db.get_shortest_path_between_functions(program_name, argument_1, argument_2)
903 elif input_query == 'functions':
904- if program_name != None:
905+ if program_name is not None:
906 func_names = db.get_function_names(program_name)
907 if func_names:
908 names_list = list(func_names)
909 else:
910- print('No functions were found in program %s on server %s.' % (program_name, remote_neo4j))
911+ print('No functions were found in program %s on server %s.' % (program_name, display_url))
912 else:
913 list_of_programs = db.get_program_names()
914 if not list_of_programs:
915- print('Server %s database empty.' % (remote_neo4j))
916+ print('Server %s database empty.' % (display_url))
917 return 0
918- func_list = []
919- for prog_name in list_of_programs:
920- func_list += db.get_function_names(prog_name)
921+
922+ func_list = [db.get_function_names(prog_name)
923+ for prog_name in list_of_programs]
924+
925 if not func_list:
926- print('Server %s contains no functions.' % (remote_neo4j))
927+ print('Server %s contains no functions.' % (display_url))
928 else:
929 names_list = func_list
930 elif input_query == 'programs':
931 list_found = list(db.get_program_names())
932 if not list_found:
933- print('No programs were found on server {}.'.format(remote_neo4j))
934+ print('No programs were found on server {}.'.format(display_url))
935 else:
936 names_list = list_found
937 else:
938@@ -100,6 +126,3 @@
939 db = db_api.SextantConnection(remote_neo4j)
940
941 return db.programs_with_metadata()
942-
943-if __name__ == '__main__':
944- main()
945
946=== modified file 'src/sextant/update_db.py'
947--- src/sextant/update_db.py 2014-08-21 16:08:10 +0000
948+++ src/sextant/update_db.py 2014-08-29 14:18:31 +0000
949@@ -13,21 +13,27 @@
950 import logging
951
952
953-def upload_program(file_path, db_url,
954+def upload_program(file_path, db_url, display_url='',
955 alternative_name=None,
956 not_object_file=False):
957 """
958 Uploads a program to the remote database.
959+
960 Raises requests.exceptions.ConnectionError if the server didn't exist.
961 Raises IOError if file_path doesn't correspond to a file.
962 Raises ValueError if the desired alternative_name (or the default, if no
963 alternative_name was specified) already exists in the database.
964 :param file_path: the path to the local file we wish to upload
965 :param db_url: the URL of the database (eg. http://localhost:7474)
966+ :param display_url: alternative URL to display instead of db_url
967 :param alternative_name: a name to give the program to override the default
968 :param object_file: bool(the file is an objdump text output file, rather than a compiled binary)
969+
970 """
971
972+ if not display_url:
973+ display_url = db_url
974+
975 connection = SextantConnection(db_url)
976
977 program_names = connection.get_program_names()
978@@ -61,7 +67,7 @@
979 if not program_representation.add_function_call(obj.name, called[-1]): # called is a tuple (address, name)
980 logging.error('Validation error: {} calling {}'.format(obj.name, called[-1]))
981
982- logging.info('Sending {} named objects to server {}...'.format(len(parsed_objects), db_url))
983+ logging.info('Sending {} named objects to server {}...'.format(len(parsed_objects), display_url))
984 program_representation.commit()
985 logging.info('Sending complete! Exiting.')
986

Subscribers

People subscribed via source and target branches