Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
b676541dae chore: update integration test numbers 2026-09-25 16:50:15 +05:30
Naman Verma
90d58826c2 fix: mark nullable as so 2026-09-25 16:05:19 +05:30
Naman Verma
fa8d1cfc56 chore: store v2 config for notification channels in db 2026-09-25 15:16:23 +05:30
54 changed files with 3885 additions and 2194 deletions

View File

@@ -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

View File

@@ -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;
/**

View File

@@ -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',
}

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
.home-container {
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-y: auto;
height: 100%;
width: 100%;

View File

@@ -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;

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
height: calc(100vh - 62px);
min-height: 400px;
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -1,6 +1,5 @@
.root {
flex: 1;
min-height: 0;
height: calc(100vh);
display: flex;
flex-direction: column;
}

View File

@@ -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%;

View File

@@ -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;

View File

@@ -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

View File

@@ -257,6 +257,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
sqlmigration.NewAddNotificationChannelTuplesFactory(sqlstore),
sqlmigration.NewAddAIObservabilityQuickFiltersFactory(sqlstore),
sqlmigration.NewAddChannelConfigFactory(sqlschema),
)
}

View 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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
})
}
}

View 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, ", ")
}

View 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
}

View File

@@ -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)

View File

@@ -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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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 ""
}

View File

@@ -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
}

View File

@@ -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.
"""

View File

@@ -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"),

View File

@@ -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

View File

@@ -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}}),
],
),
)