Skip to content

Commit 9a58dfa

Browse files
Merge pull request #216 from NASA-IMPACT/aws-cost/pricing-review
AWS pricing: weekly review job + Jules task (shout >1% spikes)
2 parents dbfd0d2 + 418c14d commit 9a58dfa

4 files changed

Lines changed: 420 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: AWS Pricing Review
2+
3+
# Weekly price-change review. Every Monday EVENING it fetches fresh AWS On-Demand prices, diffs them
4+
# against the committed snapshot, and — if anything changed — opens a PR that updates the snapshot and
5+
# whose body is an impossible-to-miss change report. Any price moving >1% is a SPIKE and is shouted at
6+
# the top of the PR (loud title + 🚨 banner). This is the deterministic engine; a Jules scheduled task
7+
# can run the same two scripts (see AGENTS.md) to author the PR with its own review.
8+
on:
9+
workflow_dispatch:
10+
inputs:
11+
region:
12+
description: 'Region to review'
13+
type: string
14+
required: false
15+
default: 'us-west-2'
16+
demo_spike:
17+
description: 'Inject a synthetic spike to test the alert formatting (do not merge the PR)'
18+
type: boolean
19+
required: false
20+
default: false
21+
schedule:
22+
- cron: '0 0 * * 2' # 00:00 UTC Tue = Monday evening in the US (~7pm CT / 6pm CST)
23+
24+
permissions:
25+
contents: write # push the review branch
26+
pull-requests: write # open/update the review PR
27+
28+
jobs:
29+
review:
30+
name: Diff prices → open review PR (spikes shouted)
31+
runs-on: ubuntu-latest
32+
env:
33+
GH_TOKEN: ${{ github.token }}
34+
REGION: ${{ github.event.inputs.region || 'us-west-2' }}
35+
steps:
36+
- name: "1 · Checkout"
37+
uses: actions/checkout@v4
38+
with:
39+
fetch-depth: 0
40+
41+
- name: "2 · Set up Python"
42+
uses: actions/setup-python@v5
43+
with:
44+
python-version: '3.12'
45+
46+
- name: "3 · Fetch fresh prices (live public bulk API, no creds)"
47+
run: |
48+
python3 aws-pricing/generate_aws_pricing.py \
49+
--region "$REGION" --out-dir "$RUNNER_TEMP/new" \
50+
--now "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
51+
52+
- name: "4 · Diff vs committed snapshot → change report"
53+
id: diff
54+
run: |
55+
DEMO=""
56+
if [ "${{ github.event.inputs.demo_spike }}" = "true" ]; then DEMO="--demo-spike"; fi
57+
python3 aws-pricing/pricing_diff.py \
58+
--old "cost-dashboard/public/data/pricing_${REGION}.json" \
59+
--new "$RUNNER_TEMP/new/pricing_${REGION}.json" \
60+
--threshold 1.0 --out-md PRICING_CHANGES.md \
61+
--out-summary "$RUNNER_TEMP/summary.json" $DEMO
62+
echo "changed=$(jq -r .changed "$RUNNER_TEMP/summary.json")" >> "$GITHUB_OUTPUT"
63+
echo "title=$(jq -r .title "$RUNNER_TEMP/summary.json")" >> "$GITHUB_OUTPUT"
64+
echo "spikes=$(jq -r .spikes "$RUNNER_TEMP/summary.json")" >> "$GITHUB_OUTPUT"
65+
cat PRICING_CHANGES.md >> "$GITHUB_STEP_SUMMARY"
66+
67+
- name: "5 · Open / update the review PR (only if something changed)"
68+
if: steps.diff.outputs.changed == 'true'
69+
run: |
70+
DATE="$(date -u +%Y-%m-%d)"
71+
BR="aws-pricing/review-${DATE}"
72+
DEMO="${{ github.event.inputs.demo_spike }}"
73+
TITLE="${{ steps.diff.outputs.title }}"
74+
[ "$DEMO" = "true" ] && TITLE="[DEMO — do not merge] $TITLE"
75+
76+
# Update the bundled snapshot + keep a dated copy of the report in cost-reports/.
77+
cp "$RUNNER_TEMP/new/pricing_${REGION}.json" "cost-dashboard/public/data/pricing_${REGION}.json"
78+
cp "$RUNNER_TEMP/new/index.json" "cost-dashboard/public/data/index.json" 2>/dev/null || true
79+
mkdir -p cost-reports
80+
cp PRICING_CHANGES.md "cost-reports/weekly-${DATE}.md"
81+
82+
git config user.name "github-actions[bot]"
83+
git config user.email "github-actions[bot]@users.noreply.github.com"
84+
git checkout -B "$BR"
85+
git add -f "cost-dashboard/public/data/pricing_${REGION}.json" \
86+
"cost-dashboard/public/data/index.json" "cost-reports/weekly-${DATE}.md"
87+
if git diff --cached --quiet; then
88+
echo "Nothing to commit."; exit 0
89+
fi
90+
git commit -m "AWS weekly pricing review — ${DATE}"
91+
git push -f origin "$BR"
92+
93+
BODY="$RUNNER_TEMP/body.md"
94+
{ [ "$DEMO" = "true" ] && echo "> 🧪 **This is a demo run — do not merge.**" && echo; cat PRICING_CHANGES.md; } > "$BODY"
95+
NUM="$(gh pr list --head "$BR" --state open --json number -q '.[0].number')"
96+
if [ -z "$NUM" ]; then
97+
gh pr create --base main --head "$BR" --title "$TITLE" --body-file "$BODY"
98+
else
99+
gh pr edit "$NUM" --title "$TITLE" --body-file "$BODY"
100+
echo "Updated existing PR #$NUM"
101+
fi
102+
103+
- name: "6 · No changes → say so"
104+
if: steps.diff.outputs.changed != 'true'
105+
run: echo "No AWS price changes for ${REGION} this week — no PR opened." >> "$GITHUB_STEP_SUMMARY"

