Skip to content

Commit

Permalink
Use token files and report health status
Browse files Browse the repository at this point in the history
  • Loading branch information
icgood committed Oct 29, 2020
1 parent 4e6850d commit 235179d
Show file tree
Hide file tree
Showing 22 changed files with 252 additions and 85 deletions.
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Everything runs in an [asyncio][2] event loop.

## Install and Usage

```bash
```console
$ pip install pymap
$ pymap --help
$ pymap dict --help
Expand All @@ -46,7 +46,7 @@ the server is restarted.

You can try out the dict plugin with demo data:

```bash
```console
$ pymap --port 1143 --debug dict --demo-data
```

Expand Down Expand Up @@ -112,7 +112,7 @@ will use the user ID as a relative path.

Try out the maildir plugin:

```
```console
$ pymap --port 1143 --debug maildir /path/to/users.txt
```

Expand All @@ -126,7 +126,7 @@ The redis plugin uses the [Redis][8] data structure store for mail and
metadata. It requires [aioredis][9] and will not appear in the plugins list
without it.

```
```console
$ pip install 'pymap[redis]'
$ pymap redis --help
```
Expand Down Expand Up @@ -163,7 +163,7 @@ instead of a redis hash with the `--users-json` command-line argument.

Try out the redis plugin:

```
```console
$ pymap --port 1143 --debug redis
```

Expand All @@ -174,15 +174,15 @@ action.

The `pymap-admin` tool is installed as an optional dependency:

```
```console
$ pip install 'pymap[admin]'
```

Once installed, subsequent restarts of the pymap server will listen on a UNIX
socket for incoming requests from the admin tool. See the [pymap-admin][10]
page for more information, and check out the help:

```
```console
$ pymap-admin --help
```

Expand Down Expand Up @@ -244,21 +244,21 @@ You will need to do some additional setup to develop and test plugins. First
off, I suggest activating a [venv][5]. Then, install the test requirements and
a local link to the pymap package:

```
```console
$ pip install -r test/requirements.txt
$ pip install -e .
```

Run the tests with py.test:

```
```console
$ py.test
```

If you intend to create a pull request, you should make sure the full suite of
tests run by CI/CD is passing:

```
```console
$ py.test
$ mypy pymap test
$ flake8 pymap test
Expand All @@ -273,8 +273,8 @@ responses, and are kept in the `test/server/` subdirectory.
This project makes heavy use of Python's [type hinting][6] system, with the
intention of a clean run of [mypy][7]:

