Add basic cli

This commit is contained in:
dave 2019-01-26 15:10:57 -08:00
parent 3980153489
commit da64324005
5 changed files with 195 additions and 4 deletions

119
nodepupper/cli.py Normal file
View File

@ -0,0 +1,119 @@
from appdirs import user_config_dir
import os
import json
import yaml
import argparse
import requests
import tempfile
import subprocess
APPNAME = "npcli"
CONFDIR = user_config_dir(APPNAME)
CONFPATH = os.path.join(CONFDIR, "conf.json")
def editorloop(fpath, validator):
"""
Open the editor until the user provides valid yaml.
Raises if we fail to edit the file.
"""
content = ""
while True:
subprocess.check_call([os.environ["EDITOR"], fpath]) # XXX commented for testing
with open(fpath) as f:
content = f.read()
try:
yaml.load(content)
break
except Exception as e:
print(e)
if input("Reopen editor? Y/n: ").lower() in ["", "y"]:
continue
else:
raise
return content
def main():
conf = {"host": "", "username": "", "password": ""}
if os.path.exists(CONFPATH):
with open(CONFPATH) as cf:
conf = json.load(cf)
else:
os.makedirs(CONFDIR, exist_ok=True)
with open(CONFPATH, "w") as cf:
json.dump(conf, cf)
parser = argparse.ArgumentParser(description="Nodepupper cli",
epilog="host/username/password will be saved to {} "
"after first use.".format(CONFPATH))
parser.add_argument("--host", default=conf["host"], help="http/s host to connect to")
# parser.add_argument("-u", "--username", help="username")
# parser.add_argument("-p", "--password", help="password")
spr_action = parser.add_subparsers(dest="action", help="action to take")
spr_action.add_parser("classlist", help="show list of classes")
spr_new = spr_action.add_parser("new", help="create a node")
spr_new.add_argument("node", help="name of node to create")
spr_edit = spr_action.add_parser("edit", help="edit a node")
spr_edit.add_argument("node", help="name of node to edit")
spr_del = spr_action.add_parser("del", help="delete a node")
spr_del.add_argument("node", help="name of node to delete")
spr_addc = spr_action.add_parser("addclass", help="add a class")
spr_addc.add_argument("cls", help="name of class to add")
spr_delc = spr_action.add_parser("delclass", help="delete a class")
spr_delc.add_argument("cls", help="name of class to delete")
args = parser.parse_args()
r = requests.session()
def getnode(nodename):
req = r.get(args.host.rstrip("/") + "/api/node/" + nodename)
req.raise_for_status()
return req.text
def putnode(nodename, body):
return r.put(args.host.rstrip("/") + "/api/node/" + nodename, data=body)
if args.action == "new":
putnode(args.node, yaml.dump({"body": {}, "classes": {}, "parents": []})).raise_for_status()
elif args.action == "del":
r.delete(args.host.rstrip("/") + "/api/node/" + args.node).raise_for_status()
elif args.action == "edit":
# TODO refuse if editor is unset
body = getnode(args.node)
newbody = None
with tempfile.TemporaryDirectory() as d:
tmppath = os.path.join(d, args.node)
with open(tmppath, "w") as f:
f.write(body)
newbody = editorloop(tmppath, lambda content: yaml.load(content))
if newbody != body:
try:
putnode(args.node, newbody).raise_for_status()
except Exception:
print("Your edits:\n")
print(newbody, "\n\n")
raise
else:
print("No changes, exiting")
elif args.action == "classlist":
print(r.get(args.host.rstrip("/") + "/api/class").text)
elif args.action == "addclass":
r.put(args.host.rstrip("/") + "/api/class/" + args.cls).raise_for_status()
elif args.action == "delclass":
r.delete(args.host.rstrip("/") + "/api/class/" + args.cls).raise_for_status()
if __name__ == "__main__":
main()

View File

