#!/usr/bin/env python3 """A small command line client for the nomore404 API. #386 One file, no dependencies, Python 3.9 or newer: copy it onto a server and run it. That is the whole design. Everything it does is a call to /api/v1, so it can do exactly what your token can do and nothing else, and anything it refuses was refused by the API rather than by this script. export N404_TOKEN=n404_... ./nomore404.py whoami ./nomore404.py monitors list ./nomore404.py monitors add --type https --target shop.example.com ./nomore404.py status ./nomore404.py incidents --open The token comes from N404_TOKEN in the environment, or from a `nomore404.env` file beside the script or in the current directory, written the way the probe's `probe.env` is: N404_TOKEN=n404_... N404_URL=https://nomore404.com Not from a command line flag. An argument is visible in `ps` to every user on the machine and lands in shell history; a file can be chmod 600 and an environment variable is what a CI system already has a place for. """ from __future__ import annotations import argparse import json import os import stat import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path DEFAULT_URL = "https://nomore404.com" ENV_FILE = "nomore404.env" TIMEOUT_S = 30 # Where a listing stops. The API pages at 200, and following every page is # what somebody wants from a command line; a runaway is bounded by this. MAX_PAGES = 100 class Problem(Exception): """Something the person can read and act on. Never a traceback.""" def _env_file() -> dict: """`nomore404.env` beside the script, or in the current directory. Beside the script first, because that is the copy somebody deliberately put next to the tool; the working directory is wherever they happen to be standing. """ for folder in (Path(__file__).resolve().parent, Path.cwd()): path = folder / ENV_FILE if not path.is_file(): continue if path.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO): print(f"warning: {path} is readable by other users on this " f"machine. chmod 600 it: it holds a credential.", file=sys.stderr) found = {} for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") found[key.strip()] = value.strip().strip('"').strip("'") return found return {} def settings() -> tuple[str, str]: """The token and the site, from the environment or the file. The environment wins, so a CI job or a one-off `N404_TOKEN=... ./…` does not need the file moved out of the way. """ from_file = _env_file() token = os.environ.get("N404_TOKEN") or from_file.get("N404_TOKEN", "") url = (os.environ.get("N404_URL") or from_file.get("N404_URL") or DEFAULT_URL).rstrip("/") if not token: raise Problem( "No token. Put N404_TOKEN in the environment, or in a " f"{ENV_FILE} file beside this script:\n" f" echo 'N404_TOKEN=n404_...' > {ENV_FILE} && chmod 600 {ENV_FILE}\n" "Make one at Account, API tokens.") return token, url def call(method: str, path: str, params: dict | None = None, body: dict | None = None) -> dict: """One request, and the API's own sentence when it refuses. The error body is the same shape everywhere, so a refusal is printed as written rather than restated here: two wordings for one rule is one wording too many, and the one in the script would be the stale one. """ token, base = settings() query = urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v is not None}) url = f"{base}/api/v1{path}" + (f"?{query}" if query else "") data = json.dumps(body).encode() if body is not None else None request = urllib.request.Request(url, data=data, method=method, headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", **({"Content-Type": "application/json"} if data else {}), }) try: with urllib.request.urlopen(request, timeout=TIMEOUT_S) as answer: raw = answer.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: raise Problem(_refusal(e)) from None except urllib.error.URLError as e: raise Problem(f"Could not reach {base}: {e.reason}") from None def _refusal(error) -> str: try: said = json.loads(error.read())["error"] except Exception: return f"{error.code} from the API, with nothing readable in it." if error.code == 429: wait = error.headers.get("Retry-After", "a moment") return f"{said['message']} (wait {wait} seconds)" return said["message"] def every(path: str, params: dict | None = None): """Every page of a listing, so nothing here has to know about cursors.""" params = dict(params or {}) params.setdefault("limit", 200) for _ in range(MAX_PAGES): page = call("GET", path, params) yield from page["items"] cursor = page.get("next_cursor") if not cursor: return params["cursor"] = cursor # --------------------------------------------------------------------------- # Printing. Readable by default, JSON when something else is reading. # --------------------------------------------------------------------------- def show(rows, columns, as_json: bool) -> None: if as_json: print(json.dumps(rows, indent=2)) return rows = list(rows) if not rows: print("Nothing here.") return widths = [max(len(str(c)), max(len(str(r.get(c, "") or "")) for r in rows)) for c in columns] for row in rows: print(" ".join(str(row.get(c, "") or "").ljust(w) for c, w in zip(columns, widths)).rstrip()) def _monitor_row(monitor: dict) -> dict: return {"id": monitor["id"], "name": monitor["name"], "type": monitor["type"], "target": monitor["target"], "state": "paused" if not monitor["enabled"] else "on"} # --------------------------------------------------------------------------- # The commands # --------------------------------------------------------------------------- def cmd_whoami(args) -> int: me = call("GET", "/me") if args.json: print(json.dumps(me, indent=2)) else: print(f"{me['organisation']['name']} ({me['organisation']['plan']}), " f"as {me['role']}, token {me['token_name']!r} may " f"{'read and write' if me['scope'] == 'read_write' else 'read'}") return 0 def cmd_monitors_list(args) -> int: show([_monitor_row(m) for m in every("/monitors")], ["id", "state", "type", "name", "target"], args.json) return 0 def cmd_monitors_add(args) -> int: body = {"type": args.type, "target": args.target} if args.name: body["name"] = args.name if args.interval: body["interval_s"] = args.interval if args.group: body["group"] = args.group if not args.affects_group_status: body["affects_group_status"] = False made = call("POST", "/monitors", body=body) print(json.dumps(made, indent=2) if args.json else f"{made['id']} {made['name']}") return 0 def cmd_monitors_show(args) -> int: monitor = call("GET", f"/monitors/{args.id}") print(json.dumps(monitor, indent=2) if args.json else "\n".join(f"{k}: {v}" for k, v in _monitor_row(monitor).items())) return 0 def cmd_monitors_set(args) -> int: """Change one. Only what was given, because the API takes only what it is sent and an omitted field is not a cleared field.""" body = {} if args.name is not None: body["name"] = args.name if args.target is not None: body["target"] = args.target if args.interval is not None: body["interval_s"] = args.interval if args.config is not None: try: body["config"] = json.loads(args.config) except json.JSONDecodeError as e: raise Problem(f"--config is not JSON: {e}") from None if args.group: body["group"] = args.group if args.ungroup: body["ungroup"] = True if args.affects_group_status is not None: body["affects_group_status"] = args.affects_group_status if not body: raise Problem("Nothing to change. Give at least one of --name, " "--target, --interval, --config, --group, --ungroup " "or --rollup.") changed = call("PATCH", f"/monitors/{args.id}", body=body) print(json.dumps(changed, indent=2) if args.json else "\n".join(f"{k}: {v}" for k, v in _monitor_row(changed).items())) return 0 def cmd_monitors_pause(args) -> int: changed = call("PATCH", f"/monitors/{args.id}", body={"enabled": args.resume}) print("on" if changed["enabled"] else "paused") return 0 def cmd_monitors_rm(args) -> int: """Deleting takes the monitor's history with it, so it asks first. The confirmation is here and not in the API on purpose: the API is for programs, which have no one to ask, and this is for a person at a keyboard who may have pasted the wrong id. """ if not args.yes: monitor = call("GET", f"/monitors/{args.id}") answer = input(f"Delete {monitor['name']!r} and its history? [y/N] ") if answer.strip().lower() not in ("y", "yes"): print("Left alone.") return 1 call("DELETE", f"/monitors/{args.id}") print("Deleted.") return 0 def cmd_status(args) -> int: state = call("GET", f"/monitors/{args.id}/status", {"days": args.days}) if args.json: print(json.dumps(state, indent=2)) return 0 uptime = state["uptime"] figure = "no data yet" if uptime["percent"] is None else f"{uptime['percent']:.2f}%" print(f"{state['status']}, {figure} over {uptime['days']} days " f"({int(uptime['down_seconds'])}s down)") # Non-zero when it is not up, so a shell can act on it. return 0 if state["status"] in ("up", "no_data") else 2 def cmd_incidents(args) -> int: """The list, or one of them when an id is given. One command rather than two, because `incidents` and `incidents ` is how somebody reads them: the list, then the one that looks wrong. """ if args.id: one = call("GET", f"/incidents/{args.id}") if args.json: print(json.dumps(one, indent=2)) return 0 seen = (one["seen_from"] or {}).get("name", "nowhere") confirmed = (one["confirmed_from"] or {}).get("name") print(f"{one['monitor_name']}: {one['cause']}") print(f"started {one['started_at']}, " + (f"resolved {one['resolved_at']}" if not one["open"] else "still open")) print(f"seen from {seen}" + (f", confirmed from {confirmed}" if confirmed else "")) if one["note"]: print(f"note: {one['note']}") return 0 rows = [{"started": i["started_at"], "monitor": i["monitor_name"], "cause": i["cause"], "state": "open" if i["open"] else "resolved", "seen": (i["seen_from"] or {}).get("continent", ""), "id": i["id"]} for i in every("/incidents", { "state": args.state, "days": args.days, "monitor": args.monitor})] show(rows, ["started", "state", "monitor", "cause", "seen", "id"], args.json) return 0 def cmd_maintenance(args) -> int: if args.id: window = call("GET", f"/maintenance/{args.id}") if args.json: print(json.dumps(window, indent=2)) return 0 print(f"{window['name']}: {window['starts_at']} to {window['ends_at']}") if window["rrule"]: print(f"repeats: {window['rrule']}") print(f"covers {len(window['monitors'])} monitors, " f"{len(window['groups'])} groups") return 0 rows = [{"starts": w["starts_at"], "ends": w["ends_at"], "name": w["name"], "monitors": len(w["monitors"]), "id": w["id"]} for w in every("/maintenance")] show(rows, ["starts", "ends", "name", "monitors", "id"], args.json) return 0 def _group_row(group: dict) -> dict: return {"id": group["id"], "name": group["name"], "monitors": group["monitors"], "note": group["description"] or ""} def cmd_groups(args) -> int: """The list, or one of them when an id is given.""" if args.id: group = call("GET", f"/groups/{args.id}") print(json.dumps(group, indent=2) if args.json else "\n".join(f"{k}: {v}" for k, v in _group_row(group).items())) return 0 show([_group_row(g) for g in every("/groups")], ["id", "monitors", "name", "note"], args.json) return 0 def cmd_groups_add(args) -> int: body = {"name": args.name} if args.description: body["description"] = args.description made = call("POST", "/groups", body=body) print(json.dumps(made, indent=2) if args.json else f"{made['id']} {made['name']}") return 0 def cmd_groups_set(args) -> int: body = {} if args.name is not None: body["name"] = args.name if args.description is not None: body["description"] = args.description if not body: raise Problem("Nothing to change. Give --name or --description.") changed = call("PATCH", f"/groups/{args.id}", body=body) print(json.dumps(changed, indent=2) if args.json else f"{changed['id']} {changed['name']}") return 0 def cmd_groups_rm(args) -> int: """Deleting a group keeps its monitors, so this asks less insistently than deleting a monitor does: nothing is watched less afterwards.""" if not args.yes: group = call("GET", f"/groups/{args.id}") answer = input(f"Delete the group {group['name']!r}? Its " f"{group['monitors']} monitors stay. [y/N] ") if answer.strip().lower() not in ("y", "yes"): print("Left alone.") return 1 call("DELETE", f"/groups/{args.id}") print("Deleted. Its monitors are ungrouped.") return 0 def parser() -> argparse.ArgumentParser: main = argparse.ArgumentParser( prog="nomore404", description=__doc__.split("\n\n")[1], formatter_class=argparse.RawDescriptionHelpFormatter) main.add_argument("--json", action="store_true", help="the API's own JSON, for a script to read") sub = main.add_subparsers(dest="command", required=True) sub.add_parser("whoami", help="which organisation this token acts for") \ .set_defaults(run=cmd_whoami) monitors = sub.add_parser("monitors", help="what is watched").add_subparsers( dest="what", required=True) monitors.add_parser("list", help="every monitor").set_defaults( run=cmd_monitors_list) add = monitors.add_parser("add", help="watch something new") add.add_argument("--type", default="https", help="https, ssl_cert, dns, smtp, ping, tcp, heartbeat, " "and the rest (default: https)") add.add_argument("--target", default="", help="the address, host or name to check") add.add_argument("--name", default="", help="what to call it") add.add_argument("--interval", type=int, default=None, help="seconds between checks") add.add_argument("--group", default=None, help="put it in this group, by the group's id") add.add_argument("--no-rollup", dest="affects_group_status", action="store_false", help="keep it out of its group's status") add.set_defaults(run=cmd_monitors_add) one = monitors.add_parser("show", help="one monitor") one.add_argument("id") one.set_defaults(run=cmd_monitors_show) change = monitors.add_parser("set", help="change one") change.add_argument("id") change.add_argument("--name", default=None) change.add_argument("--target", default=None, help="the address, host or name to check") change.add_argument("--interval", type=int, default=None, help="seconds between checks") change.add_argument("--config", default=None, help="the type's own settings, as JSON") change.add_argument("--group", default=None, help="move it to this group") change.add_argument("--ungroup", action="store_true", help="take it out of its group") change.add_argument("--rollup", dest="affects_group_status", action="store_true", default=None, help="count it towards its group's status") change.add_argument("--no-rollup", dest="affects_group_status", action="store_false", help="do not count it towards the status") change.set_defaults(run=cmd_monitors_set) pause = monitors.add_parser("pause", help="stop checking it for now") pause.add_argument("id") pause.set_defaults(run=cmd_monitors_pause, resume=False) resume = monitors.add_parser("resume", help="start checking it again") resume.add_argument("id") resume.set_defaults(run=cmd_monitors_pause, resume=True) remove = monitors.add_parser("rm", help="delete it, and its history") remove.add_argument("id") remove.add_argument("--yes", action="store_true", help="do not ask") remove.set_defaults(run=cmd_monitors_rm) status = sub.add_parser("status", help="is it up, and how has it been") status.add_argument("id") status.add_argument("--days", type=int, default=30) status.set_defaults(run=cmd_status) incidents = sub.add_parser("incidents", help="outages, or one of them") incidents.add_argument("id", nargs="?", help="one incident's id") incidents.add_argument("--state", default="all", choices=("all", "open", "resolved"), help="default: all") incidents.add_argument("--days", type=int, default=30) incidents.add_argument("--monitor", default=None, help="one monitor's id") incidents.set_defaults(run=cmd_incidents) # Subcommands all the way down, like `monitors` and unlike `incidents`. # `groups` started as a bare listing with an optional id, the way # `incidents` is, and argparse cannot have both: with subcommands beside # it, `groups ` reads the id as a subcommand name and refuses it. groups = sub.add_parser("groups", help="folders with a rollup").add_subparsers( dest="what", required=True) groups.add_parser("list", help="every group").set_defaults( run=cmd_groups, id=None) g_show = groups.add_parser("show", help="one group") g_show.add_argument("id") g_show.set_defaults(run=cmd_groups) g_add = groups.add_parser("add", help="make one") g_add.add_argument("name") g_add.add_argument("--description", default=None) g_add.set_defaults(run=cmd_groups_add, id=None) g_set = groups.add_parser("set", help="rename one") g_set.add_argument("id") g_set.add_argument("--name", default=None) g_set.add_argument("--description", default=None) g_set.set_defaults(run=cmd_groups_set) g_rm = groups.add_parser("rm", help="delete it, keeping its monitors") g_rm.add_argument("id") g_rm.add_argument("--yes", action="store_true", help="do not ask") g_rm.set_defaults(run=cmd_groups_rm) windows = sub.add_parser("maintenance", help="scheduled windows, or one") windows.add_argument("id", nargs="?", help="one window's id") windows.set_defaults(run=cmd_maintenance) return main def main(argv=None) -> int: args = parser().parse_args(argv) try: return args.run(args) except Problem as e: print(f"{e}", file=sys.stderr) return 1 except KeyboardInterrupt: return 130 if __name__ == "__main__": sys.exit(main())