mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-02 22:40:51 +00:00
Compare commits
8 Commits
issue_577
...
fix/ai-ass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6593fabc4d | ||
|
|
dba5784f77 | ||
|
|
4080c64411 | ||
|
|
2c590c6ceb | ||
|
|
5129467384 | ||
|
|
a02ea41446 | ||
|
|
c63f818789 | ||
|
|
6026e2372d |
@@ -15079,9 +15079,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- planned-maintenance:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- planned-maintenance:list
|
||||
summary: List downtime schedules
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15136,9 +15136,13 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- planned-maintenance:create
|
||||
- planned-maintenance:attach
|
||||
- rule:attach
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- planned-maintenance:create
|
||||
- planned-maintenance:attach
|
||||
- rule:attach
|
||||
summary: Create downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15183,9 +15187,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- planned-maintenance:delete
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- planned-maintenance:delete
|
||||
summary: Delete downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15241,9 +15245,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- planned-maintenance:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- planned-maintenance:read
|
||||
summary: Get downtime schedule by ID
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15298,9 +15302,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- planned-maintenance:update
|
||||
- planned-maintenance:attach
|
||||
- planned-maintenance:detach
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- planned-maintenance:update
|
||||
- planned-maintenance:attach
|
||||
- planned-maintenance:detach
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
summary: Update downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
@@ -591,7 +590,6 @@ export const listViewInitialTraceQuery: Query = {
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 10,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
} from 'api/ai-assistant/chat';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import {
|
||||
ArchiveRestore,
|
||||
@@ -47,6 +51,11 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { getTimeRangeFromActionQuery } from './utils/getTimeRangeFromActionQuery';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
@@ -273,6 +282,7 @@ interface ApplyFilterDeps {
|
||||
typeof useQueryBuilder
|
||||
>['redirectWithQueryBuilderData'];
|
||||
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
|
||||
setCustomTimeRange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,16 +343,33 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Read before toUrlCompositeQuery, which drops the requestType envelope.
|
||||
const panelType = getPanelTypeForRequestType(
|
||||
requestTypeFromActionQuery(action.query),
|
||||
action.signal,
|
||||
);
|
||||
const timeRange = getTimeRangeFromActionQuery(action.query);
|
||||
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] normalized', normalized);
|
||||
if (signalMatchesPathname(action.signal, deps.pathname)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
|
||||
if (timeRange) {
|
||||
deps.setCustomTimeRange(timeRange.start, timeRange.end);
|
||||
}
|
||||
normalized.builder.queryData.forEach((q, i) => {
|
||||
deps.handleSetQueryData(i, q);
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(normalized);
|
||||
deps.redirectWithQueryBuilderData(normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.start,
|
||||
[QueryParams.endTime]: timeRange.end,
|
||||
// relativeTime outranks startTime/endTime in the time picker, so drop it.
|
||||
[QueryParams.relativeTime]: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const base = explorerRouteForSignal(action.signal);
|
||||
@@ -353,8 +380,14 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → navigate', base);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
const url = buildExplorerNavigationUrl(base, normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange.start,
|
||||
[QueryParams.endTime]: timeRange.end,
|
||||
}),
|
||||
});
|
||||
navigate(url);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
@@ -387,6 +420,13 @@ export default function ActionsSection({
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
const dispatch = useDispatch();
|
||||
const setCustomTimeRange = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
dispatch(UpdateTimeInterval('custom', [start, end]));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Per-chip click state, keyed by chip key (see `key` below). Persists
|
||||
// loading/success/error so the chip reflects the rollback outcome until
|
||||
@@ -418,9 +458,16 @@ export default function ActionsSection({
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
});
|
||||
});
|
||||
}, [actions, pathname, redirectWithQueryBuilderData, handleSetQueryData]);
|
||||
}, [
|
||||
actions,
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
@@ -559,6 +606,7 @@ export default function ActionsSection({
|
||||
pathname,
|
||||
redirectWithQueryBuilderData,
|
||||
handleSetQueryData,
|
||||
setCustomTimeRange,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -613,6 +661,7 @@ export default function ActionsSection({
|
||||
disabled={isSuccess}
|
||||
loading={isLoading}
|
||||
aria-label={action.label}
|
||||
testId={`ai-action-chip-${action.kind}`}
|
||||
prefix={icon}
|
||||
>
|
||||
<span className={styles.chipLabel}>{action.label}</span>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import ActionsSection from '../ActionsSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('store/actions', () => {
|
||||
const actual =
|
||||
jest.requireActual<typeof import('store/actions')>('store/actions');
|
||||
return { ...actual, UpdateTimeInterval: jest.fn(actual.UpdateTimeInterval) };
|
||||
});
|
||||
|
||||
const mockUpdateTimeInterval = jest.mocked(UpdateTimeInterval);
|
||||
|
||||
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
|
||||
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
|
||||
|
||||
interface ActionOverrides {
|
||||
signal?: ApplyFilterSignalDTO;
|
||||
start?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
// Labels differ per test: auto-applied filters are deduped module-wide by label.
|
||||
function countByServiceAction(
|
||||
label: string,
|
||||
{ signal = ApplyFilterSignalDTO.logs, start, end }: ActionOverrides = {},
|
||||
): MessageActionDTO {
|
||||
return {
|
||||
kind: MessageActionKindDTO.apply_filter,
|
||||
label,
|
||||
signal,
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
start,
|
||||
end,
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentParam(param: string): string | null {
|
||||
return new URLSearchParams(getCurrentLocation().search).get(param);
|
||||
}
|
||||
|
||||
function renderOnLogsExplorer(action: MessageActionDTO): {
|
||||
redirectWithQueryBuilderData: jest.MockedFunction<
|
||||
QueryBuilderContextType['redirectWithQueryBuilderData']
|
||||
>;
|
||||
} {
|
||||
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
|
||||
QueryBuilderContextType['redirectWithQueryBuilderData']
|
||||
>;
|
||||
const handleSetQueryData = jest.fn() as jest.MockedFunction<
|
||||
QueryBuilderContextType['handleSetQueryData']
|
||||
>;
|
||||
render(
|
||||
<ActionsSection actions={[action]} messageId="message-on-page" />,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `${ROUTES.LOGS_EXPLORER}?${QueryParams.relativeTime}=6h`,
|
||||
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
|
||||
},
|
||||
);
|
||||
return { redirectWithQueryBuilderData };
|
||||
}
|
||||
|
||||
describe('ActionsSection apply_filter', () => {
|
||||
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[countByServiceAction('Open in Logs Explorer')]}
|
||||
messageId="message-1"
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTES.HOME },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
|
||||
);
|
||||
expect(currentParam(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(PANEL_TYPES.TABLE),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ApplyFilterSignalDTO.logs, ROUTES.LOGS_EXPLORER],
|
||||
[ApplyFilterSignalDTO.traces, ROUTES.TRACES_EXPLORER],
|
||||
[ApplyFilterSignalDTO.metrics, ROUTES.METRICS_EXPLORER_EXPLORER],
|
||||
])(
|
||||
'opens the %s explorer on the time range that produced the answer',
|
||||
async (signal, route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[
|
||||
countByServiceAction(`Open ${signal}`, {
|
||||
signal,
|
||||
start: START_MS,
|
||||
end: END_MS,
|
||||
}),
|
||||
]}
|
||||
messageId={`message-${signal}`}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTES.HOME },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
|
||||
|
||||
await waitFor(() => expect(getCurrentLocation().pathname).toBe(route));
|
||||
expect(currentParam(QueryParams.startTime)).toBe(String(START_MS));
|
||||
expect(currentParam(QueryParams.endTime)).toBe(String(END_MS));
|
||||
},
|
||||
);
|
||||
|
||||
it('still opens the explorer, without a time range, when the action has none', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[countByServiceAction('Open without time')]}
|
||||
messageId="message-no-time"
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTES.HOME },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
|
||||
);
|
||||
expect(currentParam(QueryParams.compositeQuery)).not.toBeNull();
|
||||
expect(currentParam(QueryParams.startTime)).toBeNull();
|
||||
expect(currentParam(QueryParams.endTime)).toBeNull();
|
||||
});
|
||||
|
||||
it('switches the open explorer to the Table view and the answer time range', async () => {
|
||||
mockUpdateTimeInterval.mockClear();
|
||||
const { redirectWithQueryBuilderData } = renderOnLogsExplorer(
|
||||
countByServiceAction('Apply with time', { start: START_MS, end: END_MS }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ builder: expect.anything() }),
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.TABLE,
|
||||
[QueryParams.startTime]: START_MS,
|
||||
[QueryParams.endTime]: END_MS,
|
||||
[QueryParams.relativeTime]: null,
|
||||
},
|
||||
);
|
||||
expect(mockUpdateTimeInterval).toHaveBeenCalledWith('custom', [
|
||||
START_MS,
|
||||
END_MS,
|
||||
]);
|
||||
expect(mockUpdateTimeInterval.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
redirectWithQueryBuilderData.mock.invocationCallOrder[0],
|
||||
);
|
||||
// The redirect is the only navigation, so Back still restores the old entry.
|
||||
expect(currentParam(QueryParams.relativeTime)).toBe('6h');
|
||||
});
|
||||
|
||||
it('keeps the open explorer time when the action has no time range', async () => {
|
||||
mockUpdateTimeInterval.mockClear();
|
||||
const { redirectWithQueryBuilderData } = renderOnLogsExplorer(
|
||||
countByServiceAction('Apply without time'),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ builder: expect.anything() }),
|
||||
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
|
||||
);
|
||||
expect(mockUpdateTimeInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from '../getPanelTypeForRequestType';
|
||||
|
||||
describe('getPanelTypeForRequestType', () => {
|
||||
it.each([
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.TABLE,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
ApplyFilterSignalDTO.traces,
|
||||
PANEL_TYPES.TRACE,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
ApplyFilterSignalDTO.metrics,
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
|
||||
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
Querybuildertypesv5RequestTypeDTO.raw_stream,
|
||||
Querybuildertypesv5RequestTypeDTO.heatmap,
|
||||
'distribution',
|
||||
'nonsense',
|
||||
'toString',
|
||||
'constructor',
|
||||
42,
|
||||
{},
|
||||
])(
|
||||
'falls back to the List view for unmapped or invalid requestType (%p)',
|
||||
(value) => {
|
||||
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('requestTypeFromActionQuery', () => {
|
||||
it('reads the requestType envelope field and returns undefined when absent', () => {
|
||||
expect(
|
||||
requestTypeFromActionQuery({
|
||||
requestType: 'scalar',
|
||||
schemaVersion: 'v5',
|
||||
compositeQuery: { queries: [] },
|
||||
}),
|
||||
).toBe('scalar');
|
||||
expect(requestTypeFromActionQuery({})).toBeUndefined();
|
||||
expect(requestTypeFromActionQuery(null)).toBeUndefined();
|
||||
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps a grouped count() payload to the Table view', () => {
|
||||
const query = {
|
||||
requestType: 'scalar',
|
||||
schemaVersion: 'v5',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
signal: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
getPanelTypeForRequestType(
|
||||
requestTypeFromActionQuery(query),
|
||||
ApplyFilterSignalDTO.logs,
|
||||
),
|
||||
).toBe(PANEL_TYPES.TABLE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getTimeRangeFromActionQuery } from '../getTimeRangeFromActionQuery';
|
||||
|
||||
const START_MS = Date.UTC(2026, 6, 26, 10, 34);
|
||||
const END_MS = Date.UTC(2026, 6, 26, 10, 49);
|
||||
|
||||
describe('getTimeRangeFromActionQuery', () => {
|
||||
it('returns the absolute range from the query envelope', () => {
|
||||
expect(
|
||||
getTimeRangeFromActionQuery({ start: START_MS, end: END_MS }),
|
||||
).toStrictEqual({ start: START_MS, end: END_MS });
|
||||
});
|
||||
|
||||
it('normalizes nanosecond timestamps to milliseconds', () => {
|
||||
expect(
|
||||
getTimeRangeFromActionQuery({
|
||||
start: START_MS * 1_000_000,
|
||||
end: END_MS * 1_000_000,
|
||||
}),
|
||||
).toStrictEqual({ start: START_MS, end: END_MS });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a missing query', undefined],
|
||||
['a null query', null],
|
||||
['missing timestamps', {}],
|
||||
['a missing end', { start: START_MS }],
|
||||
['string timestamps', { start: String(START_MS), end: String(END_MS) }],
|
||||
[
|
||||
'non-finite timestamps',
|
||||
{ start: Number.NaN, end: Number.POSITIVE_INFINITY },
|
||||
],
|
||||
['non-positive timestamps', { start: 0, end: -1 }],
|
||||
['an empty range', { start: START_MS, end: START_MS }],
|
||||
['an inverted range', { start: END_MS, end: START_MS }],
|
||||
])('returns null for %s', (_, query) => {
|
||||
expect(getTimeRangeFromActionQuery(query)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
type MessageActionDTOQuery,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export function getPanelTypeForRequestType(
|
||||
requestType: unknown,
|
||||
signal: ApplyFilterSignalDTO,
|
||||
): PANEL_TYPES {
|
||||
switch (requestType) {
|
||||
case Querybuildertypesv5RequestTypeDTO.scalar:
|
||||
return PANEL_TYPES.TABLE;
|
||||
case Querybuildertypesv5RequestTypeDTO.time_series:
|
||||
return PANEL_TYPES.TIME_SERIES;
|
||||
case Querybuildertypesv5RequestTypeDTO.trace:
|
||||
return signal === ApplyFilterSignalDTO.traces
|
||||
? PANEL_TYPES.TRACE
|
||||
: PANEL_TYPES.LIST;
|
||||
default:
|
||||
return PANEL_TYPES.LIST;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestTypeFromActionQuery(
|
||||
query: MessageActionDTOQuery | undefined,
|
||||
): unknown {
|
||||
return query?.requestType;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MessageActionDTOQuery } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
|
||||
export interface ActionTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function toTimestampMs(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return normalizeTimeToMs(Math.trunc(value));
|
||||
}
|
||||
|
||||
export function getTimeRangeFromActionQuery(
|
||||
query: MessageActionDTOQuery | undefined,
|
||||
): ActionTimeRange | null {
|
||||
const start = toTimestampMs(query?.start);
|
||||
const end = toTimestampMs(query?.end);
|
||||
if (start === null || end === null || end <= start) {
|
||||
return null;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormTraceOperatorValues,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import ListView from './index';
|
||||
|
||||
@@ -24,7 +30,7 @@ jest.mock('react-redux', () => {
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the query fires and the expected columns render, independent of that API.
|
||||
// so the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
@@ -35,6 +41,8 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
{ name: 'http.route', fieldContext: 'attribute' },
|
||||
{ name: 'cache.hit', fieldContext: 'attribute' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
@@ -48,19 +56,19 @@ const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
|
||||
resource: { 'service.name': 'frontend' },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
@@ -68,6 +76,8 @@ const listRows = [
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
attributes: {},
|
||||
resource: { 'service.name': 'demo-app' },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -84,7 +94,9 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = (): ReturnType<typeof render> =>
|
||||
const renderListView = (
|
||||
stagedQuery: Query = initialQueriesMap.traces,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
@@ -98,8 +110,8 @@ const renderListView = (): ReturnType<typeof render> =>
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
stagedQuery,
|
||||
currentQuery: stagedQuery,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
@@ -118,6 +130,10 @@ describe('Traces ListView - Data Loaded', () => {
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
|
||||
|
||||
// attribute column reads from the attributes map
|
||||
expect(screen.getByText('/api/cart')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
@@ -130,4 +146,37 @@ describe('Traces ListView - Data Loaded', () => {
|
||||
// empty status fields render "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('requests every span field instead of the selected columns', async () => {
|
||||
let requestBody: QueryRangeRequestV5 | undefined;
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
requestBody = await req.json<QueryRangeRequestV5>();
|
||||
return res(ctx.status(200), ctx.json(listResponse(listRows)));
|
||||
}),
|
||||
);
|
||||
const selectColumns = [{ name: 'http.route', fieldContext: 'attribute' }];
|
||||
const { builder } = initialQueriesMap.traces;
|
||||
renderListView({
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...builder,
|
||||
queryData: [{ ...builder.queryData[0], selectColumns }],
|
||||
queryTraceOperator: [
|
||||
{
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
expression: 'A',
|
||||
selectColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query);
|
||||
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
const queries = requestBody?.compositeQuery.queries ?? [];
|
||||
expect(queries).toHaveLength(2);
|
||||
queries.forEach(({ spec }) => {
|
||||
expect(spec).not.toHaveProperty('selectFields');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,23 +100,20 @@ function ListView({
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
// Empty selectFields makes the API return every span field.
|
||||
const requestQuery = useMemo(() => {
|
||||
const query = getListViewQuery(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
orderBy,
|
||||
);
|
||||
query.builder.queryData.forEach((queryData) => {
|
||||
queryData.selectColumns = [];
|
||||
});
|
||||
query.builder.queryTraceOperator?.forEach((queryTraceOperator) => {
|
||||
queryTraceOperator.selectColumns = [];
|
||||
});
|
||||
return query;
|
||||
}, [stagedQuery, orderBy]);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
@@ -127,14 +124,12 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
],
|
||||
@@ -155,7 +150,6 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
@@ -164,10 +158,7 @@ function ListView({
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
@@ -16,7 +17,10 @@ export function getFieldColumn(
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
// Span rows nest attribute/resource fields in maps; trace rows are flat.
|
||||
accessorFn: (row): unknown =>
|
||||
row[name] ??
|
||||
getSpanAttribute(row as Parameters<typeof getSpanAttribute>[0], name),
|
||||
enableMove: !isTimestamp,
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
|
||||
@@ -190,10 +190,12 @@ function SpanDetailsContent({
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
|
||||
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
|
||||
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
|
||||
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
|
||||
clusterName: String(
|
||||
getSpanAttribute(selectedSpan, 'k8s.cluster.name') ?? '',
|
||||
),
|
||||
podName: String(getSpanAttribute(selectedSpan, 'k8s.pod.name') ?? ''),
|
||||
nodeName: String(getSpanAttribute(selectedSpan, 'k8s.node.name') ?? ''),
|
||||
hostName: String(getSpanAttribute(selectedSpan, 'host.name') ?? ''),
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
|
||||
@@ -88,5 +88,17 @@ describe('Presentation / Styling Utils', () => {
|
||||
);
|
||||
expect(value).toBe('host-1');
|
||||
});
|
||||
|
||||
it('groups 0 and false under their own value, not "unknown"', () => {
|
||||
expect(
|
||||
getFlamegraphSpanGroupValue({ attributes: { 'host.name': 0 } }, HOST_FIELD),
|
||||
).toBe('0');
|
||||
expect(
|
||||
getFlamegraphSpanGroupValue(
|
||||
{ attributes: { 'host.name': false } },
|
||||
HOST_FIELD,
|
||||
),
|
||||
).toBe('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ export function getFlamegraphRowMetrics(
|
||||
export function getFlamegraphServiceName(
|
||||
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
|
||||
): string {
|
||||
return getSpanAttribute(span, 'service.name') || '';
|
||||
return String(getSpanAttribute(span, 'service.name') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@ export function getFlamegraphSpanGroupValue(
|
||||
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
|
||||
field: TelemetryFieldKey,
|
||||
): string {
|
||||
return getSpanAttribute(span, field.name) || 'unknown';
|
||||
return String(getSpanAttribute(span, field.name) ?? 'unknown');
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
|
||||
@@ -27,6 +27,14 @@ describe('getAvailableColorByFieldNames', () => {
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
|
||||
});
|
||||
|
||||
it('offers a field whose only values are 0 or false', () => {
|
||||
const spans = [
|
||||
span({ attributes: { 'host.name': 0 } }),
|
||||
span({ attributes: { 'host.name': false } }),
|
||||
];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
|
||||
});
|
||||
|
||||
it('does not offer fields no span carries', () => {
|
||||
const spans = [span({ resource: { 'service.name': 'api' } })];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
RESERVED_ERROR,
|
||||
} from './utils/generateColorPair';
|
||||
|
||||
/** Like `||`, but keeps `0`. */
|
||||
/** Like `||`, but keeps `0` and `false`. */
|
||||
const valueOrUndefined = <T>(value: T): T | undefined =>
|
||||
value || value === 0 ? value : undefined;
|
||||
value || value === 0 || value === false ? value : undefined;
|
||||
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
* Falsy values count as missing, except `0`.
|
||||
* Falsy values count as missing, except `0` and `false`.
|
||||
*
|
||||
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
|
||||
* structurally — the only fields touched are `resource` and `attributes`.
|
||||
@@ -30,7 +30,7 @@ export function getSpanAttribute(
|
||||
tagMap?: Record<string, any>;
|
||||
},
|
||||
key: string,
|
||||
): string | undefined {
|
||||
): string | number | boolean | undefined {
|
||||
return (
|
||||
valueOrUndefined(span.resource?.[key]) ??
|
||||
valueOrUndefined(span.attributes?.[key]) ??
|
||||
@@ -117,7 +117,7 @@ export function getSpanGroupValue(
|
||||
span: SpanV3,
|
||||
colorByFieldName: string,
|
||||
): string {
|
||||
return getSpanAttribute(span, colorByFieldName) || 'unknown';
|
||||
return String(getSpanAttribute(span, colorByFieldName) ?? 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,6 +143,6 @@ export function resolveSpanColor(
|
||||
*/
|
||||
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
|
||||
return COLOR_BY_OPTIONS.filter((opt) =>
|
||||
spans.some((s) => getSpanAttribute(s, opt.field.name)),
|
||||
spans.some((s) => getSpanAttribute(s, opt.field.name) !== undefined),
|
||||
).map((opt) => opt.field.name);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ const timestampAt = (index: number, start: number, end: number): string => {
|
||||
return new Date(end - index * step).toISOString();
|
||||
};
|
||||
|
||||
export const traceSpanRows = ({
|
||||
const flatSpanRows = ({
|
||||
count,
|
||||
offset,
|
||||
limit,
|
||||
@@ -173,9 +173,22 @@ export const traceSpanRows = ({
|
||||
};
|
||||
});
|
||||
|
||||
/** The List view returns every span field, with resource and attribute fields nested in maps. */
|
||||
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
flatSpanRows(options).map(
|
||||
({ timestamp, data: { 'service.name': serviceName, ...data } }) => ({
|
||||
timestamp,
|
||||
data: {
|
||||
...data,
|
||||
attributes: {},
|
||||
resource: { 'service.name': serviceName },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/** The Trace view lists root spans, with the span count of the whole trace. */
|
||||
export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
traceSpanRows(options).map((row, index) => ({
|
||||
flatSpanRows(options).map((row, index) => ({
|
||||
...row,
|
||||
data: {
|
||||
...row.data,
|
||||
|
||||
@@ -996,14 +996,19 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
Object.keys(searchParams).forEach((param) => {
|
||||
// null removes the param instead of writing "null".
|
||||
if (searchParams[param] === null) {
|
||||
urlQuery.delete(param);
|
||||
return;
|
||||
}
|
||||
urlQuery.set(
|
||||
param,
|
||||
shouldNotStringify
|
||||
? (searchParams[param] as string)
|
||||
: JSON.stringify(searchParams[param]),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
// Remove Hidden Filters from URL query parameters on query change
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
renderHook,
|
||||
RenderHookResult,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { navigate } = jest.requireActual<
|
||||
typeof import('lib/router/navigation')
|
||||
>('lib/router/navigation');
|
||||
return {
|
||||
useSafeNavigate: (): unknown => ({
|
||||
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
|
||||
navigate(to, { replace: opts?.replace });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const currentParams = (): URLSearchParams =>
|
||||
new URLSearchParams(getCurrentLocation().search);
|
||||
|
||||
const renderOn = (
|
||||
url: string,
|
||||
): RenderHookResult<QueryBuilderContextType, unknown> =>
|
||||
renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
describe('redirectWithQueryBuilderData search params', () => {
|
||||
it('removes params passed as null and JSON-encodes the rest', async () => {
|
||||
const { result } = renderOn(
|
||||
`/unconstrained?${QueryParams.relativeTime}=6h&${QueryParams.viewName}=kept`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.redirectWithQueryBuilderData(initialQueriesMap.logs, {
|
||||
[QueryParams.relativeTime]: null,
|
||||
[QueryParams.startTime]: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(currentParams().get(QueryParams.startTime)).toBe('1000'),
|
||||
);
|
||||
expect(currentParams().has(QueryParams.relativeTime)).toBe(false);
|
||||
expect(currentParams().get(QueryParams.viewName)).toBe('kept');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
|
||||
|
||||
@@ -19,4 +20,14 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
|
||||
);
|
||||
|
||||
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,13 +56,14 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
return plannedMaintenances, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, orgID string, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
storableMaintenanceRule := new(alertmanagertypes.PlannedMaintenanceWithRules)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storableMaintenanceRule).
|
||||
Relation("Rules").
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
@@ -162,11 +163,12 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
|
||||
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
_, err := r.sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -182,7 +184,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := r.GetPlannedMaintenanceByID(ctx, id)
|
||||
existing, err := r.GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sqlalertmanagerstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -8,11 +9,13 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"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"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -92,3 +95,67 @@ func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, valid.ID, list[0].ID)
|
||||
}
|
||||
|
||||
func TestPlannedMaintenanceScopedToOrg(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
sameOrg bool
|
||||
wantFound bool
|
||||
}{
|
||||
{name: "SameOrg_Found", sameOrg: true, wantFound: true},
|
||||
{name: "OtherOrg_NotFound", sameOrg: false, wantFound: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
schedule := &alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
Name: "window",
|
||||
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
|
||||
OrgID: valuer.GenerateUUID().StringValue(),
|
||||
}
|
||||
_, err := store.BunDB().NewInsert().Model(schedule).Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
if testCase.sameOrg {
|
||||
orgID = schedule.OrgID
|
||||
}
|
||||
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
|
||||
|
||||
got, err := maintenanceStore.GetPlannedMaintenanceByID(t.Context(), orgID, schedule.ID)
|
||||
if testCase.wantFound {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, schedule.ID, got.ID)
|
||||
} else {
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
}
|
||||
|
||||
postable := &alertmanagertypes.PostablePlannedMaintenance{Name: "renamed", Schedule: new(alertmanagertypes.Schedule)}
|
||||
require.NoError(t, json.Unmarshal([]byte(schedule.Schedule), postable.Schedule))
|
||||
claimsCtx := authtypes.NewContextWithClaims(t.Context(), authtypes.Claims{OrgID: orgID, Email: "user@example.com"})
|
||||
err = maintenanceStore.UpdatePlannedMaintenance(claimsCtx, postable, schedule.ID)
|
||||
if testCase.wantFound {
|
||||
require.NoError(t, err)
|
||||
got, err = maintenanceStore.GetPlannedMaintenanceByID(t.Context(), orgID, schedule.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", got.Name)
|
||||
} else {
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
}
|
||||
|
||||
err = maintenanceStore.DeletePlannedMaintenance(t.Context(), orgID, schedule.ID)
|
||||
require.NoError(t, err)
|
||||
count, err := store.BunDB().NewSelect().Model(new(alertmanagertypes.StorablePlannedMaintenance)).Where("id = ?", schedule.ID.StringValue()).Count(t.Context())
|
||||
require.NoError(t, err)
|
||||
if testCase.wantFound {
|
||||
assert.Equal(t, 0, count)
|
||||
} else {
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ type provider struct {
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
rulerService ruler.Ruler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
@@ -142,6 +143,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
rulerService ruler.Ruler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
@@ -196,6 +198,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
rulerService,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
globalConfig,
|
||||
@@ -252,6 +255,7 @@ func newProvider(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
rulerService ruler.Ruler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
@@ -307,6 +311,7 @@ func newProvider(
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
rulerService: rulerService,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -204,73 +207,166 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListDowntimeSchedules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateDowntimeSchedule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.DowntimeRuleIDsExtractor,
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.NewDowntimeRuleAttachExtractor(provider.rulerService),
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.NewDowntimeRuleDetachExtractor(provider.rulerService),
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package querybuilder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,7 +15,10 @@ import (
|
||||
)
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := telemetrytypes.NewTelemetryGrantSelectors(id)
|
||||
values, err := telemetrySelectorValues(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -182,27 +186,103 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0)
|
||||
atoms := make([]WhereClauseCondition, 0)
|
||||
for _, condition := range normalized.Conditions {
|
||||
if !condition.TopLevel {
|
||||
if !condition.TopLevel || len(condition.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
if condition.Operator != "=" && condition.Operator != "IN" {
|
||||
continue
|
||||
}
|
||||
|
||||
if condition.Operator == "=" || condition.Operator == "IN" {
|
||||
for _, value := range condition.Values {
|
||||
ids = append(ids, queryType+"/"+key+"/"+value)
|
||||
}
|
||||
if _, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key); !ok {
|
||||
continue
|
||||
}
|
||||
atoms = append(atoms, condition)
|
||||
}
|
||||
if len(atoms) == 0 {
|
||||
return []string{typeWildcard}, nil
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []string{typeWildcard}, nil
|
||||
listIndex := -1
|
||||
for index, atom := range atoms {
|
||||
if atom.Operator != "IN" || len(atom.Values) < 2 {
|
||||
continue
|
||||
}
|
||||
if listIndex >= 0 {
|
||||
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
|
||||
}
|
||||
listIndex = index
|
||||
}
|
||||
if listIndex < 0 {
|
||||
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
|
||||
}
|
||||
|
||||
list := atoms[listIndex]
|
||||
ids := make([]string, 0, len(list.Values))
|
||||
for _, value := range list.Values {
|
||||
atoms[listIndex] = WhereClauseCondition{Key: list.Key, Operator: "=", Values: []string{value}}
|
||||
ids = append(ids, queryType+"/"+telemetryAtomsFilter(atoms))
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func telemetryAtomsFilter(atoms []WhereClauseCondition) string {
|
||||
parts := make([]string, 0, len(atoms))
|
||||
for _, atom := range atoms {
|
||||
literals := make([]string, 0, len(atom.Values))
|
||||
for _, value := range atom.Values {
|
||||
literals = append(literals, FilterStringLiteral(value))
|
||||
}
|
||||
if len(literals) == 1 {
|
||||
parts = append(parts, atom.Key+" = "+literals[0])
|
||||
continue
|
||||
}
|
||||
parts = append(parts, atom.Key+" IN ("+strings.Join(literals, ", ")+")")
|
||||
}
|
||||
slices.Sort(parts)
|
||||
|
||||
return strings.Join(slices.Compact(parts), " AND ")
|
||||
}
|
||||
|
||||
func telemetrySelectorValues(id string) ([]string, error) {
|
||||
queryType, filter, found := strings.Cut(id, "/")
|
||||
if !found || filter == coretypes.WildCardSelectorString {
|
||||
return telemetrytypes.NewTelemetryGrantSelectors(id), nil
|
||||
}
|
||||
|
||||
normalized, err := NormalizeWhereClause(filter, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := make([]string, 0)
|
||||
for _, condition := range normalized.Conditions {
|
||||
if !condition.TopLevel {
|
||||
continue
|
||||
}
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case condition.Operator == "=", condition.Operator == "IN" && len(condition.Values) == 1:
|
||||
values = append(values, queryType+"/"+key+"/"+condition.Values[0], queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
|
||||
case condition.Operator == "IN":
|
||||
values = append(values, queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
|
||||
}
|
||||
}
|
||||
values = append(values, queryType+"/"+coretypes.WildCardSelectorString, coretypes.WildCardSelectorString)
|
||||
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
unique := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
unique = append(unique, value)
|
||||
}
|
||||
|
||||
return unique, nil
|
||||
}
|
||||
|
||||
@@ -21,100 +21,172 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
expected []coretypes.ResourceWithID
|
||||
}{
|
||||
{
|
||||
name: "top level key equality",
|
||||
name: "TopLevelEquality_OneResource",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed key",
|
||||
name: "ResourcePrefixedKey_KeptInID",
|
||||
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/resource.signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
name: "SingleList_OneResourcePerValue",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'b'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms each require a grant",
|
||||
name: "SameKeyEqualities_OneResource",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a' AND signoz.workspace.key.id = 'b'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no filter expression",
|
||||
name: "DifferentKeyEqualities_OneResource",
|
||||
body: builderQueryBody("logs", "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleListWithEquality_ExpandsPerValue",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('b', 'a') AND signoz.workspace.key.id = 'k'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TwoLists_OneResource",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleValueListBesideList_Pinned",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('k')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleValueListsOnly_Pinned",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('a') AND signoz.workspace.key.id IN ('k')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RepeatedList_ExpandsOnce",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('checkout') AND resource.service.name IN ('checkout')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TwoListsOneKey_OneResource",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') AND resource.signoz.workspace.key.id IN ('b', 'c')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.signoz.workspace.key.id IN ('b', 'c') AND signoz.workspace.key.id IN ('a', 'b')"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OldEnvironmentSpelling_NotAnAtom",
|
||||
body: builderQueryBody("traces", "resource.deployment.environment = 'prod'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ServiceList_OneResourcePerValue",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('frontend', 'checkout')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'frontend'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BareCustomerKey_NotAnAtom",
|
||||
body: builderQueryBody("logs", "service.name = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NoFilter_QueryTypeWildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key atom under or does not qualify",
|
||||
name: "TopLevelOr_NoAtoms",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated key atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
|
||||
name: "ListUnderOr_NoAtoms",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key inequality does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
|
||||
name: "NestedNegationUnion_NoAtoms",
|
||||
body: builderQueryBody("logs", "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value with spaces and slashes stays plaintext in the id",
|
||||
name: "ValueWithSpacesAndSlashes_Plaintext",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'check out/2'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit source maps to audit logs resource",
|
||||
name: "AuditSource_AuditLogsResource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query maps to traces resource without a signal",
|
||||
name: "AIBuilderQuery_TracesResource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query without filter is wildcard",
|
||||
name: "AIBuilderQueryNoFilter_Wildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
name: "PromQL_WildcardOnly",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clickhouse sql covers all signals",
|
||||
name: "ClickHouseSQL_AllSignals",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
|
||||
@@ -124,29 +196,29 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "formula produces no resources",
|
||||
name: "Formula_NoResources",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{},
|
||||
},
|
||||
{
|
||||
name: "trace operator rides on its referenced queries",
|
||||
name: "TraceOperator_ReferencedQueries",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable substitution qualifies",
|
||||
name: "VariableSubstitution_Qualifies",
|
||||
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
name: "DuplicateQueries_Deduped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -188,12 +260,28 @@ func TestTelemetrySelector(t *testing.T) {
|
||||
return values
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a'"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/resource.signoz.workspace.key.id = 'a' AND status = 500"))
|
||||
assert.Equal(t, []string{
|
||||
"builder_query/resource.service.name/x",
|
||||
"builder_query/resource.service.name/*",
|
||||
"builder_query/signoz.workspace.key.id/z",
|
||||
"builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/*",
|
||||
"*",
|
||||
}, selectorValues("builder_query/resource.service.name = 'x' AND signoz.workspace.key.id = 'z'"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a', 'b')"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a')"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a' OR status = 500"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/service.name = 'x'"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a/b'"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
|
||||
assert.Equal(t, []string{"*"}, selectorValues("*"))
|
||||
// a value containing "/" stays one logical segment (SplitN 3).
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
|
||||
|
||||
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "builder_query/signoz.workspace.key.id = ", orgID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
|
||||
@@ -205,7 +293,7 @@ func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
|
||||
{
|
||||
name: "FilterPresent_ScopedID",
|
||||
body: `{"start":1,"end":2,"filter":{"expression":"signoz.workspace.key.id = 'key-a'"}}`,
|
||||
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/key-a"}},
|
||||
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'key-a'"}},
|
||||
},
|
||||
{
|
||||
name: "FilterAbsent_Wildcard",
|
||||
|
||||
@@ -2,6 +2,7 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -39,6 +40,7 @@ const (
|
||||
type normalizedPart struct {
|
||||
text string
|
||||
join joinKind
|
||||
negated bool
|
||||
skipped bool
|
||||
}
|
||||
|
||||
@@ -48,11 +50,12 @@ type normalizedValue struct {
|
||||
}
|
||||
|
||||
type whereClauseNormalizer struct {
|
||||
variables map[string]qbtypes.VariableItem
|
||||
conditions []WhereClauseCondition
|
||||
negated bool
|
||||
orDepth int
|
||||
errors []string
|
||||
variables map[string]qbtypes.VariableItem
|
||||
conditions []WhereClauseCondition
|
||||
negated bool
|
||||
orDepth int
|
||||
negationDepth int
|
||||
errors []string
|
||||
}
|
||||
|
||||
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
|
||||
@@ -113,8 +116,11 @@ func NormalizeWhereClause(expression string, variables map[string]qbtypes.Variab
|
||||
sort.Slice(visitor.conditions, func(i, j int) bool {
|
||||
return visitor.conditions[i].sortKey() < visitor.conditions[j].sortKey()
|
||||
})
|
||||
conditions := slices.CompactFunc(visitor.conditions, func(left, right WhereClauseCondition) bool {
|
||||
return left.sortKey() == right.sortKey()
|
||||
})
|
||||
|
||||
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
|
||||
return &NormalizedWhereClause{Expression: part.text, Conditions: conditions}, nil
|
||||
}
|
||||
|
||||
func (condition WhereClauseCondition) sortKey() string {
|
||||
@@ -197,20 +203,22 @@ func (visitor *whereClauseNormalizer) visitUnaryExpression(ctx grammar.IUnaryExp
|
||||
negated := ctx.NOT() != nil
|
||||
if negated {
|
||||
visitor.negated = !visitor.negated
|
||||
visitor.negationDepth++
|
||||
}
|
||||
|
||||
part := visitor.visitPrimary(ctx.Primary())
|
||||
|
||||
if negated {
|
||||
visitor.negated = !visitor.negated
|
||||
visitor.negationDepth--
|
||||
|
||||
if part.skipped {
|
||||
return part
|
||||
}
|
||||
if part.join != joinKindNone {
|
||||
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
|
||||
if part.join != joinKindNone || part.negated {
|
||||
return normalizedPart{text: "NOT (" + part.text + ")", negated: true}
|
||||
}
|
||||
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
|
||||
return normalizedPart{text: "NOT " + part.text, negated: true}
|
||||
}
|
||||
|
||||
return part
|
||||
@@ -439,7 +447,7 @@ func (visitor *whereClauseNormalizer) appendCondition(key, operator string, valu
|
||||
Operator: operator,
|
||||
Values: values,
|
||||
Negated: visitor.negated,
|
||||
TopLevel: visitor.orDepth == 0 && !visitor.negated,
|
||||
TopLevel: visitor.orDepth == 0 && visitor.negationDepth == 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ func TestNormalizeWhereClauseEquivalenceClasses(t *testing.T) {
|
||||
},
|
||||
expected: "service.name = 'frontend' AND status = 200",
|
||||
},
|
||||
{
|
||||
name: "nested not keeps its parentheses",
|
||||
expressions: []string{
|
||||
"NOT (NOT a = 1)",
|
||||
"NOT(NOT a=1)",
|
||||
"not (not a = 1)",
|
||||
},
|
||||
expected: "NOT (NOT a = 1)",
|
||||
},
|
||||
{
|
||||
name: "operand order",
|
||||
expressions: []string{
|
||||
@@ -275,15 +284,25 @@ func TestNormalizeWhereClauseTopLevel(t *testing.T) {
|
||||
expected: map[string]bool{"service.name": false, "status": true},
|
||||
},
|
||||
{
|
||||
name: "double negation restores top level",
|
||||
name: "any negation disqualifies even when it cancels",
|
||||
expression: "NOT (NOT (service.name = 'a'))",
|
||||
expected: map[string]bool{"service.name": true},
|
||||
expected: map[string]bool{"service.name": false},
|
||||
},
|
||||
{
|
||||
name: "in condition under and is top level",
|
||||
expression: "service.name IN ('a', 'b') AND x = 1",
|
||||
expected: map[string]bool{"service.name": true, "x": true},
|
||||
},
|
||||
{
|
||||
name: "nested negation over an and is a disjunction",
|
||||
expression: "NOT (NOT service.name = 'a' AND NOT status = 500)",
|
||||
expected: map[string]bool{"service.name": false, "status": false},
|
||||
},
|
||||
{
|
||||
name: "negation over an or is a conjunction of negations",
|
||||
expression: "NOT (service.name = 'a' OR status = 500)",
|
||||
expected: map[string]bool{"service.name": false, "status": false},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -327,6 +346,8 @@ func TestNormalizeWhereClauseIdempotence(t *testing.T) {
|
||||
"a IN [3, 1, 2] AND hasAll(tags, ['a', 'b']) AND body CONTAINS 'x'",
|
||||
"duration BETWEEN 1 AND 10 OR duration > 100",
|
||||
`msg = 'with \'escapes\' and "quotes"'`,
|
||||
"NOT (NOT resource.region = 'us-east')",
|
||||
"NOT (NOT (a = 1 OR b = 2)) AND NOT c EXISTS",
|
||||
}
|
||||
|
||||
for _, expression := range expressions {
|
||||
@@ -439,3 +460,13 @@ func TestFilterStringLiteral(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseDedupesRepeatedConditions(t *testing.T) {
|
||||
normalized, err := NormalizeWhereClause("service.name IN ('checkout') AND service.name IN ('checkout') AND status = 500 AND status = 500", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "service.name IN ('checkout') AND status = 500", normalized.Expression)
|
||||
assert.Equal(t, []WhereClauseCondition{
|
||||
{Key: "service.name", Operator: "IN", Values: []string{"checkout"}, TopLevel: true},
|
||||
{Key: "status", Operator: "=", Values: []string{"500"}, TopLevel: true},
|
||||
}, normalized.Conditions)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,79 @@ const (
|
||||
|
||||
type diffSide int
|
||||
|
||||
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return defaultToWildcardIfEmpty(ids), nil
|
||||
}}
|
||||
|
||||
var DowntimeRuleSelector coretypes.SelectorFunc = func(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
if id == coretypes.WildCardSelectorString {
|
||||
return coretypes.WildcardSelector(ctx, resource, id, orgID)
|
||||
}
|
||||
|
||||
return coretypes.IDSelector(ctx, resource, id, orgID)
|
||||
}
|
||||
|
||||
func NewDowntimeRuleAttachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newDowntimeDiffExtractor(rulerService, attachedSide)
|
||||
}
|
||||
|
||||
func NewDowntimeRuleDetachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newDowntimeDiffExtractor(rulerService, detachedSide)
|
||||
}
|
||||
|
||||
func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scheduleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := rulerService.MaintenanceStore().GetPlannedMaintenanceByID(ctx, orgID.StringValue(), scheduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
incoming, err := DowntimeRuleIDsExtractor.Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attached, detached := diffDowntimeRuleLinks(stored.RuleIDs, incoming)
|
||||
if side == detachedSide {
|
||||
return detached, nil
|
||||
}
|
||||
|
||||
return attached, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
|
||||
return diffLinks(defaultToWildcardIfEmpty(stored), incoming)
|
||||
}
|
||||
|
||||
func defaultToWildcardIfEmpty(ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return []string{coretypes.WildCardSelectorString}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func NewCreateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubRuler struct {
|
||||
Ruler
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
}
|
||||
|
||||
func (stub stubRuler) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
return stub.maintenanceStore
|
||||
}
|
||||
|
||||
func TestDiffLinks(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -75,3 +94,125 @@ func TestDiffLinks(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffDowntimeRuleLinks(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
stored []string
|
||||
incoming []string
|
||||
wantAttached []string
|
||||
wantDetached []string
|
||||
}{
|
||||
{
|
||||
name: "AllToAll_NoChange",
|
||||
stored: nil,
|
||||
incoming: []string{"*"},
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "NarrowFromAllToOne_DetachesAllAttachesOne",
|
||||
stored: nil,
|
||||
incoming: []string{"r1"},
|
||||
wantAttached: []string{"r1"},
|
||||
wantDetached: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "WidenFromOneToAll_DetachesOneAttachesAll",
|
||||
stored: []string{"r1"},
|
||||
incoming: []string{"*"},
|
||||
wantAttached: []string{"*"},
|
||||
wantDetached: []string{"r1"},
|
||||
},
|
||||
{
|
||||
name: "SwapRules_DiffsIDs",
|
||||
stored: []string{"r1"},
|
||||
incoming: []string{"r2"},
|
||||
wantAttached: []string{"r2"},
|
||||
wantDetached: []string{"r1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
attached, detached := diffDowntimeRuleLinks(testCase.stored, testCase.incoming)
|
||||
|
||||
assert.ElementsMatch(t, testCase.wantAttached, attached)
|
||||
assert.ElementsMatch(t, testCase.wantDetached, detached)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowntimeRuleIDsExtractor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "MissingAlertIds_AllRules",
|
||||
body: `{"name": "window"}`,
|
||||
wantIDs: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "EmptyAlertIds_AllRules",
|
||||
body: `{"alertIds": []}`,
|
||||
wantIDs: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "ExplicitAlertIds_Kept",
|
||||
body: `{"alertIds": ["r1", "r2"]}`,
|
||||
wantIDs: []string{"r1", "r2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ids, err := DowntimeRuleIDsExtractor.Fn(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowntimeDiffExtractorScopesStoreByOrg(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
scheduleID := valuer.GenerateUUID()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
side diffSide
|
||||
hasClaims bool
|
||||
wantIDs []string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "AttachSide_UsesClaimsOrg", side: attachedSide, hasClaims: true, wantIDs: []string{"r2"}},
|
||||
{name: "DetachSide_UsesClaimsOrg", side: detachedSide, hasClaims: true, wantIDs: []string{"r1"}},
|
||||
{name: "NoClaims_Error", side: attachedSide, wantErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
store := alertmanagertypestest.NewMockMaintenanceStore(t)
|
||||
if testCase.hasClaims {
|
||||
store.EXPECT().GetPlannedMaintenanceByID(mock.Anything, orgID.StringValue(), scheduleID).Return(&alertmanagertypes.PlannedMaintenance{RuleIDs: []string{"r1"}}, nil)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/downtime_schedules/"+scheduleID.StringValue(), nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"id": scheduleID.StringValue()})
|
||||
if testCase.hasClaims {
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{OrgID: orgID.StringValue()}))
|
||||
}
|
||||
|
||||
ids, err := newDowntimeDiffExtractor(stubRuler{maintenanceStore: store}, testCase.side).Fn(coretypes.ExtractorContext{Request: req, RequestBody: []byte(`{"alertIds": ["r2"]}`)})
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,13 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, id)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -356,7 +362,13 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, id)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -105,6 +105,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ llmpricingrule.Handler }{},
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ ruler.Ruler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
global.Config{},
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
|
||||
@@ -268,6 +269,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDeleteOrphanSARolesFactory(),
|
||||
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
|
||||
sqlmigration.NewRenameSavedViewDeprecatedTraceFieldsFactory(),
|
||||
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,7 +334,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
|
||||
)
|
||||
}
|
||||
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager, rulerService ruler.Ruler) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozapiserver.NewFactory(
|
||||
orgGetter,
|
||||
@@ -374,6 +376,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
rulerService,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
globalConfig,
|
||||
|
||||
@@ -107,6 +107,7 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -622,7 +622,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.APIServer,
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager),
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
|
||||
"signoz",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
174
pkg/sqlmigration/142_add_downtime_tuples.go
Normal file
174
pkg/sqlmigration/142_add_downtime_tuples.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDowntimeTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDowntimeTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_downtime_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDowntimeTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
crudVerbs := []string{"create", "read", "update", "delete", "list"}
|
||||
readVerbs := []string{"read", "list"}
|
||||
linkVerbs := []string{"attach", "detach"}
|
||||
|
||||
tuples := make([]migrationTuple, 0, 20)
|
||||
for _, verb := range crudVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "planned-maintenance", verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "planned-maintenance", verb},
|
||||
)
|
||||
}
|
||||
for _, verb := range readVerbs {
|
||||
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "planned-maintenance", verb})
|
||||
}
|
||||
for _, objectName := range []string{"planned-maintenance", "rule"} {
|
||||
for _, verb := range linkVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", objectName, verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", objectName, verb},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -18,10 +18,19 @@ func NewMockMaintenanceStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockMaintenanceStore {
|
||||
if helper, ok := t.(interface{ Helper() }); ok {
|
||||
helper.Helper()
|
||||
}
|
||||
|
||||
mock := &MockMaintenanceStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
t.Cleanup(func() {
|
||||
if helper, ok := t.(interface{ Helper() }); ok {
|
||||
helper.Helper()
|
||||
}
|
||||
mock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -75,7 +84,7 @@ type MockMaintenanceStore_CreatePlannedMaintenance_Call struct {
|
||||
// CreatePlannedMaintenance is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
|
||||
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
|
||||
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 any, postablePlannedMaintenance any) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_CreatePlannedMaintenance_Call{Call: _e.mock.On("CreatePlannedMaintenance", context1, postablePlannedMaintenance)}
|
||||
}
|
||||
|
||||
@@ -108,16 +117,16 @@ func (_c *MockMaintenanceStore_CreatePlannedMaintenance_Call) RunAndReturn(run f
|
||||
}
|
||||
|
||||
// DeletePlannedMaintenance provides a mock function for the type MockMaintenanceStore
|
||||
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, s string, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, s, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeletePlannedMaintenance")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, s, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
@@ -131,24 +140,30 @@ type MockMaintenanceStore_DeletePlannedMaintenance_Call struct {
|
||||
|
||||
// DeletePlannedMaintenance is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockMaintenanceStore_Expecter) DeletePlannedMaintenance(context1 interface{}, uUID interface{}) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, uUID)}
|
||||
func (_e *MockMaintenanceStore_Expecter) DeletePlannedMaintenance(context1 any, s any, uUID any) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, s, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 valuer.UUID
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
@@ -159,14 +174,14 @@ func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Return(err error)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetPlannedMaintenanceByID provides a mock function for the type MockMaintenanceStore
|
||||
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
ret := _mock.Called(context1, s, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPlannedMaintenanceByID")
|
||||
@@ -174,18 +189,18 @@ func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Co
|
||||
|
||||
var r0 *alertmanagertypes.PlannedMaintenance
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)); ok {
|
||||
return returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)); ok {
|
||||
return returnFunc(context1, s, uUID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
|
||||
r0 = returnFunc(context1, s, uUID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.PlannedMaintenance)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, valuer.UUID) error); ok {
|
||||
r1 = returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID) error); ok {
|
||||
r1 = returnFunc(context1, s, uUID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
@@ -199,24 +214,30 @@ type MockMaintenanceStore_GetPlannedMaintenanceByID_Call struct {
|
||||
|
||||
// GetPlannedMaintenanceByID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockMaintenanceStore_Expecter) GetPlannedMaintenanceByID(context1 interface{}, uUID interface{}) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, uUID)}
|
||||
func (_e *MockMaintenanceStore_Expecter) GetPlannedMaintenanceByID(context1 any, s any, uUID any) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, s, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 valuer.UUID
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
@@ -227,7 +248,7 @@ func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Return(plannedMai
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -268,7 +289,7 @@ type MockMaintenanceStore_ListPlannedMaintenance_Call struct {
|
||||
// ListPlannedMaintenance is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 interface{}, s interface{}) *MockMaintenanceStore_ListPlannedMaintenance_Call {
|
||||
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 any, s any) *MockMaintenanceStore_ListPlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_ListPlannedMaintenance_Call{Call: _e.mock.On("ListPlannedMaintenance", context1, s)}
|
||||
}
|
||||
|
||||
@@ -326,7 +347,7 @@ type MockMaintenanceStore_UpdatePlannedMaintenance_Call struct {
|
||||
// - context1 context.Context
|
||||
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}, uUID interface{}) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
|
||||
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 any, postablePlannedMaintenance any, uUID any) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_UpdatePlannedMaintenance_Call{Call: _e.mock.On("UpdatePlannedMaintenance", context1, postablePlannedMaintenance, uUID)}
|
||||
}
|
||||
|
||||
|
||||
@@ -403,8 +403,8 @@ type ListPlannedMaintenanceParams struct {
|
||||
|
||||
type MaintenanceStore interface {
|
||||
CreatePlannedMaintenance(context.Context, *PostablePlannedMaintenance) (*PlannedMaintenance, error)
|
||||
DeletePlannedMaintenance(context.Context, valuer.UUID) error
|
||||
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*PlannedMaintenance, error)
|
||||
DeletePlannedMaintenance(context.Context, string, valuer.UUID) error
|
||||
GetPlannedMaintenanceByID(context.Context, string, valuer.UUID) (*PlannedMaintenance, error)
|
||||
UpdatePlannedMaintenance(context.Context, *PostablePlannedMaintenance, valuer.UUID) error
|
||||
ListPlannedMaintenance(context.Context, string) ([]*PlannedMaintenance, error)
|
||||
}
|
||||
|
||||
@@ -134,12 +134,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
@@ -245,12 +249,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -16,8 +17,12 @@ var telemetryGrantQueryTypes = map[string]bool{
|
||||
"clickhouse_sql": false,
|
||||
}
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"signoz.workspace.key.id": {},
|
||||
// signoz.workspace.key.id is stamped on the resource by ingestion and never arrives as an
|
||||
// attribute, so its bare spelling is unambiguous; customer-controlled keys must say resource.
|
||||
var telemetryGrantKeys = map[string]string{
|
||||
"signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"service.name": "resource.service.name",
|
||||
"deployment.environment.name": "resource.deployment.environment.name",
|
||||
}
|
||||
|
||||
func NewTelemetryGrantKey(keyText string) (string, bool) {
|
||||
@@ -26,11 +31,16 @@ func NewTelemetryGrantKey(keyText string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
canonical, ok := telemetryGrantKeys[fieldKey.Name]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
if fieldKey.FieldContext == FieldContextUnspecified && canonical != fieldKey.Name {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelector(input string) (string, error) {
|
||||
@@ -104,8 +114,9 @@ func NewTelemetryGrantSelectors(selector string) []string {
|
||||
|
||||
func telemetryGrantKeyNames() []string {
|
||||
names := make([]string, 0, len(telemetryGrantKeys))
|
||||
for name := range telemetryGrantKeys {
|
||||
names = append(names, name)
|
||||
for _, canonical := range telemetryGrantKeys {
|
||||
names = append(names, canonical)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -14,11 +14,14 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
|
||||
"builder_query/*": "builder_query/*",
|
||||
"promql": "promql/*",
|
||||
"clickhouse_sql": "clickhouse_sql/*",
|
||||
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
|
||||
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
|
||||
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
|
||||
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
|
||||
"builder_query/resource.service.name/frontend": "builder_query/resource.service.name/frontend",
|
||||
"builder_query/resource.service.name/*": "builder_query/resource.service.name/*",
|
||||
"builder_query/resource.deployment.environment.name/prod": "builder_query/resource.deployment.environment.name/prod",
|
||||
}
|
||||
for input, expected := range valid {
|
||||
canonical, err := NewTelemetryGrantSelector(input)
|
||||
@@ -32,6 +35,10 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
|
||||
"signoz.workspace.key.id = 'key-a'",
|
||||
"builder_trace_operator/signoz.workspace.key.id/key-a",
|
||||
"builder_query/service.name/frontend",
|
||||
"builder_query/deployment.environment.name/prod",
|
||||
"builder_query/resource.deployment.environment/prod",
|
||||
"builder_query/attribute.service.name/frontend",
|
||||
"builder_query/host.name/frontend",
|
||||
"builder_query/signoz.workspace.key.id/",
|
||||
"builder_query/signoz.workspace.key.id/$svc",
|
||||
"*/signoz.workspace.key.id/key-a",
|
||||
@@ -48,8 +55,10 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
|
||||
|
||||
func TestNewTelemetryGrantKey(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"resource.service.name": "resource.service.name",
|
||||
"resource.deployment.environment.name": "resource.deployment.environment.name",
|
||||
}
|
||||
for keyText, expected := range valid {
|
||||
key, ok := NewTelemetryGrantKey(keyText)
|
||||
@@ -57,7 +66,7 @@ func TestNewTelemetryGrantKey(t *testing.T) {
|
||||
assert.Equal(t, expected, key, keyText)
|
||||
}
|
||||
|
||||
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
|
||||
for _, keyText := range []string{"service.name", "deployment.environment.name", "resource.deployment.environment", "host.name", "attribute.signoz.workspace.key.id", "attribute.service.name", "body.signoz.workspace.key.id"} {
|
||||
_, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.False(t, ok, keyText)
|
||||
}
|
||||
|
||||
@@ -188,7 +188,9 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -199,7 +201,9 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -420,7 +424,9 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -431,7 +437,9 @@
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list"
|
||||
"list",
|
||||
"attach",
|
||||
"detach"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -250,7 +250,7 @@ def test_filtered_routes_allow_granted_key(
|
||||
timeout=10,
|
||||
)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
assert "builder_query/signoz.workspace.key.id/key-b" in denied.text
|
||||
assert "builder_query/signoz.workspace.key.id = 'key-b'" in denied.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -50,7 +50,9 @@ def test_setup(
|
||||
[
|
||||
"signoz.workspace.key.id = 'key-a'", # expression form, not the wire form
|
||||
"unknown_query_type/signoz.workspace.key.id/key-a", # unsupported query type
|
||||
"builder_query/service.name/frontend", # service.name is not a supported grant key
|
||||
"builder_query/service.name/frontend", # customer keys must carry the resource prefix
|
||||
"builder_query/attribute.service.name/frontend", # grant keys are resource attributes only
|
||||
"builder_query/host.name/frontend", # host.name is not a supported grant key
|
||||
"*/signoz.workspace.key.id/key-a", # non-prefix wildcard
|
||||
"builder_query/signoz.workspace.key.id/", # empty value
|
||||
"builder_query/signoz.workspace.key.id", # missing value, not a wildcard
|
||||
@@ -153,7 +155,7 @@ def test_denied_message_names_resource(
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert "builder_query/signoz.workspace.key.id/key-b" in response.text
|
||||
assert "builder_query/signoz.workspace.key.id = 'key-b'" in response.text
|
||||
|
||||
|
||||
def test_variables_resolve_into_gate(
|
||||
|
||||
135
tests/integration/tests/querierauthz/06_multi_key.py
Normal file
135
tests/integration/tests/querierauthz/06_multi_key.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import build_raw_query, make_query_request
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
service_role = "telemetry-scope-service"
|
||||
service_email = "scope-service@telemetry.test"
|
||||
env_role = "telemetry-scope-env"
|
||||
env_email = "scope-env@telemetry.test"
|
||||
two_services_role = "telemetry-scope-two-services"
|
||||
two_services_email = "scope-two-services@telemetry.test"
|
||||
two_keys_role = "telemetry-scope-two-keys"
|
||||
two_keys_email = "scope-two-keys@telemetry.test"
|
||||
mixed_role = "telemetry-scope-mixed"
|
||||
mixed_email = "scope-mixed@telemetry.test"
|
||||
key_wild_role = "telemetry-scope-key-wild"
|
||||
key_wild_email = "scope-key-wild@telemetry.test"
|
||||
|
||||
two_lists = "resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
for role, email, selectors in [
|
||||
(service_role, service_email, ["builder_query/resource.service.name/checkout"]),
|
||||
(env_role, env_email, ["builder_query/resource.deployment.environment.name/prod"]),
|
||||
(two_services_role, two_services_email, ["builder_query/resource.service.name/checkout", "builder_query/resource.service.name/frontend"]),
|
||||
(two_keys_role, two_keys_email, ["builder_query/signoz.workspace.key.id/k1", "builder_query/signoz.workspace.key.id/k2"]),
|
||||
(mixed_role, mixed_email, ["builder_query/resource.service.name/checkout", "builder_query/signoz.workspace.key.id/k2"]),
|
||||
(key_wild_role, key_wild_email, ["builder_query/signoz.workspace.key.id/*"]),
|
||||
]:
|
||||
create_role(admin_token, role, [transaction_group("read", "telemetryresource", "logs", selectors)])
|
||||
user_id = create_active_user(signoz, admin_token, email=email, role="signoz-viewer", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", role)
|
||||
|
||||
|
||||
@pytest.fixture(name="seed_logs")
|
||||
def seed_logs_fixture(insert_logs: Callable[[list[Logs]], None]) -> datetime:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "checkout", "signoz.workspace.key.id": "k1", "deployment.environment.name": "prod"}, body="checkout-k1"),
|
||||
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": "frontend", "signoz.workspace.key.id": "k2", "deployment.environment.name": "prod"}, body="frontend-k2"),
|
||||
]
|
||||
)
|
||||
return now
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("email", "expression"),
|
||||
[
|
||||
(service_email, "resource.service.name = 'checkout'"),
|
||||
(service_email, "resource.service.name IN ('checkout')"),
|
||||
(service_email, "resource.service.name = 'checkout' AND severity_text = 'ERROR'"),
|
||||
# any granted atom selects the query; the other atoms only narrow it
|
||||
(service_email, "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
|
||||
(env_email, "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
|
||||
(service_email, "resource.service.name IN ('checkout') AND signoz.workspace.key.id IN ('k1', 'k2')"),
|
||||
# a single list is a union: every value, or another granted atom, covers it
|
||||
(two_services_email, "resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1')"),
|
||||
(two_keys_email, "resource.service.name IN ('checkout') AND signoz.workspace.key.id IN ('k1', 'k2')"),
|
||||
# two lists are not expanded: only a key wildcard covers them
|
||||
(key_wild_email, two_lists),
|
||||
],
|
||||
)
|
||||
def test_allowed(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
seed_logs: datetime,
|
||||
email: str,
|
||||
expression: str,
|
||||
) -> None:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(email, user_password),
|
||||
int((seed_logs - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(seed_logs.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("email", "expression", "denied_resource"),
|
||||
[
|
||||
(service_email, "service.name = 'checkout'", "builder_query/*"), # bare customer key never selects
|
||||
# NOT(NOT a AND NOT b) == a OR b: nested negations must not mark either value as a required atom
|
||||
(service_email, "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')", "builder_query/*"),
|
||||
(service_email, "resource.deployment.environment.name = 'prod'", "builder_query/resource.deployment.environment.name = 'prod'"),
|
||||
(service_email, "resource.service.name = 'frontend'", "builder_query/resource.service.name = 'frontend'"),
|
||||
(env_email, "resource.service.name = 'checkout'", "builder_query/resource.service.name = 'checkout'"),
|
||||
(
|
||||
service_email,
|
||||
"resource.service.name IN ('checkout', 'frontend') AND resource.deployment.environment.name = 'prod'",
|
||||
"builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'frontend'",
|
||||
),
|
||||
# two lists are not expanded, so value grants never cover them
|
||||
(two_services_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
|
||||
(two_keys_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
|
||||
(mixed_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
|
||||
],
|
||||
)
|
||||
def test_denied(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
seed_logs: datetime,
|
||||
email: str,
|
||||
expression: str,
|
||||
denied_resource: str,
|
||||
) -> None:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(email, user_password),
|
||||
int((seed_logs - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(seed_logs.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert denied_resource in response.text
|
||||
@@ -14,6 +14,9 @@ from fixtures.auth import (
|
||||
from fixtures.role import find_role_by_name, transaction_group
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker
|
||||
|
||||
V2_RULES_URL = "/api/v2/rules"
|
||||
V3_RULES_URL = "/api/v3/rules"
|
||||
DOWNTIME_URL = "/api/v1/downtime_schedules"
|
||||
ROUTE_POLICIES_URL = "/api/v1/route_policies"
|
||||
|
||||
_EDITOR_EMAIL = "editor+alertauthz@integration.test"
|
||||
@@ -24,6 +27,9 @@ _ACTOR_EMAIL = "customrole+alertauthz@integration.test"
|
||||
_ACTOR_PASSWORD = "password123Z$"
|
||||
_ACTOR_ROLE_NAME = "alert-authz-actor"
|
||||
|
||||
_TARGET_RULE = "alert authz target"
|
||||
_DOWNTIME_NAME = "alert-authz-downtime"
|
||||
_ACTOR_DOWNTIME_NAME = "alert-authz-actor-downtime"
|
||||
_ROUTE_POLICY_NAME = "alert-authz-route-policy"
|
||||
_ACTOR_ROUTE_POLICY_NAME = "alert-authz-actor-route-policy"
|
||||
|
||||
@@ -32,6 +38,43 @@ SEED_CHANNEL_ALT = {"name": "alert-authz-channel-alt", "email_configs": [{"to":
|
||||
|
||||
ROUTE_POLICY_TEMPLATE = {"expression": 'threshold.name == "critical"', "channels": [SEED_CHANNEL["name"]], "description": "authz coverage policy"}
|
||||
|
||||
RULE_CONDITION = {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [SEED_CHANNEL["name"]]}],
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "alert_authz_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
}
|
||||
|
||||
RULE_TEMPLATE = {
|
||||
"description": "authz coverage rule",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": RULE_CONDITION,
|
||||
"labels": {"severity": "critical"},
|
||||
"annotations": {"summary": "s", "description": "d"},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
|
||||
"notificationSettings": {"groupBy": [], "usePolicy": False, "renotify": {"enabled": False, "interval": "30m", "alertStates": []}},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
}
|
||||
|
||||
DOWNTIME_SCHEDULE = {"timezone": "UTC", "startTime": "2026-09-21T00:00:00Z", "endTime": "2026-09-22T00:00:00Z"}
|
||||
|
||||
_ACTOR_BASE_TRANSACTION_GROUPS = [
|
||||
transaction_group("create", "metaresource", "route-policy", ["*"]),
|
||||
transaction_group("read", "metaresource", "route-policy", ["*"]),
|
||||
@@ -40,6 +83,14 @@ _ACTOR_BASE_TRANSACTION_GROUPS = [
|
||||
transaction_group("delete", "metaresource", "route-policy", ["*"]),
|
||||
transaction_group("attach", "metaresource", "route-policy", ["*"]),
|
||||
transaction_group("detach", "metaresource", "route-policy", ["*"]),
|
||||
transaction_group("create", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("read", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("list", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("update", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("delete", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("attach", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("detach", "metaresource", "planned-maintenance", ["*"]),
|
||||
transaction_group("attach", "metaresource", "rule", ["*"]),
|
||||
]
|
||||
|
||||
|
||||
@@ -97,6 +148,29 @@ def test_setup_users_and_channels(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
for rule in response.json()["data"]["rules"]:
|
||||
if rule["alert"] == _TARGET_RULE:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_RULES_URL),
|
||||
json={**RULE_TEMPLATE, "alert": _TARGET_RULE},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
|
||||
json={**ROUTE_POLICY_TEMPLATE, "name": _ROUTE_POLICY_NAME},
|
||||
@@ -181,7 +255,7 @@ def test_admin_allowed_on_route_policy_writes(
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]]
|
||||
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]], response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
|
||||
@@ -207,6 +281,125 @@ def test_route_policy_naming_unknown_channel_rejected(
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_editor_allowed_on_downtime_mutations(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
downtime_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz coverage window updated", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_editor_allowed_on_downtime_naming_rules(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz coverage window with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
downtime_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz coverage window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_viewer_allowed_on_downtime_reads_and_forbidden_on_writes(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
downtime_id = response.json()["data"]["id"]
|
||||
|
||||
for path in (DOWNTIME_URL, f"{DOWNTIME_URL}/{downtime_id}"):
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"GET {path}: expected 200, got {response.status_code}: {response.text}"
|
||||
|
||||
for method, path in (
|
||||
("post", DOWNTIME_URL),
|
||||
("put", f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
("delete", f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
):
|
||||
response = getattr(requests, method)(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz coverage window", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"{method.upper()} {path}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
@@ -256,6 +449,63 @@ def test_route_policy_managed_role_tuples_enforced_with_license(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"admin delete must pass on the migrated route-policy delete tuple: {response.text}"
|
||||
|
||||
|
||||
def test_downtime_managed_role_tuples_enforced_with_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
editor_token = get_token(_EDITOR_EMAIL, _EDITOR_PASSWORD)
|
||||
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"admin rule list must pass: {response.text}"
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz licensed window with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, f"editor create with a rule must pass on the migrated planned-maintenance create and rule attach tuples: {response.text}"
|
||||
downtime_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"viewer read must pass on the migrated planned-maintenance read tuple: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz licensed window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"viewer update must be forbidden, the migration grants viewers no planned-maintenance update tuple: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _DOWNTIME_NAME, "description": "authz licensed window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor widen to all rules must pass on the migrated rule attach and detach tuples: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {editor_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor delete must pass on the migrated planned-maintenance delete tuple: {response.text}"
|
||||
|
||||
|
||||
def test_setup_channel_scoped_actor(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
@@ -370,7 +620,7 @@ def test_route_policy_channel_changes_follow_channel_attach_and_detach(
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]]
|
||||
assert response.json()["data"]["channels"] == [SEED_CHANNEL_ALT["name"]], response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{ROUTE_POLICIES_URL}/{policy_id}"),
|
||||
@@ -380,6 +630,179 @@ def test_route_policy_channel_changes_follow_channel_attach_and_detach(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_downtime_naming_rule_forbidden_without_rule_attach(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "", "transactionGroups": [group for group in _ACTOR_BASE_TRANSACTION_GROUPS if group != transaction_group("attach", "metaresource", "rule", ["*"])]},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
for alert_ids in ([target_id], []):
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": alert_ids},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"alertIds={alert_ids}: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_downtime_rule_removal_forbidden_without_rule_detach(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
downtime_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window still with rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window without rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_downtime_widening_to_all_rules_forbidden_without_wildcard_attach(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
target_id = {rule["alert"]: rule["id"] for rule in response.json()["data"]["rules"]}[_TARGET_RULE]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [group for group in _ACTOR_BASE_TRANSACTION_GROUPS if group != transaction_group("attach", "metaresource", "rule", ["*"])]
|
||||
+ [
|
||||
transaction_group("attach", "metaresource", "rule", [target_id]),
|
||||
transaction_group("detach", "metaresource", "rule", ["*"]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window", "schedule": DOWNTIME_SCHEDULE, "alertIds": [target_id]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
downtime_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window for all rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
json={"name": _ACTOR_DOWNTIME_NAME, "description": "scoped actor window for all rules", "schedule": DOWNTIME_SCHEDULE, "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{downtime_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),
|
||||
json={"description": "", "transactionGroups": _ACTOR_BASE_TRANSACTION_GROUPS},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_alert_authz_cleanup(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
@@ -387,6 +810,43 @@ def test_alert_authz_cleanup(
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(DOWNTIME_URL),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
for schedule in response.json()["data"] or []:
|
||||
if schedule["name"] in (_DOWNTIME_NAME, _ACTOR_DOWNTIME_NAME):
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{schedule['id']}"),
|
||||
json={"name": schedule["name"], "description": schedule["description"], "schedule": schedule["schedule"], "alertIds": []},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DOWNTIME_URL}/{schedule['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V3_RULES_URL}?limit=200"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
for rule in response.json()["data"]["rules"]:
|
||||
if rule["alert"] == _TARGET_RULE:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_RULES_URL}/{rule['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(ROUTE_POLICIES_URL),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
|
||||
Reference in New Issue
Block a user