diff --git a/.drone.yml b/.drone.yml index 6691a82a..91b4bd0c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -144,7 +144,7 @@ steps: # Services for Unit Test Backend services: - name: rabbit_test - image: rabbitmq:3.7.19 + image: rabbitmq:3.12.0 environment: RABBITMQ_DEFAULT_USER: rabbitmq RABBITMQ_DEFAULT_PASS: rabbitmq @@ -224,10 +224,8 @@ trigger: --- kind: pipeline type: docker -name: OSS engine release (amd64) -platform: - os: linux - arch: amd64 +name: OSS engine release + steps: - name: set engine version image: alpine @@ -244,7 +242,8 @@ steps: DOCKER_BUILDKIT: 1 settings: repo: grafana/oncall - tags: ${DRONE_TAG}-amd64-linux + tags: ${DRONE_TAG} + platform: linux/arm64/v8,linux/amd64 dockerfile: engine/Dockerfile target: prod context: engine/ @@ -263,90 +262,6 @@ trigger: ref: - refs/tags/v*.*.* ---- -kind: pipeline -type: docker -name: OSS engine release (arm64) -platform: - os: linux - arch: arm64 -steps: - - name: set engine version - image: alpine - commands: - - apk add --no-cache bash sed - - if [ -z "$DRONE_TAG" ]; then echo "No tag, not modifying version"; else sed "0,/VERSION.*/ s/VERSION.*/VERSION = \"${DRONE_TAG}\"/g" engine/settings/base.py > engine/settings/base.temp && mv engine/settings/base.temp engine/settings/base.py; fi - - cat engine/settings/base.py | grep VERSION | head -1 - - - name: build and push docker image - image: plugins/docker - 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: ${DRONE_TAG}-arm64-linux - dockerfile: engine/Dockerfile - target: prod - context: engine/ - password: - from_secret: docker_password - username: - from_secret: docker_username - depends_on: - - set engine version - -trigger: - event: - - promote - target: - - oss - ref: - - refs/tags/v*.*.* - ---- -depends_on: - - OSS engine release (amd64) - - OSS engine release (arm64) -kind: pipeline -type: docker -name: manifest -steps: - - name: manifest tag - image: plugins/manifest - settings: - username: - from_secret: docker_username - password: - from_secret: docker_password - target: "grafana/oncall:${DRONE_TAG}" - template: "grafana/oncall:${DRONE_TAG}-ARCH-OS" - platforms: - - linux/amd64 - - linux/arm64 - - - name: manifest latest - image: plugins/manifest - settings: - username: - from_secret: docker_username - password: - from_secret: docker_password - target: "grafana/oncall:latest" - template: "grafana/oncall:${DRONE_TAG}-ARCH-OS" - platforms: - - linux/amd64 - - linux/arm64 - -trigger: - event: - - promote - target: - - oss - ref: - - refs/tags/v*.*.* - --- # Secret for pulling docker images. kind: secret @@ -418,6 +333,6 @@ kind: secret name: drone_token --- kind: signature -hmac: 59ad498428731001484be9ac6b9d10685b2cd49f30eb3d97ed7dc99f031dca22 +hmac: 610fb051f4361baa2621f6aa41c18b3afee5160492b30f5a5f23fcecf86f1b0c ... diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 0cf29768..ded42414 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -96,7 +96,7 @@ jobs: SLACK_CLIENT_OAUTH_ID: 1 services: rabbit_test: - image: rabbitmq:3.7.19 + image: rabbitmq:3.12.0 env: RABBITMQ_DEFAULT_USER: rabbitmq RABBITMQ_DEFAULT_PASS: rabbitmq @@ -148,7 +148,7 @@ jobs: ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }} services: rabbit_test: - image: rabbitmq:3.7.19 + image: rabbitmq:3.12.0 env: RABBITMQ_DEFAULT_USER: rabbitmq RABBITMQ_DEFAULT_PASS: rabbitmq @@ -192,7 +192,7 @@ jobs: ONCALL_TESTING_RBAC_ENABLED: ${{ matrix.rbac_enabled }} services: rabbit_test: - image: rabbitmq:3.7.19 + image: rabbitmq:3.12.0 env: RABBITMQ_DEFAULT_USER: rabbitmq RABBITMQ_DEFAULT_PASS: rabbitmq diff --git a/CHANGELOG.md b/CHANGELOG.md index a982eb61..c7ae91f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## v1.3.3 +## v1.3.4 (2023-06-29) + +This version contains just some small cleanup and CI changes 🙂 + +## v1.3.3 (2023-06-28) + ### Added - Add metric "how many alert groups user was notified of" to Prometheus exporter ([#2334](https://github.com/grafana/oncall/pull/2334/)) -## v1.3.2 +## v1.3.2 (2023-06-28) ### Changed diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index c44633e4..a7bc8910 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -179,7 +179,7 @@ services: rabbitmq: container_name: rabbitmq labels: *oncall-labels - image: "rabbitmq:3.7.15-management" + image: "rabbitmq:3.12.0-management" restart: always environment: RABBITMQ_DEFAULT_USER: "rabbitmq" diff --git a/docker-compose-mysql-rabbitmq.yml b/docker-compose-mysql-rabbitmq.yml index f116415e..0fee8cb6 100644 --- a/docker-compose-mysql-rabbitmq.yml +++ b/docker-compose-mysql-rabbitmq.yml @@ -99,7 +99,7 @@ services: cpus: "0.1" rabbitmq: - image: "rabbitmq:3.7.15-management" + image: "rabbitmq:3.12.0-management" restart: always hostname: rabbitmq volumes: diff --git a/engine/apps/alerts/migrations/0018_remove_alertreceivechannel_integration_slack_channel_id.py b/engine/apps/alerts/migrations/0018_remove_alertreceivechannel_integration_slack_channel_id.py new file mode 100644 index 00000000..97be1856 --- /dev/null +++ b/engine/apps/alerts/migrations/0018_remove_alertreceivechannel_integration_slack_channel_id.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.19 on 2023-06-28 16:00 + +from django.db import migrations +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0017_alertgroup_is_restricted'), + ] + + operations = [ + linter.IgnoreMigration(), # This field is deprecated a long time ago. + migrations.RemoveField( + model_name='alertreceivechannel', + name='integration_slack_channel_id', + ), + ] diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index 5d17e86f..42d6ce0b 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -160,8 +160,6 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): verbal_name = models.CharField(max_length=150, null=True, default=None) description_short = models.CharField(max_length=250, null=True, default=None) - integration_slack_channel_id = models.CharField(max_length=150, null=True, default=None) - is_finished_alerting_setup = models.BooleanField(default=False) # *_*_template fields are legacy way of storing templates diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index d673567c..87a23d84 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -14,7 +14,7 @@ def test_render_for_phone_call( make_alert, ): organization, _ = make_organization_with_slack_team_identity() - alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") + alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) SlackMessage.objects.create(channel_id="CWER1ASD", alert_group=alert_group) @@ -60,7 +60,7 @@ def test_delete( slack_channel = make_slack_channel(slack_team_identity, name="general", slack_id="CWER1ASD") user = make_user(organization=organization) - alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") + alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) SlackMessage.objects.create(channel_id="CWER1ASD", alert_group=alert_group) @@ -104,7 +104,7 @@ def test_alerts_count_gt( make_alert, ): organization = make_organization() - alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD") + alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) diff --git a/engine/apps/base/models/ordered_model.py b/engine/apps/base/models/ordered_model.py index b286520d..0fd980e2 100644 --- a/engine/apps/base/models/ordered_model.py +++ b/engine/apps/base/models/ordered_model.py @@ -69,9 +69,9 @@ class OrderedModel(models.Model): def save(self, *args, **kwargs) -> None: if self.order is None: - self._save_no_order_provided() + self._save_no_order_provided(*args, **kwargs) else: - super().save() + super().save(*args, **kwargs) @_retry(OperationalError) # retry on deadlock def delete(self, *args, **kwargs) -> None: @@ -81,7 +81,7 @@ class OrderedModel(models.Model): super().delete(*args, **kwargs) @_retry((IntegrityError, OperationalError)) # retry on duplicate order or deadlock - def _save_no_order_provided(self) -> None: + def _save_no_order_provided(self, *args, **kwargs) -> None: """ Save self to DB without an order provided (e.g on creation). Order is set to the next available order, or 0 if there are no other instances. @@ -97,7 +97,7 @@ class OrderedModel(models.Model): instances = self._lock_ordering_queryset() # lock ordering queryset to prevent reading inconsistent data max_order = max(instance.order for instance in instances) if instances else -1 self.order = max_order + 1 - super().save() + super().save(*args, **kwargs) @_retry(OperationalError) # retry on deadlock def to(self, order: int) -> None: diff --git a/engine/apps/slack/scenarios/scenario_step.py b/engine/apps/slack/scenarios/scenario_step.py index e6cd768f..38de8d0c 100644 --- a/engine/apps/slack/scenarios/scenario_step.py +++ b/engine/apps/slack/scenarios/scenario_step.py @@ -32,7 +32,6 @@ EVENT_SUBTYPE_MESSAGE_CHANGED = "message_changed" EVENT_SUBTYPE_MESSAGE_DELETED = "message_deleted" EVENT_SUBTYPE_BOT_MESSAGE = "bot_message" EVENT_SUBTYPE_THREAD_BROADCAST = "thread_broadcast" -EVENT_SUBTYPE_FILE_SHARE = "file_share" EVENT_TYPE_CHANNEL_DELETED = "channel_deleted" EVENT_TYPE_CHANNEL_CREATED = "channel_created" EVENT_TYPE_CHANNEL_RENAMED = "channel_rename" diff --git a/engine/apps/slack/scenarios/slack_channel_integration.py b/engine/apps/slack/scenarios/slack_channel_integration.py index 348b6209..8d5a0147 100644 --- a/engine/apps/slack/scenarios/slack_channel_integration.py +++ b/engine/apps/slack/scenarios/slack_channel_integration.py @@ -1,9 +1,7 @@ -import json import logging from django.apps import apps -from apps.integrations.tasks import create_alert from apps.slack.scenarios import scenario_step logger = logging.getLogger(__name__) @@ -30,9 +28,6 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): and "thread_ts" in payload["event"]["previous_message"] ): self.delete_thread_message_from_resolution_note(slack_user_identity, payload) - # Otherwise check if it is a message from channel with Slack Channel Integration - else: - self.create_alert_for_slack_channel_integration_if_needed(payload) def save_thread_message_for_resolution_note(self, slack_user_identity, payload): ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage") @@ -144,37 +139,6 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep): slack_thread_message.delete() self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - def create_alert_for_slack_channel_integration_if_needed(self, payload): - if "subtype" in payload["event"] and payload["event"]["subtype"] != scenario_step.EVENT_SUBTYPE_FILE_SHARE: - return - AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - alert_receive_channels = AlertReceiveChannel.objects.filter( - integration_slack_channel_id=payload["event"]["channel"], organization=self.organization - ).all() - for alert_receive_channel in alert_receive_channels: - r = self._slack_client.api_call( - "chat.getPermalink", - channel=payload["event"]["channel"], - message_ts=payload["event"]["ts"], - ) - # insert permalink to payload to have access to it in templaters - payload["event"]["amixr_mixin"] = {"permalink": r["permalink"]} - - create_alert.apply_async( - [], - { - "title": "<#{}>".format(payload["event"]["channel"]), - "message": "{}\n_New message in <#{}> channel_".format( - payload["event"]["text"], payload["event"]["channel"] - ), - "image_url": None, - "link_to_upstream_details": r["permalink"], - "alert_receive_channel_pk": alert_receive_channel.pk, - "integration_unique_data": json.dumps(payload["event"]), - "raw_request_data": payload["event"], - }, - ) - STEPS_ROUTING = [ { diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 5b0616e7..9e6bc929 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -28,7 +28,6 @@ from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING from apps.slack.scenarios.scenario_step import ( EVENT_SUBTYPE_BOT_MESSAGE, - EVENT_SUBTYPE_FILE_SHARE, EVENT_SUBTYPE_MESSAGE_CHANGED, EVENT_SUBTYPE_MESSAGE_DELETED, EVENT_TYPE_APP_MENTION, @@ -334,7 +333,6 @@ class SlackEventApiEndpointView(APIView): in [ EVENT_SUBTYPE_BOT_MESSAGE, EVENT_SUBTYPE_MESSAGE_CHANGED, - EVENT_SUBTYPE_FILE_SHARE, EVENT_SUBTYPE_MESSAGE_DELETED, ] ) diff --git a/grafana-plugin/.gitignore b/grafana-plugin/.gitignore index 16f10cb1..43e7ea8a 100644 --- a/grafana-plugin/.gitignore +++ b/grafana-plugin/.gitignore @@ -19,4 +19,4 @@ frontend_enterprise /test-results/ /playwright-report/ /playwright/.cache/ -storageState.json +/integration-tests/storageState.json diff --git a/grafana-plugin/integration-tests/globalSetup.ts b/grafana-plugin/integration-tests/globalSetup.ts index 79fb4b0c..5881971a 100644 --- a/grafana-plugin/integration-tests/globalSetup.ts +++ b/grafana-plugin/integration-tests/globalSetup.ts @@ -1,20 +1,39 @@ -import { chromium, FullConfig, expect, Page } from '@playwright/test'; +import { test as setup, chromium, FullConfig, expect, Page, BrowserContext, APIResponse } from '@playwright/test'; import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_API_URL } from './utils/constants'; import { clickButton, getInputByName } from './utils/forms'; import { goToGrafanaPage } from './utils/navigation'; +import { STORAGE_STATE } from '../playwright.config'; +const IS_CLOUD = !IS_OPEN_SOURCE; const GLOBAL_SETUP_RETRIES = 3; +const makeGrafanaLoginRequest = async (browserContext: BrowserContext): Promise => + browserContext.request.post(`${BASE_URL}/login`, { + data: { + user: GRAFANA_USERNAME, + password: GRAFANA_PASSWORD, + }, + }); + +const pollGrafanaInstanceUntilItIsHealthy = async (browserContext: BrowserContext): Promise => { + console.log('Polling the grafana instance to make sure it is healthy'); + + const res = await makeGrafanaLoginRequest(browserContext); + + if (!res.ok()) { + console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`); + await new Promise((resolve) => setTimeout(resolve, 5000)); + return pollGrafanaInstanceUntilItIsHealthy(browserContext); + } + console.log('Grafana instance is available'); + return true; +}; + /** * go to config page and wait for plugin icon to be available on left-hand navigation */ const configureOnCallPlugin = async (page: Page): Promise => { - // plugin configuration can safely be skipped for non open-source environments - if (!IS_OPEN_SOURCE) { - return; - } - /** * go to the oncall plugin configuration page and wait for the page to be loaded */ @@ -52,19 +71,26 @@ const globalSetup = async (config: FullConfig): Promise => { const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 }); const browserContext = await browser.newContext(); - const res = await browserContext.request.post(`${BASE_URL}/login`, { - data: { - user: GRAFANA_USERNAME, - password: GRAFANA_PASSWORD, - }, - }); + if (IS_CLOUD) { + /** + * check that the grafana instance is available. If HTTP 503 is returned it means the + * instance is currently unavailable. Poll until it is available + */ + await pollGrafanaInstanceUntilItIsHealthy(browserContext); + } + + const res = await makeGrafanaLoginRequest(browserContext); expect(res.ok()).toBeTruthy(); - await browserContext.storageState({ path: './storageState.json' }); + await browserContext.storageState({ path: STORAGE_STATE }); // make sure the plugin has been configured const page = await browserContext.newPage(); - await configureOnCallPlugin(page); + + if (IS_OPEN_SOURCE) { + // plugin configuration can safely be skipped for cloud environments + await configureOnCallPlugin(page); + } await browserContext.close(); }; @@ -88,4 +114,13 @@ const globalSetupWithRetries = async (config: FullConfig): Promise => { await globalSetup(config); }; -export default globalSetupWithRetries; +setup('Configure Grafana OnCall plugin', async ({}, { config }) => { + /** + * Unconditionally marks the setup as "slow", giving it triple the default timeout. + * This is mostly useful for the rare case for Cloud Grafana instances where the instance may be down/unavailable + * and we need to poll it until it is available + */ + setup.slow(); + + await globalSetupWithRetries(config); +}); diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 5cae7ef8..8ea67ebc 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -1,3 +1,5 @@ +import path from 'path'; + import type { PlaywrightTestConfig } from '@playwright/test'; import { devices } from '@playwright/test'; @@ -7,12 +9,13 @@ import { devices } from '@playwright/test'; */ require('dotenv').config(); +export const STORAGE_STATE = path.join(__dirname, 'integration-tests/storageState.json'); + /** * See https://playwright.dev/docs/test-configuration. */ const config: PlaywrightTestConfig = { testDir: './integration-tests', - globalSetup: './integration-tests/globalSetup.ts', /* Maximum time one test can run for. */ timeout: 60 * 1000, expect: { @@ -38,8 +41,6 @@ const config: PlaywrightTestConfig = { reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - storageState: './storageState.json', - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 0, /* Base URL to use in actions like `await page.goto('/')`. */ @@ -53,23 +54,33 @@ const config: PlaywrightTestConfig = { /* Configure projects for major browsers */ projects: [ + { + name: 'setup', + testMatch: /globalSetup\.ts/, + }, { name: 'chromium', use: { ...devices['Desktop Chrome'], + storageState: STORAGE_STATE, }, + dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'], + storageState: STORAGE_STATE, }, + dependencies: ['setup'], }, { name: 'webkit', use: { ...devices['Desktop Safari'], + storageState: STORAGE_STATE, }, + dependencies: ['setup'], }, /* Test against mobile viewports. */