@ -53,6 +53,10 @@ def recurse_classes(node):
return classes
def yamldump(data):
return yaml.dump(data, default_flow_style=False)
class AppWeb(object):
def __init__(self, nodedb, template_dir):
self.nodes = nodedb
@ -118,7 +122,7 @@ class AppWeb(object):
if preview:
yield "<plaintext>"
yield "---\n"
yield yaml.dump(doc, default_flow_style=False)
yield yamldump(doc)
@cherrypy.expose
def login(self):
@ -145,6 +149,61 @@ class AppWeb(object):
yield self.render("error.html", status=status, message=message, traceback=traceback)
@cherrypy.expose
@cherrypy.popargs("node")
class NodesApi(object):
def __init__(self, nodedb):
self.nodes = nodedb
def GET(self, node):
with self.nodes.db.transaction() as c:
node = c.root.nodes[node]
output = {
"body": yaml.load(node.body),
"parents": node.parent_names(),
"classes": {clsname: yaml.load(clsa.conf) for clsname, clsa in node.classes.items()}
}
yield yamldump(output)
def PUT(self, node):
nodeyaml = yaml.load(cherrypy.request.body.read().decode('utf-8'))
with self.nodes.db.transaction() as c:
newnode = NObject(node, yamldump(nodeyaml["body"]))
for clsname, clsbody in nodeyaml["classes"].items():
newnode.classes[clsname] = NClassAttachment(c.root.classes[clsname], yamldump(clsbody))
for parent in nodeyaml["parents"]:
newnode.parents.append(c.root.nodes[parent])
c.root.nodes[node] = newnode
def DELETE(self, node):
with self.nodes.db.transaction() as c:
del c.root.nodes[node]
@cherrypy.expose
@cherrypy.popargs("cls")
class ClassesApi(object):
def __init__(self, nodedb):
self.nodes = nodedb
def GET(self, cls=None):
with self.nodes.db.transaction() as c:
clslist = list(c.root.classes.keys())
clslist.sort()
yield yamldump({"classes": clslist})
def PUT(self, cls):
with self.nodes.db.transaction() as c:
c.root.classes[cls] = NClass(cls)
def DELETE(self, cls):
with self.nodes.db.transaction() as c:
for node in c.root.nodes.values():
if cls in node.class_names():
raise Exception("Class is in use by '{}'".format(node.fqdn))
del c.root.classes[cls]
@cherrypy.popargs("node")
class NodesWeb(object):
def __init__(self, root):
@ -220,10 +279,11 @@ def main():
tpl_dir = os.path.join(APPROOT, "templates") if not args.debug else "templates"
web = AppWeb(library, tpl_dir)
napi = NodesApi(library)
capi = ClassesApi(library)
def validate_password(realm, username, password):
s = library.session()
if s.query(User).filter(User.name == username, User.password == pwhash(password)).first():
if 1:
return True
return False
@ -236,6 +296,8 @@ def main():
'/login': {'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'webapp',
'tools.auth_basic.checkpassword': validate_password}})
cherrypy.tree.mount(napi, '/api/node', {'/': {'request.dispatch': cherrypy.dispatch.MethodDispatcher()}})
cherrypy.tree.mount(capi, '/api/class', {'/': {'request.dispatch': cherrypy.dispatch.MethodDispatcher()}})
cherrypy.config.update({
'tools.sessions.on': True,

View File

@ -24,6 +24,9 @@ class NObject(persistent.Persistent):
def parent_names(self):
return [n.fqdn for n in self.parents]
def class_names(self):
return list(self.classes.keys())
class NClass(persistent.Persistent):
def __init__(self, name):

View File

@ -1,7 +1,11 @@
appdirs==1.4.3
backports.functools-lru-cache==1.5
BTrees==4.5.1
certifi==2018.11.29
chardet==3.0.4
cheroot==6.5.2
CherryPy==18.0.1
idna==2.8
jaraco.functools==1.20
Jinja2==2.10
MarkupSafe==1.0
@ -10,9 +14,11 @@ persistent==4.4.2
portend==2.3
pytz==2018.5
PyYAML==3.13
requests==2.21.0
six==1.11.0
tempora==1.13
transaction==2.2.1
urllib3==1.24.1
zc.lockfile==1.3.0
ZConfig==3.3.0
ZODB==5.4.0

View File

@ -16,7 +16,8 @@ setup(name='nodepupper',
install_requires=[],
entry_points={
"console_scripts": [
"nodepupperd = nodepupper.daemon:main"
"nodepupperd = nodepupper.daemon:main",
"npcli = nodepupper.cli:main"
]
},
include_package_data=True,