#!/usr/bin/env python3 """Gitea Actions dashboard with self-hosted SVG status icons (stdlib only).""" import base64 import datetime as dt import json import os import sys import urllib.error import urllib.parse import urllib.request from zoneinfo import ZoneInfo BASE = os.environ['GITEA_URL'].rstrip('/') API = BASE + '/api/v1' TOKEN = os.environ['GITEA_API_TOKEN'] OWNER = os.environ.get('STATUS_OWNER', 'VULN') STATUS_REPO = os.environ.get('STATUS_REPOSITORY', 'status') BRANCH = os.environ.get('STATUS_BRANCH', 'main') NOW = dt.datetime.now(dt.timezone.utc) TZ = ZoneInfo('Europe/Berlin') PAGE_SIZE, MAX_PAGES = 50, 5 # The URLs use Gitea's raw file route; no externally hosted graphics. ICONS = { 'success': ('#208637', 'check'), 'failure': ('#d1242f', 'cross'), 'running': ('#0969da', 'play'), 'waiting': ('#b78103', 'clock'), 'unknown': ('#6e7781', 'question'), 'warning': ('#b78103', 'alert'), 'error': ('#d1242f', 'cross'), } def q(value): return urllib.parse.quote(str(value), safe='') def req(path, method='GET', data=None): encoded = json.dumps(data).encode('utf-8') if data is not None else None request = urllib.request.Request(API + path, data=encoded, method=method, headers={ 'Authorization': 'token ' + TOKEN, 'Accept': 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'vuln-status/3.0', }) try: with urllib.request.urlopen(request, timeout=40) as response: payload = response.read() return json.loads(payload) if payload else {} except urllib.error.HTTPError as e: detail = e.read(500).decode(errors='replace') raise RuntimeError(f'HTTP {e.code} {path}: {detail}') from e def file_path(filename): return f'/repos/{q(OWNER)}/{q(STATUS_REPO)}/contents/{urllib.parse.quote(filename, safe="/")}' def get_file(filename): try: result = req(file_path(filename) + '?ref=' + q(BRANCH)) except RuntimeError as e: if str(e).startswith('HTTP 404 '): return None, None raise return base64.b64decode(result['content']).decode('utf-8'), result['sha'] def put_file(filename, content, message, old=None): if old is None: existing, sha = get_file(filename) else: existing, sha = old if existing == content: return False body = { 'branch': BRANCH, 'content': base64.b64encode(content.encode('utf-8')).decode('ascii'), 'message': message + ' [skip ci]', } if sha: body['sha'] = sha req(file_path(filename), 'PUT' if sha else 'POST', body) return True def parse_time(raw): if not raw or str(raw).startswith('0001-'): return None try: result = dt.datetime.fromisoformat(str(raw).replace('Z', '+00:00')) if result.tzinfo is None: result = result.replace(tzinfo=dt.timezone.utc) return result.astimezone(dt.timezone.utc) except (ValueError, TypeError): return None def display_time(value): return value.astimezone(TZ).strftime('%d.%m.%Y %H:%M %Z') if value else '–' def elapsed(start, end): if not start or not end or end < start: return '–' seconds = int((end - start).total_seconds()) h, rem = divmod(seconds, 3600) m, s = divmod(rem, 60) return f'{h}h {m:02d}m' if h else f'{m}m {s:02d}s' def cell(value): return str(value).replace('|', '\\|').replace('\n', ' ').replace('\r', ' ') def svg(name): color, shape = ICONS[name] graphic = { 'check': '', 'cross': '', 'clock': '', 'play': '', 'question': '', 'alert': '', }[shape] return (f'' f'{graphic}\n') def icon_md(name): # Absolute image URL works in the Gitea README from all link contexts. # The repository must be readable by the viewer; private repos require login. url = (f'{BASE}/{q(OWNER)}/{q(STATUS_REPO)}/raw/branch/' f'{q(BRANCH)}/assets/status-{name}.svg') return f'![{name}]({url})' def get_runs(owner, repo, workflow): found = [] for page in range(1, MAX_PAGES + 1): params = urllib.parse.urlencode({'page': page, 'limit': PAGE_SIZE}) path = f'/repos/{q(owner)}/{q(repo)}/actions/runs?{params}' chunk = req(path).get('workflow_runs', []) for run in chunk: if workflow: name = str(run.get('path') or '').split('@', 1)[0].rsplit('/', 1)[-1] if name != workflow: continue found.append(run) if len(chunk) < PAGE_SIZE: break found.sort(key=lambda run: ( parse_time(run.get('created_at')) or dt.datetime.min.replace(tzinfo=dt.timezone.utc), run.get('id') or 0, ), reverse=True) return found def current_state(run): if not run: return 'unknown', 'Kein Lauf' status = str(run.get('status') or '').lower() conclusion = str(run.get('conclusion') or '').lower() if status in ('running', 'in_progress'): return 'running', 'Läuft' if status in ('queued', 'pending', 'requested', 'waiting'): return 'waiting', 'Wartet' if conclusion == 'success': return 'success', 'Erfolgreich' if conclusion in ('failure', 'timed_out', 'startup_failure'): return 'failure', 'Fehlgeschlagen' if conclusion in ('cancelled', 'skipped'): return 'unknown', conclusion.capitalize() return 'unknown', status or 'Unbekannt' def inspect_source(cfg, source): owner = source.get('owner', cfg.get('owner', OWNER)) repo = source['repo'] runs = get_runs(owner, repo, source.get('workflow')) latest = runs[0] if runs else None successful = next((r for r in runs if str(r.get('conclusion') or '').lower() == 'success'), None) success_at = (parse_time(successful.get('completed_at')) or parse_time(successful.get('updated_at')) or parse_time(successful.get('created_at'))) if successful else None max_age = float(source.get('max_age_hours', cfg.get('max_age_hours', 26))) overdue = success_at is None or NOW - success_at > dt.timedelta(hours=max_age) kind, state = current_state(latest) state_text = f'{icon_md(kind)} {state}' if overdue: state_text += f' / {icon_md("warning")} Überfällig' started = (parse_time(latest.get('started_at')) or parse_time(latest.get('run_started_at')) or parse_time(latest.get('created_at'))) if latest else None completed = (parse_time(latest.get('completed_at')) or parse_time(latest.get('updated_at'))) if latest and str(latest.get('status') or '').lower() == 'completed' else None duration = elapsed(started, completed) link = '–' if latest and latest.get('id'): url = latest.get('html_url') or f'{BASE}/{q(owner)}/{q(repo)}/actions/runs/{latest["id"]}' link = f'[{latest["id"]}]({url})' return kind, overdue, state_text, display_time(success_at), duration, link def main(): cfg_raw, _ = get_file('sources.json') if cfg_raw is None: raise RuntimeError('sources.json nicht gefunden') cfg = json.loads(cfg_raw) # Ensure all SVG graphics exist before referencing them in README. for name in ICONS: filename = f'assets/status-{name}.svg' changed = put_file(filename, svg(name), f'assets: update {name} status icon') if changed: print(f'SVG erstellt: {filename}', flush=True) # Separate Builder status at the top (not included in source totals). # Optional overrides via sources.json: "builder": {"workflow": "build.yml", "max_age_hours": 26} builder_cfg = cfg.get('builder', {}) builder_source = { 'owner': builder_cfg.get('owner', OWNER), 'repo': builder_cfg.get('repo', 'vuln-builder'), 'max_age_hours': builder_cfg.get('max_age_hours', cfg.get('max_age_hours', 26)), } if builder_cfg.get('workflow'): builder_source['workflow'] = builder_cfg['workflow'] builder_owner, builder_repo = builder_source['owner'], builder_source['repo'] builder_url = f'{BASE}/{q(builder_owner)}/{q(builder_repo)}' builder_error = None try: _, _, builder_state, builder_success, builder_duration, builder_run = inspect_source(cfg, builder_source) except Exception as exc: builder_state = f'{icon_md("error")} API-Fehler' builder_success = builder_duration = builder_run = '–' builder_error = f'{builder_repo}: {exc}' print(f'Builder {builder_repo}: {builder_state.split(" ", 1)[-1]}', flush=True) counts = dict(total=len(cfg['sources']), success=0, failure=0, running=0, waiting=0, overdue=0, error=0, unknown=0) errors, rows = [], [] for src in cfg['sources']: owner = src.get('owner', cfg.get('owner', OWNER)) repo = src['repo'] title = src.get('name', repo) url = f'{BASE}/{q(owner)}/{q(repo)}/actions' try: kind, overdue, state, success, duration, link = inspect_source(cfg, src) counts[kind] += 1 if overdue: counts['overdue'] += 1 except Exception as exc: state, success, duration, link = f'{icon_md("error")} API-Fehler', '–', '–', '–' counts['error'] += 1 errors.append(f'{repo}: {exc}') rows.append(f'| [{cell(title)}]({url}) | {state} | {success} | {duration} | {link} |') print(f'{repo}: {state.split(" ", 1)[-1]}', flush=True) lines = [ '# Vulnerability Database – Sync Status', '', 'Automatisch generiertes Dashboard für Gitea Actions.', '', f'**Letzte Prüfung:** {display_time(NOW)} ', f'**Überfällig:** Kein erfolgreicher Lauf innerhalb von {cfg.get("max_age_hours", 26)} Stunden (pro Quelle anpassbar).', '', '## SQLite-Builder', '', f'### {builder_state} · [vuln-builder]({builder_url})', '', '| Letzter erfolgreicher Build | Laufzeit des letzten Jobs | Letzter Job |', '|:---|:---|:---|', f'| **{cell(builder_success)}** | **{cell(builder_duration)}** | {builder_run} |', '', '> Der Builder erzeugt die SQLite-Datenbank aus den synchronisierten Quellen. ' 'Der Status wird automatisch über Gitea Actions aktualisiert.', '', '---', '', '## Übersicht', '', f'- Erfolgreich: **{counts["success"]}**', f'- Laufend: **{counts["running"]}**', f'- Wartend: **{counts["waiting"]}**', f'- Fehlgeschlagen: **{counts["failure"]}**', f'- Überfällig: **{counts["overdue"]}**', f'- API-Fehler: **{counts["error"]}**', '', '## Synchronisierungsstatus', '', '| Datenquelle | Status | Letzter Erfolg | Dauer letzter Lauf | Letzter Job |', '|---|---|---|---|---|', *rows, '', '## Legende', '', f'- {icon_md("success")} Erfolgreich', f'- {icon_md("waiting")} Wartet', f'- {icon_md("running")} Läuft', f'- {icon_md("failure")} Fehlgeschlagen', f'- {icon_md("warning")} Überfällig', f'- {icon_md("error")} API-Fehler', '', '## Links', '', '- [Vulnerability Database](https://vulnerability.apex-project.de)', '- [Projektwebsite](https://www.apex-project.de)', '', ] content = '\n'.join(lines) changed = put_file('README.md', content, 'docs: update vulnerability sync status') print('README aktualisiert.' if changed else 'README unverändert.', flush=True) if builder_error: errors.append(builder_error) if errors: for err in errors: print('ERROR:', err, file=sys.stderr) sys.exit(1) if __name__ == '__main__': main()