mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-27 05:40:40 +01:00
Compare commits
3 Commits
main
...
nv/notif-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b676541dae | ||
|
|
90d58826c2 | ||
|
|
fa8d1cfc56 |
@@ -184,6 +184,7 @@ components:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
@@ -217,6 +218,7 @@ components:
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
@@ -258,6 +260,7 @@ components:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
@@ -268,6 +271,7 @@ components:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
@@ -346,6 +350,7 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
@@ -374,6 +379,7 @@ components:
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
@@ -451,6 +457,7 @@ components:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
nullable: true
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
@@ -464,6 +471,7 @@ components:
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
nullable: true
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
|
||||
@@ -104,9 +104,9 @@ export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -125,9 +125,9 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -166,13 +166,19 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
|
||||
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
@@ -239,10 +245,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
|
||||
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -265,7 +277,7 @@ export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
@@ -307,10 +319,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
|
||||
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -326,7 +344,7 @@ export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
@@ -423,10 +441,16 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
|
||||
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -434,7 +458,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
@@ -450,9 +474,9 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
labels?: string[];
|
||||
labels?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -543,17 +567,23 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
|
||||
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @type object,null
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
|
||||
@@ -47,5 +47,4 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
z-index: 0;
|
||||
background: var(--l1-background);
|
||||
|
||||
// Column so the bottom strip sits under the scrolling content, not inside it.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.full-screen-content {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -74,9 +70,7 @@
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: calc(20px + var(--bottom-strip-height, 0px));
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
|
||||
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import BottomStrip from 'container/BottomStrip';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -52,7 +51,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
@@ -404,7 +402,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
@@ -871,10 +868,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
|
||||
<BottomStrip />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && isAIAssistantEnabled && (
|
||||
|
||||
@@ -12,12 +12,8 @@ export const Layout = styled(LayoutComponent)`
|
||||
}
|
||||
`;
|
||||
|
||||
// Takes the height left in `.app-content` after the bottom strip.
|
||||
// `min-height: 0` is not needed right now, overlayscrollbars already sets
|
||||
// `overflow: auto` here. Kept so this does not break if that goes away.
|
||||
export const LayoutContent = styled(LayoutComponent.Content)`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
flex-shrink: 0;
|
||||
height: var(--bottom-strip-height);
|
||||
padding: 0 var(--spacing-6);
|
||||
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
// cover the strip.
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
// The string is whatever the Go build injected, so it is rendered untouched —
|
||||
// same as SideNav. Release tags carry the "v", local builds do not.
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
export const BOTTOM_STRIP_HEIGHT = 24;
|
||||
|
||||
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomStrip;
|
||||
@@ -1,8 +1,6 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0px;
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.explorer-option-droppable-container {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
width: -webkit-fill-available;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.licenses-page {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.licenses-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
@@ -29,6 +32,7 @@
|
||||
|
||||
.licenses-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -181,9 +181,7 @@
|
||||
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new
|
||||
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
width: calc(100% - 54px);
|
||||
background: var(--l1-background);
|
||||
padding: 16px;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.version-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.version-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useSavedViewEnabled(): boolean {
|
||||
const [isEnabled] = useState(
|
||||
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
|
||||
);
|
||||
|
||||
return isEnabled;
|
||||
}
|
||||
@@ -1,29 +1,4 @@
|
||||
.alerts-container {
|
||||
// Hands the page height down to the active tab so its content can bound itself
|
||||
// instead of guessing with 100vh. Child combinators only, nested Tabs
|
||||
// (Configuration) must not be caught.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .ant-tabs-content-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-level-tab.periscope-tab {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -65,9 +40,5 @@
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.support-page-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.support-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
|
||||
&__steps-config {
|
||||
flex-shrink: 0;
|
||||
width: 600px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
// Positioning context for the absolute .steps-footer.
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Scoped here so the modal usage of FunnelConfiguration on trace details
|
||||
// stays in normal flow.
|
||||
.funnel-configuration {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
&__steps-results {
|
||||
width: 100%;
|
||||
|
||||
@@ -4,17 +4,14 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
&.funnel-details-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// .steps-footer is absolute against the config column, so its 64px is
|
||||
// reserved rather than laid out.
|
||||
margin-bottom: 64px;
|
||||
height: calc(
|
||||
100vh - 170px
|
||||
); // 64px bottom bar + 61px configuration header + 45px page navbar
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -262,7 +262,7 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
}
|
||||
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
receiver, err := postable.ToReceiver()
|
||||
channel, receiver, err := postable.ToChannel(orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -280,11 +280,6 @@ func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := alertmanagertypes.NewChannelFromReceiverWithName(receiver, postable.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.configStore.CreateChannel(ctx, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
|
||||
return provider.configStore.Set(ctx, config)
|
||||
}))
|
||||
@@ -304,15 +299,11 @@ func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiver, err := updatable.ToReceiver(channel.DisplayName)
|
||||
receiver, err := channel.UpdateFromUpdatable(updatable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := channel.Update(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -257,6 +257,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddChannelConfigFactory(sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
725
pkg/sqlmigration/131_add_channel_config.go
Normal file
725
pkg/sqlmigration/131_add_channel_config.go
Normal file
@@ -0,0 +1,725 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addChannelConfig struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type channelConfigBackfillRow struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
// notifierJSON is one entry of a receiver's *_configs list as stored in
|
||||
// notification_channel.data.
|
||||
type notifierJSON map[string]json.RawMessage
|
||||
|
||||
type channelConfigBackfillKind struct {
|
||||
configsKey string
|
||||
kind string
|
||||
convert func(notifierJSON) (map[string]any, error)
|
||||
}
|
||||
|
||||
var channelConfigBackfillKinds = []channelConfigBackfillKind{
|
||||
{configsKey: "slack_configs", kind: "slack", convert: convertSlackNotifierJSON},
|
||||
{configsKey: "email_configs", kind: "email", convert: convertEmailNotifierJSON},
|
||||
{configsKey: "webhook_configs", kind: "webhook", convert: convertWebhookNotifierJSON},
|
||||
{configsKey: "pagerduty_configs", kind: "pagerduty", convert: convertPagerdutyNotifierJSON},
|
||||
{configsKey: "opsgenie_configs", kind: "opsgenie", convert: convertOpsgenieNotifierJSON},
|
||||
{configsKey: "msteamsv2_configs", kind: "msteams", convert: convertMSTeamsNotifierJSON},
|
||||
{configsKey: "googlechat_configs", kind: "googlechat", convert: convertGoogleChatNotifierJSON},
|
||||
{configsKey: "jira_configs", kind: "jira", convert: convertJiraNotifierJSON},
|
||||
{configsKey: "jsmops_configs", kind: "jsmops", convert: convertJSMOpsNotifierJSON},
|
||||
{configsKey: "incidentio_configs", kind: "incidentio", convert: convertIncidentIONotifierJSON},
|
||||
}
|
||||
|
||||
func NewAddChannelConfigFactory(sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_channel_config"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addChannelConfig{sqlschema: sqlschema, logger: ps.Logger}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addChannelConfig) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up adds the column and fills it from each channel's receiver, as a write
|
||||
// through a receiver does. A receiver v2 cannot represent, such as one carrying
|
||||
// several notifiers or a notifier kind v2 does not model, stays NULL and is
|
||||
// logged; the repair endpoint is the remedy for those.
|
||||
func (migration *addChannelConfig) Up(ctx context.Context, db *bun.DB) error {
|
||||
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("notification_channel"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("config"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}, nil)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]*channelConfigBackfillRow, 0)
|
||||
if err := tx.NewSelect().Model(&rows).Where("config IS NULL").OrderExpr("org_id, id").Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type orgStats struct{ total, filled, unrepresentable int }
|
||||
statsByOrg := map[string]*orgStats{}
|
||||
for _, row := range rows {
|
||||
stats, ok := statsByOrg[row.OrgID]
|
||||
if !ok {
|
||||
stats = &orgStats{}
|
||||
statsByOrg[row.OrgID] = stats
|
||||
}
|
||||
stats.total++
|
||||
|
||||
channelConfig, err := channelConfigFromReceiverJSON(row.Data)
|
||||
if err != nil {
|
||||
stats.unrepresentable++
|
||||
migration.logger.WarnContext(ctx, "leaving notification channel without a v2 config", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
encoded, err := marshalUnescaped(channelConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*channelConfigBackfillRow)(nil)).
|
||||
Set("config = ?", string(encoded)).
|
||||
Where("id = ?", row.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.filled++
|
||||
}
|
||||
|
||||
for _, orgID := range slices.Sorted(maps.Keys(statsByOrg)) {
|
||||
stats := statsByOrg[orgID]
|
||||
migration.logger.InfoContext(ctx, "filled v2 config on notification channels", slog.String("org_id", orgID), slog.Int("total", stats.total), slog.Int("filled", stats.filled), slog.Int("unrepresentable", stats.unrepresentable))
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addChannelConfig) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// channelConfigFromReceiverJSON mirrors the v2 read of a stored receiver: one
|
||||
// notifier of a modelled kind, with the receiver's field names renamed to the
|
||||
// spec's and its unset templates left out.
|
||||
func channelConfigFromReceiverJSON(data string) (map[string]any, error) {
|
||||
receiver := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal([]byte(data), &receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := 0
|
||||
var found *channelConfigBackfillKind
|
||||
var notifier notifierJSON
|
||||
for key, raw := range receiver {
|
||||
if !strings.HasSuffix(key, "_configs") {
|
||||
continue
|
||||
}
|
||||
|
||||
var list []notifierJSON
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
total += len(list)
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range channelConfigBackfillKinds {
|
||||
if channelConfigBackfillKinds[i].configsKey == key {
|
||||
found = &channelConfigBackfillKinds[i]
|
||||
notifier = list[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total > 1 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries %d notifier configurations; only one per channel is supported", total)
|
||||
}
|
||||
if found == nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries no supported notifier configuration")
|
||||
}
|
||||
|
||||
spec, err := found.convert(notifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendResolved, err := notifier.boolValue("send_resolved")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec["sendResolved"] = sendResolved
|
||||
|
||||
return map[string]any{"kind": found.kind, "spec": spec}, nil
|
||||
}
|
||||
|
||||
func convertSlackNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_url": "apiUrl", "channel": "channel"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "text": "text", "color": "color", "title_link": "titleLink", "pretext": "pretext", "fallback": "fallback", "footer": "footer"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiUrl"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "slack: api_url is required")
|
||||
}
|
||||
|
||||
fields, err := notifier.objectList("fields")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
converted := make([]map[string]any, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
item := map[string]any{}
|
||||
if err := field.copyStrings(item, map[string]string{"title": "title", "value": "value"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if field.has("short") {
|
||||
short, err := field.boolValue("short")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["short"] = short
|
||||
}
|
||||
converted = append(converted, item)
|
||||
}
|
||||
spec["fields"] = converted
|
||||
}
|
||||
|
||||
actions, err := notifier.objectList("actions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
converted := make([]map[string]any, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
item := map[string]any{}
|
||||
if err := action.copyStrings(item, map[string]string{"type": "type", "text": "text", "url": "url", "style": "style", "name": "name", "value": "value"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action.has("confirm") {
|
||||
confirm, err := action.object("confirm")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confirmation := map[string]any{}
|
||||
if err := confirm.copyStrings(confirmation, map[string]string{"text": "text", "title": "title", "ok_text": "okText", "dismiss_text": "dismissText"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["confirm"] = confirmation
|
||||
}
|
||||
converted = append(converted, item)
|
||||
}
|
||||
spec["actions"] = converted
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertEmailNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"to": "to"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"html": "html"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["to"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "email: to is required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"headers": "headers"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertWebhookNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"url": "url"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["url"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "webhook: url is required")
|
||||
}
|
||||
|
||||
if err := rejectUnsupportedHTTPConfigJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username, password, err := extractBasicAuthJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bearerToken, err := extractBearerTokenJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (username != "" || password != "") && bearerToken != "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "webhook: basic auth and bearer token cannot be combined")
|
||||
}
|
||||
spec["username"], spec["password"], spec["bearerToken"] = username, password, bearerToken
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertPagerdutyNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"routing_key": "routingKey", "url": "url", "severity": "severity", "component": "component", "group": "group", "class": "class"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"source": "source", "client": "client", "client_url": "clientUrl", "description": "description"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["routingKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "pagerduty: routing_key is required")
|
||||
}
|
||||
|
||||
details, err := notifier.object("details")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(details) > 0 {
|
||||
for key, raw := range details {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "pagerduty: details.%s is not a string", key)
|
||||
}
|
||||
}
|
||||
spec["details"] = notifier["details"]
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertOpsgenieNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_key": "apiKey", "api_url": "apiUrl", "priority": "priority"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"message": "message", "description": "description", "source": "source"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "opsgenie: api_key is required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"details": "details"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertMSTeamsNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
return convertWebhookURLNotifierJSON("msteamsv2", notifier)
|
||||
}
|
||||
|
||||
func convertGoogleChatNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
return convertWebhookURLNotifierJSON("googlechat", notifier)
|
||||
}
|
||||
|
||||
func convertWebhookURLNotifierJSON(name string, notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"webhook_url": "webhookUrl"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "text": "text"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["webhookUrl"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s: webhook_url is required", name)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertJiraNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"site": "site", "project": "project", "issue_type": "issueType", "priority": "priority", "resolve_transition": "resolveTransition", "reopen_transition": "reopenTransition", "wont_fix_resolution": "wontFixResolution"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"summary": "summary", "description": "description", "reopen_duration": "reopenDuration"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"custom_fields": "customFields"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
labels, err := notifier.list("labels")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
spec["labels"] = notifier["labels"]
|
||||
}
|
||||
|
||||
if err := rejectUnsupportedHTTPConfigJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpConfig.has("authorization") {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jira: http_config.authorization is not supported")
|
||||
}
|
||||
email, apiToken, err := extractBasicAuthJSON(httpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec["email"], spec["apiToken"] = email, apiToken
|
||||
|
||||
for _, required := range []string{"site", "project", "issueType", "email", "apiToken"} {
|
||||
if spec[required] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jira: %s is required", required)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertJSMOpsNotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"api_key": "apiKey", "priority": "priority"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"message": "message", "description": "description", "tags": "tags"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["apiKey"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "jsmops: api_key is required")
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func convertIncidentIONotifierJSON(notifier notifierJSON) (map[string]any, error) {
|
||||
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := map[string]any{}
|
||||
if err := notifier.copyStrings(spec, map[string]string{"url": "url", "token": "token"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"title": "title", "description": "description"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec["url"] == "" || spec["token"] == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "incidentio: url and token are required")
|
||||
}
|
||||
if err := notifier.copyNonEmptyObjects(spec, map[string]string{"metadata": "metadata"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuthJSON(notifier notifierJSON) error {
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpConfig.has("basic_auth") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.basic_auth is not supported")
|
||||
}
|
||||
if httpConfig.has("authorization") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization is not supported")
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfigJSON(notifier)
|
||||
}
|
||||
|
||||
// rejectUnsupportedHTTPConfigJSON refuses every http_config setting the spec has
|
||||
// no field for, since a config that dropped it would unauthenticate or reroute
|
||||
// the channel on the next write. An absent http_config is fine; a present one
|
||||
// must carry the defaults for follow_redirects and enable_http2.
|
||||
func rejectUnsupportedHTTPConfigJSON(notifier notifierJSON) error {
|
||||
if !notifier.has("http_config") {
|
||||
return nil
|
||||
}
|
||||
httpConfig, err := notifier.object("http_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range []string{"oauth2", "http_headers"} {
|
||||
if httpConfig.has(key) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"bearer_token", "bearer_token_file", "proxy_url", "no_proxy"} {
|
||||
value, err := httpConfig.stringValue(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
if httpConfig.has("proxy_from_environment") {
|
||||
fromEnvironment, err := httpConfig.boolValue("proxy_from_environment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEnvironment {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.proxy_from_environment is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
tlsConfig, err := httpConfig.object("tls_config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key := range tlsConfig {
|
||||
if key != "insecure_skip_verify" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.tls_config is not supported")
|
||||
}
|
||||
}
|
||||
if tlsConfig.has("insecure_skip_verify") {
|
||||
insecure, err := tlsConfig.boolValue("insecure_skip_verify")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insecure {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.tls_config is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"follow_redirects", "enable_http2"} {
|
||||
enabled, err := httpConfig.boolValue(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractBasicAuthJSON(httpConfig notifierJSON) (string, string, error) {
|
||||
basicAuth, err := httpConfig.object("basic_auth")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(basicAuth) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
for key := range basicAuth {
|
||||
if key != "username" && key != "password" {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.basic_auth.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
username, err := basicAuth.stringValue("username")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
password, err := basicAuth.stringValue("password")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func extractBearerTokenJSON(httpConfig notifierJSON) (string, error) {
|
||||
if !httpConfig.has("authorization") {
|
||||
return "", nil
|
||||
}
|
||||
authorization, err := httpConfig.object("authorization")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for key := range authorization {
|
||||
if key != "type" && key != "credentials" {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization.%s is not supported", key)
|
||||
}
|
||||
}
|
||||
scheme, err := authorization.stringValue("type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "http_config.authorization.type %q is not supported", scheme)
|
||||
}
|
||||
|
||||
return authorization.stringValue("credentials")
|
||||
}
|
||||
|
||||
// has reports a key that is present and not null.
|
||||
func (n notifierJSON) has(key string) bool {
|
||||
raw, ok := n[key]
|
||||
return ok && string(raw) != "null"
|
||||
}
|
||||
|
||||
func (n notifierJSON) stringValue(key string) (string, error) {
|
||||
if !n.has(key) {
|
||||
return "", nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) boolValue(key string) (bool, error) {
|
||||
if !n.has(key) {
|
||||
return false, nil
|
||||
}
|
||||
var value bool
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return false, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// object reads an absent key as an empty object; a caller that must tell the
|
||||
// two apart checks has first.
|
||||
func (n notifierJSON) object(key string) (notifierJSON, error) {
|
||||
if !n.has(key) {
|
||||
return notifierJSON{}, nil
|
||||
}
|
||||
value := notifierJSON{}
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) list(key string) ([]json.RawMessage, error) {
|
||||
if !n.has(key) {
|
||||
return nil, nil
|
||||
}
|
||||
var value []json.RawMessage
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) objectList(key string) ([]notifierJSON, error) {
|
||||
if !n.has(key) {
|
||||
return nil, nil
|
||||
}
|
||||
var value []notifierJSON
|
||||
if err := json.Unmarshal(n[key], &value); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// copyStrings writes each field as the spec's plain string, "" when absent.
|
||||
func (n notifierJSON) copyStrings(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.stringValue(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec[to] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyNonEmptyStrings leaves an empty field out, which is how the spec spells
|
||||
// an unset template.
|
||||
func (n notifierJSON) copyNonEmptyStrings(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.stringValue(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != "" {
|
||||
spec[to] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n notifierJSON) copyNonEmptyObjects(spec map[string]any, keys map[string]string) error {
|
||||
for from, to := range keys {
|
||||
value, err := n.object(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(value) > 0 {
|
||||
spec[to] = n[from]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,17 +2,11 @@ package alertmanagertypes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -30,20 +24,6 @@ var (
|
||||
|
||||
type Channels = []*Channel
|
||||
|
||||
type GettableChannels = []*Channel
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchema is not the shape OpenAPI wants for a
|
||||
// discriminated union. OpenAPI's discriminator requires every oneOf branch to
|
||||
// be a $ref to a named component and a sibling property whose value selects
|
||||
// the variant. Our payload instead uses the *presence* of one of the 18
|
||||
// *_configs arrays to imply the type, so no discriminator can be attached.
|
||||
// Refactor PostableChannel into a {name, type, config} envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) so each notification kind
|
||||
// becomes a named component and the discriminator can be wired up properly.
|
||||
type PostableChannel struct {
|
||||
Receiver
|
||||
}
|
||||
|
||||
// Channel represents a single receiver of the alertmanager config.
|
||||
type Channel struct {
|
||||
bun.BaseModel `bun:"table:notification_channel"`
|
||||
@@ -57,43 +37,11 @@ type Channel struct {
|
||||
DisplayName string `json:"name" required:"true" bun:"display_name"`
|
||||
Type string `json:"type" required:"true" bun:"type"`
|
||||
Data string `json:"data" required:"true" bun:"data"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
}
|
||||
|
||||
// NewChannelFromReceiver creates a new Channel from a Receiver.
|
||||
// It can return nil if the receiver is the default receiver.
|
||||
// A receiver carries no internal name, so one is generated from its name.
|
||||
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
if receiver.Name == DefaultReceiverName {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
|
||||
}
|
||||
|
||||
// Initialize channel with common fields
|
||||
channel := Channel{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Name: generateChannelName(receiver.Name),
|
||||
DisplayName: receiver.Name,
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "marshal receiver")
|
||||
}
|
||||
channel.Data = string(data)
|
||||
|
||||
channel.Type = receiverChannelType(receiver)
|
||||
if channel.Type == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
// Config is the v2 config a read returns. A v2 write stores it as the caller
|
||||
// wrote it and a v1 write derives it from the defaulted receiver. Only a row
|
||||
// the migration could not backfill has none.
|
||||
Config ChannelConfig `json:"-" bun:"config,type:text,nullzero"`
|
||||
OrgID string `json:"orgId" required:"true" bun:"org_id"`
|
||||
}
|
||||
|
||||
const channelNameSuffixLen = 8
|
||||
@@ -137,57 +85,6 @@ func generateChannelName(displayName string) string {
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
// NewChannelFromReceiverWithName overrides the name that NewChannelFromReceiver
|
||||
// generates.
|
||||
func NewChannelFromReceiverWithName(receiver *Receiver, name string, orgID string) (*Channel, error) {
|
||||
channel, err := NewChannelFromReceiver(receiver, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel.Name = name
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// receiverChannelType returns the channel.Type discriminator. Walks
|
||||
// Receiver's own fields first (native), then the embed (upstream); first
|
||||
// non-empty *_configs slice wins.
|
||||
func receiverChannelType(receiver *Receiver) string {
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nonEmptyConfigsField(v reflect.Value) string {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
yamlTag := field.Tag.Get("yaml")
|
||||
if yamlTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the base type name (e.g., "email_configs" -> "email").
|
||||
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
|
||||
if len(matches) != 2 {
|
||||
continue
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
|
||||
cfg, err := NewDefaultConfig(
|
||||
globalConfig,
|
||||
@@ -228,64 +125,3 @@ func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
stats["alertmanager.channel.count"] = int64(len(channels))
|
||||
return stats
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, c.Name, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.DisplayName != channel.DisplayName {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
// Unreachable while the name is passed in above rather than derived from the
|
||||
// receiver, which is why this is internal rather than invalid input.
|
||||
if c.Name != channel.Name {
|
||||
return errors.NewInternalf(ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel internal name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
type alias PostableChannel
|
||||
reflector := &jsonschema.Reflector{}
|
||||
|
||||
schema, err := reflector.Reflect(alias{}, jsonschema.DefinitionsPrefix("#/components/schemas/"))
|
||||
if err != nil {
|
||||
return jsonschema.Schema{}, err
|
||||
}
|
||||
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@ package alertmanagertypes
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
@@ -12,6 +15,31 @@ import (
|
||||
// API -> storage
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ToChannel returns the receiver alongside because the alertmanager config is
|
||||
// updated from it, not from the channel.
|
||||
func (p *PostableNotificationChannel) ToChannel(orgID string) (*Channel, *Receiver, error) {
|
||||
receiver, err := p.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal receiver")
|
||||
}
|
||||
|
||||
return &Channel{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
Type: p.Config.Kind.ToStoredType(),
|
||||
Data: string(data),
|
||||
Config: p.Config,
|
||||
OrgID: orgID,
|
||||
}, receiver, nil
|
||||
}
|
||||
|
||||
// ToReceiver hands the assembled receiver to newDefaultedReceiver, which is the
|
||||
// only place upstream applies a notifier's defaults and validation — several
|
||||
// integrations panic without them.
|
||||
@@ -45,22 +73,51 @@ func (t *TestableNotificationChannel) ToReceiver() (*Receiver, error) {
|
||||
return postable.ToReceiver()
|
||||
}
|
||||
|
||||
func (c *Channel) UpdateFromUpdatable(updatable UpdatableNotificationChannel) (*Receiver, error) {
|
||||
receiver, err := updatable.ToReceiver(c.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal receiver")
|
||||
}
|
||||
|
||||
c.Type = updatable.Config.Kind.ToStoredType()
|
||||
c.Data = string(data)
|
||||
c.Config = updatable.Config
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Storage -> API
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// toPostableNotificationChannel derives the kind from the config the receiver
|
||||
// actually carries rather than from Channel.Type, so a row written with several
|
||||
// notifier kinds is rejected instead of reported under whichever one
|
||||
// receiverChannelType happened to pick.
|
||||
func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel, error) {
|
||||
// toChannelConfig returns the stored config. Only a row the migration could not
|
||||
// backfill has none, so deriving it here reports why.
|
||||
func (c *Channel) toChannelConfig() (ChannelConfig, error) {
|
||||
if c.Config.IsZero() {
|
||||
return c.deriveChannelConfig()
|
||||
}
|
||||
|
||||
return c.Config, nil
|
||||
}
|
||||
|
||||
// deriveChannelConfig derives the kind from the config the receiver actually
|
||||
// carries rather than from Channel.Type, so a row written with several notifier
|
||||
// kinds is rejected instead of reported under whichever one receiverChannelType
|
||||
// happened to pick.
|
||||
func (c *Channel) deriveChannelConfig() (ChannelConfig, error) {
|
||||
receiver := &Receiver{Receiver: &config.Receiver{}}
|
||||
if err := json.Unmarshal([]byte(c.Data), receiver); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
return ChannelConfig{}, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
}
|
||||
|
||||
if total := countNotifierConfigs(receiver); total > 1 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
|
||||
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total)
|
||||
}
|
||||
|
||||
for _, channelKind := range channelKinds {
|
||||
@@ -70,17 +127,21 @@ func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel,
|
||||
|
||||
spec, err := channelKind.extractSpec(c.DisplayName, receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return ChannelConfig{}, err
|
||||
}
|
||||
|
||||
return &PostableNotificationChannel{
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: ChannelConfig{Kind: channelKind.kind, Spec: spec},
|
||||
}, nil
|
||||
// The derived config is stored and decoded back through the same
|
||||
// validation a request goes through, so one that would not decode is
|
||||
// unrepresentable rather than stored.
|
||||
channelConfig := ChannelConfig{Kind: channelKind.kind, Spec: spec}
|
||||
if err := channelConfig.Validate(); err != nil {
|
||||
return ChannelConfig{}, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "channel %q: %s", c.DisplayName, err.Error())
|
||||
}
|
||||
|
||||
return channelConfig, nil
|
||||
}
|
||||
|
||||
return nil, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
|
||||
return ChannelConfig{}, errors.NewInvalidInputf(ErrCodeChannelUnsupportedKind, "channel %q carries no supported notifier configuration", c.DisplayName)
|
||||
}
|
||||
|
||||
// countNotifierConfigs totals every *_configs entry on the receiver, including
|
||||
@@ -111,15 +172,15 @@ func countConfigsFields(v reflect.Value) int {
|
||||
}
|
||||
|
||||
func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel, error) {
|
||||
postable, err := c.toPostableNotificationChannel()
|
||||
channelConfig, err := c.toChannelConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GettableNotificationChannel{
|
||||
Name: postable.Name,
|
||||
DisplayName: postable.DisplayName,
|
||||
Config: postable.Config,
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: channelConfig,
|
||||
ID: c.ID,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// field fails rather than going unasserted. Webhook is covered by
|
||||
// TestPostableChannelToReceiverRoundTripsWebhookAuthModes, whose auth modes are
|
||||
// mutually exclusive and so cannot all be set at once.
|
||||
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
sendResolved := true
|
||||
short := true
|
||||
|
||||
@@ -270,18 +270,14 @@ func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
require.NoError(t, err)
|
||||
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, postable.Name, roundTripped.Name)
|
||||
assert.Equal(t, testCase.kind, roundTripped.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
assert.Equal(t, testCase.kind, derived.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -299,10 +295,7 @@ func TestPostableChannelToReceiverOmitsEmailTransportCredentials(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, credentialKey := range []string{"auth_username", "auth_password", "auth_secret", "tls_config"} {
|
||||
@@ -354,16 +347,13 @@ func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, channel.Data, testCase.expectedInData)
|
||||
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, derived.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -396,7 +386,7 @@ func TestRejectUnrepresentableHTTPConfigCoversEveryUpstreamMember(t *testing.T)
|
||||
assert.Equal(t, 5, reflect.TypeFor[commoncfg.ProxyConfig]().NumField())
|
||||
}
|
||||
|
||||
func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
channel Channel
|
||||
@@ -558,7 +548,7 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
_, err := testCase.channel.toPostableNotificationChannel()
|
||||
_, err := testCase.channel.deriveChannelConfig()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -566,7 +556,7 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
|
||||
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
|
||||
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
|
||||
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
func TestDeriveChannelConfigReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
@@ -595,10 +585,10 @@ func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *te
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
|
||||
|
||||
postable, err := channel.toPostableNotificationChannel()
|
||||
derived, err := channel.deriveChannelConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
|
||||
assert.Equal(t, ChannelKindWebhook, derived.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, derived.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
94
pkg/types/alertmanagertypes/channelkind.go
Normal file
94
pkg/types/alertmanagertypes/channelkind.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeChannelUnsupportedKind = errors.MustNewCode("channel_unsupported_kind")
|
||||
)
|
||||
|
||||
// ChannelKind selects which ChannelSpec a channel carries and which notifier
|
||||
// integration is built for it.
|
||||
type ChannelKind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ChannelKindSlack = ChannelKind{valuer.NewString("slack")}
|
||||
ChannelKindEmail = ChannelKind{valuer.NewString("email")}
|
||||
ChannelKindWebhook = ChannelKind{valuer.NewString("webhook")}
|
||||
ChannelKindPagerduty = ChannelKind{valuer.NewString("pagerduty")}
|
||||
ChannelKindOpsgenie = ChannelKind{valuer.NewString("opsgenie")}
|
||||
ChannelKindMSTeams = ChannelKind{valuer.NewString("msteams")}
|
||||
ChannelKindGoogleChat = ChannelKind{valuer.NewString("googlechat")}
|
||||
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
|
||||
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
|
||||
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
|
||||
)
|
||||
|
||||
func (ChannelKind) Enum() []any {
|
||||
kinds := make([]any, 0, len(channelKinds))
|
||||
for _, channelKind := range channelKinds {
|
||||
kinds = append(kinds, channelKind.kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func (t ChannelKind) IsValid() bool {
|
||||
return slices.ContainsFunc(t.Enum(), func(v any) bool { return v == t })
|
||||
}
|
||||
|
||||
// ToStoredType returns the Channel.Type a channel of this kind is stored under,
|
||||
// which matches the kind for all but msteams.
|
||||
func (t ChannelKind) ToStoredType() string {
|
||||
if t == ChannelKindMSTeams {
|
||||
return "msteamsv2"
|
||||
}
|
||||
|
||||
return t.StringValue()
|
||||
}
|
||||
|
||||
func ErrUnsupportedChannelKind(s string) error {
|
||||
return errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
ErrCodeChannelUnsupportedKind,
|
||||
"unknown notification channel kind %q; allowed values: %s",
|
||||
s, allowedValuesForChannelKind(),
|
||||
)
|
||||
}
|
||||
|
||||
// parseStoredChannelType inverts ToStoredType. It reports false for the notifier
|
||||
// kinds v1 accepted but v2 does not model.
|
||||
func parseStoredChannelType(stored string) (ChannelKind, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind.ToStoredType() == stored {
|
||||
return channelKind.kind, true
|
||||
}
|
||||
}
|
||||
|
||||
return ChannelKind{}, false
|
||||
}
|
||||
|
||||
func allowedValuesForChannelKind() string {
|
||||
return formatAllowedValues((ChannelKind{}).Enum())
|
||||
}
|
||||
|
||||
func formatAllowedValues(enum []any) string {
|
||||
values := make([]string, 0, len(enum))
|
||||
for _, value := range enum {
|
||||
stringValuer, ok := value.(interface{ StringValue() string })
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
values = append(values, "`"+stringValuer.StringValue()+"`")
|
||||
}
|
||||
slices.Sort(values)
|
||||
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
86
pkg/types/alertmanagertypes/channelkindregistry.go
Normal file
86
pkg/types/alertmanagertypes/channelkindregistry.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package alertmanagertypes
|
||||
|
||||
type channelKindEntry struct {
|
||||
kind ChannelKind
|
||||
newEmptySpec func() ChannelSpec
|
||||
// countConfigs guards extractSpec, which reads the receiver's first config of
|
||||
// this kind and so must not be called when there is none.
|
||||
countConfigs func(receiver *Receiver) int
|
||||
extractSpec func(name string, receiver *Receiver) (ChannelSpec, error)
|
||||
}
|
||||
|
||||
// channelKinds registers each notification kind with the spec constructor
|
||||
// UnmarshalJSON picks by kind and the extractor that reads a stored receiver
|
||||
// back. The ChannelKind enum derives from it; the JSON schema hooks stay
|
||||
// literal lists so each branch reads as one line.
|
||||
var channelKinds = []channelKindEntry{
|
||||
{
|
||||
kind: ChannelKindSlack,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelSlackConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.SlackConfigs) },
|
||||
extractSpec: newChannelSlackConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindEmail,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelEmailConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.EmailConfigs) },
|
||||
extractSpec: newChannelEmailConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindWebhook,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelWebhookConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.WebhookConfigs) },
|
||||
extractSpec: newChannelWebhookConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindPagerduty,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelPagerdutyConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.PagerdutyConfigs) },
|
||||
extractSpec: newChannelPagerdutyConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindOpsgenie,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelOpsgenieConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.OpsGenieConfigs) },
|
||||
extractSpec: newChannelOpsgenieConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindMSTeams,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelMSTeamsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.MSTeamsV2Configs) },
|
||||
extractSpec: newChannelMSTeamsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindGoogleChat,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelGoogleChatConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.GoogleChatConfigs) },
|
||||
extractSpec: newChannelGoogleChatConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJira,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelJiraConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JiraConfigs) },
|
||||
extractSpec: newChannelJiraConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindJSMOps,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelJSMOpsConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.JSMOpsConfigs) },
|
||||
extractSpec: newChannelJSMOpsConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindIncidentIO,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelIncidentIOConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
|
||||
extractSpec: newChannelIncidentIOConfigFromReceiver,
|
||||
},
|
||||
}
|
||||
|
||||
func buildEmptyChannelSpecForKind(kind ChannelKind) (ChannelSpec, bool) {
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.kind == kind {
|
||||
return channelKind.newEmptySpec(), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -65,10 +65,7 @@ func TestChannelKindMSTeamsIsStoredAsMSTeamsV2(t *testing.T) {
|
||||
Config: ChannelConfig{Kind: ChannelKindMSTeams, Spec: &ChannelMSTeamsConfig{WebhookURL: "https://a"}},
|
||||
}
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
channel, _, err := postable.ToChannel("org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "msteamsv2", channel.Type)
|
||||
|
||||
@@ -93,7 +93,7 @@ func (c *Channel) Diagnose() *ChannelRepair {
|
||||
return repair
|
||||
}
|
||||
|
||||
if _, err := c.toPostableNotificationChannel(); err != nil {
|
||||
if _, err := c.toChannelConfig(); err != nil {
|
||||
repair.Defect, repair.Detail = ChannelDefectUnrepresentable, err.Error()
|
||||
return repair
|
||||
}
|
||||
|
||||
131
pkg/types/alertmanagertypes/channelspec.go
Normal file
131
pkg/types/alertmanagertypes/channelspec.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
type ChannelSpec interface {
|
||||
Validate() error
|
||||
toUndefaultedReceiver(displayName string) (*Receiver, error)
|
||||
}
|
||||
|
||||
// parseSecretURL and parseUpstreamURL wrap the two URL types upstream uses for
|
||||
// notifier endpoints. Callers holding an optional URL skip the call on an empty
|
||||
// string, so the field stays nil and is omitted rather than stored as an empty URL.
|
||||
func parseSecretURL(raw string) (*config.SecretURL, error) {
|
||||
parsed, err := parseUpstreamURL(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return (*config.SecretURL)(parsed), nil
|
||||
}
|
||||
|
||||
func parseUpstreamURL(raw string) (*config.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse url %q", raw)
|
||||
}
|
||||
|
||||
return &config.URL{URL: parsed}, nil
|
||||
}
|
||||
|
||||
func formatSecretURL(secretURL *config.SecretURL) string {
|
||||
if secretURL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatUpstreamURL((*config.URL)(secretURL))
|
||||
}
|
||||
|
||||
func formatUpstreamURL(upstreamURL *config.URL) string {
|
||||
if upstreamURL == nil || upstreamURL.URL == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return upstreamURL.String()
|
||||
}
|
||||
|
||||
func rejectAnyHTTPAuth(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if httpConfig.BasicAuth != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
}
|
||||
|
||||
if httpConfig.Authorization != nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return rejectUnsupportedHTTPConfig(channelName, httpConfig)
|
||||
}
|
||||
|
||||
func rejectUnsupportedHTTPConfig(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
fieldName string
|
||||
isFieldConfigured bool
|
||||
}{
|
||||
{"oauth2", httpConfig.OAuth2 != nil},
|
||||
{"bearer_token", httpConfig.BearerToken != ""},
|
||||
{"bearer_token_file", httpConfig.BearerTokenFile != ""},
|
||||
{"proxy_url", httpConfig.ProxyURL.URL != nil && httpConfig.ProxyURL.String() != ""},
|
||||
{"no_proxy", httpConfig.NoProxy != ""},
|
||||
{"proxy_from_environment", httpConfig.ProxyFromEnvironment},
|
||||
{"http_headers", httpConfig.HTTPHeaders != nil},
|
||||
{"tls_config", httpConfig.TLSConfig != (commoncfg.TLSConfig{})},
|
||||
{"follow_redirects", !httpConfig.FollowRedirects},
|
||||
{"enable_http2", !httpConfig.EnableHTTP2},
|
||||
} {
|
||||
if field.isFieldConfigured {
|
||||
return errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets http_config.%s, which is not supported", channelName, field.fieldName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.BasicAuth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillSendResolved gives an omitted field its notifier default, so what is
|
||||
// stored and read back is what takes effect.
|
||||
func fillSendResolved(sendResolved **bool, upstreamDefault bool) {
|
||||
if *sendResolved == nil {
|
||||
value := upstreamDefault
|
||||
*sendResolved = &value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSendResolved covers a spec assembled in code rather than decoded, whose
|
||||
// defaults were never filled. send_resolved has no omitempty, so a zero value
|
||||
// would marshal as an explicit false and overwrite the default rather than leave it.
|
||||
func resolveSendResolved(sendResolved *bool, upstreamDefault bool) bool {
|
||||
if sendResolved == nil {
|
||||
return upstreamDefault
|
||||
}
|
||||
|
||||
return *sendResolved
|
||||
}
|
||||
74
pkg/types/alertmanagertypes/channelspec_email.go
Normal file
74
pkg/types/alertmanagertypes/channelspec_email.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/textproto"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
|
||||
// credentials and TLS settings come from the deployment's global config, so a
|
||||
// channel can only choose recipients and body.
|
||||
type ChannelEmailConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
|
||||
Headers map[string]string `json:"headers,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelEmailConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultEmailConfig.VSendResolved)
|
||||
c.HTML.SetIfUnset(config.DefaultEmailConfig.HTML)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) Validate() error {
|
||||
if c.To == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.to is required for an email channel")
|
||||
}
|
||||
|
||||
// A read reports header names as textproto canonicalizes them, turning
|
||||
// "subject" into "Subject", so a name that is not already in that form is
|
||||
// rejected rather than answered with one the caller never sent.
|
||||
for _, header := range slices.Sorted(maps.Keys(c.Headers)) {
|
||||
if canonical := textproto.CanonicalMIMEHeaderKey(header); canonical != header {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.headers name %q must be written as %q", header, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
EmailConfigs: []*config.EmailConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultEmailConfig.VSendResolved)},
|
||||
To: c.To,
|
||||
HTML: c.HTML.StringValue(),
|
||||
Headers: c.Headers,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpec, error) {
|
||||
email := receiver.EmailConfigs[0]
|
||||
sendResolved := email.VSendResolved
|
||||
|
||||
return &ChannelEmailConfig{
|
||||
SendResolved: &sendResolved,
|
||||
To: email.To,
|
||||
HTML: valuer.UnsetIfEmpty(email.HTML),
|
||||
Headers: email.Headers,
|
||||
}, nil
|
||||
}
|
||||
68
pkg/types/alertmanagertypes/channelspec_googlechat.go
Normal file
68
pkg/types/alertmanagertypes/channelspec_googlechat.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelGoogleChatConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelGoogleChatConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelGoogleChatConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(DefaultGoogleChatReceiverConfig.Title)
|
||||
c.Text.SetIfUnset(DefaultGoogleChatReceiverConfig.Text)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) Validate() error {
|
||||
if c.WebhookURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for a googlechat channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhookURL, err := parseSecretURL(c.WebhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)},
|
||||
WebhookURL: webhookURL,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
googlechat := receiver.GoogleChatConfigs[0]
|
||||
sendResolved := googlechat.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, googlechat.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelGoogleChatConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: formatSecretURL(googlechat.WebhookURL),
|
||||
Title: valuer.UnsetIfEmpty(googlechat.Title),
|
||||
Text: valuer.UnsetIfEmpty(googlechat.Text),
|
||||
}, nil
|
||||
}
|
||||
73
pkg/types/alertmanagertypes/channelspec_incidentio.go
Normal file
73
pkg/types/alertmanagertypes/channelspec_incidentio.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelIncidentIOConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true"`
|
||||
Token string `json:"token" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Metadata map[string]string `json:"metadata,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelIncidentIOConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelIncidentIOConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(DefaultIncidentIOReceiverConfig.Title)
|
||||
c.Description.SetIfUnset(DefaultIncidentIOReceiverConfig.Description)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for an incidentio channel")
|
||||
}
|
||||
|
||||
if c.Token == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.token is required for an incidentio channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
IncidentIOConfigs: []*IncidentIOReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)},
|
||||
URL: c.URL,
|
||||
Token: config.Secret(c.Token),
|
||||
Title: c.Title.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Metadata: c.Metadata,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
incidentio := receiver.IncidentIOConfigs[0]
|
||||
sendResolved := incidentio.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, incidentio.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelIncidentIOConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: incidentio.URL,
|
||||
Token: string(incidentio.Token),
|
||||
Title: valuer.UnsetIfEmpty(incidentio.Title),
|
||||
Description: valuer.UnsetIfEmpty(incidentio.Description),
|
||||
Metadata: incidentio.Metadata,
|
||||
}, nil
|
||||
}
|
||||
159
pkg/types/alertmanagertypes/channelspec_jira.go
Normal file
159
pkg/types/alertmanagertypes/channelspec_jira.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
type ChannelJiraConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
|
||||
// Cloud is supported; the REST base is derived from it.
|
||||
Site string `json:"site" required:"true"`
|
||||
Project string `json:"project" required:"true"`
|
||||
IssueType string `json:"issueType" required:"true"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Priority string `json:"priority"`
|
||||
Labels []string `json:"labels,omitzero"`
|
||||
ResolveTransition string `json:"resolveTransition"`
|
||||
ReopenTransition string `json:"reopenTransition"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero"`
|
||||
WontFixResolution string `json:"wontFixResolution"`
|
||||
CustomFields map[string]any `json:"customFields,omitzero"`
|
||||
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
|
||||
func (c *ChannelJiraConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJiraConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, false)
|
||||
c.Summary.SetIfUnset(DefaultJiraSummaryTemplate)
|
||||
c.Description.SetIfUnset(DefaultJiraDescriptionTemplate)
|
||||
c.ReopenDuration.SetIfUnset(defaultJiraReopenDuration.String())
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) Validate() error {
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
field string
|
||||
}{
|
||||
{c.Site, "site"},
|
||||
{c.Project, "project"},
|
||||
{c.IssueType, "issueType"},
|
||||
{c.Email, "email"},
|
||||
{c.APIToken, "apiToken"},
|
||||
} {
|
||||
if required.value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.%s is required for a jira channel", required.field)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.ReopenDuration.IsZero() {
|
||||
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q is not a valid duration", c.ReopenDuration)
|
||||
}
|
||||
|
||||
// A read reports the duration as model.Duration formats it, collapsing
|
||||
// "72h" into "3d", so a value that is not already in that form is rejected
|
||||
// rather than answered with one the caller never sent.
|
||||
if canonical := reopenDuration.String(); canonical != c.ReopenDuration.StringValue() {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q must be written as %q", c.ReopenDuration, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
// Seeded from upstream's default rather than a zero value: FollowRedirects
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist them
|
||||
// as false and read back as a config ChannelJiraConfig cannot represent.
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Email,
|
||||
Password: commoncfg.Secret(c.APIToken),
|
||||
}
|
||||
|
||||
jira := &JiraReceiverConfig{
|
||||
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, false)},
|
||||
Site: c.Site,
|
||||
Project: c.Project,
|
||||
IssueType: c.IssueType,
|
||||
Summary: c.Summary.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Labels: c.Labels,
|
||||
ResolveTransition: c.ResolveTransition,
|
||||
ReopenTransition: c.ReopenTransition,
|
||||
WontFixResolution: c.WontFixResolution,
|
||||
CustomFields: c.CustomFields,
|
||||
HTTPConfig: &httpConfig,
|
||||
}
|
||||
|
||||
if !c.ReopenDuration.IsZero() {
|
||||
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse reopenDuration %q", c.ReopenDuration)
|
||||
}
|
||||
jira.ReopenDuration = reopenDuration
|
||||
}
|
||||
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
JiraConfigs: []*JiraReceiverConfig{jira},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
jira := receiver.JiraConfigs[0]
|
||||
sendResolved := jira.VSendResolved
|
||||
|
||||
if err := rejectUnsupportedHTTPConfig(name, jira.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if jira.HTTPConfig != nil && jira.HTTPConfig.Authorization != nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", name)
|
||||
}
|
||||
|
||||
if err := rejectHTTPBasicAuthBeyondPassword(name, jira.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := &ChannelJiraConfig{
|
||||
SendResolved: &sendResolved,
|
||||
Site: jira.Site,
|
||||
Project: jira.Project,
|
||||
IssueType: jira.IssueType,
|
||||
Summary: valuer.UnsetIfEmpty(jira.Summary),
|
||||
Description: valuer.UnsetIfEmpty(jira.Description),
|
||||
Priority: jira.Priority,
|
||||
Labels: jira.Labels,
|
||||
ResolveTransition: jira.ResolveTransition,
|
||||
ReopenTransition: jira.ReopenTransition,
|
||||
ReopenDuration: valuer.UnsetIfEmpty(jira.ReopenDuration.String()),
|
||||
WontFixResolution: jira.WontFixResolution,
|
||||
CustomFields: jira.CustomFields,
|
||||
}
|
||||
|
||||
if jira.HTTPConfig != nil && jira.HTTPConfig.BasicAuth != nil {
|
||||
spec.Email = jira.HTTPConfig.BasicAuth.Username
|
||||
spec.APIToken = string(jira.HTTPConfig.BasicAuth.Password)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
73
pkg/types/alertmanagertypes/channelspec_jsmops.go
Normal file
73
pkg/types/alertmanagertypes/channelspec_jsmops.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
|
||||
// keyed by the integration API key, which the notifier pins itself.
|
||||
type ChannelJSMOpsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Priority string `json:"priority"`
|
||||
// Tags is the comma-separated list JSM Ops attaches to the alert.
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJSMOpsConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(DefaultJSMOpsReceiverConfig.Message)
|
||||
c.Description.SetIfUnset(DefaultJSMOpsReceiverConfig.Description)
|
||||
c.Tags.SetIfUnset(DefaultJSMOpsReceiverConfig.Tags)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for a jsmops channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
JSMOpsConfigs: []*JSMOpsReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)},
|
||||
APIKey: config.Secret(c.APIKey),
|
||||
Message: c.Message.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Tags: c.Tags.StringValue(),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelJSMOpsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
jsmops := receiver.JSMOpsConfigs[0]
|
||||
sendResolved := jsmops.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, jsmops.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelJSMOpsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: string(jsmops.APIKey),
|
||||
Message: valuer.UnsetIfEmpty(jsmops.Message),
|
||||
Description: valuer.UnsetIfEmpty(jsmops.Description),
|
||||
Priority: jsmops.Priority,
|
||||
Tags: valuer.UnsetIfEmpty(jsmops.Tags),
|
||||
}, nil
|
||||
}
|
||||
68
pkg/types/alertmanagertypes/channelspec_msteams.go
Normal file
68
pkg/types/alertmanagertypes/channelspec_msteams.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelMSTeamsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
}
|
||||
|
||||
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelMSTeamsConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)
|
||||
c.Title.SetIfUnset(config.DefaultMSTeamsV2Config.Title)
|
||||
c.Text.SetIfUnset(config.DefaultMSTeamsV2Config.Text)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) Validate() error {
|
||||
if c.WebhookURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for an msteams channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhookURL, err := parseSecretURL(c.WebhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
MSTeamsV2Configs: []*config.MSTeamsV2Config{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)},
|
||||
WebhookURL: webhookURL,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelMSTeamsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
msteams := receiver.MSTeamsV2Configs[0]
|
||||
sendResolved := msteams.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, msteams.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelMSTeamsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: formatSecretURL(msteams.WebhookURL),
|
||||
Title: valuer.UnsetIfEmpty(msteams.Title),
|
||||
Text: valuer.UnsetIfEmpty(msteams.Text),
|
||||
}, nil
|
||||
}
|
||||
85
pkg/types/alertmanagertypes/channelspec_opsgenie.go
Normal file
85
pkg/types/alertmanagertypes/channelspec_opsgenie.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelOpsgenieConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
|
||||
Details map[string]string `json:"details,omitzero"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelOpsgenieConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(config.DefaultOpsGenieConfig.Message)
|
||||
c.Description.SetIfUnset(config.DefaultOpsGenieConfig.Description)
|
||||
c.Source.SetIfUnset(config.DefaultOpsGenieConfig.Source)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var apiURL *config.URL
|
||||
if c.APIURL != "" {
|
||||
parsed, err := parseUpstreamURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
OpsGenieConfigs: []*config.OpsGenieConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)},
|
||||
APIKey: config.Secret(c.APIKey),
|
||||
APIURL: apiURL,
|
||||
Message: c.Message.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Source: c.Source.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Details: c.Details,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelOpsgenieConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
opsgenie := receiver.OpsGenieConfigs[0]
|
||||
sendResolved := opsgenie.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, opsgenie.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelOpsgenieConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: string(opsgenie.APIKey),
|
||||
APIURL: formatUpstreamURL(opsgenie.APIURL),
|
||||
Message: valuer.UnsetIfEmpty(opsgenie.Message),
|
||||
Description: valuer.UnsetIfEmpty(opsgenie.Description),
|
||||
Source: valuer.UnsetIfEmpty(opsgenie.Source),
|
||||
Priority: opsgenie.Priority,
|
||||
Details: opsgenie.Details,
|
||||
}, nil
|
||||
}
|
||||
149
pkg/types/alertmanagertypes/channelspec_pagerduty.go
Normal file
149
pkg/types/alertmanagertypes/channelspec_pagerduty.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelPagerdutyConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
RoutingKey string `json:"routingKey" required:"true" format:"password"`
|
||||
URL string `json:"url"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
|
||||
Severity string `json:"severity"`
|
||||
Component string `json:"component"`
|
||||
Group string `json:"group"`
|
||||
Class string `json:"class"`
|
||||
Details map[string]string `json:"details,omitzero"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON defaults source to client, as the notifier does, and gives an
|
||||
// omitted details map the notifier's own entries. A details map the caller
|
||||
// sent is kept as sent; the notifier still adds its entries when delivering.
|
||||
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelPagerdutyConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)
|
||||
c.Description.SetIfUnset(config.DefaultPagerdutyConfig.Description)
|
||||
c.Client.SetIfUnset(config.DefaultPagerdutyConfig.Client)
|
||||
c.ClientURL.SetIfUnset(config.DefaultPagerdutyConfig.ClientURL)
|
||||
c.Source.SetIfUnset(c.Client.StringValue())
|
||||
|
||||
if c.Details == nil {
|
||||
c.Details = make(map[string]string, len(config.DefaultPagerdutyDetails))
|
||||
for key, value := range config.DefaultPagerdutyDetails {
|
||||
if template, ok := value.(string); ok {
|
||||
c.Details[key] = template
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) Validate() error {
|
||||
if c.RoutingKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.routingKey is required for a pagerduty channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var eventsURL *config.URL
|
||||
if c.URL != "" {
|
||||
parsed, err := parseUpstreamURL(c.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventsURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
PagerdutyConfigs: []*config.PagerdutyConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)},
|
||||
RoutingKey: config.Secret(c.RoutingKey),
|
||||
URL: eventsURL,
|
||||
Source: c.Source.StringValue(),
|
||||
Client: c.Client.StringValue(),
|
||||
ClientURL: c.ClientURL.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Severity: c.Severity,
|
||||
Component: c.Component,
|
||||
Group: c.Group,
|
||||
Class: c.Class,
|
||||
Details: newUpstreamDetails(c.Details),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
pagerduty := receiver.PagerdutyConfigs[0]
|
||||
sendResolved := pagerduty.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, pagerduty.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var details map[string]string
|
||||
if len(pagerduty.Details) > 0 {
|
||||
extracted, err := extractStringDetails(name, pagerduty.Details)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details = extracted
|
||||
}
|
||||
|
||||
return &ChannelPagerdutyConfig{
|
||||
SendResolved: &sendResolved,
|
||||
RoutingKey: string(pagerduty.RoutingKey),
|
||||
URL: formatUpstreamURL(pagerduty.URL),
|
||||
Source: valuer.UnsetIfEmpty(pagerduty.Source),
|
||||
Client: valuer.UnsetIfEmpty(pagerduty.Client),
|
||||
ClientURL: valuer.UnsetIfEmpty(pagerduty.ClientURL),
|
||||
Description: valuer.UnsetIfEmpty(pagerduty.Description),
|
||||
Severity: pagerduty.Severity,
|
||||
Component: pagerduty.Component,
|
||||
Group: pagerduty.Group,
|
||||
Class: pagerduty.Class,
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PagerDuty is the one notifier whose details upstream types as map[string]any.
|
||||
func newUpstreamDetails(details map[string]string) map[string]any {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
upstream[key] = value
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func extractStringDetails(name string, details map[string]any) (map[string]string, error) {
|
||||
extracted := make(map[string]string, len(details))
|
||||
for key, value := range details {
|
||||
stringValue, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q sets a non-string value for details.%s, which is not supported", name, key,
|
||||
)
|
||||
}
|
||||
extracted[key] = stringValue
|
||||
}
|
||||
|
||||
return extracted, nil
|
||||
}
|
||||
200
pkg/types/alertmanagertypes/channelspec_slack.go
Normal file
200
pkg/types/alertmanagertypes/channelspec_slack.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelSlackConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password"`
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero"`
|
||||
Fields []ChannelSlackField `json:"fields,omitzero"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitzero"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
Title string `json:"title" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Short *bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelSlackAction is a link button when URL is set, otherwise a message
|
||||
// button that needs Name. Upstream clears whichever side is not in use.
|
||||
type ChannelSlackAction struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Text string `json:"text" required:"true"`
|
||||
URL string `json:"url"`
|
||||
Style string `json:"style"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackConfirmation struct {
|
||||
Text string `json:"text" required:"true"`
|
||||
Title string `json:"title"`
|
||||
OkText string `json:"okText"`
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c *ChannelSlackConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelSlackConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultSlackConfig.VSendResolved)
|
||||
c.Title.SetIfUnset(config.DefaultSlackConfig.Title)
|
||||
c.Text.SetIfUnset(config.DefaultSlackConfig.Text)
|
||||
c.Color.SetIfUnset(config.DefaultSlackConfig.Color)
|
||||
c.TitleLink.SetIfUnset(config.DefaultSlackConfig.TitleLink)
|
||||
c.Pretext.SetIfUnset(config.DefaultSlackConfig.Pretext)
|
||||
c.Fallback.SetIfUnset(config.DefaultSlackConfig.Fallback)
|
||||
c.Footer.SetIfUnset(config.DefaultSlackConfig.Footer)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) Validate() error {
|
||||
if c.APIURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
|
||||
}
|
||||
|
||||
for i, field := range c.Fields {
|
||||
if field.Title == "" || field.Value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i, action := range c.Actions {
|
||||
if action.Type == "" || action.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
|
||||
}
|
||||
if action.URL == "" && action.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
|
||||
}
|
||||
if action.Confirm != nil && action.Confirm.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
apiURL, err := parseSecretURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
SlackConfigs: []*config.SlackConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultSlackConfig.VSendResolved)},
|
||||
APIURL: apiURL,
|
||||
Channel: c.Channel,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
Color: c.Color.StringValue(),
|
||||
TitleLink: c.TitleLink.StringValue(),
|
||||
Pretext: c.Pretext.StringValue(),
|
||||
Fallback: c.Fallback.StringValue(),
|
||||
Footer: c.Footer.StringValue(),
|
||||
Fields: newUpstreamSlackFields(c.Fields),
|
||||
Actions: newUpstreamSlackActions(c.Actions),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
slack := receiver.SlackConfigs[0]
|
||||
sendResolved := slack.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, slack.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelSlackConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIURL: formatSecretURL(slack.APIURL),
|
||||
Channel: slack.Channel,
|
||||
Title: valuer.UnsetIfEmpty(slack.Title),
|
||||
Text: valuer.UnsetIfEmpty(slack.Text),
|
||||
Color: valuer.UnsetIfEmpty(slack.Color),
|
||||
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
|
||||
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
|
||||
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
|
||||
Footer: valuer.UnsetIfEmpty(slack.Footer),
|
||||
Fields: newChannelSlackFields(slack.Fields),
|
||||
Actions: newChannelSlackActions(slack.Actions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]ChannelSlackField, 0, len(upstream))
|
||||
for _, field := range upstream {
|
||||
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
|
||||
if action.Confirm != nil {
|
||||
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
|
||||
}
|
||||
upstream = append(upstream, upstreamAction)
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actions := make([]ChannelSlackAction, 0, len(upstream))
|
||||
for _, upstreamAction := range upstream {
|
||||
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
|
||||
if upstreamAction.ConfirmField != nil {
|
||||
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
127
pkg/types/alertmanagertypes/channelspec_webhook.go
Normal file
127
pkg/types/alertmanagertypes/channelspec_webhook.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// bearerAuthorizationType is the scheme SigNoz writes for token auth.
|
||||
const bearerAuthorizationType = "Bearer"
|
||||
|
||||
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
|
||||
// overloaded onto one password field, where an empty username meant the password
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
// as upstream allows, but not together with BearerToken.
|
||||
type ChannelWebhookConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true" format:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password" format:"password"`
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
}
|
||||
|
||||
func (c *ChannelWebhookConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelWebhookConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultWebhookConfig.VSendResolved)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
|
||||
}
|
||||
|
||||
usesBasicAuth := c.Username != "" || c.Password != ""
|
||||
|
||||
if usesBasicAuth && c.BearerToken != "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhook := &config.WebhookConfig{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultWebhookConfig.VSendResolved)},
|
||||
URL: config.SecretTemplateURL(c.URL),
|
||||
}
|
||||
|
||||
// Seeded from upstream's default rather than a zero value: FollowRedirects
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
|
||||
// them as false and read back as a config ChannelWebhookConfig cannot represent.
|
||||
switch {
|
||||
case c.Username != "" || c.Password != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Username,
|
||||
Password: commoncfg.Secret(c.Password),
|
||||
}
|
||||
webhook.HTTPConfig = &httpConfig
|
||||
case c.BearerToken != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.Authorization = &commoncfg.Authorization{
|
||||
Type: bearerAuthorizationType,
|
||||
Credentials: commoncfg.Secret(c.BearerToken),
|
||||
}
|
||||
webhook.HTTPConfig = &httpConfig
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
WebhookConfigs: []*config.WebhookConfig{webhook},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
upstream := receiver.WebhookConfigs[0]
|
||||
sendResolved := upstream.VSendResolved
|
||||
if err := rejectUnsupportedHTTPConfig(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rejectHTTPBasicAuthBeyondPassword(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rejectHTTPAuthorizationBeyondBearer(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
webhook := &ChannelWebhookConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: string(upstream.URL),
|
||||
}
|
||||
|
||||
if upstream.HTTPConfig != nil {
|
||||
if basicAuth := upstream.HTTPConfig.BasicAuth; basicAuth != nil {
|
||||
webhook.Username = basicAuth.Username
|
||||
webhook.Password = string(basicAuth.Password)
|
||||
}
|
||||
if authorization := upstream.HTTPConfig.Authorization; authorization != nil {
|
||||
webhook.BearerToken = string(authorization.Credentials)
|
||||
}
|
||||
}
|
||||
|
||||
return webhook, nil
|
||||
}
|
||||
|
||||
func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commoncfg.HTTPClientConfig) error {
|
||||
if httpConfig == nil || httpConfig.Authorization == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
158
pkg/types/alertmanagertypes/channelv1.go
Normal file
158
pkg/types/alertmanagertypes/channelv1.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
// TODO: the oneOf emitted by JSONSchema is not the shape OpenAPI wants for a
|
||||
// discriminated union. OpenAPI's discriminator requires every oneOf branch to
|
||||
// be a $ref to a named component and a sibling property whose value selects
|
||||
// the variant. Our payload instead uses the *presence* of one of the 18
|
||||
// *_configs arrays to imply the type, so no discriminator can be attached.
|
||||
// Refactor PostableChannel into a {name, type, config} envelope (see
|
||||
// ruletypes.RuleThresholdData for the pattern) so each notification kind
|
||||
// becomes a named component and the discriminator can be wired up properly.
|
||||
type PostableChannel struct {
|
||||
Receiver
|
||||
}
|
||||
|
||||
func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
type alias PostableChannel
|
||||
reflector := &jsonschema.Reflector{}
|
||||
|
||||
schema, err := reflector.Reflect(alias{}, jsonschema.DefinitionsPrefix("#/components/schemas/"))
|
||||
if err != nil {
|
||||
return jsonschema.Schema{}, err
|
||||
}
|
||||
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
// NewChannelFromReceiver builds the channel a v1 write carries. The receiver is
|
||||
// all there is, so the name is generated from its display name and the type and
|
||||
// config derived from it.
|
||||
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
if receiver.Name == DefaultReceiverName {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
|
||||
}
|
||||
|
||||
channelType := receiverChannelType(receiver)
|
||||
if channelType == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "marshal receiver")
|
||||
}
|
||||
|
||||
channel := &Channel{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Name: generateChannelName(receiver.Name),
|
||||
DisplayName: receiver.Name,
|
||||
Type: channelType,
|
||||
Data: string(data),
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
// A receiver v2 cannot represent is refused rather than stored, so every
|
||||
// row written from here on reads through v2.
|
||||
channel.Config, err = channel.deriveChannelConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
channel, err := NewChannelFromReceiver(receiver, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.DisplayName != channel.DisplayName {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.Config = channel.Config
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// receiverChannelType returns the channel.Type discriminator. Walks
|
||||
// Receiver's own fields first (native), then the embed (upstream); first
|
||||
// non-empty *_configs slice wins.
|
||||
func receiverChannelType(receiver *Receiver) string {
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nonEmptyConfigsField(v reflect.Value) string {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
yamlTag := field.Tag.Get("yaml")
|
||||
if yamlTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the base type name (e.g., "email_configs" -> "email").
|
||||
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
|
||||
if len(matches) != 2 {
|
||||
continue
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -44,6 +44,12 @@ func (enum UnsetOrNonEmptyString) IsZero() bool {
|
||||
return enum.val == ""
|
||||
}
|
||||
|
||||
func (enum *UnsetOrNonEmptyString) SetIfUnset(val string) {
|
||||
if enum.IsZero() {
|
||||
enum.val = val
|
||||
}
|
||||
}
|
||||
|
||||
func (enum UnsetOrNonEmptyString) StringValue() string {
|
||||
return enum.val
|
||||
}
|
||||
|
||||
28
tests/fixtures/notification_channel.py
vendored
28
tests/fixtures/notification_channel.py
vendored
@@ -1,4 +1,5 @@
|
||||
# pylint: disable=line-too-long
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -8,6 +9,7 @@ import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
from testcontainers.core.container import Network
|
||||
from wiremock.testing.testcontainer import WireMockContainer
|
||||
|
||||
@@ -46,6 +48,32 @@ def assert_email_channel_payload_clean(payload: str) -> None:
|
||||
assert SMTP_TEST_FROM not in payload
|
||||
|
||||
|
||||
def rewrite_channel_as_legacy_receiver(signoz: types.SigNoz, channel_id: str, receiver: dict) -> None:
|
||||
"""Overwrite a channel row, and its receiver in the org's alertmanager config,
|
||||
the way v1 stored them before the config column existed. Neither API writes
|
||||
such rows any more, so tests that need one seed it here. The receiver's name
|
||||
must be the channel's display name. The alertmanager picks the swapped
|
||||
receiver up on its next poll of the stored config."""
|
||||
notifier_type = next(key.removesuffix("_configs") for key in receiver if key.endswith("_configs"))
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
conn.execute(
|
||||
sql.text("UPDATE notification_channel SET type = :type, data = :data, config = NULL WHERE id = :id"),
|
||||
{"id": channel_id, "type": notifier_type, "data": json.dumps(receiver)},
|
||||
)
|
||||
org_id, stored = conn.execute(
|
||||
sql.text("SELECT c.org_id, c.config FROM alertmanager_config c JOIN notification_channel n ON n.org_id = c.org_id WHERE n.id = :id"),
|
||||
{"id": channel_id},
|
||||
).one()
|
||||
config = json.loads(stored)
|
||||
config["receivers"] = [receiver if existing["name"] == receiver["name"] else existing for existing in config["receivers"]]
|
||||
raw = json.dumps(config)
|
||||
conn.execute(
|
||||
sql.text("UPDATE alertmanager_config SET config = :config, hash = :hash WHERE org_id = :org_id"),
|
||||
{"config": raw, "hash": hashlib.md5(raw.encode()).hexdigest(), "org_id": org_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
"""
|
||||
Default notification channel configs shared across alertmanager tests.
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
)
|
||||
from fixtures.notification_channel import rewrite_channel_as_legacy_receiver
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
@@ -49,24 +50,45 @@ def test_repair_reports_nothing_for_a_readable_channel(
|
||||
assert [channel["id"] for channel in repair["channels"]] == [channel_id]
|
||||
|
||||
|
||||
def test_repair_deletes_a_v1_channel_of_an_unmodelled_kind(
|
||||
def test_repair_deletes_a_legacy_channel_of_an_unmodelled_kind(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v1-telegram-{uuid.uuid4().hex[:8]}"
|
||||
name = f"legacy-telegram-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]},
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
|
||||
|
||||
# v2 lists the row with an empty kind and refuses to read it.
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
params={"query": name},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
listed = response.json()["data"]
|
||||
assert listed["total"] == 1
|
||||
assert listed["channels"][0]["displayName"] == name
|
||||
assert listed["channels"][0]["kind"] == ""
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}/repair"),
|
||||
@@ -111,29 +133,33 @@ def test_repair_deletes_a_v1_channel_of_an_unmodelled_kind(
|
||||
assert response.json()["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_repair_splits_a_v1_channel_carrying_several_notifiers(
|
||||
def test_repair_splits_a_legacy_channel_carrying_several_notifiers(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v1-fanout-{uuid.uuid4().hex[:8]}"
|
||||
name = f"legacy-fanout-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Only v1 accepts a receiver with more than one notifier configuration.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"webhook_configs": [{"url": "https://webhook.test/hook"}],
|
||||
},
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(
|
||||
signoz,
|
||||
channel_id,
|
||||
{
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"webhook_configs": [{"url": "https://webhook.test/hook"}],
|
||||
},
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}/repair"),
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
V2_BASE_URL = "/api/v2/notification_channels"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"clashing_field,message_fragment",
|
||||
[
|
||||
pytest.param("name", "with name", id="name"),
|
||||
pytest.param("displayName", "with display name", id="display_name"),
|
||||
],
|
||||
)
|
||||
def test_create_rejects_a_duplicate_with_conflict( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
clashing_field: str,
|
||||
message_fragment: str,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
shared = f"v2-dup-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
first = {"name": f"{shared}-first", "displayName": f"{shared} first", "config": {"kind": "email", "spec": {"to": "first@integration.test", "html": "<p>body</p>"}}}
|
||||
first[clashing_field] = shared
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=first,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
cleanup_notification_channels.append(response.json()["data"]["id"])
|
||||
|
||||
second = {"name": f"{shared}-second", "displayName": f"{shared} second", "config": {"kind": "email", "spec": {"to": "second@integration.test", "html": "<p>body</p>"}}}
|
||||
second[clashing_field] = shared
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=second,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
# Both v2 conflicts share a status and an error code, so only the message
|
||||
# separates a clashing display name from a clashing name.
|
||||
assert message_fragment in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"name": "Not_A_Label",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="name_not_dns1123_label",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}},
|
||||
id="no_name_and_no_generate_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "explicit",
|
||||
"generateName": True,
|
||||
"displayName": "Explicit",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="name_with_generate_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"generateName": True,
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="generate_name_without_display_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "default-receiver",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="reserved_receiver_name",
|
||||
),
|
||||
pytest.param({"name": "rejected"}, id="no_config"),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "telegram", "spec": {"chatId": 1}}},
|
||||
id="unmodelled_kind",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"channel": "#a",
|
||||
"text": "body",
|
||||
"iconEmoji": ":tada:",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="unknown_spec_field",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "slack", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
id="spec_of_another_kind",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "slack", "spec": {"channel": "#alerts", "title": "Alert", "text": "body"}},
|
||||
},
|
||||
id="slack_without_api_url",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "email", "spec": {"html": "<p>body</p>"}}},
|
||||
id="email_without_to",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "webhook", "spec": {}}},
|
||||
id="webhook_without_url",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "pagerduty", "spec": {"description": "body"}}},
|
||||
id="pagerduty_without_routing_key",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "opsgenie", "spec": {"message": "subject", "description": "body"}},
|
||||
},
|
||||
id="opsgenie_without_api_key",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "msteams", "spec": {"title": "Alert", "text": "body"}},
|
||||
},
|
||||
id="msteams_without_webhook_url",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "googlechat", "spec": {"title": "Alert", "text": "body"}},
|
||||
},
|
||||
id="googlechat_without_webhook_url",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "oncall@integration.test",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_site",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"issueType": "Bug",
|
||||
"email": "oncall@integration.test",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_project",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"email": "oncall@integration.test",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_issue_type",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"apiToken": "jira-api-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_email",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "oncall@integration.test",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_without_api_token",
|
||||
),
|
||||
pytest.param(
|
||||
{"name": "rejected", "config": {"kind": "jsmops", "spec": {}}},
|
||||
id="jsmops_without_api_key",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "incidentio", "spec": {"token": "incidentio-token"}},
|
||||
},
|
||||
id="incidentio_without_url",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "incidentio",
|
||||
"spec": {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF"},
|
||||
},
|
||||
},
|
||||
id="incidentio_without_token",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "fields": [{"title": "Severity"}]},
|
||||
},
|
||||
},
|
||||
id="slack_field_without_value",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"actions": [{"type": "button", "url": "https://signoz.test"}],
|
||||
},
|
||||
},
|
||||
},
|
||||
id="slack_action_without_text",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"actions": [{"type": "button", "text": "Open"}],
|
||||
},
|
||||
},
|
||||
},
|
||||
id="slack_action_without_url_or_name",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {
|
||||
"apiUrl": "https://hooks.slack.test/services/T/B/X",
|
||||
"actions": [{"type": "button", "text": "Ack", "name": "ack", "confirm": {"title": "Sure?"}}],
|
||||
},
|
||||
},
|
||||
},
|
||||
id="slack_action_confirm_without_text",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}},
|
||||
"type": "this key is not a valid",
|
||||
},
|
||||
id="unknown_envelope_field",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "webhook",
|
||||
"spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"},
|
||||
},
|
||||
},
|
||||
id="webhook_basic_auth_with_bearer_token",
|
||||
),
|
||||
# The next three break a rule of the notifier rather than of the request
|
||||
# shape, and still surface as a 400.
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://jira.acme.com",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "a@integration.test",
|
||||
"apiToken": "t",
|
||||
"summary": "Alert",
|
||||
"description": "body",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_site_not_jira_cloud",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "a@integration.test",
|
||||
"apiToken": "t",
|
||||
"summary": "Alert",
|
||||
"description": "body",
|
||||
"reopenDuration": "30s",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_reopen_duration_below_a_minute",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "incidentio",
|
||||
"spec": {
|
||||
"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF",
|
||||
"token": "Bearer incidentio-token",
|
||||
"title": "Alert",
|
||||
"description": "body",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="incidentio_token_with_bearer_prefix",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "slack",
|
||||
"spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": ""},
|
||||
},
|
||||
},
|
||||
id="slack_title_empty_instead_of_omitted",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "jsmops", "spec": {"apiKey": "jsm-api-key", "tags": ""}},
|
||||
},
|
||||
id="jsmops_tags_empty_instead_of_omitted",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {
|
||||
"kind": "jira",
|
||||
"spec": {
|
||||
"site": "https://acme.atlassian.net",
|
||||
"project": "OPS",
|
||||
"issueType": "Bug",
|
||||
"email": "a@integration.test",
|
||||
"apiToken": "t",
|
||||
"reopenDuration": "72h",
|
||||
},
|
||||
},
|
||||
},
|
||||
id="jira_reopen_duration_not_as_reported",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"name": "rejected",
|
||||
"config": {"kind": "email", "spec": {"to": "a@integration.test", "headers": {"subject": "must be written in canonical form, Subject"}}},
|
||||
},
|
||||
id="email_header_name_not_canonical",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_create_rejects_invalid_bodies(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
pytest.param({"sort": "data"}, id="sort_outside_the_enum"),
|
||||
pytest.param({"order": "sideways"}, id="order_outside_the_enum"),
|
||||
pytest.param({"kind": "telegram"}, id="kind_outside_the_enum"),
|
||||
pytest.param({"limit": -1}, id="negative_limit"),
|
||||
pytest.param({"offset": -1}, id="negative_offset"),
|
||||
],
|
||||
)
|
||||
def test_list_rejects_invalid_params(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
params: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_get_unknown_id(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/0199a1b2-c3d4-7000-8000-000000000000"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"name": "renamed",
|
||||
"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}},
|
||||
},
|
||||
id="name_in_body",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"displayName": "Renamed",
|
||||
"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}},
|
||||
},
|
||||
id="display_name_in_body",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "slack", "spec": {}}},
|
||||
id="spec_missing_required_field",
|
||||
),
|
||||
pytest.param({}, id="no_config"),
|
||||
],
|
||||
)
|
||||
def test_update_rejects_invalid_bodies( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-badupdate-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"name": "test-send",
|
||||
"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}},
|
||||
},
|
||||
id="name_in_body",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "slack", "spec": {}}},
|
||||
id="spec_missing_required_field",
|
||||
),
|
||||
pytest.param(
|
||||
{"config": {"kind": "telegram", "spec": {"chatId": 1}}},
|
||||
id="unmodelled_kind",
|
||||
),
|
||||
pytest.param({}, id="no_config"),
|
||||
],
|
||||
)
|
||||
def test_test_rejects_invalid_bodies(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/test"),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
@@ -0,0 +1,239 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import update_rule_channel_name, verify_notification_expectation
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
)
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
from fixtures.notification_channel import rewrite_channel_as_legacy_receiver
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
V2_BASE_URL = "/api/v2/notification_channels"
|
||||
|
||||
|
||||
def test_get_reflects_a_v2_update_after_a_v1_create(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
display_name = f"V1 then V2 {uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": display_name, "slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/V1", "channel": "#from-v1"}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["spec"]["channel"] == "#from-v1"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
json={"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/V2", "channel": "#from-v2"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["spec"]["channel"] == "#from-v2"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
fetched = response.json()["data"]
|
||||
assert fetched["displayName"] == display_name
|
||||
assert fetched["config"]["spec"]["apiUrl"] == "https://hooks.slack.test/services/T/B/V2"
|
||||
assert fetched["config"]["spec"]["channel"] == "#from-v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"receiver",
|
||||
[
|
||||
pytest.param(
|
||||
{"telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]},
|
||||
id="kind_v2_does_not_model",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"slack_configs": [{"api_url": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts"}],
|
||||
"webhook_configs": [{"url": "https://webhook.test/hook"}],
|
||||
},
|
||||
id="several_notifiers",
|
||||
),
|
||||
pytest.param(
|
||||
{"webhook_configs": [{"url": "https://webhook.test/hook", "http_config": {"proxy_url": "http://proxy.test:3128"}}]},
|
||||
id="unsupported_http_config",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v1_rejects_a_receiver_v2_cannot_represent(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
receiver: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": f"v1-rejected-{uuid.uuid4().hex[:8]}", **receiver},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_update_retypes_a_legacy_channel_of_an_unmodelled_kind(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-telegram-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(signoz, channel_id, {"name": name, "telegram_configs": [{"chat": 12345, "token": "telegram-bot-token"}]})
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# A v2 update needs nothing from the stored config, so it can rewrite a
|
||||
# channel v2 cannot read.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
json={"config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#retyped"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["config"]["kind"] == "slack"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
fetched = response.json()["data"]
|
||||
assert fetched["displayName"] == name
|
||||
assert fetched["config"]["kind"] == "slack"
|
||||
assert fetched["config"]["spec"]["channel"] == "#retyped"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
params={"query": name},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["channels"][0]["kind"] == "slack"
|
||||
|
||||
|
||||
def test_alerts_still_reach_a_legacy_channel_v2_cannot_read( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
cleanup_notification_channels: list[str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"legacy-delivery-{uuid.uuid4().hex[:8]}"
|
||||
slack_path = f"/services/T/B/{name}"
|
||||
webhook_path = f"/webhook/{name}"
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[Mapping(request=MappingRequest(method=HttpMethods.POST, url=path), response=MappingResponse(status=200, json_body={}), persistent=False) for path in (slack_path, webhook_path)],
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "webhook", "spec": {"url": notification_channel.container_configs["8080"].get(webhook_path)}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
channel_id = response.json()["data"]["id"]
|
||||
cleanup_notification_channels.append(channel_id)
|
||||
rewrite_channel_as_legacy_receiver(
|
||||
signoz,
|
||||
channel_id,
|
||||
{
|
||||
"name": name,
|
||||
"slack_configs": [{"api_url": notification_channel.container_configs["8080"].get(slack_path), "channel": "#legacy"}],
|
||||
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get(webhook_path)}],
|
||||
},
|
||||
)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# The rule must not fire before the alertmanager has polled the swapped receiver.
|
||||
time.sleep(12)
|
||||
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="metrics", data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
with open(get_testdata_file_path("ruler/test_scenarios/threshold_above_at_least_once/rule.json"), encoding="utf-8") as f:
|
||||
rule_data = json.load(f)
|
||||
update_rule_channel_name(rule_data, name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=120,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(destination_type="webhook", validation_data={"path": slack_path, "json_body": {"channel": "#legacy"}}),
|
||||
types.NotificationValidation(destination_type="webhook", validation_data={"path": webhook_path, "json_body": {"status": "firing", "receiver": name}}),
|
||||
],
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user