```
mypy pymap
```console
mypy pymap test
```

No code contribution will be accepted unless it makes every effort to use type
Expand Down
2 changes: 2 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ RUN apk --update add --virtual build-dependencies \
&& pip install "${install_arg}${install_source}" \
&& apk del build-dependencies

EXPOSE 143 4190 50051

ENTRYPOINT ["pymap"]
CMD ["--help"]
74 changes: 49 additions & 25 deletions pymap/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@
from datetime import datetime, timedelta, timezone
from secrets import token_bytes
from ssl import SSLContext
from typing import Optional, Sequence, Awaitable
from typing import Optional, Sequence, List, Awaitable

from grpclib.events import listen, RecvRequest
from grpclib.health.check import ServiceStatus
from grpclib.health.service import Health, OVERALL
from grpclib.server import Server
from pymap.interfaces.backend import BackendInterface, ServiceInterface
from pymap.interfaces.backend import ServiceInterface
from pymapadmin import is_compatible, __version__ as server_version
from pymapadmin.local import get_admin_socket
from pymapadmin.local import get_admin_socket, get_token_file

from .errors import get_incompatible_version_error
from .handlers import handlers
from .token import get_admin_token
from .typing import Handler

__all__ = ['AdminService']

Expand All @@ -45,12 +48,14 @@ class AdminService(ServiceInterface): # pragma: no cover
@classmethod
def add_arguments(cls, parser: ArgumentParser) -> None:
group = parser.add_argument_group('admin service')
group.add_argument('--admin-path', metavar='FILE', help=SUPPRESS)
group.add_argument('--admin-host', action='append', metavar='IFACE',
help='the network interface to listen on')
group.add_argument('--admin-port', metavar='NUM',
help='the port or service name to listen on')
group.add_argument('--admin-port', metavar='NUM', type=int,
default=50051, help='the port to listen on')
group.add_argument('--no-admin-token', action='store_true',
help='disable the admin token')
group.add_argument('--admin-token-file', metavar='FILE', help=SUPPRESS)
group.add_argument('--admin-token-duration', metavar='SEC', type=int,
help=SUPPRESS)

Expand All @@ -62,43 +67,62 @@ def _init_admin_token(self) -> None:
duration = timedelta(seconds=duration_sec)
expiration = datetime.now(tz=timezone.utc) + duration
self._admin_token = token_bytes()
token = get_admin_token(self._admin_token, expiration)
print(f'PYMAP_ADMIN_TOKEN={token.serialize()}', file=sys.stderr)
token = get_admin_token(self._admin_token, expiration).serialize()
self._write_admin_token(token)
print(f'PYMAP_ADMIN_TOKEN={token}', file=sys.stderr)

def _write_admin_token(self, admin_token: str) -> None:
args = self.config.args
try:
token_file = get_token_file(args.admin_token_file, mkdir=True)
token_file.write_text(admin_token)
except OSError:
pass

async def _check_version(self, event: RecvRequest) -> None:
client_version = event.metadata['client-version']
if isinstance(client_version, bytes):
client_version = client_version.decode('ascii')
if not is_compatible(client_version, server_version):
raise get_incompatible_version_error(
client_version, server_version)
client_version = event.metadata.get('client-version')
if client_version is not None:
if isinstance(client_version, bytes):
client_version = client_version.decode('ascii')
if not is_compatible(client_version, server_version):
raise get_incompatible_version_error(
client_version, server_version)

def _get_health(self) -> Handler:
backend_status = ServiceStatus()
self.backend.status.register(backend_status.set)
return Health({OVERALL: [backend_status]})

async def start(self) -> Awaitable:
self._init_admin_token()
backend = self.backend
path: Optional[str] = self.config.args.admin_path
host: Optional[str] = self.config.args.admin_host
port: Optional[int] = self.config.args.admin_port
server_handlers: List[Handler] = [self._get_health()]
server_handlers.extend(handler(backend, self._admin_token)
for _, handler in handlers)
ssl = self.config.ssl_context
servers = [await self._start_local(backend)]
if host or port:
servers += [await self._start(backend, host, port, ssl)]
servers = [await self._start_local(server_handlers, path),
await self._start(server_handlers, host, port, ssl)]
return asyncio.create_task(self._run(servers))

def _new_server(self, backend: BackendInterface) -> Server:
server = Server([handler(backend, self._admin_token)
for _, handler in handlers])
def _new_server(self, server_handlers: Sequence[Handler]) -> Server:
server = Server(server_handlers)
listen(server, RecvRequest, self._check_version)
return server

async def _start_local(self, backend: BackendInterface) -> Server:
server = self._new_server(backend)
path = get_admin_socket(mkdir=True)
async def _start_local(self, server_handlers: Sequence[Handler],
path: Optional[str]) -> Server:
server = self._new_server(server_handlers)
path = str(get_admin_socket(path, mkdir=True))
await server.start(path=path)
return server

async def _start(self, backend: BackendInterface, host: Optional[str],
port: Optional[int], ssl: Optional[SSLContext]) -> Server:
server = self._new_server(backend)
async def _start(self, server_handlers: Sequence[Handler],
host: Optional[str], port: Optional[int],
ssl: Optional[SSLContext]) -> Server:
server = self._new_server(server_handlers)
await server.start(host=host, port=port, ssl=ssl, reuse_address=True)
return server

Expand Down
12 changes: 4 additions & 8 deletions pymap/admin/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@

from __future__ import annotations

from abc import abstractmethod, ABCMeta
from abc import ABCMeta
from contextlib import closing, asynccontextmanager, AsyncExitStack
from typing import Any, Type, Optional, Mapping, Dict, AsyncGenerator
from typing import Type, Optional, Mapping, AsyncGenerator
from typing_extensions import Final

from pymap.context import connection_exit
Expand All @@ -17,14 +17,15 @@

from ..errors import get_unimplemented_error
from ..token import TokenCredentials
from ..typing import Handler

__all__ = ['handlers', 'BaseHandler', 'LoginHandler']

#: Registers new admin handler plugins.
handlers: Plugin[Type[BaseHandler]] = Plugin('pymap.admin.handlers')


class BaseHandler(metaclass=ABCMeta):
class BaseHandler(Handler, metaclass=ABCMeta):
"""Base class for implementing admin request handlers.
Args:
Expand All @@ -41,11 +42,6 @@ def __init__(self, backend: BackendInterface,
self.login: Final = backend.login
self.admin_token: Final = admin_token

@abstractmethod
def __mapping__(self) -> Dict[str, Any]:
# Remove if IServable becomes public API in grpclib
...

@asynccontextmanager
async def catch_errors(self, command: str) -> AsyncGenerator[Result, None]:
"""Context manager to catch
Expand Down
21 changes: 21 additions & 0 deletions pymap/admin/typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@

from __future__ import annotations

from abc import abstractmethod
from typing import Mapping
from typing_extensions import Protocol

from grpclib.const import Handler as _Handler

__all__ = ['Handler']


class Handler(Protocol):
"""Defines the protocol for :class:`~grpclib.server.Server` handlers. This
can be removed if ``grpclib._typing.IServable`` becomes public API.
"""

@abstractmethod
def __mapping__(self) -> Mapping[str, _Handler]:
...
6 changes: 6 additions & 0 deletions pymap/backend/dict/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pymap.config import BackendCapability, IMAPConfig
from pymap.exceptions import AuthorizationFailure, NotAllowedError, \
UserNotFound
from pymap.health import HealthStatus
from pymap.interfaces.backend import BackendInterface, ServiceInterface
from pymap.interfaces.login import LoginTokenData, LoginInterface, \
IdentityInterface
Expand All @@ -43,6 +44,7 @@ def __init__(self, login: Login, config: Config) -> None:
super().__init__()
self._login = login
self._config = config
self._status = HealthStatus(True)

@property
def login(self) -> Login:
Expand All @@ -52,6 +54,10 @@ def login(self) -> Login:
def config(self) -> Config:
return self._config

@property
def status(self) -> HealthStatus:
return self._status

@classmethod
def add_subparser(cls, name: str, subparsers: Any) -> ArgumentParser:
parser = subparsers.add_parser(name, help='in-memory backend')
Expand Down
6 changes: 6 additions & 0 deletions pymap/backend/maildir/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pymap.config import BackendCapability, IMAPConfig
from pymap.exceptions import AuthorizationFailure, NotSupportedError
from pymap.filter import PluginFilterSet, SingleFilterSet
from pymap.health import HealthStatus
from pymap.interfaces.backend import BackendInterface, ServiceInterface
from pymap.interfaces.login import LoginInterface, IdentityInterface
from pymap.user import UserMetadata
Expand All @@ -39,6 +40,7 @@ def __init__(self, login: Login, config: Config) -> None:
super().__init__()
self._login = login
self._config = config
self._status = HealthStatus(True)

@property
def login(self) -> Login:
Expand All @@ -52,6 +54,10 @@ def users(self) -> None:
def config(self) -> Config:
return self._config

@property
def status(self) -> HealthStatus:
return self._status

@classmethod
def add_subparser(cls, name: str, subparsers: Any) -> ArgumentParser:
parser = subparsers.add_parser(name, help='on-disk backend')
Expand Down

0 comments on commit 235179d

Please sign in to comment.