Skip to content

Commit

Permalink
✨ Add initial receiver CLI skeleton
Browse files Browse the repository at this point in the history
Ref #12
  • Loading branch information
webknjaz committed Aug 3, 2019
1 parent cf74148 commit 0734aec
Show file tree
Hide file tree
Showing 3 changed files with 295 additions and 0 deletions.
161 changes: 161 additions & 0 deletions octomachinery/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#! /usr/bin/env python3
"""Octomachinery CLI entrypoint."""

import asyncio
import importlib
import json
import os
import pathlib
import tempfile

import click

from ..app.action.runner import run as process_action
from .utils import (
augment_http_headers, make_http_headers_from_event,
parse_event_stub_from_fd, validate_http_headers,
)


@click.group()
@click.pass_context
def cli(ctx):
"""Click CLI base."""
pass


@cli.command()
@click.option('--event', '-e', prompt=True, type=str)
@click.option('--payload-path', '-p', prompt=True, type=click.File(mode='r'))
@click.option('--token', '-t', prompt=True, type=str)
@click.option('--app', '-a', prompt=True, type=int)
@click.option('--private-key', '-P', prompt=True, type=click.File(mode='r'))
@click.option('--entrypoint-module', '-m', prompt=True, type=str)
@click.pass_context
def receive(
ctx,
event, payload_path,
token,
app, private_key,
entrypoint_module,
):
"""Webhook event receive command."""
app_missing_private_key = app is not None and not private_key
if app_missing_private_key:
ctx.fail('App requires a private key')

creds_present = token or (app and private_key)
if not creds_present:
ctx.fail('Any GitHub auth credentials are missing')

too_many_creds_present = token and (app or private_key)
if not too_many_creds_present:
ctx.fail(
'Please choose between a token or an app id with a private key',
)

http_headers, event_data = parse_event_stub_from_fd(payload_path)

if event and http_headers:
ctx.fail('Supply only one of an event name or an event fixture file')

if http_headers:
http_headers = augment_http_headers(http_headers)
event = http_headers['x-event-event']
else:
http_headers = make_http_headers_from_event(event)
validate_http_headers(http_headers)

if app is None:
_process_event_as_action(
event, event_data,
token,
entrypoint_module,
)
else:
asyncio.run(_process_event_as_app(
http_headers, event_data,
app, private_key,
entrypoint_module,
))


def _process_event_as_action(
event, event_data,
token,
entrypoint_module,
):
os.environ['OCTOMACHINERY_APP_MODE'] = 'action'

os.environ['GITHUB_ACTION'] = 'Fake CLI Action'
os.environ['GITHUB_ACTOR'] = event_data['sender']['login']
os.environ['GITHUB_EVENT_NAME'] = event
os.environ['GITHUB_WORKSPACE'] = str(pathlib.Path('.').resolve())
os.environ['GITHUB_SHA'] = event_data['head_commit']['id']
os.environ['GITHUB_REF'] = event_data['ref']
os.environ['GITHUB_REPOSITORY'] = event_data['repository']['full_name']
os.environ['GITHUB_TOKEN'] = token
os.environ['GITHUB_WORKFLOW'] = 'Fake CLI Workflow'

with tempfile.NamedTemporaryFile(
suffix='.json', prefix='github-workflow-event-',
) as tmp_event_file:
json.dump(tmp_event_file, event_data)
os.environ['GITHUB_EVENT_PATH'] = tmp_event_file.name
importlib.import_module(entrypoint_module)
process_action()


async def _process_event_as_app(
http_headers, event_data,
app, private_key,
entrypoint_module,
):
os.environ['OCTOMACHINERY_APP_MODE'] = 'app'

os.environ['GITHUB_APP_IDENTIFIER'] = app
os.environ['GITHUB_PRIVATE_KEY'] = private_key.read()

importlib.import_module(entrypoint_module)
from ..app.routing.webhooks_dispatcher import route_github_webhook_event
from ..app.runtime.context import RUNTIME_CONTEXT
from ..app.github.api.app_client import GitHubApp
from ..app.config import BotAppConfig
from aiohttp.web_request import Request
config = BotAppConfig.from_dotenv()
from aiohttp.http_parser import RawRequestMessage
import yarl

