Index: docs/api.rst =================================================================== --- docs/api.rst +++ docs/api.rst @@ -37,6 +37,8 @@ | /samples/`id` | Get all non-empty sample info for Sample `id`. | +---------------------------+------------------------------------------------------------------------------------------+ +.. _auth_tokens: + Write Operations ---------------- Index: docs/tools.rst =================================================================== --- docs/tools.rst +++ docs/tools.rst @@ -44,6 +44,43 @@ Run a built-in test. See the :ref:`tests` documentation for more details on this tool. + +Server Administration +~~~~~~~~~~~~~~~~~~~~~ + +The ``lnt admin`` tool allows connecting to a server through LNTs REST API and +perform data queries and modifications. Data modification is only possible with +an authentication mechanism specified in the `lntadmin.cfg` file. See +:ref:`auth_tokens` for details. + + ``lnt admin create-config`` + Create a `lntadmin.cfg` configuration file in the current directory. The file + describes the URL, authentication settings and default database and + test-suite settings for an LNT server. The other admin commands read this + file if it exists. + + ``lnt admin list-machines`` + List machines and their id numbers + + ``lnt admin get-machine `` + Download machine information and run list for a specific machine. + + ``lnt admin rm-machine `` + Removes the specified machine and related runs and samples. + + ``lnt admin rm-run `` + Removes the specified run and related samples. + + ``lnt admin rename-machine `` + Renames the specified machine. + + ``lnt admin get-run `` + Downloads the specified run. + + ``lnt admin post-run `` + Post the specified filename as a new run to the server. + + Server-Side Tools ----------------- Index: lnt/lnttool/admin.py =================================================================== --- /dev/null +++ lnt/lnttool/admin.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python +import click + +_config_filename = 'lntadmin.yaml' + + +def _load_dependencies(): + global yaml, sys, requests, json, os, httplib + import yaml + import sys + import requests + import json + import os + import httplib + + +def _error(msg): + sys.stderr.write('%s\n' % msg) + + +def _fatal(msg): + _error(msg) + sys.exit(1) + + +def _check_normalize_config(config, need_auth_token): + '''Verify whether config is correct and complete. Also normalizes the + server URL if necessary.''' + lnt_url = config.get('lnt_url', None) + if lnt_url is None: + _fatal('No lnt_url specified in config or commandline\n' + 'Tip: Use `create-config` for an example configuration') + if lnt_url.endswith('/'): + lnt_url = lnt_url[:-1] + config['lnt_url'] = lnt_url + database = config.get('database', None) + if database is None: + _fatal('No database specified in config or commandline') + testsuite = config.get('testsuite', None) + if testsuite is None: + config['testsuite'] = 'nts' + + session = requests.Session() + user = config.get('user', None) + password = config.get('password', None) + if user is not None and password is not None: + session.auth = (user, password) + + auth_token = config.get('auth_token', None) + if need_auth_token and auth_token is None: + _fatal('No auth_token specified in config') + else: + session.headers.update({'AuthToken': auth_token}) + config['session'] = session + + +def _make_config(kwargs, need_auth_token=False): + '''Load configuration from yaml file, merges it with the commandline + options and verifies the resulting configuration.''' + verbose = kwargs.get('verbose', False) + # Load config file + config = {} + try: + config = yaml.load(open(_config_filename)) + except IOError: + if verbose: + _error("Could not load configuration file '%s'\n" % + _config_filename) + for key, value in kwargs.items(): + if value is None: + continue + config[key] = value + _check_normalize_config(config, need_auth_token=need_auth_token) + return config + + +def _check_response(response): + '''Check given response. If it is not a 200 response print an error message + and quit.''' + status_code = response.status_code + if 200 <= status_code and status_code < 400: + return + + sys.stderr.write("%d: %s\n" % + (status_code, httplib.responses.get(status_code, ''))) + sys.stderr.write("\n%s\n" % response.text) + sys.exit(1) + + +def _print_machine_info(machine, indent=''): + for key, value in machine.items(): + sys.stdout.write('%s%s: %s\n' % (indent, key, value)) + + +def _common_options(func): + func = click.option("--lnt-url", help="URL of LNT server")(func) + func = click.option("--database", help="database to use")(func) + func = click.option("--testsuite", help="testsuite to use")(func) + func = click.option("--verbose", "-v", is_flag=True, + help="verbose output")(func) + return func + + +@click.command("list-machines") +@_common_options +def action_list_machines(**kwargs): + """List machines and their id numbers.""" + config = _make_config(kwargs) + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/machines' + .format(**config)) + session = config['session'] + response = session.get(url) + _check_response(response) + data = json.loads(response.text) + for machine in data['machines']: + id = machine.get('id', None) + name = machine.get('name', None) + sys.stdout.write("%s:%s\n" % (name, id)) + if config['verbose']: + _print_machine_info(machine, indent='\t') + + +@click.command("get-machine") +@click.argument("machine") +@_common_options +def action_get_machine(**kwargs): + """Download machine information and run list.""" + config = _make_config(kwargs) + + filename = 'machine_%s.json' % config['machine'] + if os.path.exists(filename): + _fatal("'%s' already exists" % filename) + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/machines/{machine}' + .format(**config)) + session = config['session'] + response = session.get(url) + _check_response(response) + data = json.loads(response.text) + assert len(data['machines']) == 1 + machine = data['machines'][0] + + result = { + 'machine': machine + } + runs = data.get('runs', None) + if runs is not None: + result['runs'] = runs + with open(filename, "w") as destfile: + json.dump(result, destfile, indent=2) + sys.stdout.write("%s created.\n" % filename) + + +@click.command("rm-machine") +@click.argument("machine") +@_common_options +def action_rm_machine(**kwargs): + """Removes machine and related data.""" + config = _make_config(kwargs, need_auth_token=True) + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/machines/{machine}' + .format(**config)) + session = config['session'] + response = session.delete(url) + _check_response(response) + + +@click.command("rename-machine") +@click.argument("machine") +@click.argument("new-name") +@_common_options +def action_rename_machine(**kwargs): + """Rename machine.""" + config = _make_config(kwargs, need_auth_token=True) + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/machines/{machine}' + .format(**config)) + session = config['session'] + response = session.post(url, data=(('action', 'rename'), + ('name', config['new_name']))) + _check_response(response) + + +@click.command("get-run") +@click.argument("run") +@_common_options +def action_get_run(**kwargs): + """Downloads run.""" + config = _make_config(kwargs) + + filename = 'run_%s.json' % config['run'] + if os.path.exists(filename): + _fatal("'%s' already exists" % filename) + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/runs/{run}' + .format(**config)) + session = config['session'] + response = session.get(url) + _check_response(response) + + data = json.loads(response.text) + with open(filename, "w") as destfile: + json.dump(data, destfile, indent=2) + sys.stdout.write("%s created.\n" % filename) + + +@click.command("rm-run") +@click.argument("run") +@_common_options +def action_rm_run(**kwargs): + """Removes run and related data.""" + config = _make_config(kwargs, need_auth_token=True) + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/runs/{run}' + .format(**config)) + session = config['session'] + response = session.delete(url) + _check_response(response) + + +@click.command("post-run") +@click.argument("datafile") +@_common_options +def action_post_run(**kwargs): + """Submit runfile to server.""" + config = _make_config(kwargs, need_auth_token=True) + + with open(config['datafile'], "r") as datafile: + data = datafile.read() + + url = ('{lnt_url}/api/db_{database}/v4/{testsuite}/runs' + .format(**config)) + session = config['session'] + response = session.post(url, data=data, allow_redirects=False) + _check_response(response) + if response.status_code == 301: + sys.stdout.write(response.headers.get('Location') + '\n') + if config['verbose']: + try: + response_data = json.loads(response.text) + json.dump(response_data, sys.stderr, response_data, indent=2) + except: + sys.stderr.write(response.text) + sys.stderr.write('\n') + + +@click.command('create-config') +def action_create_config(): + """Create example configuration.""" + if os.path.exists(_config_filename): + _fatal("'%s' already exists" % _config_filename) + with open(_config_filename, "w") as out: + out.write('''\ +lnt_url: "http://localhost:8000" +database: default +testsuite: nts +# user: 'http_user' +# password: 'http_password' +# auth_token: 'secret' +''') + sys.stderr.write("Created '%s'\n" % _config_filename) + + +class AdminCLI(click.MultiCommand): + '''Admin subcommands. Put into this class so we can lazily import + dependencies.''' + _commands = [ + action_create_config, + action_get_machine, + action_get_run, + action_list_machines, + action_post_run, + action_rename_machine, + action_rm_machine, + action_rm_run, + ] + def list_commands(self, ctx): + return [command.name for command in self._commands] + + def get_command(self, ctx, name): + _load_dependencies() + for command in self._commands: + if command.name == name: + return command + raise ValueError("Request unknown command '%s'" % name) + + +@click.group("admin", cls=AdminCLI, no_args_is_help=True) +def group_admin(): + """LNT server admin client.""" Index: lnt/lnttool/main.py =================================================================== --- lnt/lnttool/main.py +++ lnt/lnttool/main.py @@ -6,6 +6,7 @@ from .import_report import action_importreport from .updatedb import action_updatedb from .viewcomparison import action_view_comparison +from .admin import group_admin from lnt.util import logger import click import logging @@ -119,7 +120,7 @@ @click.group("runtest", cls=RunTestCLI, context_settings=dict( ignore_unknown_options=True, allow_extra_args=True,)) -def action_runtest(): +def group_runtest(): """run a builtin test application""" init_logger(logging.INFO) @@ -461,13 +462,12 @@ Use ``lnt --help`` for more information on a specific command. """ cli.add_command(action_checkformat) -cli.add_command(action_create) cli.add_command(action_convert) +cli.add_command(action_create) cli.add_command(action_import) cli.add_command(action_importreport) cli.add_command(action_profile) cli.add_command(action_runserver) -cli.add_command(action_runtest) cli.add_command(action_send_daily_report) cli.add_command(action_send_run_comparison) cli.add_command(action_showtests) @@ -475,6 +475,8 @@ cli.add_command(action_update) cli.add_command(action_updatedb) cli.add_command(action_view_comparison) +cli.add_command(group_admin) +cli.add_command(group_runtest) def main(): Index: tests/lnttool/admin.shtest =================================================================== --- /dev/null +++ tests/lnttool/admin.shtest @@ -0,0 +1,63 @@ +# RUN: rm -f %T/lntadmin.yaml +# RUN: cd %T ; lnt admin create-config +# RUN: FileCheck %s < %T/lntadmin.yaml --check-prefix=CREATE_CONFIG +# CREATE_CONFIG: lnt_url: "http://localhost:8000" +# CREATE_CONFIG-NEXT: database: default +# CREATE_CONFIG-NEXT: testsuite: nts +# CREATE_CONFIG-NEXT: # user: 'http_user' +# CREATE_CONFIG-NEXT: # password: 'http_password' +# CREATE_CONFIG-NEXT: # auth_token: 'secret' + +# RUN: rm -rf %t.instance +# RUN: python %{shared_inputs}/create_temp_instance.py \ +# RUN: %s %{shared_inputs}/SmallInstance %t.instance +# RUN: %{shared_inputs}/server_wrapper.sh %t.instance 9092 /bin/sh %s %T %{shared_inputs} + +DIR="$1" +SHARED_INPUTS="$2" +cd "$DIR" +cat > lntadmin.yaml << '__EOF__' +lnt_url: "http://localhost:9092" +database: default +testsuite: nts +auth_token: test_token +__EOF__ + +lnt admin post-run "${SHARED_INPUTS}/sample-a-small.plist" > post_run.stdout +# RUN: FileCheck %s --check-prefix=POST_RN < %T/post_run.stdout +# POST_RN: http://localhost:9092/api/db_default/v4/nts/runs/3 + +lnt admin get-run 3 +# RUN: FileCheck %s --check-prefix=GET_RN < %T/run_3.json +# GET_RN: { +# GET_RN: "runs": [ +# GET_RN: { +# GET_RN: "start_time": "2009-11-17T02:12:25", +# GET_RN: "id": 3, +# GET_RN: "end_time": "2009-11-17T03:44:48", +#... +# GET_RN: } +# GET_RN: ], +# GET_RN: "generated_by": "LNT Server v0.4.2dev", +# GET_RN: "samples": [ +#... +# GET_RN: ] +# GET_RN: } + +lnt admin list-machines > list_machines.stdout +# RUN: FileCheck %s --check-prefix=LIST_MACHINES < %T/list_machines.stdout +# LIST_MACHINES: localhost__clang_DEV__x86_64:1 +# LIST_MACHINES-NEXT: LNT SAMPLE MACHINE:2 + +lnt admin rm-machine 1 + +lnt admin list-machines > list_machines2.stdout +# RUN: FileCheck %s --check-prefix=LIST_MACHINES2 < %T/list_machines2.stdout +# LIST_MACHINES2-NOT: localhost__clang_DEV__x86_64:1 +# LIST_MACHINES2: LNT SAMPLE MACHINE:2 + +lnt admin rename-machine 2 hal9000 + +lnt admin list-machines > list_machines3.stdout +# RUN: FileCheck %s --check-prefix=LIST_MACHINES3 < %T/list_machines3.stdout +# LIST_MACHINES3: hal9000:2