diff --git a/.drone.yml b/.drone.yml index 8b49c117..8c4b3332 100644 --- a/.drone.yml +++ b/.drone.yml @@ -107,6 +107,8 @@ steps: target: prod config: from_secret: gcr_admin + build_args: + - BUILDKIT_INLINE_CACHE=1 depends_on: - Lint Backend - Unit Test Backend @@ -128,6 +130,8 @@ steps: from_secret: docker_password username: from_secret: docker_username + build_args: + - BUILDKIT_INLINE_CACHE=1 depends_on: - Lint Backend - Unit Test Backend @@ -244,6 +248,11 @@ steps: # Be aware that the this plugin requires privileged capabilities, otherwise the integrated # Docker daemon is not able to start. privileged: true + environment: + # force docker to use buildkit feature, this will skip build stages that aren't required in + # the final image (ie. dev & dev-enterprise) + # https://github.com/docker/cli/issues/1134#issuecomment-406449342 + DOCKER_BUILDKIT: 1 settings: repo: grafana/oncall tags: latest,${DRONE_TAG} @@ -255,9 +264,36 @@ steps: from_secret: docker_password username: from_secret: docker_username + build_args: + - BUILDKIT_INLINE_CACHE=1 depends_on: - set engine version + - name: merge helm release oss pr + image: alpine + environment: + GITHUB_API_KEY: + from_secret: github_api_token + # Allow this step to fail as it's not critical to the build process and we don't want to block + # the build promotion. If this step fails we can always merge the PR manually + failure: ignore + commands: + # yamllint disable rule:line-length + - apk add --no-cache curl jq + # Step 1. Fetch PRs from GitHub's API that're open and have a particular head ref indicative of a helm release PR + # API docs - https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests + # NOTE: ${DRONE_TAG:1} will slice off the "v" prefix from the tag + - 'curl -L -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_API_KEY}" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/grafana/oncall/pulls?head=grafana:helm-release/$${DRONE_TAG:1}&state=open" > prs.json' + - cat prs.json + # Step 2. Extract the PR number from the first PR in the list to be able to pass that to the next API call + - cat prs.json | jq -r ".[0].number" > pr_number.txt + - cat pr_number.txt + # Step 3. Merge the PR (https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#merge-a-pull-request) + - 'cat pr_number.txt | xargs -r -I{pull_number} curl -L -X PUT -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $${GITHUB_API_KEY}" -H "X-GitHub-Api-Version: 2022-11-28" "https://api.github.com/repos/grafana/oncall/pulls/{pull_number}/merge"' + # yamllint enable rule:line-length + depends_on: + - build and push docker image + trigger: event: - promote @@ -327,8 +363,14 @@ get: path: infra/data/ci/drone kind: secret name: drone_token + +--- +# GitHub API token (only scoped to grafana/oncall + PR access) +get: + name: github-api-token + path: ci/data/repo/grafana/oncall/drone +kind: secret +name: github_api_token --- kind: signature -hmac: 8b387638ef35f86ed9f8c8c964726e0ae703beb7d0f18e053b5041ab8ee9bed2 - -... +hmac: 9cc2cc2db01455d34768577047f8069d19c0573b6a05dd26c88b9deb3af3a0c9 diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 1281dc7c..f077805e 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -124,8 +124,11 @@ jobs: engine/requirements-dev.txt - name: Lint migrations working-directory: engine + # makemigrations --check = Exit with a non-zero status if model changes are missing migrations + # and don't actually write them. run: | pip install -r requirements.txt -r requirements-dev.txt + python manage.py makemigrations --check python manage.py lintmigrations unit-test-helm-chart: diff --git a/.github/workflows/update-make-docs.yml b/.github/workflows/update-make-docs.yml index 6a358ecf..4d4ce6a5 100644 --- a/.github/workflows/update-make-docs.yml +++ b/.github/workflows/update-make-docs.yml @@ -12,16 +12,16 @@ jobs: - name: Update procedure run: | + BRANCH=update-make-docs + git checkout -b "${BRANCH}" curl -s -Lo docs/docs.mk https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/docs.mk curl -s -Lo docs/make-docs https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/make-docs if git diff --exit-code; then exit 0; fi - BRANCH="$(date +%Y-%m-%d)/update-make-docs" - git checkout -b "${BRANCH}" git add . git config --local user.email "bot@grafana.com" git config --local user.name "grafanabot" git commit -m "Update \`make docs\` procedure" git push -v origin "refs/heads/${BRANCH}" - gh pr create --fill --label "pr:no changelog" + gh pr create --fill --label "pr:no changelog" || true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d9311a63..2f28fda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## v1.3.50 (2023-11-02) + +### Fixed + +- Return alert groups for deleted integrations on private api ([3223](https://github.com/grafana/oncall/pull/3223)) ## v1.3.49 (2023-10-31) @@ -15,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevent additional polling on Incidents if the previous request didn't complete ([#3205](https://github.com/grafana/oncall/pull/3205)) - Order results from `GET /teams` internal API endpoint by ascending name by @joeyorlando ([#3220](https://github.com/grafana/oncall/pull/3220)) +- Order alert groups internal API endpoint by descending started_at by @mderynck ([#3240](https://github.com/grafana/oncall/pull/3240)) ### Fixed diff --git a/Tiltfile b/Tiltfile index da178445..446b3970 100644 --- a/Tiltfile +++ b/Tiltfile @@ -31,13 +31,11 @@ def plugin_json(): allow_k8s_contexts(["kind-kind"]) -local_resource("download-cache", cmd="docker pull grafana/oncall:latest; docker tag grafana/oncall localhost:63628/grafana/oncall:latest") - # Build the image including frontend folder for pytest docker_build_sub( "localhost:63628/oncall/engine:dev", context="./engine", - cache_from="localhost:63628/grafana/oncall:latest", + cache_from="grafana/oncall:latest", # only=["./engine", "./grafana-plugin"], ignore=["./grafana-plugin/test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/"], child_context=".", diff --git a/docs/make-docs b/docs/make-docs index a6cc6b1b..badc36a4 100755 --- a/docs/make-docs +++ b/docs/make-docs @@ -6,6 +6,12 @@ # [Semantic versioning](https://semver.org/) is used to help the reader identify the significance of changes. # Changes are relevant to this script and the support docs.mk GNU Make interface. # +# ## 5.1.1 (2023-10-30) +# +# ### Added +# +# - Support for Datadog and Oracle data source plugins repositories. +# # ## 5.1.0 (2023-10-20) # # ### Added @@ -249,8 +255,10 @@ SOURCES_grafana_cloud_frontend_observability_faro_web_sdk='faro-web-sdk' SOURCES_helm_charts_mimir_distributed='mimir' SOURCES_helm_charts_tempo_distributed='tempo' SOURCES_opentelemetry='opentelemetry-docs' +SOURCES_plugins_grafana_datadog_datasource='datadog-datasource' SOURCES_plugins_grafana_jira_datasource='jira-datasource' SOURCES_plugins_grafana_mongodb_datasource='mongodb-datasource' +SOURCES_plugins_grafana_oracle_datasource='oracle-datasource' SOURCES_plugins_grafana_splunk_datasource='splunk-datasource' VERSIONS_as_code='UNVERSIONED' @@ -261,8 +269,10 @@ VERSIONS_grafana_cloud_k6='UNVERSIONED' VERSIONS_grafana_cloud_data_configuration_integrations='UNVERSIONED' VERSIONS_grafana_cloud_frontend_observability_faro_web_sdk='UNVERSIONED' VERSIONS_opentelemetry='UNVERSIONED' +VERSIONS_plugins_grafana_datadog_datasource='latest' VERSIONS_plugins_grafana_jira_datasource='latest' VERSIONS_plugins_grafana_mongodb_datasource='latest' +VERSIONS_plugins_grafana_oracle_datasource='latest' VERSIONS_plugins_grafana_splunk_datasource='latest' VERSIONS_technical_documentation='UNVERSIONED' VERSIONS_website='UNVERSIONED' @@ -272,8 +282,10 @@ PATHS_grafana_cloud='content/docs/grafana-cloud' PATHS_helm_charts_mimir_distributed='docs/sources/helm-charts/mimir-distributed' PATHS_helm_charts_tempo_distributed='docs/sources/helm-charts/tempo-distributed' PATHS_mimir='docs/sources/mimir' +PATHS_plugins_grafana_datadog_datasource='docs/sources' PATHS_plugins_grafana_jira_datasource='docs/sources' PATHS_plugins_grafana_mongodb_datasource='docs/sources' +PATHS_plugins_grafana_oracle_datasource='docs/sources' PATHS_plugins_grafana_splunk_datasource='docs/sources' PATHS_tempo='docs/sources/tempo' PATHS_website='content' diff --git a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py index e1e8ed6f..e835eb69 100644 --- a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py +++ b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py @@ -247,10 +247,9 @@ class EscalationSnapshotMixin: is_on_maintenance_or_debug_mode = self.channel.maintenance_mode is not None - if self.is_restricted or is_on_maintenance_or_debug_mode or not self.escalation_chain_exists: + if is_on_maintenance_or_debug_mode or not self.escalation_chain_exists: logger.debug( f"Not escalating alert group w/ pk: {self.pk}\n" - f"is_restricted: {self.is_restricted}\n" f"is_on_maintenance_or_debug_mode: {is_on_maintenance_or_debug_mode}\n" f"escalation_chain_exists: {self.escalation_chain_exists}" ) diff --git a/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py b/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py index 44c58fac..e55cf543 100644 --- a/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py @@ -1,6 +1,5 @@ from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertClassicMarkdownTemplater -from common.constants.alert_group_restrictions import IS_RESTRICTED_MESSAGE, IS_RESTRICTED_TITLE from common.utils import str_or_backup @@ -11,13 +10,11 @@ class AlertClassicMarkdownRenderer(AlertBaseRenderer): def render(self): templated_alert = self.templated_alert - is_restricted = self.alert.group.is_restricted - return { - "title": IS_RESTRICTED_TITLE if is_restricted else str_or_backup(templated_alert.title, "Alert"), - "message": IS_RESTRICTED_MESSAGE if is_restricted else str_or_backup(templated_alert.message, ""), - "image_url": None if is_restricted else str_or_backup(templated_alert.image_url, None), - "source_link": None if is_restricted else str_or_backup(templated_alert.source_link, None), + "title": str_or_backup(templated_alert.title, "Alert"), + "message": str_or_backup(templated_alert.message, ""), + "image_url": str_or_backup(templated_alert.image_url, None), + "source_link": str_or_backup(templated_alert.source_link, None), } diff --git a/engine/apps/alerts/incident_appearance/renderers/web_renderer.py b/engine/apps/alerts/incident_appearance/renderers/web_renderer.py index 616a5f52..0a096e43 100644 --- a/engine/apps/alerts/incident_appearance/renderers/web_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/web_renderer.py @@ -1,6 +1,5 @@ from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertWebTemplater -from common.constants.alert_group_restrictions import IS_RESTRICTED_MESSAGE, IS_RESTRICTED_TITLE from common.utils import str_or_backup @@ -11,13 +10,11 @@ class AlertWebRenderer(AlertBaseRenderer): def render(self): templated_alert = self.templated_alert - is_restricted = self.alert.group.is_restricted - return { - "title": IS_RESTRICTED_TITLE if is_restricted else str_or_backup(templated_alert.title, "Alert"), - "message": IS_RESTRICTED_MESSAGE if is_restricted else str_or_backup(templated_alert.message, ""), - "image_url": None if is_restricted else str_or_backup(templated_alert.image_url, None), - "source_link": None if is_restricted else str_or_backup(templated_alert.source_link, None), + "title": str_or_backup(templated_alert.title, "Alert"), + "message": str_or_backup(templated_alert.message, ""), + "image_url": str_or_backup(templated_alert.image_url, None), + "source_link": str_or_backup(templated_alert.source_link, None), } diff --git a/engine/apps/alerts/migrations/0035_alter_alertreceivechannel_maintenance_author.py b/engine/apps/alerts/migrations/0035_alter_alertreceivechannel_maintenance_author.py new file mode 100644 index 00000000..3d0362d6 --- /dev/null +++ b/engine/apps/alerts/migrations/0035_alter_alertreceivechannel_maintenance_author.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.6 on 2023-10-31 19:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0017_alter_organization_maintenance_author'), + ('alerts', '0034_alter_resolutionnote_source'), + ] + + operations = [ + migrations.AlterField( + model_name='alertreceivechannel', + name='maintenance_author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_maintenances_created', to='user_management.user'), + ), + ] diff --git a/engine/apps/alerts/migrations/0036_alertgroup_grafana_incident_id.py b/engine/apps/alerts/migrations/0036_alertgroup_grafana_incident_id.py new file mode 100644 index 00000000..7bf218cd --- /dev/null +++ b/engine/apps/alerts/migrations/0036_alertgroup_grafana_incident_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-10-31 20:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0035_alter_alertreceivechannel_maintenance_author'), + ] + + operations = [ + migrations.AddField( + model_name='alertgroup', + name='grafana_incident_id', + field=models.CharField(default=None, max_length=100, null=True), + ), + ] diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 17051911..0087f9f4 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -389,8 +389,11 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. # https://code.djangoproject.com/ticket/28545 is_open_for_grouping = models.BooleanField(default=None, null=True, blank=True) + # TODO: drop this column in an upcoming release is_restricted = models.BooleanField(default=False, null=True) + grafana_incident_id = models.CharField(max_length=100, null=True, default=None) + @staticmethod def get_silenced_state_filter(): """ diff --git a/engine/apps/alerts/paging.py b/engine/apps/alerts/paging.py index 694e88ed..42a996f7 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -44,7 +44,13 @@ class DirectPagingAlertPayload(typing.TypedDict): def _trigger_alert( - organization: Organization, team: Team | None, message: str, title: str, from_user: User + organization: Organization, + team: Team | None, + message: str, + title: str, + permalink: str | None, + grafana_incident_id: str | None, + from_user: User, ) -> AlertGroup: """Trigger manual integration alert from params.""" alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( @@ -73,7 +79,7 @@ def _trigger_alert( "message": message, "uid": str(uuid4()), # avoid grouping "author_username": from_user.username, - "permalink": None, + "permalink": permalink, }, } @@ -87,7 +93,13 @@ def _trigger_alert( link_to_upstream_details=None, channel_filter=channel_filter, ) - return alert.group + alert_group = alert.group + + if grafana_incident_id is not None: + alert_group.grafana_incident_id = grafana_incident_id + alert_group.save(update_fields=["grafana_incident_id"]) + + return alert_group def _construct_title(from_user: User, team: Team | None, users: UserNotifications) -> str: @@ -111,6 +123,8 @@ def direct_paging( from_user: User, message: str, title: str | None = None, + source_url: str | None = None, + grafana_incident_id: str | None = None, team: Team | None = None, users: UserNotifications | None = None, alert_group: AlertGroup | None = None, @@ -135,7 +149,7 @@ def direct_paging( # create alert group if needed if alert_group is None: - alert_group = _trigger_alert(organization, team, message, title, from_user) + alert_group = _trigger_alert(organization, team, message, title, source_url, grafana_incident_id, from_user) for u, important in users: alert_group.log_records.create( diff --git a/engine/apps/api/serializers/alert.py b/engine/apps/api/serializers/alert.py index 626408dd..7774b946 100644 --- a/engine/apps/api/serializers/alert.py +++ b/engine/apps/api/serializers/alert.py @@ -71,5 +71,4 @@ class AlertRawSerializer(serializers.ModelSerializer): ] def get_raw_request_data(self, obj): - # TODO: - return {} if obj.group.is_restricted else obj.raw_request_data + return obj.raw_request_data diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 3a46583f..1e8371ea 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -149,7 +149,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize "status", "declare_incident_link", "team", - "is_restricted", + "grafana_incident_id", ] @extend_schema_field( diff --git a/engine/apps/api/serializers/paging.py b/engine/apps/api/serializers/paging.py index 9c16087c..ab3584bb 100644 --- a/engine/apps/api/serializers/paging.py +++ b/engine/apps/api/serializers/paging.py @@ -43,15 +43,21 @@ class DirectPagingSerializer(serializers.Serializer): title = serializers.CharField(required=False, default=None) message = serializers.CharField(required=False, default=None, allow_null=True) + source_url = serializers.URLField(required=False, default=None, allow_null=True) + grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True) def validate(self, attrs): organization = self.context["organization"] alert_group_id = attrs["alert_group_id"] title = attrs["title"] message = attrs["message"] + source_url = attrs["source_url"] + grafana_incident_id = attrs["grafana_incident_id"] - if alert_group_id and (title or message): - raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive") + if alert_group_id and (title or message or source_url or grafana_incident_id): + raise serializers.ValidationError( + "alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive" + ) if alert_group_id: try: diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index b293256c..a22ebdb3 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -76,6 +76,27 @@ def test_get_filter_by_integration( assert len(response.data["results"]) == 4 +@pytest.mark.django_db +def test_get_alert_groups_from_deleted_integration(alert_group_internal_api_setup, make_user_auth_headers): + user, token, alert_groups = alert_group_internal_api_setup + + alert_receive_channel = alert_groups[0].channel + alert_receive_channel.delete() + + client = APIClient() + + url = reverse("api-internal:alertgroup-list") + response = client.get( + url, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + # Alert groups from deleted integrations should be returned + assert len(response.data["results"]) == 4 + + @pytest.mark.django_db def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_headers): user, token, _ = alert_group_internal_api_setup diff --git a/engine/apps/api/tests/test_labels.py b/engine/apps/api/tests/test_labels.py index 2f7e0961..a4c314bc 100644 --- a/engine/apps/api/tests/test_labels.py +++ b/engine/apps/api/tests/test_labels.py @@ -192,7 +192,7 @@ def test_labels_feature_false( make_alert_receive_channel, settings, ): - setattr(settings, "FEATURE_LABELS_ENABLED", False) + setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", False) organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() diff --git a/engine/apps/api/tests/test_paging.py b/engine/apps/api/tests/test_paging.py index a45da9b8..24e5f32c 100644 --- a/engine/apps/api/tests/test_paging.py +++ b/engine/apps/api/tests/test_paging.py @@ -9,6 +9,8 @@ from apps.api.permissions import LegacyAccessControlRole title = "Custom title" message = "Testing direct paging with new alert group" +source_url = "https://www.example.com" +grafana_incident_id = "abcd1234" @pytest.mark.django_db @@ -77,6 +79,43 @@ def test_direct_paging_page_team( data={ "team": team.public_primary_key, "message": message, + "source_url": source_url, + "grafana_incident_id": grafana_incident_id, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + + alert_group = AlertGroup.objects.get(public_primary_key=response.json()["alert_group_id"]) + alert = alert_group.alerts.first() + + assert alert_group.grafana_incident_id == grafana_incident_id + assert alert.raw_request_data["oncall"]["permalink"] == source_url + + +@pytest.mark.django_db +def test_direct_paging_page_from_grafana_incident( + make_organization_and_user_with_plugin_token, + make_team, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + client = APIClient() + url = reverse("api-internal:direct_paging") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "message": message, + "grafana_incident_id": "asdf", }, format="json", **make_user_auth_headers(user, token), @@ -185,15 +224,26 @@ def test_direct_paging_no_user_or_team_specified( assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL +@pytest.mark.parametrize( + "field_name,field_value", + [ + ("title", title), + ("message", message), + ("source_url", source_url), + ("grafana_incident_id", grafana_incident_id), + ], +) @pytest.mark.django_db -def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusive( +def test_direct_paging_alert_group_id_and_other_fields_are_mutually_exclusive( make_organization_and_user_with_plugin_token, make_team, make_user_auth_headers, make_alert_receive_channel, make_alert_group, + field_name, + field_value, ): - error_msg = "alert_group_id and (title, message) are mutually exclusive" + error_msg = "alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive" organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) team = make_team(organization=organization) @@ -207,17 +257,15 @@ def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusiv client = APIClient() url = reverse("api-internal:direct_paging") - base_data = {"team": team.public_primary_key, "alert_group_id": alert_group.public_primary_key} - response = client.post( - url, data={**base_data, "message": message}, format="json", **make_user_auth_headers(user, token) - ) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["non_field_errors"] == [error_msg] - - response = client.post( - url, data={**base_data, "title": title}, format="json", **make_user_auth_headers(user, token) + url, + data={ + "team": team.public_primary_key, + "alert_group_id": alert_group.public_primary_key, + field_name: field_value, + }, + format="json", + **make_user_auth_headers(user, token), ) assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 97ef641e..f022010e 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -324,7 +324,7 @@ class AlertGroupView( def get_queryset(self, ignore_filtering_by_available_teams=False): # no select_related or prefetch_related is used at this point, it will be done on paginate_queryset. - alert_receive_channels_qs = AlertReceiveChannel.objects.filter( + alert_receive_channels_qs = AlertReceiveChannel.objects_with_deleted.filter( organization_id=self.request.auth.organization.id ) if not ignore_filtering_by_available_teams: @@ -423,7 +423,7 @@ class AlertGroupView( # enrich alert groups with select_related and prefetch_related alert_group_pks = [alert_group.pk for alert_group in alert_groups] - queryset = AlertGroup.objects.filter(pk__in=alert_group_pks).order_by("-pk") + queryset = AlertGroup.objects.filter(pk__in=alert_group_pks).order_by("-started_at") queryset = self.get_serializer_class().setup_eager_loading(queryset) alert_groups = list(queryset) diff --git a/engine/apps/api/views/paging.py b/engine/apps/api/views/paging.py index ff48a648..fda9791a 100644 --- a/engine/apps/api/views/paging.py +++ b/engine/apps/api/views/paging.py @@ -33,6 +33,8 @@ class DirectPagingAPIView(APIView): from_user=request.user, message=validated_data["message"], title=validated_data["title"], + source_url=validated_data["source_url"], + grafana_incident_id=validated_data["grafana_incident_id"], team=validated_data["team"], users=[(user["instance"], user["important"]) for user in validated_data["users"]], alert_group=validated_data["alert_group"], diff --git a/engine/apps/labels/tests/test_labels.py b/engine/apps/labels/tests/test_labels.py index 78b3790a..9d32583a 100644 --- a/engine/apps/labels/tests/test_labels.py +++ b/engine/apps/labels/tests/test_labels.py @@ -2,7 +2,27 @@ import pytest from apps.alerts.models import AlertReceiveChannel from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache -from apps.labels.utils import get_associating_label_model +from apps.labels.utils import get_associating_label_model, is_labels_feature_enabled + + +@pytest.mark.django_db +def test_labels_feature_flag(mock_is_labels_feature_enabled_for_org, make_organization, settings): + organization = make_organization() + # returns True if feature flag is enabled + assert settings.FEATURE_LABELS_ENABLED_FOR_ALL + assert organization.id not in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS + assert is_labels_feature_enabled(organization) + + mock_is_labels_feature_enabled_for_org(organization.org_id) + # returns True if feature flag is disabled and organization is in the feature list + assert not settings.FEATURE_LABELS_ENABLED_FOR_ALL + assert organization.org_id in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS + assert is_labels_feature_enabled(organization) + + mock_is_labels_feature_enabled_for_org(12345) + # returns False if feature flag is disabled and organization is not in the feature list + assert organization.org_id not in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS + assert not is_labels_feature_enabled(organization) @pytest.mark.django_db diff --git a/engine/apps/labels/utils.py b/engine/apps/labels/utils.py index b4acbf23..56b0a1ad 100644 --- a/engine/apps/labels/utils.py +++ b/engine/apps/labels/utils.py @@ -43,12 +43,18 @@ def get_associating_label_model(obj_model_name: str) -> typing.Type["AssociatedL def is_labels_feature_enabled(organization) -> bool: - # check FEATURE_LABELS_ENABLED in settings - # checking labels feature flag per organization will be added later - + """ + Checks if labels feature enabled for all organizations (FEATURE_LABELS_ENABLED_FOR_ALL). + If not, checks if current organization's grafana org_id is in the list of organizations labels feature enabled for + (FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS) + """ logger.info( "is_labels_feature_enabled: " - f"FEATURE_LABELS_ENABLED={settings.FEATURE_LABELS_ENABLED} " + f"FEATURE_LABELS_ENABLED_FOR_ALL={settings.FEATURE_LABELS_ENABLED_FOR_ALL}, " + f"organization in FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS=" + f"{organization.id in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS}, " f"organization={organization.id}" ) - return settings.FEATURE_LABELS_ENABLED + if not settings.FEATURE_LABELS_ENABLED_FOR_ALL: + return organization.org_id in settings.FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS + return settings.FEATURE_LABELS_ENABLED_FOR_ALL diff --git a/engine/apps/public_api/serializers/alerts.py b/engine/apps/public_api/serializers/alerts.py index 755d38fd..a033c2a2 100644 --- a/engine/apps/public_api/serializers/alerts.py +++ b/engine/apps/public_api/serializers/alerts.py @@ -21,4 +21,4 @@ class AlertSerializer(EagerLoadingMixin, serializers.ModelSerializer): ] def get_payload(self, obj): - return {} if obj.group.is_restricted else obj.raw_request_data + return obj.raw_request_data diff --git a/engine/apps/public_api/serializers/incidents.py b/engine/apps/public_api/serializers/incidents.py index 0c1b8cbe..1a6b2df5 100644 --- a/engine/apps/public_api/serializers/incidents.py +++ b/engine/apps/public_api/serializers/incidents.py @@ -4,7 +4,6 @@ from rest_framework import serializers from apps.alerts.models import AlertGroup from apps.telegram.models.message import TelegramMessage from common.api_helpers.mixins import EagerLoadingMixin -from common.constants.alert_group_restrictions import IS_RESTRICTED_TITLE class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): @@ -42,7 +41,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): ] def get_title(self, obj): - return IS_RESTRICTED_TITLE if obj.is_restricted else obj.web_title_cache + return obj.web_title_cache def get_alerts_count(self, obj): return len(obj.alerts.all()) diff --git a/engine/apps/schedules/migrations/0017_alter_oncallschedule_polymorphic_ctype.py b/engine/apps/schedules/migrations/0017_alter_oncallschedule_polymorphic_ctype.py new file mode 100644 index 00000000..c4b082bf --- /dev/null +++ b/engine/apps/schedules/migrations/0017_alter_oncallschedule_polymorphic_ctype.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.6 on 2023-10-31 19:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('schedules', '0016_alter_shiftswaprequest_created_at'), + ] + + operations = [ + migrations.AlterField( + model_name='oncallschedule', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype'), + ), + ] diff --git a/engine/apps/twilioapp/migrations/0008_alter_twiliophonecallsender_account_and_more.py b/engine/apps/twilioapp/migrations/0008_alter_twiliophonecallsender_account_and_more.py new file mode 100644 index 00000000..8ff27a20 --- /dev/null +++ b/engine/apps/twilioapp/migrations/0008_alter_twiliophonecallsender_account_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.6 on 2023-10-31 19:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('phone_notifications', '0001_initial'), + ('twilioapp', '0007_delete_twiliologrecord'), + ] + + operations = [ + migrations.AlterField( + model_name='twiliophonecallsender', + name='account', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_account', to='twilioapp.twilioaccount'), + ), + migrations.AlterField( + model_name='twiliosms', + name='sms_record', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss', to='phone_notifications.smsrecord'), + ), + migrations.AlterField( + model_name='twiliosmssender', + name='account', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_account', to='twilioapp.twilioaccount'), + ), + migrations.AlterField( + model_name='twilioverificationsender', + name='account', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_account', to='twilioapp.twilioaccount'), + ), + ] diff --git a/engine/apps/user_management/migrations/0017_alter_organization_maintenance_author.py b/engine/apps/user_management/migrations/0017_alter_organization_maintenance_author.py new file mode 100644 index 00000000..96aedccc --- /dev/null +++ b/engine/apps/user_management/migrations/0017_alter_organization_maintenance_author.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.6 on 2023-10-31 19:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0016_alter_user_role'), + ] + + operations = [ + migrations.AlterField( + model_name='organization', + name='maintenance_author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_maintenances_created', to='user_management.user'), + ), + ] diff --git a/engine/apps/zvonok/migrations/0002_alter_zvonokphonecall_phone_call_record.py b/engine/apps/zvonok/migrations/0002_alter_zvonokphonecall_phone_call_record.py new file mode 100644 index 00000000..461c75fe --- /dev/null +++ b/engine/apps/zvonok/migrations/0002_alter_zvonokphonecall_phone_call_record.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.6 on 2023-10-31 19:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('phone_notifications', '0001_initial'), + ('zvonok', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='zvonokphonecall', + name='phone_call_record', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss', to='phone_notifications.phonecallrecord'), + ), + ] diff --git a/engine/common/constants/alert_group_restrictions.py b/engine/common/constants/alert_group_restrictions.py deleted file mode 100644 index b30f2da6..00000000 --- a/engine/common/constants/alert_group_restrictions.py +++ /dev/null @@ -1,2 +0,0 @@ -IS_RESTRICTED_TITLE = "UPGRADE TO SEE MORE" -IS_RESTRICTED_MESSAGE = "UPGRADE TO SEE MORE" diff --git a/engine/common/utils.py b/engine/common/utils.py index e70f7ab2..c8287f6d 100644 --- a/engine/common/utils.py +++ b/engine/common/utils.py @@ -1,5 +1,6 @@ import functools import html +import json import os import random import re @@ -112,6 +113,14 @@ def getenv_integer(variable_name: str, default: int) -> int: return default +def getenv_list(variable_name: str, default: list) -> list: + value = os.environ.get(variable_name) + if value is None: + return default + + return json.loads(value) + + def batch_queryset(qs, batch_size=1000): qs_count = qs.count() for start in range(0, qs_count, batch_size): diff --git a/engine/conftest.py b/engine/conftest.py index 59ab1f33..e7acc97e 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -180,7 +180,16 @@ def mock_apply_async(monkeypatch): @pytest.fixture(autouse=True) def mock_is_labels_feature_enabled(settings): - setattr(settings, "FEATURE_LABELS_ENABLED", True) + setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", True) + + +@pytest.fixture +def mock_is_labels_feature_enabled_for_org(settings): + def _mock_is_labels_feature_enabled_for_org(org_id): + setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", False) + setattr(settings, "FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS", [org_id]) + + return _mock_is_labels_feature_enabled_for_org @pytest.fixture diff --git a/engine/settings/base.py b/engine/settings/base.py index 260c4f96..100ff125 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -7,7 +7,7 @@ from random import randrange from celery.schedules import crontab from firebase_admin import credentials, initialize_app -from common.utils import getenv_boolean, getenv_integer +from common.utils import getenv_boolean, getenv_integer, getenv_list VERSION = "dev-oss" SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) @@ -66,9 +66,12 @@ FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", defa FEATURE_INBOUND_EMAIL_ENABLED = getenv_boolean("FEATURE_INBOUND_EMAIL_ENABLED", default=True) FEATURE_PROMETHEUS_EXPORTER_ENABLED = getenv_boolean("FEATURE_PROMETHEUS_EXPORTER_ENABLED", default=False) FEATURE_GRAFANA_ALERTING_V2_ENABLED = getenv_boolean("FEATURE_GRAFANA_ALERTING_V2_ENABLED", default=False) -FEATURE_LABELS_ENABLED = getenv_boolean("FEATURE_LABELS_ENABLED", default=False) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) +# Enable labels feature fo all organizations. This flag overrides FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS +FEATURE_LABELS_ENABLED_FOR_ALL = getenv_boolean("FEATURE_LABELS_ENABLED_FOR_ALL", default=False) +# Enable labels feature for organizations from the list. Use Grafana org_id, not OnCall id, for this flag +FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS = getenv_list("FEATURE_LABELS_ENABLED_FOR_GRAFANA_ORGS", default=list()) TWILIO_API_KEY_SID = os.environ.get("TWILIO_API_KEY_SID") TWILIO_API_KEY_SECRET = os.environ.get("TWILIO_API_KEY_SECRET") diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 3fa5aaa1..e3b7d1c1 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -113,7 +113,7 @@ "@grafana/data": "^9.2.4", "@grafana/faro-web-sdk": "^1.0.0-beta4", "@grafana/faro-web-tracing": "^1.0.0-beta4", - "@grafana/labels": "^1.1.0", + "@grafana/labels": "~1.2.1", "@grafana/runtime": "9.3.0-beta1", "@grafana/ui": "^9.4.7", "@opentelemetry/api": "^1.3.0", diff --git a/grafana-plugin/src/containers/Labels/Labels.tsx b/grafana-plugin/src/containers/Labels/Labels.tsx index 732c123b..f744e3f9 100644 --- a/grafana-plugin/src/containers/Labels/Labels.tsx +++ b/grafana-plugin/src/containers/Labels/Labels.tsx @@ -1,9 +1,9 @@ import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; -import '@grafana/labels/dist/theme.css'; import ServiceLabels from '@grafana/labels'; import { Field } from '@grafana/ui'; import cn from 'classnames/bind'; +import { isEmpty } from 'lodash-es'; import { observer } from 'mobx-react'; import { LabelKeyValue } from 'models/label/label.types'; @@ -57,6 +57,31 @@ const Labels = observer( }; }, []); + const isValid = () => { + return ( + (propsErrors || []) + .map((error: LabelKeyValue, index) => { + // error object is empty => Valid + if (isEmpty(error)) { + return undefined; + } + const matchingValue = value[index]?.value; + // We have a name for the value => Valid + if (error.value && matchingValue?.name) { + return undefined; + } + const matchingKey = value[index]?.key; + // We have a name for the key => Valid + if (error.key && matchingKey?.name) { + return undefined; + } + // Invalid + return error; + }) + .filter((er: LabelKeyValue) => er).length === 0 + ); + }; + const cachedOnLoadValuesForKey = useCallback(() => { let result = undefined; return async (key: string, search?: string) => { @@ -87,7 +112,7 @@ const Labels = observer( onUpdateValue={labelsStore.updateKeyValue.bind(labelsStore)} onRowItemRemoval={(_pair, _index) => {}} onUpdateError={onUpdateError} - errors={{ ...propsErrors }} + errors={isValid() ? {} : { ...propsErrors }} onDataUpdate={setValue} /> diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index 5514042e..32ccdedc 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -85,6 +85,7 @@ export interface Alert { alert_receive_channel: Partial; paged_users: PagedUser[]; team: GrafanaTeam['id']; + grafana_incident_id: string | null; // set by client loading?: boolean; diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 42d2fc6d..cf50e337 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -177,13 +177,15 @@ class IncidentPage extends React.Component
- + {(!incident.resolved || incident.paged_users.length > 0) && ( + + )} {this.renderTimeline()}
@@ -400,13 +402,15 @@ class IncidentPage extends React.Component onSilence: this.getSilenceClickHandler(incident), onUnsilence: this.getUnsilenceClickHandler(incident), })} - - - - - + {incident.grafana_incident_id === null && ( + + + + + + )}