mirror of
https://github.com/Sea-Haven-Industries/apm-wo-analysis.git
synced 2026-09-30 03:03:14 +00:00
Two push surfaces (no App Home) + interactive drill-down, per CLAUDE.md.
Block Kit (blockkit.py, pure/offline): build_daily_summary (header, vs-yesterday
deltas, escalation breakdown with 3rd highlighted, action/routine, top sites,
mismatch callout, category drill buttons + 📊 Open dashboard link, footer),
build_escalation_alert (one @here, returns None on zero-3rd — suppression), and
build_wo_modal (views.open payload, capped under Slack's 100-block limit).
Lambdas: slack_post/handler.py (classifier-invoked: read today/yesterday
summary.json, post daily summary, conditionally post the batched alert from
details.json) and slack_post/interactions.py (API Gateway: verify Slack
signature, filter details.json, views.open the WO modal within the 3s trigger_id
window). slackio.py centralizes Secrets Manager creds, the SSM dashboard URL,
signature verification, and analytics/ reads — keeping blockkit pure.
Classifier: emit analytics/dt=*/details.json (per-WO index for the modals) and
async-invoke slack-post after the snapshot write (best-effort; a Slack failure
never fails classification).
CDK: slack-post + interactions Lambdas (Docker-bundled slack_sdk), HTTP API on
apm-wo.seahaven.com (wildcard ACM cert + Route53 alias; signature-verified, so
the route is unauthenticated by design), SSM /apm-wo-analysis/grafana-base-url,
and scoped IAM (read analytics/, read the Slack secret + dashboard param;
classifier granted lambda:InvokeFunction on slack-post). Slack creds live in one
Secrets Manager secret apm-wo-analysis/slack-credentials {botToken, signingSecret,
channelId}; cdk.json gains cert/zone/domain context.
WO drill-downs link to Grafana only — no APM deep-links (per decision).
Deliverables for test time: slack/manifest.yaml (app manifest, interactivity
request_url = apm-wo.seahaven.com).
Tests: tests/test_blockkit.py (30 offline cases — deltas, zero-3rd None, <100
blocks under large inputs, modal truncation/overflow, dashboard URL) and Phase 4
assertions in test_pipeline_synth.py (both Lambdas, the API route/domain/alias,
and no broad/write IAM on the Slack roles). 49/49 tests pass; cdk synth green.
70 lines
2.3 KiB
Python
70 lines
2.3 KiB
Python
"""Shared Slack + AWS I/O for the post and interactions Lambdas.
|
|
|
|
Keeps the Block Kit builders (blockkit.py) pure: everything that touches the
|
|
network or AWS lives here. Slack credentials are a single Secrets Manager secret
|
|
``{ botToken, signingSecret, channelId }``; the Grafana dashboard URL is
|
|
operational config in SSM (editable without a redeploy). Both are cached for the
|
|
life of the execution environment.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
import boto3
|
|
from slack_sdk import WebClient
|
|
from slack_sdk.signature import SignatureVerifier
|
|
|
|
_secrets = boto3.client("secretsmanager")
|
|
_ssm = boto3.client("ssm")
|
|
_s3 = boto3.client("s3")
|
|
|
|
_SECRET_NAME = os.environ["SLACK_SECRET_NAME"]
|
|
_DASHBOARD_PARAM = os.environ["DASHBOARD_URL_PARAM"]
|
|
_BUCKET = os.environ["ANALYTICS_BUCKET"]
|
|
|
|
# Lazily-populated caches (warm across invocations in the same container).
|
|
_creds: dict | None = None
|
|
_dashboard_url: str | None = None
|
|
|
|
|
|
def get_credentials() -> dict:
|
|
"""Return the Slack creds dict: ``botToken``, ``signingSecret``, ``channelId``."""
|
|
global _creds
|
|
if _creds is None:
|
|
raw = _secrets.get_secret_value(SecretId=_SECRET_NAME)["SecretString"]
|
|
_creds = json.loads(raw)
|
|
return _creds
|
|
|
|
|
|
def get_dashboard_url() -> str:
|
|
"""Grafana dashboard URL for the 📊 button / modal overflow links (SSM)."""
|
|
global _dashboard_url
|
|
if _dashboard_url is None:
|
|
_dashboard_url = _ssm.get_parameter(Name=_DASHBOARD_PARAM)["Parameter"]["Value"]
|
|
return _dashboard_url
|
|
|
|
|
|
def web_client() -> WebClient:
|
|
return WebClient(token=get_credentials()["botToken"])
|
|
|
|
|
|
def channel_id() -> str:
|
|
return get_credentials()["channelId"]
|
|
|
|
|
|
def verify_signature(body: str, timestamp: str, signature: str) -> bool:
|
|
"""Validate a Slack request signature (HMAC + 5-minute replay window)."""
|
|
verifier = SignatureVerifier(signing_secret=get_credentials()["signingSecret"])
|
|
return verifier.is_valid(body=body, timestamp=timestamp, signature=signature)
|
|
|
|
|
|
def read_analytics_json(dt: str, name: str):
|
|
"""Read ``analytics/dt=<dt>/<name>`` as JSON, or None if absent."""
|
|
key = f"analytics/dt={dt}/{name}"
|
|
try:
|
|
obj = _s3.get_object(Bucket=_BUCKET, Key=key)
|
|
except _s3.exceptions.NoSuchKey:
|
|
return None
|
|
return json.loads(obj["Body"].read())
|