diff --git a/nodepupper/cli.py b/nodepupper/cli.py new file mode 100644 index 0000000..ad6eb98 --- /dev/null +++ b/nodepupper/cli.py @@ -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() diff --git a/nodepupper/daemon.py b/nodepupper/daemon.py index 4403a82..dd1017a 100644 --- a/nodepupper/daemon.py +++ b/nodepupper/daemon.py @@ -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 "" 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, diff --git a/nodepupper/nodeops.py b/nodepupper/nodeops.py index 8f901fe..b69229f 100644 --- a/nodepupper/nodeops.py +++ b/nodepupper/nodeops.py @@ -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): diff --git a/requirements.txt b/requirements.txt index a8561ff..9fc62a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/setup.py b/setup.py index 12300b0..534135c 100644 --- a/setup.py +++ b/setup.py @@ -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,