2
0
mirror of https://github.com/xcat2/confluent.git synced 2025-01-24 00:23:53 +00:00

Merge remote-tracking branch 'refs/remotes/xcat2/master'

This commit is contained in:
Amanda Duffy 2017-04-20 14:19:33 -04:00
commit ec90ef889b
31 changed files with 850 additions and 91 deletions

View File

@ -235,7 +235,11 @@ def rcompleter(text, state):
def parse_command(command):
args = shlex.split(command, posix=True)
try:
args = shlex.split(command, posix=True)
except ValueError as ve:
print('Error: ' + ve.message)
return []
return args

View File

@ -0,0 +1,165 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
__author__ = 'alin37'
import optparse
import os
import sys
path = os.path.dirname(os.path.realpath(__file__))
path = os.path.realpath(os.path.join(path, '..', 'lib', 'python'))
if path.startswith('/opt'):
sys.path.append(path)
import confluent.client as client
def attrrequested(attr, attrlist, seenattributes):
for candidate in attrlist:
truename = candidate
if candidate.startswith('hm'):
candidate = candidate.replace('hm', 'hardwaremanagement', 1)
if candidate == attr:
seenattributes.add(truename)
return True
elif '.' not in candidate and attr.startswith(candidate + '.'):
seenattributes.add(truename)
return True
return False
argparser = optparse.OptionParser(
usage='''\n %prog [options] noderange [list of attributes] \
\n %prog [options] noderange attribute1=value1,attribute2=value,...
\n ''')
argparser.add_option('-b', '--blame', action='store_true',
help='Show information about how attributes inherited')
argparser.add_option('-c', '--clear', action='store_true',
help='Clear variables')
(options, args) = argparser.parse_args()
showtype = 'current'
requestargs=None
try:
noderange = args[0]
nodelist = '/noderange/{0}/nodes/'.format(noderange)
except IndexError:
nodelist = '/nodes/'
session = client.Command()
exitcode = 0
#Sets attributes
if len(args) > 1:
#clears attribute
if options.clear:
targpath = '/noderange/{0}/attributes/all'.format(noderange)
keydata = {}
for attrib in args[1:]:
keydata[attrib] = None
for res in session.update(targpath, keydata):
if 'error' in res:
if 'errorcode' in res:
exitcode = res['errorcode']
sys.stderr.write('Error: ' + res['error'] + '\n')
sys.exit(exitcode)
else:
if args[1] == 'all':
showtype = 'all'
elif args[1] == 'current':
showtype = 'current'
elif "=" in args[1]:
try:
if len(args[1:]) > 1:
for val in args[1:]:
val = val.split('=')
exitcode=session.simple_noderange_command(noderange, 'attributes/all'.format(noderange), val[1], val[0])
else:
val=args[1].split('=')
exitcode=session.simple_noderange_command(noderange, 'attributes/all'.format(noderange),val[1],val[0])
except:
sys.stderr.write('Error: {0} not a valid expression\n'.format(str (args[1:])))
exitcode = 1
sys.exit(exitcode)
else:
requestargs = args[1:]
# Lists all attributes
if len(args) > 0:
seenattributes = set([])
for res in session.read('/noderange/{0}/attributes/{1}'.format(noderange,showtype)):
if 'error' in res:
print "found error"
sys.stderr.write(res['error'] + '\n')
exitcode = 1
continue
for node in res['databynode']:
for attr in res['databynode'][node]:
seenattributes.add(attr)
currattr = res['databynode'][node][attr]
if requestargs is None or attrrequested(attr, args[1:], seenattributes):
if 'value' in currattr:
if currattr['value'] is not None:
attrout = '{0}: {1}: {2}'.format(
node, attr, currattr['value'])
else:
attrout = '{0}: {1}:'.format(node, attr)
elif 'isset' in currattr:
if currattr['isset']:
attrout = '{0}: {1}: ********'.format(node, attr)
else:
attrout = '{0}: {1}:'.format(node, attr)
elif 'broken' in currattr:
attrout = '{0}: {1}: *ERROR* BROKEN EXPRESSION: ' \
'{2}'.format(node, attr,
currattr['broken'])
elif isinstance(currattr, list) or isinstance(currattr, tuple):
attrout = '{0}: {1}: {2}'.format(node, attr, ', '.join(map(str, currattr)))
elif isinstance(currattr, dict):
dictout = []
for k,v in currattr.items:
dictout.append("{0}={1}".format(k,v))
attrout = '{0}: {1}: {2}'.format(node, attr, ', '.join(map(str, dictout)))
else:
print ("CODE ERROR" + repr(attr))
if options.blame or 'broken' in currattr:
blamedata = []
if 'inheritedfrom' in currattr:
blamedata.append('inherited from group {0}'.format(
currattr['inheritedfrom']
))
if 'expression' in currattr:
blamedata.append(
'derived from expression "{0}"'.format(
currattr['expression']))
if blamedata:
attrout += ' (' + ', '.join(blamedata) + ')'
print attrout
if not exitcode:
if requestargs:
for attr in args[1:]:
if attr not in seenattributes:
sys.stderr.write('Error: {0} not a valid attribute\n'.format(attr))
exitcode = 1
else:
for res in session.read(nodelist):
if 'error' in res:
sys.stderr.write(res['error'] + '\n')
exitcode = 1
else:
print res['item']['href'].replace('/', '')
sys.exit(exitcode)

View File

0
confluent_client/bin/nodeconsole Normal file → Executable file
View File

13
confluent_client/bin/nodeeventlog Normal file → Executable file
View File

