Rename project to icalendar-timeseries-server
This commit is contained in:
commit
5722fd7c98
29 changed files with 1450 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
**/.idea/
|
||||
*.iml
|
||||
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/*.egg-info/
|
||||
*.coverage
|
||||
**/.mypy_cache/
|
91
.gitlab-ci.yml
Normal file
91
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
image: s3lph/icalendar-timeseries-server-ci:20190820-01
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- release
|
||||
|
||||
|
||||
|
||||
before_script:
|
||||
- export ITS_VERSION=$(python -c 'import icalendar_timeseries_server; print(icalendar_timeseries_server.__version__)')
|
||||
|
||||
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- sudo -u its python3 -m coverage run --rcfile=setup.cfg -m unittest discover icalendar_timeseries_server
|
||||
- sudo -u its python3 -m coverage combine
|
||||
- sudo -u its python3 -m coverage report --rcfile=setup.cfg
|
||||
|
||||
codestyle:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- sudo -u its pycodestyle icalendar_timeseries_server
|
||||
|
||||
|
||||
|
||||
build_wheel:
|
||||
stage: build
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- python3 setup.py egg_info bdist_wheel
|
||||
- cd dist
|
||||
- sha256sum *.whl > SHA256SUMS
|
||||
artifacts:
|
||||
paths:
|
||||
- "dist/*.whl"
|
||||
- dist/SHA256SUMS
|
||||
only:
|
||||
- tags
|
||||
|
||||
build_debian:
|
||||
stage: build
|
||||
script:
|
||||
# The Python package name provided by the python3-magic Debian package is "python-magic" rather than "file-magic".
|
||||
- sed -re 's/file-magic/python-magic/' -i setup.py
|
||||
- echo -n > package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
|
||||
- |
|
||||
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
|
||||
echo "icalendar-timeseries-server (${version}-1); urgency=medium\n" >> package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
|
||||
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
|
||||
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
|
||||
done
|
||||
- gzip -9n package/debian/icalendar-timeseries-server/usr/share/doc/icalendar-timeseries-server/changelog
|
||||
- python3.7 setup.py egg_info install --root=package/debian/icalendar-timeseries-server/ --prefix=/usr --optimize=1
|
||||
- cd package/debian
|
||||
- mkdir -p icalendar-timeseries-server/usr/lib/python3/dist-packages/
|
||||
- rsync -a icalendar-timeseries-server/usr/lib/python3.7/site-packages/ icalendar-timeseries-server/usr/lib/python3/dist-packages/
|
||||
- rm -rf icalendar-timeseries-server/usr/lib/python3.7/
|
||||
- find icalendar-timeseries-server/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
|
||||
- find icalendar-timeseries-server/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
|
||||
- mv icalendar-timeseries-server/usr/bin/icalendar-timeseries-server icalendar-timeseries-server/usr/lib/icalendar-timeseries-server/icalendar-timeseries-server
|
||||
- rm -rf icalendar-timeseries-server/usr/bin
|
||||
- sed -re 's$#!/usr/local/bin/python3.7$#!/usr/bin/python3$' -i icalendar-timeseries-server/usr/lib/icalendar-timeseries-server/icalendar-timeseries-server
|
||||
- find icalendar-timeseries-server -type f -exec chmod 0644 {} \;
|
||||
- find icalendar-timeseries-server -type d -exec chmod 755 {} \;
|
||||
- find icalendar-timeseries-server -type f -name .gitkeep -delete
|
||||
- chmod +x icalendar-timeseries-server/usr/lib/icalendar-timeseries-server/icalendar-timeseries-server icalendar-timeseries-server/DEBIAN/postinst icalendar-timeseries-server/DEBIAN/prerm icalendar-timeseries-server/DEBIAN/postrm
|
||||
- dpkg-deb --build icalendar-timeseries-server
|
||||
- mv icalendar-timeseries-server.deb "icalendar-timeseries-server_${ITS_VERSION}-1_all.deb"
|
||||
- sudo -u nobody lintian "icalendar-timeseries-server_${ITS_VERSION}-1_all.deb"
|
||||
- sha256sum *.deb > SHA256SUMS
|
||||
artifacts:
|
||||
paths:
|
||||
- "package/debian/*.deb"
|
||||
- package/debian/SHA256SUMS
|
||||
only:
|
||||
- tags
|
||||
|
||||
|
||||
|
||||
release:
|
||||
stage: release
|
||||
script:
|
||||
- python package/release.py
|
||||
only:
|
||||
- tags
|
17
CHANGELOG.md
Normal file
17
CHANGELOG.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
# iCalendar Timeseries Server Changelog
|
||||
|
||||
<!-- BEGIN RELEASE v0.1 -->
|
||||
## Version 0.1
|
||||
|
||||
First pre-1.0 release of iCalendar Timeseries Server.
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1 -->
|
||||
- Configure multiple scrape sources
|
||||
- Authorization: HTTP Basic Auth or TLS Client Certificates
|
||||
- Offer API endpoints /api/v1/query and /api/v1/query_range
|
||||
- Support simple PromQL label filters
|
||||
<!-- END CHANGES 0.1 -->
|
||||
|
||||
<!-- END RELEASE v0.1 -->
|
8
Dockerfile
Normal file
8
Dockerfile
Normal file
|
@ -0,0 +1,8 @@
|
|||
FROM python:3.7-stretch
|
||||
|
||||
RUN useradd -d /home/its -m its \
|
||||
&& apt-get update -qy \
|
||||
&& apt-get install -y --no-install-recommends sudo build-essential lintian rsync \
|
||||
&& python3.7 -m pip install coverage wheel pycodestyle mypy
|
||||
|
||||
WORKDIR /home/its
|
176
README.md
Normal file
176
README.md
Normal file
|
@ -0,0 +1,176 @@
|
|||
# iCalendar Timeseries Server
|
||||
|
||||
This project is a small service that scrapes iCalendar files served
|
||||
over HTTP, parses their contents and returns the data in a timeseries
|
||||
format compatible to the `/api/v1/query` API endpoint of a Prometheus
|
||||
server. This allows e.g. a Grafana administrator to add a Prometheus
|
||||
data source pointing at this server, returning the events in the
|
||||
calendars in the `event` metric.
|
||||
|
||||
## Example
|
||||
|
||||
Consider the following iCalendar file:
|
||||
|
||||
```
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//ACME//NONSGML Rocket Powered Anvil//EN
|
||||
BEGIN:VEVENT
|
||||
UID:20190603T032500CEST-foo
|
||||
SUMMARY:Foo
|
||||
DESCRIPTION:An example event
|
||||
DTSTART;TZID=Europe/Zurich;VALUE=DATE-TIME:20190603T032500
|
||||
DTEND;TZID=Europe/Zurich;VALUE=DATE-TIME:20190603T040000
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:20190603T032500CEST-bar
|
||||
SUMMARY:Bar
|
||||
DESCRIPTION:Another example event
|
||||
DTSTART;TZID=Europe/Zurich;VALUE=DATE-TIME:20190603T032500
|
||||
DTEND;TZID=Europe/Zurich;VALUE=DATE-TIME:20190603T040000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
The server would transform this into the following API response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "vector",
|
||||
"result": [
|
||||
{
|
||||
"metric": {
|
||||
"__name__": "event",
|
||||
"calendar": "0",
|
||||
"uid": "20190603T032500CEST-foo",
|
||||
"summary": "Foo",
|
||||
"description": "An example event"
|
||||
},
|
||||
"value": [
|
||||
1560043497,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"metric": {
|
||||
"__name__": "event",
|
||||
"calendar": "1",
|
||||
"uid": "20190603T032500CEST-bar",
|
||||
"summary": "Bar",
|
||||
"description": "Another example event"
|
||||
},
|
||||
"value": [
|
||||
1560043497,
|
||||
1
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `bottle`: Serve the results
|
||||
- `dateutil`: Work with recurring events
|
||||
- `icalendar`: Parse iCalendar
|
||||
- `isodate`: Parse ISO-8601 time periods
|
||||
- `jinja2`: Template value replacements
|
||||
- `pytz`: Work with timezones
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is done through a JSON config file:
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"addr": "127.0.0.1",
|
||||
"port": 8090,
|
||||
"start_delta": "-PT3H",
|
||||
"end_delta": "P30D",
|
||||
"cache": "PT15M",
|
||||
"tz": "Europe/Zurich",
|
||||
"calendars": {
|
||||
"private": {
|
||||
"url": "https://example.cloud/dav/me/private.ics",
|
||||
"auth": {
|
||||
"type": "basic",
|
||||
"username": "me",
|
||||
"password": "mysupersecurepassword"
|
||||
}
|
||||
},
|
||||
"public": {
|
||||
"url": "https://example.cloud/dav/me/public.ics"
|
||||
},
|
||||
"confidential": {
|
||||
"url": "https://example.cloud/dav/me/confidential.ics",
|
||||
"ca": "/etc/ssl/ca.pem",
|
||||
"auth": {
|
||||
"type": "tls",
|
||||
"keyfile": "/etc/ssl/client.pem",
|
||||
"passphrase": "mysupersecurepassword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"key_replace": {
|
||||
"summary": "01_summary",
|
||||
"description": "02_description"
|
||||
},
|
||||
"value_replace": {
|
||||
"summary": "{{ summary|truncate(100) }}",
|
||||
"description": "{{ description|truncate(100) }}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Explanation
|
||||
|
||||
|
||||
| JMESPath | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `addr` | string | The address to listen on. |
|
||||
| `port` | int | The port to listen on. |
|
||||
| `start_delta` | string | A signed ISO 8601 duration string, describing the event range start offset relative to the current time. |
|
||||
| `end_delta` | string | An unsigned ISO 8601 duration string, describing the event range end offset relative to the current time. |
|
||||
| `cache` | string | An unsigned ISO 8601 duration string, describing the cache timeout duration. |
|
||||
| `tz` | string | The local timezone. |
|
||||
| `calendars` | dict | The calendars to scrape. |
|
||||
| `keys(calendars)` | string | Name of the calendar. |
|
||||
| `calendars.*.url` | string | The HTTP or HTTPS URL to scrape. |
|
||||
| `calendars.*.ca` | string | Path to the CA certificate file to validate the server's TLS certificate against, in PEM format (optional). |
|
||||
| `calendars.*.auth` | dict | Authorization config for the calendar. |
|
||||
| `calendars.*.auth[].type` | string | Authorization type, one of `none` (no authorization), `basic` (HTTP Basic Authentication), `tls` (TLS client certificate). |
|
||||
| `calendars.*.auth[?type=='basic'].username` | string | The Basic Auth username to authenticate with. |
|
||||
| `calendars.*.auth[?type=='basic'].password` | string | The Basic Auth password to authenticate with. |
|
||||
| `calendars.*.auth[?type=='tls'].keyfile` | string | Path to the key file containing the TLS private key, client certificate and certificate chain, in PEM format. |
|
||||
| `calendars.*.auth[?type=='tls'].passphrase` | string | Passphrase for the private key (optional). |
|
||||
| `key_replace` | dict | Labels to rename, might be necessary e.g. for column ordering in Grafana. |
|
||||
| `keys(key_replace)` | string | The labels to rename. |
|
||||
| `key_replace.*` | string | The names to rename the labels to. |
|
||||
| `value_replace` | dict | Label values to postprocess. |
|
||||
| `keys(value_replace)` | string | Original label name to postprocess, also may introduce new labels. |
|
||||
| `value_replace.*` | string | The new value for the label. Supports Jinja2 templates. All original label values can be accessed by using their original name as a Jinja2 variable name. |
|
||||
|
||||
## Queries
|
||||
|
||||
The most basic query is simply the metric name:
|
||||
|
||||
```
|
||||
event
|
||||
```
|
||||
|
||||
In addition, PromQL label filters can be used.
|
||||
|
||||
```
|
||||
event{calendar="public",foo=~".*"}
|
||||
```
|
||||
|
||||
## Why Prometheus API
|
||||
|
||||
- It's JSON. A JSON generator is builtin in Python, so no further dependency.
|
||||
- The Prometheus Data Source is builtin into Grafana.
|
||||
- The API is simple.
|
29
icalendar-timeseries-server.json
Normal file
29
icalendar-timeseries-server.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"addr": "127.0.0.1",
|
||||
"port": 8090,
|
||||
"start_delta": "-PT3H",
|
||||
"end_delta": "P60D",
|
||||
"cache": "PT3M",
|
||||
"tz": "Europe/Zurich",
|
||||
"calendars": {
|
||||
"tlstest": {
|
||||
"url": "https://localhost/private.ics",
|
||||
"ca": "/home/sebastian/tlstest/ca/ca/ca.crt",
|
||||
"auth": {
|
||||
"type": "tls",
|
||||
"keyfile": "/home/sebastian/tlstest/client/combined.pem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"key_replace": {
|
||||
"summary": "a_summary",
|
||||
"description": "b_description",
|
||||
"calendar": "c_calendar"
|
||||
},
|
||||
"value_replace": {
|
||||
"summary": "{{ summary|truncate(100, end=' \\N{HORIZONTAL ELLIPSIS}') }}",
|
||||
"description": "{{ description|truncate(100, end=' \\N{HORIZONTAL ELLIPSIS}') }}",
|
||||
"calendar": "{{ 0 if calendar == 'private' else 1 }}",
|
||||
"useless_metric": "{{ start.timestamp() + end.timestamp() }}"
|
||||
}
|
||||
}
|
2
icalendar_timeseries_server/__init__.py
Normal file
2
icalendar_timeseries_server/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
__version__ = '0.1'
|
4
icalendar_timeseries_server/__main__.py
Normal file
4
icalendar_timeseries_server/__main__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
from icalendar_timeseries_server.main import main
|
||||
|
||||
main()
|
71
icalendar_timeseries_server/api.py
Normal file
71
icalendar_timeseries_server/api.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
from typing import List
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.error import HTTPError
|
||||
import traceback
|
||||
|
||||
import bottle
|
||||
from isodate import Duration
|
||||
|
||||
from icalendar_timeseries_server.config import get_config
|
||||
from icalendar_timeseries_server.event import Event
|
||||
from icalendar_timeseries_server.cal import scrape_calendar
|
||||
from icalendar_timeseries_server.query import MetricQuery
|
||||
|
||||
|
||||
@bottle.route('/api/v1/query')
|
||||
@bottle.route('/api/v1/query_range')
|
||||
def prometheus_api():
|
||||
tz = get_config().tz
|
||||
now: datetime = datetime.now(tz)
|
||||
start_delta: Duration = get_config().start_delta
|
||||
end_delta: Duration = get_config().end_delta
|
||||
start: datetime = now + start_delta
|
||||
end: datetime = now + end_delta
|
||||
events: List[Event] = []
|
||||
|
||||
try:
|
||||
q = MetricQuery(bottle.request.query['query'])
|
||||
except ValueError as e:
|
||||
response = {
|
||||
'status': 'error',
|
||||
'errorType': 'bad_data',
|
||||
'error': str(e)
|
||||
}
|
||||
bottle.response.status = 400
|
||||
traceback.print_exc()
|
||||
bottle.response.add_header('Content-Type', 'application/json')
|
||||
return json.dumps(response)
|
||||
|
||||
try:
|
||||
for name, caldef in get_config().calendars.items():
|
||||
events.extend(scrape_calendar(name, caldef, start, end))
|
||||
events = list(filter(q, events))
|
||||
events.sort(key=lambda e: e.start)
|
||||
response = {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'resultType': 'vector',
|
||||
'result': [e.serialize() for e in events]
|
||||
}
|
||||
}
|
||||
except HTTPError as e:
|
||||
response = {
|
||||
'status': 'error',
|
||||
'errorType': 'internal',
|
||||
'error': str(e)
|
||||
}
|
||||
bottle.response.status = 500
|
||||
traceback.print_exc()
|
||||
except BaseException:
|
||||
response = {
|
||||
'status': 'error',
|
||||
'errorType': 'internal',
|
||||
'error': 'An internal error occurred.'
|
||||
}
|
||||
bottle.response.status = 500
|
||||
traceback.print_exc()
|
||||
|
||||
bottle.response.add_header('Content-Type', 'application/json')
|
||||
return json.dumps(response)
|
92
icalendar_timeseries_server/cal.py
Normal file
92
icalendar_timeseries_server/cal.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
from typing import Dict, List, Iterable, Tuple
|
||||
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
from dateutil import rrule
|
||||
from icalendar import cal
|
||||
|
||||
from icalendar_timeseries_server import __version__
|
||||
from icalendar_timeseries_server.config import get_config, CalendarConfig
|
||||
from icalendar_timeseries_server.event import Event
|
||||
|
||||
|
||||
_SCRAPE_CACHE: Dict[str, Tuple[datetime, List[Event]]] = dict()
|
||||
|
||||
__py_version: str = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
|
||||
USER_AGENT: str = f'icalendar-timeseries-server/{__version__} (Python/{__py_version})'
|
||||
|
||||
|
||||
def _parse_recurring(event: cal.Event, start: datetime, end: datetime, duration: timedelta) -> List[datetime]:
|
||||
occurences: List[datetime] = []
|
||||
|
||||
evstart = event.get('dtstart').dt
|
||||
# First occurence lies in the future; no need to process further
|
||||
if evstart >= end:
|
||||
return occurences
|
||||
|
||||
# Extract recurrence rules from ical
|
||||
ical_lines = event.to_ical().decode('utf-8').split('\r\n')
|
||||
recurrence = '\n'.join(
|
||||
[x for x in ical_lines
|
||||
if x.startswith('RRULE') or x.startswith('RDATE') or x.startswith('EXRULE') or x.startswith('EXDATE')])
|
||||
# Create a generator that yields a timestamp for each recurrence
|
||||
generator = rrule.rrulestr(recurrence, dtstart=evstart)
|
||||
|
||||
# Generate an event entry for each occurence of the event
|
||||
for dt in generator:
|
||||
# Skip past occurences and break once the the event lies too far in the future
|
||||
if dt + duration < start:
|
||||
continue
|
||||
if dt > end:
|
||||
break
|
||||
# Create an event entry
|
||||
occurences.append(dt)
|
||||
return occurences
|
||||
|
||||
|
||||
def _parse_calendar(name: str, calendar: cal.Calendar, start: datetime, end: datetime) -> List[Event]:
|
||||
events = []
|
||||
for element in calendar.walk():
|
||||
if element.name == "VEVENT":
|
||||
dtstart = element.get('dtstart').dt
|
||||
if isinstance(dtstart, date):
|
||||
dtstart = datetime(dtstart.year, dtstart.month, dtstart.day, tzinfo=start.tzinfo)
|
||||
# Process either end timestamp or duration, if present
|
||||
if 'dtend' in element:
|
||||
evend = element.get('dtend').dt
|
||||
if isinstance(evend, date):
|
||||
evend = datetime(evend.year, evend.month, evend.day, tzinfo=start.tzinfo)
|
||||
duration = evend - dtstart
|
||||
elif 'duration' in element:
|
||||
duration = element.get('duration').dt
|
||||
else:
|
||||
duration = timedelta(0)
|
||||
if element.get('rrule') is not None or element.get('rdate') is not None:
|
||||
occurences: Iterable[datetime] = _parse_recurring(element, start, end, duration)
|
||||
else:
|
||||
occurences = [dtstart]
|
||||
for occurence in occurences:
|
||||
if start <= occurence < end:
|
||||
events.append(Event(name, element, occurence, occurence + duration))
|
||||
return events
|
||||
|
||||
|
||||
def scrape_calendar(name: str, config: CalendarConfig, start: datetime, end: datetime) -> List[Event]:
|
||||
global _SCRAPE_CACHE
|
||||
now: datetime = datetime.now(tz=get_config().tz)
|
||||
if get_config().cache.total_seconds() > 0 and name in _SCRAPE_CACHE:
|
||||
cache_timeout, cached = _SCRAPE_CACHE[name]
|
||||
if now < cache_timeout:
|
||||
print('serving cached')
|
||||
return cached
|
||||
print('doing request')
|
||||
|
||||
opener: urllib.request.OpenerDirector = config.get_url_opener()
|
||||
with opener.open(config.url) as response:
|
||||
data = response.read().decode('utf-8')
|
||||
calendar = cal.Calendar.from_ical(data)
|
||||
parsed: List[Event] = _parse_calendar(name, calendar, start, end)
|
||||
_SCRAPE_CACHE[name] = now + get_config().cache, parsed
|
||||
return parsed
|
228
icalendar_timeseries_server/config.py
Normal file
228
icalendar_timeseries_server/config.py
Normal file
|
@ -0,0 +1,228 @@
|
|||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import timedelta
|
||||
import ssl
|
||||
import urllib.request
|
||||
import sys
|
||||
|
||||
import pytz
|
||||
import jinja2
|
||||
from isodate import Duration, parse_duration
|
||||
|
||||
from icalendar_timeseries_server import __version__
|
||||
|
||||
|
||||
CONFIG: Optional['Config'] = None
|
||||
|
||||
JENV: Optional[jinja2.Environment] = None
|
||||
|
||||
|
||||
__py_version: str = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
|
||||
USER_AGENT: str = f'icalendar-timeseries-server/{__version__} (Python/{__py_version})'
|
||||
|
||||
|
||||
class CalendarConfig:
|
||||
|
||||
def __init__(self, config: Dict[str, Any], config_path: str) -> None:
|
||||
self._url: str = _keycheck('url', config, str, config_path)
|
||||
self._ca: Optional[str] = _keycheck('ca', config, str, config_path, optional=True)
|
||||
auth: Dict[str, Any] = _keycheck('auth', config, dict, config_path, default_value={'type': 'none'})
|
||||
self._authtype: str = _keycheck('type', auth, str, f'{config_path}.auth',
|
||||
default_value='none',
|
||||
valid_values=['none', 'basic', 'tls'])
|
||||
self._request_headers: Dict[str, str] = {
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
|
||||
if self._authtype == 'none':
|
||||
# No auth, nothing to do
|
||||
pass
|
||||
|
||||
elif self._authtype == 'basic':
|
||||
# Build the Authorization header required for HTTP Basic Auth
|
||||
username: str = _keycheck('username', auth, str, f'{config_path}.auth')
|
||||
password: str = _keycheck('password', auth, str, f'{config_path}.auth')
|
||||
token: str = base64.b64encode(f'{username}:{password}'.encode()).decode()
|
||||
self._request_headers['Authorization'] = f'Basic {token}'
|
||||
|
||||
elif self._authtype == 'tls':
|
||||
# Store TLS config for later use
|
||||
self._tls_keyfile: str = _keycheck('keyfile', auth, str, f'{config_path}.auth')
|
||||
self._tls_passphrase: str = _keycheck('passphrase', auth, str, f'{config_path}.auth', optional=True)
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return self._url
|
||||
|
||||
def get_url_opener(self) -> urllib.request.OpenerDirector:
|
||||
|
||||
if self._authtype == 'tls':
|
||||
# Create an OpenSSL context and load the CA and client certificates
|
||||
context: ssl.SSLContext = ssl.create_default_context(cafile=self._ca)
|
||||
context.load_cert_chain(certfile=self._tls_keyfile, password=self._tls_passphrase)
|
||||
opener: urllib.request.OpenerDirector = \
|
||||
urllib.request.build_opener(urllib.request.HTTPSHandler(context=context))
|
||||
|
||||
elif self._ca is not None:
|
||||
# No TLS Client Certificate, but load the CA certificate
|
||||
context: ssl.SSLContext = ssl.create_default_context(cafile=self._ca)
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context))
|
||||
|
||||
else:
|
||||
# No TLS config, return default opener
|
||||
opener = urllib.request.build_opener()
|
||||
|
||||
# Set request headers, at least User-Agent, optionally Authorization
|
||||
opener.addheaders = [(k, v) for k, v in self._request_headers.items()]
|
||||
return opener
|
||||
|
||||
|
||||
class Config:
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None) -> None:
|
||||
if config is None:
|
||||
config = dict()
|
||||
self._addr: str = _keycheck('addr', config, str, '', default_value='127.0.0.1')
|
||||
self._port: int = _keycheck('port', config, int, '', default_value=8090)
|
||||
self._tz: pytz.tzinfo = _parse_timezone('tz', config, '', default_value='UTC')
|
||||
self._start_delta: Duration = _parse_timedelta('start_delta', config, '', default_value='PT')
|
||||
self._end_delta: Duration = _parse_timedelta('end_delta', config, '', default_value='P30D')
|
||||
self._cache: Duration = _parse_timedelta('cache', config, '', default_value='PT', force_positive=True)
|
||||
self._calendars: Dict[str, CalendarConfig] = self._parse_calendars_config('calendars', config, '')
|
||||
self._key_replace = _parse_key_replace('key_replace', config, '')
|
||||
self._value_replace = _parse_value_replace('value_replace', config, '')
|
||||
|
||||
@staticmethod
|
||||
def _parse_calendars_config(key: str,
|
||||
config: Dict[str, Any],
|
||||
path: str) -> Dict[str, CalendarConfig]:
|
||||
cdef: Dict[str, Any] = _keycheck(key, config, dict, path, default_value={})
|
||||
calendars: Dict[str, CalendarConfig] = dict()
|
||||
for name, c in cdef.items():
|
||||
calendar: CalendarConfig = CalendarConfig(c, f'{path}.{name}')
|
||||
calendars[name] = calendar
|
||||
return calendars
|
||||
|
||||
@property
|
||||
def addr(self) -> str:
|
||||
return self._addr
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def tz(self) -> pytz.tzinfo:
|
||||
return self._tz
|
||||
|
||||
@property
|
||||
def start_delta(self) -> Duration:
|
||||
return self._start_delta
|
||||
|
||||
@property
|
||||
def end_delta(self) -> Duration:
|
||||
return self._end_delta
|
||||
|
||||
@property
|
||||
def cache(self) -> Duration:
|
||||
return self._cache
|
||||
|
||||
@property
|
||||
def calendars(self) -> Dict[str, CalendarConfig]:
|
||||
return self._calendars
|
||||
|
||||
@property
|
||||
def key_replace(self) -> Dict[str, str]:
|
||||
return self._key_replace
|
||||
|
||||
@property
|
||||
def value_replace(self) -> Dict[str, str]:
|
||||
return self._value_replace
|
||||
|
||||
|
||||
def _keycheck(key: str,
|
||||
config: Dict[str, Any],
|
||||
typ: Type,
|
||||
path: str,
|
||||
default_value: Any = None,
|
||||
optional: bool = False,
|
||||
valid_values: Optional[List[Any]] = None) -> Any:
|
||||
if key not in config:
|
||||
if default_value is not None or optional:
|
||||
return default_value
|
||||
raise KeyError(f'Expected member "{key}" not found at path {path}')
|
||||
value: Any = config[key]
|
||||
if not isinstance(value, typ):
|
||||
raise TypeError(f'Expected {typ}, not {type(value).__name__} for path {path}.{key}')
|
||||
if valid_values is not None:
|
||||
if value not in valid_values:
|
||||
raise ValueError(f'Expected one of {", ".join(valid_values)} ({typ}), not {value} for path {path}.{key}')
|
||||
return value
|
||||
|
||||
|
||||
def _parse_timedelta(key: str,
|
||||
config: Dict[str, Any],
|
||||
path: str,
|
||||
default_value: Any = None,
|
||||
force_positive: bool = False) -> Duration:
|
||||
tdstring: str = _keycheck(key, config, str, path, default_value=default_value)
|
||||
duration: Union[Duration, timedelta] = parse_duration(tdstring)
|
||||
if isinstance(duration, timedelta):
|
||||
duration = Duration(days=duration.days,
|
||||
seconds=duration.seconds,
|
||||
microseconds=duration.microseconds)
|
||||
if force_positive and duration.total_seconds() < 0:
|
||||
raise ValueError(f'Duration must be positive for path {path}.{key}')
|
||||
return duration
|
||||
|
||||
|
||||
def _parse_timezone(key: str,
|
||||
config: Dict[str, Any],
|
||||
path: str,
|
||||
default_value: Any = None) -> Any:
|
||||
zonename: str = _keycheck(key, config, str, path, default_value=default_value)
|
||||
return pytz.timezone(zonename)
|
||||
|
||||
|
||||
def _parse_key_replace(key: str,
|
||||
config: Dict[str, Any],
|
||||
path: str) -> Dict[str, str]:
|
||||
key_replace: Dict[str, Any] = _keycheck(key, config, dict, path, default_value={})
|
||||
for label in key_replace.keys():
|
||||
_keycheck(label, key_replace, str, f'{path}.{key}')
|
||||
return key_replace
|
||||
|
||||
|
||||
def _parse_value_replace(key: str,
|
||||
config: Dict[str, Any],
|
||||
path: str) -> Dict[str, str]:
|
||||
value_replace: Dict[str, Any] = _keycheck(key, config, dict, path, default_value={})
|
||||
for label in value_replace.keys():
|
||||
_keycheck(label, value_replace, str, f'{path}.{key}')
|
||||
return value_replace
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
global CONFIG
|
||||
return CONFIG
|
||||
|
||||
|
||||
def get_jenv() -> jinja2.Environment:
|
||||
global JENV
|
||||
return JENV
|
||||
|
||||
|
||||
def load_config(filename: str):
|
||||
global CONFIG, JENV
|
||||
with open(filename, 'r') as f:
|
||||
json_config = json.loads(f.read())
|
||||
CONFIG = Config(json_config)
|
||||
JENV = jinja2.Environment()
|
||||
|
||||
|
||||
def load_default_config():
|
||||
global CONFIG, JENV
|
||||
CONFIG = Config({})
|
||||
JENV = jinja2.Environment()
|
66
icalendar_timeseries_server/event.py
Normal file
66
icalendar_timeseries_server/event.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
from typing import Any, Dict, List, Set
|
||||
|
||||
import icalendar
|
||||
import jinja2
|
||||
from datetime import datetime
|
||||
|
||||
from icalendar_timeseries_server.config import get_config, get_jenv
|
||||
from icalendar_timeseries_server.query import Metric
|
||||
|
||||
_ATTRIBUTES: List[str] = [
|
||||
'attachs',
|
||||
'categories',
|
||||
'class',
|
||||
'comment',
|
||||
'description',
|
||||
'geo',
|
||||
'location',
|
||||
'percent-complete',
|
||||
'priority',
|
||||
'resources',
|
||||
'status',
|
||||
'summary'
|
||||
]
|
||||
|
||||
|
||||
class Event(Metric):
|
||||
|
||||
def __init__(self, cname: str, event: icalendar.cal.Event, start: datetime, end: datetime):
|
||||
self.start: datetime = start
|
||||
self.calendar: str = cname
|
||||
# self.attributes: Dict[str, str] = dict()
|
||||
attributes: Dict[str, str] = dict()
|
||||
tmp: Dict[str, Any] = {
|
||||
'calendar': cname,
|
||||
'start': start,
|
||||
'end': end
|
||||
}
|
||||
for attr in _ATTRIBUTES:
|
||||
tmp[attr] = event.get(attr, '')
|
||||
substitution_keys = set(_ATTRIBUTES)
|
||||
substitution_keys.update(['start', 'end'])
|
||||
substitution_keys.update(get_config().key_replace.keys())
|
||||
substitution_keys.update(get_config().value_replace.keys())
|
||||
for attr in substitution_keys:
|
||||
newkey: str = get_config().key_replace.get(attr, attr)
|
||||
value: str = tmp.get(attr, '')
|
||||
newval_template: str = get_config().value_replace.get(attr, str(value))
|
||||
jtemplate: jinja2.Template = get_jenv().from_string(newval_template)
|
||||
newvalue: str = jtemplate.render(**tmp)
|
||||
attributes[newkey] = newvalue
|
||||
self.uid: str = f'{cname}-{start.strftime("%Y%m%dT%H%M%S%Z")}'
|
||||
super().__init__('event', attributes)
|
||||
|
||||
def serialize(self) -> Dict[str, Any]:
|
||||
event: Dict[str, Any] = {
|
||||
'metric': {
|
||||
'__name__': 'event',
|
||||
'calendar': self.calendar
|
||||
},
|
||||
'value': [
|
||||
self.start.timestamp(),
|
||||
1
|
||||
]
|
||||
}
|
||||
event['metric'].update(self._labels)
|
||||
return event
|
24
icalendar_timeseries_server/main.py
Normal file
24
icalendar_timeseries_server/main.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
import sys
|
||||
|
||||
import bottle
|
||||
|
||||
from icalendar_timeseries_server.config import load_config, load_default_config, get_config
|
||||
|
||||
# Contains decorated bottle handler function for /api/v1/query
|
||||
# noinspection PyUnresolvedReferences
|
||||
from icalendar_timeseries_server.api import prometheus_api
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) == 1:
|
||||
load_default_config()
|
||||
elif len(sys.argv) == 2:
|
||||
load_config(sys.argv[1])
|
||||
else:
|
||||
print(f'Can only read one config file, got "{" ".join(sys.argv[1:])}"')
|
||||
exit(1)
|
||||
bottle.run(host=get_config().addr, port=get_config().port)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
189
icalendar_timeseries_server/query.py
Normal file
189
icalendar_timeseries_server/query.py
Normal file
|
@ -0,0 +1,189 @@
|
|||
from typing import Dict
|
||||
|
||||
import re
|
||||
|
||||
LABEL_MATCH_OPERATORS = [
|
||||
'=',
|
||||
'!=',
|
||||
'=~',
|
||||
'!~'
|
||||
]
|
||||
|
||||
|
||||
class Metric:
|
||||
|
||||
def __init__(self, name: str, labels: Dict[str, str]):
|
||||
self._name: str = name
|
||||
self._labels: Dict[str, str] = dict()
|
||||
for k, v in labels.items():
|
||||
self._labels[k] = v
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def keys(self):
|
||||
return self._labels.keys()
|
||||
|
||||
def __getitem__(self, item: str) -> str:
|
||||
return self._labels.get(item, None)
|
||||
|
||||
def __getattr__(self, item: str) -> str:
|
||||
return self._labels.get(item, None)
|
||||
|
||||
|
||||
class LabelFilter:
|
||||
|
||||
def __init__(self, op: str, label: str, value: str):
|
||||
if op not in LABEL_MATCH_OPERATORS:
|
||||
raise ValueError()
|
||||
self._op = op
|
||||
self._label = label
|
||||
self._value = value
|
||||
|
||||
def __call__(self, metric: Metric) -> bool:
|
||||
if self._label not in metric.keys():
|
||||
return False
|
||||
if self._op == '=':
|
||||
return metric[self._label] == self._value
|
||||
elif self._op == '!=':
|
||||
return metric[self._label] != self._value
|
||||
elif self._op == '=~':
|
||||
return re.match(self._value, metric[self._label]) is not None
|
||||
elif self._op == '!~':
|
||||
return re.match(self._value, metric[self._label]) is None
|
||||
|
||||
|
||||
class MetricQuery:
|
||||
|
||||
def __init__(self, q: str) -> None:
|
||||
self._filters = []
|
||||
# The query '1+1' is used by Grafana to test the connection.
|
||||
if q == "1+1":
|
||||
return
|
||||
self.__parse(q)
|
||||
|
||||
def __parse(self, q: str):
|
||||
print(q)
|
||||
# globalstate:
|
||||
# 0 = parsing metric name
|
||||
# 1 = parsing filters
|
||||
# 2 = fully parsed query
|
||||
globalstate = 0
|
||||
self._metric_name = ''
|
||||
# buffer for the name of the filter that is being parsed
|
||||
cfiltername = ''
|
||||
# buffer for the filter operator that is being parsed
|
||||
coperator = ''
|
||||
# buffer for the filter value that is being parsed
|
||||
cfiltervalue = ''
|
||||
# filterstate:
|
||||
# 0 = parsing label name
|
||||
# 1 = parsing operator
|
||||
# 2 = parsing filter value
|
||||
filterstate = 0
|
||||
# Whether currently inside a quoted string
|
||||
quoted = False
|
||||
# Whether the last character started an escape sequence
|
||||
escaping = False
|
||||
# query string index
|
||||
i = 0
|
||||
while i < len(q):
|
||||
c = q[i]
|
||||
i += 1
|
||||
if globalstate == 0:
|
||||
# Parsing the metric name: append the character to the name buffer,
|
||||
# or move on to the filter parsing step when "{" is encountered.
|
||||
if c == '{':
|
||||
globalstate = 1
|
||||
else:
|
||||
self._metric_name += c
|
||||
elif globalstate == 1:
|
||||
# Parsing the query's filters, until an unescaped "}" is encountered.
|
||||
if filterstate == 0:
|
||||
# Parsing the filter's label name.
|
||||
# When a character is not accepted, re-loop over it in the next mode.
|
||||
if c in '_: 0123456789 ' or ('a' <= c <= 'z') or ('A' <= c <= 'Z'):
|
||||
cfiltername += c
|
||||
else:
|
||||
filterstate = 1
|
||||
i -= 1
|
||||
elif filterstate == 1:
|
||||
# Parsing the filter's operator, one of =, !=, =~, !~.
|
||||
# When a character is not accepted, re-loop over it in the next mode.
|
||||
if c in '!~= ':
|
||||
coperator += c
|
||||
else:
|
||||
filterstate = 2
|
||||
i -= 1
|
||||
elif filterstate == 2:
|
||||
# Parsing the filter's value.
|
||||
# When a "," is encountered, start with the next filter. When a "}" is encountered, parsing is done.
|
||||
if not quoted:
|
||||
if c.strip() == '':
|
||||
continue
|
||||
if c == '"':
|
||||
quoted = True
|
||||
elif c == ',' or c == '}':
|
||||
# Done parsing the filter. Validate label name and operator.
|
||||
if coperator not in LABEL_MATCH_OPERATORS:
|
||||
raise ValueError(f'Invalid operator: {coperator.strip()}')
|
||||
if not re.match('^[a-zA-Z_:][a-zA-Z0-9_:]*$', cfiltername.strip()):
|
||||
raise ValueError(f'Invalid label name: {cfiltername.strip()}')
|
||||
self._filters.append(LabelFilter(coperator.strip(), cfiltername.strip(), cfiltervalue))
|
||||
# Reset filter buffers
|
||||
cfiltername = ''
|
||||
coperator = ''
|
||||
cfiltervalue = ''
|
||||
filterstate = 0
|
||||
if c == '}':
|
||||
globalstate = 2
|
||||
else:
|
||||
raise ValueError()
|
||||
else:
|
||||
# Quoting and escaping
|
||||
if c == '"' and not escaping:
|
||||
quoted = False
|
||||
else:
|
||||
if escaping:
|
||||
escaping = False
|
||||
cfiltervalue += c
|
||||
else:
|
||||
if c == '\\':
|
||||
escaping = True
|
||||
else:
|
||||
cfiltervalue += c
|
||||
else:
|
||||
if c.strip() == '':
|
||||
# Trim remaining whitespace
|
||||
continue
|
||||
else:
|
||||
raise ValueError('Garbage after metric query')
|
||||
# Match metric name against the regex from the Prometheus documentation
|
||||
if not re.match('^[a-zA-Z_:][a-zA-Z0-9_:]*$', self._metric_name.strip()):
|
||||
raise ValueError(f'Invalid metric name: {self._metric_name.strip()}')
|
||||
if escaping:
|
||||
raise ValueError('Expected escape sequence, got EOF')
|
||||
elif quoted:
|
||||
raise ValueError('Expected \'\"\', got EOF')
|
||||
elif globalstate == 1:
|
||||
raise ValueError('Expected \'}\', got EOF')
|
||||
elif filterstate != 0:
|
||||
raise ValueError('Unexpected EOF')
|
||||
|
||||
def __call__(self, metric: Metric):
|
||||
"""
|
||||
Applies the filter deducted from the query string to the given metric.
|
||||
|
||||
:param metric: The metric to apply the filter to.
|
||||
:return: True if the filter matches, False otherwise.
|
||||
"""
|
||||
# Never match a metric with a different name
|
||||
if metric.name != self._metric_name:
|
||||
return False
|
||||
# Iterate the single filters, return False if even one does not match
|
||||
for f in self._filters:
|
||||
if not f(metric):
|
||||
return False
|
||||
# Return True if all filters matched
|
||||
return True
|
0
icalendar_timeseries_server/test/__init__.py
Normal file
0
icalendar_timeseries_server/test/__init__.py
Normal file
128
icalendar_timeseries_server/test/test_config.py
Normal file
128
icalendar_timeseries_server/test/test_config.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
|
||||
import unittest
|
||||
|
||||
import json
|
||||
import pytz
|
||||
from datetime import timedelta
|
||||
|
||||
from isodate.duration import Duration
|
||||
|
||||
from icalendar_timeseries_server.config import _keycheck, _parse_timedelta, _parse_timezone, Config
|
||||
|
||||
|
||||
_CONFIG_VALID = """
|
||||
{
|
||||
"addr": "127.0.0.1",
|
||||
"port": 8090,
|
||||
"start_delta": "-PT3H",
|
||||
"end_delta": "P30D",
|
||||
"cache": "PT15M",
|
||||
"tz": "Europe/Zurich",
|
||||
"calendars": {
|
||||
"private": {
|
||||
"url": "https://example.cloud/dav/me/private.ics",
|
||||
"auth": {
|
||||
"type": "basic",
|
||||
"username": "me",
|
||||
"password": "mysupersecurepassword"
|
||||
}
|
||||
},
|
||||
"public": {
|
||||
"url": "https://example.cloud/dav/me/public.ics"
|
||||
},
|
||||
"confidential": {
|
||||
"url": "https://example.cloud/dav/me/confidential.ics",
|
||||
"ca": "/etc/ssl/ca.pem",
|
||||
"auth": {
|
||||
"type": "tls",
|
||||
"keyfile": "/etc/ssl/client.pem",
|
||||
"passphrase": "mysupersecurepassword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"key_replace": {
|
||||
"summary": "01_summary",
|
||||
"description": "02_description"
|
||||
},
|
||||
"value_replace": {
|
||||
"summary": "{{ summary|truncate(100) }}",
|
||||
"description": "{{ description|truncate(100) }}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ConfigTest(unittest.TestCase):
|
||||
|
||||
def test_keycheck_valid(self) -> None:
|
||||
config = {
|
||||
'foo': 'bar',
|
||||
'bar': 42,
|
||||
'baz': [],
|
||||
'qux': {}
|
||||
}
|
||||
strvalue = _keycheck('foo', config, str, '', valid_values=['foo', 'bar', 'baz'])
|
||||
self.assertEqual(strvalue, 'bar')
|
||||
strdefvalue = _keycheck('fooo', config, str, '', default_value='baar')
|
||||
self.assertEqual(strdefvalue, 'baar')
|
||||
stroptvalue = _keycheck('foooo', config, str, '', optional=True)
|
||||
self.assertIsNone(stroptvalue)
|
||||
intvalue = _keycheck('bar', config, int, '')
|
||||
self.assertEqual(intvalue, 42)
|
||||
listvalue = _keycheck('baz', config, list, '')
|
||||
self.assertEqual(listvalue, [])
|
||||
objvalue = _keycheck('qux', config, dict, '')
|
||||
self.assertEqual(objvalue, {})
|
||||
|
||||
def test_keycheck_missing(self) -> None:
|
||||
config = {
|
||||
'foo': 'bar',
|
||||
}
|
||||
with self.assertRaises(KeyError):
|
||||
_keycheck('baz', config, str, '')
|
||||
|
||||
def test_keycheck_type(self) -> None:
|
||||
config = {
|
||||
'foo': 'bar',
|
||||
}
|
||||
with self.assertRaises(TypeError):
|
||||
_keycheck('foo', config, int, '')
|
||||
|
||||
def test_keycheck_value(self) -> None:
|
||||
config = {
|
||||
'foo': '1337',
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
_keycheck('foo', config, str, '', valid_values=['foo', 'bar', 'baz'])
|
||||
|
||||
def test_parse_timedelta(self) -> None:
|
||||
config = {
|
||||
'pos': 'PT42S',
|
||||
'neg': '-PT42S'
|
||||
}
|
||||
self.assertEqual(_parse_timedelta('zero', config, '', default_value='PT'), timedelta(seconds=0))
|
||||
self.assertEqual(_parse_timedelta('pos', config, ''), timedelta(seconds=42))
|
||||
self.assertEqual(_parse_timedelta('neg', config, ''), timedelta(seconds=-42))
|
||||
self.assertEqual(_parse_timedelta('pos', config, '', force_positive=True), timedelta(seconds=42))
|
||||
with self.assertRaises(ValueError):
|
||||
_parse_timedelta('neg', config, '', force_positive=True)
|
||||
|
||||
def test_parse_timezone(self) -> None:
|
||||
config = {
|
||||
'tz': 'Europe/Zurich',
|
||||
'notz': 'North/Winterfell'
|
||||
}
|
||||
self.assertEqual(_parse_timezone('tz', config, ''), pytz.timezone('Europe/Zurich'))
|
||||
self.assertEqual(_parse_timezone('def', config, '', default_value='Europe/Berlin'),
|
||||
pytz.timezone('Europe/Berlin'))
|
||||
with self.assertRaises(pytz.exceptions.UnknownTimeZoneError):
|
||||
_parse_timezone('notz', config, '')
|
||||
|
||||
def test_parse_full_config_valid(self):
|
||||
config = Config(json.loads(_CONFIG_VALID))
|
||||
self.assertEqual(config.addr, '127.0.0.1')
|
||||
self.assertEqual(config.port, 8090)
|
||||
self.assertEqual(config.start_delta, Duration(hours=-3))
|
||||
self.assertEqual(config.end_delta, Duration(days=30))
|
||||
self.assertEqual(config.cache, Duration(minutes=15))
|
||||
self.assertEqual(config.tz, pytz.timezone('Europe/Zurich'))
|
|
@ -0,0 +1 @@
|
|||
/etc/icalendar-timeseries-server.json
|
14
package/debian/icalendar-timeseries-server/DEBIAN/control
Normal file
14
package/debian/icalendar-timeseries-server/DEBIAN/control
Normal file
|
@ -0,0 +1,14 @@
|
|||
Package: icalendar-timeseries-server
|
||||
Version: 0.1
|
||||
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
||||
Section: web
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.7), python3-jinja2, python3-bottle, python3-dateutil, python3-icalendar, python3-isodate, python3-tz
|
||||
Description: Scrape iCalendar endpoints and present their data in a
|
||||
timeseries format. A small service that scrapes iCalendar files
|
||||
served over HTTP, parses their contents and returns a timeseries
|
||||
format compatible to the /api/v1/query API endpoint of a Prometheus
|
||||
server. This allows e.g. a Grafana administrator to add a Prometheus
|
||||
data source pointing at this server, returning the events in the
|
||||
calendars in the event metric.
|
20
package/debian/icalendar-timeseries-server/DEBIAN/postinst
Executable file
20
package/debian/icalendar-timeseries-server/DEBIAN/postinst
Executable file
|
@ -0,0 +1,20 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$1" == "configure" ]]; then
|
||||
|
||||
if ! getent group its >/dev/null; then
|
||||
groupadd --system its
|
||||
fi
|
||||
|
||||
if ! getent passwd its >/dev/null; then
|
||||
useradd --system --create-home --gid its --home-dir /var/lib/its --shell /usr/sbin/nologin its
|
||||
fi
|
||||
|
||||
chown its:its /var/lib/its
|
||||
chmod 0750 /var/lib/its
|
||||
|
||||
systemctl daemon-reload || true
|
||||
|
||||
fi
|
9
package/debian/icalendar-timeseries-server/DEBIAN/postrm
Executable file
9
package/debian/icalendar-timeseries-server/DEBIAN/postrm
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$1" == "remove" ]]; then
|
||||
|
||||
systemctl daemon-reload || true
|
||||
|
||||
fi
|
9
package/debian/icalendar-timeseries-server/DEBIAN/prerm
Executable file
9
package/debian/icalendar-timeseries-server/DEBIAN/prerm
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$1" == "remove" ]]; then
|
||||
|
||||
userdel its
|
||||
|
||||
fi
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"addr": "127.0.0.1",
|
||||
"port": 8090,
|
||||
"start_delta": "-PT3H",
|
||||
"end_delta": "P60D",
|
||||
"cache": "PT3M",
|
||||
"tz": "Europe/Zurich",
|
||||
"calendars": {},
|
||||
"key_replace": {
|
||||
"summary": "01_summary",
|
||||
"description": "02_description",
|
||||
"calendar": "03_calendar"
|
||||
},
|
||||
"value_replace": {
|
||||
"summary": "{{ summary|truncate(100, end=' \\N{HORIZONTAL ELLIPSIS}') }}",
|
||||
"description": "{{ description|truncate(100, end=' \\N{HORIZONTAL ELLIPSIS}') }}"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
[Unit]
|
||||
Description=icalendar-timeseries-server
|
||||
After=networking.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 -m icalendar_timeseries_server /etc/icalendar-timeseries-server.json
|
||||
User=its
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -0,0 +1,16 @@
|
|||
Copyright 2019 s3lph
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
157
package/release.py
Executable file
157
package/release.py
Executable file
|
@ -0,0 +1,157 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import http.client
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
def parse_changelog(tag: str) -> Optional[str]:
|
||||
release_changelog: str = ''
|
||||
with open('CHANGELOG.md', 'r') as f:
|
||||
in_target: bool = False
|
||||
done: bool = False
|
||||
for line in f.readlines():
|
||||
if in_target:
|
||||
if f'<!-- END RELEASE {tag} -->' in line:
|
||||
done = True
|
||||
break
|
||||
release_changelog += line
|
||||
elif f'<!-- BEGIN RELEASE {tag} -->' in line:
|
||||
in_target = True
|
||||
continue
|
||||
if not done:
|
||||
return None
|
||||
return release_changelog
|
||||
|
||||
|
||||
def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]:
|
||||
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
|
||||
headers: Dict[str, str] = {
|
||||
'Private-Token': api_token
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
|
||||
except HTTPError as e:
|
||||
print(e.read().decode())
|
||||
sys.exit(1)
|
||||
resp_data: bytes = resp.read()
|
||||
joblist: List[Dict[str, Any]] = json.loads(resp_data.decode())
|
||||
|
||||
jobidmap: Dict[str, str] = {}
|
||||
for job in joblist:
|
||||
name: str = job['name']
|
||||
job_id: str = job['id']
|
||||
jobidmap[name] = job_id
|
||||
return jobidmap
|
||||
|
||||
|
||||
def fetch_single_shafile(url: str) -> str:
|
||||
req = urllib.request.Request(url)
|
||||
try:
|
||||
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
|
||||
except HTTPError as e:
|
||||
print(e.read().decode())
|
||||
sys.exit(1)
|
||||
resp_data: bytes = resp.readline()
|
||||
shafile: str = resp_data.decode()
|
||||
filename: str = shafile.strip().split(' ')[-1].strip()
|
||||
return filename
|
||||
|
||||
|
||||
def fetch_wheel_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
|
||||
mybase: str = f'{base_url}/jobs/{job_ids["build_wheel"]}/artifacts/raw'
|
||||
wheel_sha_url: str = f'{mybase}/dist/SHA256SUMS'
|
||||
wheel_filename: str = fetch_single_shafile(wheel_sha_url)
|
||||
wheel_url: str = f'{mybase}/dist/{wheel_filename}'
|
||||
return wheel_url, wheel_sha_url
|
||||
|
||||
|
||||
def fetch_debian_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
|
||||
mybase: str = f'{base_url}/jobs/{job_ids["build_debian"]}/artifacts/raw'
|
||||
debian_sha_url: str = f'{mybase}/package/debian/SHA256SUMS'
|
||||
debian_filename: str = fetch_single_shafile(debian_sha_url)
|
||||
debian_url: str = f'{mybase}/package/debian/{debian_filename}'
|
||||
return debian_url, debian_sha_url
|
||||
|
||||
|
||||
def main():
|
||||
api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN')
|
||||
release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG')
|
||||
project_name: Optional[str] = os.getenv('CI_PROJECT_PATH')
|
||||
project_id: Optional[str] = os.getenv('CI_PROJECT_ID')
|
||||
pipeline_id: Optional[str] = os.getenv('CI_PIPELINE_ID')
|
||||
if api_token is None:
|
||||
print('GITLAB_API_TOKEN is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if release_tag is None:
|
||||
print('CI_COMMIT_TAG is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if project_name is None:
|
||||
print('CI_PROJECT_PATH is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if project_id is None:
|
||||
print('CI_PROJECT_ID is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if pipeline_id is None:
|
||||
print('CI_PIPELINE_ID is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
changelog: Optional[str] = parse_changelog(release_tag)
|
||||
if changelog is None:
|
||||
print('Changelog could not be parsed.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
job_ids: Dict[str, str] = fetch_job_ids(project_id, pipeline_id, api_token)
|
||||
|
||||
base_url: str = f'https://gitlab.com/{project_name}/-'
|
||||
|
||||
wheel_url, wheel_sha_url = fetch_wheel_url(base_url, job_ids)
|
||||
debian_url, debian_sha_url = fetch_debian_url(base_url, job_ids)
|
||||
|
||||
augmented_changelog = f'''{changelog.strip()}
|
||||
|
||||
### Download
|
||||
|
||||
- [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url}))
|
||||
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))'''
|
||||
|
||||
post_body: str = json.dumps({'description': augmented_changelog})
|
||||
|
||||
gitlab_release_api_url: str = \
|
||||
f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
|
||||
headers: Dict[str, str] = {
|
||||
'Private-Token': api_token,
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
}
|
||||
|
||||
request = urllib.request.Request(
|
||||
gitlab_release_api_url,
|
||||
post_body.encode('utf-8'),
|
||||
headers=headers,
|
||||
method='POST'
|
||||
)
|
||||
try:
|
||||
response: http.client.HTTPResponse = urllib.request.urlopen(request)
|
||||
except HTTPError as e:
|
||||
print(e.read().decode())
|
||||
sys.exit(1)
|
||||
response_bytes: bytes = response.read()
|
||||
response_str: str = response_bytes.decode()
|
||||
response_data: Dict[str, Any] = json.loads(response_str)
|
||||
|
||||
if response_data['tag_name'] != release_tag:
|
||||
print('Something went wrong...', file=sys.stderr)
|
||||
print(response_str, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(response_data['description'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
@ -0,0 +1 @@
|
|||
.
|
22
setup.cfg
Normal file
22
setup.cfg
Normal file
|
@ -0,0 +1,22 @@
|
|||
|
||||
#
|
||||
# PyCodestyle
|
||||
#
|
||||
|
||||
[pycodestyle]
|
||||
max-line-length = 120
|
||||
statistics = True
|
||||
|
||||
#
|
||||
# Coverage
|
||||
#
|
||||
|
||||
[run]
|
||||
branch = True
|
||||
parallel = True
|
||||
source = icalendar_timeseries_server/
|
||||
|
||||
[report]
|
||||
show_missing = True
|
||||
include = icalendar_timeseries_server/*
|
||||
omit = */test/*.py
|
40
setup.py
Executable file
40
setup.py
Executable file
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
from icalendar_timeseries_server import __version__
|
||||
|
||||
setup(
|
||||
name='icalendar_timeseries_server',
|
||||
version=__version__,
|
||||
author='s3lph',
|
||||
author_email='',
|
||||
description='',
|
||||
license='MIT',
|
||||
keywords='ical,icalendar,timeseries,prometheus,grafana',
|
||||
url='https://gitlab.com/s3lph/icalendar-timeseries-server',
|
||||
packages=find_packages(exclude=['*.test']),
|
||||
long_description='',
|
||||
python_requires='>=3.6',
|
||||
install_requires=[
|
||||
'bottle',
|
||||
'python-dateutil',
|
||||
'icalendar',
|
||||
'isodate',
|
||||
'jinja2',
|
||||
'pytz'
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'icalendar-timeseries-server = icalendar_timeseries_server:main'
|
||||
]
|
||||
},
|
||||
classifiers=[
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Environment :: Web Environment',
|
||||
'Framework :: Bottle',
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Topic :: System :: Monitoring'
|
||||
],
|
||||
)
|
Loading…
Reference in a new issue