diff --git a/app/alarms.py b/app/alarms.py
index 7f71bdf..e35e4ac 100644
--- a/app/alarms.py
+++ b/app/alarms.py
@@ -1,48 +1,40 @@
import datetime
-from typing import Optional
+from typing import Optional, List
from app.extensions import db
from app.models.alarms import Alarm
+def alarms_for(target: str) -> List[Alarm]:
+ return list(Alarm.query.filter(
+ Alarm.target == target
+ ).all())
+
+
def _get_alarm(target: str,
- alarm_type: str,
- *,
- proxy_id: Optional[int] = None,
- origin_id: Optional[int] = None,
+ aspect: str,
create_if_missing: bool = True) -> Optional[Alarm]:
- alarm: Optional[Alarm]
- if target == "proxy":
- alarm = Alarm.query.filter(
- Alarm.target == "proxy",
- Alarm.alarm_type == alarm_type,
- Alarm.proxy_id == proxy_id
- ).first()
- elif target == "origin":
- alarm = Alarm.query.filter(
- Alarm.target == "origin",
- Alarm.alarm_type == alarm_type,
- Alarm.proxy_id == origin_id
- ).first()
- else:
- return None
+ alarm: Optional[Alarm] = Alarm.query.filter(
+ Alarm.aspect == aspect,
+ Alarm.target == target
+ ).first()
if create_if_missing and alarm is None:
alarm = Alarm()
+ alarm.aspect = aspect
alarm.target = target
- alarm.alarm_type = alarm_type
+ alarm.text = "New alarm"
alarm.state_changed = datetime.datetime.utcnow()
- if target == "proxy":
- alarm.proxy_id = proxy_id
- if target == "origin":
- alarm.origin_id = origin_id
+ alarm.last_updated = datetime.datetime.utcnow()
db.session.add(alarm)
- db.session.commit()
return alarm
-def get_proxy_alarm(proxy_id: int, alarm_type: str) -> Alarm:
- alarm = _get_alarm("proxy", alarm_type, proxy_id=proxy_id)
+def get_alarm(target: str, aspect: str) -> Optional[Alarm]:
+ return _get_alarm(target, aspect, create_if_missing=False)
+
+
+def get_or_create_alarm(target: str, aspect: str) -> Alarm:
+ alarm = _get_alarm(target, aspect, create_if_missing=True)
if alarm is None:
- # mypy can't tell that this will never be reached
- raise RuntimeError("Creating an alarm must have failed.")
+ raise RuntimeError("Asked for an alarm to be created but got None.")
return alarm
diff --git a/app/cli/automate.py b/app/cli/automate.py
index a8dd090..d3f39cb 100644
--- a/app/cli/automate.py
+++ b/app/cli/automate.py
@@ -15,6 +15,7 @@ from app.terraform.block_external import BlockExternalAutomation
from app.terraform.block_ooni import BlockOONIAutomation
from app.terraform.block_roskomsvoboda import BlockRoskomsvobodaAutomation
from app.terraform.eotk.aws import EotkAWSAutomation
+from app.terraform.alarms.eotk_aws import AlarmEotkAwsAutomation
from app.terraform.alarms.proxy_azure_cdn import AlarmProxyAzureCdnAutomation
from app.terraform.alarms.proxy_cloudfront import AlarmProxyCloudfrontAutomation
from app.terraform.alarms.proxy_http_status import AlarmProxyHTTPStatusAutomation
@@ -37,6 +38,7 @@ else:
jobs = {
x.short_name: x
for x in [
+ AlarmEotkAwsAutomation,
AlarmProxyAzureCdnAutomation,
AlarmProxyCloudfrontAutomation,
AlarmProxyHTTPStatusAutomation,
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 51d12a3..0ad9ca9 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,7 +1,10 @@
+from abc import abstractmethod
from datetime import datetime
from typing import Union, List, Optional, Any
+from app.alarms import alarms_for
from app.extensions import db
+from app.models.alarms import Alarm
class AbstractConfiguration(db.Model): # type: ignore
@@ -13,6 +16,15 @@ class AbstractConfiguration(db.Model): # type: ignore
updated = db.Column(db.DateTime(), default=datetime.utcnow, nullable=False)
destroyed = db.Column(db.DateTime(), nullable=True)
+ @property
+ def alarms(self) -> List[Alarm]:
+ return alarms_for(self.brn)
+
+ @property
+ @abstractmethod
+ def brn(self) -> str:
+ raise NotImplementedError()
+
def destroy(self) -> None:
self.destroyed = datetime.utcnow()
self.updated = datetime.utcnow()
@@ -59,6 +71,11 @@ class AbstractResource(db.Model): # type: ignore
if self.updated is None:
self.updated = datetime.utcnow()
+ @property
+ @abstractmethod
+ def brn(self) -> str:
+ raise NotImplementedError()
+
def deprecate(self, *, reason: str) -> None:
self.deprecated = datetime.utcnow()
self.deprecation_reason = reason
diff --git a/app/models/alarms.py b/app/models/alarms.py
index b529e06..a272e72 100644
--- a/app/models/alarms.py
+++ b/app/models/alarms.py
@@ -14,38 +14,30 @@ class AlarmState(enum.Enum):
class Alarm(db.Model): # type: ignore
id = db.Column(db.Integer, primary_key=True)
- target = db.Column(db.String(60), nullable=False)
- group_id = db.Column(db.Integer, db.ForeignKey("group.id"))
- origin_id = db.Column(db.Integer, db.ForeignKey("origin.id"))
- proxy_id = db.Column(db.Integer, db.ForeignKey("proxy.id"))
- bridge_id = db.Column(db.Integer, db.ForeignKey("bridge.id"))
- alarm_type = db.Column(db.String(255), nullable=False)
+ target = db.Column(db.String(255), nullable=False)
+ aspect = db.Column(db.String(255), nullable=False)
alarm_state = db.Column(db.Enum(AlarmState), default=AlarmState.UNKNOWN, nullable=False)
state_changed = db.Column(db.DateTime(), nullable=False)
- last_updated = db.Column(db.DateTime())
- text = db.Column(db.String(255))
-
- group = db.relationship("Group", back_populates="alarms")
- origin = db.relationship("Origin", back_populates="alarms")
- proxy = db.relationship("Proxy", back_populates="alarms")
- bridge = db.relationship("Bridge", back_populates="alarms")
+ last_updated = db.Column(db.DateTime(), nullable=False)
+ text = db.Column(db.String(255), nullable=False)
@classmethod
def csv_header(cls) -> List[str]:
- return [
- "id", "target", "group_id", "origin_id", "proxy_id", "bridge_id", "alarm_type",
- "alarm_state", "state_changed", "last_updated", "text"
- ]
+ return ["id", "target", "alarm_type", "alarm_state", "state_changed", "last_updated", "text"]
def csv_row(self) -> List[Any]:
- return [
- getattr(self, x) for x in self.csv_header()
- ]
+ return [getattr(self, x) for x in self.csv_header()]
def update_state(self, state: AlarmState, text: str) -> None:
+ from app.models.activity import Activity
+
if self.alarm_state != state or self.state_changed is None:
self.state_changed = datetime.utcnow()
+ activity = Activity(activity_type="alarm_state",
+ text=f"{self.alarm_state.name}->{state.name}! State changed for "
+ f"{self.aspect} on {self.target}: {text}")
+ activity.notify()
+ db.session.add(activity)
self.alarm_state = state
self.text = text
self.last_updated = datetime.utcnow()
- db.session.commit()
diff --git a/app/models/base.py b/app/models/base.py
index 13e20b7..12ca543 100644
--- a/app/models/base.py
+++ b/app/models/base.py
@@ -13,7 +13,6 @@ class Group(AbstractConfiguration):
bridgeconfs = db.relationship("BridgeConf", back_populates="group")
eotks = db.relationship("Eotk", back_populates="group")
onions = db.relationship("Onion", back_populates="group")
- alarms = db.relationship("Alarm", back_populates="group")
@classmethod
def csv_header(cls) -> List[str]:
diff --git a/app/models/bridges.py b/app/models/bridges.py
index a2cacc4..daa1bef 100644
--- a/app/models/bridges.py
+++ b/app/models/bridges.py
@@ -39,7 +39,6 @@ class Bridge(AbstractResource):
bridgeline = db.Column(db.String(255), nullable=True)
conf = db.relationship("BridgeConf", back_populates="bridges")
- alarms = db.relationship("Alarm", back_populates="bridge")
@classmethod
def csv_header(cls) -> List[str]:
diff --git a/app/models/mirrors.py b/app/models/mirrors.py
index 7a7f281..b305b50 100644
--- a/app/models/mirrors.py
+++ b/app/models/mirrors.py
@@ -1,5 +1,6 @@
from typing import Optional, List
+from flask import current_app
from tldextract import extract
from app.extensions import db
@@ -14,7 +15,10 @@ class Origin(AbstractConfiguration):
group = db.relationship("Group", back_populates="origins")
proxies = db.relationship("Proxy", back_populates="origin")
- alarms = db.relationship("Alarm", back_populates="origin")
+
+ @property
+ def brn(self) -> str:
+ return f"brn:{current_app.config['GLOBAL_NAMESPACE']}:{self.group_id}:mirror:conf:origin/{self.domain_name}"
@classmethod
def csv_header(cls) -> List[str]:
@@ -45,7 +49,10 @@ class Proxy(AbstractResource):
url = db.Column(db.String(255), nullable=True)
origin = db.relationship("Origin", back_populates="proxies")
- alarms = db.relationship("Alarm", back_populates="proxy")
+
+ @property
+ def brn(self) -> str:
+ return f"brn:{current_app.config['GLOBAL_NAMESPACE']}:{self.origin.group_id}:mirror:{self.provider}:proxy/{self.id}"
@classmethod
def csv_header(cls) -> List[str]:
diff --git a/app/models/onions.py b/app/models/onions.py
index cb8f252..679bd1d 100644
--- a/app/models/onions.py
+++ b/app/models/onions.py
@@ -1,3 +1,5 @@
+from flask import current_app
+
from app.extensions import db
from app.models import AbstractConfiguration, AbstractResource
@@ -17,3 +19,7 @@ class Eotk(AbstractResource):
region = db.Column(db.String(20), nullable=False)
group = db.relationship("Group", back_populates="eotks")
+
+ @property
+ def brn(self) -> str:
+ return f"brn:{current_app.config['GLOBAL_NAMESPACE']}:{self.group_id}:eotk:{self.provider}:instance/{self.region}"
diff --git a/app/portal/__init__.py b/app/portal/__init__.py
index f2537ea..b7c8231 100644
--- a/app/portal/__init__.py
+++ b/app/portal/__init__.py
@@ -1,8 +1,9 @@
from datetime import datetime, timedelta, timezone
-from typing import Optional
+from typing import Optional, Union
from flask import Blueprint, render_template, request
from flask.typing import ResponseReturnValue
+from jinja2 import Markup
from sqlalchemy import desc, or_
from app.models.activity import Activity
@@ -10,6 +11,7 @@ from app.models.alarms import Alarm, AlarmState
from app.models.bridges import Bridge
from app.models.mirrors import Origin, Proxy
from app.models.base import Group
+from app.models.onions import Eotk
from app.portal.automation import bp as automation
from app.portal.bridgeconf import bp as bridgeconf
from app.portal.bridge import bp as bridge
@@ -50,11 +52,44 @@ def format_datetime(s: Optional[datetime]) -> str:
return s.strftime("%a, %d %b %Y %H:%M:%S")
+@portal.app_template_filter("describe_brn")
+def describe_brn(s: str) -> Union[str, Markup]:
+ parts = s.split(":")
+ if parts[3] == "mirror":
+ if parts[5].startswith("origin/"):
+ origin = Origin.query.filter(
+ Origin.domain_name == parts[5][len("origin/"):]
+ ).first()
+ if not origin:
+ return s
+ return f"Origin: {origin.domain_name} ({origin.group.group_name})"
+ if parts[5].startswith("proxy/"):
+ proxy = Proxy.query.filter(
+ Proxy.id == int(parts[5][len("proxy/"):])
+ ).first()
+ if not proxy:
+ return s
+ return Markup(f"Proxy: {proxy.url}
({proxy.origin.group.group_name}: {proxy.origin.domain_name})") # type: ignore
+ if parts[5].startswith("quota/"):
+ if parts[4] == "cloudfront":
+ return f"Quota: CloudFront {parts[5][len('quota/'):]}"
+ if parts[3] == "eotk":
+ if parts[5].startswith("instance/"):
+ eotk = Eotk.query.filter(
+ Eotk.group_id == parts[2],
+ Eotk.region == parts[5][len("instance/"):]
+ ).first()
+ if not eotk:
+ return s
+ return f"EOTK Instance: {eotk.group.group_name} in {eotk.provider} {eotk.region}"
+ return s
+
+
def total_origins_blocked() -> int:
count = 0
for o in Origin.query.filter(Origin.destroyed.is_(None)).all():
for a in o.alarms:
- if a.alarm_type.startswith("origin-block-ooni-"):
+ if a.aspect.startswith("origin-block-ooni-"):
if a.alarm_state == AlarmState.WARNING:
count += 1
break
diff --git a/app/portal/templates/tables.html.j2 b/app/portal/templates/tables.html.j2
index 35ef30c..0b0ed78 100644
--- a/app/portal/templates/tables.html.j2
+++ b/app/portal/templates/tables.html.j2
@@ -28,7 +28,7 @@
Resource
- Type
+ Aspect
State
Message
Last Update
@@ -37,14 +37,8 @@