@ -1,7 +1,7 @@
#!/usr/bin/python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2015-2016 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -16,6 +16,7 @@
# limitations under the License.
from datetime import datetime as dt
import optparse
import os
import sys
@ -26,13 +27,13 @@ if path.startswith('/opt'):
import confluent.client as client
argparser = optparse.OptionParser(
usage="Usage: %prog [options] noderange (clear)")
(options, args) = argparser.parse_args()
try:
noderange = sys.argv[1]
noderange = args[0]
except IndexError:
sys.stderr.write(
'Usage: {0} <noderange> [clear]\n'.format(
sys.argv[0]))
argparser.print_help()
sys.exit(1)
deletemode = False

11
confluent_client/bin/nodefirmware Normal file → Executable file
View File

@ -1,7 +1,7 @@
#!/usr/bin/python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2016 Lenovo
# Copyright 2016-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import optparse
import os
import sys
path = os.path.dirname(os.path.realpath(__file__))
@ -57,12 +58,12 @@ def printfirm(node, prefix, data):
print('{0}: {1}: {2}'.format(node, prefix, version))
argparser = optparse.OptionParser(usage="Usage: %prog <noderange>")
(options, args) = argparser.parse_args()
try:
noderange = sys.argv[1]
noderange = args[0]
except IndexError:
sys.stderr.write(
'Usage: {0} <noderange>\n'.format(
sys.argv[0]))
argparser.print_help()
sys.exit(1)
try:
session = client.Command()

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2015 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -16,6 +16,7 @@
# limitations under the License.
import codecs
import optparse
import os
import sys
@ -28,10 +29,12 @@ import confluent.client as client
sys.stdout = codecs.getwriter('utf8')(sys.stdout)
argparser = optparse.OptionParser(usage="Usage: %prog <noderange>")
(options, args) = argparser.parse_args()
try:
noderange = sys.argv[1]
noderange = args[0]
except IndexError:
sys.stderr.write('Usage: {0} <noderange>\n'.format(sys.argv[0]))
argparser.print_help()
sys.exit(1)

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2015 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import optparse
import os
import sys
@ -25,10 +26,12 @@ if path.startswith('/opt'):
import confluent.client as client
argparser = optparse.OptionParser(usage="Usage: %prog <noderange> [on|off]")
(options, args) = argparser.parse_args()
try:
noderange = sys.argv[1]
noderange = args[0]
except IndexError:
sys.stderr.write('Usage: {0} <noderange> [on|off]\n'.format(sys.argv[0]))
argparser.print_help()
sys.exit(1)
identifystate = None

9
confluent_client/bin/nodeinventory Normal file → Executable file
View File

@ -1,7 +1,7 @@
#!/usr/bin/python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2016 Lenovo
# Copyright 2016-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import optparse
import os
import sys
path = os.path.dirname(os.path.realpath(__file__))
@ -69,12 +70,12 @@ def printerror(res, node=None):
exitcode = 1
argparser = optparse.OptionParser(usage="Usage: %prog <noderange>")
(options, args) = argparser.parse_args()
try:
noderange = sys.argv[1]
except IndexError:
sys.stderr.write(
'Usage: {0} <noderange>\n'.format(
sys.argv[0]))
argparser.print_help()
sys.exit(1)
try:
session = client.Command()

