Skip to content

Commit

Permalink
Replace telnet shell with HTTP API
Browse files Browse the repository at this point in the history
  • Loading branch information
Johann Bahl committed Aug 31, 2023
1 parent a342060 commit 0d24394
Show file tree
Hide file tree
Showing 18 changed files with 1,446 additions and 582 deletions.
7 changes: 0 additions & 7 deletions README.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,6 @@ configurable.
Features
========

Telnet shell
------------

Telnet into localhost port 6023 to get an interactive console. The console can
currently be used to inspect the scheduler's live status.


Self-check
----------

Expand Down
5 changes: 5 additions & 0 deletions changelog.d/20230626_005413_jb_replace_telnet_with_http.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Replace prettytable with rich

- Replace telnet shell with HTTP API

- Migrate `backy check` to `backy client check` and use the new HTTP API
16 changes: 14 additions & 2 deletions doc/backy.conf.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
global:
base-dir: /my/backydir
worker-limit: 3
api:
addrs: "127.0.0.1, ::1"
port: 1234
tokens:
"test-token": "test-server"
"cli-token": "cli"
cli-default:
token: "cli-token"
peers:
"test-server":
url: "https://example.com:1234"
token: "token2"
schedules:
default:
daily:
Expand Down Expand Up @@ -37,8 +49,8 @@ jobs:
test03:
schedule: hourly
source:
type: flyingcircus,
type: flyingcircus
consul_acl_token: a211c244-846b-11e5-999b-081196cf15b4
vm: test03,
vm: test03
pool: test
image: test03.root
26 changes: 4 additions & 22 deletions doc/man-backy.rst
Original file line number Diff line number Diff line change
Expand Up @@ -241,24 +241,6 @@ environment variables like **CEPH_CLUSTER** or **CEPH_ARGS**.
**backy scheduler** processes exit cleanly on SIGTERM.


Telnet shell
------------

The schedules opens a telnet server (default: localhost port 6023) for live
inspection. The telnet interface accepts the following commands:

jobs [REGEX]
Prints an overview of all configured jobs together with their last and
next backup run. An optional (extended) regular expression restricts output
to matching job names.

status
Dumps internal server status details.

quit
Exits the telnet shell.


Files
-----

Expand Down Expand Up @@ -288,12 +270,12 @@ config
status-interval
Update status file every N seconds (default: 30).

telnet-addrs
Comma-separated list of listen addresses for the telnet server
api-addrs
Comma-separated list of listen addresses for the api server
(default: 127.0.0.1, ::1).

telnet-port
Port number of the telnet server (default: 6023).
api-port
Port number of the api server (default: 6023).

.. _schedules:

Expand Down
3 changes: 0 additions & 3 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@
scriv = super.scriv.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ super.setuptools ];
});
telnetlib3 = super.telnetlib3.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ super.setuptools ];
});
execnet = super.execnet.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ super.hatchling super.hatch-vcs ];
});
Expand Down
504 changes: 466 additions & 38 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,19 @@ consulate-fc-nix-test = "1.1.0a1"
humanize = "^4.8.0"
mmh3 = "^4.0"
packaging = "^23.1"
prettytable = "^3.6.0"
python-lzo = "^1.15"
requests = "^2.31.0"
shortuuid = "^1.0.11"
structlog = "^23.1.0"
telnetlib3 = "^2.0.0"
tzlocal = "^5.0"
colorama = "^0.4.6"
aiohttp = "^3.8.4"
rich = "^13.3.2"

