From ed22a14ffbca2b18925d5d6ff6f9f4e65caf8a26 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Mon, 24 Apr 2023 10:36:05 -0500 Subject: [PATCH] feat(discord_gcp_alerts): add discord gcp alerts package (#354) --- .../workflows/deploy_discord_gcp_alerts.yaml | 62 ++ .github/workflows/main.yaml | 4 + README.md | 1 + packages/discord_gcp_alerts/.dockerignore | 10 + packages/discord_gcp_alerts/.gitignore | 6 + packages/discord_gcp_alerts/Dockerfile | 21 + packages/discord_gcp_alerts/README.md | 7 + .../discord_gcp_alerts/analysis_options.yaml | 1 + packages/discord_gcp_alerts/bin/server.dart | 21 + packages/discord_gcp_alerts/build.yaml | 16 + .../lib/discord_gcp_alerts.dart | 2 + .../lib/src/gcp_alert_handler.dart | 47 ++ .../lib/src/models/gcp_alert.dart | 246 ++++++++ .../lib/src/models/gcp_alert.g.dart | 221 +++++++ .../lib/src/models/models.dart | 1 + packages/discord_gcp_alerts/pubspec.lock | 565 ++++++++++++++++++ packages/discord_gcp_alerts/pubspec.yaml | 20 + .../test/src/gcp_alert_handler_test.dart | 122 ++++ .../test/src/models/gcp_alert_test.dart | 34 ++ 19 files changed, 1407 insertions(+) create mode 100644 .github/workflows/deploy_discord_gcp_alerts.yaml create mode 100644 packages/discord_gcp_alerts/.dockerignore create mode 100644 packages/discord_gcp_alerts/.gitignore create mode 100644 packages/discord_gcp_alerts/Dockerfile create mode 100644 packages/discord_gcp_alerts/README.md create mode 100644 packages/discord_gcp_alerts/analysis_options.yaml create mode 100644 packages/discord_gcp_alerts/bin/server.dart create mode 100644 packages/discord_gcp_alerts/build.yaml create mode 100644 packages/discord_gcp_alerts/lib/discord_gcp_alerts.dart create mode 100644 packages/discord_gcp_alerts/lib/src/gcp_alert_handler.dart create mode 100644 packages/discord_gcp_alerts/lib/src/models/gcp_alert.dart create mode 100644 packages/discord_gcp_alerts/lib/src/models/gcp_alert.g.dart create mode 100644 packages/discord_gcp_alerts/lib/src/models/models.dart create mode 100644 packages/discord_gcp_alerts/pubspec.lock create mode 100644 packages/discord_gcp_alerts/pubspec.yaml create mode 100644 packages/discord_gcp_alerts/test/src/gcp_alert_handler_test.dart create mode 100644 packages/discord_gcp_alerts/test/src/models/gcp_alert_test.dart diff --git a/.github/workflows/deploy_discord_gcp_alerts.yaml b/.github/workflows/deploy_discord_gcp_alerts.yaml new file mode 100644 index 00000000..7ddc93f7 --- /dev/null +++ b/.github/workflows/deploy_discord_gcp_alerts.yaml @@ -0,0 +1,62 @@ +name: Deploy Discord GCP Alerts + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - .github/workflows/deploy_discord_gcp_alerts.yaml + - "packages/discord_gcp_alerts/**" + +env: + PROJECT_ID: code-push-prod + SERVICE: discord-gcp-alerts + REGION: us-central1 + +jobs: + deploy: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: packages/discord_gcp_alerts + + name: ☁️ Discord GCP Alerts + + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v3 + with: + submodules: recursive + + - uses: dart-lang/setup-dart@v1 + + - name: Setup Cloud SDK + uses: google-github-actions/setup-gcloud@v0.2.0 + with: + project_id: ${{ env.PROJECT_ID }} + service_account_key: ${{ secrets.CLOUD_RUN_SA_PROD }} + export_default_credentials: true + + - name: Authorize Docker Push + run: gcloud auth configure-docker + + - name: Build and Push Container + run: |- + docker build -t gcr.io/${{ env.PROJECT_ID }}/${{ env.SERVICE }}:${{ github.sha }} . + docker push gcr.io/${{ env.PROJECT_ID }}/${{ env.SERVICE }}:${{ github.sha }} + + - name: Deploy to Cloud Run + id: deploy + uses: google-github-actions/deploy-cloudrun@v0.4.0 + with: + service: ${{ env.SERVICE }} + image: gcr.io/${{ env.PROJECT_ID }}/${{ env.SERVICE }}:${{ github.sha }} + region: ${{ env.REGION }} + + - name: Show Output + run: echo ${{ steps.deploy.outputs.url }} + + - name: Ping + run: curl "${{ steps.deploy.outputs.url }}" diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 13a4ec67..6bb7f699 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -35,6 +35,10 @@ jobs: - ./.github/workflows/main.yaml - ./.github/actions/dart_package - packages/artifact_proxy/** + discord_gcp_alerts: + - ./.github/workflows/main.yaml + - ./.github/actions/dart_package + - packages/discord_gcp_alerts/** shorebird_cli: - ./.github/workflows/main.yaml - ./.github/actions/dart_package diff --git a/README.md b/README.md index ffc9fd72..8338015a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This repository is a monorepo containing the following packages: | [shorebird_cli](packages/shorebird_cli/README.md) | Command-line which allows developers to interact with various Shorebird services | | [shorebird_code_push_client](packages/shorebird_code_push_client/README.md) | Dart library which allows Dart applications to interact with the ShoreBird CodePush API | | [shorebird_code_push_protocol](packages/shorebird_code_push_protocol/README.md) | Dart library which contains common interfaces used by Shorebird CodePush | +| [discord_gcp_alerts](packages/discord_gcp_alerts/README.md) | Dart server which forwards GCP alerts to Discord | | [jwt](packages/jwt/README.md) | Dart library for verifying Json Web Tokens | | [updater](updater/README.md) | Rust library which handles the CodePush logic and does the real update work | diff --git a/packages/discord_gcp_alerts/.dockerignore b/packages/discord_gcp_alerts/.dockerignore new file mode 100644 index 00000000..30fd4b10 --- /dev/null +++ b/packages/discord_gcp_alerts/.dockerignore @@ -0,0 +1,10 @@ +.dockerignore +Dockerfile +coverage/ +build/ +.dart_tool/ +.git/ +.github/ +.gitignore +.idea/ +.packages diff --git a/packages/discord_gcp_alerts/.gitignore b/packages/discord_gcp_alerts/.gitignore new file mode 100644 index 00000000..2d29cdcd --- /dev/null +++ b/packages/discord_gcp_alerts/.gitignore @@ -0,0 +1,6 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Test related files +coverage/ \ No newline at end of file diff --git a/packages/discord_gcp_alerts/Dockerfile b/packages/discord_gcp_alerts/Dockerfile new file mode 100644 index 00000000..c333dee7 --- /dev/null +++ b/packages/discord_gcp_alerts/Dockerfile @@ -0,0 +1,21 @@ +# Use latest stable channel SDK. +FROM dart:stable AS build + +# Resolve app dependencies. +WORKDIR /app +COPY pubspec.* ./ +RUN dart pub get + +# Copy app source code (except anything in .dockerignore) and AOT compile app. +COPY . . +RUN dart compile exe bin/server.dart -o bin/server + +# Build minimal serving image from AOT-compiled `/server` +# and the pre-built AOT-runtime in the `/runtime/` directory of the base image. +FROM scratch +COPY --from=build /runtime/ / +COPY --from=build /app/bin/server /app/bin/ + +# Start server. +EXPOSE 8080 +CMD ["/app/bin/server"] diff --git a/packages/discord_gcp_alerts/README.md b/packages/discord_gcp_alerts/README.md new file mode 100644 index 00000000..ab56f0e7 --- /dev/null +++ b/packages/discord_gcp_alerts/README.md @@ -0,0 +1,7 @@ +# Discord GCP Alerts + +A simple server which proxies GCP Alert notifications to a Discord webhook. + +``` +WEBHOOK_URL= dart bin/server.dart +``` diff --git a/packages/discord_gcp_alerts/analysis_options.yaml b/packages/discord_gcp_alerts/analysis_options.yaml new file mode 100644 index 00000000..7b65405b --- /dev/null +++ b/packages/discord_gcp_alerts/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml \ No newline at end of file diff --git a/packages/discord_gcp_alerts/bin/server.dart b/packages/discord_gcp_alerts/bin/server.dart new file mode 100644 index 00000000..be9bfaca --- /dev/null +++ b/packages/discord_gcp_alerts/bin/server.dart @@ -0,0 +1,21 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:discord_gcp_alerts/discord_gcp_alerts.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; + +void main() async { + final webhookUrl = Platform.environment['WEBHOOK_URL']; + if (webhookUrl == null) { + print('WEBHOOK_URL environment variable is not set.'); + exit(1); + } + final handler = gcpAlertHandler(webhookUrl: webhookUrl); + final router = Router()..post('/', handler); + final port = int.parse(Platform.environment['PORT'] ?? '8080'); + final ip = InternetAddress.anyIPv4; + final server = await serve(router.call, ip, port); + print('Server listening on port ${server.port}'); +} diff --git a/packages/discord_gcp_alerts/build.yaml b/packages/discord_gcp_alerts/build.yaml new file mode 100644 index 00000000..edc6c369 --- /dev/null +++ b/packages/discord_gcp_alerts/build.yaml @@ -0,0 +1,16 @@ +targets: + $default: + builders: + source_gen|combining_builder: + options: + ignore_for_file: + - implicit_dynamic_parameter + - require_trailing_commas + - cast_nullable_to_non_nullable + - lines_longer_than_80_chars + - unnecessary_lambdas + json_serializable: + options: + field_rename: snake + checked: true + explicit_to_json: true diff --git a/packages/discord_gcp_alerts/lib/discord_gcp_alerts.dart b/packages/discord_gcp_alerts/lib/discord_gcp_alerts.dart new file mode 100644 index 00000000..450fd4ae --- /dev/null +++ b/packages/discord_gcp_alerts/lib/discord_gcp_alerts.dart @@ -0,0 +1,2 @@ +export 'src/gcp_alert_handler.dart'; +export 'src/models/models.dart'; diff --git a/packages/discord_gcp_alerts/lib/src/gcp_alert_handler.dart b/packages/discord_gcp_alerts/lib/src/gcp_alert_handler.dart new file mode 100644 index 00000000..2bd314d8 --- /dev/null +++ b/packages/discord_gcp_alerts/lib/src/gcp_alert_handler.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:discord_gcp_alerts/discord_gcp_alerts.dart'; +import 'package:http/http.dart' as http; +import 'package:shelf/shelf.dart'; + +/// A handler which notifies a Discord channel of a GCP alert. +Handler gcpAlertHandler({required String webhookUrl, http.Client? client}) { + return (Request req) async { + final body = await req.readAsString(); + final notification = GCPAlert.fromJson( + json.decode(body) as Map, + ); + final incident = notification.incident; + final summary = incident?.summary; + final state = incident?.state; + final url = incident?.url; + final resource = incident?.resourceName; + final conditionName = incident?.conditionName; + final discordPayload = { + 'embeds': [ + { + 'title': 'GCP Alert', + 'description': summary, + 'url': url, + 'color': 0xFF0000, + 'fields': [ + {'name': 'State', 'value': state, 'inline': true}, + {'name': 'Resource', 'value': resource, 'inline': true}, + {'name': 'Condition', 'value': conditionName, 'inline': true}, + ] + } + ], + 'attachments': const [] + }; + + final post = client?.post ?? http.post; + final response = await post( + Uri.parse(webhookUrl), + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + body: json.encode(discordPayload), + ); + + return Response(response.statusCode, body: response.body); + }; +} diff --git a/packages/discord_gcp_alerts/lib/src/models/gcp_alert.dart b/packages/discord_gcp_alerts/lib/src/models/gcp_alert.dart new file mode 100644 index 00000000..e38e67a5 --- /dev/null +++ b/packages/discord_gcp_alerts/lib/src/models/gcp_alert.dart @@ -0,0 +1,246 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'gcp_alert.g.dart'; + +/// {@template gcp_alert} +/// A GCP alert notification. +/// https://console.cloud.google.com/monitoring/alerting/notifications +/// +/// For sample alerts, see https://cloud.google.com/monitoring/alerts/policies-in-json. +/// {@endtemplate} +@JsonSerializable() +class GCPAlert { + /// {@macro gcp_alert} + const GCPAlert({this.version, this.incident}); + + /// Creates a [GCPAlert] from a JSON [Map]. + factory GCPAlert.fromJson(Map json) => + _$GCPAlertFromJson(json); + + /// Converts a [GCPAlert] to the JSON representation. + Map toJson() => _$GCPAlertToJson(this); + + /// The version of the alert. + final String? version; + + /// The incident associated with the alert. + final Incident? incident; +} + +/// {@template incident} +/// An incident which triggered an alert. +/// {@endtemplate} +@JsonSerializable() +class Incident { + /// {@macro incident} + const Incident({ + this.incidentId, + this.scopingProjectId, + this.scopingProjectNumber, + this.url, + this.startedAt, + this.endedAt, + this.state, + this.summary, + this.observedValue, + this.resource, + this.resourceTypeDisplayName, + this.resourceId, + this.resourceDisplayName, + this.resourceName, + this.metric, + this.policyName, + this.documentation, + this.condition, + this.conditionName, + this.thresholdValue, + }); + + /// Creates an [Incident] from a JSON [Map]. + factory Incident.fromJson(Map json) => + _$IncidentFromJson(json); + + /// Converts an [Incident] to the JSON representation. + Map toJson() => _$IncidentToJson(this); + + /// The unique identifier of the incident. + final String? incidentId; + + /// The project ID of the scoping project. + final String? scopingProjectId; + + /// The project number of the scoping project. + final int? scopingProjectNumber; + + /// The URL of the incident. + final String? url; + + /// The time the incident started. + final int? startedAt; + + /// The time the incident ended. + final int? endedAt; + + /// The state of the incident. + final String? state; + + /// A summary of the incident. + final String? summary; + + /// The observed value of the incident. + final String? observedValue; + + /// The resource associated with the incident. + final Resource? resource; + + /// The display name of the resource type. + final String? resourceTypeDisplayName; + + /// The unique identifier of the resource. + final String? resourceId; + + /// The display name of the resource. + final String? resourceDisplayName; + + /// The name of the resource. + final String? resourceName; + + /// The metric associated with the incident. + final Metric? metric; + + /// The name of the policy associated with the incident. + final String? policyName; + + /// The documentation associated with the incident. + final String? documentation; + + /// The condition associated with the incident. + final Condition? condition; + + /// The name of the condition associated with the incident. + final String? conditionName; + + /// The threshold value of the incident. + final String? thresholdValue; +} + +/// {@template resource} +/// A resource associated with an incident. +/// {@endtemplate} +@JsonSerializable() +class Resource { + /// {@macro resource} + const Resource({this.type}); + + /// Creates a [Resource] from a JSON [Map]. + factory Resource.fromJson(Map json) => + _$ResourceFromJson(json); + + /// Converts a [Resource] to the JSON representation. + Map toJson() => _$ResourceToJson(this); + + /// The type of the resource. + final String? type; +} + +/// {@template metric} +/// A metric associated with an incident. +/// {@endtemplate} +@JsonSerializable() +class Metric { + /// {@macro metric} + const Metric({this.type, this.displayName}); + + /// Creates a [Metric] from a JSON [Map]. + factory Metric.fromJson(Map json) => _$MetricFromJson(json); + + /// Converts a [Metric] to the JSON representation. + Map toJson() => _$MetricToJson(this); + + /// The type of the metric. + final String? type; + + /// The display name of the metric. + final String? displayName; +} + +/// {@template condition} +/// A condition associated with an incident. +/// {@endtemplate} +@JsonSerializable() +class Condition { + /// {@macro condition} + const Condition({this.name, this.displayName, this.conditionThreshold}); + + /// Creates a [Condition] from a JSON [Map]. + factory Condition.fromJson(Map json) => + _$ConditionFromJson(json); + + /// Converts a [Condition] to the JSON representation. + Map toJson() => _$ConditionToJson(this); + + /// The name of the condition. + final String? name; + + /// The display name of the condition. + final String? displayName; + + /// The condition threshold. + final ConditionThreshold? conditionThreshold; +} + +/// {@template condition_threshold} +/// The condition threshold associated with an incident. +/// {@endtemplate} +@JsonSerializable() +class ConditionThreshold { + /// {@macro condition_threshold} + const ConditionThreshold({ + this.filter, + this.comparison, + this.thresholdValue, + this.duration, + this.trigger, + }); + + /// Creates a [ConditionThreshold] from a JSON [Map]. + factory ConditionThreshold.fromJson(Map json) => + _$ConditionThresholdFromJson(json); + + /// Converts a [ConditionThreshold] to the JSON representation. + Map toJson() => _$ConditionThresholdToJson(this); + + /// The filter associated with the condition threshold. + final String? filter; + + /// The comparison associated with the condition threshold. + final String? comparison; + + /// The threshold value associated with the condition threshold. + final double? thresholdValue; + + /// The duration associated with the condition threshold. + final String? duration; + + /// The trigger associated with the condition threshold. + final Trigger? trigger; +} + +/// {@template trigger} +/// The trigger associated with an incident. +/// {@endtemplate} +@JsonSerializable() +class Trigger { + /// {@macro trigger} + const Trigger({this.count}); + + /// Creates a [Trigger] from a JSON [Map]. + factory Trigger.fromJson(Map json) => + _$TriggerFromJson(json); + + /// Converts a [Trigger] to the JSON representation. + Map toJson() => _$TriggerToJson(this); + + /// The count associated with the trigger. + final int? count; +} diff --git a/packages/discord_gcp_alerts/lib/src/models/gcp_alert.g.dart b/packages/discord_gcp_alerts/lib/src/models/gcp_alert.g.dart new file mode 100644 index 00000000..c59da333 --- /dev/null +++ b/packages/discord_gcp_alerts/lib/src/models/gcp_alert.g.dart @@ -0,0 +1,221 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'gcp_alert.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GCPAlert _$GCPAlertFromJson(Map json) => $checkedCreate( + 'GCPAlert', + json, + ($checkedConvert) { + final val = GCPAlert( + version: $checkedConvert('version', (v) => v as String?), + incident: $checkedConvert( + 'incident', + (v) => v == null + ? null + : Incident.fromJson(v as Map)), + ); + return val; + }, + ); + +Map _$GCPAlertToJson(GCPAlert instance) => { + 'version': instance.version, + 'incident': instance.incident?.toJson(), + }; + +Incident _$IncidentFromJson(Map json) => $checkedCreate( + 'Incident', + json, + ($checkedConvert) { + final val = Incident( + incidentId: $checkedConvert('incident_id', (v) => v as String?), + scopingProjectId: + $checkedConvert('scoping_project_id', (v) => v as String?), + scopingProjectNumber: + $checkedConvert('scoping_project_number', (v) => v as int?), + url: $checkedConvert('url', (v) => v as String?), + startedAt: $checkedConvert('started_at', (v) => v as int?), + endedAt: $checkedConvert('ended_at', (v) => v as int?), + state: $checkedConvert('state', (v) => v as String?), + summary: $checkedConvert('summary', (v) => v as String?), + observedValue: $checkedConvert('observed_value', (v) => v as String?), + resource: $checkedConvert( + 'resource', + (v) => v == null + ? null + : Resource.fromJson(v as Map)), + resourceTypeDisplayName: $checkedConvert( + 'resource_type_display_name', (v) => v as String?), + resourceId: $checkedConvert('resource_id', (v) => v as String?), + resourceDisplayName: + $checkedConvert('resource_display_name', (v) => v as String?), + resourceName: $checkedConvert('resource_name', (v) => v as String?), + metric: $checkedConvert( + 'metric', + (v) => v == null + ? null + : Metric.fromJson(v as Map)), + policyName: $checkedConvert('policy_name', (v) => v as String?), + documentation: $checkedConvert('documentation', (v) => v as String?), + condition: $checkedConvert( + 'condition', + (v) => v == null + ? null + : Condition.fromJson(v as Map)), + conditionName: $checkedConvert('condition_name', (v) => v as String?), + thresholdValue: + $checkedConvert('threshold_value', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const { + 'incidentId': 'incident_id', + 'scopingProjectId': 'scoping_project_id', + 'scopingProjectNumber': 'scoping_project_number', + 'startedAt': 'started_at', + 'endedAt': 'ended_at', + 'observedValue': 'observed_value', + 'resourceTypeDisplayName': 'resource_type_display_name', + 'resourceId': 'resource_id', + 'resourceDisplayName': 'resource_display_name', + 'resourceName': 'resource_name', + 'policyName': 'policy_name', + 'conditionName': 'condition_name', + 'thresholdValue': 'threshold_value' + }, + ); + +Map _$IncidentToJson(Incident instance) => { + 'incident_id': instance.incidentId, + 'scoping_project_id': instance.scopingProjectId, + 'scoping_project_number': instance.scopingProjectNumber, + 'url': instance.url, + 'started_at': instance.startedAt, + 'ended_at': instance.endedAt, + 'state': instance.state, + 'summary': instance.summary, + 'observed_value': instance.observedValue, + 'resource': instance.resource?.toJson(), + 'resource_type_display_name': instance.resourceTypeDisplayName, + 'resource_id': instance.resourceId, + 'resource_display_name': instance.resourceDisplayName, + 'resource_name': instance.resourceName, + 'metric': instance.metric?.toJson(), + 'policy_name': instance.policyName, + 'documentation': instance.documentation, + 'condition': instance.condition?.toJson(), + 'condition_name': instance.conditionName, + 'threshold_value': instance.thresholdValue, + }; + +Resource _$ResourceFromJson(Map json) => $checkedCreate( + 'Resource', + json, + ($checkedConvert) { + final val = Resource( + type: $checkedConvert('type', (v) => v as String?), + ); + return val; + }, + ); + +Map _$ResourceToJson(Resource instance) => { + 'type': instance.type, + }; + +Metric _$MetricFromJson(Map json) => $checkedCreate( + 'Metric', + json, + ($checkedConvert) { + final val = Metric( + type: $checkedConvert('type', (v) => v as String?), + displayName: $checkedConvert('display_name', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const {'displayName': 'display_name'}, + ); + +Map _$MetricToJson(Metric instance) => { + 'type': instance.type, + 'display_name': instance.displayName, + }; + +Condition _$ConditionFromJson(Map json) => $checkedCreate( + 'Condition', + json, + ($checkedConvert) { + final val = Condition( + name: $checkedConvert('name', (v) => v as String?), + displayName: $checkedConvert('display_name', (v) => v as String?), + conditionThreshold: $checkedConvert( + 'condition_threshold', + (v) => v == null + ? null + : ConditionThreshold.fromJson(v as Map)), + ); + return val; + }, + fieldKeyMap: const { + 'displayName': 'display_name', + 'conditionThreshold': 'condition_threshold' + }, + ); + +Map _$ConditionToJson(Condition instance) => { + 'name': instance.name, + 'display_name': instance.displayName, + 'condition_threshold': instance.conditionThreshold?.toJson(), + }; + +ConditionThreshold _$ConditionThresholdFromJson(Map json) => + $checkedCreate( + 'ConditionThreshold', + json, + ($checkedConvert) { + final val = ConditionThreshold( + filter: $checkedConvert('filter', (v) => v as String?), + comparison: $checkedConvert('comparison', (v) => v as String?), + thresholdValue: $checkedConvert( + 'threshold_value', (v) => (v as num?)?.toDouble()), + duration: $checkedConvert('duration', (v) => v as String?), + trigger: $checkedConvert( + 'trigger', + (v) => v == null + ? null + : Trigger.fromJson(v as Map)), + ); + return val; + }, + fieldKeyMap: const {'thresholdValue': 'threshold_value'}, + ); + +Map _$ConditionThresholdToJson(ConditionThreshold instance) => + { + 'filter': instance.filter, + 'comparison': instance.comparison, + 'threshold_value': instance.thresholdValue, + 'duration': instance.duration, + 'trigger': instance.trigger?.toJson(), + }; + +Trigger _$TriggerFromJson(Map json) => $checkedCreate( + 'Trigger', + json, + ($checkedConvert) { + final val = Trigger( + count: $checkedConvert('count', (v) => v as int?), + ); + return val; + }, + ); + +Map _$TriggerToJson(Trigger instance) => { + 'count': instance.count, + }; diff --git a/packages/discord_gcp_alerts/lib/src/models/models.dart b/packages/discord_gcp_alerts/lib/src/models/models.dart new file mode 100644 index 00000000..b3eb1960 --- /dev/null +++ b/packages/discord_gcp_alerts/lib/src/models/models.dart @@ -0,0 +1 @@ +export 'gcp_alert.dart'; diff --git a/packages/discord_gcp_alerts/pubspec.lock b/packages/discord_gcp_alerts/pubspec.lock new file mode 100644 index 00000000..020347c8 --- /dev/null +++ b/packages/discord_gcp_alerts/pubspec.lock @@ -0,0 +1,565 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a36ec4843dc30ea6bf652bf25e3448db6c5e8bcf4aa55f063a5d1dad216d8214 + url: "https://pub.dev" + source: hosted + version: "58.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: cc4242565347e98424ce9945c819c192ec0838cb9d1f6aa4a97cc96becbc5b27 + url: "https://pub.dev" + source: hosted + version: "5.10.0" + args: + dependency: transitive + description: + name: args + sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "757153e5d9cd88253cb13f28c2fb55a537dc31fefd98137549895b5beb7c6169" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: b0a8a7b8a76c493e85f1b84bffa0588859a06197863dba8c9036b15581fd9727 + url: "https://pub.dev" + source: hosted + version: "2.3.3" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "14febe0f5bac5ae474117a36099b4de6f1dbc52df6c5e55534b3da9591bf4292" + url: "https://pub.dev" + source: hosted + version: "7.2.7" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b7c748fd4b9adf8d25d72a4c4a59ef119f12876cf414f94f8af5131d5fa2b0" + url: "https://pub.dev" + source: hosted + version: "8.4.4" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + url: "https://pub.dev" + source: hosted + version: "4.4.0" + collection: + dependency: transitive + description: + name: collection + sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + url: "https://pub.dev" + source: hosted + version: "1.17.1" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: transitive + description: + name: crypto + sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + url: "https://pub.dev" + source: hosted + version: "3.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "6d691edde054969f0e0f26abb1b30834b5138b963793e56f69d3a9a4435e6352" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + glob: + dependency: transitive + description: + name: glob + sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "6aa2946395183537c8b880962d935877325d6a09a2867c3970c05c0fed6ac482" + url: "https://pub.dev" + source: hosted + version: "0.13.5" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: c192bb6fb4ae99d06053f67a2c1c65350a29bc778a39d9a12b96bd2ec820e9dc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 + url: "https://pub.dev" + source: hosted + version: "4.8.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: dadc08bd61f72559f938dd08ec20dbfec6c709bba83515085ea943d2078d187a + url: "https://pub.dev" + source: hosted + version: "6.6.1" + logging: + dependency: transitive + description: + name: logging + sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + url: "https://pub.dev" + source: hosted + version: "0.12.15" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "80a996cd9a69284b3dc521ce185ffe9150cde69767c2d3a0720147d93c0cef53" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: ec85d7d55339d85f44ec2b682a82fea340071e8978257e5a43e69f79e98ef50c + url: "https://pub.dev" + source: hosted + version: "1.2.2" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: c24a96135a2ccd62c64b69315a14adc5c3419df63b4d7c05832a346fdb73682c + url: "https://pub.dev" + source: hosted + version: "1.4.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: aef74dc9195746a384843102142ab65b6a4735bb3beea791e63527b88cc83306 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: "0b0bfb835e8b2bb43c5341ee689f0d2851e9cea377a4f2db4ec06a1a99beace4" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: e792b76b96a36d4a41b819da593aff4bdd413576b3ba6150df5d8d9996d2e74c + url: "https://pub.dev" + source: hosted + version: "1.1.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: a988c0e8d8ffbdb8a28aa7ec8e449c260f3deb808781fe1284d22c5bba7156e8 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: c2bea18c95cfa0276a366270afaa2850b09b4a76db95d546f3d003dcc7011298 + url: "https://pub.dev" + source: hosted + version: "1.2.7" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "4f92f103ef63b1bbac6f4bd1930624fca81b2574464482512c4f0896319be575" + url: "https://pub.dev" + source: hosted + version: "1.24.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: daadc9baabec998b062c9091525aa95786508b1c48e9c30f1f891b8bf6ff2e64 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + test_core: + dependency: transitive + description: + name: test_core + sha256: "3642b184882f79e76ca57a9230fb971e494c3c1fd09c21ae3083ce891bcc0aa1" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: ebc48c51db35beeeec8c414e32f7bd78e612bd7f5992ccb0d46e19edaeb40b08 + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "518254c0d3ee20667a1feef39eefe037df87439851e4b3cb277e5b3f37afa2f0" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + url: "https://pub.dev" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.19.6 <3.0.0" diff --git a/packages/discord_gcp_alerts/pubspec.yaml b/packages/discord_gcp_alerts/pubspec.yaml new file mode 100644 index 00000000..9ca23495 --- /dev/null +++ b/packages/discord_gcp_alerts/pubspec.yaml @@ -0,0 +1,20 @@ +name: discord_gcp_alerts +description: A server which proxies GCP Alert notifications to Discord. +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=2.19.6 <3.0.0' + +dependencies: + http: ^0.13.5 + json_annotation: ^4.8.0 + shelf: ^1.4.0 + shelf_router: ^1.1.0 + +dev_dependencies: + build_runner: ^2.3.3 + json_serializable: ^6.6.1 + mocktail: ^0.3.0 + test: ^1.21.0 + very_good_analysis: ^4.0.0 diff --git a/packages/discord_gcp_alerts/test/src/gcp_alert_handler_test.dart b/packages/discord_gcp_alerts/test/src/gcp_alert_handler_test.dart new file mode 100644 index 00000000..bc7ac88d --- /dev/null +++ b/packages/discord_gcp_alerts/test/src/gcp_alert_handler_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:discord_gcp_alerts/discord_gcp_alerts.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; + +class _MockHttpClient extends Mock implements http.Client {} + +void main() { + group('gcpAlertHandler', () { + const webHookUrl = 'http://example.com/webhook'; + late http.Client client; + late Handler handler; + + setUp(() { + client = _MockHttpClient(); + handler = gcpAlertHandler(webhookUrl: webHookUrl, client: client); + }); + + test('forwards the request to the webhook', () async { + when( + () => client.post( + Uri.parse(webHookUrl), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer((_) async => http.Response('', 200)); + + await handler( + Request( + 'POST', + Uri.parse('http://localhost:8080/'), + body: json.encode( + { + 'version': 'test', + 'incident': { + 'incident_id': '12345', + 'scoping_project_id': '12345', + 'scoping_project_number': 12345, + 'url': 'http://www.example.com', + 'started_at': 0, + 'ended_at': 0, + 'state': 'OPEN', + 'summary': 'Test Incident', + 'apigee_url': 'http://www.example.com', + 'observed_value': '1.0', + 'resource': { + 'type': 'example_resource', + 'labels': {'example': 'label'} + }, + 'resource_type_display_name': 'Example Resource Type', + 'resource_id': '12345', + 'resource_display_name': 'Example Resource', + 'resource_name': 'projects/12345/example_resources/12345', + 'metric': { + 'type': 'test.googleapis.com/metric', + 'displayName': 'Test Metric', + 'labels': {'example': 'label'} + }, + 'metadata': { + 'system_labels': {'example': 'label'}, + 'user_labels': {'example': 'label'} + }, + 'policy_name': 'projects/12345/alertPolicies/12345', + 'policy_user_labels': {'example': 'label'}, + 'documentation': 'Test documentation', + 'condition': { + 'name': 'projects/12345/alertPolicies/12345/conditions/12345', + 'displayName': 'Example condition', + 'conditionThreshold': { + 'filter': + 'metric.type=\"test.googleapis.com/metric\" resource.type=\"example_resource\"', + 'comparison': 'COMPARISON_GT', + 'thresholdValue': 0.5, + 'duration': '0s', + 'trigger': {'count': 1} + } + }, + 'condition_name': 'Example condition', + 'threshold_value': '0.5' + } + }, + ), + ), + ); + + verify( + () => client.post( + Uri.parse(webHookUrl), + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + body: json.encode({ + 'embeds': [ + { + 'title': 'GCP Alert', + 'description': 'Test Incident', + 'url': 'http://www.example.com', + 'color': 16711680, + 'fields': [ + {'name': 'State', 'value': 'OPEN', 'inline': true}, + { + 'name': 'Resource', + 'value': 'projects/12345/example_resources/12345', + 'inline': true + }, + { + 'name': 'Condition', + 'value': 'Example condition', + 'inline': true + } + ] + } + ], + 'attachments': [] + }), + ), + ).called(1); + }); + }); +} diff --git a/packages/discord_gcp_alerts/test/src/models/gcp_alert_test.dart b/packages/discord_gcp_alerts/test/src/models/gcp_alert_test.dart new file mode 100644 index 00000000..55eeda21 --- /dev/null +++ b/packages/discord_gcp_alerts/test/src/models/gcp_alert_test.dart @@ -0,0 +1,34 @@ +import 'package:discord_gcp_alerts/discord_gcp_alerts.dart'; +import 'package:test/test.dart'; + +void main() { + group('GCPAlert', () { + test('can be (de)serialized', () { + const alert = GCPAlert( + version: '1.0.0', + incident: Incident( + condition: Condition( + name: 'test', + displayName: 'Test Condition', + conditionThreshold: ConditionThreshold( + trigger: Trigger(count: 10), + filter: 'metric.type = "test.googleapis.com/metric"', + comparison: 'ComparisonType.greaterThan', + thresholdValue: 1, + duration: '0s', + ), + ), + resource: Resource(type: 'test_resource'), + metric: Metric( + type: 'test.googleapis.com/metric', + displayName: 'Test Metric', + ), + ), + ); + expect( + GCPAlert.fromJson(alert.toJson()).toJson(), + equals(alert.toJson()), + ); + }); + }); +}