diff --git a/Dockerfile.response b/Dockerfile.response index 2957b4e..bd65de2 100644 --- a/Dockerfile.response +++ b/Dockerfile.response @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.9-slim RUN apt-get update && apt-get install -y --no-install-recommends \ netcat-openbsd \ diff --git a/README.md b/README.md index 778f64b..cc2962a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ To start the application locally, copy `env.dev.example` to `.env` and configure You will need to configure a Slack app following the instructions below, and can then start the application with `docker-compose up -d`. -Note if you are using ngrok, they have now introduced auth tokens so you'll need to add one to the `ngrok.yml` in the ngrok container +Note if you are using ngrok, they have now introduced auth tokens. You can add an NGROK_AUTHTOKEN to your local .env file and it will be passed down to the container so you don't risk commiting it. ## Versions and Releases @@ -20,7 +20,7 @@ By default, any merge to main will be a MINOR release. You can control which ver In order to avoid polluting our real Slack workspace, and to give you full control over permissions, you should configure your local copy of the app with [your own Slack workspace](#slack-create). -You now need to [create a Slack app](#slack-app-create) and [configure it](#slack-app-config). Note that you'll need your public ngrok URL to configure endpoints for Slack to use, which you can find by running `docker-compose logs ngrok`. +You now need to [create a Slack app](#slack-app-create) and [configure it](#slack-app-config). Note that you'll need your public ngrok URL to configure endpoints for Slack to use, which you can find by visiting the ngrok admin page which is visible on ```localhost:4040``` once you have run ```docker-compose up```. After you've configured your app, Slack will provide you with bot OAuth token (starting `xoxb-`) and a signing secret, which should be used for the `SLACK_TOKEN` and `SLACK_SIGNING_SECRET` environment variables, respectively. You'll also need to set `SLACK_TEAM_ID` to the team ID of your Slack workspace. @@ -36,10 +36,6 @@ GitHub signin is turned off in dev mode, but you can enable it by enabling the ` To connect to GitHub, you'll need to create a GitHub OAuth App and set the environment variables `SOCIAL_AUTH_GITHUB_KEY` and `SOCIAL_AUTH_GITHUB_SECRET` to its the app's key and secret respectively. There is already an app called "opg-response-development" which is set up in the ministryofjustice organization for local development. -#### Statuspage - -As with Slack, local development shouldn't interfere with our real Statuspage so you'll need to set up your own account. You should then set `STATUSPAGEIO_API_KEY` to [your API key](#statuspage-api-key) and `STATUSPAGEIO_PAGE_ID` to your team ID. - ### Environment variables | Variable | Real value required? | Details | @@ -54,8 +50,6 @@ As with Slack, local development shouldn't interfere with our real Statuspage so | INCIDENT_BOT_NAME | Yes | The name of your test app | | INCIDENT_CHANNEL_NAME | Yes | The channel to post new live incidents to | | INCIDENT_REPORT_CHANNEL_NAME | Yes | The channel to post new incident reports to | -| STATUSPAGEIO_API_KEY | Only if testing Statuspage | Provided by Statuspage | -| STATUSPAGEIO_PAGE_ID | Only if testing Statuspage | Provided by Statuspage | | PAGERDUTY_API_KEY | Only if testing PagerDuty | Provided by Pagerduty | | PAGERDUTY_EMAIL | Only if testing PagerDuty | Provided by Pagerduty | | PAGERDUTY_SERVICE | Only if testing PagerDuty | Provided by Pagerduty | @@ -77,7 +71,3 @@ As with Slack, local development shouldn't interfere with our real Statuspage so ### slack-app-config [https://github.com/monzo/response/blob/master/docs/slack_app_config.md](https://github.com/monzo/response/blob/master/docs/slack_app_config.md) - -### statuspage-api-key - -[https://support.atlassian.com/statuspage/docs/create-and-manage-api-keys/](https://support.atlassian.com/statuspage/docs/create-and-manage-api-keys/) diff --git a/docker-compose.yml b/docker-compose.yml index 07f4355..00c90a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,11 +55,13 @@ services: - postgres_data:/var/lib/postgresql/data/ ngrok: - image: gtriggiano/ngrok-tunnel + image: ngrok/ngrok:latest + restart: unless-stopped + command: + - "http" + - "http://response:8000" container_name: ngrok - environment: - TARGET_HOST: "response" - TARGET_PORT: 8000 + env_file: .env ports: - "4040:4040" depends_on: diff --git a/env.dev.example b/env.dev.example index 21356e8..c8b553e 100644 --- a/env.dev.example +++ b/env.dev.example @@ -12,9 +12,6 @@ INCIDENT_BOT_NAME= INCIDENT_CHANNEL_NAME=incidents INCIDENT_REPORT_CHANNEL_NAME=incidents -STATUSPAGEIO_API_KEY= -STATUSPAGEIO_PAGE_ID= - PAGERDUTY_API_KEY= PAGERDUTY_EMAIL= PAGERDUTY_SERVICE= diff --git a/opgincidentresponse/actions/keyword_handlers.py b/opgincidentresponse/actions/keyword_handlers.py index 09328ed..473a809 100644 --- a/opgincidentresponse/actions/keyword_handlers.py +++ b/opgincidentresponse/actions/keyword_handlers.py @@ -1,12 +1,6 @@ -from response.core.models.incident import Incident -from django.conf import settings from response.slack.models import CommsChannel from response.slack.decorators import keyword_handler -@keyword_handler(['status page', 'statuspage']) -def status_page_notification(comms_channel: CommsChannel, user: str, keyword: str, text: str, ts: str): - comms_channel.post_in_channel(f"ℹ️ You mentioned the Status Page - You can find our statuspage here: https://theofficeofthepublicguardian.statuspage.io/") - @keyword_handler(['runbook', 'run book']) def runbook_notification(comms_channel: CommsChannel, user: str, keyword: str, text: str, ts: str): comms_channel.post_in_channel(f"ℹ️ You mentioned runbooks - You can find runbooks for our services here: https://ministryofjustice.github.io/opg-technical-guidance/#opg-technical-guidance/") \ No newline at end of file diff --git a/opgincidentresponse/actions/statuspage.py b/opgincidentresponse/actions/statuspage.py deleted file mode 100644 index a987904..0000000 --- a/opgincidentresponse/actions/statuspage.py +++ /dev/null @@ -1,179 +0,0 @@ -import logging -import json -from django.db import models - -from response.core.models import Incident -from response.slack import block_kit, dialog_builder -from response.slack.models import CommsChannel -from response.slack.decorators import ActionContext, action_handler, dialog_handler, keyword_handler -from response.slack.decorators.incident_command import incident_command -from datetime import datetime - -from opgincidentresponse.models import StatusPage - -logger = logging.getLogger(__name__) - -OPEN_STATUS_PAGE_DIALOG = "dialog-open-status-page" -STATUS_PAGE_UPDATE = "status-page-update" - -@incident_command( - ["statuspage", "sp"], helptext="Update the statuspage for this incident" -) -def handle_statuspage(incident: Incident, user_id: str, message: str): - - logger.info("Handling statuspage command") - comms_channel = CommsChannel.objects.get(incident=incident) - - try: - status_page = StatusPage.objects.get(incident=incident) - values = status_page.get_from_statuspage() - - if values.get("status") == "resolved": - comms_channel.post_in_channel( - "The status page can't be updated after it has been resolved." - ) - return True, None - except models.ObjectDoesNotExist: - logger.info( - "Existing status page not found. Posting button to create a new one" - ) - - msg = block_kit.Message() - msg.add_block( - block_kit.Section( - block_id="title", - text=block_kit.Text(f"To update the Statuspage, click below!"), - ) - ) - msg.add_block( - block_kit.Actions( - block_id="actions", - elements=[ - block_kit.Button( - "Update Statuspage", OPEN_STATUS_PAGE_DIALOG, value=incident.pk - ) - ], - ) - ) - - msg.send(comms_channel.channel_id) - return True, None - - -@action_handler(OPEN_STATUS_PAGE_DIALOG) -def handle_open_status_page_dialog(action_context: ActionContext): - try: - status_page = StatusPage.objects.get(incident=action_context.incident) - values = status_page.get_from_statuspage() - - if values.get("status") == "resolved": - logger.info( - f"Status Page incident '{values.get('name')}' has been resolved" - ) - status_page.incident.comms_channel().post_in_channel( - "The status page can't be updated after it has been resolved." - ) - return - - except models.ObjectDoesNotExist: - values = { - "name": "We're experiencing some issues at the moment", - "status": "investigating", - "message": "We're getting all the information we need to fix this and will update the status page as soon as we can.", - "impact_override": "major", - "component_id": None, - "component_status": "operational", - } - - dialog = dialog_builder.Dialog( - title="Statuspage Update", - submit_label="Update", - state=action_context.incident.pk, - elements=[ - dialog_builder.Text( - label="Name", - name="name", - value=values.get("name"), - hint="Make this concise and clear - it's what will show in the apps", - ), - dialog_builder.SelectWithOptions( - [ - ("Investigating", "investigating"), - ("Identified", "identified"), - ("Monitoring", "monitoring"), - ("Resolved", "resolved"), - ], - label="Status", - name="incident_status", - value=values.get("status"), - ), - dialog_builder.TextArea( - label="Description", - name="message", - optional=True, - value=values.get("message"), - ), - dialog_builder.SelectWithOptions( - [ - ("Minor", "minor"), - ("Major", "major"), - ("Critical", "critical"), - ], - label="Severity", - name="impact_override", - optional=True, - value=values.get("impact_override"), - ), - dialog_builder.SelectWithOptions( - StatusPage.get_components(), - label="Affected component", - name="component_id", - optional=True, - value=values.get("component_id"), - ), - dialog_builder.SelectWithOptions( - [ - ("Operational", "operational"), - ("Under maintenance", "under_maintenance"), - ("Degraded performance", "degraded_performance"), - ("Partial outage", "partial_outage"), - ("Major outage", "major_outage"), - ], - label="Component status", - name="component_status", - optional=True, - value=values.get("component_status"), - ), - ], - ) - - dialog.send_open_dialog(STATUS_PAGE_UPDATE, action_context.trigger_id) - - -@dialog_handler(STATUS_PAGE_UPDATE) -def update_status_page( - user_id: str, channel_id: str, submission: json, response_url: str, state: json -): - incident_id = state - incident = Incident.objects.get(pk=incident_id) - - try: - status_page = StatusPage.objects.get(incident=incident_id) - except models.ObjectDoesNotExist: - status_page = StatusPage(incident=incident) - status_page.save() - - statuspage_incident = { - "name": submission["name"], - "status": submission["incident_status"], - "message": submission["message"] or "", - "component_status": submission["component_status"], - } - - if submission["component_id"]: - statuspage_incident["component_ids"] = [submission["component_id"]] - - if submission["impact_override"]: - statuspage_incident["impact_override"] = submission["impact_override"] - - status_page.update_statuspage(**statuspage_incident) diff --git a/opgincidentresponse/models.py b/opgincidentresponse/models.py index 762508d..60fe020 100644 --- a/opgincidentresponse/models.py +++ b/opgincidentresponse/models.py @@ -1,106 +1,6 @@ -import statuspageio - -from django.conf import settings from django.contrib import admin from django.db import models -from response.core.models import Incident - -class StatusPageError(Exception): - pass - -__statuspage_client = None - -def statuspage_client(): - global __statuspage_client - if __statuspage_client == None: - if getattr(settings, "STATUSPAGEIO_API_KEY", None) and getattr( - settings, "STATUSPAGEIO_PAGE_ID", None - ): - __statuspage_client = statuspageio.Client( - api_key=settings.STATUSPAGEIO_API_KEY, - page_id=settings.STATUSPAGEIO_PAGE_ID, - ) - else: - raise ValueError( - "Statuspage client called but not configured. Check that STATUSPAGEIO_API_KEY and STATUSPAGEIO_PAGE_ID are configured in Django settings." - ) - return __statuspage_client - -class StatusPage(models.Model): - incident = models.ForeignKey(Incident, on_delete=models.PROTECT) - statuspage_incident_id = models.CharField(max_length=100, unique=True, null=True) - - def update_statuspage(self, **kwargs): - if self.statuspage_incident_id: - statuspage_client().incidents.update( - incident_id=self.statuspage_incident_id, **kwargs - ) - else: - response = statuspage_client().incidents.create(**kwargs) - self.statuspage_incident_id = response["id"] - self.save() - - if 'component_ids' in kwargs and kwargs['component_ids']: - if kwargs['status'] == 'resolved': - status = 'operational' - else: - status = kwargs['component_status'] - - if status: - statuspage_client().components.update( - component_id=kwargs['component_ids'][0], status=status - ) - - def get_from_statuspage(self): - if self.statuspage_incident_id: - for incident in statuspage_client().incidents.list(): - if incident["id"] == self.statuspage_incident_id: - obj = { - "name": incident["name"], - "status": incident["status"], - "message": incident["incident_updates"][0]["body"], - "impact_override": incident["impact_override"], - } - - if len(incident["components"]) > 0: - obj["component_id"] = incident["components"][0]["id"] - obj["component_status"] = incident["components"][0]["status"] - - return obj - raise StatusPageError( - f"Statuspage incident with id {self.statuspage_incident_id} not found" - ) - return {} - - @staticmethod - def get_components(): - options = [] - - components = statuspage_client().components.list() - - for component in components: - if component.group: - continue - - name = component.name - - if component.group_id: - group_name = [c.name for c in components if c.id == component.group_id][0] - name = f"{group_name} — {name}" - - options.append( (name, component.id) ) - - return sorted(options, key=lambda t: t[0].lower()) - -@admin.register(StatusPage) -class StatusPageAdmin(admin.ModelAdmin): - list_display = ("incident_summary", "statuspage_incident_id") - - def incident_summary(self, obj): - return obj.incident.report - - class PagerDutySpecialist(models.Model): name = models.CharField(max_length=100, unique=True) summary = models.TextField(max_length=1000) diff --git a/opgincidentresponse/settings/base.py b/opgincidentresponse/settings/base.py index 5f353d3..36a7826 100644 --- a/opgincidentresponse/settings/base.py +++ b/opgincidentresponse/settings/base.py @@ -219,11 +219,6 @@ def get_env_var(setting, warn_only=False): INCIDENT_CHANNEL_ID = SLACK_CLIENT.get_channel_id(INCIDENT_CHANNEL_NAME) INCIDENT_REPORT_CHANNEL_ID = SLACK_CLIENT.get_channel_id(INCIDENT_REPORT_CHANNEL_NAME) -## Statuspage - -STATUSPAGEIO_API_KEY = get_env_var("STATUSPAGEIO_API_KEY") -STATUSPAGEIO_PAGE_ID = get_env_var("STATUSPAGEIO_PAGE_ID") - ## PagerDuty PAGERDUTY_API_KEY = get_env_var("PAGERDUTY_API_KEY") diff --git a/opgincidentresponse/tests/actions/test_keyword_handlers.py b/opgincidentresponse/tests/actions/test_keyword_handlers.py index d1e0abe..bfaea15 100644 --- a/opgincidentresponse/tests/actions/test_keyword_handlers.py +++ b/opgincidentresponse/tests/actions/test_keyword_handlers.py @@ -1,14 +1,6 @@ -from opgincidentresponse.actions.pagerduty import page_specialist -from opgincidentresponse.actions.keyword_handlers import status_page_notification, runbook_notification +from opgincidentresponse.actions.keyword_handlers import runbook_notification from response.slack.models import CommsChannel -def test_status_page_notification(mocker): - mocker.patch.object(CommsChannel, 'post_in_channel') - - status_page_notification(CommsChannel, '', '', '', '') - - CommsChannel.post_in_channel.assert_called_once_with("ℹ️ You mentioned the Status Page - You can find our statuspage here: https://theofficeofthepublicguardian.statuspage.io/") - def test_runbook_notification(mocker): mocker.patch.object(CommsChannel, 'post_in_channel') diff --git a/opgincidentresponse/wsgi.py b/opgincidentresponse/wsgi.py index 6169f9b..dc0feff 100644 --- a/opgincidentresponse/wsgi.py +++ b/opgincidentresponse/wsgi.py @@ -11,5 +11,4 @@ from .actions.incident_commands import * from .actions.incident_notifications import * from .actions.keyword_handlers import * -from .actions.statuspage import * from .actions.pagerduty import * diff --git a/requirements.txt b/requirements.txt index b765f04..94400b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,4 @@ python-dotenv==1.0.1 gunicorn==21.2.0 psycopg2-binary==2.9.9 social-auth-app-django -statuspageio pypd