From 69e6ab1051fd17d6f0ac4f8b15cacab524096c3f Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Mon, 16 Mar 2026 18:40:14 +0100 Subject: [PATCH 1/2] Improve alerts, email branding, and add password.javazone.no MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alert improvements: - Filter service-linked role events (ECS ENI provisioning noise) - Add CI context (actor/repo/sha/run) to team provisioner Slack notifications - Daily cost spikes: add $1 min threshold, usage type + tag breakdown, CE links - Weekly cost report: replace bullet lists with Block Kit tables Email and password-set: - Subject/body: "Velkommen til JavaBin", footer: "Javabrukerforeningen i Norge" - Brand colors: navy #1a1a2e → salmon #f05350 across email + password-set pages - CID-embed logo PNG to avoid Outlook "trust this email" prompt - Route password.javazone.no via ALB to password-set Lambda - Handler supports both ALB and Function URL event formats --- scripts/provision-groups.py | 12 ++ scripts/provision-groups.sh | 8 + scripts/provision-teams.sh | 9 + terraform/lambda-src/cost_report/handler.py | 102 +++++++--- .../lambda-src/daily_cost_check/handler.py | 151 ++++++++++++++- terraform/lambda-src/password_set/handler.py | 65 ++++--- terraform/lambda-src/slack_alert/handler.py | 22 +++ .../lambda-src/team_provisioner/handler.py | 174 ++++++++++++++++-- terraform/platform/lambdas/main.tf | 63 ++++++- terraform/platform/lambdas/variables.tf | 25 +++ terraform/platform/main.tf | 5 + 11 files changed, 561 insertions(+), 75 deletions(-) diff --git a/scripts/provision-groups.py b/scripts/provision-groups.py index 8444f04..952f414 100644 --- a/scripts/provision-groups.py +++ b/scripts/provision-groups.py @@ -13,6 +13,7 @@ """ import json +import os import subprocess import sys import tempfile @@ -107,10 +108,21 @@ def main(): for g in resolved_groups: print(f" {g['name']}: {len(g['members'])} member(s)") + # Pass CI context so the Lambda can attribute the sync in Slack + ci_context = { + k: v for k, v in { + "actor": os.environ.get("GITHUB_ACTOR"), + "repo": os.environ.get("GITHUB_REPOSITORY"), + "sha": os.environ.get("GITHUB_SHA"), + "run_id": os.environ.get("GITHUB_RUN_ID"), + }.items() if v + } + payload = { "action": "sync_groups_and_heros", "groups": resolved_groups, "heros": hero_details, + **({"ci_context": ci_context} if ci_context else {}), } print(f"\nInvoking javabin-team-provisioner with {len(hero_details)} hero(es)...") diff --git a/scripts/provision-groups.sh b/scripts/provision-groups.sh index 0845793..c10139a 100644 --- a/scripts/provision-groups.sh +++ b/scripts/provision-groups.sh @@ -20,7 +20,15 @@ fi COUNT=$(yq '.groups | length' "$GROUPS_FILE") echo "Provisioning ${COUNT} group(s) from ${GROUPS_FILE}..." +# Build CI context from GitHub Actions environment +CI_CONTEXT="{}" +if [ -n "$GITHUB_ACTOR" ]; then + CI_CONTEXT=$(printf '{"actor":"%s","repo":"%s","sha":"%s","run_id":"%s"}' \ + "${GITHUB_ACTOR}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}" "${GITHUB_RUN_ID}") +fi + PAYLOAD=$(yq -o json '{"action": "sync_groups", "groups": .groups}' "$GROUPS_FILE") +PAYLOAD=$(echo "$PAYLOAD" | yq -o json ". + {\"ci_context\": ${CI_CONTEXT}}") aws lambda invoke \ --function-name javabin-team-provisioner \ diff --git a/scripts/provision-teams.sh b/scripts/provision-teams.sh index b9c3515..9e82d01 100644 --- a/scripts/provision-teams.sh +++ b/scripts/provision-teams.sh @@ -30,7 +30,16 @@ if [ "$COUNT" -eq 0 ]; then fi echo "Invoking javabin-team-provisioner with ${COUNT} team(s)..." + +# Build CI context from GitHub Actions environment (empty object if not in CI) +CI_CONTEXT="{}" +if [ -n "$GITHUB_ACTOR" ]; then + CI_CONTEXT=$(printf '{"actor":"%s","repo":"%s","sha":"%s","run_id":"%s"}' \ + "${GITHUB_ACTOR}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}" "${GITHUB_RUN_ID}") +fi + PAYLOAD=$(echo "$TEAMS" | yq -o json '{"teams": .}') +PAYLOAD=$(echo "$PAYLOAD" | yq -o json ". + {\"ci_context\": ${CI_CONTEXT}}") aws lambda invoke \ --function-name javabin-team-provisioner \ diff --git a/terraform/lambda-src/cost_report/handler.py b/terraform/lambda-src/cost_report/handler.py index 51168e7..291adfd 100644 --- a/terraform/lambda-src/cost_report/handler.py +++ b/terraform/lambda-src/cost_report/handler.py @@ -156,22 +156,65 @@ def fmt_usd_nok(usd): return f"${usd:.2f} (~{nok:.0f} NOK)" -def fmt_service_lines(services, start, end, week_total): - """Return sorted mrkdwn lines with % of total.""" - url = cost_explorer_url(start, end) +def build_service_table(services, week_total, prev_week=None): + """Build a Block Kit table block for a category of services.""" sorted_svcs = sorted(services.items(), key=lambda x: x[1], reverse=True) - lines = [] + + has_prev = prev_week is not None + header = [ + {"type": "raw_text", "text": "Service"}, + {"type": "raw_text", "text": "Amount"}, + {"type": "raw_text", "text": "%"}, + ] + if has_prev: + header.append({"type": "raw_text", "text": "WoW"}) + + rows = [header] for i, (svc, cost) in enumerate(sorted_svcs): - pct = (cost / week_total * 100) if week_total > 0 else 0 - if i < MAX_SERVICES_PER_CATEGORY: - lines.append(f" * {svc} — *${cost:.2f}* ({pct:.1f}%)") - else: + if i >= MAX_SERVICES_PER_CATEGORY: remaining = len(sorted_svcs) - MAX_SERVICES_PER_CATEGORY remaining_cost = sum(c for _, c in sorted_svcs[MAX_SERVICES_PER_CATEGORY:]) - lines.append(f" _+{remaining} more — ${remaining_cost:.2f}_") + row = [ + {"type": "raw_text", "text": f"+{remaining} more"}, + {"type": "raw_text", "text": f"${remaining_cost:.2f}"}, + {"type": "raw_text", "text": ""}, + ] + if has_prev: + row.append({"type": "raw_text", "text": ""}) + rows.append(row) break - lines.append(f" <{url}|View in Cost Explorer>") - return "\n".join(lines) + + pct = (cost / week_total * 100) if week_total > 0 else 0 + nok = cost * USD_TO_NOK + row = [ + {"type": "raw_text", "text": svc}, + {"type": "raw_text", "text": f"${cost:.2f} (~{nok:.0f} NOK)"}, + {"type": "raw_text", "text": f"{pct:.1f}%"}, + ] + if has_prev: + prev_cost = prev_week.get(svc, 0) + delta = cost - prev_cost + if abs(delta) < 0.01: + row.append({"type": "raw_text", "text": "-"}) + elif delta > 0: + row.append({"type": "raw_text", "text": f"+${delta:.2f}"}) + else: + row.append({"type": "raw_text", "text": f"-${abs(delta):.2f}"}) + rows.append(row) + + col_settings = [ + {"is_wrapped": True}, + {"align": "right"}, + {"align": "right"}, + ] + if has_prev: + col_settings.append({"align": "right"}) + + return { + "type": "table", + "column_settings": col_settings, + "rows": rows, + } def fmt_change(current, previous): @@ -361,44 +404,47 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs, projected = (mtd_total / mtd_days * total_days_in_month) if mtd_days > 0 else 0 curr_month_name = mtd_start_d.strftime("%B") - # Per-project tag breakdown + # Per-project tag breakdown as table if project_costs: - sorted_projects = sorted(project_costs.items(), key=lambda x: x[1], reverse=True) - project_lines = [] - for proj_name, cost in sorted_projects: - if cost >= 0.01: - pct = (cost / tw_total * 100) if tw_total > 0 else 0 - nok = cost * USD_TO_NOK - project_lines.append(f" \u2022 {proj_name} \u2014 *${cost:.2f}* (~{nok:.0f} NOK) `{pct:.1f}%`") - if project_lines: + filtered = {k: v for k, v in project_costs.items() if v >= 0.01} + if filtered: blocks.append({ "type": "section", - "text": {"type": "mrkdwn", - "text": ":label: *By Project Tag*\n" + "\n".join(project_lines[:10])} + "text": {"type": "mrkdwn", "text": ":label: *By Project Tag*"} }) + blocks.append(build_service_table(filtered, tw_total)) - # Categorised service breakdown + # Categorised service breakdown as tables ai, infra, other = categorise(this_week) + prev_ai, prev_infra, prev_other = categorise(prev_week) + + ce_url = cost_explorer_url(tw_start, tw_end) if ai: blocks.append({ "type": "section", - "text": {"type": "mrkdwn", - "text": f":brain: *AI Models*\n{fmt_service_lines(ai, tw_start, tw_end, tw_total)}"} + "text": {"type": "mrkdwn", "text": f":brain: *AI Models*"} }) + blocks.append(build_service_table(ai, tw_total, prev_ai)) if infra: blocks.append({ "type": "section", - "text": {"type": "mrkdwn", - "text": f":cloud: *Infrastructure*\n{fmt_service_lines(infra, tw_start, tw_end, tw_total)}"} + "text": {"type": "mrkdwn", "text": f":cloud: *Infrastructure*"} }) + blocks.append(build_service_table(infra, tw_total, prev_infra)) if other: other_total = sum(other.values()) blocks.append({ "type": "section", "text": {"type": "mrkdwn", - "text": f":envelope: *Other (< ${OTHER_THRESHOLD:.2f})* — ${other_total:.2f} total\n{fmt_service_lines(other, tw_start, tw_end, tw_total)}"} + "text": f":envelope: *Other (< ${OTHER_THRESHOLD:.2f})* \u2014 ${other_total:.2f} total"} }) + blocks.append(build_service_table(other, tw_total)) + + blocks.append({ + "type": "context", + "elements": [{"type": "mrkdwn", "text": f"<{ce_url}|View in Cost Explorer>"}] + }) blocks.append({"type": "divider"}) diff --git a/terraform/lambda-src/daily_cost_check/handler.py b/terraform/lambda-src/daily_cost_check/handler.py index e1f1142..a972edd 100644 --- a/terraform/lambda-src/daily_cost_check/handler.py +++ b/terraform/lambda-src/daily_cost_check/handler.py @@ -1,6 +1,7 @@ import json import logging import os +import urllib.parse from datetime import datetime, timedelta, timezone import boto3 @@ -14,6 +15,8 @@ COST_WEBHOOK_PARAM = os.environ["COST_WEBHOOK_PARAM"] SPIKE_THRESHOLD = float(os.environ.get("SPIKE_THRESHOLD", "1.2")) # 20% above average +# Minimum daily spend (USD) to qualify as a spike — filters noise on tiny amounts +MIN_SPIKE_AMOUNT = float(os.environ.get("MIN_SPIKE_AMOUNT", "1.00")) # --------------------------------------------------------------------------- @@ -59,13 +62,86 @@ def get_period_avg(ce, start, end): return {svc: total / num_days for svc, total in totals.items()} +def get_usage_type_breakdown(ce, day, service): + """Fetch usage type breakdown for a specific service on a given day.""" + start = str(day) + end = str(day + timedelta(days=1)) + response = ce.get_cost_and_usage( + TimePeriod={"Start": start, "End": end}, + Granularity="DAILY", + Metrics=["UnblendedCost"], + Filter={"Dimensions": {"Key": "SERVICE", "Values": [service]}}, + GroupBy=[{"Type": "DIMENSION", "Key": "USAGE_TYPE"}], + ) + costs = {} + for period in response["ResultsByTime"]: + for group in period["Groups"]: + usage_type = group["Keys"][0] + amt = float(group["Metrics"]["UnblendedCost"]["Amount"]) + if amt >= 0.01: + costs[usage_type] = amt + return costs + + +def get_tag_breakdown(ce, day, service, tag_key="project"): + """Fetch tag breakdown for a specific service on a given day.""" + start = str(day) + end = str(day + timedelta(days=1)) + response = ce.get_cost_and_usage( + TimePeriod={"Start": start, "End": end}, + Granularity="DAILY", + Metrics=["UnblendedCost"], + Filter={"Dimensions": {"Key": "SERVICE", "Values": [service]}}, + GroupBy=[{"Type": "TAG", "Key": tag_key}], + ) + costs = {} + for period in response["ResultsByTime"]: + for group in period["Groups"]: + tag_val = group["Keys"][0] + if "$" in tag_val: + tag_val = tag_val.split("$", 1)[1] or "(untagged)" + amt = float(group["Metrics"]["UnblendedCost"]["Amount"]) + if amt >= 0.01: + costs[tag_val] = amt + return costs + + +# --------------------------------------------------------------------------- +# Cost Explorer URL builder +# --------------------------------------------------------------------------- +def cost_explorer_url(start, end, service=None): + """Build a Cost Explorer URL, optionally filtered to a specific service.""" + params = { + "groupBy": "UsageType" if service else "Service", + "hasBlended": "false", + "hasAmortized": "false", + "excludeDiscounts": "true", + "timeRangeOption": "Custom", + "startDate": str(start), + "endDate": str(end), + "chartStyle": "Stack", + } + if service: + params["filter"] = json.dumps( + [{"dimension": "Service", "values": [service]}] + ) + base = "https://us-east-1.console.aws.amazon.com/cost-management/home#/custom" + return f"{base}?{urllib.parse.urlencode(params)}" + + # --------------------------------------------------------------------------- # Spike detection # --------------------------------------------------------------------------- def detect_spikes(yesterday_costs, avg_costs): - """Return list of (service, yesterday, avg, pct_change) for spikes.""" + """Return list of (service, yesterday, avg, pct_change) for spikes. + + Filters out services where yesterday's spend is below MIN_SPIKE_AMOUNT + to avoid alerting on insignificant absolute values with high percentage changes. + """ spikes = [] for svc, cost in yesterday_costs.items(): + if cost < MIN_SPIKE_AMOUNT: + continue avg = avg_costs.get(svc, 0) if avg < 0.01: spikes.append((svc, cost, 0, None)) @@ -89,7 +165,19 @@ def fmt_change(pct, yesterday, avg): return line -def build_alert_blocks(spikes, yesterday_date): +def fmt_usage_types(usage_types, max_items=3): + """Format top usage types as a compact mrkdwn string.""" + sorted_types = sorted(usage_types.items(), key=lambda x: x[1], reverse=True) + lines = [] + for usage_type, cost in sorted_types[:max_items]: + lines.append(f" \u2022 {usage_type}: ${cost:.2f}") + if len(sorted_types) > max_items: + rest = sum(c for _, c in sorted_types[max_items:]) + lines.append(f" \u2022 +{len(sorted_types) - max_items} more: ${rest:.2f}") + return "\n".join(lines) + + +def build_alert_blocks(spikes, spike_details, yesterday_date): date_str = yesterday_date.strftime("%b %d, %Y") blocks = [ @@ -105,24 +193,61 @@ def build_alert_blocks(spikes, yesterday_date): for svc, yesterday, avg, pct in spikes: nok = yesterday * USD_TO_NOK + delta = yesterday - avg + delta_nok = delta * USD_TO_NOK + fields = [ {"type": "mrkdwn", "text": f"*Service*\n{svc}"}, {"type": "mrkdwn", "text": f"*Yesterday*\n${yesterday:.2f} (~{nok:.0f} NOK)"}, ] if avg > 0: avg_nok = avg * USD_TO_NOK - fields.append({"type": "mrkdwn", "text": f"*7-day avg*\n${avg:.2f} (~{avg_nok:.0f} NOK)"}) - fields.append({"type": "mrkdwn", "text": f"*Change*\n{fmt_change(pct, yesterday, avg)}"}) + fields.append( + {"type": "mrkdwn", + "text": f"*7-day avg*\n${avg:.2f} (~{avg_nok:.0f} NOK)"} + ) + fields.append( + {"type": "mrkdwn", + "text": f"*Change*\n{fmt_change(pct, yesterday, avg)}\n+${delta:.2f} (~{delta_nok:.0f} NOK)"} + ) blocks.append({"type": "section", "fields": fields}) + # Usage type and tag detail for this service + detail = spike_details.get(svc, {}) + detail_parts = [] + + usage_types = detail.get("usage_types") + if usage_types: + detail_parts.append(f"*Top usage types:*\n{fmt_usage_types(usage_types)}") + + tags = detail.get("tags") + if tags: + tag_lines = ", ".join( + f"{t}: ${c:.2f}" for t, c in + sorted(tags.items(), key=lambda x: x[1], reverse=True)[:5] + ) + detail_parts.append(f"*By project:* {tag_lines}") + + ce_url = detail.get("url") + if ce_url: + detail_parts.append(f"<{ce_url}|View in Cost Explorer>") + + if detail_parts: + blocks.append({ + "type": "context", + "elements": [{"type": "mrkdwn", "text": "\n".join(detail_parts)}] + }) + blocks.append({"type": "divider"}) ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + overall_url = cost_explorer_url(yesterday_date, yesterday_date) blocks.append({ "type": "context", "elements": [{"type": "mrkdwn", - "text": f"Source: AWS Cost Explorer | Generated {ts} | NOK rate: ~{USD_TO_NOK}"}] + "text": f"<{overall_url}|View all in Cost Explorer> | Generated {ts} | " + f"Min spike threshold: ${MIN_SPIKE_AMOUNT:.2f} | NOK rate: ~{USD_TO_NOK}"}] }) return blocks @@ -146,7 +271,21 @@ def handler(event, context): logger.info("No spikes detected. Silent success.") return {"statusCode": 200, "body": "No spikes"} - blocks = build_alert_blocks(spikes, yesterday) + # Fetch usage type and tag breakdowns for each spiking service + spike_details = {} + for svc, _, _, _ in spikes[:5]: # Cap detail queries to avoid API throttling + detail = {"url": cost_explorer_url(yesterday, yesterday, service=svc)} + try: + detail["usage_types"] = get_usage_type_breakdown(ce, yesterday, svc) + except Exception as e: + logger.warning("Usage type query failed for %s: %s", svc, e) + try: + detail["tags"] = get_tag_breakdown(ce, yesterday, svc) + except Exception as e: + logger.warning("Tag query failed for %s: %s", svc, e) + spike_details[svc] = detail + + blocks = build_alert_blocks(spikes, spike_details, yesterday) fallback = f"Daily cost spike: {len(spikes)} service(s) above 7-day average" webhook_url = get_webhook_url(ssm, COST_WEBHOOK_PARAM) diff --git a/terraform/lambda-src/password_set/handler.py b/terraform/lambda-src/password_set/handler.py index a8de633..eff307d 100644 --- a/terraform/lambda-src/password_set/handler.py +++ b/terraform/lambda-src/password_set/handler.py @@ -151,18 +151,18 @@ def _mark_token_used(jti): background:#f5f5f5;min-height:100vh;display:flex;align-items:center;justify-content:center} .card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08); max-width:440px;width:100%;overflow:hidden} - .hdr{background:#1a1a2e;padding:24px 32px;text-align:center} + .hdr{background:#f05350;padding:24px 32px;text-align:center} .hdr img{height:40px} .bd{padding:32px} - h1{color:#1a1a2e;font-size:20px;margin-bottom:8px} + h1{color:#f05350;font-size:20px;margin-bottom:8px} .email{color:#666;font-size:14px;margin-bottom:24px} label{display:block;font-size:14px;font-weight:600;color:#333;margin-bottom:6px} input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:6px; font-size:15px;margin-bottom:16px} - input[type=password]:focus{outline:none;border-color:#1a1a2e;box-shadow:0 0 0 2px rgba(26,26,46,.15)} - .btn{width:100%;padding:12px;background:#1a1a2e;color:#fff;border:none;border-radius:6px; + input[type=password]:focus{outline:none;border-color:#f05350;box-shadow:0 0 0 2px rgba(240,83,80,.2)} + .btn{width:100%;padding:12px;background:#f05350;color:#fff;border:none;border-radius:6px; font-size:15px;font-weight:600;cursor:pointer} - .btn:hover{background:#2d2d4e} + .btn:hover{background:#d94442} .btn:disabled{opacity:.5;cursor:not-allowed} .rules{font-size:13px;color:#888;margin-bottom:20px} .rules span{display:block;margin:2px 0} @@ -198,7 +198,7 @@ def _mark_token_used(jti): -
javaBin — java.no · javazone.no
+
JavaBin — Javabrukerforeningen i Norge · java.no · javazone.no