Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/openedx-status-api
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
- uses: docker/build-push-action@v6
with:
context: .
Expand Down
22 changes: 4 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,9 @@ Django, so it keeps working when Open edX is down.

## Quick start

This runs the API, the web page ([openedx-status-web](https://github.com/Abstract-Tech/openedx-status-web))
and a Caddy proxy with Docker Compose:

cd deploy
cp .env.example .env # set STATUS_DOMAIN, and point its DNS at this machine
cp checks.example.yml checks.yml # put your own addresses in
docker compose up -d

Then open `https://<STATUS_DOMAIN>`. To try it locally without HTTPS, set `STATUS_DOMAIN=:80`,
`STATUS_BIND=127.0.0.1` and `STATUS_HTTP_PORT=8088` in `.env`.

Day to day:

- Change the checks: edit `checks.yml`, then `docker compose up -d --force-recreate status-api` (a plain restart can keep a stale copy of the file).
- Logs: `docker compose logs -f status-api`.
- Backup: the history is the SQLite file in the `status-data` volume.
- Already have a reverse proxy? Remove the `caddy` service and point your proxy at `status-web:8080`.
To run the API, the web page ([openedx-status-web](https://github.com/Abstract-Tech/openedx-status-web))
and a Caddy proxy together, use [openedx-status-deploy](https://github.com/Abstract-Tech/openedx-status-deploy).
This repo only contains the API and its Docker image (`ghcr.io/abstract-tech/openedx-status-api`).

To run the API alone without Docker:

Expand All @@ -44,7 +30,7 @@ If you mount a host folder as `/data`, run `chown 10001` on it, because the cont
- `params`: extra options for some kinds

To keep a secret out of the file, write `<name>_env: SOME_VARIABLE` instead of the value, for example
`password_env: DB_PASSWORD`. With the compose file in `deploy/`, put `DB_PASSWORD=...` in `deploy/.env`
`password_env: DB_PASSWORD`. With [openedx-status-deploy](https://github.com/Abstract-Tech/openedx-status-deploy), put `DB_PASSWORD=...` in its `.env`
(it is passed to the API container) and recreate the container. This works for the http `expected_status` and `verify_tls`, the mysql `user`,
`password` and `database`, the mongo `username` and `password`, the redis `password`, and the ssl `warn_days`.

Expand Down
17 changes: 0 additions & 17 deletions deploy/.env.example

This file was deleted.

2 changes: 0 additions & 2 deletions deploy/.gitignore

This file was deleted.

6 changes: 0 additions & 6 deletions deploy/Caddyfile

This file was deleted.

11 changes: 0 additions & 11 deletions deploy/checks.example.yml

This file was deleted.

70 changes: 0 additions & 70 deletions deploy/docker-compose.yml

This file was deleted.

5 changes: 5 additions & 0 deletions openedx_status_api/incidents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ def __init__(self, store: Store, threshold: int):
self._fails: dict[str, int] = {}
self._first_fail: dict[str, int] = {} # ts of the first failure in the current streak

def restore(self, check: Check):
n, first = self.store.failure_streak(check.id, self.threshold)
if n:
self._fails[check.id], self._first_fail[check.id] = n, first

def observe(self, check: Check, r: Result):
open_id = self.store.open_incident_for(check.id)
if r.ok:
Expand Down
2 changes: 2 additions & 0 deletions openedx_status_api/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ def start(self):
n = self.store.resolve_orphaned_incidents([c.id for c in self.checks], int(time.time()))
if n:
log.info("resolved %d incident(s) for checks no longer configured", n)
for c in self.checks:
self.tracker.restore(c)
self._tasks = [asyncio.create_task(self._loop(c), name=f"check-{c.id}") for c in self.checks]
self._tasks.append(asyncio.create_task(self._prune_loop(), name="prune"))

Expand Down
12 changes: 12 additions & 0 deletions openedx_status_api/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ def is_failing(self, check_id: str, n: int) -> bool:
).fetchall()
return len(rows) == n and not any(r["ok"] for r in rows)

def failure_streak(self, check_id: str, limit: int) -> tuple[int, int | None]:
with self._lock:
rows = self._db.execute(
"SELECT ts, ok FROM results WHERE check_id=? ORDER BY ts DESC, rowid DESC LIMIT ?", (check_id, limit)
).fetchall()
streak = []
for r in rows:
if r["ok"]:
break
streak.append(r["ts"])
return len(streak), (streak[-1] if streak else None)

def days(self, check_id: str, retention: int = 90, now: int | None = None) -> list[dict]:
"""Exactly `retention` entries oldest->newest, zero-filled."""
today = datetime.fromtimestamp(now or time.time(), timezone.utc).date()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "openedx-status-api"
version = "0.1.0"
version = "0.1.2"
description = "Standalone status-page backend for Open edX"
readme = "README.md"
license = {text = "MIT"}
Expand Down
12 changes: 12 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,15 @@ def test_never_reported_check_waits_but_stale_one_degrades(store, settings):
assert build_status(settings, cs, store, NOW)["groups"][0]["status"] == "operational" # b has not reported yet
store.record("b", Result(NOW - 1000, True, "ok"))
assert build_status(settings, cs, store, NOW)["groups"][0]["status"] == "degraded" # b went stale


def test_failure_streak_survives_restart(store):
c = Check("a", "A", "G", "tcp", "x:1")
for i in range(2):
store.record("a", Result(NOW + i, False, ""))
t = IncidentTracker(store, 3)
t.restore(c)
store.record("a", Result(NOW + 2, False, ""))
t.observe(c, Result(NOW + 2, False, ""))
assert store.open_incident_for("a") is not None
assert store.incidents(0)[0]["started"] == NOW
Loading