class protocol_stub:
transport = None
message = RawRequestMessage(
'POST', '/', 'HTTP/1.1',
http_headers,
None, None, None, None, None,
yarl.URL('/'),
)
http_request = Request(
message=message,
payload=None, # dummy
protocol=protocol_stub(),
payload_writer=None, # dummy
task=None, # dummy
loop=asyncio.get_running_loop(),
)

async def read_coro():
return event_data
http_request.read = read_coro
async with GitHubApp(config.github) as github_app:
RUNTIME_CONTEXT.github_app = ( # pylint: disable=assigning-non-slot
github_app
)
route_github_webhook_event(http_request)


def main():
"""CLI entrypoint."""
return cli(obj={}, auto_envvar_prefix='OCTOMACHINERY_CLI_')


__name__ == '__main__' and main()
133 changes: 133 additions & 0 deletions octomachinery/cli/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Utility helpers for CLI."""

import itertools
import json

import multidict
import yaml


def _probe_yaml(event_file_fd):
try:
http_headers, event, extra = itertools.islice(
itertools.chain(
yaml.safe_load_all(event_file_fd),
(None, ) * 3,
),
3,
)
except yaml.parser.ParserError:
raise ValueError('YAML file is not valid')
finally:
event_file_fd.seek(0)

if extra is not None:
raise ValueError('YAML file must only contain 1–2 documents')

if event is None:
event = http_headers
http_headers = ()

if event is None:
raise ValueError('YAML file must contain 1–2 non-empty documents')

return http_headers, event


def _probe_jsonl(event_file_fd):
first_line = event_file_fd.readline()
second_line = event_file_fd.readline()
third_line = event_file_fd.readline()
event_file_fd.seek(0)

if third_line:
raise ValueError('JSONL file must only contain 1–2 JSON lines')

http_headers = json.loads(first_line)

try:
event = json.loads(second_line)
except ValueError:
event = None

if event is None:
event = http_headers
http_headers = ()

return http_headers, event


def _probe_json(event_file_fd):
event = json.load(event_file_fd)
event_file_fd.seek(0)

if not isinstance(event, dict):
raise ValueError('JSON file must only contain an object mapping')

http_headers = ()

return http_headers, event


def _parse_fd_content(event_file_fd):
"""Guess file content type and read event with HTTP headers."""
for event_reader in _probe_yaml, _probe_jsonl:
try:
return event_reader(event_file_fd)
except ValueError:
pass

# Last probe call is outside the loop to propagate exception further
return _probe_json(event_file_fd)


def _transform_http_headers_list_to_multidict(headers):
return multidict.CIMultiDict(next(iter(h.items()), ()) for h in headers)


def parse_event_stub_from_fd(event_file_fd):
"""Read event with HTTP headers as CIMultiDict instance."""
http_headers, event = _parse_fd_content(event_file_fd)
return _transform_http_headers_list_to_multidict(http_headers), event


def validate_http_headers(headers):
"""Verify that HTTP headers look sane."""
if headers['content-type'] != 'application/json':
raise ValueError

if not headers['user-agent'].startswith('GitHub-Hookshot/'):
raise ValueError

gh_delivery_lens = tuple(map(len, headers['x-github-delivery'].split('-')))
if gh_delivery_lens != (8, 4, 4, 4, 12):
raise ValueError

if not isinstance(headers['x-github-event'], str):
raise ValueError


def augment_http_headers(headers):
"""Add fake HTTP headers for the missing positions."""
fake_headers = make_http_headers_from_event(headers['x-github-event'])

if 'content-type' not in headers:
headers['content-type'] = fake_headers['content-type']

if 'user-agent' not in headers:
headers['user-agent'] = fake_headers['user-agent']

if 'x-github-delivery' not in headers:
headers['x-github-delivery'] = fake_headers['x-github-delivery']

return headers


def make_http_headers_from_event(event_name):
"""Generate fake HTTP headers with the given event name."""
return multidict.CIMultiDict(**{
'content-type': 'application/json',
'user-agent': 'GitHub-Hookshot/fallback-value',
'x-github-delivery': '49b0a670-63cd-11e9-8686-1a54dc9fb341',
'x-github-event': event_name,
})
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ setup_requires =
# NOTE: cryptography is an optional dependency of pyjwt
install_requires =
aiohttp
click
cryptography
environ-config
envparse
Expand Down

0 comments on commit 0734aec

Please sign in to comment.