[tool.poetry.dev-dependencies]
pre-commit = "^3.3.3"
pytest = "^7.4.0"
pytest-aiohttp = "^1.0.4"
pytest-asyncio = "^0.21.1"
pytest-cache = "^1.0"
pytest-cov = "^4.1.0"
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@ def version():
install_requires=[
"consulate",
"packaging",
"prettytable",
"tzlocal",
"PyYaml",
"setuptools",
"shortuuid",
"python-lzo",
"telnetlib3>=1.0",
"humanize",
"mmh3",
"structlog",
"aiohttp",
"rich",
],
extras_require={
"test": [
Expand Down
156 changes: 156 additions & 0 deletions src/backy/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import datetime
import re
from json import JSONEncoder
from os import path as p
from typing import Any, List, Tuple

from aiohttp import hdrs, web
from aiohttp.web_exceptions import (
HTTPAccepted,
HTTPBadRequest,
HTTPForbidden,
HTTPNotFound,
HTTPPreconditionFailed,
HTTPPreconditionRequired,
HTTPServiceUnavailable,
HTTPUnauthorized,
)
from aiohttp.web_middlewares import middleware
from aiohttp.web_runner import AppRunner, TCPSite
from structlog.stdlib import BoundLogger

import backy.daemon
from backy.backup import Backup
from backy.revision import Revision


class BackyJSONEncoder(JSONEncoder):
def default(self, o: Any) -> Any:
if hasattr(o, "to_dict"):
return o.to_dict()
elif isinstance(o, datetime.datetime):
return o.isoformat()
else:
super().default(o)


class BackyAPI:
daemon: "backy.daemon.BackyDaemon"
sites: dict[Tuple[str, int], TCPSite]
runner: AppRunner
tokens: dict
log: BoundLogger

def __init__(self, daemon, log):
self.log = log.bind(subsystem="api")
self.daemon = daemon
self.sites = {}
self.app = web.Application(
middlewares=[self.log_conn, self.require_auth, self.to_json]
)
self.app.add_routes(
[
web.get("/v1/status", self.get_status),
web.post("/v1/reload", self.reload_daemon),
web.get("/v1/jobs", self.get_jobs),
# web.get("/v1/jobs/{job_name}", self.get_job),
web.post("/v1/jobs/{job_name}/run", self.run_job),
]
)

async def start(self):
self.runner = AppRunner(self.app)
await self.runner.setup()

async def stop(self):
await self.runner.cleanup()
self.sites = {}

async def reconfigure(
self, tokens: dict[str, str], addrs: List[str], port: int
):
self.log.debug("reconfigure")
self.tokens = tokens
endpoints = [(addr, port) for addr in addrs if addr and port]
for ep in endpoints:
if ep not in self.sites:
self.sites[ep] = site = TCPSite(self.runner, ep[0], ep[1])
await site.start()
self.log.info("added-site", site=site.name)
for ep, site in self.sites.items():
if ep not in endpoints:
await site.stop()
del self.sites[ep]
self.log.info("deleted-site", site=site.name)

@middleware
async def log_conn(self, request: web.Request, handler):
request["log"] = self.log.bind(
path=request.path, query=request.query_string
)
try:
resp = await handler(request)
except Exception as e:
if not isinstance(e, web.HTTPException):
request["log"].exception("error-handling-request")
else:
request["log"].debug(
"request-result", status_code=e.status_code
)
raise
request["log"].debug("request-result", response=resp.body)
return resp

@middleware
async def require_auth(self, request: web.Request, handler):
request["log"].debug("new-conn")
token = request.headers.get(hdrs.AUTHORIZATION, "")
if not token.startswith("Bearer "):
request["log"].info("auth-invalid-token")
raise HTTPUnauthorized()
token = token.removeprefix("Bearer ")
if len(token) < 3: # avoid potential truthiness edge cases
request["log"].info("auth-token-too-short")
raise HTTPUnauthorized()
client = self.tokens.get(token, None)
if not client:
request["log"].info("auth-token-unknown")
raise HTTPUnauthorized()
request["client"] = client
request["log"] = request["log"].bind(client=client)
request["log"].debug("auth-passed")
return await handler(request)

@middleware
async def to_json(self, request: web.Request, handler):
resp = await handler(request)
if isinstance(resp, web.Response):
return resp
elif resp is None:
raise web.HTTPNoContent()
else:
return web.json_response(resp, dumps=BackyJSONEncoder().encode)

async def get_status(self, request: web.Request):
filter = request.query.get("filter", "")
if filter:
filter = re.compile(filter)
return self.daemon.status(filter)

async def reload_daemon(self, request: web.Request):
self.daemon.reload()

async def get_jobs(self, request: web.Request):
return list(self.daemon.jobs.values())

async def get_job(self, request: web.Request):
try:
name = request.match_info.get("job_name", None)
return self.daemon.jobs[name]
except KeyError:
raise HTTPNotFound()

async def run_job(self, request: web.Request):
j = await self.get_job(request)
j.run_immediately.set()
raise HTTPAccepted()
Loading

0 comments on commit 0d24394

Please sign in to comment.