mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-30 17:50:41 +01:00
Compare commits
6 Commits
email-aler
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a062b3fb1 | ||
|
|
39bf182213 | ||
|
|
2247968c62 | ||
|
|
d2e10d1d9c | ||
|
|
90205a376f | ||
|
|
59c4f5c7e3 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -39,8 +39,6 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
|
||||
@@ -756,14 +756,10 @@ function QueryBuilderSearchV2(
|
||||
|
||||
let operatorOptions;
|
||||
if (currentFilterItem?.key?.dataType) {
|
||||
// Fallback to universal suggestions if no match found for currentFilter dataType
|
||||
const operatorsForDataType =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentFilterItem.key
|
||||
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
] ?? QUERY_BUILDER_OPERATORS_BY_TYPES.universal;
|
||||
|
||||
operatorOptions = operatorsForDataType.map((operator) => ({
|
||||
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentFilterItem.key
|
||||
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
].map((operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { render, RenderResult, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValues,
|
||||
@@ -86,9 +91,6 @@ const renderWithContext = (props = {}): RenderResult => {
|
||||
);
|
||||
};
|
||||
|
||||
const getSearchCombobox = (): HTMLElement =>
|
||||
within(screen.getByTestId('qb-search-select')).getByRole('combobox');
|
||||
|
||||
// Constants to fix linter errors
|
||||
const TYPE_TAG = 'tag';
|
||||
const IS_COLUMN_FALSE = false;
|
||||
@@ -111,14 +113,6 @@ const mockAggregateKeysData = {
|
||||
isJSON: IS_JSON_FALSE,
|
||||
id: 'service.name--string--tag--false',
|
||||
},
|
||||
{
|
||||
key: 'unmapped.attribute',
|
||||
dataType: 'not-a-real-data-type' as unknown as DataTypes,
|
||||
type: TYPE_TAG,
|
||||
isColumn: IS_COLUMN_FALSE,
|
||||
isJSON: IS_JSON_FALSE,
|
||||
id: 'unmapped.attribute--String--tag--false',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -171,28 +165,41 @@ jest.mock('hooks/dashboard/useDashboardVariables', () => ({
|
||||
}));
|
||||
|
||||
describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('should complete full flow from key selection to value', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithContext();
|
||||
const { container } = renderWithContext();
|
||||
|
||||
const combobox = getSearchCombobox();
|
||||
// Get the combobox input specifically
|
||||
const combobox = container.querySelector(
|
||||
'.query-builder-search-v2 .ant-select-selection-search-input',
|
||||
) as HTMLInputElement;
|
||||
|
||||
// 1. Focus and type to trigger key suggestions
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'http.');
|
||||
await act(async () => {
|
||||
fireEvent.focus(combobox);
|
||||
fireEvent.change(combobox, { target: { value: 'http.' } });
|
||||
});
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// 2. Select a key from suggestions
|
||||
await user.click(await screen.findByText('http.status'));
|
||||
const statusOption = await screen.findByText('http.status');
|
||||
await act(async () => {
|
||||
fireEvent.click(statusOption);
|
||||
});
|
||||
|
||||
// Should show operator suggestions
|
||||
expect(screen.getByText('=')).toBeInTheDocument();
|
||||
expect(screen.getByText('!=')).toBeInTheDocument();
|
||||
|
||||
// 3. Select an operator
|
||||
await user.click(screen.getByText('='));
|
||||
const equalsOption = screen.getByText('=');
|
||||
await act(async () => {
|
||||
fireEvent.click(equalsOption);
|
||||
});
|
||||
|
||||
// Should show value suggestions
|
||||
expect(screen.getByText('200')).toBeInTheDocument();
|
||||
@@ -200,7 +207,10 @@ describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
|
||||
// 4. Select a value
|
||||
await user.click(screen.getByText('200'));
|
||||
const valueOption = screen.getByText('200');
|
||||
await act(async () => {
|
||||
fireEvent.click(valueOption);
|
||||
});
|
||||
|
||||
// Verify final filter
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
@@ -217,52 +227,39 @@ describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Operator suggestions for data types missing from the operators map', () => {
|
||||
it('should fall back to universal operators for a non-canonical data type', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithContext();
|
||||
|
||||
const combobox = getSearchCombobox();
|
||||
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'unmapped.');
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
await user.click(await screen.findByText('unmapped.attribute'));
|
||||
|
||||
expect(screen.getByText('=')).toBeInTheDocument();
|
||||
expect(screen.getByText('!=')).toBeInTheDocument();
|
||||
expect(screen.getByText('>')).toBeInTheDocument();
|
||||
expect(screen.getByText('<')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('REGEX')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('='));
|
||||
|
||||
expect(combobox).toHaveDisplayValue(/unmapped\.attribute =/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Variable Suggestions', () => {
|
||||
it('should suggest dynamic variable when key matches a variable attribute', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithContext();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const combobox = getSearchCombobox();
|
||||
it('should suggest dynamic variable when key matches a variable attribute', async () => {
|
||||
const { container } = renderWithContext();
|
||||
|
||||
// Get the combobox input
|
||||
const combobox = container.querySelector(
|
||||
'.query-builder-search-v2 .ant-select-selection-search-input',
|
||||
) as HTMLInputElement;
|
||||
|
||||
// Focus and type to trigger key suggestions for service.name
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'service.');
|
||||
await act(async () => {
|
||||
fireEvent.focus(combobox);
|
||||
fireEvent.change(combobox, { target: { value: 'service.' } });
|
||||
});
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
// Select service.name key from suggestions
|
||||
await user.click(await screen.findByText('service.name'));
|
||||
const serviceNameOption = await screen.findByText('service.name');
|
||||
await act(async () => {
|
||||
fireEvent.click(serviceNameOption);
|
||||
});
|
||||
|
||||
// Select equals operator
|
||||
await user.click(screen.getByText('='));
|
||||
await act(async () => {
|
||||
const equalsOption = screen.getByText('=');
|
||||
fireEvent.click(equalsOption);
|
||||
});
|
||||
|
||||
// Should show value suggestions including the dynamic variable
|
||||
// For 'service.name', we expect to see '$service' as the first suggestion
|
||||
@@ -274,7 +271,9 @@ describe('Dynamic Variable Suggestions', () => {
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
|
||||
// Select the variable suggestion
|
||||
await user.click(variableSuggestion);
|
||||
await act(async () => {
|
||||
fireEvent.click(variableSuggestion);
|
||||
});
|
||||
|
||||
// Verify the query was updated with the variable as value
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
|
||||
@@ -2,9 +2,7 @@ import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { expandResourceCard, renderCreateRolePage } from './testUtils';
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
import { expandAllCards, renderCreateRolePage } from './testUtils';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
@@ -125,7 +123,7 @@ describe('PermissionEditor', () => {
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', async () => {
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(
|
||||
@@ -144,7 +142,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('defaults all actions to None scope', async () => {
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -162,7 +160,7 @@ describe('PermissionEditor', () => {
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -183,7 +181,7 @@ describe('PermissionEditor', () => {
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -202,7 +200,7 @@ describe('PermissionEditor', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -221,7 +219,7 @@ describe('PermissionEditor', () => {
|
||||
it('adds item when typed and Enter pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -241,7 +239,7 @@ describe('PermissionEditor', () => {
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -266,7 +264,7 @@ describe('PermissionEditor', () => {
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -288,7 +286,7 @@ describe('PermissionEditor', () => {
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -310,7 +308,7 @@ describe('PermissionEditor', () => {
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -332,7 +330,7 @@ describe('PermissionEditor', () => {
|
||||
it('removes item when X clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -358,7 +356,7 @@ describe('PermissionEditor', () => {
|
||||
it('names each badge close button after the item it removes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -387,7 +385,7 @@ describe('PermissionEditor', () => {
|
||||
it('exposes the full item value as a title so truncated badges stay readable', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -409,7 +407,7 @@ describe('PermissionEditor', () => {
|
||||
it('moves focus to the previous badge when closed with the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -440,7 +438,7 @@ describe('PermissionEditor', () => {
|
||||
it('does not steal focus when a badge is closed with the mouse', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -473,7 +471,7 @@ describe('PermissionEditor', () => {
|
||||
it('shows Add button disabled when input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -493,7 +491,7 @@ describe('PermissionEditor', () => {
|
||||
it('shows confirm dialog when leaving Only Selected with items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -517,7 +515,7 @@ describe('PermissionEditor', () => {
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -546,7 +544,7 @@ describe('PermissionEditor', () => {
|
||||
it('keeps items when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -577,7 +575,7 @@ describe('PermissionEditor', () => {
|
||||
it('does not show dialog when leaving Only Selected with no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const createToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -596,7 +594,7 @@ describe('PermissionEditor', () => {
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', async () => {
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const listToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -625,16 +623,14 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('expands all cards when expand button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
await user.click(screen.getByTestId('expand-all-button'));
|
||||
|
||||
const headers = screen.getAllByTestId(/^resource-card-header-/);
|
||||
expect(headers.length).toBeGreaterThan(1);
|
||||
headers.forEach((header) => {
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { expandResourceCard, renderCreateRolePage } from './testUtils';
|
||||
|
||||
jest.setTimeout(15_000);
|
||||
import { expandAllCards, renderCreateRolePage } from './testUtils';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
@@ -19,7 +17,7 @@ async function selectOnlySelected(
|
||||
resource = 'logs',
|
||||
): Promise<void> {
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard(resource);
|
||||
await expandAllCards();
|
||||
|
||||
const card = screen.getByTestId(`resource-card-${resource}`);
|
||||
const readToggle = within(card).getByTestId(`action-toggle-${resource}-read`);
|
||||
@@ -62,7 +60,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
|
||||
it('does not show wizard button for non-telemetry resources', async () => {
|
||||
await renderCreateRolePage();
|
||||
await expandResourceCard('factor-api-key');
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
@@ -73,9 +71,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
expect(
|
||||
within(apiKeyCard).queryByTestId(
|
||||
'telemetry-wizard-trigger-factor-api-key-read',
|
||||
),
|
||||
screen.queryByTestId('telemetry-wizard-trigger-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
@@ -26,10 +26,8 @@ export async function renderCreateRolePage(): Promise<
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function expandResourceCard(resourceId: string): Promise<void> {
|
||||
export async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const card = await screen.findByTestId(`resource-card-${resourceId}`);
|
||||
await user.click(
|
||||
within(card).getByTestId(`resource-card-header-${resourceId}`),
|
||||
);
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../variableFormModel';
|
||||
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
|
||||
}
|
||||
|
||||
/** The list spec a saved variable carries, whatever its plugin. */
|
||||
function listSpec(m: VariableFormModel): {
|
||||
allowMultiple?: boolean;
|
||||
allowAllValue?: boolean;
|
||||
} {
|
||||
return formModelToDto(m).spec as {
|
||||
allowMultiple?: boolean;
|
||||
allowAllValue?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
describe('formModelToDto — the ALL flag needs multi-select', () => {
|
||||
it('keeps ALL for a multi-select dynamic variable', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'host.name',
|
||||
multiSelect: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: true, allowAllValue: true });
|
||||
});
|
||||
|
||||
it('drops ALL for a single-select dynamic variable', () => {
|
||||
// The API rejects allowAllValue without allowMultiple, and this used to be forced
|
||||
// true for every dynamic variable — which blocked saving the whole dashboard.
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: 'host.name',
|
||||
multiSelect: false,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: false, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('drops ALL for a single-select query variable that still carries the flag', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT 1',
|
||||
multiSelect: false,
|
||||
showAllOption: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: false, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('respects the toggle on a multi-select query variable', () => {
|
||||
expect(
|
||||
listSpec(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT 1',
|
||||
multiSelect: true,
|
||||
showAllOption: false,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ allowMultiple: true, allowAllValue: false });
|
||||
});
|
||||
|
||||
it('round-trips a single-select dynamic variable unchanged', () => {
|
||||
// What the dashboard in the report holds: saving it must not flip the flag.
|
||||
const dto = {
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: 'host.name',
|
||||
display: { name: 'host.name', description: '' },
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
sort: 'alphabetical-asc',
|
||||
plugin: {
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: { name: 'host.name', signal: 'all' },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,9 +133,14 @@ export function formModelToDto(
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
|
||||
// which forced showALLOption true on save); other types respect the toggle.
|
||||
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
|
||||
// forced showALLOption true on save); other types respect the toggle. Either
|
||||
// way it needs multi-select — ALL is a set of values, and the API rejects the
|
||||
// flag without it, which used to make a single-select dynamic variable
|
||||
// unsaveable and blocked every other edit to the dashboard with it.
|
||||
allowAllValue:
|
||||
model.multiSelect &&
|
||||
(model.type === 'DYNAMIC' ? true : model.showAllOption),
|
||||
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
|
||||
|
||||
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
|
||||
import { useVariableSelection } from './hooks/useVariableSelection';
|
||||
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
|
||||
import VariableSelector from './components/VariableSelector/VariableSelector';
|
||||
import styles from './VariablesBar.module.scss';
|
||||
|
||||
@@ -96,12 +97,10 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
|
||||
variable={variable}
|
||||
variables={variables}
|
||||
selections={selection}
|
||||
selection={
|
||||
selection[variable.name] ?? {
|
||||
value: variable.multiSelect ? [] : '',
|
||||
allSelected: false,
|
||||
}
|
||||
}
|
||||
// Until the seed commits a selection, stand in the same default it will
|
||||
// commit, through the one resolver — an empty stand-in reads as "nothing
|
||||
// selected" to a control that snapshots it on mount.
|
||||
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
|
||||
onChange={(next): void => setSelection(variable.name, next)}
|
||||
onAutoSelect={(next): void => autoSelect(variable.name, next)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import TextSelector from '../components/selectors/TextSelector';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
function renderSelector(
|
||||
selection: VariableSelection,
|
||||
onChange = jest.fn(),
|
||||
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
|
||||
const { rerender } = render(
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue="flower"
|
||||
onChange={onChange}
|
||||
testId="variable-input-service"
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onChange,
|
||||
rerender: (next): void =>
|
||||
rerender(
|
||||
<TextSelector
|
||||
selection={next}
|
||||
defaultValue="flower"
|
||||
onChange={onChange}
|
||||
testId="variable-input-service"
|
||||
/>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByTestId('variable-input-service') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('TextSelector', () => {
|
||||
it('shows the value the seed commits after mount', () => {
|
||||
// The bar mounts before the seed resolves the definition's default, so the first
|
||||
// render sees nothing selected. The box must follow the value once it lands.
|
||||
const { rerender } = renderSelector({ value: '', allSelected: false });
|
||||
|
||||
rerender({ value: 'flower', allSelected: false });
|
||||
|
||||
expect(input().value).toBe('flower');
|
||||
});
|
||||
|
||||
it('follows a selection replaced from outside (share link, reset)', () => {
|
||||
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
|
||||
|
||||
rerender({ value: 'rose', allSelected: false });
|
||||
|
||||
expect(input().value).toBe('rose');
|
||||
});
|
||||
|
||||
it('keeps what the user types until they commit it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
|
||||
|
||||
await user.clear(input());
|
||||
await user.type(input(), 'tulip');
|
||||
|
||||
// Still local — a keystroke must not cascade to dependent panels.
|
||||
expect(input().value).toBe('tulip');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await user.tab();
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: 'tulip',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the default when emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
|
||||
|
||||
await user.clear(input());
|
||||
await user.tab();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: 'flower',
|
||||
allSelected: false,
|
||||
});
|
||||
expect(input().value).toBe('flower');
|
||||
});
|
||||
});
|
||||
@@ -87,4 +87,99 @@ describe('ValueSelector', () => {
|
||||
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('an ALL selection whose options are still loading', () => {
|
||||
it('reads ALL, not the "Select value" placeholder', () => {
|
||||
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
|
||||
// which carries no values at all) — the control must not read as unselected.
|
||||
renderSelector({ value: null, allSelected: true }, [], true);
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-placeholder'),
|
||||
).toHaveTextContent('ALL');
|
||||
});
|
||||
|
||||
it('still shows concrete values while options load', () => {
|
||||
renderSelector(
|
||||
{ value: ['checkout-service-prod'], allSelected: false },
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-select-selection-placeholder'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing', () => {
|
||||
function clearIcon(): Element | null {
|
||||
return document.querySelector('.ant-select-clear');
|
||||
}
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
}
|
||||
|
||||
it('offers no clear icon while the list is closed', () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
expect(clearIcon()).toBeNull();
|
||||
});
|
||||
|
||||
it('offers it once the list is open', async () => {
|
||||
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(clearIcon()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers no clear icon while every option is selected', async () => {
|
||||
// ALL is every option, so there is nothing to clear — and the shared control
|
||||
// refuses to empty an ALL selection, which would leave the icon inert.
|
||||
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(clearIcon()).toBeNull();
|
||||
});
|
||||
|
||||
it('empties the list and commits nothing until it closes', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={OPTIONS}
|
||||
variableType="query"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={{ value: VALUES, allSelected: false }}
|
||||
onChange={onChange}
|
||||
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
await user.click(clearIcon() as Element);
|
||||
|
||||
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
// Closing fills in whatever the variable should hold.
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
value: [OPTIONS[0]],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,47 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
|
||||
});
|
||||
|
||||
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
|
||||
const next = run(
|
||||
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('still honours a configured default over ALL', () => {
|
||||
const next = run(
|
||||
model({
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'b',
|
||||
}),
|
||||
['a', 'b'],
|
||||
{ value: [], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
|
||||
@@ -97,6 +97,125 @@ describe('useSeedVariableSelection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the configured default over a stored empty text value', () => {
|
||||
// Persisted from a session where the box was never filled — the default the
|
||||
// definition carries must win, or the variable reads as unset forever.
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: '', allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: 'prod',
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: [], allSelected: false } });
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
// Custom options need no request, so ALL is materialized here rather than left as
|
||||
// a flag for the post-fetch reconcile to expand.
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b,c',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a', 'b', 'c'],
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops values a custom variable no longer offers, at seed time', () => {
|
||||
useDashboardStore.getState().setVariableValues('d1', {
|
||||
env: { value: ['a', 'gone'], allSelected: false },
|
||||
});
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'CUSTOM',
|
||||
customValue: 'a,b',
|
||||
multiSelect: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: ['a'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stored ALL selection that has not materialized yet', () => {
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.setVariableValues('d1', { env: { value: null, allSelected: true } });
|
||||
const dash = dashboard('d1', [
|
||||
model({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
defaultValue: 'b',
|
||||
}),
|
||||
]);
|
||||
|
||||
renderHook(() => useSeedVariableSelection(dash));
|
||||
|
||||
expect(seededValue('d1', 'env')).toStrictEqual({
|
||||
value: null,
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rewrite the store when the effect re-runs with the same values', () => {
|
||||
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
|
||||
// re-running the seed. Writing an identical map would re-render every subscriber.
|
||||
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
|
||||
const { rerender } = renderHook(
|
||||
({ dash }) => useSeedVariableSelection(dash),
|
||||
{ initialProps: { dash: dashboard('d1', [variable]) } },
|
||||
);
|
||||
const afterSeed = useDashboardStore.getState().variableValues;
|
||||
|
||||
rerender({ dash: dashboard('d1', [{ ...variable }]) });
|
||||
|
||||
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
|
||||
});
|
||||
|
||||
it('initializes the fetch context with idle states for every variable', () => {
|
||||
const dash = dashboard('d1', [
|
||||
model({ name: 'env', type: 'TEXT' }),
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useVariableSelection } from '../hooks/useVariableSelection';
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
|
||||
useQueryState: (): unknown => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({
|
||||
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
|
||||
dtoToFormModel: (dto: unknown): unknown => dto,
|
||||
}));
|
||||
|
||||
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
|
||||
return { ...emptyVariableFormModel(), ...overrides };
|
||||
}
|
||||
|
||||
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
|
||||
const env = model({
|
||||
name: 'env',
|
||||
type: 'QUERY',
|
||||
multiSelect: true,
|
||||
queryValue: 'SELECT env',
|
||||
});
|
||||
const svc = model({
|
||||
name: 'svc',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT svc WHERE env = $env',
|
||||
});
|
||||
|
||||
const dashboard = {
|
||||
id: 'd1',
|
||||
spec: { variables: [env, svc] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
function svcCycleId(): number {
|
||||
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
|
||||
}
|
||||
|
||||
describe('useVariableSelection — setSelection', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardStore.setState({
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-cascade when the picked value is the one already held', () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
const afterFirstPick = svcCycleId();
|
||||
|
||||
// Same values, then the same set in a different order: neither is a change, so
|
||||
// the dependent must not be re-fetched.
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['b', 'a'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(svcCycleId()).toBe(afterFirstPick);
|
||||
});
|
||||
|
||||
it('ignores an auto-fill that the store already satisfies', async () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
// What a selector's first-render reconcile produces before the seed commits: a
|
||||
// value the store then resolves to on its own.
|
||||
await act(async () => {
|
||||
result.current.autoSelect('env', { value: ['a'], allSelected: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
|
||||
expect(svcCycleId()).toBe(before);
|
||||
});
|
||||
|
||||
it('still applies an auto-fill that changes the value', async () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
await act(async () => {
|
||||
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
|
||||
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('still cascades a genuine change', () => {
|
||||
const { result } = renderHook(() => useVariableSelection(dashboard));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['a'], allSelected: false });
|
||||
});
|
||||
const before = svcCycleId();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelection('env', {
|
||||
value: ['a', 'c'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
|
||||
value: ['a', 'c'],
|
||||
allSelected: false,
|
||||
});
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../../selectionTypes';
|
||||
import { textDefault } from '../../utils/resolveVariableSelection';
|
||||
import { computeVariableDependencies } from '../../utils/variableDependencies';
|
||||
import TextSelector from '../selectors/TextSelector';
|
||||
import VariableValueControl from '../selectors/VariableValueControl';
|
||||
@@ -65,7 +66,7 @@ function VariableSelector({
|
||||
variable.type === 'TEXT' ? (
|
||||
<TextSelector
|
||||
selection={selection}
|
||||
defaultValue={variable.textValue}
|
||||
defaultValue={textDefault(variable)}
|
||||
onChange={onChange}
|
||||
testId={`variable-input-${variable.name}`}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
|
||||
import { Input } from 'antd';
|
||||
@@ -31,6 +31,17 @@ function TextSelector({
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
|
||||
// The committed value can land after this input mounts — the seed resolves the
|
||||
// definition's default one tick later, and a share link or a reset can replace it
|
||||
// at any point. Without following it the box would keep showing its first render
|
||||
// (empty, since nothing is seeded yet) until the user focused and blurred it.
|
||||
// Typing never touches the selection, so an edit in progress cannot be interrupted.
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
|
||||
);
|
||||
}, [selection.value, defaultValue]);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => {
|
||||
void logEvent(
|
||||
|
||||
@@ -54,11 +54,26 @@ function ValueSelector({
|
||||
[selection, options],
|
||||
);
|
||||
|
||||
// That "all" path needs the options, so an ALL selection whose options have not
|
||||
// arrived yet has nothing to render and the control would read "Select value"
|
||||
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
|
||||
// selection is known, only its options are pending. Display only, so it can never
|
||||
// be committed as a value.
|
||||
const isAllPendingOptions = selection.allSelected && options.length === 0;
|
||||
|
||||
// Buffer edits while the dropdown is open; the committed selection is shown
|
||||
// when closed. This defers the dependent cascade to a single commit-on-close.
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(committedValues);
|
||||
|
||||
// ALL is every option, so there is nothing to clear — and the shared control refuses
|
||||
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
|
||||
// in the list is the way out of it.
|
||||
const draftIsAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => draft.includes(option));
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
@@ -94,8 +109,11 @@ function ValueSelector({
|
||||
errorMessage={errorMessage}
|
||||
onRetry={onRetry}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select value"
|
||||
// Clearing belongs to the open list: on the closed control the icon would
|
||||
// appear on hover, in a row of variable pills, for an action whose result is
|
||||
// not visible.
|
||||
allowClear={isOpen && !draftIsAll}
|
||||
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): JSX.Element => (
|
||||
@@ -129,12 +147,10 @@ function ValueSelector({
|
||||
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
|
||||
variableType,
|
||||
});
|
||||
// Empties the list, committing nothing. Closing resolves an empty draft
|
||||
// to whatever the variable should hold — its configured default, else ALL
|
||||
// where it offers one, else the first option.
|
||||
setDraft([]);
|
||||
// A clear on the closed control falls back to the default immediately;
|
||||
// while open it just empties the draft (committed on close).
|
||||
if (!isOpen) {
|
||||
onChange(emptyFallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,13 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
|
||||
import { knownVariableOptions } from '../utils/knownVariableOptions';
|
||||
import {
|
||||
areSelectionsEqual,
|
||||
reconcileWithOptions,
|
||||
resolveDefaultSelection,
|
||||
} from '../utils/resolveVariableSelection';
|
||||
import { hasUsableValue } from '../utils/selectionUtils';
|
||||
import type {
|
||||
SelectedVariableValue,
|
||||
VariableSelection,
|
||||
@@ -18,6 +24,44 @@ import {
|
||||
} from '../utils/variableDependencies';
|
||||
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
|
||||
|
||||
function isStoredSelectionSet(
|
||||
stored: VariableSelection,
|
||||
model: VariableFormModel,
|
||||
): boolean {
|
||||
return !!stored.allSelected || hasUsableValue(stored, model.type);
|
||||
}
|
||||
|
||||
/** Whether the seeded map matches, entry for entry, what the store already holds. */
|
||||
function isSameSelection(
|
||||
seeded: VariableSelectionMap,
|
||||
current: VariableSelectionMap,
|
||||
): boolean {
|
||||
const names = Object.keys(seeded);
|
||||
return (
|
||||
names.length === Object.keys(current).length &&
|
||||
names.every(
|
||||
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A seeded value taken as far as it can go: for a variable whose options need no
|
||||
* request, resolve against them now — ALL becomes the concrete array, values the list
|
||||
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
|
||||
* once the options arrive.
|
||||
*/
|
||||
function resolveAgainstKnownOptions(
|
||||
value: VariableSelection,
|
||||
model: VariableFormModel,
|
||||
): VariableSelection {
|
||||
const options = knownVariableOptions(model);
|
||||
if (options.length === 0) {
|
||||
return value;
|
||||
}
|
||||
return reconcileWithOptions(model, value, options) ?? value;
|
||||
}
|
||||
|
||||
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
|
||||
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
|
||||
function fromUrlValue(
|
||||
@@ -70,22 +114,31 @@ export function useSeedVariableSelection(
|
||||
variables.forEach((variable) => {
|
||||
const urlValue = urlValues?.[variable.name];
|
||||
const stored = selection[variable.name];
|
||||
const seed = (value: VariableSelection): void => {
|
||||
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
|
||||
};
|
||||
if (urlValue !== undefined) {
|
||||
const fromUrl = fromUrlValue(urlValue, variable);
|
||||
// When the URL carries only the ALL sentinel but the store already holds
|
||||
// the materialized full-option array, reuse it — avoids the re-fetch +
|
||||
// re-materialize round-trip (and its dependent-refetch cascade) on load.
|
||||
seeded[variable.name] =
|
||||
seed(
|
||||
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
|
||||
? stored
|
||||
: fromUrl;
|
||||
} else if (stored) {
|
||||
seeded[variable.name] = stored;
|
||||
: fromUrl,
|
||||
);
|
||||
} else if (stored && isStoredSelectionSet(stored, variable)) {
|
||||
seed(stored);
|
||||
} else {
|
||||
seeded[variable.name] = resolveDefaultSelection(variable);
|
||||
seed(resolveDefaultSelection(variable));
|
||||
}
|
||||
});
|
||||
setVariableValues(dashboardId, seeded);
|
||||
// This runs again whenever the spec's variable array changes identity — a refetch
|
||||
// or any spec edit — and writing an identical map would re-render every selection
|
||||
// subscriber for nothing.
|
||||
if (!isSameSelection(seeded, selection)) {
|
||||
setVariableValues(dashboardId, seeded);
|
||||
}
|
||||
|
||||
// Read-once: a share link's `?variables=` seeds the store, then the param is
|
||||
// dropped so the store is the sole source of truth. Selection changes never
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
VARIABLE_TYPE_EVENT_LABEL,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableSelectionMap } from '../selectionTypes';
|
||||
import { knownVariableOptions } from '../utils/knownVariableOptions';
|
||||
import {
|
||||
useFetchedVariableOptions,
|
||||
type VariableOptions,
|
||||
@@ -30,14 +27,12 @@ export function useVariableOptions(
|
||||
): VariableOptions {
|
||||
const fetched = useFetchedVariableOptions(variable, variables, selections);
|
||||
|
||||
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
|
||||
// refetch hands back an equal-but-new model, and a new options array would re-fire
|
||||
// the post-fetch reconcile for nothing.
|
||||
const customOptions = useMemo(
|
||||
() =>
|
||||
variable.type === 'CUSTOM'
|
||||
? sortValuesByOrder(
|
||||
commaValuesParser(variable.customValue),
|
||||
variable.sort,
|
||||
).map(String)
|
||||
: ([] as string[]),
|
||||
() => knownVariableOptions(variable),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[variable.type, variable.customValue, variable.sort],
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
VariableSelection,
|
||||
VariableSelectionMap,
|
||||
} from '../selectionTypes';
|
||||
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
|
||||
import { useSeedVariableSelection } from './useSeedVariableSelection';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,10 @@ export function useVariableSelection(
|
||||
|
||||
const setSelection = useCallback(
|
||||
(name: string, next: VariableSelection): void => {
|
||||
const current = selectionRef.current[name];
|
||||
if (current && areSelectionsEqual(next, current)) {
|
||||
return;
|
||||
}
|
||||
setVariableValue(dashboardId, name, next);
|
||||
enqueueDescendants(name);
|
||||
},
|
||||
@@ -124,8 +129,19 @@ export function useVariableSelection(
|
||||
if (names.length === 0 || !dashboardId) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
|
||||
enqueueDescendantsBatch(names);
|
||||
// A fill can arrive already satisfied: a selector reconciles against the options
|
||||
// on its first render, before the seed has committed, and the seed then resolves
|
||||
// to the same value. Refresh only what actually moved, so a settled load ends
|
||||
// without a write or a dependent refetch.
|
||||
const current = selectionRef.current;
|
||||
const changed = names.filter(
|
||||
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
|
||||
);
|
||||
if (changed.length === 0) {
|
||||
return;
|
||||
}
|
||||
setVariableValues(dashboardId, { ...current, ...fills });
|
||||
enqueueDescendantsBatch(changed);
|
||||
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
|
||||
|
||||
const autoSelect = useCallback(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
|
||||
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
|
||||
/**
|
||||
* The options of a variable whose list needs no request — a CUSTOM variable's comma
|
||||
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
|
||||
* TEXT, which has none.
|
||||
*
|
||||
* Knowing them up front lets the seed resolve such a variable's value completely
|
||||
* (materializing ALL, dropping values the list no longer offers) instead of leaving
|
||||
* that to the post-fetch reconcile, which would cost a second store write and a
|
||||
* refetch of everything downstream.
|
||||
*/
|
||||
export function knownVariableOptions(model: VariableFormModel): string[] {
|
||||
if (model.type !== 'CUSTOM') {
|
||||
return [];
|
||||
}
|
||||
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
|
||||
String,
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
|
||||
}
|
||||
|
||||
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
|
||||
function textDefault(model: VariableFormModel): string {
|
||||
export function textDefault(model: VariableFormModel): string {
|
||||
return firstConfiguredDefault(model) ?? model.textValue;
|
||||
}
|
||||
|
||||
@@ -53,15 +53,31 @@ function isAllDefault(
|
||||
);
|
||||
}
|
||||
|
||||
/** The configured default (or first option) as a fresh selection. */
|
||||
/**
|
||||
* The default selection for a variable with nothing usable selected: its configured
|
||||
* default, else ALL for an ALL-enabled multi-select, else the first option.
|
||||
*/
|
||||
function fillDefault(
|
||||
model: VariableFormModel,
|
||||
options: string[],
|
||||
): VariableSelection {
|
||||
const fallback = firstConfiguredDefault(model);
|
||||
const initial = fallback && options.includes(fallback) ? fallback : options[0];
|
||||
if (fallback && options.includes(fallback)) {
|
||||
return {
|
||||
value: model.multiSelect ? [fallback] : fallback,
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
|
||||
// to an arbitrary first option — the same default the seed applies (keep in sync
|
||||
// with resolveDefaultSelection).
|
||||
if (model.multiSelect && model.showAllOption) {
|
||||
return materializeAll(model, options, null) ?? ALL_SELECTION;
|
||||
}
|
||||
|
||||
return {
|
||||
value: model.multiSelect ? [initial] : initial,
|
||||
value: model.multiSelect ? [options[0]] : options[0],
|
||||
allSelected: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,12 +241,9 @@ func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanager
|
||||
}
|
||||
|
||||
func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertmanagertypes.Config) error {
|
||||
resolved, err := alertmanagerConfig.Resolved()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config := resolved.AlertmanagerConfig()
|
||||
config := alertmanagerConfig.AlertmanagerConfig()
|
||||
|
||||
var err error
|
||||
// Load SigNoz's alertmanager notification templates from the configured
|
||||
// globs. The upstream default templates (default.tmpl, email.tmpl) are
|
||||
// always loaded from the embedded alertmanager assets inside FromGlobs, so
|
||||
@@ -278,7 +275,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
|
||||
continue
|
||||
}
|
||||
extendedRcv, err := resolved.GetReceiver(rcv.Name)
|
||||
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,7 +350,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
go server.dispatcher.Run()
|
||||
go server.inhibitor.Run()
|
||||
|
||||
server.alertmanagerConfig = resolved
|
||||
server.alertmanagerConfig = alertmanagerConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package alertmanager
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +26,11 @@ type Signoz struct {
|
||||
alertmanagerserver.Config `mapstructure:",squash" yaml:",squash"`
|
||||
}
|
||||
|
||||
type Legacy struct {
|
||||
// ApiURL is the URL of the legacy signoz alertmanager.
|
||||
ApiURL *url.URL `mapstructure:"api_url"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("alertmanager"), newConfig)
|
||||
}
|
||||
|
||||
@@ -167,10 +167,6 @@ func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgI
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.UpdateReceiver(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,10 +217,6 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.CreateReceiver(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package signozalertmanager
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(t.Context(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*alertmanagertypes.StoreableConfig)(nil)).
|
||||
IfNotExists().
|
||||
Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().ExecContext(t.Context(), "CREATE UNIQUE INDEX IF NOT EXISTS idx_alertmanager_config_org_id ON alertmanager_config(org_id)")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*alertmanagertypes.Channel)(nil)).
|
||||
IfNotExists().
|
||||
Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T) (*provider, sqlstore.SQLStore) {
|
||||
t.Helper()
|
||||
|
||||
serverConfig := alertmanagerserver.NewConfig()
|
||||
serverConfig.Global.SMTPFrom = "alerts@example.com"
|
||||
serverConfig.Global.SMTPSmarthost = config.HostPort{Host: "smtp.sendgrid.net", Port: "587"}
|
||||
serverConfig.Global.SMTPAuthUsername = "apikey"
|
||||
serverConfig.Global.SMTPAuthPassword = "operator-secret"
|
||||
|
||||
sqlStore := newTestSQLStore(t)
|
||||
|
||||
p, err := New(
|
||||
factorytest.NewSettings(),
|
||||
alertmanager.Config{
|
||||
Provider: "signoz",
|
||||
Signoz: alertmanager.Signoz{
|
||||
PollInterval: time.Minute,
|
||||
Config: serverConfig,
|
||||
},
|
||||
},
|
||||
sqlStore,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return p, sqlStore
|
||||
}
|
||||
|
||||
func requireNoSMTPSettings(t *testing.T, payload string) {
|
||||
t.Helper()
|
||||
|
||||
assert.NotContains(t, payload, "operator-secret")
|
||||
assert.NotContains(t, payload, "smtp.sendgrid.net")
|
||||
assert.NotContains(t, payload, "apikey")
|
||||
assert.NotContains(t, payload, "auth_password")
|
||||
assert.NotContains(t, payload, "smtp_auth_password")
|
||||
}
|
||||
|
||||
func TestCreateEmailChannelWithStoredConfigWithoutSMTP(t *testing.T) {
|
||||
p, _ := newTestProvider(t)
|
||||
orgID := "test-org-1"
|
||||
|
||||
require.NoError(t, p.SetDefaultConfig(t.Context(), orgID))
|
||||
|
||||
seeded, err := p.GetConfig(t.Context(), orgID)
|
||||
require.NoError(t, err)
|
||||
requireNoSMTPSettings(t, seeded.StoreableConfig().Config)
|
||||
|
||||
receiver, err := alertmanagertypes.NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := p.CreateChannel(t.Context(), orgID, receiver)
|
||||
require.NoError(t, err)
|
||||
requireNoSMTPSettings(t, channel.Data)
|
||||
|
||||
stored, err := p.GetChannelByID(t.Context(), orgID, channel.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stored.Data, "team@example.com")
|
||||
requireNoSMTPSettings(t, stored.Data)
|
||||
|
||||
cfg, err := p.GetConfig(t.Context(), orgID)
|
||||
require.NoError(t, err)
|
||||
requireNoSMTPSettings(t, cfg.StoreableConfig().Config)
|
||||
|
||||
require.NoError(t, cfg.SetGlobalConfig(p.config.Signoz.Global))
|
||||
resolved, err := cfg.Resolved()
|
||||
require.NoError(t, err)
|
||||
resolvedReceiver, err := resolved.GetReceiver("email-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resolvedReceiver.EmailConfigs, 1)
|
||||
assert.Equal(t, "smtp.sendgrid.net:587", resolvedReceiver.EmailConfigs[0].Smarthost.String())
|
||||
assert.Equal(t, "operator-secret", string(resolvedReceiver.EmailConfigs[0].AuthPassword))
|
||||
}
|
||||
|
||||
func TestUpdateStampedLegacyEmailChannel(t *testing.T) {
|
||||
p, sqlStore := newTestProvider(t)
|
||||
orgID := "test-org-2"
|
||||
|
||||
require.NoError(t, p.SetDefaultConfig(t.Context(), orgID))
|
||||
|
||||
receiver, err := alertmanagertypes.NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
|
||||
require.NoError(t, err)
|
||||
channel, err := p.CreateChannel(t.Context(), orgID, receiver)
|
||||
require.NoError(t, err)
|
||||
|
||||
stampedData := `{"name":"email-receiver","email_configs":[{"send_resolved":false,"to":"team@example.com","from":"old@example.com","hello":"localhost","smarthost":"email-smtp.us-east-1.amazonaws.com:587","auth_username":"AKIA000","auth_password":"old-ses-secret","require_tls":true}]}`
|
||||
_, err = sqlStore.BunDB().ExecContext(t.Context(), "UPDATE notification_channel SET data = ? WHERE id = ?", stampedData, channel.ID.StringValue())
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := alertmanagertypes.NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"new-team@example.com"}]}`)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, p.UpdateChannelByReceiverAndID(t.Context(), orgID, updated, channel.ID))
|
||||
|
||||
stored, err := p.GetChannelByID(t.Context(), orgID, channel.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stored.Data, "new-team@example.com")
|
||||
assert.NotContains(t, stored.Data, "old-ses-secret")
|
||||
assert.NotContains(t, stored.Data, "amazonaws.com")
|
||||
requireNoSMTPSettings(t, stored.Data)
|
||||
|
||||
cfg, err := p.GetConfig(t.Context(), orgID)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, cfg.StoreableConfig().Config, "old-ses-secret")
|
||||
requireNoSMTPSettings(t, cfg.StoreableConfig().Config)
|
||||
}
|
||||
@@ -231,7 +231,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
|
||||
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type scrubEmailChannelTransport struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type alertmanagerConfigScrubRow struct {
|
||||
bun.BaseModel `bun:"table:alertmanager_config"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Config string `bun:"config"`
|
||||
}
|
||||
|
||||
type notificationChannelScrubRow struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
var emailTransportKeys = []string{
|
||||
"from",
|
||||
"hello",
|
||||
"smarthost",
|
||||
"auth_username",
|
||||
"auth_password",
|
||||
"auth_password_file",
|
||||
"auth_secret",
|
||||
"auth_secret_file",
|
||||
"auth_identity",
|
||||
"require_tls",
|
||||
"tls_config",
|
||||
"force_implicit_tls",
|
||||
}
|
||||
|
||||
var globalSMTPKeys = []string{
|
||||
"smtp_from",
|
||||
"smtp_hello",
|
||||
"smtp_smarthost",
|
||||
"smtp_auth_username",
|
||||
"smtp_auth_password",
|
||||
"smtp_auth_password_file",
|
||||
"smtp_auth_secret",
|
||||
"smtp_auth_secret_file",
|
||||
"smtp_auth_identity",
|
||||
"smtp_require_tls",
|
||||
"smtp_tls_config",
|
||||
"smtp_force_implicit_tls",
|
||||
}
|
||||
|
||||
func NewScrubEmailChannelTransportFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("scrub_email_channel_transport"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &scrubEmailChannelTransport{sqlstore: sqlstore, logger: ps.Logger}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *scrubEmailChannelTransport) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *scrubEmailChannelTransport) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if err := migration.scrubConfigs(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migration.scrubChannels(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *scrubEmailChannelTransport) scrubConfigs(ctx context.Context, tx bun.Tx) error {
|
||||
rows := make([]*alertmanagerConfigScrubRow, 0)
|
||||
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
cfg := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable config", slog.String("config_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
if globalRaw, ok := cfg["global"]; ok && string(globalRaw) != "null" {
|
||||
global := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal(globalRaw, &global); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable global", slog.String("config_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if deleteKeys(global, globalSMTPKeys) {
|
||||
newGlobal, err := json.Marshal(global)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["global"] = newGlobal
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if receiversRaw, ok := cfg["receivers"]; ok && string(receiversRaw) != "null" {
|
||||
receivers := make([]map[string]json.RawMessage, 0)
|
||||
if err := json.Unmarshal(receiversRaw, &receivers); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable receivers", slog.String("config_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
receiversChanged := false
|
||||
for _, receiver := range receivers {
|
||||
scrubbed, err := scrubEmailConfigs(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receiversChanged = receiversChanged || scrubbed
|
||||
}
|
||||
|
||||
if receiversChanged {
|
||||
newReceivers, err := json.Marshal(receivers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["receivers"] = newReceivers
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
newConfig, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*alertmanagerConfigScrubRow)(nil)).
|
||||
Set("config = ?", string(newConfig)).
|
||||
Set("hash = ?", fmt.Sprintf("%x", md5.Sum(newConfig))).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *scrubEmailChannelTransport) scrubChannels(ctx context.Context, tx bun.Tx) error {
|
||||
rows := make([]*notificationChannelScrubRow, 0)
|
||||
if err := tx.NewSelect().Model(&rows).Where("type = ?", "email").Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
receiver := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal([]byte(row.Data), &receiver); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping notification channel with unreadable data", slog.String("channel_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
scrubbed, err := scrubEmailConfigs(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !scrubbed {
|
||||
continue
|
||||
}
|
||||
|
||||
newData, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*notificationChannelScrubRow)(nil)).
|
||||
Set("data = ?", string(newData)).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scrubEmailConfigs(receiver map[string]json.RawMessage) (bool, error) {
|
||||
emailConfigsRaw, ok := receiver["email_configs"]
|
||||
if !ok || string(emailConfigsRaw) == "null" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
emailConfigs := make([]map[string]json.RawMessage, 0)
|
||||
if err := json.Unmarshal(emailConfigsRaw, &emailConfigs); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, emailConfig := range emailConfigs {
|
||||
changed = deleteKeys(emailConfig, emailTransportKeys) || changed
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
newEmailConfigs, err := json.Marshal(emailConfigs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
receiver["email_configs"] = newEmailConfigs
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func deleteKeys(m map[string]json.RawMessage, keys []string) bool {
|
||||
deleted := false
|
||||
for _, key := range keys {
|
||||
if _, ok := m[key]; ok {
|
||||
delete(m, key)
|
||||
deleted = true
|
||||
}
|
||||
}
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
func (migration *scrubEmailChannelTransport) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -35,8 +35,12 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"email_configs": []any{map[string]any{
|
||||
"send_resolved": false,
|
||||
"to": "test@example.com",
|
||||
"smarthost": "",
|
||||
"from": "alerts@example.com",
|
||||
"hello": "localhost",
|
||||
"smarthost": "smtp.example.com:587",
|
||||
"require_tls": true,
|
||||
"html": "{{ template \"email.default.html\" . }}",
|
||||
"tls_config": map[string]any{"insecure_skip_verify": false},
|
||||
"threading": map[string]any{},
|
||||
}},
|
||||
},
|
||||
@@ -59,6 +63,7 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"slack_configs": []any{map[string]any{
|
||||
"send_resolved": true,
|
||||
"api_url": "https://slack.com/api/test",
|
||||
"app_url": "https://slack.com/api/chat.postMessage",
|
||||
"channel": "#alerts",
|
||||
"callback_id": "{{ template \"slack.default.callbackid\" . }}",
|
||||
"color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
|
||||
@@ -72,6 +77,12 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"title": "{{ template \"slack.default.title\" . }}",
|
||||
"title_link": "{{ template \"slack.default.titlelink\" . }}",
|
||||
"username": "{{ template \"slack.default.username\" . }}",
|
||||
"http_config": map[string]any{
|
||||
"tls_config": map[string]any{"insecure_skip_verify": false},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": nil,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -93,6 +104,7 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"pagerduty_configs": []any{map[string]any{
|
||||
"send_resolved": false,
|
||||
"service_key": "test",
|
||||
"url": "https://events.pagerduty.com/v2/enqueue",
|
||||
"client": "{{ template \"pagerduty.default.client\" . }}",
|
||||
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
|
||||
"description": "{{ template \"pagerduty.default.description\" .}}",
|
||||
@@ -104,6 +116,12 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": "{{ .Alerts.Resolved | toJson }}",
|
||||
},
|
||||
"http_config": map[string]any{
|
||||
"tls_config": map[string]any{"insecure_skip_verify": false},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": nil,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -130,6 +148,7 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"pagerduty_configs": []any{map[string]any{
|
||||
"send_resolved": false,
|
||||
"service_key": "test",
|
||||
"url": "https://events.pagerduty.com/v2/enqueue",
|
||||
"client": "{{ template \"pagerduty.default.client\" . }}",
|
||||
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
|
||||
"description": "{{ template \"pagerduty.default.description\" .}}",
|
||||
@@ -141,6 +160,12 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": "{{ .Alerts.Resolved | toJson }}",
|
||||
},
|
||||
"http_config": map[string]any{
|
||||
"tls_config": map[string]any{"insecure_skip_verify": false},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": nil,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
@@ -148,6 +173,7 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"slack_configs": []any{map[string]any{
|
||||
"send_resolved": true,
|
||||
"api_url": "https://slack.com/api/test",
|
||||
"app_url": "https://slack.com/api/chat.postMessage",
|
||||
"channel": "#alerts",
|
||||
"callback_id": "{{ template \"slack.default.callbackid\" . }}",
|
||||
"color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
|
||||
@@ -161,6 +187,12 @@ func TestNewConfigFromChannels(t *testing.T) {
|
||||
"title": "{{ template \"slack.default.title\" . }}",
|
||||
"title_link": "{{ template \"slack.default.titlelink\" . }}",
|
||||
"username": "{{ template \"slack.default.username\" . }}",
|
||||
"http_config": map[string]any{
|
||||
"tls_config": map[string]any{"insecure_skip_verify": false},
|
||||
"follow_redirects": true,
|
||||
"enable_http2": true,
|
||||
"proxy_url": nil,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -174,7 +174,7 @@ func newConfigFromString(s string) (*config.Config, map[string]customReceiverCon
|
||||
return amConfig, customConfigs, nil
|
||||
}
|
||||
|
||||
func extendedReceivers(c *config.Config, customConfigs map[string]customReceiverConfigs) []*Receiver {
|
||||
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
|
||||
receivers := make([]*Receiver, len(c.Receivers))
|
||||
for i := range c.Receivers {
|
||||
base := c.Receivers[i]
|
||||
@@ -185,14 +185,7 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver
|
||||
}
|
||||
}
|
||||
|
||||
return receivers
|
||||
}
|
||||
|
||||
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
|
||||
persistable := *c
|
||||
persistable.Global = persistableGlobal(c.Global)
|
||||
|
||||
b, err := json.Marshal(storedConfig{Config: &persistable, Receivers: extendedReceivers(c, customConfigs)})
|
||||
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
|
||||
if err != nil {
|
||||
// Taking inspiration from the upstream. This is never expected to happen.
|
||||
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
|
||||
@@ -201,28 +194,6 @@ func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverC
|
||||
return b
|
||||
}
|
||||
|
||||
func persistableGlobal(g *config.GlobalConfig) *config.GlobalConfig {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stripped := *g
|
||||
stripped.SMTPFrom = ""
|
||||
stripped.SMTPHello = ""
|
||||
stripped.SMTPSmarthost = config.HostPort{}
|
||||
stripped.SMTPAuthUsername = ""
|
||||
stripped.SMTPAuthPassword = ""
|
||||
stripped.SMTPAuthPasswordFile = ""
|
||||
stripped.SMTPAuthSecret = ""
|
||||
stripped.SMTPAuthSecretFile = ""
|
||||
stripped.SMTPAuthIdentity = ""
|
||||
stripped.SMTPRequireTLS = false
|
||||
stripped.SMTPTLSConfig = nil
|
||||
stripped.SMTPForceImplicitTLS = nil
|
||||
|
||||
return &stripped
|
||||
}
|
||||
|
||||
func newConfigHash(s string) [16]byte {
|
||||
return md5.Sum([]byte(s))
|
||||
}
|
||||
@@ -235,37 +206,6 @@ func (c *Config) flush() {
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (c *Config) Resolved() (*Config, error) {
|
||||
raw, err := json.Marshal(storedConfig{Config: c.alertmanagerConfig, Receivers: extendedReceivers(c.alertmanagerConfig, c.customConfigs)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
alertmanagerConfig, customConfigs, err := newConfigFromString(string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storeableConfig := *c.storeableConfig
|
||||
resolved := &Config{
|
||||
alertmanagerConfig: alertmanagerConfig,
|
||||
customConfigs: customConfigs,
|
||||
storeableConfig: &storeableConfig,
|
||||
}
|
||||
resolved.applyNativeDefaults()
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
_, err := c.Resolved()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Config) CopyWithReset() (*Config, error) {
|
||||
newConfig, err := NewDefaultConfig(
|
||||
*c.alertmanagerConfig.Global,
|
||||
@@ -331,15 +271,6 @@ func (c *Config) StoreableConfig() *StoreableConfig {
|
||||
return c.storeableConfig
|
||||
}
|
||||
|
||||
func cloneReceiver(receiver *Receiver) (*Receiver, error) {
|
||||
raw, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewReceiver(string(raw))
|
||||
}
|
||||
|
||||
func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
// check that receiver name is not already used
|
||||
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
@@ -348,21 +279,16 @@ func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
}
|
||||
}
|
||||
|
||||
owned, err := cloneReceiver(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
route, err := NewRouteFromReceiver(owned)
|
||||
route, err := NewRouteFromReceiver(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
|
||||
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *owned.Receiver)
|
||||
c.setCustomConfigs(owned)
|
||||
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
|
||||
c.setCustomConfigs(receiver)
|
||||
|
||||
if err := c.validate(); err != nil {
|
||||
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyNativeDefaults()
|
||||
@@ -387,21 +313,16 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
|
||||
}
|
||||
|
||||
func (c *Config) UpdateReceiver(receiver *Receiver) error {
|
||||
owned, err := cloneReceiver(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// find and update receiver
|
||||
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
if existingReceiver.Name == owned.Name {
|
||||
c.alertmanagerConfig.Receivers[i] = *owned.Receiver
|
||||
c.setCustomConfigs(owned)
|
||||
if existingReceiver.Name == receiver.Name {
|
||||
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
|
||||
c.setCustomConfigs(receiver)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.validate(); err != nil {
|
||||
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyNativeDefaults()
|
||||
|
||||
@@ -330,120 +330,6 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func newSMTPGlobalConfig() GlobalConfig {
|
||||
return GlobalConfig{
|
||||
SMTPFrom: "alerts@example.com",
|
||||
SMTPHello: "example.com",
|
||||
SMTPSmarthost: config.HostPort{Host: "smtp.sendgrid.net", Port: "587"},
|
||||
SMTPAuthUsername: "apikey",
|
||||
SMTPAuthPassword: "operator-secret",
|
||||
SMTPRequireTLS: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newEmailTestConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
|
||||
cfg, err := NewDefaultConfig(
|
||||
newSMTPGlobalConfig(),
|
||||
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
|
||||
"1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
receiver, err := NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cfg.CreateReceiver(receiver))
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestStoreableConfigCarriesNoSMTPSettings(t *testing.T) {
|
||||
cfg := newEmailTestConfig(t)
|
||||
|
||||
raw := cfg.StoreableConfig().Config
|
||||
assert.NotContains(t, raw, "operator-secret")
|
||||
assert.NotContains(t, raw, "smtp.sendgrid.net")
|
||||
assert.NotContains(t, raw, "apikey")
|
||||
assert.NotContains(t, raw, "alerts@example.com")
|
||||
|
||||
assert.Equal(t, "operator-secret", string(cfg.alertmanagerConfig.Global.SMTPAuthPassword))
|
||||
}
|
||||
|
||||
func TestResolvedFillsEmailTransportFromGlobal(t *testing.T) {
|
||||
cfg := newEmailTestConfig(t)
|
||||
|
||||
resolved, err := cfg.Resolved()
|
||||
require.NoError(t, err)
|
||||
|
||||
receiver, err := resolved.GetReceiver("email-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, receiver.EmailConfigs, 1)
|
||||
|
||||
got := receiver.EmailConfigs[0]
|
||||
assert.Equal(t, "team@example.com", got.To)
|
||||
assert.Equal(t, "smtp.sendgrid.net:587", got.Smarthost.String())
|
||||
assert.Equal(t, "alerts@example.com", got.From)
|
||||
assert.Equal(t, "apikey", got.AuthUsername)
|
||||
assert.Equal(t, "operator-secret", string(got.AuthPassword))
|
||||
require.NotNil(t, got.RequireTLS)
|
||||
assert.True(t, *got.RequireTLS)
|
||||
|
||||
stored, err := cfg.GetReceiver("email-receiver")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, stored.EmailConfigs[0].Smarthost.String())
|
||||
assert.NotContains(t, cfg.StoreableConfig().Config, "operator-secret")
|
||||
}
|
||||
|
||||
func TestStaleStoredSMTPSettingsAreReplacedOnLoad(t *testing.T) {
|
||||
stored := &StoreableConfig{
|
||||
Config: `{"global":{"resolve_timeout":"5m","smtp_from":"old@example.com","smtp_hello":"localhost","smtp_smarthost":"email-smtp.us-east-1.amazonaws.com:587","smtp_auth_username":"old-user","smtp_auth_password":"old-secret","smtp_require_tls":true},"route":{"receiver":"default-receiver","group_by":["ruleId"],"routes":[{"receiver":"email-receiver","continue":true,"matchers":["ruleId=~\"-1\""]}],"group_wait":"30s","group_interval":"5m","repeat_interval":"4h"},"receivers":[{"name":"default-receiver"},{"name":"email-receiver","email_configs":[{"send_resolved":false,"to":"team@example.com","from":"old@example.com","hello":"localhost","smarthost":"email-smtp.us-east-1.amazonaws.com:587","auth_username":"old-user","auth_password":"old-secret","require_tls":true}]}]}`,
|
||||
OrgID: "1",
|
||||
}
|
||||
|
||||
cfg, err := NewConfigFromStoreableConfig(stored)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := cfg.GetReceiver("email-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, loaded.EmailConfigs, 1)
|
||||
assert.Empty(t, loaded.EmailConfigs[0].Smarthost.String())
|
||||
assert.Empty(t, string(loaded.EmailConfigs[0].AuthPassword))
|
||||
|
||||
require.NoError(t, cfg.SetGlobalConfig(newSMTPGlobalConfig()))
|
||||
|
||||
resolved, err := cfg.Resolved()
|
||||
require.NoError(t, err)
|
||||
|
||||
receiver, err := resolved.GetReceiver("email-receiver")
|
||||
require.NoError(t, err)
|
||||
got := receiver.EmailConfigs[0]
|
||||
assert.Equal(t, "smtp.sendgrid.net:587", got.Smarthost.String())
|
||||
assert.Equal(t, "operator-secret", string(got.AuthPassword))
|
||||
assert.Equal(t, "alerts@example.com", got.From)
|
||||
|
||||
assert.NotContains(t, cfg.StoreableConfig().Config, "old-secret")
|
||||
assert.NotContains(t, cfg.StoreableConfig().Config, "amazonaws.com")
|
||||
assert.NotContains(t, cfg.StoreableConfig().Config, "operator-secret")
|
||||
}
|
||||
|
||||
func TestCreateReceiverDoesNotMutateCaller(t *testing.T) {
|
||||
cfg := newEmailTestConfig(t)
|
||||
|
||||
resolved, err := cfg.Resolved()
|
||||
require.NoError(t, err)
|
||||
receiver, err := resolved.GetReceiver("email-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "smtp.sendgrid.net:587", receiver.EmailConfigs[0].Smarthost.String())
|
||||
|
||||
throwaway, err := cfg.CopyWithReset()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, throwaway.CreateReceiver(receiver))
|
||||
|
||||
assert.Equal(t, "smtp.sendgrid.net:587", receiver.EmailConfigs[0].Smarthost.String())
|
||||
assert.Equal(t, "operator-secret", string(receiver.EmailConfigs[0].AuthPassword))
|
||||
}
|
||||
|
||||
// Round-trip: create → serialize → reload → GetReceiver still has the configs.
|
||||
func TestConfigPreservesGoogleChatConfigs(t *testing.T) {
|
||||
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
|
||||
|
||||
@@ -37,7 +37,6 @@ func NewReceiver(input string) (*Receiver, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripEmailTransport(withDefaults)
|
||||
receiver.Receiver = withDefaults
|
||||
|
||||
// Extend this block when adding another native notifier type.
|
||||
@@ -52,23 +51,6 @@ func NewReceiver(input string) (*Receiver, error) {
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func stripEmailTransport(base *config.Receiver) {
|
||||
for _, ec := range base.EmailConfigs {
|
||||
ec.From = ""
|
||||
ec.Hello = ""
|
||||
ec.Smarthost = config.HostPort{}
|
||||
ec.AuthUsername = ""
|
||||
ec.AuthPassword = ""
|
||||
ec.AuthPasswordFile = ""
|
||||
ec.AuthSecret = ""
|
||||
ec.AuthSecretFile = ""
|
||||
ec.AuthIdentity = ""
|
||||
ec.RequireTLS = nil
|
||||
ec.TLSConfig = nil
|
||||
ec.ForceImplicitTLS = nil
|
||||
}
|
||||
}
|
||||
|
||||
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
|
||||
bytes, err := yaml.Marshal(base)
|
||||
if err != nil {
|
||||
@@ -118,12 +100,7 @@ func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsF
|
||||
return err
|
||||
}
|
||||
|
||||
resolvedConfig, err := testConfig.Resolved()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultedReceiver, err := resolvedConfig.GetReceiver(receiver.Name)
|
||||
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -46,31 +46,6 @@ func TestNewReceiver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReceiverStripsEmailTransport(t *testing.T) {
|
||||
receiver, err := NewReceiver(`{"name":"email","email_configs":[{"to":"team@example.com","from":"attacker@example.com","hello":"example.com","smarthost":"smtp.example.com:587","auth_username":"user","auth_password":"supersecret","auth_secret":"alsosecret","auth_identity":"id","require_tls":false,"headers":{"Subject":"custom"}}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, receiver.EmailConfigs, 1)
|
||||
|
||||
got := receiver.EmailConfigs[0]
|
||||
assert.Equal(t, "team@example.com", got.To)
|
||||
assert.Equal(t, map[string]string{"Subject": "custom"}, got.Headers)
|
||||
|
||||
assert.Empty(t, got.From)
|
||||
assert.Empty(t, got.Hello)
|
||||
assert.Empty(t, got.Smarthost.String())
|
||||
assert.Empty(t, got.AuthUsername)
|
||||
assert.Empty(t, string(got.AuthPassword))
|
||||
assert.Empty(t, string(got.AuthSecret))
|
||||
assert.Empty(t, got.AuthIdentity)
|
||||
assert.Nil(t, got.RequireTLS)
|
||||
assert.Nil(t, got.TLSConfig)
|
||||
|
||||
bytes, err := json.Marshal(receiver)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(bytes), "supersecret")
|
||||
assert.NotContains(t, string(bytes), "smtp.example.com")
|
||||
}
|
||||
|
||||
// Omitted fields fall back to DefaultGoogleChatReceiverConfig.
|
||||
func TestNewReceiverGoogleChatAppliesDefaults(t *testing.T) {
|
||||
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages"}]}`)
|
||||
|
||||
@@ -22,7 +22,6 @@ pytest_plugins = [
|
||||
"fixtures.keycloak",
|
||||
"fixtures.idp",
|
||||
"fixtures.notification_channel",
|
||||
"fixtures.maildev",
|
||||
"fixtures.alerts",
|
||||
"fixtures.cloudintegrations",
|
||||
"fixtures.jsontypes",
|
||||
|
||||
145
tests/fixtures/alerts.py
vendored
145
tests/fixtures/alerts.py
vendored
@@ -1,13 +1,11 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
@@ -17,7 +15,6 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.maildev import get_all_mails, verify_email_received
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.traces import Traces
|
||||
|
||||
@@ -314,145 +311,3 @@ def update_rule_channel_name(rule_data: dict, channel_name: str):
|
||||
# loop over all the sepcs and update the channels
|
||||
for spec in thresholds["spec"]:
|
||||
spec["channels"] = [channel_name]
|
||||
|
||||
|
||||
def _is_json_subset(subset, superset) -> bool:
|
||||
"""Check if subset is contained within superset recursively.
|
||||
- For dicts: all keys in subset must exist in superset with matching values
|
||||
- For lists: all items in subset must be present in superset
|
||||
- For scalars: exact equality
|
||||
"""
|
||||
if isinstance(subset, dict):
|
||||
if not isinstance(superset, dict):
|
||||
return False
|
||||
return all(key in superset and _is_json_subset(value, superset[key]) for key, value in subset.items())
|
||||
if isinstance(subset, list):
|
||||
if not isinstance(superset, list):
|
||||
return False
|
||||
return all(any(_is_json_subset(sub_item, sup_item) for sup_item in superset) for sub_item in subset)
|
||||
if isinstance(subset, re.Pattern):
|
||||
return isinstance(superset, str) and subset.search(superset) is not None
|
||||
return subset == superset
|
||||
|
||||
|
||||
def verify_webhook_notification_expectation(
|
||||
notification_channel: types.TestContainerDocker,
|
||||
validation_data: dict,
|
||||
) -> bool:
|
||||
"""Check if wiremock received a request at the given path
|
||||
whose JSON body is a superset of the expected json_body."""
|
||||
path = validation_data["path"]
|
||||
json_body = validation_data["json_body"]
|
||||
|
||||
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
|
||||
try:
|
||||
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
|
||||
except requests.exceptions.RequestException:
|
||||
return False
|
||||
if res.status_code != HTTPStatus.OK:
|
||||
return False
|
||||
|
||||
for req in res.json()["requests"]:
|
||||
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
|
||||
if _is_json_subset(json_body, body):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _check_notification_validation(
|
||||
validation: types.NotificationValidation,
|
||||
notification_channel: types.TestContainerDocker,
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> bool:
|
||||
"""Dispatch a single validation check to the appropriate verifier."""
|
||||
if validation.destination_type == "webhook":
|
||||
return verify_webhook_notification_expectation(notification_channel, validation.validation_data)
|
||||
if validation.destination_type == "email":
|
||||
return verify_email_received(maildev, validation.validation_data)
|
||||
raise ValueError(f"Invalid destination type: {validation.destination_type}")
|
||||
|
||||
|
||||
def verify_notification_expectation(
|
||||
notification_channel: types.TestContainerDocker,
|
||||
maildev: types.TestContainerDocker,
|
||||
expected_notification: types.AMNotificationExpectation,
|
||||
) -> bool:
|
||||
"""Poll for expected notifications across webhook and email channels."""
|
||||
time_to_wait = datetime.now() + timedelta(seconds=expected_notification.wait_time_seconds)
|
||||
|
||||
while datetime.now() < time_to_wait:
|
||||
all_found = all(_check_notification_validation(v, notification_channel, maildev) for v in expected_notification.notification_validations)
|
||||
|
||||
if expected_notification.should_notify and all_found:
|
||||
logger.info("All expected notifications found")
|
||||
return True
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
# Timeout reached
|
||||
if not expected_notification.should_notify:
|
||||
# Verify no notifications were received
|
||||
for validation in expected_notification.notification_validations:
|
||||
found = _check_notification_validation(validation, notification_channel, maildev)
|
||||
assert not found, f"Expected no notification but found one for {validation.destination_type} with data {validation.validation_data}"
|
||||
logger.info("No notifications found, as expected")
|
||||
return True
|
||||
|
||||
missing = [v for v in expected_notification.notification_validations if not _check_notification_validation(v, notification_channel, maildev)]
|
||||
assert len(missing) == 0, f"Expected all notifications to be found but missing: {missing}, received: {_received_notifications(notification_channel, maildev, missing)}"
|
||||
return True
|
||||
|
||||
|
||||
def _received_notifications(
|
||||
notification_channel: types.TestContainerDocker,
|
||||
maildev: types.TestContainerDocker,
|
||||
missing: list[types.NotificationValidation],
|
||||
) -> dict:
|
||||
received = {}
|
||||
if any(v.destination_type == "webhook" for v in missing):
|
||||
webhook_bodies = []
|
||||
for validation in missing:
|
||||
if validation.destination_type != "webhook":
|
||||
continue
|
||||
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
|
||||
try:
|
||||
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
|
||||
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
|
||||
except requests.exceptions.RequestException as exc:
|
||||
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
|
||||
received["webhook"] = webhook_bodies
|
||||
if any(v.destination_type == "email" for v in missing):
|
||||
received["email"] = get_all_mails(maildev)
|
||||
return received
|
||||
|
||||
|
||||
def update_raw_channel_config(
|
||||
channel_config: dict,
|
||||
channel_name: str,
|
||||
notification_channel: types.TestContainerDocker,
|
||||
) -> dict:
|
||||
"""
|
||||
Updates the channel config to point to the given wiremock
|
||||
notification_channel container to receive notifications.
|
||||
"""
|
||||
config = channel_config.copy()
|
||||
|
||||
config["name"] = channel_name
|
||||
|
||||
url_field_map = {
|
||||
"slack_configs": "api_url",
|
||||
"msteamsv2_configs": "webhook_url",
|
||||
"webhook_configs": "url",
|
||||
"pagerduty_configs": "url",
|
||||
"opsgenie_configs": "api_url",
|
||||
}
|
||||
|
||||
for config_key, url_field in url_field_map.items():
|
||||
if config_key in config:
|
||||
for entry in config[config_key]:
|
||||
if url_field in entry:
|
||||
original_url = entry[url_field]
|
||||
path = urlparse(original_url).path
|
||||
entry[url_field] = notification_channel.container_configs["8080"].get(path)
|
||||
|
||||
return config
|
||||
|
||||
2
tests/fixtures/auth.py
vendored
2
tests/fixtures/auth.py
vendored
@@ -77,7 +77,7 @@ def register_admin(
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, f"failed to register admin: {response.status_code} {response.text}"
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
return types.Operation(name="create_user_admin")
|
||||
|
||||
|
||||
10
tests/fixtures/http.py
vendored
10
tests/fixtures/http.py
vendored
@@ -125,19 +125,13 @@ def gateway(
|
||||
|
||||
@pytest.fixture(name="make_http_mocks", scope="function")
|
||||
def make_http_mocks() -> Callable[[types.TestContainerDocker, list[Mapping]], None]:
|
||||
mocked_containers = []
|
||||
|
||||
def _make_http_mocks(container: types.TestContainerDocker, mappings: list[Mapping]) -> None:
|
||||
Config.base_url = container.host_configs["8080"].get("/__admin")
|
||||
|
||||
for mapping in mappings:
|
||||
Mappings.create_mapping(mapping=mapping)
|
||||
|
||||
mocked_containers.append(container)
|
||||
|
||||
yield _make_http_mocks
|
||||
|
||||
for container in mocked_containers:
|
||||
Config.base_url = container.host_configs["8080"].get("/__admin")
|
||||
Mappings.delete_all_mappings()
|
||||
Requests.reset_request_journal()
|
||||
Mappings.delete_all_mappings()
|
||||
Requests.reset_request_journal()
|
||||
|
||||
143
tests/fixtures/maildev.py
vendored
143
tests/fixtures/maildev.py
vendored
@@ -1,143 +0,0 @@
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from testcontainers.core.container import DockerContainer, Network
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MAILDEV_INCOMING_USER = "apikey"
|
||||
MAILDEV_INCOMING_PASS = "integration-smtp-secret"
|
||||
|
||||
SMTP_TEST_FROM = "alertmanager@integration.test"
|
||||
|
||||
OLD_PROVIDER_SMTP_PASS = "old-provider-smtp-secret"
|
||||
NEW_PROVIDER_SMTP_PASS = "new-provider-smtp-secret"
|
||||
|
||||
|
||||
def signoz_smtp_env(maildev: "types.TestContainerDocker", password: str = MAILDEV_INCOMING_PASS) -> dict:
|
||||
return {
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__SMARTHOST": f"{maildev.container_configs['1025'].address}:{maildev.container_configs['1025'].port}",
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__FROM": SMTP_TEST_FROM,
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__AUTH__USERNAME": MAILDEV_INCOMING_USER,
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__AUTH__PASSWORD": password,
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__REQUIRE__TLS": "false",
|
||||
}
|
||||
|
||||
|
||||
def create_maildev(
|
||||
network: Network,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "maildev",
|
||||
incoming_user: str = MAILDEV_INCOMING_USER,
|
||||
incoming_pass: str = MAILDEV_INCOMING_PASS,
|
||||
) -> types.TestContainerDocker:
|
||||
def create() -> types.TestContainerDocker:
|
||||
container = DockerContainer(image="maildev/maildev:2.2.1")
|
||||
container.with_env("MAILDEV_INCOMING_USER", incoming_user)
|
||||
container.with_env("MAILDEV_INCOMING_PASS", incoming_pass)
|
||||
container.with_exposed_ports(1025, 1080)
|
||||
container.with_network(network=network)
|
||||
container.start()
|
||||
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
host_configs={
|
||||
"1025": types.TestContainerUrlConfig(
|
||||
scheme="smtp",
|
||||
address=container.get_container_host_ip(),
|
||||
port=container.get_exposed_port(1025),
|
||||
),
|
||||
"1080": types.TestContainerUrlConfig(
|
||||
scheme="http",
|
||||
address=container.get_container_host_ip(),
|
||||
port=container.get_exposed_port(1080),
|
||||
),
|
||||
},
|
||||
container_configs={
|
||||
"1025": types.TestContainerUrlConfig(
|
||||
scheme="smtp",
|
||||
address=container.get_wrapped_container().name,
|
||||
port=1025,
|
||||
),
|
||||
"1080": types.TestContainerUrlConfig(
|
||||
scheme="http",
|
||||
address=container.get_wrapped_container().name,
|
||||
port=1080,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.containers.get(container_id=container.id).stop()
|
||||
client.containers.get(container_id=container.id).remove(v=True)
|
||||
except docker.errors.NotFound:
|
||||
logger.info(
|
||||
"Skipping removal of MailDev, MailDev(%s) not found. Maybe it was manually removed?",
|
||||
{"id": container.id},
|
||||
)
|
||||
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
cache_key,
|
||||
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="maildev", scope="package")
|
||||
def maildev(network: Network, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.TestContainerDocker:
|
||||
return create_maildev(network, request, pytestconfig)
|
||||
|
||||
|
||||
def get_all_mails(_maildev: types.TestContainerDocker) -> list[dict]:
|
||||
url = _maildev.host_configs["1080"].get("/email")
|
||||
response = requests.get(url, timeout=5)
|
||||
assert response.status_code == HTTPStatus.OK, f"Failed to fetch emails from MailDev, status code: {response.status_code}, response: {response.text}"
|
||||
|
||||
def addresses(entries: list[dict]) -> str:
|
||||
return ",".join(sorted(entry.get("address", "") for entry in entries))
|
||||
|
||||
return [
|
||||
{
|
||||
"subject": email.get("subject", ""),
|
||||
"html": email.get("html", ""),
|
||||
"text": email.get("text", ""),
|
||||
"from": addresses(email.get("from", [])),
|
||||
"to": addresses(email.get("to", [])),
|
||||
}
|
||||
for email in response.json()
|
||||
]
|
||||
|
||||
|
||||
def verify_email_received(_maildev: types.TestContainerDocker, filters: dict) -> bool:
|
||||
def matches(expected, actual: str) -> bool:
|
||||
if isinstance(expected, re.Pattern):
|
||||
return expected.search(actual) is not None
|
||||
return expected == actual
|
||||
|
||||
for email in get_all_mails(_maildev):
|
||||
if all(key in email and matches(filter_value, email[key]) for key, filter_value in filters.items()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def delete_all_mails(_maildev: types.TestContainerDocker) -> None:
|
||||
url = _maildev.host_configs["1080"].get("/email/all")
|
||||
response = requests.delete(url, timeout=5)
|
||||
assert response.status_code == HTTPStatus.OK, f"Failed to delete emails from MailDev, status code: {response.status_code}, response: {response.text}"
|
||||
159
tests/fixtures/notification_channel.py
vendored
159
tests/fixtures/notification_channel.py
vendored
@@ -1,6 +1,3 @@
|
||||
# pylint: disable=line-too-long
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
@@ -14,116 +11,10 @@ from wiremock.testing.testcontainer import WireMockContainer
|
||||
from fixtures import reuse, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
EMAIL_TRANSPORT_KEYS = [
|
||||
"from",
|
||||
"hello",
|
||||
"smarthost",
|
||||
"auth_username",
|
||||
"auth_password",
|
||||
"auth_password_file",
|
||||
"auth_secret",
|
||||
"auth_secret_file",
|
||||
"auth_identity",
|
||||
"require_tls",
|
||||
"tls_config",
|
||||
"force_implicit_tls",
|
||||
]
|
||||
|
||||
|
||||
def assert_email_channel_payload_clean(payload: str) -> None:
|
||||
receiver = json.loads(payload)
|
||||
for email_config in receiver["email_configs"]:
|
||||
transport_keys = set(email_config.keys()) & set(EMAIL_TRANSPORT_KEYS)
|
||||
transport_keys -= {"smarthost"} if email_config.get("smarthost", "") == "" else set()
|
||||
assert not transport_keys, f"email channel payload carries transport keys {transport_keys}: {payload}"
|
||||
|
||||
assert MAILDEV_INCOMING_PASS not in payload
|
||||
assert SMTP_TEST_FROM not in payload
|
||||
|
||||
|
||||
"""
|
||||
Default notification channel configs shared across alertmanager tests.
|
||||
"""
|
||||
slack_default_config = {
|
||||
# channel name configured on runtime
|
||||
"slack_configs": [
|
||||
{
|
||||
"api_url": "services/TEAM_ID/BOT_ID/TOKEN_ID", # base_url configured on runtime
|
||||
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
|
||||
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
msteams_default_config = {
|
||||
"msteamsv2_configs": [
|
||||
{
|
||||
"webhook_url": "msteams/webhook_url", # base_url configured on runtime
|
||||
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
|
||||
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
pagerduty_default_config = {
|
||||
"pagerduty_configs": [
|
||||
{
|
||||
"routing_key": "PagerDutyRoutingKey",
|
||||
"url": "v2/enqueue", # base_url configured on runtime
|
||||
"client": "SigNoz Alert Manager",
|
||||
"client_url": "https://enter-signoz-host-n-port-here/alerts",
|
||||
"description": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n\t{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n\t {{" "}}(\n\t {{- with .CommonLabels.Remove .GroupLabels.Names }}\n\t\t{{- range $index, $label := .SortedPairs -}}\n\t\t {{ if $index }}, {{ end }}\n\t\t {{- $label.Name }}="{{ $label.Value -}}"\n\t\t{{- end }}\n\t {{- end -}}\n\t )\n\t{{- end }}',
|
||||
"details": {
|
||||
"firing": '{{ template "pagerduty.default.instances" .Alerts.Firing }}',
|
||||
"num_firing": "{{ .Alerts.Firing | len }}",
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": '{{ template "pagerduty.default.instances" .Alerts.Resolved }}',
|
||||
},
|
||||
"source": "SigNoz Alert Manager",
|
||||
"severity": "{{ (index .Alerts 0).Labels.severity }}",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
opsgenie_default_config = {
|
||||
"opsgenie_configs": [
|
||||
{
|
||||
"api_key": "OpsGenieAPIKey",
|
||||
"api_url": "/", # base_url configured on runtime
|
||||
"description": '{{ if gt (len .Alerts.Firing) 0 -}}\r\n\tAlerts Firing:\r\n\t{{ range .Alerts.Firing }}\r\n\t - Message: {{ .Annotations.description }}\r\n\tLabels:\r\n\t{{ range .Labels.SortedPairs -}}\r\n\t\t{{- if ne .Name "ruleId" }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end -}}\r\n\t{{- end }} Annotations:\r\n\t{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end }} Source: {{ .GeneratorURL }}\r\n\t{{ end }}\r\n{{- end }}\r\n{{ if gt (len .Alerts.Resolved) 0 -}}\r\n\tAlerts Resolved:\r\n\t{{ range .Alerts.Resolved }}\r\n\t - Message: {{ .Annotations.description }}\r\n\tLabels:\r\n\t{{ range .Labels.SortedPairs -}}\r\n\t\t{{- if ne .Name "ruleId" }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end -}}\r\n\t{{- end }} Annotations:\r\n\t{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end }} Source: {{ .GeneratorURL }}\r\n\t{{ end }}\r\n{{- end }}',
|
||||
"priority": '{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
|
||||
"message": "{{ .CommonLabels.alertname }}",
|
||||
"details": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
webhook_default_config = {
|
||||
"webhook_configs": [
|
||||
{
|
||||
"url": "webhook/webhook_url", # base_url configured on runtime
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
email_default_config = {
|
||||
"email_configs": [
|
||||
{
|
||||
"to": "test@example.com",
|
||||
"html": '<html><body>{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}</body></html>',
|
||||
"headers": {
|
||||
"Subject": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}'
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="notification_channel", scope="package")
|
||||
def notification_channel(
|
||||
network: Network,
|
||||
@@ -176,40 +67,6 @@ def notification_channel(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="create_notification_channel", scope="function")
|
||||
def create_notification_channel(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> Callable[[dict], str]:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
channel_ids = []
|
||||
|
||||
def _create_notification_channel(channel_config: dict) -> str:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json=channel_config,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, f"Failed to create channel, Response: {response.text} Response status: {response.status_code}"
|
||||
channel_id = response.json()["data"]["id"]
|
||||
channel_ids.append(channel_id)
|
||||
return channel_id
|
||||
|
||||
yield _create_notification_channel
|
||||
|
||||
for channel_id in channel_ids:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
if response.status_code != HTTPStatus.NO_CONTENT:
|
||||
logger.error("Failed to delete channel: %s", {"channel_id": channel_id, "status": response.status_code, "response": response.text})
|
||||
|
||||
|
||||
@pytest.fixture(name="create_webhook_notification_channel", scope="function")
|
||||
def create_webhook_notification_channel(
|
||||
signoz: types.SigNoz,
|
||||
@@ -246,19 +103,3 @@ def create_webhook_notification_channel(
|
||||
return channel_id
|
||||
|
||||
return _create_webhook_notification_channel
|
||||
|
||||
|
||||
def send_test_notification(signoz: types.SigNoz, token: str, receiver: dict, wait_seconds: int = 90) -> None:
|
||||
deadline = time.time() + wait_seconds
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if last.status_code == HTTPStatus.NO_CONTENT:
|
||||
return
|
||||
time.sleep(2)
|
||||
raise AssertionError(f"test notification did not succeed within {wait_seconds}s, last response: {last.status_code} {last.text}")
|
||||
|
||||
37
tests/fixtures/types.py
vendored
37
tests/fixtures/types.py
vendored
@@ -197,40 +197,3 @@ class AlertTestCase:
|
||||
alert_data: list[AlertData]
|
||||
# list of alert expectations for the test case
|
||||
alert_expectation: AlertExpectation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationValidation:
|
||||
# destination type of the notification, either webhook or email
|
||||
# slack, msteams, pagerduty, opsgenie, webhook channels send notifications through webhook
|
||||
# email channels send notifications through email
|
||||
destination_type: Literal["webhook", "email"]
|
||||
# validation data for validating the received notification payload
|
||||
validation_data: dict[str, any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AMNotificationExpectation:
|
||||
# whether we expect any notifications to be fired or not, false when testing downtime scenarios
|
||||
# or don't expect any notifications to be fired in given time period
|
||||
should_notify: bool
|
||||
# seconds to wait for the notifications to be fired, if no
|
||||
# notifications are fired in the expected time, the test will fail
|
||||
wait_time_seconds: int
|
||||
# list of notifications to expect, as a single rule can trigger multiple notifications
|
||||
# spanning across different notifiers
|
||||
notification_validations: list[NotificationValidation]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlertManagerNotificationTestCase:
|
||||
# name of the test case
|
||||
name: str
|
||||
# path to the rule file in testdata directory
|
||||
rule_path: str
|
||||
# list of alert data that will be inserted into the database for the rule to be triggered
|
||||
alert_data: list[AlertData]
|
||||
# configuration for the notification channel
|
||||
channel_config: dict[str, any]
|
||||
# notification expectations for the test case
|
||||
notification_expectation: AMNotificationExpectation
|
||||
|
||||
@@ -39,7 +39,5 @@ def test_teardown(
|
||||
idp: types.TestContainerIDP, # pylint: disable=unused-argument
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
migrator: types.Operation, # pylint: disable=unused-argument
|
||||
maildev: types.TestContainerDocker, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "User login successful", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "Database connection established", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: insufficient funds", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "API request received", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:04:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:04:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:05:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:05:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:06:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:06:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: insufficient funds", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:07:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:07:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:08:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "Response sent to client", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:08:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:09:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
|
||||
{ "timestamp": "2026-01-29T10:10:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"alert": "content_templating_logs",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"filter": {
|
||||
"expression": "body CONTAINS 'payment failure'"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{"name": "service.name", "fieldContext": "resource"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "Payment failure spike detected on $service_name",
|
||||
"summary": "Payment failures elevated on $service_name"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:01:00+00:00","value":80,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:02:00+00:00","value":95,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:03:00+00:00","value":110,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:04:00+00:00","value":120,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:05:00+00:00","value":125,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:06:00+00:00","value":130,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:07:00+00:00","value":135,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:08:00+00:00","value":140,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:09:00+00:00","value":145,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:10:00+00:00","value":150,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:11:00+00:00","value":155,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:12:00+00:00","value":160,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"alert": "content_templating_metrics",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 100,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "container_memory_bytes_content_templating",
|
||||
"timeAggregation": "avg",
|
||||
"spaceAggregation": "max"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{"name": "namespace", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
{"name": "pod", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
{"name": "container", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
{"name": "node", "fieldContext": "attribute", "fieldDataType": "string"},
|
||||
{"name": "severity", "fieldContext": "attribute", "fieldDataType": "string"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "Container $container in pod $pod ($namespace) exceeded memory threshold",
|
||||
"summary": "High container memory in $namespace/$pod"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{ "timestamp": "2026-01-29T10:00:00.000000Z", "duration": "PT1.2S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a1", "span_id": "c1b2c3d4e5f6a7b8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:00:30.000000Z", "duration": "PT1.4S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a2", "span_id": "c2b3c4d5e6f7a8b9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:01:00.000000Z", "duration": "PT1.6S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a3", "span_id": "c3b4c5d6e7f8a9b0", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:01:30.000000Z", "duration": "PT1.8S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a4", "span_id": "c4b5c6d7e8f9a0b1", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:02:00.000000Z", "duration": "PT2.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a5", "span_id": "c5b6c7d8e9f0a1b2", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:02:30.000000Z", "duration": "PT2.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a6", "span_id": "c6b7c8d9e0f1a2b3", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:03:00.000000Z", "duration": "PT2.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a7", "span_id": "c7b8c9d0e1f2a3b4", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:03:30.000000Z", "duration": "PT2.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a8", "span_id": "c8b9c0d1e2f3a4b5", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:04:00.000000Z", "duration": "PT2.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a9", "span_id": "c9b0c1d2e3f4a5b6", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:04:30.000000Z", "duration": "PT3.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b1", "span_id": "d1c2d3e4f5a6b7c8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:05:00.000000Z", "duration": "PT3.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b2", "span_id": "d2c3d4e5f6a7b8c9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:05:30.000000Z", "duration": "PT3.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b3", "span_id": "d3c4d5e6f7a8b9c0", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:06:00.000000Z", "duration": "PT3.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b4", "span_id": "d4c5d6e7f8a9b0c1", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:06:30.000000Z", "duration": "PT3.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b5", "span_id": "d5c6d7e8f9a0b1c2", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:07:00.000000Z", "duration": "PT4.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b6", "span_id": "d6c7d8e9f0a1b2c3", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:07:30.000000Z", "duration": "PT4.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b7", "span_id": "d7c8d9e0f1a2b3c4", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:08:00.000000Z", "duration": "PT4.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b8", "span_id": "d8c9d0e1f2a3b4c5", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:08:30.000000Z", "duration": "PT4.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b9", "span_id": "d9c0d1e2f3a4b5c6", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:09:00.000000Z", "duration": "PT4.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6c1", "span_id": "e1d2e3f4a5b6c7d8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
{ "timestamp": "2026-01-29T10:10:00.000000Z", "duration": "PT5.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6c2", "span_id": "e2d3e4f5a6b7c8d9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"alert": "content_templating_traces",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "TRACES_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 1,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"channels": [
|
||||
"test channel"
|
||||
],
|
||||
"targetUnit": "s"
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"unit": "ns",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {
|
||||
"expression": "http.request.path = '/checkout'"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "p90(duration_nano)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{"name": "service.name", "fieldContext": "resource"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "p90 latency high on $service_name",
|
||||
"summary": "p90 latency exceeded threshold on $service_name"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import text
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.maildev import (
|
||||
MAILDEV_INCOMING_PASS,
|
||||
SMTP_TEST_FROM,
|
||||
delete_all_mails,
|
||||
verify_email_received,
|
||||
)
|
||||
from fixtures.notification_channel import assert_email_channel_payload_clean, send_test_notification
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
CHANNEL_TYPE_CASES = [
|
||||
(
|
||||
"webhook",
|
||||
lambda sink: {"webhook_configs": [{"url": sink.container_configs["8080"].get("/webhook/crud-original"), "send_resolved": True}]},
|
||||
lambda sink: {"webhook_configs": [{"url": sink.container_configs["8080"].get("/webhook/crud-updated"), "send_resolved": True}]},
|
||||
"crud-original",
|
||||
"crud-updated",
|
||||
),
|
||||
(
|
||||
"slack",
|
||||
lambda sink: {"slack_configs": [{"api_url": sink.container_configs["8080"].get("/services/T/B/X"), "channel": "#crud-original"}]},
|
||||
lambda sink: {"slack_configs": [{"api_url": sink.container_configs["8080"].get("/services/T/B/X"), "channel": "#crud-updated"}]},
|
||||
"#crud-original",
|
||||
"#crud-updated",
|
||||
),
|
||||
(
|
||||
"pagerduty",
|
||||
lambda sink: {"pagerduty_configs": [{"routing_key": "crud-original-routing-key"}]},
|
||||
lambda sink: {"pagerduty_configs": [{"routing_key": "crud-updated-routing-key"}]},
|
||||
"crud-original-routing-key",
|
||||
"crud-updated-routing-key",
|
||||
),
|
||||
(
|
||||
"opsgenie",
|
||||
lambda sink: {"opsgenie_configs": [{"api_key": "crud-original-api-key", "message": "{{ .CommonLabels.alertname }}"}]},
|
||||
lambda sink: {"opsgenie_configs": [{"api_key": "crud-updated-api-key", "message": "{{ .CommonLabels.alertname }}"}]},
|
||||
"crud-original-api-key",
|
||||
"crud-updated-api-key",
|
||||
),
|
||||
(
|
||||
"msteamsv2",
|
||||
lambda sink: {"msteamsv2_configs": [{"webhook_url": sink.container_configs["8080"].get("/msteams/crud-original")}]},
|
||||
lambda sink: {"msteamsv2_configs": [{"webhook_url": sink.container_configs["8080"].get("/msteams/crud-updated")}]},
|
||||
"crud-original",
|
||||
"crud-updated",
|
||||
),
|
||||
(
|
||||
"email",
|
||||
lambda sink: {"email_configs": [{"to": "crud-original@integration.test"}]},
|
||||
lambda sink: {"email_configs": [{"to": "crud-updated@integration.test"}]},
|
||||
"crud-original@integration.test",
|
||||
"crud-updated@integration.test",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"channel_type,make_config,make_updated_config,created_marker,updated_marker",
|
||||
CHANNEL_TYPE_CASES,
|
||||
ids=[case[0] for case in CHANNEL_TYPE_CASES],
|
||||
)
|
||||
def test_channel_crud( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
channel_type: str,
|
||||
make_config: Callable[[types.TestContainerDocker], dict],
|
||||
make_updated_config: Callable[[types.TestContainerDocker], dict],
|
||||
created_marker: str,
|
||||
updated_marker: str,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"crud-{channel_type}-{uuid.uuid4()}"
|
||||
|
||||
config = {"name": name, **make_config(notification_channel)}
|
||||
response = requests.post(signoz.self.host_configs["8080"].get("/api/v1/channels"), json=config, headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
created = response.json()["data"]
|
||||
channel_id = created["id"]
|
||||
assert created["name"] == name
|
||||
assert created["type"] == channel_type
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/channels"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
listed = {channel["name"]: channel for channel in response.json()["data"]}
|
||||
assert name in listed
|
||||
assert listed[name]["type"] == channel_type
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert created_marker in response.json()["data"]["data"]
|
||||
|
||||
updated_config = {"name": name, **make_updated_config(notification_channel)}
|
||||
response = requests.put(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), json=updated_config, headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]["data"]
|
||||
assert updated_marker in data
|
||||
assert created_marker not in data
|
||||
|
||||
response = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
|
||||
def test_create_rejects_duplicate_name(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"duplicate-{uuid.uuid4()}"
|
||||
|
||||
create_notification_channel({"name": name, "email_configs": [{"to": "first@integration.test"}]})
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": name, "email_configs": [{"to": "second@integration.test"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "unique" in response.text
|
||||
|
||||
|
||||
def test_create_rejects_channel_without_configs(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": f"empty-{uuid.uuid4()}"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "notification configuration" in response.text
|
||||
|
||||
|
||||
def test_update_rejects_name_change(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"rename-{uuid.uuid4()}"
|
||||
channel_id = create_notification_channel({"name": name, "email_configs": [{"to": "rename@integration.test"}]})
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"),
|
||||
json={"name": f"{name}-renamed", "email_configs": [{"to": "rename@integration.test"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "cannot update channel name" in response.text
|
||||
|
||||
|
||||
def test_channels_require_authentication(signoz: types.SigNoz) -> None:
|
||||
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/channels"), timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.UNAUTHORIZED, response.text
|
||||
|
||||
|
||||
def test_email_channel_never_stores_or_serves_smtp_settings(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
hostile_name = f"hostile-email-{uuid.uuid4()}"
|
||||
hostile_config = {
|
||||
"name": hostile_name,
|
||||
"email_configs": [
|
||||
{
|
||||
"to": "hostile@integration.test",
|
||||
"from": "spoofed@integration.test",
|
||||
"hello": "attacker.test",
|
||||
"smarthost": "smtp.attacker.test:2525",
|
||||
"auth_username": "attacker",
|
||||
"auth_password": "tenant-posted-secret",
|
||||
"require_tls": False,
|
||||
"headers": {"Subject": "hostile subject"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = requests.post(signoz.self.host_configs["8080"].get("/api/v1/channels"), json=hostile_config, headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
created = response.json()["data"]
|
||||
assert_email_channel_payload_clean(created["data"])
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/channels/{created['id']}"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
served = response.json()["data"]["data"]
|
||||
assert_email_channel_payload_clean(served)
|
||||
assert "hostile@integration.test" in served
|
||||
assert "hostile subject" in served
|
||||
assert "smtp.attacker.test" not in served
|
||||
assert "tenant-posted-secret" not in served
|
||||
|
||||
response = requests.get(signoz.self.host_configs["8080"].get("/api/v1/channels"), headers={"Authorization": f"Bearer {token}"}, timeout=TIMEOUT)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert "tenant-posted-secret" not in response.text
|
||||
assert MAILDEV_INCOMING_PASS not in response.text
|
||||
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
stored = conn.execute(
|
||||
text("SELECT data FROM notification_channel WHERE name = :name"),
|
||||
{"name": hostile_name},
|
||||
).fetchone()
|
||||
assert stored is not None
|
||||
assert_email_channel_payload_clean(stored[0])
|
||||
assert "tenant-posted-secret" not in stored[0]
|
||||
|
||||
configs = conn.execute(text("SELECT config FROM alertmanager_config")).fetchall()
|
||||
assert len(configs) > 0
|
||||
for (config_raw,) in configs:
|
||||
assert MAILDEV_INCOMING_PASS not in config_raw
|
||||
assert "tenant-posted-secret" not in config_raw
|
||||
assert '"smtp_auth_password"' not in config_raw
|
||||
assert '"auth_password"' not in config_raw
|
||||
|
||||
|
||||
def test_email_test_channel_delivers_via_env_transport(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
delete_all_mails(maildev)
|
||||
|
||||
recipient = f"delivery-{uuid.uuid4()}@integration.test"
|
||||
send_test_notification(
|
||||
signoz,
|
||||
token,
|
||||
{"name": f"delivery-{uuid.uuid4()}", "email_configs": [{"to": recipient}]},
|
||||
)
|
||||
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if verify_email_received(maildev, {"to": recipient, "from": SMTP_TEST_FROM}):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"no email delivered to {recipient} from {SMTP_TEST_FROM}")
|
||||
@@ -1,360 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.maildev import delete_all_mails
|
||||
from fixtures.notification_channel import (
|
||||
email_default_config,
|
||||
msteams_default_config,
|
||||
opsgenie_default_config,
|
||||
pagerduty_default_config,
|
||||
slack_default_config,
|
||||
webhook_default_config,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
NOTIFIERS_TEST = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="slack_notifier_default_templating",
|
||||
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=slack_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
# extra wait for alertmanager server setup
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/services/TEAM_ID/BOT_ID/TOKEN_ID",
|
||||
"json_body": {
|
||||
"username": "Alertmanager",
|
||||
"attachments": [
|
||||
{
|
||||
"color": "danger",
|
||||
"mrkdwn_in": ["fallback", "pretext", "text"],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="msteams_notifier_default_templating",
|
||||
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=msteams_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/msteams/webhook_url",
|
||||
"json_body": {
|
||||
"type": "message",
|
||||
"attachments": [
|
||||
{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"content": {
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.2",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Alerts",
|
||||
"weight": "Bolder",
|
||||
"size": "Medium",
|
||||
"wrap": True,
|
||||
"color": "Attention",
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Labels",
|
||||
"weight": "Bolder",
|
||||
"size": "Medium",
|
||||
},
|
||||
{
|
||||
"type": "FactSet",
|
||||
"text": "",
|
||||
"facts": [
|
||||
{
|
||||
"title": "threshold.name",
|
||||
"value": "critical",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Annotations",
|
||||
"weight": "Bolder",
|
||||
"size": "Medium",
|
||||
},
|
||||
{
|
||||
"type": "FactSet",
|
||||
"text": "",
|
||||
"facts": [
|
||||
{
|
||||
"title": "description",
|
||||
"value": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"msteams": {"width": "full"},
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "View Alert",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="pagerduty_notifier_default_templating",
|
||||
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=pagerduty_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v2/enqueue",
|
||||
"json_body": {
|
||||
"routing_key": "PagerDutyRoutingKey",
|
||||
"event_action": "trigger",
|
||||
"payload": {
|
||||
"source": "SigNoz Alert Manager",
|
||||
"severity": "critical",
|
||||
"custom_details": {
|
||||
"firing": {
|
||||
"Annotations": [
|
||||
{"description = This alert is fired when the defined metric (current value": "15) crosses the threshold (10)"},
|
||||
],
|
||||
"Labels": [
|
||||
"alertname = threshold_above_at_least_once",
|
||||
"severity = critical",
|
||||
"threshold.name = critical",
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"client": "SigNoz Alert Manager",
|
||||
"client_url": "https://enter-signoz-host-n-port-here/alerts",
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="opsgenie_notifier_default_templating",
|
||||
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=opsgenie_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v2/alerts",
|
||||
"json_body": {
|
||||
"message": "threshold_above_at_least_once",
|
||||
"details": {
|
||||
"alertname": "threshold_above_at_least_once",
|
||||
"severity": "critical",
|
||||
"threshold.name": "critical",
|
||||
},
|
||||
"priority": "P1",
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="webhook_notifier_default_templating",
|
||||
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=webhook_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/webhook/webhook_url",
|
||||
"json_body": {
|
||||
"status": "firing",
|
||||
"alerts": [
|
||||
{
|
||||
"status": "firing",
|
||||
"labels": {
|
||||
"alertname": "threshold_above_at_least_once",
|
||||
"severity": "critical",
|
||||
"threshold.name": "critical",
|
||||
},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
|
||||
"summary": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
|
||||
},
|
||||
}
|
||||
],
|
||||
"commonLabels": {
|
||||
"alertname": "threshold_above_at_least_once",
|
||||
"severity": "critical",
|
||||
"threshold.name": "critical",
|
||||
},
|
||||
"commonAnnotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
|
||||
"summary": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="email_notifier_default_templating",
|
||||
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=email_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="email",
|
||||
validation_data={
|
||||
"subject": re.compile(r'\[FIRING:1\] threshold_above_at_least_once for \(alertname="threshold_above_at_least_once", ruleSource="http://localhost:8080/alerts/overview\?ruleId=[0-9a-f-]+", severity="critical", threshold\.name="critical"\)'),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"notifier_test_case",
|
||||
NOTIFIERS_TEST,
|
||||
ids=lambda notifier_test_case: notifier_test_case.name,
|
||||
)
|
||||
def test_notifier_templating( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
notifier_test_case: types.AlertManagerNotificationTestCase,
|
||||
):
|
||||
channel_name = str(uuid.uuid4())
|
||||
|
||||
channel_config = update_raw_channel_config(notifier_test_case.channel_config, channel_name, notification_channel)
|
||||
logger.info("Channel config: %s", {"channel_config": channel_config})
|
||||
|
||||
webhook_validations = [v for v in notifier_test_case.notification_expectation.notification_validations if v.destination_type == "webhook"]
|
||||
if len(webhook_validations) > 0:
|
||||
mock_mappings = [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url=v.validation_data["path"]),
|
||||
response=MappingResponse(status=200, json_body={}),
|
||||
persistent=False,
|
||||
)
|
||||
for v in webhook_validations
|
||||
]
|
||||
|
||||
make_http_mocks(notification_channel, mock_mappings)
|
||||
logger.info("Mock mappings created")
|
||||
|
||||
if any(v.destination_type == "email" for v in notifier_test_case.notification_expectation.notification_validations):
|
||||
delete_all_mails(maildev)
|
||||
logger.info("Mails deleted")
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
logger.info("Channel created with name: %s", {"channel_name": channel_name})
|
||||
|
||||
time.sleep(12)
|
||||
|
||||
insert_alert_data(
|
||||
notifier_test_case.alert_data,
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
|
||||
rule_path = get_testdata_file_path(notifier_test_case.rule_path)
|
||||
with open(rule_path, encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
rule_id = create_alert_rule(rule_data)
|
||||
logger.info("rule created: %s", {"rule_id": rule_id, "rule_name": rule_data["alert"]})
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
notifier_test_case.notification_expectation,
|
||||
)
|
||||
@@ -1,332 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.maildev import delete_all_mails
|
||||
from fixtures.notification_channel import (
|
||||
msteams_default_config,
|
||||
opsgenie_default_config,
|
||||
pagerduty_default_config,
|
||||
slack_default_config,
|
||||
webhook_default_config,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
CONTENT_TEMPLATING_TEST = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="msteams_metrics_default_templating",
|
||||
rule_path="alertmanager/content_templating/metrics_rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alertmanager/content_templating/metrics_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=msteams_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/msteams/webhook_url",
|
||||
"json_body": {
|
||||
"type": "message",
|
||||
"attachments": [
|
||||
{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
"content": {
|
||||
"type": "AdaptiveCard",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": re.compile(
|
||||
r'\[FIRING:1\] content_templating_metrics for \(alertname="content_templating_metrics", container="checkout", namespace="production", node="ip-10-0-1-23", pod="checkout-7d9c8b5f4-x2k9p", ruleSource="http://localhost:8080/alerts/overview\?ruleId=[0-9a-f-]+", severity="critical", threshold\.name="critical"\)'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="opsgenie_metrics_default_templating",
|
||||
rule_path="alertmanager/content_templating/metrics_rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alertmanager/content_templating/metrics_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=opsgenie_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v2/alerts",
|
||||
"json_body": {
|
||||
"message": "content_templating_metrics",
|
||||
"details": {
|
||||
"alertname": "content_templating_metrics",
|
||||
"container": "checkout",
|
||||
"namespace": "production",
|
||||
"node": "ip-10-0-1-23",
|
||||
"pod": "checkout-7d9c8b5f4-x2k9p",
|
||||
"severity": "critical",
|
||||
"threshold.name": "critical",
|
||||
},
|
||||
"priority": "P1",
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="pagerduty_metrics_default_templating",
|
||||
rule_path="alertmanager/content_templating/metrics_rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alertmanager/content_templating/metrics_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=pagerduty_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v2/enqueue",
|
||||
"json_body": {
|
||||
"routing_key": "PagerDutyRoutingKey",
|
||||
"payload": {
|
||||
"severity": "critical",
|
||||
"custom_details": {
|
||||
"firing": {
|
||||
"Labels": [
|
||||
"alertname = content_templating_metrics",
|
||||
"container = checkout",
|
||||
"namespace = production",
|
||||
"node = ip-10-0-1-23",
|
||||
"pod = checkout-7d9c8b5f4-x2k9p",
|
||||
"severity = critical",
|
||||
"threshold.name = critical",
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
"client": "SigNoz Alert Manager",
|
||||
"client_url": "https://enter-signoz-host-n-port-here/alerts",
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="slack_logs_default_templating",
|
||||
rule_path="alertmanager/content_templating/logs_rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="logs",
|
||||
data_path="alertmanager/content_templating/logs_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=slack_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/services/TEAM_ID/BOT_ID/TOKEN_ID",
|
||||
"json_body": {
|
||||
"username": "Alertmanager",
|
||||
"attachments": [
|
||||
{
|
||||
"color": "danger",
|
||||
"mrkdwn_in": ["fallback", "pretext", "text"],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="slack_metrics_default_templating",
|
||||
rule_path="alertmanager/content_templating/metrics_rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alertmanager/content_templating/metrics_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=slack_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/services/TEAM_ID/BOT_ID/TOKEN_ID",
|
||||
"json_body": {
|
||||
"username": "Alertmanager",
|
||||
"attachments": [
|
||||
{
|
||||
"color": "danger",
|
||||
"mrkdwn_in": ["fallback", "pretext", "text"],
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="webhook_metrics_default_templating",
|
||||
rule_path="alertmanager/content_templating/metrics_rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alertmanager/content_templating/metrics_data.jsonl",
|
||||
),
|
||||
],
|
||||
channel_config=webhook_default_config,
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/webhook/webhook_url",
|
||||
"json_body": {
|
||||
"status": "firing",
|
||||
"alerts": [
|
||||
{
|
||||
"status": "firing",
|
||||
"labels": {
|
||||
"alertname": "content_templating_metrics",
|
||||
"container": "checkout",
|
||||
"namespace": "production",
|
||||
"node": "ip-10-0-1-23",
|
||||
"pod": "checkout-7d9c8b5f4-x2k9p",
|
||||
"severity": "critical",
|
||||
"threshold.name": "critical",
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Container checkout in pod checkout-7d9c8b5f4-x2k9p (production) exceeded memory threshold",
|
||||
"summary": "High container memory in production/checkout-7d9c8b5f4-x2k9p",
|
||||
},
|
||||
}
|
||||
],
|
||||
"commonLabels": {
|
||||
"alertname": "content_templating_metrics",
|
||||
"container": "checkout",
|
||||
"namespace": "production",
|
||||
"node": "ip-10-0-1-23",
|
||||
"pod": "checkout-7d9c8b5f4-x2k9p",
|
||||
"severity": "critical",
|
||||
"threshold.name": "critical",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content_templating_test_case",
|
||||
CONTENT_TEMPLATING_TEST,
|
||||
ids=lambda content_templating_test_case: content_templating_test_case.name,
|
||||
)
|
||||
def test_content_templating( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
content_templating_test_case: types.AlertManagerNotificationTestCase,
|
||||
):
|
||||
channel_name = str(uuid.uuid4())
|
||||
|
||||
channel_config = update_raw_channel_config(content_templating_test_case.channel_config, channel_name, notification_channel)
|
||||
logger.info("Channel config: %s", {"channel_config": channel_config})
|
||||
|
||||
webhook_validations = [v for v in content_templating_test_case.notification_expectation.notification_validations if v.destination_type == "webhook"]
|
||||
if len(webhook_validations) > 0:
|
||||
mock_mappings = [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url=v.validation_data["path"]),
|
||||
response=MappingResponse(status=200, json_body={}),
|
||||
persistent=False,
|
||||
)
|
||||
for v in webhook_validations
|
||||
]
|
||||
|
||||
make_http_mocks(notification_channel, mock_mappings)
|
||||
logger.info("Mock mappings created")
|
||||
|
||||
if any(v.destination_type == "email" for v in content_templating_test_case.notification_expectation.notification_validations):
|
||||
delete_all_mails(maildev)
|
||||
logger.info("Mails deleted")
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
logger.info("Channel created with name: %s", {"channel_name": channel_name})
|
||||
|
||||
time.sleep(12)
|
||||
|
||||
insert_alert_data(
|
||||
content_templating_test_case.alert_data,
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=10),
|
||||
)
|
||||
|
||||
rule_path = get_testdata_file_path(content_templating_test_case.rule_path)
|
||||
with open(rule_path, encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
rule_id = create_alert_rule(rule_data)
|
||||
logger.info("rule created: %s", {"rule_id": rule_id, "rule_name": rule_data["alert"]})
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
content_templating_test_case.notification_expectation,
|
||||
)
|
||||
@@ -1,35 +0,0 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.maildev import signoz_smtp_env
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
maildev: types.TestContainerDocker,
|
||||
notification_channel: types.TestContainerDocker,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_alertmanager",
|
||||
env_overrides={
|
||||
**signoz_smtp_env(maildev),
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_PAGERDUTY__URL": notification_channel.container_configs["8080"].get("/v2/enqueue"),
|
||||
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_OPSGENIE__API__URL": notification_channel.container_configs["8080"].get("/"),
|
||||
},
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import docker
|
||||
import pytest
|
||||
import requests
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, token_getter
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.maildev import (
|
||||
NEW_PROVIDER_SMTP_PASS,
|
||||
SMTP_TEST_FROM,
|
||||
delete_all_mails,
|
||||
get_all_mails,
|
||||
signoz_smtp_env,
|
||||
verify_email_received,
|
||||
)
|
||||
from fixtures.notification_channel import send_test_notification
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def wait_for_email(maildev: types.TestContainerDocker, filters: dict, wait_seconds: int = 30) -> None:
|
||||
deadline = time.time() + wait_seconds
|
||||
while time.time() < deadline:
|
||||
if verify_email_received(maildev, filters):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"no email matching {filters} within {wait_seconds}s, inbox: {get_all_mails(maildev)}")
|
||||
|
||||
|
||||
def test_smtp_rotation_applies_to_existing_channels( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
maildev_old: types.TestContainerDocker,
|
||||
maildev_new: types.TestContainerDocker,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
channel_name = f"rotation-{uuid.uuid4()}"
|
||||
recipient = f"rotation-{uuid.uuid4()}@integration.test"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": channel_name, "email_configs": [{"to": recipient}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
delete_all_mails(maildev_old)
|
||||
recipient_old_probe = f"probe-old-{uuid.uuid4()}@integration.test"
|
||||
send_test_notification(signoz, token, {"name": f"probe-{uuid.uuid4()}", "email_configs": [{"to": recipient_old_probe}]})
|
||||
wait_for_email(maildev_old, {"to": recipient_old_probe, "from": SMTP_TEST_FROM})
|
||||
logger.info("Delivery through the old provider verified")
|
||||
|
||||
docker.from_env().containers.get(signoz.self.id).stop()
|
||||
logger.info("Stopped signoz running against the old provider")
|
||||
|
||||
signoz_new = create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_smtp_rotation_new",
|
||||
env_overrides=signoz_smtp_env(maildev_new, password=NEW_PROVIDER_SMTP_PASS),
|
||||
)
|
||||
token_new = token_getter(signoz_new)(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz_new.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
headers={"Authorization": f"Bearer {token_new}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
listed = {channel["name"]: channel for channel in response.json()["data"]}
|
||||
assert channel_name in listed
|
||||
|
||||
delete_all_mails(maildev_new)
|
||||
mails_at_old_provider = len(get_all_mails(maildev_old))
|
||||
recipient_new_probe = f"probe-new-{uuid.uuid4()}@integration.test"
|
||||
send_test_notification(signoz_new, token_new, {"name": f"probe-{uuid.uuid4()}", "email_configs": [{"to": recipient_new_probe}]})
|
||||
wait_for_email(maildev_new, {"to": recipient_new_probe, "from": SMTP_TEST_FROM})
|
||||
assert len(get_all_mails(maildev_old)) == mails_at_old_provider, "old provider must receive nothing after rotation"
|
||||
@@ -1,40 +0,0 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.maildev import NEW_PROVIDER_SMTP_PASS, OLD_PROVIDER_SMTP_PASS, create_maildev, signoz_smtp_env
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="maildev_old", scope="package")
|
||||
def maildev_old(network: Network, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.TestContainerDocker:
|
||||
return create_maildev(network, request, pytestconfig, cache_key="maildev_smtp_old", incoming_pass=OLD_PROVIDER_SMTP_PASS)
|
||||
|
||||
|
||||
@pytest.fixture(name="maildev_new", scope="package")
|
||||
def maildev_new(network: Network, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.TestContainerDocker:
|
||||
return create_maildev(network, request, pytestconfig, cache_key="maildev_smtp_new", incoming_pass=NEW_PROVIDER_SMTP_PASS)
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
maildev_old: types.TestContainerDocker,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_smtp_rotation",
|
||||
env_overrides=signoz_smtp_env(maildev_old, password=OLD_PROVIDER_SMTP_PASS),
|
||||
)
|
||||
@@ -75,7 +75,3 @@ ignore = [
|
||||
|
||||
[tool.ruff.format]
|
||||
# Defaults align with black (double quotes, 4-space indent).
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"fixtures/notification_channel.py" = ["E501"]
|
||||
"integration/tests/alertmanager/*" = ["E501"]
|
||||
|
||||
Reference in New Issue
Block a user