Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d34172241 | ||
|
|
454684b646 | ||
|
|
5d1fc601ea | ||
|
|
5c0385e27c | ||
|
|
8110c5949d | ||
|
|
fc510f2b2c | ||
|
|
9cb05ddf77 | ||
|
|
1f4e9c075a | ||
|
|
a95429509f | ||
|
|
169f28363b | ||
|
|
b383f9dd8d | ||
|
|
a8757fd6f1 | ||
|
|
3950867dae | ||
|
|
1bce7581e5 | ||
|
|
b9a2e1011f | ||
|
|
a11108838d | ||
|
|
2ee601c198 | ||
|
|
367d76eb9d |
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
# ci-sync: 2026-05-30T02:31:20Z
|
||||||
|
# Homelab CI — Python lane (git-ci-01) + secret scan (git-ci-02)
|
||||||
|
# Skip: @skipci in branch name or commit message
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
skip-ci-check:
|
||||||
|
runs-on: [homelab, self-hosted, linux]
|
||||||
|
container:
|
||||||
|
image: node:20-bookworm
|
||||||
|
outputs:
|
||||||
|
should-skip: ${{ steps.check.outputs.skip }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
- id: check
|
||||||
|
run: |
|
||||||
|
SKIP=0
|
||||||
|
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
|
||||||
|
MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-$(git log -1 --pretty=%B 2>/dev/null || true)}"
|
||||||
|
echo "$BRANCH" "$MSG" | grep -qi '@skipci' && SKIP=1
|
||||||
|
echo "skip=$SKIP" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
python-ci:
|
||||||
|
needs: skip-ci-check
|
||||||
|
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||||
|
runs-on: [homelab, self-hosted, linux, python]
|
||||||
|
container:
|
||||||
|
# node image: actions/checkout@v4 needs Node; install python3 in-job
|
||||||
|
image: node:20-bookworm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Python tooling
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv
|
||||||
|
python3 -m pip install --upgrade pip --break-system-packages
|
||||||
|
if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi
|
||||||
|
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi
|
||||||
|
pip install bandit pip-audit ruff --break-system-packages
|
||||||
|
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check . || true
|
||||||
|
|
||||||
|
- name: Bandit (advisory)
|
||||||
|
run: bandit -r . -q || true
|
||||||
|
|
||||||
|
- name: pip-audit (advisory)
|
||||||
|
run: pip-audit -r requirements.txt 2>/dev/null || pip-audit 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Pytest
|
||||||
|
run: |
|
||||||
|
if [ -d tests ] || ls test_*.py *_test.py 2>/dev/null; then
|
||||||
|
pip install pytest --break-system-packages
|
||||||
|
pytest -q || true
|
||||||
|
else
|
||||||
|
echo "No tests found — skip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
secret-scan:
|
||||||
|
needs: skip-ci-check
|
||||||
|
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||||
|
runs-on: [homelab, self-hosted, linux, heavy]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Gitleaks
|
||||||
|
run: |
|
||||||
|
extra=""
|
||||||
|
if [ -f .gitleaks.toml ]; then
|
||||||
|
extra="--config /repo/.gitleaks.toml"
|
||||||
|
fi
|
||||||
|
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
|
||||||
|
detect --source /repo --no-banner --redact ${extra}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Homelab bootstrap — gitleaks allowlist (tests, examples, placeholders)
|
||||||
|
title = "homelab gitea bootstrap"
|
||||||
|
|
||||||
|
[allowlist]
|
||||||
|
description = "Test fixtures and example configs are not production secrets"
|
||||||
|
paths = [
|
||||||
|
'''(?i).*\.test\.(ts|tsx|js|jsx|py)$''',
|
||||||
|
'''(?i).*\.spec\.(ts|tsx|js|jsx)$''',
|
||||||
|
'''(?i).*/tests/.*''',
|
||||||
|
'''(?i).*/__tests__/.*''',
|
||||||
|
'''(?i).*\.example\.(yml|yaml|env|json|toml)$''',
|
||||||
|
'''(?i).*vault\.example\.(yml|yaml)$''',
|
||||||
|
'''(?i).*\.env\.example$''',
|
||||||
|
]
|
||||||
|
regexes = [
|
||||||
|
'''(?i)(invalid|fake|dummy|placeholder|example|changeme|change_me|not-a-real)''',
|
||||||
|
'''(?i)sk-or-invalid''',
|
||||||
|
'''(?i)msk-or-invalid''',
|
||||||
|
]
|
||||||
+12
-15
@@ -111,24 +111,21 @@ make beszel-install-agents BESZEL_ONLY=pote-236 # if agent not yet installed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next steps (priority order)
|
**Vikunja:** [todo.levkin.ca → Business → POTE](https://todo.levkin.ca) (`POTE`)
|
||||||
|
|
||||||
### Soon
|
## Open tasks (source of truth)
|
||||||
|
|
||||||
- [ ] **Sync server to `main`** — `make deploy-pote` from laptop (server was rsync’d pre-merge; safe to refresh).
|
| P | Task | Owner | Status |
|
||||||
- [ ] **Confirm first cron email** — tomorrow 07:00 server time; fix via `crontab -e` / `.env` `REPORT_RECIPIENTS` if needed.
|
|---|------|-------|--------|
|
||||||
- [ ] **Beszel agent** — `make beszel-install-agents BESZEL_ONLY=pote-236` if not done.
|
| **P3** | Dedicated `pote@levkine.ca` mailbox (vs shared `alerts@`) | @you | optional / low |
|
||||||
- [ ] **Proxmox backup** — schedule backup for LXC 236 on pve10.
|
| **P3** | Git deploy on LXC (replace rsync-only) | @agent | optional / low |
|
||||||
- [ ] **Merge ansible branch** — homelab inventory/deploy scripts if still on `feature/outline-setup-api`.
|
| **P3** | Kuma LAN health monitor | @agent | optional / low |
|
||||||
|
| **P3** | Mattermost / webhook alerts (email only today) | @agent | optional / low |
|
||||||
|
| **P3** | Full history ingest (second data source beyond kadoa cap) | @agent | optional / low |
|
||||||
|
|
||||||
### Optional enhancements
|
**Closed 2026-07-11:** Beszel agent on pote-236 (`beszel-agent` active).
|
||||||
|
**Closed 2026-07-12:** `make deploy-pote` synced to LXC 236; 07:00 cron email delivered (`idobkin@gmail.com`, log OK). DB ~1129 trades / 80 officials.
|
||||||
- [ ] **Dedicated mailbox** — e.g. `pote@levkine.ca` in Mailcow instead of shared `alerts@` ([smtp-inventory.md](https://git.levkin.ca/ilia/ansible/src/branch/master/docs/guides/smtp-inventory.md) in ansible repo).
|
**Closed 2026-07-15:** Proxmox backup — LXC 236 confirmed in pve10 vzdump job vmid list. Daily email re-verified through Jul 15 (44 successes logged); DB now 85 officials / 1161 trades. **Weekly report bug fixed** — caller in `report_generator.py` passed stale kwarg names (`days_lookback`/`min_suspicious_trades`/`min_timing_score`) to `PatternDetector.identify_repeat_offenders(lookback_days, min_suspicious_rate)`; had been failing silently every Sun 08:00 since ≥2026-07-05. Fixed + deployed to `.48` + verified `generate_weekly_summary()`/`format_as_text()` run clean (next real send: Sun 08:00).
|
||||||
- [ ] **Git deploy on LXC** — clone `gitea@git.levkin.ca:ilia/POTE.git` + deploy key; replace rsync-only workflow.
|
|
||||||
- [ ] **UniFi DNS** — `mail.levkine.ca` → `10.0.10.132` so `.env` can use hostname instead of raw IP ([unifi-static-dhcp.md](https://git.levkin.ca/ilia/ansible/src/branch/master/docs/guides/unifi-static-dhcp.md)).
|
|
||||||
- [ ] **Full history ingest** — kadoa JSON is capped; add second source (capitol-api, Clerk scrape) for backfill.
|
|
||||||
- [ ] **Kuma monitor** — LAN HTTP health script or SSH check (no public URL).
|
|
||||||
- [ ] **Mattermost / webhook alerts** — not implemented; email only today.
|
|
||||||
|
|
||||||
### Not planned (unless you want them)
|
### Not planned (unless you want them)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Test SMTP connection before sending",
|
help="Test SMTP connection before sending",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lookback-days",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Include trades filed in the last N days ending on the report date (default: 1)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--save-to-file",
|
"--save-to-file",
|
||||||
help="Also save report to this file path",
|
help="Also save report to this file path",
|
||||||
@@ -81,7 +87,9 @@ def main():
|
|||||||
logger.info(f"Generating daily report for {report_date or date.today()}...")
|
logger.info(f"Generating daily report for {report_date or date.today()}...")
|
||||||
with get_session() as session:
|
with get_session() as session:
|
||||||
generator = ReportGenerator(session)
|
generator = ReportGenerator(session)
|
||||||
report_data = generator.generate_daily_summary(report_date)
|
report_data = generator.generate_daily_summary(
|
||||||
|
report_date, lookback_days=args.lookback_days
|
||||||
|
)
|
||||||
|
|
||||||
# Format as text and HTML
|
# Format as text and HTML
|
||||||
text_body = generator.format_as_text(report_data, "daily")
|
text_body = generator.format_as_text(report_data, "daily")
|
||||||
|
|||||||
@@ -27,13 +27,15 @@ class ReportGenerator:
|
|||||||
self.detector = PatternDetector(session)
|
self.detector = PatternDetector(session)
|
||||||
|
|
||||||
def generate_daily_summary(
|
def generate_daily_summary(
|
||||||
self, report_date: Optional[date] = None
|
self, report_date: Optional[date] = None, *, lookback_days: int = 1
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate a daily summary report.
|
Generate a daily summary report.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
report_date: Date to generate report for (defaults to today)
|
report_date: Date to generate report for (defaults to today)
|
||||||
|
lookback_days: Include trades filed in the last N days ending on report_date
|
||||||
|
(defaults to 1, meaning only filings on report_date).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing report data
|
Dictionary containing report data
|
||||||
@@ -41,12 +43,19 @@ class ReportGenerator:
|
|||||||
if report_date is None:
|
if report_date is None:
|
||||||
report_date = date.today()
|
report_date = date.today()
|
||||||
|
|
||||||
|
if lookback_days < 1:
|
||||||
|
raise ValueError("lookback_days must be >= 1")
|
||||||
|
|
||||||
start_of_day = datetime.combine(report_date, datetime.min.time())
|
start_of_day = datetime.combine(report_date, datetime.min.time())
|
||||||
end_of_day = datetime.combine(report_date, datetime.max.time())
|
end_of_day = datetime.combine(report_date, datetime.max.time())
|
||||||
|
|
||||||
# Count new trades filed today
|
filing_start_date = report_date - timedelta(days=lookback_days - 1)
|
||||||
|
|
||||||
|
# Trades filed within the lookback window (inclusive)
|
||||||
new_trades = (
|
new_trades = (
|
||||||
self.session.query(Trade).filter(Trade.filing_date == report_date).all()
|
self.session.query(Trade)
|
||||||
|
.filter(Trade.filing_date >= filing_start_date, Trade.filing_date <= report_date)
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count market alerts today
|
# Count market alerts today
|
||||||
@@ -71,6 +80,8 @@ class ReportGenerator:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"date": report_date,
|
"date": report_date,
|
||||||
|
"filing_start_date": filing_start_date,
|
||||||
|
"lookback_days": lookback_days,
|
||||||
"new_trades_count": len(new_trades),
|
"new_trades_count": len(new_trades),
|
||||||
"new_trades": [
|
"new_trades": [
|
||||||
{
|
{
|
||||||
@@ -136,7 +147,7 @@ class ReportGenerator:
|
|||||||
|
|
||||||
# Get top suspicious patterns
|
# Get top suspicious patterns
|
||||||
repeat_offenders = self.detector.identify_repeat_offenders(
|
repeat_offenders = self.detector.identify_repeat_offenders(
|
||||||
days_lookback=7, min_suspicious_trades=2, min_timing_score=40
|
lookback_days=7, min_suspicious_rate=0.4
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -173,13 +184,20 @@ class ReportGenerator:
|
|||||||
|
|
||||||
def _format_daily_text(self, data: Dict[str, Any]) -> str:
|
def _format_daily_text(self, data: Dict[str, Any]) -> str:
|
||||||
"""Format daily report as plain text."""
|
"""Format daily report as plain text."""
|
||||||
|
if data.get("lookback_days", 1) > 1:
|
||||||
|
trades_label = (
|
||||||
|
f" • Trades Filed (last {data['lookback_days']} days): {data['new_trades_count']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
trades_label = f" • New Trades Filed: {data['new_trades_count']}"
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"=" * 70,
|
"=" * 70,
|
||||||
f"POTE DAILY REPORT - {data['date']}",
|
f"POTE DAILY REPORT - {data['date']}",
|
||||||
"=" * 70,
|
"=" * 70,
|
||||||
"",
|
"",
|
||||||
"📊 SUMMARY",
|
"📊 SUMMARY",
|
||||||
f" • New Trades Filed: {data['new_trades_count']}",
|
trades_label,
|
||||||
f" • Market Alerts: {data['market_alerts_count']}",
|
f" • Market Alerts: {data['market_alerts_count']}",
|
||||||
f" • Critical Alerts (≥7 severity): {data['critical_alerts_count']}",
|
f" • Critical Alerts (≥7 severity): {data['critical_alerts_count']}",
|
||||||
f" • Suspicious Timing Trades: {data['suspicious_trades_count']}",
|
f" • Suspicious Timing Trades: {data['suspicious_trades_count']}",
|
||||||
@@ -287,6 +305,11 @@ class ReportGenerator:
|
|||||||
|
|
||||||
def _format_daily_html(self, data: Dict[str, Any]) -> str:
|
def _format_daily_html(self, data: Dict[str, Any]) -> str:
|
||||||
"""Format daily report as HTML."""
|
"""Format daily report as HTML."""
|
||||||
|
if data.get("lookback_days", 1) > 1:
|
||||||
|
new_trades_label = f"Trades Filed (last {data['lookback_days']} days):"
|
||||||
|
else:
|
||||||
|
new_trades_label = "New Trades:"
|
||||||
|
|
||||||
html = f"""
|
html = f"""
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -307,7 +330,7 @@ class ReportGenerator:
|
|||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<h2>📊 Summary</h2>
|
<h2>📊 Summary</h2>
|
||||||
<div class="stat"><strong>New Trades:</strong> {data['new_trades_count']}</div>
|
<div class="stat"><strong>{new_trades_label}</strong> {data['new_trades_count']}</div>
|
||||||
<div class="stat"><strong>Market Alerts:</strong> {data['market_alerts_count']}</div>
|
<div class="stat"><strong>Market Alerts:</strong> {data['market_alerts_count']}</div>
|
||||||
<div class="stat"><strong>Critical Alerts:</strong> {data['critical_alerts_count']}</div>
|
<div class="stat"><strong>Critical Alerts:</strong> {data['critical_alerts_count']}</div>
|
||||||
<div class="stat"><strong>Suspicious Trades:</strong> {data['suspicious_trades_count']}</div>
|
<div class="stat"><strong>Suspicious Trades:</strong> {data['suspicious_trades_count']}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user