Skip to content

Commit

Permalink
Merge pull request #51 from surface-security/develop_update
Browse files Browse the repository at this point in the history
Updates from develop branch, bump to 1.2.0
  • Loading branch information
DDuarte committed Feb 16, 2024
2 parents b17c278 + 9fb68fb commit 1e0e706
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 16 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The following app settings are available for customization (from [dkron/apps.py]
| DKRON_URL | `http://localhost:8888` | dkron server URL |
| DKRON_PATH | | used to build browser-visible URLs to dkron - can be a full URL if no reverse proxy is being used |
| DKRON_BIN_DIR | | directory to store and execute the dkron binaries, defaults to temporary one - hardly optimal, do set one up! |
| DKRON_VERSION | `3.1.10` | dkron version to (download and) use |
| DKRON_VERSION | `3.2.7` | dkron version to (download and) use |
| DKRON_DOWNLOAD_URL_TEMPLATE | `https://github.com/distribworks/dkron/releases/download/v{version}/dkron_{version}_{system}_{machine}.tar.gz` | can be changed in case a dkron fork is meant to be used |
| DKRON_SERVER | `False` | always `run_dkron` in server mode |
| DKRON_TAGS | `[]` | tags for the agent/server created by `run_dkron` - `label=` tag is not required as it is added by `DKRON_JOB_LABEL` |
Expand All @@ -35,8 +35,10 @@ The following app settings are available for customization (from [dkron/apps.py]
| DKRON_ENCRYPT | | gossip encrypt key for `run_dkron` |
| DKRON_API_AUTH | | HTTP Basic auth header value, if dkron instance is protected with it (really recommended, if instance is exposed) |
| DKRON_TOKEN | | Token used by `run_dkron` for webhook calls into this app |
| DKRON_WEBHOOK_URL | | URL called by dkron webhooks to post job status to this app - passed as `--webhook-url` to dkron, so you need to map `dkron.views.webhook` in your project urls.py and this should be full URL to that route and reachable by dkron|
| DKRON_PRE_WEBHOOK_URL | | URL called by dkron webhooks to post job start to this app - passed as `--pre-webhook-url` to dkron, so you need to map `dkron.views.pre_webhook` in your project urls.py and this should be full URL to that route and reachable by dkron. Requires DKRON_SENTRY_CRON_URL otherwise nothing would happen. |
| DKRON_WEBHOOK_URL | | URL called by dkron webhooks to post job status to this app - passed as `--webhook-url` to dkron, so you need to map `dkron.views.webhook` in your project urls.py and this should be full URL to that route and reachable by dkron |
| DKRON_NAMESPACE | | string to be prefixed to each job created by this app in dkron so the same dkron cluster can be used by different apps/instances without conflicting job names (assuming unique namespaces ^^) |
| DKRON_SENTRY_CRON_URL | Optional Sentry URL used for monitoring jobs. Use placeholder `<monitor_slug>` in URL for job name. |

Besides starting the django app (with `./manage.py runserver`, `gunicorn` or similar) also start dkron agent with `./manage.py run_dkron`:

Expand Down
6 changes: 5 additions & 1 deletion dkron/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# directory to store and execute the dkron binaries, defaults to temporary one - hardly optimal, do set one up!
BIN_DIR=None,
# dkron version to (download and) use
VERSION='3.1.10',
VERSION='3.2.7',
# can be changed in case a dkron fork is meant to be used
DOWNLOAD_URL_TEMPLATE='https://github.com/distribworks/dkron/releases/download/v{version}/dkron_{version}_{system}_{machine}.tar.gz',
# always `run_dkron` in server mode
Expand All @@ -29,12 +29,16 @@
API_AUTH=None,
# Token used by `run_dkron` for webhook calls into this app
TOKEN=None,
# URL called by dkron webhooks to post job start to this app - passed as `--pre-webhook-url` to dkron, so you need to map `dkron.views.pre_webhook` in your project urls.py and this should be full URL to that route and reachable by dkron. Requires DKRON_SENTRY_CRON_URL otherwise nothing would happen
PRE_WEBHOOK_URL=None,
# URL called by dkron webhooks to post job status to this app - passed as `--webhook-url` to dkron, so you need to map `dkron.views.webhook` in your project urls.py and this should be full URL to that route and reachable by dkron
WEBHOOK_URL=None,
# string to be prefixed to each job created by this app in dkron so the same dkron cluster can be used by different apps/instances without conflicting job names (assuming unique namespaces ^^)
NAMESPACE=None,
# node name to be passed to dkron as `--node-name` - defaults to machine hostname
NODE_NAME=None,
# Optional Sentry URL used for monitoring jobs. Use placeholder `<monitor_slug>` in URL for job name.
SENTRY_CRON_URL=None,
)


Expand Down
11 changes: 10 additions & 1 deletion dkron/management/commands/run_dkron.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import tempfile
import os
import platform
import requests
import shutil
import tarfile
Expand Down Expand Up @@ -84,6 +83,16 @@ def handle(self, *_, **options):
args.extend(['--join', j])
if settings.DKRON_WORKDIR:
os.chdir(settings.DKRON_WORKDIR)
if settings.DKRON_PRE_WEBHOOK_URL and settings.DKRON_TOKEN and settings.DKRON_SENTRY_CRON_URL:
flag_name = '--pre-webhook-url' if utils.dkron_binary_version() < (3, 2, 0) else '--pre-webhook-endpoint'
args.extend(
[
flag_name,
settings.DKRON_PRE_WEBHOOK_URL,
'--pre-webhook-payload',
f'{settings.DKRON_TOKEN}\n{{{{ .JobName }}}}',
]
)
if settings.DKRON_WEBHOOK_URL and settings.DKRON_TOKEN:
flag_name = '--webhook-url' if utils.dkron_binary_version() < (3, 2, 0) else '--webhook-endpoint'
args.extend(
Expand Down
5 changes: 4 additions & 1 deletion dkron/urls_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@

app_name = 'dkron_api'

urlpatterns = [path('webhook/', views.webhook, name='webhook')]
urlpatterns = [
path('pre-webhook/', views.pre_webhook, name='pre_webhook'),
path('webhook/', views.webhook, name='webhook'),
]
82 changes: 81 additions & 1 deletion dkron/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging
import platform
import time
from typing import Iterator, Literal, Optional, Union
from typing import Any, Iterator, Literal, Optional, Union
import requests
from functools import lru_cache
import re
Expand Down Expand Up @@ -337,3 +337,83 @@ def run_async(_command, *args, **kwargs) -> Union[tuple[str, str], str]:
except requests.ConnectionError:
# if dkron not available, use after_response
return __run_async.after_response(_command, *args, **kwargs)


def dkron_to_sentry_schedule(job: Optional[models.Job]) -> dict[str, Any]:
# https://dkron.io/docs/usage/cron-spec/
# https://docs.sentry.io/product/crons/getting-started/http/
if not job or not job.enabled or not job.schedule or job.schedule == '@manually':
return {"type": "crontab", "value": "0 5 31 2 *"} # never executes

if job.schedule.startswith('@parent '):
parent_job = models.Job.objects.filter(name=job.schedule[8:]).first()
return dkron_to_sentry_schedule(parent_job)

if job.schedule in ('@yearly', '@annually'):
return {"type": "crontab", "value": "0 0 1 1 *"}

if job.schedule == '@monthly':
return {"type": "crontab", "value": "0 0 1 * *"}

if job.schedule == '@weekly':
return {"type": "crontab", "value": "0 0 * * 0"}

if job.schedule in ('@daily', '@midnight'):
return {"type": "crontab", "value": "0 0 * * *"}

if job.schedule == '@hourly':
return {"type": "crontab", "value": "0 * * * *"}

if job.schedule == '@minutely':
return {"type": "crontab", "value": "* * * * *"}

if job.schedule.startswith("@every "):
# FIXME: not the full spec of https://pkg.go.dev/time#ParseDuration
match = re.match(r"@every (\d+)([smh])", job.schedule)
duration = int(match.group(1))
unit = match.group(2)
if unit == "s":
unit = "second"
elif unit == "m":
unit = "minute"
elif unit == "h":
unit = "hour"

return {"type": "interval", "value": duration, "unit": unit}

schedule_without_seconds = " ".join(job.schedule.split(" ")[1:])
return {"type": "crontab", "value": schedule_without_seconds}


def get_timezone() -> str:
try:
import tzlocal

return tzlocal.get_localzone().zone
except ImportError:
return "Europe/Dublin"


def send_sentry_monitor(job: models.Job, status: Literal["in_progress", "ok", "error"]) -> bool:
if not settings.DKRON_SENTRY_CRON_URL:
return False

try:
req = requests.post(
settings.DKRON_SENTRY_CRON_URL.replace("<monitor_slug>", job.name),
json={
"monitor_config": {
"schedule": dkron_to_sentry_schedule(job),
"checkin_margin": 5, # TODO: make this configurable
"max_runtime": 30, # TODO: make this configurable
"timezone": get_timezone(),
},
"status": status,
},
)
req.raise_for_status()
return True
except Exception:
logger.exception("Failed to send monitor config to Sentry")

return False
31 changes: 31 additions & 0 deletions dkron/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,34 @@ def auth(request):
return http.HttpResponse()


@csrf_exempt
def pre_webhook(request):
if settings.DKRON_TOKEN is None:
return http.HttpResponseNotFound()

if request.method != 'POST':
return http.HttpResponseBadRequest()

lines = request.body.decode().splitlines()
if len(lines) != 2:
return http.HttpResponseBadRequest()

if lines[0] != settings.DKRON_TOKEN:
return http.HttpResponseForbidden()

job_name = utils.trim_namespace(lines[1])
if not job_name:
return http.HttpResponseNotFound()

o = models.Job.objects.filter(name=job_name).first()
if o is None:
return http.HttpResponseNotFound()

utils.send_sentry_monitor(o, "in_progress")

return http.HttpResponse()


@csrf_exempt
def webhook(request):
if settings.DKRON_TOKEN is None:
Expand All @@ -49,6 +77,9 @@ def webhook(request):
o.last_run_success = lines[2] == 'true'
o.last_run_date = timezone.now()
o.save()

utils.send_sentry_monitor(o, "ok" if o.last_run_success else "error")

if not o.last_run_success and o.notify_on_error:
notify(
'dkron_failed_job',
Expand Down
2 changes: 1 addition & 1 deletion testapp/testapp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
DKRON_URL = 'http://localhost:8888/'
DKRON_JOB_LABEL = 'testapp'
DKRON_SERVER = True
# to switch a different version than default (currently 3.1.10)
# to switch a different version than default
# DKRON_VERSION= '3.2.2'

# DKRON_PATH is meant to be setup in an nginx location before the main app, so it can re-use app authentication (and authz) to access dkron (which has no authentication)
Expand Down
20 changes: 12 additions & 8 deletions testapp/tests/test_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,11 +466,13 @@ def test_run_async(self, mp, tp, job_prefix=''):
self.assertEqual(x, expected_return)

mp.reset_mock()

expected_mock_call.kwargs['json']['schedule'] = '@at 2023-02-07T00:00:05+00:00'
expected_mock_call.kwargs['params'] = {}
type(mpp).status_code = mock.PropertyMock(side_effect=[201, 200])
utils.dkron_binary_version.cache_clear()
x = utils.run_async('somecommand', 'arg1', kwarg='value', enable=True)
with override_settings(DKRON_VERSION='3.1.10'):
x = utils.run_async('somecommand', 'arg1', kwarg='value', enable=True)
mp.assert_has_calls([expected_mock_call])
self.assertEqual(x, expected_return)

Expand Down Expand Up @@ -595,13 +597,15 @@ def test_run_dkron_webhook(self, exec_mock):
f.write(b'1')

with mock.patch('tempfile.mkdtemp', return_value=tmp):
# using default version of 3.1.10
management.call_command('run_dkron', stdout=out, stderr=err)
self.assertEqual(exec_mock.call_count, 1)
exec_args = exec_mock.call_args_list[0][0][1]
self.assertIn('--webhook-url', exec_args)
opt_i = exec_args.index('--webhook-url')
self.assertEqual(exec_args[opt_i : opt_i + 3], ['--webhook-url', 'https://whatever', '--webhook-payload'])
with override_settings(DKRON_VERSION='3.1.10'):
management.call_command('run_dkron', stdout=out, stderr=err)
self.assertEqual(exec_mock.call_count, 1)
exec_args = exec_mock.call_args_list[0][0][1]
self.assertIn('--webhook-url', exec_args)
opt_i = exec_args.index('--webhook-url')
self.assertEqual(
exec_args[opt_i : opt_i + 3], ['--webhook-url', 'https://whatever', '--webhook-payload']
)

exec_mock.reset_mock()
with override_settings(DKRON_VERSION='3.2.1'):
Expand Down
2 changes: 1 addition & 1 deletion testapp/tests/test_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Test(TestCase):
@mock.patch('platform.machine')
@mock.patch('platform.system')
@mock.patch('requests.get')
@override_settings(DKRON_BIN_DIR=None)
@override_settings(DKRON_BIN_DIR=None, DKRON_VERSION='3.1.10')
def test_run_dkron_download(self, req_mock, sys_mock, mach_mock):
from dkron.management.commands import run_dkron
from io import BytesIO
Expand Down

0 comments on commit 1e0e706

Please sign in to comment.