AGENTS.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# AGENTS.md — guide for coding agents (Jules, etc.)
2+
3+
This repo is a test station for VEDA GitHub Actions + Netlify dashboards. Full context is in
4+
`CLAUDE.md`; app-specific docs are in `docs/`.
5+
6+
## Environment
7+
- **Python 3.12, standard library only** for all generators (no `pip install`, no `requests`, no
8+
`boto3`). AWS pricing is pulled from AWS's **public** Price List Bulk API — **no credentials**.
9+
- **Node 18+** for the dashboards (`cost-dashboard/`, `fte-dashboard/`, …): `cd <dir> && npm ci`.
10+
- Quick checks: `python3 aws-pricing/test_generate.py` (offline golden test),
11+
`cd cost-dashboard && npm run typecheck && npm run build`.
12+
13+
## Conventions
14+
- Match surrounding style; keep generators stdlib-only and deterministic (they take `--now`).
15+
- Netlify "Pattern B": each dashboard owns its `<dir>/netlify.toml`; there is **no** root netlify.toml.
16+
17+
---
18+
19+
## ⭐ Scheduled task: **Weekly AWS pricing review** (run Monday evening)
20+
21+
**Goal:** pull the latest AWS On-Demand prices, compare to the committed snapshot, and open a PR that
22+
updates it — and if any price moved **more than 1%**, make that **impossible to miss**.
23+
24+
**Do exactly this:**
25+
1. Fetch fresh prices (heavy EC2 file ~473 MB; takes ~1–2 min):
26+
```bash
27+
python3 aws-pricing/generate_aws_pricing.py --region us-west-2 \
28+
--out-dir /tmp/new --now "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
29+
```
30+
2. Diff against the committed snapshot and generate the change report:
31+
```bash
32+
python3 aws-pricing/pricing_diff.py \
33+
--old cost-dashboard/public/data/pricing_us-west-2.json \
34+
--new /tmp/new/pricing_us-west-2.json \
35+
--threshold 1.0 --out-md PRICING_CHANGES.md --out-summary /tmp/summary.json
36+
```
37+
3. If `/tmp/summary.json` has `"changed": false`, **do nothing** (no PR).
38+
4. Otherwise, in the PR:
39+
- Update `cost-dashboard/public/data/pricing_us-west-2.json` (and `index.json`) with the fresh files.
40+
- Save the report to `cost-reports/weekly-<YYYY-MM-DD>.md`.
41+
- **Use `PRICING_CHANGES.md` as the PR body verbatim** — it already contains the loud 🚨 spike
42+
banner and giant headers. Do not soften or summarize it away.
43+
- **Use `summary.json`'s `title` as the PR title** — for a spike it starts with `🚨 AWS pricing
44+
SPIKE ±X%` so it's impossible to miss in the PR list.
45+
5. Spikes = any price where `|Δ| > 1%`. If there are spikes, the biggest one must be shouted at the
46+
very top of the PR body (the report already does this). Never bury a spike.
47+
48+
**Rules:** On-Demand prices only; they are approximate. Keep the report's alarm formatting loud — the
49+
whole point is that a >1% move is obvious at a glance. One PR per run; if a same-day PR exists, update it.
50+
51+
> A GitHub Action (`.github/workflows/aws-pricing-review.yml`) does the same thing deterministically
52+
> as a fallback/engine — you can mirror its steps. Test the alert formatting with its `demo_spike`
53+
> input (or `pricing_diff.py --demo-spike`), which injects a synthetic +7.3% move.

aws-pricing/pricing_diff.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""Diff two AWS pricing snapshots and write an impossible-to-miss change report.
3+
4+
Compares an OLD pricing_<region>.json against a NEW one, computes the % change of every price, and
5+
writes a Markdown report (PRICING_CHANGES.md) plus a machine-readable summary. Any price that moves
6+
more than --threshold percent (default 1%) is a **SPIKE** and is shouted at the top of the report
7+
with 🚨 banners and giant headers so it's impossible to miss in a PR. Standard library only.
8+
9+
Used by the weekly "AWS pricing review" job (GitHub Action or a Jules scheduled task): fetch fresh
10+
prices, diff against the committed snapshot, and open a PR whose body is this report.
11+
12+
python aws-pricing/pricing_diff.py --old <snapshot.json> --new <fresh.json> \
13+
--out-md PRICING_CHANGES.md --out-summary summary.json [--threshold 1.0] [--demo-spike]
14+
15+
--demo-spike injects a synthetic +7.3% move into the NEW data so you can test the alert formatting
16+
without waiting for a real price change.
17+
"""
18+
import argparse
19+
import json
20+
import sys
21+
22+
23+
def load(path):
24+
with open(path, encoding="utf-8") as fh:
25+
return json.load(fh)
26+
27+
28+
def flatten(doc):
29+
"""A pricing doc -> {human label: price} for every comparable price in it."""
30+
out = {}
31+
for t, i in doc.get("ec2", {}).get("instances", {}).items():
32+
out[f"EC2 · {t}"] = i.get("hourlyUSD")
33+
for t, v in doc.get("ebs", {}).items():
34+
out[f"EBS · {t} (GB-mo)"] = v
35+
s = doc.get("s3", {})
36+
if "standardStorageUsdPerGBMonth" in s:
37+
out["S3 · Standard storage (GB-mo)"] = s["standardStorageUsdPerGBMonth"]
38+
out["S3 · PUT (per 1k)"] = s.get("putPer1k")
39+
out["S3 · GET (per 1k)"] = s.get("getPer1k")
40+
for _, i in doc.get("rds", {}).get("instances", {}).items():
41+
out[f"RDS · {i.get('engine')} {i.get('instanceType')}"] = i.get("hourlyUSD")
42+
for t, v in doc.get("rds", {}).get("storage", {}).items():
43+
out[f"RDS storage · {t} (GB-mo)"] = v
44+
lam = doc.get("lambda", {})
45+
if "requestUsd" in lam:
46+
out["Lambda · request"] = lam["requestUsd"]
47+
out["Lambda · GB-second"] = lam.get("gbSecondUsd")
48+
return {k: v for k, v in out.items() if isinstance(v, (int, float))}
49+
50+
51+
def fmt(v):
52+
"""Money-ish formatting that keeps precision for tiny per-request rates."""
53+
if v is None:
54+
return "—"
55+
if v == 0:
56+
return "$0"
57+
if v >= 1:
58+
return f"${v:,.2f}"
59+
if v >= 0.01:
60+
return f"${v:.4f}".rstrip("0").rstrip(".")
61+
return f"${v:.3g}"
62+
63+
64+
def diff(old, new, threshold):
65+
"""Return (spikes, minor, added, removed) lists, each sorted by |%| descending."""
66+
fo, fn = flatten(old), flatten(new)
67+
spikes, minor, added, removed = [], [], [], []
68+
for key in sorted(set(fo) | set(fn)):
69+
o, n = fo.get(key), fn.get(key)
70+
if o is None:
71+
added.append((key, n))
72+
continue
73+
if n is None:
74+
removed.append((key, o))
75+
continue
76+
if o == n:
77+
continue
78+
pct = ((n - o) / o * 100) if o else float("inf")
79+
rec = {"key": key, "old": o, "new": n, "pct": pct}
80+
(spikes if abs(pct) > threshold else minor).append(rec)
81+
spikes.sort(key=lambda r: abs(r["pct"]), reverse=True)
82+
minor.sort(key=lambda r: abs(r["pct"]), reverse=True)
83+
return spikes, minor, added, removed
84+
85+
86+
def arrow(pct):
87+
return "🔺" if pct > 0 else "🔻"
88+
89+
90+
def row(r):
91+
return f"| {arrow(r['pct'])} | **{r['key']}** | {fmt(r['old'])} | {fmt(r['new'])} | **{r['pct']:+.2f}%** |"
92+
93+
94+
def build_md(old, new, spikes, minor, added, removed, threshold):
95+
region = new.get("label", new.get("region", "?"))
96+
o_gen = (old.get("generated") or "?")[:10]
97+
n_gen = (new.get("generated") or "?")[:10]
98+
L = []
99+
if spikes:
100+
top = spikes[0]
101+
L += [
102+
"# 🚨🚨🚨 AWS PRICE SPIKE ALERT 🚨🚨🚨",
103+
"",
104+
f"## ‼️ {len(spikes)} price change(s) exceed the {threshold:g}% threshold — REVIEW BEFORE MERGING ‼️",
105+
"",
106+
f"> # {arrow(top['pct'])} BIGGEST MOVE: {top['key']} &nbsp; **{top['pct']:+.2f}%**",
107+
f"> ## {fmt(top['old'])}{fmt(top['new'])}",
108+
"",
109+
"| | Item | Old | New | Change |",
110+
"|:--:|---|---:|---:|:--:|",
111+
*[row(r) for r in spikes],
112+
"",
113+
"---",
114+
"",
115+
]
116+
elif minor or added or removed:
117+
L += [f"# ✅ Weekly AWS pricing update — no spikes over {threshold:g}%", ""]
118+
else:
119+
L += ["# ✅ Weekly AWS pricing update — no changes", ""]
120+
121+
L += [f"**Region:** {region} · **Data:** {o_gen}{n_gen}", ""]
122+
123+
if minor:
124+
L += [
125+
f"<details{' open' if not spikes else ''}><summary>Other changes under {threshold:g}% "
126+
f"({len(minor)})</summary>",
127+
"",
128+
"| | Item | Old | New | Change |",
129+
"|:--:|---|---:|---:|:--:|",
130+
*[row(r) for r in minor],
131+
"",
132+
"</details>",
133+
"",
134+
]
135+
if added:
136+
L += ["**Newly listed:** " + ", ".join(f"{k} ({fmt(v)})" for k, v in added), ""]
137+
if removed:
138+
L += ["**No longer listed:** " + ", ".join(f"{k} (was {fmt(v)})" for k, v in removed), ""]
139+
140+
L += [
141+
"---",
142+
"_On-Demand list prices from AWS's public Price List Bulk API. Approximate — exclude Free "
143+
"Tier, Savings Plans/RIs, discounts, and taxes. Generated by `aws-pricing/pricing_diff.py`._",
144+
]
145+
return "\n".join(L) + "\n"
146+
147+
148+
def title(spikes, minor, added, removed, n_gen):
149+
date = (n_gen or "")[:10]
150+
if spikes:
151+
t = spikes[0]
152+
return f"🚨 AWS pricing SPIKE {t['pct']:+.1f}% ({t['key']}) — weekly review {date}"
153+
if minor or added or removed:
154+
return f"AWS weekly pricing update — {date} (no spikes)"
155+
return f"AWS weekly pricing — no changes {date}"
156+
157+
158+
def demo_spike(new):
159+
"""Inject a synthetic +7.3% EC2 move + a -4.2% Lambda move so the alert can be tested."""
160+
inst = new.get("ec2", {}).get("instances", {})
161+
if inst:
162+
k = sorted(inst)[0]
163+
inst[k]["hourlyUSD"] = round(inst[k]["hourlyUSD"] * 1.073, 6)
164+
lam = new.get("lambda", {})
165+
if "gbSecondUsd" in lam:
166+
lam["gbSecondUsd"] = round(lam["gbSecondUsd"] * 0.958, 10)
167+
return new
168+
169+
170+
def main():
171+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
172+
ap.add_argument("--old", required=True, help="previous pricing_<region>.json (the committed snapshot)")
173+
ap.add_argument("--new", required=True, help="freshly generated pricing_<region>.json")
174+
ap.add_argument("--threshold", type=float, default=1.0, help="spike threshold in %% (default 1.0)")
175+
ap.add_argument("--out-md", default="PRICING_CHANGES.md")
176+
ap.add_argument("--out-summary", default="pricing_changes_summary.json")
177+
ap.add_argument("--demo-spike", action="store_true", help="inject a synthetic spike into --new (testing)")
178+
args = ap.parse_args()
179+
180+
old, new = load(args.old), load(args.new)
181+
if args.demo_spike:
182+
new = demo_spike(new)
183+
184+
spikes, minor, added, removed = diff(old, new, args.threshold)
185+
md = build_md(old, new, spikes, minor, added, removed, args.threshold)
186+
with open(args.out_md, "w", encoding="utf-8") as fh:
187+
fh.write(md)
188+
189+
total = len(spikes) + len(minor) + len(added) + len(removed)
190+
top = spikes[0] if spikes else None
191+
summary = {
192+
"changed": total > 0,
193+
"spikes": len(spikes),
194+
"changes": total,
195+
"threshold": args.threshold,
196+
"max_pct": round(top["pct"], 3) if top else 0.0,
197+
"max_item": top["key"] if top else "",
198+
"title": title(spikes, minor, added, removed, new.get("generated", "")),
199+
}
200+
with open(args.out_summary, "w", encoding="utf-8") as fh:
201+
json.dump(summary, fh, indent=2)
202+
203+
print(f"pricing-diff: {len(spikes)} spike(s) >{args.threshold:g}%, {len(minor)} minor, "
204+
f"{len(added)} added, {len(removed)} removed", file=sys.stderr)
205+
if top:
206+
print(f" biggest: {top['key']} {top['pct']:+.2f}% ({fmt(top['old'])}{fmt(top['new'])})", file=sys.stderr)
207+
208+
209+
if __name__ == "__main__":
210+
main()

0 commit comments

Comments
 (0)