20
confluent_client/bin/nodelist Normal file → Executable file
View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2015 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -42,7 +42,7 @@ def attrrequested(attr, attrlist, seenattributes):
return True
return False
argparser = optparse.OptionParser(
usage="Usage: %prog [options] noderange [list of attributes")
usage="Usage: %prog [options] noderange [list of attributes]")
argparser.add_option('-b', '--blame', action='store_true',
help='Show information about how attributes inherited')
(options, args) = argparser.parse_args()
@ -76,7 +76,21 @@ if len(args) > 1:
attrout = '{0}: {1}: ********'.format(node, attr)
else:
attrout = '{0}: {1}:'.format(node, attr)
if options.blame:
elif 'broken' in currattr:
attrout = '{0}: {1}: *ERROR* BROKEN EXPRESSION: ' \
'{2}'.format(node, attr,
currattr['broken'])
elif isinstance(currattr, list) or isinstance(currattr, tuple):
attrout = '{0}: {1}: {2}'.format(node, attr, ', '.join(map(str, currattr)))
elif isinstance(currattr, dict):
dictout = []
for k, v in currattr.items:
dictout.append("{0}={1}".format(k, v))
attrout = '{0}: {1}: {2}'.format(node, attr, ', '.join(map(str, dictout)))
else:
print ("CODE ERROR" + repr(attr))
if options.blame or 'broken' in currattr:
blamedata = []
if 'inheritedfrom' in currattr:
blamedata.append('inherited from group {0}'.format(

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2015 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import optparse
import os
import sys
@ -25,13 +26,14 @@ if path.startswith('/opt'):
import confluent.client as client
argparser = optparse.OptionParser(
usage="Usage: %prog [options] noderange "
"([status|on|off|shutdown|boot|reset])")
(options, args) = argparser.parse_args()
try:
noderange = sys.argv[1]
noderange = args[0]
except IndexError:
sys.stderr.write(
'Usage: {0} <noderange> ([status|on|off|shutdown|boot|reset]\n'.format(
sys.argv[0]))
argparser.print_help()
sys.exit(1)
setstate = None
@ -43,5 +45,6 @@ if len(sys.argv) > 2:
session = client.Command()
exitcode = 0
session.add_precede_key('oldstate')
sys.exit(
session.simple_noderange_command(noderange, '/power/state', setstate))

86
confluent_client/bin/noderun Executable file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2016-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import optparse
import os
import select
import shlex
import subprocess
import sys
path = os.path.dirname(os.path.realpath(__file__))
path = os.path.realpath(os.path.join(path, '..', 'lib', 'python'))
if path.startswith('/opt'):
sys.path.append(path)
import confluent.client as client
argparser = optparse.OptionParser(
usage="Usage: %prog node commandexpression",
epilog="Expressions are the same as in attributes, e.g. "
"'ipmitool -H {hardwaremanagement.manager}' will be expanded.")
argparser.disable_interspersed_args()
(options, args) = argparser.parse_args()
if len(args) < 2:
argparser.print_help()
sys.exit(1)
c = client.Command()
cmdstr = " ".join(args[1:])
nodeforpopen = {}
popens = []
for exp in c.create('/noderange/{0}/attributes/expression'.format(args[0]),
{'expression': cmdstr}):
ex = exp['databynode']
for node in ex:
cmd = ex[node]['value'].encode('utf-8')
cmdv = shlex.split(cmd)
nopen = subprocess.Popen(
cmdv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
popens.append(nopen)
nodeforpopen[nopen] = node
all = set([])
pipedesc = {}
exitcode = 0
for pop in popens:
node = nodeforpopen[pop]
pipedesc[pop.stdout] = { 'node': node, 'popen': pop, 'type': 'stdout'}
pipedesc[pop.stderr] = {'node': node, 'popen': pop, 'type': 'stderr'}
all.add(pop.stdout)
all.add(pop.stderr)
rdy, _, _ = select.select(all, [], [], 10)
while all and rdy:
for r in rdy:
data = r.readline()
desc = pipedesc[r]
if data:
node = desc['node']
if desc['type'] == 'stdout':
sys.stdout.write('{0}: {1}'.format(node,data))
else:
sys.stderr.write('{0}: {1}'.format(node, data))
else:
pop = desc['popen']
ret = pop.poll()
if ret is not None:
exitcode = exitcode | ret
all.discard(r)
if all:
rdy, _, _ = select.select(all, [], [], 10)
sys.exit(exitcode)

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2015 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -40,7 +40,7 @@ sensorcollections = {
argparser = optparse.OptionParser(
usage="Usage: %prog [options] noderange [sensor(s)")
usage="Usage: %prog [options] noderange ([sensor(s)])")
argparser.add_option('-i', '--interval', type='float',
help='Interval to do repeated samples over')
argparser.add_option('-n', '--numreadings', type='int',
@ -60,7 +60,7 @@ if options.numreadings:
try:
noderange = args[0]
except IndexError:
argparser.print_usage()
argparser.print_help()
sys.exit(1)
sensors = []
for sensorgroup in args[1:]:

View File

@ -26,7 +26,8 @@ if path.startswith('/opt'):
import confluent.client as client
argparser = optparse.OptionParser()
argparser = optparse.OptionParser(
usage='Usage: %prog [options] noderange [default|cd|network|setup|hd]')
argparser.add_option('-b', '--bios', dest='biosmode',
action='store_true', default=False,
help='Request BIOS style boot (rather than UEFI)')
@ -34,15 +35,16 @@ argparser.add_option('-p', '--persist', dest='persist', action='store_true',
default=False,
help='Request the boot device be persistent rather than '
'one time')
argparser.add_option('-u', '--uefi', dest='uefi', action='store_true',
default=True,
help='Request UEFI style boot (rather than BIOS)')
(options, args) = argparser.parse_args()
try:
noderange = args[0]
except IndexError:
sys.stderr.write(
'Usage: {0} <noderange> [default|cd|network|setup|hd]\n'.format(
sys.argv[0]))
argparser.print_help()
sys.exit(1)
bootdev = None
if len(sys.argv) > 2:

View File

@ -44,6 +44,7 @@ def _parseserver(string):
class Command(object):
def __init__(self, server=None):
self._prevkeyname = None
self.connection = None
if server is None:
if 'CONFLUENT_HOST' in os.environ:
@ -74,6 +75,9 @@ class Command(object):
if authdata['authpassed'] == 1:
self.authenticated = True
def add_precede_key(self, keyname):
self._prevkeyname = keyname
def handle_results(self, ikey, rc, res):
if 'error' in res:
sys.stderr.write('Error: {0}\n'.format(res['error']))
@ -93,7 +97,17 @@ class Command(object):
else:
rc |= 1
elif ikey in res[node]:
print('{0}: {1}'.format(node, res[node][ikey]['value']))
if 'value' in res[node][ikey]:
val = res[node][ikey]['value']
elif 'isset' in res[node][ikey]:
val = '********' if res[node][ikey] else ''
else:
val = repr(res[node][ikey])
if self._prevkeyname and self._prevkeyname in res[node]:
print('{0}: {2}->{1}'.format(
node, val, res[node][self._prevkeyname]['value']))
else:
print('{0}: {1}'.format(node, val))
return rc
def simple_noderange_command(self, noderange, resource, input=None,
@ -223,7 +237,12 @@ def send_request(operation, path, server, parameters=None):
tlvdata.send(server, payload)
result = tlvdata.recv(server)
while '_requestdone' not in result:
yield result
try:
yield result
except GeneratorExit:
while '_requestdone' not in result:
result = tlvdata.recv(server)
raise
result = tlvdata.recv(server)

View File

@ -0,0 +1,37 @@
confetty(1) --- Interactive confluent client
=================================================
## SYNOPSIS
`confetty`
## DESCRIPTION
**confetty** launches an interactive CLI session to the
confluent service. It provides a filesystem-like
view of the confluent interface. It is intended to
be mostly an aid for developing client software, with
day to day administration generally being easier with
the various function specific commands.
## COMMANDS
The CLI may be navigated by shell commands and some other
commands.
* `cd`:
Change the location within the tree
* `ls`:
List the elements within the current directory/tree
* `show` **ELEMENT**, `cat` **ELEMENT**:
Display the result of reading a specific element (by full or relative path)
* `unset` **ELEMENT** **ATTRIBUTE**
For an element with attributes, request to clear the value of the attribue
* `set` **ELEMENT** **ATTRIBUTE**=**VALUE**
Set the specified attribute to the given value
* `start` **ELEMENT**
Start a console session indicated by **ELEMENT** (e.g. /nodes/n1/console/session)
* `rm` **ELEMENT**
Request removal of an element. (e.g. rm events/hardware/log clears log from a node)

View File

@ -0,0 +1,75 @@
nodeattrib(1) -- List or change confluent nodes attributes
=========================================================
## SYNOPSIS
`nodeattrib` `noderange` [ current | all ]
`nodeattrib` `noderange` [-b] [<nodeattribute>...]
`nodeattrib` `noderange` [<nodeattribute1=value1> <nodeattribute2=value2> ...]
`nodeattrib` `noderange` [-c] [<nodeattribute1> <nodeattribute2=value2> ...]
## DESCRIPTION
**nodeattrib** queries the confluent server to get information about nodes. In
the simplest form, it simply takes the given noderange(5) and lists the
matching nodes, one line at a time.
If a list of node attribute names are given, the value of those are also
displayed. If `-b` is specified, it will also display information on
how inherited and expression based attributes are defined. There is more
information on node attributes in nodeattributes(5) man page.
If `-c` is specified, this will set the nodeattribute to a null valid.
This is different from setting the value to an empty string.
## OPTIONS
* `-b`, `--blame`:
Annotate inherited and expression based attributes to show their base value.
* `-c`, `--clear`:
Clear given nodeattributes since '' is not the same as empty
## EXAMPLES
* Listing matching nodes of a simple noderange:
`# nodeattrib n1-n2`
`n1`: console.method: ipmi
`n1`: hardwaremanagement.manager: 172.30.3.1
`n2`: console.method: ipmi
`n2`: hardwaremanagement.manager: 172.30.3.2
* Getting an attribute of nodes matching a noderange:
`# nodeattrib n1,n2 hardwaremanagement.manager`
`n1: hardwaremanagement.manager: 172.30.3.1`
`n2: hardwaremanagement.manager: 172.30.3.2`
* Getting a group of attributes while determining what group defines them:
`# nodeattrib n1,n2 hardwaremanagement --blame`
`n1: hardwaremanagement.manager: 172.30.3.1`
`n1: hardwaremanagement.method: ipmi (inherited from group everything)`
`n1: hardwaremanagement.switch: r8e1`
`n1: hardwaremanagement.switchport: 14`
`n2: hardwaremanagement.manager: 172.30.3.2`
`n2: hardwaremanagement.method: ipmi (inherited from group everything)`
`n2: hardwaremanagement.switch: r8e1`
`n2: hardwaremanagement.switchport: 2`
* Listing matching nodes of a simple noderange that are set:
`# nodeattrib n1-n2 current`
`n1`: console.method: ipmi
`n1`: hardwaremanagement.manager: 172.30.3.1
`n2`: console.method: ipmi
`n2`: hardwaremanagement.manager: 172.30.3.2
* Change attribute on nodes of a simple noderange:
`# nodeattrib n1-n2 console.method=serial`
`n1`: console.method: serial
`n1`: hardwaremanagement.manager: 172.30.3.1
`n2`: console.method: serial
`n2`: hardwaremanagement.manager: 172.30.3.2
* Clear attribute on nodes of a simple noderange, if you want to retain the variable set the attribute to "":
`# nodeattrib n1-n2 -c console.method`
`# nodeattrib n1-n2 console.method`
Error: console.logging not a valid attribute

View File

@ -0,0 +1,30 @@
nodeconsole(1) -- Open a console to a confluent node
=====================================================
## SYNOPSIS
`nodeconsole` `node`
## DESCRIPTION
**nodeconsole** opens an interactive console session to a given node. This is the
text or serial console of a system. Exiting is done by hitting `Ctrl-e`, then `c`,
then `.`. Note that console output by default is additionally logged to
`/var/log/confluent/consoles/`**NODENAME**.
## ESCAPE SEQUENCE COMMANDS
While connected to a console, a number of commands may be performed through escape
sequences. To begin an command escape sequence, hit `Ctrl-e`, then `c`. The next
keystroke will be interpreted as a command. The following commands are available.
* `.`:
Exit the session and return to the command prompt
* `b`:
Send a break to the remote console when possible (some console plugins may not support this)
* `o`:
Request confluent to disconnect and reconnect to console. For example if there is suspicion
that the console has gone inoperable, but would work if reconnected.
* `?`:
Get a list of supported commands
* `<enter>`:
Abandon entering an escape sequence command

View File

@ -1,4 +1,7 @@
from setuptools import setup
import os
scriptlist = ['bin/{0}'.format(d) for d in os.listdir('bin/')]
setup(
name='confluent_client',
@ -7,9 +10,6 @@ setup(
author_email='jjohnson2@lenovo.com',
url='http://xcat.sf.net/',
packages=['confluent'],
scripts=['bin/confetty', 'bin/nodeconsole', 'bin/nodeeventlog',
'bin/nodefirmware', 'bin/nodehealth', 'bin/nodeidentify',
'bin/nodeinventory', 'bin/nodelist', 'bin/nodepower',
'bin/nodesensors', 'bin/nodesetboot'],
scripts=scriptlist,
data_files=[('/etc/profile.d', ['confluent_env.sh'])],
)

View File

@ -0,0 +1,67 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import optparse
import sys
import os
path = os.path.dirname(os.path.realpath(__file__))
path = os.path.realpath(os.path.join(path, '..', 'lib', 'python'))
if path.startswith('/opt'):
# if installed into system path, do not muck with things
sys.path.append(path)
import confluent.config.configmanager as cfm
import confluent.config.conf as conf
import confluent.main as main
argparser = optparse.OptionParser(
usage="Usage: %prog [options] [dump|restore] [path]")
argparser.add_option('-p', '--password',
help='Password to use to protect/unlock a protected dump')
argparser.add_option('-r', '--redact', action='store_true',
help='Redact potentially sensitive data rather than store')
argparser.add_option('-u', '--unprotected', action='store_true',
help='Specify that no password should be used to protect'
' the key information. Fields will be encrypted, '
'but keys.json will contain unencrypted decryption'
' keys that may be used to read the dump')
(options, args) = argparser.parse_args()
if len(args) != 2 or args[0] not in ('dump', 'restore'):
argparser.print_help()
sys.exit(1)
dumpdir = args[1]
if args[0] == 'restore':
pid = main.is_running()
if pid is not None:
print("Confluent is running, must shut down to restore db")
sys.exit(1)
cfm.restore_db_from_directory(dumpdir, options.password)
elif args[0] == 'dump':
if options.password is None and not (options.unprotected or options.redact):
print("Must indicate a password to protect or -u to opt opt of "
"secure value protection or -r to skip all protected data")
sys.exit(1)
os.umask(077)
main._initsecurity(conf.get_config())
if not os.path.exists(dumpdir):
os.makedirs(dumpdir)
cfm.dump_db_to_directory(dumpdir, options.password, options.redact)

View File

@ -37,6 +37,7 @@ _passcache = {}
_passchecking = {}
authworkers = None
authcleaner = None
class Credentials(object):
@ -195,6 +196,13 @@ def check_user_passphrase(name, passphrase, element=None, tenant=False):
#such a beast could be passed into pyghmi as a way for pyghmi to
#magically get offload of the crypto functions without having
#to explicitly get into the eventlet tpool game
global authworkers
global authcleaner
if authworkers is None:
authworkers = multiprocessing.Pool(processes=1)
else:
authcleaner.cancel()
authcleaner = eventlet.spawn_after(30, _clean_authworkers)
crypted = eventlet.tpool.execute(_do_pbkdf, passphrase, salt)
del _passchecking[(user, tenant)]
eventlet.sleep(0.05) # either way, we want to stall so that client can't
@ -211,19 +219,16 @@ def _apply_pbkdf(passphrase, salt):
lambda p, s: hmac.new(p, s, hashlib.sha256).digest())
def _clean_authworkers():
global authworkers
global authcleaner
authworkers = None
authcleaner = None
def _do_pbkdf(passphrase, salt):
# we must get it over to the authworkers pool or else get blocked in
# compute. However, we do want to wait for result, so we have
# one of the exceedingly rare sort of circumstances where 'apply'
# actually makes sense
return authworkers.apply(_apply_pbkdf, [passphrase, salt])
def init_auth():
# have a some auth workers available. Keep them distinct from
# the general populace of workers to avoid unauthorized users
# starving out productive work
global authworkers
# for now we'll just have one auth worker and see if there is any
# demand for more. I personally doubt it.
authworkers = multiprocessing.Pool(processes=1)
return authworkers.apply(_apply_pbkdf, [passphrase, salt])

View File

@ -65,6 +65,7 @@ import anydbm as dbm
import ast
import base64
import confluent.config.attributes as allattributes
import confluent.config.conf as conf
import confluent.log
import confluent.util
import confluent.exceptions as exc
@ -128,6 +129,18 @@ def _get_protected_key(keydict, password, paramname):
raise exc.LockedCredentials("No available decryption key")
def _parse_key(keydata, password=None):
if keydata.startswith('*unencrypted:'):
return base64.b64decode(keydata[13:])
elif password:
salt, iv, crypt, hmac = [base64.b64decode(x)
for x in keydata.split('!')]
privkey, integkey = _derive_keys(password, salt)
return decrypt_value([iv, crypt, hmac], privkey, integkey)
raise(exc.LockedCredentials(
"Passphrase protected secret requires password"))
def _format_key(key, password=None):
if password is not None:
salt = os.urandom(32)
@ -707,6 +720,8 @@ class ConfigManager(object):
:param uid: Custom identifier number if desired. Defaults to random.
:param displayname: Optional long format name for UI consumption
"""
if 'idmap' not in _cfgstore['main']:
_cfgstore['main']['idmap'] = {}
if uid is None:
uid = _generate_new_id()
else:
@ -720,8 +735,6 @@ class ConfigManager(object):
self._cfgstore['users'][name] = {'id': uid}
if displayname is not None:
self._cfgstore['users'][name]['displayname'] = displayname
if 'idmap' not in _cfgstore['main']:
_cfgstore['main']['idmap'] = {}
_cfgstore['main']['idmap'][uid] = {
'tenant': self.tenant,
'username': name
@ -761,6 +774,14 @@ class ConfigManager(object):
decrypt=self.decrypt)
return nodeobj
def expand_attrib_expression(self, nodelist, expression):
if type(nodelist) in (unicode, str):
nodelist = (nodelist,)
for node in nodelist:
cfgobj = self._cfgstore['nodes'][node]
fmt = _ExpressionFormat(cfgobj, node)
yield (node, fmt.format(expression))
def get_node_attributes(self, nodelist, attributes=(), decrypt=None):
if decrypt is None:
decrypt = self.decrypt
@ -1179,6 +1200,70 @@ class ConfigManager(object):
self._bg_sync_to_file()
#TODO: wait for synchronization to suceed/fail??)
def _load_from_json(self, jsondata):
"""Load fresh configuration data from jsondata
:param jsondata: String of jsondata
:return:
"""
dumpdata = json.loads(jsondata)
tmpconfig = {}
for confarea in _config_areas:
if confarea not in dumpdata:
continue
tmpconfig[confarea] = {}
for element in dumpdata[confarea]:
newelement = copy.deepcopy(dumpdata[confarea][element])
for attribute in dumpdata[confarea][element]:
if newelement[attribute] == '*REDACTED*':
raise Exception(
"Unable to restore from redacted backup")
elif attribute == 'cryptpass':
passparts = newelement[attribute].split('!')
newelement[attribute] = tuple([base64.b64decode(x)
for x in passparts])
elif 'cryptvalue' in newelement[attribute]:
bincrypt = newelement[attribute]['cryptvalue']
bincrypt = tuple([base64.b64decode(x)
for x in bincrypt.split('!')])
newelement[attribute]['cryptvalue'] = bincrypt
elif attribute in ('nodes', '_expressionkeys'):
# A group with nodes
# delete it and defer until nodes are being added
# which will implicitly fill this up
# Or _expressionkeys attribute, which will similarly
# be rebuilt
del newelement[attribute]
tmpconfig[confarea][element] = newelement
# We made it through above section without an exception, go ahead and
# replace
# Start by erasing the dbm files if present
for confarea in _config_areas:
try:
os.unlink(os.path.join(self._cfgdir, confarea))
except OSError as e:
if e.errno == 2:
pass
# Now we have to iterate through each fixed up element, using the
# set attribute to flesh out inheritence and expressions
for confarea in _config_areas:
if confarea not in tmpconfig:
continue
if confarea == 'nodes':
self.set_node_attributes(tmpconfig[confarea], True)
elif confarea == 'nodegroups':
self.set_group_attributes(tmpconfig[confarea], True)
elif confarea == 'users':
for user in tmpconfig[confarea]:
uid = tmpconfig[confarea].get('id', None)
displayname = tmpconfig[confarea].get('displayname', None)
self.create_user(user, uid=uid, displayname=displayname)
if 'cryptpass' in tmpconfig[confarea][user]:
self._cfgstore['users'][user]['cryptpass'] = \
tmpconfig[confarea][user]['cryptpass']
_mark_dirtykey('users', user, self.tenant)
self._bg_sync_to_file()
def _dump_to_json(self, redact=None):
"""Dump the configuration in json form to output
@ -1336,28 +1421,80 @@ class ConfigManager(object):
self._recalculate_expressions(cfgobj[key], formatter, node,
changeset)
def _restore_keys(jsond, password, newpassword=None):
# the jsond from the restored file, password (if any) used to protect
# the file, and newpassword to use, (also check the service.cfg file)
global _masterkey
global _masterintegritykey
keydata = json.loads(jsond)
cryptkey = _parse_key(keydata['cryptkey'], password)
integritykey = _parse_key(keydata['integritykey'], password)
conf.init_config()
cfg = conf.get_config()
if cfg.has_option('security', 'externalcfgkey'):
keyfilename = cfg.get('security', 'externalcfgkey')
with open(keyfilename, 'r') as keyfile:
newpassword = keyfile.read()
set_global('master_privacy_key', _format_key(cryptkey,
password=newpassword))
set_global('master_integrity_key', _format_key(integritykey,
password=newpassword))
_masterkey = cryptkey
_masterintegritykey = integritykey
ConfigManager.wait_for_sync()
# At this point, we should have the key situation all sorted
def _dump_keys(password):
if _masterkey is None or _masterintegritykey is None:
init_masterkey()
cryptkey = _format_key(_masterkey, password=password)
cryptkey = '!'.join(map(base64.b64encode, cryptkey['passphraseprotected']))
if 'passphraseprotected' in cryptkey:
cryptkey = '!'.join(map(base64.b64encode,
cryptkey['passphraseprotected']))
else:
cryptkey = '*unencrypted:{0}'.format(base64.b64encode(
cryptkey['unencryptedvalue']))
integritykey = _format_key(_masterintegritykey, password=password)
integritykey = '!'.join(map(base64.b64encode, integritykey['passphraseprotected']))
if 'passphraseprotected' in integritykey:
integritykey = '!'.join(map(base64.b64encode,
integritykey['passphraseprotected']))
else:
integritykey = '*unencrypted:{0}'.format(base64.b64encode(
integritykey['unencryptedvalue']))
return json.dumps({'cryptkey': cryptkey, 'integritykey': integritykey},
sort_keys=True, indent=4, separators=(',', ': '))
def restore_db_from_directory(location, password):
try:
with open(os.path.join(location, 'keys.json'), 'r') as cfgfile:
keydata = cfgfile.read()
json.loads(keydata)
_restore_keys(keydata, password)
except IOError as e:
if e.errno == 2:
raise Exception("Cannot restore without keys, this may be a "
"redacted dump")
with open(os.path.join(location, 'main.json'), 'r') as cfgfile:
cfgdata = cfgfile.read()
ConfigManager(tenant=None)._load_from_json(cfgdata)
def dump_db_to_directory(location, password, redact=None):
with open(os.path.join(location, 'keys.json'), 'w') as cfgfile:
cfgfile.write(_dump_keys(password))
cfgfile.write('\n')
if not redact:
with open(os.path.join(location, 'keys.json'), 'w') as cfgfile:
cfgfile.write(_dump_keys(password))
cfgfile.write('\n')
with open(os.path.join(location, 'main.json'), 'w') as cfgfile:
cfgfile.write(ConfigManager(tenant=None)._dump_to_json(redact=redact))
cfgfile.write('\n')
try:
for tenant in os.listdir(
os.path.join(ConfigManager._cfgdir, '/tenants/')):
with open(os.path.join(location, tenant + '.json'), 'w') as cfgfile:
with open(os.path.join(location, 'tenants', tenant,
'main.json'), 'w') as cfgfile:
cfgfile.write(ConfigManager(tenant=tenant)._dump_to_json(
redact=redact))
cfgfile.write('\n')

View File

@ -122,6 +122,7 @@ def _init_core():
'attributes': {
'all': PluginRoute({'handler': 'attributes'}),
'current': PluginRoute({'handler': 'attributes'}),
'expression': PluginRoute({'handler': 'attributes'}),
},
'boot': {
'nextdevice': PluginRoute({

View File

@ -35,6 +35,7 @@ import eventlet.greenthread
import greenlet
import json
import socket
import sys
import traceback
import time
import urlparse
@ -233,6 +234,17 @@ def _csrf_valid(env, session):
# oblige the request and apply a new token to the
# session
session['csrftoken'] = util.randomstring(32)
elif 'HTTP_REFERER' in env:
# If there is a referrer, make sure it stays consistent
# across the session. A change in referer is a bad thing
try:
referer = env['HTTP_REFERER'].split('/')[2]
except IndexError:
return False
if 'validreferer' not in session:
session['validreferer'] = referer
elif session['validreferer'] != referer:
return False
return True
# The session has CSRF protection enabled, only mark valid if
# the client has provided an auth token and that token matches the
@ -257,15 +269,15 @@ def _authorize_request(env, operation):
sessionid = cc['confluentsessionid'].value
sessid = sessionid
if sessionid in httpsessions:
if env['PATH_INFO'] == '/sessions/current/logout':
targets = []
for mythread in httpsessions[sessionid]['inflight']:
targets.append(mythread)
for mythread in targets:
eventlet.greenthread.kill(mythread)
del httpsessions[sessionid]
return ('logout',)
if _csrf_valid(env, httpsessions[sessionid]):
if env['PATH_INFO'] == '/sessions/current/logout':
targets = []
for mythread in httpsessions[sessionid]['inflight']:
targets.append(mythread)
for mythread in targets:
eventlet.greenthread.kill(mythread)
del httpsessions[sessionid]
return ('logout',)
httpsessions[sessionid]['expiry'] = time.time() + 90
name = httpsessions[sessionid]['name']
authdata = auth.authorize(
@ -273,6 +285,12 @@ def _authorize_request(env, operation):
skipuserobj=httpsessions[sessionid]['skipuserobject'])
if (not authdata) and 'HTTP_AUTHORIZATION' in env:
if env['PATH_INFO'] == '/sessions/current/logout':
if 'HTTP_REFERER' in env:
# note that this doesn't actually do harm
# otherwise, but this way do not give appearance
# of something having a side effect if it has the smell
# of a CSRF
return {'code': 401}
return ('logout',)
name, passphrase = base64.b64decode(
env['HTTP_AUTHORIZATION'].replace('Basic ', '')).split(':', 1)
@ -369,7 +387,8 @@ def resourcehandler_backend(env, start_response):
"""Function to handle new wsgi requests
"""
mimetype, extension = _pick_mimetype(env)
headers = [('Content-Type', mimetype), ('Cache-Control', 'no-cache'),
headers = [('Content-Type', mimetype), ('Cache-Control', 'no-store'),
('Pragma', 'no-cache'),
('X-Content-Type-Options', 'nosniff'),
('Content-Security-Policy', "default-src 'self'"),
('X-XSS-Protection', '1'), ('X-Frame-Options', 'deny'),
@ -723,9 +742,20 @@ def serve(bind_host, bind_port):
#but deps are simpler without flup
#also, the potential for direct http can be handy
#todo remains unix domain socket for even http
eventlet.wsgi.server(
eventlet.listen((bind_host, bind_port, 0, 0), family=socket.AF_INET6),
resourcehandler, log=False, log_output=False, debug=False)
sock = None
while not sock:
try:
sock = eventlet.listen(
(bind_host, bind_port, 0, 0), family=socket.AF_INET6)
except socket.error as e:
if e.errno != 98:
raise
sys.stderr.write(
'Failed to open HTTP due to busy port, trying again in'
' a second\n')
eventlet.sleep(1)
eventlet.wsgi.server(sock, resourcehandler, log=False, log_output=False,
debug=False)
class HttpApi(object):

View File

@ -1,7 +1,7 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2014 IBM Corporation
# Copyright 2015 Lenovo
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -89,15 +89,41 @@ def _updatepidfile():
pidfile.close()
def is_running():
# Utility function for utilities to check if confluent is running
try:
pidfile = open('/var/run/confluent/pid', 'r+')
fcntl.flock(pidfile, fcntl.LOCK_SH)
pid = pidfile.read()
if pid != '':
try:
os.kill(int(pid), 0)
return pid
except OSError:
# There is no process running by that pid, must be stale
pass
fcntl.flock(pidfile, fcntl.LOCK_UN)
pidfile.close()
except IOError:
pass
return None
def _checkpidfile():
try:
pidfile = open('/var/run/confluent/pid', 'r+')
fcntl.flock(pidfile, fcntl.LOCK_EX)
pid = pidfile.read()
if pid != '':
print ('/var/run/confluent/pid exists and indicates %s is still '
'running' % pid)
sys.exit(1)
try:
os.kill(int(pid), 0)
print ('/var/run/confluent/pid exists and indicates %s is still '
'running' % pid)
sys.exit(1)
except OSError:
# There is no process running by that pid, must be stale
pass
pidfile.seek(0)
pidfile.write(str(os.getpid()))
fcntl.flock(pidfile, fcntl.LOCK_UN)
pidfile.close()
@ -196,13 +222,14 @@ def run():
_daemonize()
if havefcntl:
_updatepidfile()
auth.init_auth()
signal.signal(signal.SIGINT, terminate)
signal.signal(signal.SIGTERM, terminate)
#TODO(jbjohnso): eventlet has a bug about unix domain sockets, this code
#works with bugs fixed
if dbgif:
oumask = os.umask(0077)
try:
os.remove('/var/run/confluent/dbg.sock')
except OSError:
pass # We are not expecting the file to exist
dbgsock = eventlet.listen("/var/run/confluent/dbg.sock",
family=socket.AF_UNIX)
eventlet.spawn_n(backdoor.backdoor_server, dbgsock)

View File

@ -797,6 +797,11 @@ class PowerState(ConfluentChoiceMessage):
])
keyname = 'state'
def __init__(self, node, state, oldstate=None):
super(PowerState, self).__init__(node, state)
if oldstate is not None:
self.kvpairs[node]['oldstate'] = {'value': oldstate}
class BMCReset(ConfluentChoiceMessage):
valid_values = set([

View File

@ -152,6 +152,20 @@ def update_nodegroup(group, element, configmanager, inputdata):
return retrieve_nodegroup(group, element, configmanager, inputdata)
def _expand_expression(nodes, configmanager, inputdata):
expression = inputdata.get_attributes(list(nodes)[0])
if type(expression) is dict:
expression = expression['expression']
if type(expression) is dict:
expression = expression['expression']
for expanded in configmanager.expand_attrib_expression(nodes, expression):
yield msg.KeyValueData({'value': expanded[1]}, expanded[0])
def create(nodes, element, configmanager, inputdata):
if nodes is not None and element[-1] == 'expression':
return _expand_expression(nodes, configmanager, inputdata)
def update_nodes(nodes, element, configmanager, inputdata):
updatedict = {}
for node in nodes:

View File

@ -344,8 +344,8 @@ class IpmiHandler(object):
except socket.gaierror as ge:
if ge[0] == -2:
raise exc.TargetEndpointUnreachable(ge[1])
raise
self.ipmicmd = persistent_ipmicmds[(node, tenant)]
self.ipmicmd.setup_confluent_keyhandler()
bootdevices = {
'optical': 'cd'
@ -356,7 +356,9 @@ class IpmiHandler(object):
self.broken = True
self.error = response['error']
else:
self.ipmicmd = ipmicmd
self.loggedin = True
self.ipmicmd.setup_confluent_keyhandler()
self._logevt.set()
def handle_request(self):
@ -793,10 +795,22 @@ class IpmiHandler(object):
return
elif 'update' == self.op:
powerstate = self.inputdata.powerstate(self.node)
oldpower = None
if powerstate == 'boot':
oldpower = self.ipmicmd.get_power()
if 'powerstate' in oldpower:
oldpower = oldpower['powerstate']
self.ipmicmd.set_power(powerstate, wait=30)
power = self.ipmicmd.get_power()
if powerstate == 'boot' and oldpower == 'on':
power = {'powerstate': 'reset'}
else:
power = self.ipmicmd.get_power()
if powerstate == 'reset' and power['powerstate'] == 'on':
power['powerstate'] = 'reset'
self.output.put(msg.PowerState(node=self.node,
state=power['powerstate']))
state=power['powerstate'],
oldstate=oldpower))
return
def handle_reset(self):

View File

@ -170,7 +170,8 @@ def process_request(connection, request, cfm, authdata, authname, skipauth):
auditlog.log(auditmsg)
try:
if operation == 'start':
return start_term(authname, cfm, connection, params, path)
return start_term(authname, cfm, connection, params, path,
authdata, skipauth)
elif operation == 'shutdown':
configmanager.ConfigManager.shutdown()
else:
@ -187,7 +188,7 @@ def process_request(connection, request, cfm, authdata, authname, skipauth):
return
def start_term(authname, cfm, connection, params, path):
def start_term(authname, cfm, connection, params, path, authdata, skipauth):
elems = path.split('/')
if len(elems) < 4 or elems[1] != 'nodes':
raise exc.InvalidArgumentException('Invalid path {0}'.format(path))
@ -233,7 +234,9 @@ def start_term(authname, cfm, connection, params, path):
consession.reopen()
continue
else:
raise Exception("TODO")
process_request(connection, data, cfm, authdata, authname,
skipauth)
continue
if not data:
consession.destroy()
return
@ -244,7 +247,16 @@ def _tlshandler(bind_host, bind_port):
plainsocket = socket.socket(socket.AF_INET6)
plainsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
plainsocket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
plainsocket.bind((bind_host, bind_port, 0, 0))
bound = False
while not bound:
try:
plainsocket.bind((bind_host, bind_port, 0, 0))
bound = True
except socket.error as e:
if e.errno != 98:
raise
sys.stderr.write('TLS Socket in use, retrying in 1 second\n')
eventlet.sleep(1)
plainsocket.listen(5)
while (1): # TODO: exithook
cnn, addr = plainsocket.accept()

View File

@ -33,6 +33,9 @@ done
grep -v confluent/__init__.py INSTALLED_FILES.bare > INSTALLED_FILES
cat INSTALLED_FILES
%post
if [ -x /usr/bin/systemctl ]; then /usr/bin/systemctl try-restart confluent; fi
%clean
rm -rf $RPM_BUILD_ROOT

View File

@ -5,7 +5,7 @@ setup(
name='confluent_server',
version='#VERSION#',
author='Jarrod Johnson',
author_email='jbjohnso@us.ibm.com',
author_email='jjohnson2@lenovo.com',
url='http://xcat.sf.net/',
description='confluent systems management server',
packages=['confluent', 'confluent/config', 'confluent/interface',
@ -14,7 +14,7 @@ setup(
'confluent/plugins/configuration/'],
install_requires=['paramiko', 'pycrypto>=2.6', 'confluent_client>=0.1.0', 'eventlet',
'pyghmi>=0.6.5'],
scripts=['bin/confluent'],
scripts=['bin/confluent', 'bin/confluentdbutil'],
data_files=[('/etc/init.d', ['sysvinit/confluent']),
('/usr/lib/systemd/system', ['systemd/confluent.service']),
('/opt/confluent/lib/python/confluent/plugins/console/', [])],